1use geop_core_math::{primitives::Pose, scalars::Scalar, vector::Vector3};
23use serde::Serialize;
24
25use super::{Pointer, hit::nearer};
26
27pub mod size {
29 pub const FREE: f64 = 1.4;
31 pub const ARROW: (f64, f64) = (2.5, 10.0);
33 pub const PLANE: (f64, f64) = (2.5, 4.0);
35 pub const RING: f64 = 7.0;
37 pub const CUBE_AT: f64 = 12.5;
40 pub const CUBE: f64 = 0.9;
42 pub const NEAR: f64 = 1.0;
44 pub const FACING: f64 = 0.2;
49}
50
51pub const GRID: f64 = 20.0 / 9.0;
56pub const ANGLE_SNAP: f64 = 15.0;
58pub const SCALE_SNAP: f64 = 0.1;
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
63pub struct Modes {
64 pub translate: bool,
66 pub rotate: bool,
68 pub scale: bool,
70}
71
72#[derive(Clone, Debug, PartialEq)]
77pub struct Gizmo {
78 pub at: Vector3,
79 pub axes: Option<[Vector3; 3]>,
80 pub modes: Modes,
81}
82
83impl Gizmo {
84 pub fn new(at: Vector3) -> Self {
87 Self {
88 at,
89 axes: None,
90 modes: Modes::default(),
91 }
92 }
93
94 pub fn translate(mut self) -> Self {
95 self.modes.translate = true;
96 self
97 }
98
99 pub fn rotate(mut self) -> Self {
100 self.modes.rotate = true;
101 self
102 }
103
104 pub fn scale(mut self) -> Self {
105 self.modes.scale = true;
106 self
107 }
108
109 pub fn local(mut self, axes: [Vector3; 3]) -> Self {
112 self.axes = Some(axes);
113 self
114 }
115}
116
117#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum Orientation {
121 World,
122 #[default]
124 Local,
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130pub enum Axis {
131 X,
132 Y,
133 Z,
134}
135
136impl Axis {
137 pub const ALL: [Axis; 3] = [Axis::X, Axis::Y, Axis::Z];
138
139 pub fn of<T>(self, per_axis: &[T; 3]) -> &T {
141 let [x, y, z] = per_axis;
142 match self {
143 Axis::X => x,
144 Axis::Y => y,
145 Axis::Z => z,
146 }
147 }
148
149 pub fn of_mut<T>(self, per_axis: &mut [T; 3]) -> &mut T {
150 let [x, y, z] = per_axis;
151 match self {
152 Axis::X => x,
153 Axis::Y => y,
154 Axis::Z => z,
155 }
156 }
157
158 pub fn others(self) -> [Axis; 2] {
160 match self {
161 Axis::X => [Axis::Y, Axis::Z],
162 Axis::Y => [Axis::Z, Axis::X],
163 Axis::Z => [Axis::X, Axis::Y],
164 }
165 }
166
167 pub fn name(self) -> &'static str {
168 match self {
169 Axis::X => "x",
170 Axis::Y => "y",
171 Axis::Z => "z",
172 }
173 }
174}
175
176impl Serialize for Axis {
177 fn serialize<Ser: serde::Serializer>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> {
178 serializer.serialize_u8(match self {
179 Axis::X => 0,
180 Axis::Y => 1,
181 Axis::Z => 2,
182 })
183 }
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
188#[serde(tag = "part", content = "axis", rename_all = "snake_case")]
189pub enum GizmoPart {
190 Free,
192 Move(Axis),
194 Plane(Axis),
196 Turn(Axis),
198 Stretch(Axis),
200 Scale,
202}
203
204#[derive(Clone, Debug, PartialEq)]
206pub enum Change {
207 Translate(Vector3),
209 Rotate { axis: Vector3, degrees: f64 },
212 Scale {
215 axes: [Vector3; 3],
216 factors: [f64; 3],
217 },
218}
219
220#[derive(Clone, Debug, PartialEq)]
223pub struct GizmoDrag {
224 pub centre: Vector3,
225 pub change: Change,
226}
227
228impl GizmoDrag {
229 pub fn apply(&self, p: &Vector3) -> Vector3 {
231 let c = &self.centre;
232 match &self.change {
233 Change::Translate(by) => p.add(by),
234 Change::Rotate { .. } => match self.turn() {
235 Some(pose) => pose.apply(p),
236 None => *p,
237 },
238 Change::Scale { axes, factors } => {
239 let d = p.sub(c);
240 axes.iter().zip(factors).fold(*c, |at, (axis, &factor)| {
241 at.add(&axis.prod_scalar(d.prod_dot(axis).mul(Scalar::from_f64(factor))))
242 })
243 }
244 }
245 }
246
247 pub fn apply_f64(&self, p: [f64; 3]) -> [f64; 3] {
249 let moved = self.apply(&Vector3::from_array(p.map(Scalar::from_f64)));
250 moved.to_array().map(|x| x.to_f64())
251 }
252
253 pub fn turn(&self) -> Option<Pose> {
256 match &self.change {
257 Change::Translate(by) => Some(Pose::identity().with_position(*by)),
258 Change::Rotate { axis, degrees } => {
259 let radians = Scalar::from_f64(degrees.to_radians());
260 Pose::rotation_about(&self.centre, axis, radians).ok()
261 }
262 Change::Scale { .. } => None,
263 }
264 }
265}
266
267#[derive(Clone, Debug, PartialEq, Serialize)]
271pub struct GizmoView {
272 pub at: Vector3,
273 pub axes: [Vector3; 3],
275 #[serde(skip)]
276 pub modes: Modes,
277 pub parts: Vec<GizmoPart>,
280 pub hover: Option<GizmoPart>,
281 pub active: Option<GizmoPart>,
282 pub readout: Option<String>,
283}
284
285fn world() -> [Vector3; 3] {
287 [0, 1, 2].map(Vector3::axis)
288}
289
290impl GizmoView {
291 pub fn moved(mut self, motion: &geop_core_math::primitives::Motion) -> Self {
293 self.at = motion.apply(&self.at);
294 self.axes = self.axes.map(|axis| motion.rotate(&axis));
295 self
296 }
297
298 pub fn of(gizmo: &Gizmo, orientation: Orientation) -> Self {
300 let axes = match (orientation, gizmo.axes) {
301 (Orientation::Local, Some(axes)) => axes,
302 _ => world(),
303 };
304 Self {
305 at: gizmo.at,
306 axes,
307 modes: gizmo.modes,
308 parts: Self::parts_of(gizmo.modes),
309 hover: None,
310 active: None,
311 readout: None,
312 }
313 }
314
315 fn parts_of(m: Modes) -> Vec<GizmoPart> {
318 let mut parts = Vec::new();
319 if m.translate {
320 parts.push(GizmoPart::Free);
321 parts.extend(Axis::ALL.map(GizmoPart::Move));
322 parts.extend(Axis::ALL.map(GizmoPart::Plane));
323 }
324 if m.rotate {
325 parts.extend(Axis::ALL.map(GizmoPart::Turn));
326 }
327 if m.scale {
328 parts.extend(Axis::ALL.map(GizmoPart::Stretch));
329 parts.push(GizmoPart::Scale);
330 }
331 parts
332 }
333
334 pub fn diagonal(&self) -> Option<Vector3> {
336 let [a, b, c] = &self.axes;
337 a.add(b).add(c).normalize().ok()
338 }
339
340 pub fn shown(&self, part: GizmoPart, dir: &Vector3) -> bool {
342 let across = |a: &Vector3| a.prod_cross(dir).norm().to_f64() >= size::FACING;
343 match part {
344 GizmoPart::Free | GizmoPart::Turn(_) => true,
345 GizmoPart::Move(i) | GizmoPart::Stretch(i) => across(i.of(&self.axes)),
346 GizmoPart::Plane(i) => i.of(&self.axes).prod_dot(dir).abs().to_f64() >= size::FACING,
347 GizmoPart::Scale => self.diagonal().is_some_and(|d| across(&d)),
348 }
349 }
350
351 pub fn reach(&self, pointer: &Pointer) -> Scalar {
353 pointer.reach_at(1.0, pointer.ray.closest_to_point(&self.at))
354 }
355
356 pub fn hit(&self, pointer: &Pointer) -> Option<GizmoPart> {
363 let ray = &pointer.ray;
364 let r = self.reach(pointer);
365 let out = |d: &Vector3, reaches: f64| {
366 self.at
367 .add(&d.prod_scalar(r.mul(Scalar::from_f64(reaches))))
368 };
369 let near = |dist: Scalar, reaches: f64| {
370 (!dist.definitely_greater(r.mul(Scalar::from_f64(reaches))))
371 .then(|| dist.div(r).unwrap_or(Scalar::ZERO))
372 };
373 (self.parts.iter().copied())
374 .filter(|&part| self.shown(part, ray.dir()))
375 .filter_map(|part| {
376 let cube = |at: Vector3| {
379 let dist = ray.distance_to_point(&at).0;
380 let over = near(dist, size::CUBE).is_some();
381 Some((over, 0, near(dist, size::CUBE + size::NEAR)?))
382 };
383 let (over, rank, dist) = match part {
384 GizmoPart::Free => (
385 true,
386 0,
387 near(ray.distance_to_point(&self.at).0, size::FREE)?,
388 ),
389 GizmoPart::Stretch(i) => cube(out(i.of(&self.axes), size::CUBE_AT))?,
390 GizmoPart::Scale => cube(out(&self.diagonal()?, size::CUBE_AT))?,
391 GizmoPart::Move(i) => {
392 let (a, b) = size::ARROW;
393 let d = i.of(&self.axes);
394 let dist = ray.distance_to_segment(&out(d, a), &out(d, b)).0;
395 (false, 1, near(dist, size::NEAR)?)
396 }
397 GizmoPart::Plane(i) => {
398 let [j, k] = i.others();
399 let (lo, hi) = size::PLANE;
400 let corner = |x: f64, y: f64| {
401 out(j.of(&self.axes), x)
402 .add(&k.of(&self.axes).prod_scalar(r.mul(Scalar::from_f64(y))))
403 };
404 let [p, q, s, t] =
405 [(lo, lo), (hi, lo), (hi, hi), (lo, hi)].map(|(x, y)| corner(x, y));
406 let inside = ray.intersect_triangle(&p, &q, &s).is_some()
407 || ray.intersect_triangle(&p, &s, &t).is_some();
408 (true, 1, inside.then_some(Scalar::ZERO)?)
409 }
410 GizmoPart::Turn(i) => {
411 let ring = self.ring(i, r);
412 let dist = ring
413 .windows(2)
414 .filter_map(|w| match w {
415 [a, b] => Some(ray.distance_to_segment(a, b).0),
416 _ => None,
417 })
418 .min_by(|&a, &b| nearer(a, b))?;
419 (false, 2, near(dist, size::NEAR)?)
420 }
421 };
422 Some((!over, rank, dist, part))
423 })
424 .min_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)).then(nearer(a.2, b.2)))
425 .map(|(_, _, _, part)| part)
426 }
427
428 pub fn ring(&self, i: Axis, r: Scalar) -> Vec<Vector3> {
431 const SEGMENTS: usize = 64;
432 let [u, v] = i.others().map(|k| k.of(&self.axes));
433 let radius = r.mul(Scalar::from_f64(size::RING));
434 (0..=SEGMENTS)
435 .map(|k| {
436 let a = std::f64::consts::TAU * k as f64 / SEGMENTS as f64;
437 let (s, c) = a.sin_cos();
438 self.at
439 .add(&u.prod_scalar(radius.mul(Scalar::from_f64(c))))
440 .add(&v.prod_scalar(radius.mul(Scalar::from_f64(s))))
441 })
442 .collect()
443 }
444}
445
446fn snap(value: f64, step: f64) -> f64 {
449 (value / step).round() * step
450}
451
452pub fn grid_step(reach: f64) -> f64 {
455 let least = GRID * reach;
456 let power = 10f64.powf(least.log10().floor());
457 [1.0, 2.0, 5.0, 10.0]
458 .into_iter()
459 .map(|m| m * power)
460 .find(|&step| step >= least)
461 .unwrap_or(10.0 * power)
462}
463
464fn write(value: f64, step: f64) -> String {
466 let decimals = (-step.log10()).ceil().max(0.0) as usize;
467 let value = if value == 0.0 { 0.0 } else { value };
468 format!("{value:.decimals$}")
469}
470
471#[derive(Clone, Debug)]
476pub struct GizmoGrab {
477 pub view: GizmoView,
478 pub part: GizmoPart,
479 reach: Scalar,
480 turned: f64,
481}
482
483impl GizmoGrab {
484 pub fn new(view: GizmoView, part: GizmoPart, pointer: &Pointer) -> Self {
486 let reach = view.reach(pointer);
487 Self {
488 view,
489 part,
490 reach,
491 turned: 0.0,
492 }
493 }
494
495 pub fn drag(
499 &mut self,
500 from: &Pointer,
501 to: &Pointer,
502 free: bool,
503 ) -> Option<(GizmoDrag, String)> {
504 let view = &self.view;
505 let c = view.at;
506 let axes = view.axes;
507 let step = grid_step(self.reach.to_f64());
508 let snapped = |v: f64, step: f64| if free { v } else { snap(v, step) };
509 let in_plane = |pointer: &Pointer, normal: &Vector3| {
510 pointer.ray.intersect_plane(&c, normal).map(|(_, p)| p)
511 };
512 let translate = |which: &[Axis], amounts: Vec<f64>| {
514 let by = which
515 .iter()
516 .zip(&amounts)
517 .fold(Vector3::zero(), |by, (&k, &a)| {
518 by.add(&k.of(&axes).prod_scalar(Scalar::from_f64(a)))
519 });
520 let text = which
521 .iter()
522 .zip(&amounts)
523 .map(|(&k, &a)| format!("{} {}", k.name(), write(a, step)))
524 .collect::<Vec<_>>()
525 .join(" ");
526 (Change::Translate(by), text)
527 };
528 let (change, text) = match self.part {
529 GizmoPart::Move(i) => {
530 let s = |p: &Pointer| p.ray.line_parameter(&c, i.of(&axes));
531 let moved = s(to)?.sub(s(from)?).to_f64();
532 translate(&[i], vec![snapped(moved, step)])
533 }
534 GizmoPart::Plane(i) => {
535 let d = in_plane(to, i.of(&axes))?.sub(&in_plane(from, i.of(&axes))?);
536 let which = i.others();
537 let amounts = which
538 .iter()
539 .map(|&k| snapped(d.prod_dot(k.of(&axes)).to_f64(), step))
540 .collect();
541 translate(&which, amounts)
542 }
543 GizmoPart::Free => {
544 let d = in_plane(to, from.ray.dir())?.sub(&in_plane(from, from.ray.dir())?);
545 let amounts = Axis::ALL
546 .iter()
547 .map(|&k| snapped(d.prod_dot(k.of(&axes)).to_f64(), step))
548 .collect();
549 translate(&Axis::ALL, amounts)
550 }
551 GizmoPart::Turn(i) => {
552 let a = i.of(&axes);
553 if a.prod_dot(from.ray.dir()).abs().to_f64() >= size::FACING {
554 let v0 = in_plane(from, a)?.sub(&c);
558 let v1 = in_plane(to, a)?.sub(&c);
559 let sin = v0.prod_cross(&v1).prod_dot(a).to_f64();
560 let cos = v0.prod_dot(&v1).to_f64();
561 if sin == 0.0 && cos == 0.0 {
562 return None;
563 }
564 let angle = sin.atan2(cos).to_degrees();
565 let turns = ((self.turned - angle) / 360.0).round();
566 self.turned = angle + 360.0 * turns;
567 } else {
568 let r = self.reach;
573 let grabbed = view
574 .ring(i, r)
575 .into_iter()
576 .filter_map(|p| {
577 let (dist, t) = from.ray.distance_to_point(&p);
578 (!dist.definitely_greater(r.mul(Scalar::from_f64(size::NEAR))))
579 .then_some((t, p))
580 })
581 .min_by(|x, y| nearer(x.0, y.0))?
582 .1;
583 let tangent = a.prod_cross(&grabbed.sub(&c)).normalize().ok()?;
584 let s = |p: &Pointer| p.ray.line_parameter(&grabbed, &tangent);
585 let along = s(to)?.sub(s(from)?).to_f64();
586 self.turned = (along / (size::RING * r.to_f64())).to_degrees();
587 }
588 let degrees = snapped(self.turned, ANGLE_SNAP);
589 let text = format!(
590 "{} {}°",
591 i.name(),
592 write(degrees, if free { 0.1 } else { 1.0 })
593 );
594 (Change::Rotate { axis: *a, degrees }, text)
595 }
596 GizmoPart::Stretch(i) => {
597 let factor = self.factor(from, to, i.of(&axes), free)?;
598 let mut factors = [1.0; 3];
599 *i.of_mut(&mut factors) = factor;
600 let text = format!("{} ×{}", i.name(), write(factor, SCALE_SNAP / 10.0));
601 (Change::Scale { axes, factors }, text)
602 }
603 GizmoPart::Scale => {
604 let factor = self.factor(from, to, &view.diagonal()?, free)?;
605 let text = format!("×{}", write(factor, SCALE_SNAP / 10.0));
606 (
607 Change::Scale {
608 axes,
609 factors: [factor; 3],
610 },
611 text,
612 )
613 }
614 };
615 Some((GizmoDrag { centre: c, change }, text))
616 }
617
618 fn factor(&self, from: &Pointer, to: &Pointer, direction: &Vector3, free: bool) -> Option<f64> {
622 let c = &self.view.at;
623 let s0 = from.ray.line_parameter(c, direction)?.to_f64();
624 let s1 = to.ray.line_parameter(c, direction)?.to_f64();
625 let factor = s1 / s0;
626 if !factor.is_finite() {
627 return None;
628 }
629 let factor = if free {
630 factor
631 } else {
632 snap(factor, SCALE_SNAP).max(SCALE_SNAP)
633 };
634 (factor > 0.0).then_some(factor)
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use geop_core_math::{primitives::Ray, scalars::Scalar, vector::Vector3};
641
642 use super::*;
643 use crate::editing::Reach;
644
645 fn v(x: f64, y: f64, z: f64) -> Vector3 {
646 Vector3::from_array([x, y, z].map(Scalar::from_f64))
647 }
648
649 fn pointer(origin: [f64; 3], dir: [f64; 3]) -> Pointer {
651 Pointer {
652 cycle: 0,
653 ray: Ray::try_new(
654 v(origin[0], origin[1], origin[2]),
655 v(dir[0], dir[1], dir[2]),
656 )
657 .unwrap(),
658 reach: Reach::Tube {
659 radius: Scalar::from_f64(0.1),
660 },
661 }
662 }
663
664 fn down(x: f64, y: f64) -> Pointer {
666 pointer([x, y, 10.0], [0.0, 0.0, -1.0])
667 }
668
669 fn every() -> GizmoView {
670 let gizmo = Gizmo::new(v(0.0, 0.0, 0.0)).translate().rotate().scale();
671 GizmoView::of(&gizmo, Orientation::World)
672 }
673
674 #[test]
677 fn parts_are_hit_where_they_are_laid_out() {
678 let gizmo = every();
679 let hit = |x, y| gizmo.hit(&down(x, y));
680 assert_eq!(hit(0.0, 0.0), Some(GizmoPart::Free));
681 assert_eq!(hit(0.6, 0.0), Some(GizmoPart::Move(Axis::X)));
682 assert_eq!(hit(0.0, 0.9), Some(GizmoPart::Move(Axis::Y)));
683 assert_eq!(hit(0.32, 0.32), Some(GizmoPart::Plane(Axis::Z)));
685 let r = 0.7 / 2f64.sqrt();
687 assert_eq!(hit(r, r), Some(GizmoPart::Turn(Axis::Z)));
688 assert_eq!(hit(1.25, 0.0), Some(GizmoPart::Stretch(Axis::X)));
689 assert_eq!(hit(3.0, 3.0), None);
690 let moving = GizmoView::of(
692 &Gizmo::new(v(0.0, 0.0, 0.0)).translate(),
693 Orientation::World,
694 );
695 assert_eq!(moving.hit(&down(r, r)), None);
696 assert_eq!(moving.hit(&down(1.25, 0.0)), None);
697 }
698
699 #[test]
703 fn squares_win_over_what_only_reaches_over_them() {
704 let gizmo = every();
705 let only = |part| GizmoView {
706 parts: vec![part],
707 ..gizmo.clone()
708 };
709 let through = |from: [f64; 3], to: [f64; 3]| {
711 let dir: [f64; 3] = std::array::from_fn(|k| to[k] - from[k]);
712 pointer(std::array::from_fn(|k| from[k] - 10.0 * dir[k]), dir)
713 };
714 let square = GizmoPart::Plane(Axis::Z);
715 let on_square = [0.325, 0.325, 0.0];
717 let a = (-0.62f64 / 0.7).asin();
719 let ring = through(on_square, [0.7 * a.cos(), 0.0, 0.7 * a.sin()]);
720 let c = 1.25 / 3f64.sqrt();
723 let s = 0.14 / 2f64.sqrt();
724 let beside_cube = through(on_square, [c + s, c - s, c]);
725 for (ray, other) in [
726 (&ring, GizmoPart::Turn(Axis::Y)),
727 (&beside_cube, GizmoPart::Scale),
728 ] {
729 assert_eq!(only(other).hit(ray), Some(other));
731 assert_eq!(only(square).hit(ray), Some(square));
732 assert_eq!(gizmo.hit(ray), Some(square), "{other:?}");
733 }
734 assert_eq!(
736 gizmo.hit(&through(on_square, [c, c, c])),
737 Some(GizmoPart::Scale)
738 );
739 }
740
741 #[test]
744 fn arrows_move_along_their_axis() {
745 let side = |z: f64| pointer([0.0, -10.0, z], [0.0, 1.0, 0.0]);
746 let gizmo = every();
747 let part = gizmo.hit(&side(0.6)).unwrap();
748 assert_eq!(part, GizmoPart::Move(Axis::Z));
749 let mut grab = GizmoGrab::new(gizmo, part, &side(0.6));
750 let (drag, text) = grab.drag(&side(0.6), &side(2.0), false).unwrap();
751 let Change::Translate(by) = drag.change else {
752 panic!("{drag:?}")
753 };
754 assert_eq!([0, 1, 2].map(|k| by[k].to_f64()), [0.0, 0.0, 1.5]);
755 assert_eq!(text, "z 1.5");
756 let (drag, _) = grab.drag(&side(0.6), &side(2.0), true).unwrap();
757 let Change::Translate(by) = drag.change else {
758 panic!("{drag:?}")
759 };
760 assert!((by[2].to_f64() - 1.4).abs() < 1e-12);
761 }
762
763 #[test]
766 fn rings_turn_about_their_axis() {
767 let gizmo = every();
768 let at = |a: f64| {
769 let a = a.to_radians();
770 down(0.7 * a.cos(), 0.7 * a.sin())
771 };
772 let mut grab = GizmoGrab::new(gizmo, GizmoPart::Turn(Axis::Z), &at(0.0));
773 let degrees = |grab: &mut GizmoGrab, a: f64| match grab.drag(&at(0.0), &at(a), false) {
774 Some((
775 GizmoDrag {
776 change: Change::Rotate { degrees, .. },
777 ..
778 },
779 _,
780 )) => degrees,
781 other => panic!("{other:?}"),
782 };
783 assert_eq!(degrees(&mut grab, 92.0), 90.0);
784 assert_eq!(degrees(&mut grab, 170.0), 165.0);
785 assert_eq!(degrees(&mut grab, 200.0), 195.0);
787 let (drag, text) = grab.drag(&at(0.0), &at(90.0), false).unwrap();
788 assert_eq!(text, "z 90°");
789 let turned = drag.apply_f64([1.0, 0.0, 0.0]);
790 assert!(
791 turned[0].abs() < 1e-12 && (turned[1] - 1.0).abs() < 1e-12,
792 "{turned:?}"
793 );
794 }
795
796 #[test]
799 fn rings_seen_edge_on_follow_the_pointer_along_them() {
800 let gizmo = every();
801 let side = |x: f64| pointer([x, -10.0, 0.0], [0.0, 1.0, 0.0]);
804 let mut grab = GizmoGrab::new(gizmo, GizmoPart::Turn(Axis::Z), &side(0.0));
805 let quarter = std::f64::consts::FRAC_PI_2 * 0.7;
806 let (drag, _) = grab.drag(&side(0.0), &side(quarter), false).unwrap();
807 let Change::Rotate { degrees, .. } = drag.change else {
808 panic!("{drag:?}")
809 };
810 assert_eq!(degrees, 90.0);
811 }
812
813 #[test]
816 fn cubes_scale() {
817 let gizmo = every();
818 let mut grab = GizmoGrab::new(gizmo.clone(), GizmoPart::Stretch(Axis::X), &down(1.25, 0.0));
819 let (drag, text) = grab.drag(&down(1.25, 0.0), &down(2.5, 0.0), false).unwrap();
820 assert_eq!(text, "x ×2.00");
821 let stretched = drag.apply_f64([1.0, 1.0, 1.0]);
822 let want = [2.0, 1.0, 1.0];
823 assert!(
824 (0..3).all(|k| (stretched[k] - want[k]).abs() < 1e-12),
825 "{stretched:?}"
826 );
827 let diagonal = |s: f64| {
828 let d = s / 3f64.sqrt();
829 pointer([d - 10.0, d + 10.0, d], [1.0, -1.0, 0.0])
830 };
831 let part = gizmo.hit(&diagonal(1.25)).unwrap();
832 assert_eq!(part, GizmoPart::Scale);
833 let mut grab = GizmoGrab::new(gizmo, part, &diagonal(1.25));
834 let (drag, _) = grab.drag(&diagonal(1.25), &diagonal(0.625), false).unwrap();
835 let half = drag.apply_f64([1.0, 1.0, 1.0]);
836 assert!(half.iter().all(|c| (c - 0.5).abs() < 1e-12), "{half:?}");
837 }
838
839 #[test]
842 fn grid_steps_are_round() {
843 assert_eq!(grid_step(0.1), 0.5);
844 assert_eq!(grid_step(0.5), 2.0);
845 assert_eq!(grid_step(4.0), 10.0);
846 assert_eq!(grid_step(0.04), 0.1);
847 }
848
849 #[test]
851 fn local_axes_are_followed() {
852 let s = 0.5f64.sqrt();
853 let gizmo = Gizmo::new(v(0.0, 0.0, 0.0)).translate().local([
854 v(s, s, 0.0),
855 v(-s, s, 0.0),
856 v(0.0, 0.0, 1.0),
857 ]);
858 let local = GizmoView::of(&gizmo, Orientation::Local);
859 assert_eq!(
860 local.hit(&down(0.5 * s, 0.5 * s)),
861 Some(GizmoPart::Move(Axis::X))
862 );
863 let world = GizmoView::of(&gizmo, Orientation::World);
864 assert_eq!(world.hit(&down(0.5, 0.0)), Some(GizmoPart::Move(Axis::X)));
865 let mut grab = GizmoGrab::new(local, GizmoPart::Plane(Axis::Z), &down(0.4 * s, 0.0));
866 let (drag, _) = grab
867 .drag(&down(0.0, 0.0), &down(2.0 * s, 2.0 * s), false)
868 .unwrap();
869 let moved = drag.apply_f64([0.0, 0.0, 0.0]);
870 assert!((moved[0] - 2.0 * s).abs() < 1e-12 && (moved[1] - 2.0 * s).abs() < 1e-12);
871 }
872}