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 }