git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/display/float3.rs (48.3K)

   1 //! Narrow-trait `Float3` — a labeled group of three STANDARD [`Slider`]s (X/Y/Z), each with
   2 //! the toolkit's readout, embedded by value inside `ParametersBg` (its only consumer), which
   3 //! drives it through direct `WidgetHost` calls. The group label is the ordinary detached
   4 //! control label (the adapter's, exactly like a slider row's); below it sit three `Adapted<Slider>` children in
   5 //! whatever style the DE config gives every other slider (the band that swallowed the rodent,
   6 //! the recessed well, the square track), each fronted by its axis letter. The model caches its
   7 //! laid-out rect ([`Layout::rect_assigned`]) and lays the children out from it; paint and input
   8 //! delegate to them, so the rows look and feel like a plain slider row rather than the bespoke
   9 //! flat track + square thumb this widget used to draw.
  10 //!
  11 //! Hosts reading this panel through the legacy flat views get the children's quads, rounded
  12 //! rects and text through the adapter's prim bridges (the sub-sliders paint into this widget's
  13 //! own [`Paint::paint`]); their relief prims (recessed-track carve, thumb sphere) cannot ride
  14 //! those views and travel through [`Float3::sliders`] + [`Float3::get_row_rects`] instead, the
  15 //! way `ParametersBg::reliefs` / `spheres` read a slider row.
  16 
  17 use crate::context::UiContext;
  18 use crate::scene::layout::{Rect, Size};
  19 use crate::scene::paint::PaintCtx;
  20 use crate::widget::input::Slider;
  21 use crate::widget::{
  22     Adapted, ElementState, Event, EventCtx, Input, Layout, MouseButton, MouseScrollDelta,
  23     Paint, WidgetHost,
  24 };
  25 
  26 /// Vertical gap between the three slider rows.
  27 const ROW_GAP: f32 = 4.0;
  28 /// The axis-letter column left of each slider.
  29 pub(crate) const AXIS_W: f32 = 16.0;
  30 /// Readout / edit-buffer precision of the rows, and of [`Float3::value_string`].
  31 const DECIMALS: usize = 2;
  32 /// The same with the trackball on. Turning a vector is a small change to
  33 /// each of its components, and at two decimals a short one — a pull of 0.06
  34 /// — has seven directions it can point in.
  35 const BALL_DECIMALS: usize = 3;
  36 /// Gap between the trackball and the axis-letter column.
  37 const BALL_GAP: f32 = 10.0;
  38 /// The rings on the ball: circles of latitude about the vector, this many
  39 /// degrees from it. Five, a sixth of a half turn apart, so there is one on
  40 /// the equator and the far hemisphere carries its own pair — a vector
  41 /// pointing away still shows rings on the side that faces out.
  42 const RING_ANGLES: [f32; 5] = [30.0, 60.0, 90.0, 120.0, 150.0];
  43 /// Segments a ring is drawn in.
  44 const RING_SEGMENTS: usize = 48;
  45 
  46 /// How far one wheel notch rolls the ball. A drag is 1:1 with the ball's
  47 /// surface; a scroll is the fine handle, a quarter turn in six notches.
  48 const SCROLL_TURN: f32 = std::f32::consts::PI / 12.0;
  49 
  50 /// A trackball drag in progress: where the pointer last was, and the vector
  51 /// being turned at FULL precision. The rows hold it rounded to their
  52 /// readouts, and a host may write the rounded string back between moves;
  53 /// turning that instead would lose every step smaller than a readout tick.
  54 #[derive(Debug, Clone, Copy)]
  55 struct BallDrag {
  56     last: (f32, f32),
  57     dir: [f32; 3],
  58     len: f32,
  59 }
  60 
  61 pub struct Float3 {
  62     /// The assigned (label-inclusive) rect.
  63     rect: Rect,
  64     /// Four rows, of which the first [`Float3::components`] are the group's
  65     /// (three unless a host asks for two or four — `float2` / `float4`
  66     /// parameter rows). The rest are never laid out, drawn or hit.
  67     sliders: [Adapted<Slider>; 4],
  68     axes: [&'static str; 4],
  69     /// How many rows the group has, 1..=4; three by default.
  70     n: usize,
  71     label: Option<String>,
  72     dragging_idx: Option<usize>,
  73     /// Whether the group carries a TRACKBALL left of its rows: a ball the
  74     /// vector is drawn on and turned by. See [`Float3::set_trackball`].
  75     ball: bool,
  76     ball_drag: Option<BallDrag>,
  77     /// The ball's own id in the scroll-gesture bookkeeping
  78     /// (`UiContext::scroll_initiate_widget_id`): a gesture that begins on
  79     /// the ball is the ball's until it ends, as one on a band is the band's.
  80     ball_id: crate::widget::WidgetId,
  81     /// The vector a SCROLL is turning, at full precision, as a direction
  82     /// and a length — what [`BallDrag`] is to a drag. Kept between events
  83     /// for as long as the rows still hold what it rounds to
  84     /// ([`Float3::fine`]); a trackpad sends a pixel at a time, and a pixel
  85     /// turns a short vector by less than the rows can hold.
  86     fine: Option<([f32; 3], f32)>,
  87     /// The view the ball is seen from: the vector's space to the view's,
  88     /// as three rows — the view's right, its up, and the axis toward the
  89     /// viewer, each a direction in the vector's space. The identity (X
  90     /// right, Y up, Z toward the viewer) until a host sets one
  91     /// ([`Float3::set_view`]).
  92     view: [[f32; 3]; 3],
  93 }
  94 
  95 /// The ball's view with nothing set: X right, Y up, Z toward the viewer.
  96 pub const IDENTITY_VIEW: [[f32; 3]; 3] = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
  97 
  98 impl Float3 {
  99     pub fn new() -> Adapted<Float3> {
 100         let row = || Slider::new().with_readout(true).with_decimals(DECIMALS);
 101         Adapted::new(Float3 {
 102             rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
 103             sliders: [row(), row(), row(), row()],
 104             axes: ["X", "Y", "Z", "W"],
 105             n: 3,
 106             label: None,
 107             dragging_idx: None,
 108             ball: false,
 109             ball_drag: None,
 110             ball_id: crate::widget::WidgetId(crate::widget::NEXT_WIDGET_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)),
 111             fine: None,
 112             view: IDENTITY_VIEW,
 113         })
 114     }
 115 
 116     /// See the ball from a host's camera: `view`'s rows are the camera's
 117     /// right, its up, and the direction from the scene toward it, in the
 118     /// vector's own space. The vector is then drawn on the ball as it lies
 119     /// in the host's 3D view — pointing at the viewer on the ball when it
 120     /// points at the camera in the scene — and a drag or a scroll rolls it
 121     /// about the camera's axes, so pushing the ball right swings the vector
 122     /// to the right of the SCREEN, whatever that is in the scene. The rows
 123     /// and the value are untouched: only what the ball shows and how it
 124     /// turns. Rows that are not unit length or not square to each other
 125     /// are made so, and a degenerate view is refused.
 126     pub fn set_view(&mut self, view: [[f32; 3]; 3]) -> bool {
 127         let dot = |a: [f32; 3], b: [f32; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
 128         let unit = |v: [f32; 3]| {
 129             let l = dot(v, v).sqrt();
 130             (l > 1e-6).then(|| v.map(|c| c / l))
 131         };
 132         let cross = |a: [f32; 3], b: [f32; 3]| [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
 133         // Toward the viewer is kept; right is squared to it, and up follows.
 134         let Some(toward) = unit(view[2]) else { return false };
 135         let along = dot(view[0], toward);
 136         let Some(right) = unit([0, 1, 2].map(|k| view[0][k] - along * toward[k])) else { return false };
 137         let up = cross(toward, right);
 138         self.view = [right, up, toward];
 139         true
 140     }
 141 
 142     pub fn view(&self) -> [[f32; 3]; 3] {
 143         self.view
 144     }
 145 
 146     /// A direction of the vector's space, as the view sees it.
 147     fn to_view(&self, v: [f32; 3]) -> [f32; 3] {
 148         self.view.map(|row| row[0] * v[0] + row[1] * v[1] + row[2] * v[2])
 149     }
 150 
 151     /// And back: a direction of the view's, in the vector's space.
 152     #[allow(clippy::wrong_self_convention)] // a transform's inverse, beside `to_view`
 153     fn from_view(&self, p: [f32; 3]) -> [f32; 3] {
 154         [0, 1, 2].map(|k| self.view[0][k] * p[0] + self.view[1][k] * p[1] + self.view[2][k] * p[2])
 155     }
 156 
 157     /// Roll a direction of the vector's space as the VIEW sees the ball
 158     /// roll: into the view, [`Self::rolled`], and back.
 159     fn rolled_in_view(&self, dir: [f32; 3], dx: f32, dy: f32, radius: f32) -> [f32; 3] {
 160         self.from_view(Self::rolled(self.to_view(dir), dx, dy, radius))
 161     }
 162 
 163     /// Give the group a trackball, or take it away.
 164     ///
 165     /// The three sliders set a vector a component at a time, which is the
 166     /// wrong handle for its DIRECTION: pointing a pull somewhere else means
 167     /// moving all three, by amounts no one can do in their head. The ball
 168     /// is that handle. It sits left of the rows, as tall as they are, with
 169     /// the vector drawn on it from the centre — bright on the near side,
 170     /// dim when it points away — and dragging the ball rolls it under the
 171     /// pointer, turning the vector with it and keeping its length. The rows
 172     /// stay: they are still how a component is typed or a length changed.
 173     ///
 174     /// The view is X to the right, Y up, Z toward the viewer — the axes
 175     /// the rows are lettered by — until a host gives it a camera
 176     /// ([`Float3::set_view`]). A vector of no length has no direction to
 177     /// turn, so the first drag gives it a length of one, toward the viewer.
 178     pub fn set_trackball(&mut self, on: bool) {
 179         // A direction is three numbers: two or four have no ball.
 180         self.ball = on && self.n == 3;
 181         self.ball_drag = None;
 182         let decimals = self.decimals();
 183         for s in self.sliders.iter_mut() {
 184             s.set_decimals(decimals);
 185         }
 186         self.layout_rows();
 187     }
 188 
 189     pub fn has_trackball(&self) -> bool {
 190         self.ball
 191     }
 192 
 193     /// Give the group `n` rows (1..=4: X, Y, Z, W) — a `float2` or
 194     /// `float4` value is the same control with fewer or more of them. A
 195     /// group of other than three has no trackball.
 196     pub fn set_components(&mut self, n: usize) {
 197         self.n = n.clamp(1, 4);
 198         if self.n != 3 {
 199             self.ball = false;
 200             self.ball_drag = None;
 201         }
 202         self.layout_rows();
 203     }
 204 
 205     /// How many rows the group has.
 206     pub fn components(&self) -> usize {
 207         self.n
 208     }
 209 
 210     /// Give every row a soft range (`Slider::set_soft`): a value typed
 211     /// past an end widens that row's range.
 212     pub fn set_soft(&mut self, soft: bool) {
 213         for s in self.sliders.iter_mut() {
 214             s.set_soft(soft);
 215         }
 216     }
 217 
 218     fn decimals(&self) -> usize {
 219         if self.ball { BALL_DECIMALS } else { DECIMALS }
 220     }
 221 
 222     /// The ball's diameter: the three rows' height, so it costs the group
 223     /// no height of its own.
 224     pub fn ball_diameter() -> f32 {
 225         3.0 * crate::layout::slider_height() + 2.0 * ROW_GAP
 226     }
 227 
 228     /// What the trackball takes of the group's width: the ball and its gap.
 229     /// A host measuring the rows' tracks counts it as chrome.
 230     pub fn trackball_chrome() -> f32 {
 231         Self::ball_diameter() + BALL_GAP
 232     }
 233 
 234     /// The ball as `(cx, cy, radius)`, when the group has one.
 235     pub fn ball_circle(&self) -> Option<(f32, f32, f32)> {
 236         if !self.ball {
 237             return None;
 238         }
 239         let r = Self::ball_diameter() * 0.5;
 240         Some((self.rect.x + r, self.rect.y + self.label_top() + r, r))
 241     }
 242 
 243     pub fn ball_hit(&self, px: f32, py: f32) -> bool {
 244         self.ball_circle().is_some_and(|(cx, cy, r)| (px - cx).powi(2) + (py - cy).powi(2) <= r * r)
 245     }
 246 
 247     /// The vector the rows hold, in their scaled values.
 248     pub fn vector(&self) -> [f32; 3] {
 249         [0, 1, 2].map(|i| self.sliders[i].get_scaled_value())
 250     }
 251 
 252     /// Roll the ball by a pointer delta: `dx` turns the vector about the
 253     /// vertical axis, `dy` about the horizontal one, by the angle that
 254     /// much of the ball's surface subtends — so the point under the
 255     /// pointer stays under it. Pure, for the tests.
 256     pub fn rolled(dir: [f32; 3], dx: f32, dy: f32, radius: f32) -> [f32; 3] {
 257         let (s, c) = (dx / radius).sin_cos();
 258         let (x, y, z) = (dir[0] * c + dir[2] * s, dir[1], -dir[0] * s + dir[2] * c);
 259         // Screen y runs down and the vector's Y up: a pull downward turns
 260         // the near point toward -Y.
 261         let (s, c) = (dy / radius).sin_cos();
 262         let out = [x, y * c - z * s, y * s + z * c];
 263         let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2]).sqrt();
 264         if len > 0.0 { out.map(|v| v / len) } else { dir }
 265     }
 266 
 267     /// One ring on the unit ball: the circle of points `degrees` from
 268     /// `dir`, as `segments + 1` points, the last closing on the first. A
 269     /// circle of latitude about the vector as its pole — so the rings are
 270     /// the vector's own, and turn exactly as it does. Seen from the front
 271     /// they are concentric circles when the vector points at the viewer
 272     /// and foreshorten into ellipses as it turns away, which is what makes
 273     /// a rotation readable on a ball that is otherwise the same from every
 274     /// side.
 275     pub fn ring(dir: [f32; 3], degrees: f32, segments: usize) -> Vec<[f32; 3]> {
 276         let cross = |a: [f32; 3], b: [f32; 3]| [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
 277         let unit = |v: [f32; 3]| {
 278             let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
 279             if l > 0.0 { v.map(|c| c / l) } else { v }
 280         };
 281         let d = unit(dir);
 282         // Any axis not along the vector gives a basis across it; which one
 283         // only moves where on the circle the points start.
 284         let aside = if d[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [1.0, 0.0, 0.0] };
 285         let u = unit(cross(d, aside));
 286         let w = cross(d, u);
 287         let (st, ct) = degrees.to_radians().sin_cos();
 288         (0..=segments)
 289             .map(|i| {
 290                 let (sp, cp) = (i as f32 / segments as f32 * std::f32::consts::TAU).sin_cos();
 291                 [0, 1, 2].map(|k| ct * d[k] + st * (cp * u[k] + sp * w[k]))
 292             })
 293             .collect()
 294     }
 295 
 296     fn ball_begin(&mut self, px: f32, py: f32) {
 297         let (dir, len) = self.fine();
 298         self.ball_drag = Some(BallDrag { last: (px, py), dir, len });
 299     }
 300 
 301     /// The vector to turn, as a direction and a length: the full-precision
 302     /// copy the last scroll left, while the rows still hold what it rounds
 303     /// to — within half a readout tick and the rows' own float resolution
 304     /// over their range — and the rows' vector otherwise (someone typed, or
 305     /// dragged a band). A vector of no length points at the viewer with a
 306     /// length of one.
 307     fn fine(&self) -> ([f32; 3], f32) {
 308         let v = self.vector();
 309         if let Some((dir, len)) = self.fine {
 310             let (min, max) = self.sliders[0].range();
 311             let tol = 0.5 * 10f32.powi(-(self.decimals() as i32)) + (max - min).abs() * 5e-7;
 312             if (0..3).all(|i| (dir[i] * len - v[i]).abs() <= tol) {
 313                 return (dir, len);
 314             }
 315         }
 316         let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
 317         // No length: toward the viewer, wherever the view puts that.
 318         if len > 1e-6 { (v.map(|c| c / len), len) } else { (self.view[2], 1.0) }
 319     }
 320 
 321     pub fn ball_id(&self) -> crate::widget::WidgetId {
 322         self.ball_id
 323     }
 324 
 325     /// Roll the ball by a scroll: the ball is scrolled as content is, its
 326     /// surface moving the way a page under the pointer would — a two-finger
 327     /// gesture in both axes at once, a wheel notch in one — by
 328     /// [`SCROLL_TURN`] a notch. The length is kept.
 329     pub fn ball_scroll(&mut self, delta: &MouseScrollDelta) -> bool {
 330         let Some((_, _, r)) = self.ball_circle() else {
 331             return false;
 332         };
 333         let (nx, ny) = match delta {
 334             MouseScrollDelta::LineDelta(x, y) => (*x, *y),
 335             MouseScrollDelta::PixelDelta(pos) => (pos.x as f32 / 60.0, pos.y as f32 / 60.0),
 336         };
 337         if nx == 0.0 && ny == 0.0 {
 338             return false;
 339         }
 340         let (dir, len) = self.fine();
 341         let dir = self.rolled_in_view(dir, nx * SCROLL_TURN * r, ny * SCROLL_TURN * r, r);
 342         self.fine = Some((dir, len));
 343         for (s, c) in self.sliders.iter_mut().zip(dir) {
 344             s.set_scaled_value(c * len);
 345         }
 346         true
 347     }
 348 
 349     /// Whether the scroll gesture in progress is this group's — the ball's
 350     /// or one of its bands'.
 351     pub fn wheel_latched(&self, ui: &UiContext) -> bool {
 352         !ui.scroll_gesture_new
 353             && ui.scroll_initiate_widget_id.is_some_and(|id| id == self.ball_id || self.sliders.iter().any(|s| s.base().id() == id))
 354     }
 355 
 356     /// How near a scroll at `(px, py)` is to something of this group's
 357     /// that takes one: on the ball, nothing is nearer; else the distance to
 358     /// the nearest band whose halo holds the pointer. `None` off both.
 359     pub fn wheel_zone(&self, px: f32, py: f32) -> Option<f32> {
 360         if self.ball_hit(px, py) {
 361             return Some(0.0);
 362         }
 363         self.nearest_band(px, py).map(|(_, d)| d)
 364     }
 365 
 366     fn ball_roll(&mut self, px: f32, py: f32) -> bool {
 367         let (Some(mut drag), Some((_, _, r))) = (self.ball_drag, self.ball_circle()) else {
 368             return false;
 369         };
 370         let (dx, dy) = (px - drag.last.0, py - drag.last.1);
 371         if dx == 0.0 && dy == 0.0 {
 372             return false;
 373         }
 374         drag.dir = self.rolled_in_view(drag.dir, dx, dy, r);
 375         drag.last = (px, py);
 376         self.ball_drag = Some(drag);
 377         for (s, c) in self.sliders.iter_mut().zip(drag.dir) {
 378             s.set_scaled_value(c * drag.len);
 379         }
 380         true
 381     }
 382 
 383     /// The trackball: the ball, and the vector on it. Emitted through the
 384     /// host's scene path (`ParametersBg::paint_scene_rows`) rather than
 385     /// [`Paint::paint`], because a sphere is not a prim the legacy flat
 386     /// views carry.
 387     pub fn paint_ball(&self, ctx: &mut PaintCtx) {
 388         let Some((cx, cy, r)) = self.ball_circle() else {
 389             return;
 390         };
 391         let held = self.ball_drag.is_some();
 392         let body = if held { [0.26, 0.29, 0.40, 1.0] } else { [0.19, 0.21, 0.29, 1.0] };
 393         ctx.sphere(cx, cy, r, &crate::scene::material::Material::from_fill(body));
 394         ctx.arc(cx, cy, r, 1.0, 0.0, std::f32::consts::TAU, [0.42, 0.45, 0.58, 0.9]);
 395 
 396         // While held the ball shows the vector it is turning, not the one
 397         // the rows rounded it to.
 398         let v = match self.ball_drag {
 399             Some(d) => d.dir.map(|c| c * d.len),
 400             None => self.vector(),
 401         };
 402         let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
 403 
 404         // The rings, under the vector: the near half of each, in short
 405         // strokes. The far half is behind the ball. They follow the
 406         // full-precision direction a drag or a scroll is turning, so they
 407         // move on every pixel where the rounded rows would hold them still;
 408         // a vector of no length shows them about the axis its first turn
 409         // will start from, fainter.
 410         let pole = match self.ball_drag {
 411             Some(d) => d.dir,
 412             None => self.fine().0,
 413         };
 414         let ring = [0.80, 0.84, 0.96, if len > 1e-6 { 0.42 } else { 0.18 }];
 415         let inset = r - 0.75;
 416         // Everything on the ball is drawn as the view sees it.
 417         let pole = self.to_view(pole);
 418         for degrees in RING_ANGLES {
 419             let points = Self::ring(pole, degrees, RING_SEGMENTS);
 420             for pair in points.windows(2) {
 421                 let (a, b) = (pair[0], pair[1]);
 422                 if a[2] < 0.0 || b[2] < 0.0 {
 423                     continue;
 424                 }
 425                 ctx.vector(
 426                     cx + a[0] * inset,
 427                     cy - a[1] * inset,
 428                     cx + b[0] * inset,
 429                     cy - b[1] * inset,
 430                     1.0,
 431                     ring,
 432                     crate::scene::paint::Cap::Round,
 433                 );
 434             }
 435         }
 436         // The accent's HUE at alphas of the ball's own: the configured
 437         // accent carries an alpha meant for washes, and at that alpha the
 438         // near side drew paler than the far one.
 439         let accent = crate::color::highlight_primary_color();
 440         let accent = |a: f32| [accent[0], accent[1], accent[2], a];
 441         if len <= 1e-6 {
 442             // No length, no direction: a dim hub and nothing on the ball.
 443             ctx.circle(cx, cy, 2.5, accent(0.35));
 444             return;
 445         }
 446         let d = self.to_view(v.map(|c| c / len));
 447         // The tip sits on the ball's surface as the view sees it, a little in
 448         // from the rim so a vector lying in the screen plane stays on it.
 449         let reach = r - 5.0;
 450         let (tx, ty) = (cx + d[0] * reach, cy - d[1] * reach);
 451         let near = d[2] >= 0.0;
 452         let col = accent(if near { 1.0 } else { 0.35 });
 453         ctx.vector(cx, cy, tx, ty, 2.0, col, crate::scene::paint::Cap::Round);
 454         ctx.circle(cx, cy, 2.0, col);
 455         ctx.circle(tx, ty, if near { 4.5 } else { 3.0 }, col);
 456     }
 457 
 458     /// The height a labeled (`labeled`) group lays out to: the detached label band plus three slider rows and their gaps — the row-height table entry.
 459     pub fn preferred_height(labeled: bool) -> f32 {
 460         Self::preferred_height_for(labeled, 3)
 461     }
 462 
 463     /// [`Self::preferred_height`] for a group of `n` rows.
 464     pub fn preferred_height_for(labeled: bool, n: usize) -> f32 {
 465         let n = n.clamp(1, 4) as f32;
 466         let top = if labeled { crate::layout::control_label_strip() } else { 0.0 };
 467         top + n * crate::layout::slider_height() + (n - 1.0) * ROW_GAP
 468     }
 469 
 470     /// Normalized (0..1) values, X/Y/Z.
 471     pub fn values(&self) -> [f32; 3] {
 472         [self.sliders[0].value, self.sliders[1].value, self.sliders[2].value]
 473     }
 474 
 475     /// The scaled values of the group's rows, as many as it has.
 476     pub fn scaled_values(&self) -> Vec<f32> {
 477         self.sliders[..self.n].iter().map(|s| s.get_scaled_value()).collect()
 478     }
 479 
 480     /// Normalized values in for the group's rows, as many as it has; each
 481     /// row clamps to 0..1, and a row with no value given keeps its own.
 482     pub fn set_values_n(&mut self, values: &[f32]) {
 483         for (s, v) in self.sliders[..self.n].iter_mut().zip(values) {
 484             s.set_value(*v);
 485         }
 486     }
 487 
 488     /// Normalized values in; each row clamps to 0..1.
 489     pub fn set_values(&mut self, values: [f32; 3]) {
 490         for (s, v) in self.sliders.iter_mut().zip(values) {
 491             s.set_value(v);
 492         }
 493     }
 494 
 495     /// The row whose readout is open for typing, if any.
 496     pub fn editing_idx(&self) -> Option<usize> {
 497         self.sliders.iter().position(|s| s.editing)
 498     }
 499 
 500     /// The scaled values as the `x:y:z` row string (`DECIMALS` places) the parameter pane
 501     /// stores — the one formatter for every host sync.
 502     pub fn value_string(&self) -> String {
 503         let d = self.decimals();
 504         self.sliders[..self.n].iter().map(|s| format!("{:.*}", d, s.get_scaled_value())).collect::<Vec<_>>().join(":")
 505     }
 506 
 507     /// The three rows, X/Y/Z — for hosts that draw this group through the legacy flat views
 508     /// and need each row's relief prims (`track_relief`, `thumb_sphere`) over its
 509     /// [`Self::get_row_rects`] rect.
 510     pub fn sliders(&self) -> &[Adapted<Slider>] {
 511         &self.sliders[..self.n]
 512     }
 513 
 514     /// Detached-label band above the rows (zero unlabeled) — the adapter's
 515     /// `Widget::label_offset` over the synced label.
 516     fn label_top(&self) -> f32 {
 517         crate::widget::input::slider::detached_strip(&self.label)
 518     }
 519 
 520     /// The three slider rows' rects (`(x, y, w, h)`, X/Y/Z), laid out below the label band and
 521     /// right of the axis-letter column. Each is exactly the rect its sub-slider is assigned.
 522     pub fn get_row_rects(&self) -> Vec<(f32, f32, f32, f32)> {
 523         let top = self.rect.y + self.label_top();
 524         let ball = if self.ball { Self::trackball_chrome() } else { 0.0 };
 525         let x = self.rect.x + ball + AXIS_W;
 526         let w = (self.rect.width - ball - AXIS_W).max(10.0);
 527         let h = crate::layout::slider_height();
 528         (0..self.n).map(|i| (x, top + i as f32 * (h + ROW_GAP), w, h)).collect()
 529     }
 530 
 531     fn layout_rows(&mut self) {
 532         let rows = self.get_row_rects();
 533         for (i, s) in self.sliders.iter_mut().enumerate() {
 534             // A row past the group's count is laid out nowhere, so it is
 535             // hit by nothing.
 536             let r = rows.get(i).copied().unwrap_or((0.0, 0.0, 0.0, 0.0));
 537             s.set_rect(r.0, r.1, r.2, r.3);
 538         }
 539     }
 540 
 541     /// Wheel over the rows, the parameter pane's slider-row contract: under the band style the
 542     /// capture zone is each row's shape halo ([`Slider::scroll_hit`]) or its gesture latch,
 543     /// otherwise the row's rect; a row in zone takes the wheel ungated (the halo already gated
 544     /// spatially, and the adapter's rect gate would clip its fringe). Returns whether a row took
 545     /// it, whether or not the value string ticked over.
 546     ///
 547     /// ONE row takes it: the latched one, else the NEAREST of the rows whose
 548     /// halo holds the pointer. A halo reaches past its band by more than the
 549     /// gap between rows, so two rows' halos hold any point between them —
 550     /// and until 2026-09-28 the first in X/Y/Z order won, so a scroll over
 551     /// the Y band turned X.
 552     /// The BALL comes before the rows: a gesture it holds, or one nobody
 553     /// holds that falls on it, rolls it ([`Self::ball_scroll`]).
 554     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ui: &mut UiContext) -> bool {
 555         let ball_latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self.ball_id);
 556         let band_latched = self.wheel_latched(ui) && !ball_latched;
 557         if self.ball && (ball_latched || (!band_latched && self.ball_hit(px, py))) {
 558             ui.scroll_initiate_widget_id = Some(self.ball_id);
 559             self.ball_scroll(delta);
 560             return true;
 561         }
 562         let Some(i) = self.wheel_row(px, py, ui) else {
 563             return false;
 564         };
 565         let s = &mut self.sliders[i];
 566         let was_scroll = s.scroll_enabled;
 567         s.set_scroll(true);
 568         let taken = s.mouse_wheel_ungated(delta, px, py, ui);
 569         s.set_scroll(was_scroll);
 570         taken
 571     }
 572 
 573     /// The row a wheel at `(px, py)` belongs to, if any — see [`Self::wheel`] —
 574     /// with its band centre's distance from the pointer for a host choosing
 575     /// between this group and its neighbours.
 576     pub fn wheel_row(&self, px: f32, py: f32, ui: &UiContext) -> Option<usize> {
 577         let latched = self
 578             .sliders
 579             .iter()
 580             .position(|s| !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(s.base().id()));
 581         latched.or_else(|| self.nearest_band(px, py).map(|(i, _)| i))
 582     }
 583 
 584     /// The nearest row whose halo holds the pointer, with the distance from
 585     /// the pointer to that band's centre line.
 586     pub fn nearest_band(&self, px: f32, py: f32) -> Option<(usize, f32)> {
 587         self.get_row_rects()
 588             .into_iter()
 589             .enumerate()
 590             .filter(|(i, r)| self.sliders[*i].inner().scroll_hit(Rect { x: r.0, y: r.1, width: r.2, height: r.3 }, px, py))
 591             .map(|(i, r)| (i, (py - (r.1 + r.3 * 0.5)).abs()))
 592             .min_by(|a, b| a.1.total_cmp(&b.1))
 593     }
 594 }
 595 
 596 impl Adapted<Float3> {
 597     /// See [`Float3::set_trackball`].
 598     pub fn with_trackball(mut self, on: bool) -> Self {
 599         self.set_trackball(on);
 600         self
 601     }
 602 
 603     /// See [`Float3::set_components`].
 604     pub fn with_components(mut self, n: usize) -> Self {
 605         self.set_components(n);
 606         self
 607     }
 608 
 609     pub fn with_values(mut self, values: [f32; 3]) -> Self {
 610         self.set_values(values);
 611         self
 612     }
 613 
 614     pub fn with_range(mut self, min: f32, max: f32) -> Self {
 615         for s in self.sliders.iter_mut() {
 616             s.set_range(min, max);
 617         }
 618         self
 619     }
 620 }
 621 
 622 impl Layout for Float3 {
 623     // The Slider convention: the label eats into the assigned rect, the host sizes the row
 624     // for it ([`Float3::preferred_height`]).
 625 
 626     /// The three rows alone: the adapter adds the detached-label strip itself
 627     /// (`Adapted::preferred_height`), as it does for every non-inflating widget.
 628     fn intrinsic_size(&self) -> Option<Size> {
 629         Some(Size::new(0.0, Float3::preferred_height_for(false, self.n)))
 630     }
 631 
 632     fn rect_assigned(&mut self, rect: Rect) {
 633         self.rect = rect;
 634         self.layout_rows();
 635     }
 636 }
 637 
 638 impl Paint for Float3 {
 639     fn color(&self) -> [f32; 4] {
 640         [0.0, 0.0, 0.0, 0.0]
 641     }
 642 
 643     fn widget_font(&self) -> Option<String> {
 644         Some(crate::layout::control_label_font_detached())
 645     }
 646 
 647     fn sync_label(&mut self, label: &str) {
 648         self.label = Some(label.to_string());
 649         self.layout_rows();
 650     }
 651 
 652     /// The axis letters and the three rows, each painted by its own slider over its row rect
 653     /// (an unlabeled slider's content rect is its whole rect). The group label is the adapter's
 654     /// detached label, like a slider row's.
 655     fn paint(&self, _rect: Rect, ctx: &mut PaintCtx) {
 656         let rows = self.get_row_rects();
 657         for (i, r) in rows.into_iter().enumerate() {
 658             let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
 659             // Bounded to the row PLUS its gutter: the axis letter is drawn to
 660             // the left of the row rect by design, so the row alone would clip
 661             // it away entirely.
 662             ctx.text_with(
 663                 self.axes[i].to_string(),
 664                 rect.x - AXIS_W + 2.0,
 665                 crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
 666                 12.0,
 667                 [0xaa, 0xaa, 0xbb],
 668                 None,
 669                 Some([rect.x - AXIS_W, rect.y, rect.x + rect.width, rect.y + rect.height]),
 670             );
 671             Paint::paint(&*self.sliders[i], rect, ctx);
 672         }
 673     }
 674 }
 675 
 676 impl Input for Float3 {
 677     fn draggable(&self, _rect: Rect) -> bool {
 678         self.dragging_idx.is_some() || self.ball_drag.is_some()
 679     }
 680 
 681     fn is_dragging(&self) -> bool {
 682         self.dragging_idx.is_some() || self.ball_drag.is_some()
 683     }
 684 
 685     /// A host-driven drag begins on the row under the pointer — unless a press already
 686     /// started one (the pane's press path), in which case that row keeps it. The ball
 687     /// comes first: it stands beside the rows, inside their span of y.
 688     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
 689         if self.dragging_idx.is_some() || self.ball_drag.is_some() {
 690             return;
 691         }
 692         if self.ball_hit(px, py) {
 693             self.ball_begin(px, py);
 694             return;
 695         }
 696         let rows = self.get_row_rects();
 697         for (i, r) in rows.into_iter().enumerate() {
 698             if py >= r.1 && py <= r.1 + r.3 {
 699                 self.sliders[i].drag_begin(px, py);
 700                 self.dragging_idx = Some(i);
 701                 return;
 702             }
 703         }
 704     }
 705 
 706     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
 707         if self.ball_drag.is_some() {
 708             return self.ball_roll(px, py);
 709         }
 710         match self.dragging_idx {
 711             Some(i) => self.sliders[i].drag_update(px, py),
 712             None => false,
 713         }
 714     }
 715 
 716     fn drag_end(&mut self) {
 717         self.ball_drag = None;
 718         if let Some(i) = self.dragging_idx.take() {
 719             self.sliders[i].drag_end();
 720         }
 721     }
 722 
 723     /// The rows' wheel-glide inertia.
 724     fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
 725         let mut dummy = UiContext::new();
 726         let mut changed = false;
 727         for s in self.sliders.iter_mut() {
 728             changed |= WidgetHost::tick(s, dt, &mut dummy);
 729         }
 730         changed
 731     }
 732 
 733     fn take_change(&mut self) -> bool {
 734         let mut any = false;
 735         for s in self.sliders.iter_mut() {
 736             any |= s.take_change();
 737         }
 738         any
 739     }
 740 
 741     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
 742         match event {
 743             Event::MouseButton { button, state, x: px, y: py, .. } => {
 744                 if *button != MouseButton::Left {
 745                     return false;
 746                 }
 747                 let mut dummy = UiContext::new();
 748                 match state {
 749                     ElementState::Pressed => {
 750                         // A press on the ball takes hold of it; the drag
 751                         // that follows turns the vector (`drag_update`).
 752                         if self.ball_hit(*px, *py) {
 753                             self.ball_begin(*px, *py);
 754                             return true;
 755                         }
 756                         let rows = self.get_row_rects();
 757                         for (i, r) in rows.into_iter().enumerate() {
 758                             if *py < r.1 || *py > r.1 + r.3 {
 759                                 continue;
 760                             }
 761                             // The child's own readout click claims focus through the
 762                             // dummy ctx (a no-op beyond the thread-local slot); the
 763                             // GROUP is the host's focus target, as before.
 764                             if !self.sliders[i].mouse_input(*button, *state, *px, *py, &mut dummy) {
 765                                 continue;
 766                             }
 767                             if self.sliders[i].is_dragging() {
 768                                 self.dragging_idx = Some(i);
 769                             }
 770                             if self.sliders[i].editing {
 771                                 for (j, s) in self.sliders.iter_mut().enumerate() {
 772                                     if j != i && s.editing {
 773                                         s.unfocus();
 774                                     }
 775                                 }
 776                                 ectx.request_focus();
 777                             }
 778                             return true;
 779                         }
 780                         false
 781                     }
 782                     ElementState::Released => {
 783                         let mut any = false;
 784                         for s in self.sliders.iter_mut() {
 785                             any |= s.mouse_input(*button, *state, *px, *py, &mut dummy);
 786                         }
 787                         if self.dragging_idx.take().is_some() {
 788                             any = true;
 789                         }
 790                         if self.ball_drag.take().is_some() {
 791                             any = true;
 792                         }
 793                         any
 794                     }
 795                 }
 796             }
 797             Event::MouseWheel { delta, x: px, y: py, .. } => {
 798                 let mut dummy = UiContext::new();
 799                 let ui = ectx.ui.as_deref_mut();
 800                 match ui {
 801                     Some(ui) => self.wheel(delta, *px, *py, ui),
 802                     None => self.wheel(delta, *px, *py, &mut dummy),
 803                 }
 804             }
 805             Event::PointerMove { x: px, y: py, .. } => {
 806                 let mut dummy = UiContext::new();
 807                 let mut changed = false;
 808                 for s in self.sliders.iter_mut() {
 809                     changed |= s.on_cursor_moved(*px, *py, &mut dummy);
 810                 }
 811                 changed
 812             }
 813             Event::KeyInput(key_event) => {
 814                 let mut dummy = UiContext::new();
 815                 for s in self.sliders.iter_mut() {
 816                     if s.editing {
 817                         return s.keyboard_input(key_event, &mut dummy);
 818                     }
 819                 }
 820                 false
 821             }
 822             // Focus loss commits every open readout edit (each row's own FocusOut).
 823             Event::FocusOut => {
 824                 for s in self.sliders.iter_mut() {
 825                     s.unfocus();
 826                 }
 827                 true
 828             }
 829             _ => false,
 830         }
 831     }
 832 }
 833 
 834 #[cfg(test)]
 835 mod tests {
 836     use super::*;
 837 
 838     /// The ParametersBg drive pattern: readout click opens the row's edit, Enter/unfocus
 839     /// commits back into the normalized value, a track press starts a drag.
 840     #[test]
 841     fn readout_edit_commits_on_unfocus() {
 842         let mut ctx = UiContext::new();
 843         let mut f = Float3::new().with_values([0.5, 0.5, 0.5]).with_range(0.0, 10.0);
 844         WidgetHost::set_rect(&mut f, 0.0, 0.0, 300.0, Float3::preferred_height(false));
 845 
 846         let rows = f.get_row_rects();
 847         assert_eq!(rows.len(), 3);
 848         assert_eq!(f.value_string(), "5.00:5.00:5.00");
 849         // Click row 1's readout (the 60px box at the row's right end).
 850         let rx = rows[1].0 + rows[1].2 - 30.0;
 851         let ry = rows[1].1 + rows[1].3 * 0.5;
 852         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, rx, ry, &mut ctx));
 853         assert_eq!(f.editing_idx(), Some(1));
 854 
 855         f.sliders[1].set_value_string("7.5");
 856         WidgetHost::unfocus(&mut f);
 857         assert_eq!(f.editing_idx(), None);
 858         assert!((f.values()[1] - 0.75).abs() < 1e-4, "7.5 of 0..10 normalizes to 0.75");
 859 
 860         // Track press starts a drag; drag_update moves the value; release ends it.
 861         let track_x = rows[0].0 + 20.0;
 862         let track_y = rows[0].1 + rows[0].3 * 0.5;
 863         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, track_x, track_y, &mut ctx));
 864         assert!(f.is_dragging());
 865         f.drag_update(track_x + 100.0, track_y);
 866         assert!(f.values()[0] > 0.5, "drag right raises the value");
 867         f.drag_end();
 868         assert!(!f.is_dragging());
 869     }
 870 
 871     /// The trackball stands left of the rows, as tall as they are, and
 872     /// dragging it rolls the vector: a quarter turn of the ball's surface
 873     /// to the right carries a vector pointing at the viewer onto +X, one
 874     /// downward onto -Y, and the length is kept. The rows read to a third
 875     /// decimal, and a vector of no length is given one on the first drag.
 876     #[test]
 877     fn the_trackball_turns_the_vector_and_keeps_its_length() {
 878         let mut ctx = UiContext::new();
 879         let quarter = |r: f32| r * std::f32::consts::FRAC_PI_2;
 880         let group = |v: [f32; 3]| {
 881             // Range -10..10: a scaled value v is (v + 10) / 20 normalized.
 882             let mut f = Float3::new().with_range(-10.0, 10.0).with_values(v.map(|c| (c + 10.0) / 20.0)).with_trackball(true);
 883             WidgetHost::set_rect(&mut f, 0.0, 0.0, 400.0, Float3::preferred_height(false));
 884             f
 885         };
 886         let close = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
 887 
 888         let plain = {
 889             let mut f = Float3::new();
 890             WidgetHost::set_rect(&mut f, 0.0, 0.0, 400.0, Float3::preferred_height(false));
 891             f.get_row_rects()[0]
 892         };
 893         let mut f = group([0.0, 0.0, 2.0]);
 894         let (cx, cy, r) = f.ball_circle().expect("a ball");
 895         assert_eq!(r * 2.0, Float3::ball_diameter());
 896         let row = f.get_row_rects()[0];
 897         assert_eq!(row.0, plain.0 + Float3::trackball_chrome(), "the rows start past the ball");
 898         assert_eq!(row.2, plain.2 - Float3::trackball_chrome());
 899         assert_eq!(f.value_string(), "0.000:0.000:2.000", "three decimals with the ball on");
 900 
 901         // A press on the ball takes hold; the drag rolls it a quarter turn right.
 902         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, cx, cy, &mut ctx));
 903         assert!(f.is_dragging());
 904         assert!(f.drag_update(cx + quarter(r), cy));
 905         assert!(close(f.vector(), [2.0, 0.0, 0.0]), "{:?}", f.vector());
 906         f.drag_end();
 907         assert!(!f.is_dragging());
 908 
 909         // Downward: the near point turns toward -Y.
 910         let mut f = group([0.0, 0.0, 2.0]);
 911         f.drag_begin(cx, cy);
 912         assert!(f.drag_update(cx, cy + quarter(r)));
 913         assert!(close(f.vector(), [0.0, -2.0, 0.0]), "{:?}", f.vector());
 914 
 915         // Many small moves add up to what one large one does: the drag
 916         // turns its own full-precision copy, not the rounded rows.
 917         let mut f = group([0.0, 0.0, 0.06]);
 918         f.drag_begin(cx, cy);
 919         for i in 1..=100 {
 920             f.drag_update(cx + quarter(r) * i as f32 / 100.0, cy);
 921         }
 922         assert!(close(f.vector(), [0.06, 0.0, 0.0]), "{:?}", f.vector());
 923 
 924         // No length: the first drag gives it one.
 925         let mut f = group([0.0, 0.0, 0.0]);
 926         f.drag_begin(cx, cy);
 927         assert!(f.drag_update(cx + quarter(r), cy));
 928         assert!(close(f.vector(), [1.0, 0.0, 0.0]), "{:?}", f.vector());
 929 
 930         // Off the ball a press is the rows', as before.
 931         let mut f = group([0.0, 0.0, 2.0]);
 932         let row = f.get_row_rects()[0];
 933         assert!(!f.ball_hit(row.0 + 20.0, row.1 + row.3 * 0.5));
 934         assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, row.0 + 20.0, row.1 + row.3 * 0.5, &mut ctx));
 935         assert!(f.is_dragging());
 936         f.drag_update(row.0 + 60.0, row.1 + row.3 * 0.5);
 937         assert!(f.vector()[1] == 0.0 && f.vector()[2] == 2.0, "only X moved: {:?}", f.vector());
 938 
 939         // A scroll rolls the ball as content is scrolled: a notch is
 940         // fifteen degrees, the wheel in one axis and a two-finger gesture in
 941         // both. Wheel DOWN moves content up, and the near point with it.
 942         let turn = std::f32::consts::PI / 12.0;
 943         let mut f = group([0.0, 0.0, 2.0]);
 944         ctx.scroll_gesture_new = true;
 945         ctx.scroll_initiate_widget_id = None;
 946         assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), cx, cy, &mut ctx));
 947         assert!(close(f.vector(), [0.0, 2.0 * turn.sin(), 2.0 * turn.cos()]), "{:?}", f.vector());
 948         assert_eq!(ctx.scroll_initiate_widget_id, Some(f.ball_id()), "the ball owns the gesture");
 949         // Latched: the pointer has drifted onto a band, and the ball still turns.
 950         ctx.scroll_gesture_new = false;
 951         let row = f.get_row_rects()[0];
 952         let before = f.vector();
 953         assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), row.0 + 20.0, row.1 + row.3 * 0.5, &mut ctx));
 954         assert!(close(f.vector(), [0.0, 0.0, 2.0]), "rolled back: {:?} from {before:?}", f.vector());
 955 
 956         // A trackpad sends a pixel at a time. Sixty of them to the right are
 957         // one notch, on a vector short enough that a single pixel turns it
 958         // by less than the rows can hold.
 959         let mut f = group([0.0, 0.0, 0.06]);
 960         ctx.scroll_gesture_new = true;
 961         ctx.scroll_initiate_widget_id = None;
 962         for _ in 0..60 {
 963             assert!(f.wheel(&MouseScrollDelta::PixelDelta(crate::widget::Position { x: 1.0, y: 0.0 }), cx, cy, &mut ctx));
 964             ctx.scroll_gesture_new = false;
 965         }
 966         assert!(close(f.vector(), [0.06 * turn.sin(), 0.0, 0.06 * turn.cos()]), "{:?}", f.vector());
 967         // A typed component ends the scroll's copy: the next scroll turns
 968         // what the rows hold.
 969         f.sliders[1].set_scaled_value(3.0);
 970         ctx.scroll_gesture_new = true;
 971         f.wheel(&MouseScrollDelta::LineDelta(1.0, 0.0), cx, cy, &mut ctx);
 972         assert!((f.vector()[1] - 3.0).abs() < 2e-3, "Y is what was typed: {:?}", f.vector());
 973 
 974         // Off the ball a scroll is the bands', and a gesture a band holds
 975         // stays the band's over the ball.
 976         let mut f = group([0.0, 0.0, 2.0]);
 977         let row = f.get_row_rects()[0];
 978         ctx.scroll_gesture_new = true;
 979         ctx.scroll_initiate_widget_id = None;
 980         let band_x = row.0 + (row.2 - 68.0) * 0.5;
 981         assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), band_x, row.1 + row.3 * 0.5, &mut ctx));
 982         assert_ne!(f.vector()[0], 0.0, "the X band turned");
 983         let (y, z) = (f.vector()[1], f.vector()[2]);
 984         ctx.scroll_gesture_new = false;
 985         assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), cx, cy, &mut ctx));
 986         assert_eq!((f.vector()[1], f.vector()[2]), (y, z), "the ball did not take a band's gesture");
 987         // Over the readouts, past the bands' halos, nothing takes a scroll;
 988         // and a group without a ball has no ball to hit.
 989         assert_eq!(f.wheel_zone(395.0, cy), None);
 990         let mut plain = Float3::new().with_range(-10.0, 10.0);
 991         WidgetHost::set_rect(&mut plain, 0.0, 0.0, 400.0, Float3::preferred_height(false));
 992         assert!(!plain.ball_hit(cx, cy));
 993 
 994         // Seen from a camera: one out along +X, looking back at the origin,
 995         // with -Z to its right. A vector along +X points at it, so on the
 996         // ball it faces the viewer, tip at the centre; rolled a quarter to
 997         // the right it swings to the right of the SCREEN, which in the
 998         // scene is -Z; and the rows hold the scene's numbers throughout.
 999         let camera = [[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]];
