Skip to main content

loam_render/
gizmo.rs

1use glam::{Vec3, Vec4};
2use loam_math::{Bivector, Plane4, Rotor4};
3use loam_shape::LineMesh;
4
5use crate::hypergimbal::{Hypergimbal, Ring, RingStyle};
6
7const RING_REACH: f32 = 1.0 + std::f32::consts::SQRT_2;
8
9const SHAFT_INNER: f32 = 0.45;
10
11const SHAFT_HEAD: f32 = 0.28;
12
13const SHAFT_CLEARANCE: f32 = 2.0 * SHAFT_HEAD;
14
15const SHAFT_OUTER: f32 = RING_REACH + SHAFT_CLEARANCE;
16
17const INV_SQRT_3: f32 = 0.577_350_26;
18
19const MIN_SHAFT_INCIDENCE: f32 = 1e-2;
20
21#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
22#[repr(usize)]
23pub enum Axis4 {
24    X = 0,
25    Y = 1,
26    Z = 2,
27    W = 3,
28}
29
30impl Axis4 {
31    pub const ALL: [Self; 4] = [Self::X, Self::Y, Self::Z, Self::W];
32
33    pub fn label(self) -> &'static str {
34        match self {
35            Self::X => "x",
36            Self::Y => "y",
37            Self::Z => "z",
38            Self::W => "w",
39        }
40    }
41
42    pub fn unit(self) -> Vec4 {
43        match self {
44            Self::X => Vec4::X,
45            Self::Y => Vec4::Y,
46            Self::Z => Vec4::Z,
47            Self::W => Vec4::W,
48        }
49    }
50
51    /// `w` takes the direction equiangular to the three scene axes.
52    pub fn shaft_direction(self) -> Vec3 {
53        match self {
54            Self::X => Vec3::X,
55            Self::Y => Vec3::Y,
56            Self::Z => Vec3::Z,
57            Self::W => Vec3::new(-INV_SQRT_3, -INV_SQRT_3, -INV_SQRT_3),
58        }
59    }
60}
61
62#[derive(Copy, Clone, Debug, PartialEq)]
63pub struct Shaft {
64    pub axis: Axis4,
65    pub origin: Vec3,
66    pub direction: Vec3,
67    /// Drawn extent; neither the pick nor the drag is clipped to it.
68    pub inner: f32,
69    pub outer: f32,
70    pub head: f32,
71}
72
73impl Shaft {
74    pub fn point(&self, along: f32) -> Vec3 {
75        self.origin + self.direction * along
76    }
77
78    pub fn head_start(&self) -> f32 {
79        self.outer - self.head
80    }
81
82    /// Closest approach of the ray to the shaft's line (Ericson, *Real-Time Collision Detection*, 2005, §5.1.9); `None` within 0.6° of parallel.
83    pub fn ray_parameter(&self, ray_origin: Vec3, ray_direction: Vec3) -> Option<f32> {
84        let offset = self.origin - ray_origin;
85        let alignment = self.direction.dot(ray_direction);
86        let ray_length_squared = ray_direction.length_squared();
87        let denominator = ray_length_squared - alignment * alignment;
88        if denominator <= MIN_SHAFT_INCIDENCE * MIN_SHAFT_INCIDENCE * ray_length_squared {
89            return None;
90        }
91        Some(
92            (alignment * ray_direction.dot(offset)
93                - self.direction.dot(offset) * ray_length_squared)
94                / denominator,
95        )
96    }
97
98    fn hit_depth(&self, ray_origin: Vec3, ray_direction: Vec3, tolerance: f32) -> Option<f32> {
99        let along = self.ray_parameter(ray_origin, ray_direction)?;
100        let nearest = self.point(along.clamp(self.head_start(), self.outer));
101        let unit = ray_direction.normalize();
102        let offset = nearest - ray_origin;
103        let depth = offset.dot(unit);
104        if depth <= 0.0 || (offset - unit * depth).length() > tolerance {
105            return None;
106        }
107        Some(depth)
108    }
109
110    pub fn drag_translation(&self, grab: f32, cursor: f32) -> Vec4 {
111        self.axis.unit() * (cursor - grab)
112    }
113
114    fn append_line_mesh(&self, style: &ShaftStyle, scale: f32, out: &mut LineMesh<3>) {
115        let color = style.colors[self.axis as usize];
116        let mut push = |from: Vec3, to: Vec3| {
117            out.segments.push((from.to_array(), to.to_array()));
118            out.colors.push((color, color));
119            out.widths.push(style.width_px);
120        };
121        let tip = self.point(self.outer);
122        push(self.point(self.inner), tip);
123
124        let barb_root = self.point(self.head_start());
125        let (across, up) = self.direction.any_orthonormal_pair();
126        let radius = style.head_radius * scale;
127        let step = std::f32::consts::TAU / style.head_barbs as f32;
128        for k in 0..style.head_barbs {
129            let angle = k as f32 * step;
130            push(
131                tip,
132                barb_root + (across * angle.cos() + up * angle.sin()) * radius,
133            );
134        }
135    }
136}
137
138#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
139pub enum HandleId {
140    Rotate(Plane4),
141    Translate(Axis4),
142}
143
144#[derive(Copy, Clone, Debug, PartialEq)]
145pub enum Handle {
146    Rotate(Ring),
147    Translate(Shaft),
148}
149
150impl Handle {
151    pub fn id(self) -> HandleId {
152        match self {
153            Self::Rotate(ring) => HandleId::Rotate(ring.plane),
154            Self::Translate(shaft) => HandleId::Translate(shaft.axis),
155        }
156    }
157}
158
159#[derive(Copy, Clone, Debug, PartialEq)]
160pub enum TransformDelta {
161    Rotate { plane: Plane4, angle: f32 },
162    Translate { axis: Axis4, distance: f32 },
163}
164
165impl TransformDelta {
166    pub fn rotor(self) -> Rotor4 {
167        match self {
168            Self::Rotate { plane, angle } => (plane.unit_bivector() * angle).exp(),
169            Self::Translate { .. } => Rotor4::IDENTITY,
170        }
171    }
172
173    pub fn translation(self) -> Vec4 {
174        match self {
175            Self::Rotate { .. } => Vec4::ZERO,
176            Self::Translate { axis, distance } => axis.unit() * distance,
177        }
178    }
179}
180
181/// Anchored at the press, so a widget following the subject does not chase its own drag.
182#[derive(Copy, Clone, Debug, PartialEq)]
183pub enum HandleDrag {
184    Rotate { ring: Ring, grab: Vec3 },
185    Translate { shaft: Shaft, grab: f32 },
186}
187
188impl HandleDrag {
189    pub fn press(handle: Handle, ray_origin: Vec3, ray_direction: Vec3) -> Option<Self> {
190        match handle {
191            Handle::Rotate(ring) => ring
192                .ray_hit(ray_origin, ray_direction)
193                .map(|grab| Self::Rotate { ring, grab }),
194            Handle::Translate(shaft) => shaft
195                .ray_parameter(ray_origin, ray_direction)
196                .map(|grab| Self::Translate { shaft, grab }),
197        }
198    }
199
200    pub fn id(&self) -> HandleId {
201        match self {
202            Self::Rotate { ring, .. } => HandleId::Rotate(ring.plane),
203            Self::Translate { shaft, .. } => HandleId::Translate(shaft.axis),
204        }
205    }
206
207    /// `None` when the ray cannot be read against the handle; callers hold the last delta.
208    pub fn delta(&self, ray_origin: Vec3, ray_direction: Vec3) -> Option<TransformDelta> {
209        match self {
210            Self::Rotate { ring, grab } => {
211                let cursor = ring.ray_hit(ray_origin, ray_direction)?;
212                Some(TransformDelta::Rotate {
213                    plane: ring.plane,
214                    angle: ring.drag_angle(*grab, cursor),
215                })
216            }
217            Self::Translate { shaft, grab } => {
218                let cursor = shaft.ray_parameter(ray_origin, ray_direction)?;
219                Some(TransformDelta::Translate {
220                    axis: shaft.axis,
221                    distance: cursor - grab,
222                })
223            }
224        }
225    }
226}
227
228#[derive(Copy, Clone, Debug, PartialEq)]
229pub struct TransformGizmo {
230    pub center: Vec3,
231    pub scale: f32,
232}
233
234impl TransformGizmo {
235    pub fn hypergimbal(&self) -> Hypergimbal {
236        Hypergimbal {
237            center: self.center,
238            scale: self.scale,
239        }
240    }
241
242    pub fn rings(&self) -> [Ring; 6] {
243        self.hypergimbal().rings()
244    }
245
246    pub fn shafts(&self) -> [Shaft; 4] {
247        Axis4::ALL.map(|axis| self.shaft(axis))
248    }
249
250    pub fn shaft(&self, axis: Axis4) -> Shaft {
251        Shaft {
252            axis,
253            origin: self.center,
254            direction: axis.shaft_direction(),
255            inner: SHAFT_INNER * self.scale,
256            outer: SHAFT_OUTER * self.scale,
257            head: SHAFT_HEAD * self.scale,
258        }
259    }
260
261    /// A shaft beats a ring wherever both are in range; the mesh draws shafts last.
262    pub fn pick(&self, ray_origin: Vec3, ray_direction: Vec3, tolerance: f32) -> Option<Handle> {
263        let mut nearest: Option<(f32, Shaft)> = None;
264        for shaft in self.shafts() {
265            let Some(depth) = shaft.hit_depth(ray_origin, ray_direction, tolerance) else {
266                continue;
267            };
268            if nearest.is_none_or(|(best, _)| depth < best) {
269                nearest = Some((depth, shaft));
270            }
271        }
272        if let Some((_, shaft)) = nearest {
273            return Some(Handle::Translate(shaft));
274        }
275        self.hypergimbal()
276            .pick(ray_origin, ray_direction, tolerance)
277            .map(Handle::Rotate)
278    }
279
280    pub fn append_line_mesh(&self, style: &GizmoStyle, out: &mut LineMesh<3>) {
281        self.hypergimbal().append_line_mesh(&style.rings, out);
282        for shaft in self.shafts() {
283            shaft.append_line_mesh(&style.shafts, self.scale, out);
284        }
285    }
286}
287
288#[derive(Clone, Debug, Default)]
289pub struct GizmoStyle {
290    pub rings: RingStyle,
291    pub shafts: ShaftStyle,
292}
293
294#[derive(Clone, Debug)]
295pub struct ShaftStyle {
296    pub width_px: f32,
297    pub head_barbs: usize,
298    pub head_radius: f32,
299    /// RGBA per axis, in [`Axis4::ALL`] order.
300    pub colors: [[f32; 4]; 4],
301}
302
303impl ShaftStyle {
304    pub const AXIS_COLORS: [[f32; 4]; 4] = [
305        [0.96, 0.36, 0.34, 1.0],
306        [0.42, 0.90, 0.46, 1.0],
307        [0.38, 0.58, 0.98, 1.0],
308        [0.82, 0.44, 0.98, 1.0],
309    ];
310}
311
312impl Default for ShaftStyle {
313    fn default() -> Self {
314        Self {
315            width_px: 2.5,
316            head_barbs: 4,
317            head_radius: 0.09,
318            colors: Self::AXIS_COLORS,
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use loam_math::Rotor;
327    use std::f32::consts::{PI, TAU};
328
329    const WIDGET: TransformGizmo = TransformGizmo {
330        center: Vec3::new(0.3, -0.7, 1.1),
331        scale: 0.8,
332    };
333
334    const EYE: Vec3 = Vec3::new(4.6, 4.4, 7.3);
335
336    fn ray_to(target: Vec3) -> (Vec3, Vec3) {
337        let eye = WIDGET.center + EYE;
338        (eye, (target - eye).normalize())
339    }
340
341    fn tolerance() -> f32 {
342        0.02 * WIDGET.scale
343    }
344
345    #[test]
346    fn every_ring_and_every_shaft_is_reachable_by_pointing_at_it() {
347        const SAMPLES: usize = 72;
348        for plane in Plane4::ALL {
349            let ring = WIDGET.hypergimbal().ring(plane);
350            let hits = (0..SAMPLES)
351                .filter(|step| {
352                    let (origin, direction) =
353                        ray_to(ring.point(*step as f32 / SAMPLES as f32 * TAU));
354                    WIDGET
355                        .pick(origin, direction, tolerance())
356                        .is_some_and(|handle| handle.id() == HandleId::Rotate(plane))
357                })
358                .count();
359            assert!(
360                hits * 2 > SAMPLES,
361                "{plane:?} selectable at only {hits}/{SAMPLES} points on its own ring"
362            );
363        }
364        for axis in Axis4::ALL {
365            let shaft = WIDGET.shaft(axis);
366            for step in 0..=SAMPLES {
367                let along = shaft.head_start() + shaft.head * step as f32 / SAMPLES as f32;
368                let (origin, direction) = ray_to(shaft.point(along));
369                let picked = WIDGET.pick(origin, direction, tolerance()).map(Handle::id);
370                assert_eq!(
371                    picked,
372                    Some(HandleId::Translate(axis)),
373                    "{axis:?} at {along} out picked {picked:?}"
374                );
375            }
376        }
377    }
378
379    #[test]
380    fn a_w_handle_drag_stays_in_its_own_plane_or_on_its_own_axis() {
381        for plane in [Plane4::Xw, Plane4::Yw, Plane4::Zw] {
382            let ring = WIDGET.hypergimbal().ring(plane);
383            for step in 0..12 {
384                let chi = -PI + step as f32 / 12.0 * TAU;
385                let held = HandleDrag::Rotate {
386                    ring,
387                    grab: ring.point(0.0),
388                };
389                let (origin, direction) = ray_to(ring.point(chi));
390                let delta = held.delta(origin, direction).expect("ring faces the eye");
391                let log = delta.rotor().log();
392                for other in Plane4::ALL {
393                    if other == plane {
394                        continue;
395                    }
396                    let component = log.component(other);
397                    assert!(
398                        component.abs() < 1e-5,
399                        "{plane:?} drag leaked {component} into {other:?}"
400                    );
401                }
402                let TransformDelta::Rotate { angle, .. } = delta else {
403                    panic!("{plane:?} ring produced a translation");
404                };
405                if angle.abs() < PI - 0.1 && angle.abs() > 1e-3 {
406                    assert!(
407                        (log.component(plane) - angle).abs() < 1e-4,
408                        "{plane:?} drag of {angle} logged as {}",
409                        log.component(plane)
410                    );
411                }
412                assert_eq!(delta.translation(), Vec4::ZERO);
413            }
414        }
415
416        let shaft = WIDGET.shaft(Axis4::W);
417        for grab in [-1.7_f32, 0.0, 0.4, 2.9] {
418            for cursor in [-2.2_f32, -0.3, 1.1, 3.6] {
419                let translation = shaft.drag_translation(grab, cursor);
420                assert_eq!(translation.x, 0.0);
421                assert_eq!(translation.y, 0.0);
422                assert_eq!(translation.z, 0.0);
423                assert_eq!(translation.w, cursor - grab);
424                assert_eq!(
425                    TransformDelta::Translate {
426                        axis: Axis4::W,
427                        distance: cursor - grab,
428                    }
429                    .rotor(),
430                    Rotor4::IDENTITY
431                );
432            }
433        }
434    }
435
436    #[test]
437    fn a_shaft_drag_asks_for_the_distance_the_cursor_slid_along_it() {
438        for axis in Axis4::ALL {
439            let shaft = WIDGET.shaft(axis);
440            for grab_step in 0..5 {
441                let grab_at = shaft.inner + (shaft.outer - shaft.inner) * grab_step as f32 / 5.0;
442                let (origin, direction) = ray_to(shaft.point(grab_at));
443                let held = HandleDrag::press(Handle::Translate(shaft), origin, direction)
444                    .expect("the press ray reaches the shaft it was aimed at");
445                for travel in [-0.9_f32, -0.2, 0.0, 0.35, 1.4] {
446                    let (origin, direction) = ray_to(shaft.point(grab_at + travel));
447                    let delta = held
448                        .delta(origin, direction)
449                        .expect("cursor ray reaches it");
450                    let TransformDelta::Translate {
451                        axis: got,
452                        distance,
453                    } = delta
454                    else {
455                        panic!("{axis:?} shaft produced a rotation");
456                    };
457                    assert_eq!(got, axis);
458                    assert!(
459                        (distance - travel).abs() < 1e-3,
460                        "{axis:?}: slid {travel} and asked for {distance}"
461                    );
462                    assert_eq!(delta.translation(), axis.unit() * distance);
463                }
464            }
465        }
466    }
467
468    #[test]
469    fn no_handle_drifts_while_held_still_and_every_drag_is_odd_under_reversal() {
470        fn drag(handle: Handle, from: Vec3, to: Vec3) -> TransformDelta {
471            let (origin, direction) = ray_to(from);
472            let held = HandleDrag::press(handle, origin, direction).expect("press reaches it");
473            let (origin, direction) = ray_to(to);
474            held.delta(origin, direction).expect("release reaches it")
475        }
476
477        for plane in Plane4::ALL {
478            let ring = WIDGET.hypergimbal().ring(plane);
479            let (grab, cursor) = (ring.point(0.4), ring.point(1.9));
480            assert_eq!(
481                drag(Handle::Rotate(ring), grab, grab),
482                TransformDelta::Rotate { plane, angle: 0.0 },
483                "{plane:?} drifted while held still"
484            );
485            let out = drag(Handle::Rotate(ring), grab, cursor).rotor();
486            let back = drag(Handle::Rotate(ring), cursor, grab).rotor();
487            let round_trip = (out.log() + back.log()).magnitude_squared();
488            assert!(round_trip < 1e-8, "{plane:?} reversal left {round_trip}");
489        }
490        for axis in Axis4::ALL {
491            let shaft = WIDGET.shaft(axis);
492            let (grab, cursor) = (
493                shaft.point(0.6 * shaft.outer),
494                shaft.point(0.95 * shaft.outer),
495            );
496            assert_eq!(
497                drag(Handle::Translate(shaft), grab, grab),
498                TransformDelta::Translate {
499                    axis,
500                    distance: 0.0
501                },
502                "{axis:?} drifted while held still"
503            );
504            let out = drag(Handle::Translate(shaft), grab, cursor).translation();
505            let back = drag(Handle::Translate(shaft), cursor, grab).translation();
506            assert!((out + back).length() < 1e-4, "{axis:?} reversal left {out}");
507        }
508    }
509
510    #[test]
511    fn grazing_and_backward_rays_miss_the_shaft() {
512        let shaft = WIDGET.shaft(Axis4::X);
513        let eye = shaft.point(shaft.outer * 0.5) + Vec3::Y * 3.0;
514        assert!(shaft.ray_parameter(eye, -Vec3::Y).is_some());
515        assert!(
516            shaft.hit_depth(eye, Vec3::Y, tolerance()).is_none(),
517            "a shaft behind the eye must not hit"
518        );
519        assert!(
520            shaft.ray_parameter(eye, shaft.direction).is_none(),
521            "a ray along the shaft must miss"
522        );
523        let grazing = (shaft.direction + Vec3::Y * (MIN_SHAFT_INCIDENCE * 0.5)).normalize();
524        assert!(
525            shaft.ray_parameter(eye, grazing).is_none(),
526            "a grazing ray must miss"
527        );
528        assert!(
529            shaft.ray_parameter(eye, Vec3::ZERO).is_none(),
530            "a degenerate ray must miss"
531        );
532    }
533
534    #[test]
535    fn a_ray_reaching_no_handle_picks_nothing() {
536        let eye = WIDGET.center + Vec3::Z * 40.0;
537        let miss = WIDGET.center + Vec3::Y * 60.0;
538        assert!(WIDGET
539            .pick(eye, (miss - eye).normalize(), tolerance())
540            .is_none());
541        assert!(WIDGET
542            .pick(eye, (WIDGET.center - eye).normalize(), tolerance())
543            .is_none());
544    }
545
546    #[test]
547    fn arrowheads_clear_the_ring_shell_and_the_hub_stays_empty() {
548        let shell = RING_REACH * WIDGET.scale;
549        for shaft in WIDGET.shafts() {
550            assert!(shaft.inner > 0.0, "a stem starts at the hub");
551            assert!(
552                shaft.head_start() > shell,
553                "{:?}'s head starts at {}, inside the ring shell at {shell}",
554                shaft.axis,
555                shaft.head_start()
556            );
557            assert!((shaft.direction.length() - 1.0).abs() < 1e-6);
558            assert_eq!(shaft.origin, WIDGET.center);
559        }
560        for ring in WIDGET.rings() {
561            let reach = (ring.center - WIDGET.center).length() + ring.radius;
562            assert!(
563                reach <= shell + 1e-4,
564                "a ring reaches {reach}, past {shell}"
565            );
566        }
567        let shafts = WIDGET.shafts();
568        for (i, first) in shafts.iter().enumerate() {
569            for second in &shafts[i + 1..] {
570                assert!(
571                    first.direction.cross(second.direction).length() > 0.5,
572                    "{:?} and {:?} point the same way",
573                    first.axis,
574                    second.axis
575                );
576            }
577        }
578    }
579
580    #[test]
581    fn line_mesh_is_well_formed_and_on_the_handles() {
582        let style = GizmoStyle {
583            rings: RingStyle {
584                segments: 12,
585                ..RingStyle::default()
586            },
587            shafts: ShaftStyle::default(),
588        };
589        let mut mesh = LineMesh::<3>::default();
590        mesh.segments.push(([0.0; 3], [1.0; 3]));
591        mesh.colors.push(([0.0; 4], [0.0; 4]));
592        mesh.widths.push(1.0);
593
594        WIDGET.append_line_mesh(&style, &mut mesh);
595        let shaft_segments = 4 * (1 + style.shafts.head_barbs);
596        assert_eq!(
597            mesh.segments.len(),
598            1 + 6 * style.rings.segments + shaft_segments
599        );
600        assert_eq!(mesh.colors.len(), mesh.segments.len());
601        assert_eq!(mesh.widths.len(), mesh.segments.len());
602
603        let head_span = style.shafts.head_radius * WIDGET.scale;
604        for (index, (start, end)) in mesh.segments[1 + 6 * style.rings.segments..]
605            .iter()
606            .enumerate()
607        {
608            let shaft = WIDGET.shafts()[index / (1 + style.shafts.head_barbs)];
609            for point in [Vec3::from_array(*start), Vec3::from_array(*end)] {
610                let along = (point - shaft.origin).dot(shaft.direction);
611                let off_axis = (point - shaft.point(along)).length();
612                assert!(
613                    (shaft.inner - 1e-4..=shaft.outer + 1e-4).contains(&along)
614                        && off_axis <= head_span + 1e-4,
615                    "{:?} emitted {point}, off its own arrow",
616                    shaft.axis
617                );
618            }
619        }
620    }
621}