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 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 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 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#[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 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 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 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}