1000         let mut f = group([2.0, 0.0, 0.0]);
1001         assert!(f.set_view(camera));
1002         let tip = |f: &Adapted<Float3>| {
1003             let mut pc = PaintCtx::new();
1004             f.paint_ball(&mut pc);
1005             pc.finish().items.into_iter().find_map(|i| match i.prim {
1006                 crate::scene::paint::Prim::Vector { x2, y2, thickness: 2.0, .. } => Some((x2, y2)),
1007                 _ => None,
1008             }).expect("the vector's stroke")
1009         };
1010         let (tx, ty) = tip(&f);
1011         assert!((tx - cx).abs() < 1e-3 && (ty - cy).abs() < 1e-3, "pointing at the camera: ({tx}, {ty})");
1012         f.drag_begin(cx, cy);
1013         assert!(f.drag_update(cx + quarter(r), cy));
1014         assert!(close(f.vector(), [0.0, 0.0, -2.0]), "screen right is the scene's -Z: {:?}", f.vector());
1015         f.drag_end();
1016         let (tx, _) = tip(&f);
1017         assert!(tx > cx + r * 0.5, "and it is drawn to the right");
1018         // A scroll rolls about the camera's axes too, and a vector of no
1019         // length starts toward the camera.
1020         let mut f = group([2.0, 0.0, 0.0]);
1021         f.set_view(camera);
1022         ctx.scroll_gesture_new = true;
1023         ctx.scroll_initiate_widget_id = None;
1024         f.wheel(&MouseScrollDelta::LineDelta(0.0, -6.0), cx, cy, &mut ctx);
1025         assert!(close(f.vector(), [0.0, 2.0, 0.0]), "six notches up: {:?}", f.vector());
1026         let mut f = group([0.0, 0.0, 0.0]);
1027         f.set_view(camera);
1028         f.drag_begin(cx, cy);
1029         f.drag_update(cx + 0.001, cy);
1030         assert!(close(f.vector(), [1.0, 0.0, 0.0]), "toward the camera: {:?}", f.vector());
1031         // A view that is not square is made so; one with no direction is refused.
1032         let mut f = group([0.0, 0.0, 2.0]);
1033         assert!(f.set_view([[2.0, 0.0, 1.0], [0.0, 9.0, 0.0], [0.0, 0.0, 3.0]]));
1034         assert_eq!(f.view(), IDENTITY_VIEW);
1035         assert!(!f.set_view([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]]));
1036         assert_eq!(f.view(), IDENTITY_VIEW);
1037 
1038         // The rings are circles of latitude about the vector: every point
1039         // on one is the same angle from it, whichever way it points.
1040         for dir in [[0.0f32, 0.0, 1.0], [0.3, 0.5, 0.4], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]] {
1041             let l = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
1042             let d = dir.map(|c: f32| c / l);
1043             for degrees in RING_ANGLES {
1044                 let ring = Float3::ring(dir, degrees, 24);
1045                 assert_eq!(ring.len(), 25);
1046                 assert!(close(ring[0], ring[24]), "the ring closes");
1047                 for p in &ring {
1048                     let dot = p[0] * d[0] + p[1] * d[1] + p[2] * d[2];
1049                     assert!((dot - degrees.to_radians().cos()).abs() < 1e-5, "{degrees} degrees from {dir:?}: {p:?}");
1050                     assert!(((p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt() - 1.0).abs() < 1e-5, "on the ball");
1051                 }
1052             }
1053         }
1054         // Pointing at the viewer they are concentric circles about the
1055         // centre; turned a quarter onto +X the equator's ring is seen edge
1056         // on, a line down the middle.
1057         let facing = Float3::ring([0.0, 0.0, 1.0], 30.0, 24);
1058         assert!(facing.iter().all(|p| ((p[0] * p[0] + p[1] * p[1]).sqrt() - 0.5).abs() < 1e-5 && p[2] > 0.0));
1059         let edge_on = Float3::ring([1.0, 0.0, 0.0], 90.0, 24);
1060         assert!(edge_on.iter().all(|p| p[0].abs() < 1e-5));
1061 
1062         // Painted: strokes for the near halves of the rings, and they move
1063         // when the vector turns.
1064         let strokes = |v: [f32; 3]| -> Vec<(f32, f32)> {
1065             let mut pc = PaintCtx::new();
1066             group(v).paint_ball(&mut pc);
1067             pc.finish()
1068                 .items
1069                 .into_iter()
1070                 .filter_map(|i| match i.prim {
1071                     crate::scene::paint::Prim::Vector { x1, y1, thickness: 1.0, .. } => Some((x1, y1)),
1072                     _ => None,
1073                 })
1074                 .collect()
1075         };
1076         let facing = strokes([0.0, 0.0, 2.0]);
1077         assert!(facing.len() > 60, "rings are drawn: {}", facing.len());
1078         assert!(facing.iter().all(|(x, y)| (x - cx).powi(2) + (y - cy).powi(2) <= r * r + 0.5), "on the ball");
1079         assert_ne!(facing, strokes([2.0, 0.0, 0.0]), "a turned vector turns its rings");
1080         assert_ne!(strokes([0.0, 0.0, 2.0]).len(), 0);
1081 
1082         // The ball paints a sphere and the vector on it; without one, nothing.
1083         let mut pc = PaintCtx::new();
1084         group([0.0, 0.0, 2.0]).paint_ball(&mut pc);
1085         let prims: Vec<_> = pc.finish().items.into_iter().map(|i| i.prim).collect();
1086         assert!(prims.iter().any(|p| matches!(p, crate::scene::paint::Prim::Sphere { .. })), "{prims:?}");
1087         assert!(prims.iter().any(|p| matches!(p, crate::scene::paint::Prim::Vector { .. })));
1088         let mut pc = PaintCtx::new();
1089         Float3::new().paint_ball(&mut pc);
1090         assert!(pc.finish().items.is_empty());
1091     }
1092 }