Skip to main content

geop_core_editor/editing/
gizmo.rs

1//! [`Gizmo`]: moving, turning and scaling something in the viewport by
2//! dragging a manipulator drawn at it — the one way every operation offers
3//! a drag in three dimensions.
4//!
5//! An operation asks for a gizmo at a point, with the modes it allows and,
6//! if what it stands on has an orientation of its own, its axes (see
7//! [`super::Form::gizmo`]). The editor draws it, tests the pointer against
8//! it, follows a drag of one of its parts and snaps it, and sends the
9//! operation what the drag did as a [`GizmoDrag`]: a translation, a turn
10//! about one of its axes, or a scale along them — always measured from
11//! where the drag started, and applied by the operation to its arguments
12//! as they were then (see [`crate::step_editor`]).
13//!
14//! It is laid out in the pointer's reaches (see [`super::Reach`]), so it
15//! keeps its size on screen however far away or zoomed in the view is: a
16//! ball at its centre to drag freely in the plane facing the eye, an arrow
17//! along each axis and a square in each plane of two of them, a ring about
18//! each axis, a cube beyond each arrow to stretch along it and one on the
19//! diagonal to scale evenly. The viewer draws it by the same sizes, from
20//! [`GizmoView`].
21
22use geop_core_math::{primitives::Pose, scalars::Scalar, vector::Vector3};
23use serde::Serialize;
24
25use super::{Pointer, hit::nearer};
26
27/// How the gizmo is laid out, in reaches of the pointer at its centre.
28pub mod size {
29    /// The radius of the ball at the centre.
30    pub const FREE: f64 = 1.4;
31    /// Where an arrow starts, out from the centre, and where its tip is.
32    pub const ARROW: (f64, f64) = (2.5, 10.0);
33    /// Where a plane's square starts and ends, along both its axes.
34    pub const PLANE: (f64, f64) = (2.5, 4.0);
35    /// The radius of the rings.
36    pub const RING: f64 = 7.0;
37    /// How far out a cube to scale by sits: along its axis, or the
38    /// diagonal.
39    pub const CUBE_AT: f64 = 12.5;
40    /// Half the size of such a cube.
41    pub const CUBE: f64 = 0.9;
42    /// How near the pointer has to pass to an arrow, a ring or a cube.
43    pub const NEAR: f64 = 1.0;
44    /// How far from looking along it an arrow, or a cube, is still shown —
45    /// and how far from looking along its plane a square is: the sine of
46    /// the angle. Nearer, it is foreshortened to a dot or a line on
47    /// screen, and a drag of it could not be followed.
48    pub const FACING: f64 = 0.2;
49}
50
51/// The finest spacing of the grid the viewer draws on a plane worked in,
52/// in reaches (20 pixels, a reach being 9): what a translation snaps to is
53/// the round step — 1, 2 or 5 times a power of ten — at least this far on
54/// screen.
55pub const GRID: f64 = 20.0 / 9.0;
56/// What a turn snaps to, in degrees.
57pub const ANGLE_SNAP: f64 = 15.0;
58/// What a scale factor snaps to.
59pub const SCALE_SNAP: f64 = 0.1;
60
61/// What a gizmo lets the user do.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
63pub struct Modes {
64    /// Move along an axis, in a plane of two, or freely.
65    pub translate: bool,
66    /// Turn about an axis.
67    pub rotate: bool,
68    /// Scale along an axis, or evenly.
69    pub scale: bool,
70}
71
72/// A gizmo an operation asks for (see [`super::Form::gizmo`]): at `at`,
73/// doing what `modes` allow — oriented along the world's axes, or `axes`,
74/// what it stands on's own, orthonormal and right-handed, if it has an
75/// orientation of its own: then the user chooses which.
76#[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    /// A gizmo at `at` that does nothing yet: see [`Gizmo::translate`],
85    /// [`Gizmo::rotate`] and [`Gizmo::scale`].
86    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    /// Oriented along `axes` too, if the user chooses: orthonormal and
110    /// right-handed.
111    pub fn local(mut self, axes: [Vector3; 3]) -> Self {
112        self.axes = Some(axes);
113        self
114    }
115}
116
117/// Which way a gizmo is turned: along the world's axes, or its own.
118#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum Orientation {
121    World,
122    /// Along what it stands on's own axes, where it has them.
123    #[default]
124    Local,
125}
126
127/// One of a gizmo's three axes, which picks its own from any three things
128/// given per axis. Sent as its index, `0` for `x`.
129#[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    /// Its own of `per_axis`.
140    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    /// The two others, in turn: `y` and `z` for `x`.
159    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/// A part of a gizmo, to hover and drag: by its axis.
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
188#[serde(tag = "part", content = "axis", rename_all = "snake_case")]
189pub enum GizmoPart {
190    /// The ball at the centre: moving freely, in the plane facing the eye.
191    Free,
192    /// An arrow: moving along its axis.
193    Move(Axis),
194    /// The square in the plane normal to an axis: moving in that plane.
195    Plane(Axis),
196    /// A ring: turning about its axis.
197    Turn(Axis),
198    /// A cube beyond an arrow: scaling along its axis.
199    Stretch(Axis),
200    /// The cube on the diagonal: scaling evenly.
201    Scale,
202}
203
204/// What a drag of a gizmo did, from where it started.
205#[derive(Clone, Debug, PartialEq)]
206pub enum Change {
207    /// Moved by this much, in world coordinates.
208    Translate(Vector3),
209    /// Turned by `degrees` about the line through the centre along the
210    /// unit vector `axis` — counter-clockwise, looking against it.
211    Rotate { axis: Vector3, degrees: f64 },
212    /// Scaled about the centre by `factors[i]` along the unit vector
213    /// `axes[i]`.
214    Scale {
215        axes: [Vector3; 3],
216        factors: [f64; 3],
217    },
218}
219
220/// What a drag of a gizmo did, from where it started: a [`Change`] about
221/// `centre`, where the gizmo stood when the drag started.
222#[derive(Clone, Debug, PartialEq)]
223pub struct GizmoDrag {
224    pub centre: Vector3,
225    pub change: Change,
226}
227
228impl GizmoDrag {
229    /// Where the change takes the point `p`.
230    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    /// Where the change takes the point `p`, given as plain coordinates.
248    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    /// The change as a rigid motion: a pose taking where something was to
254    /// where it goes — `None` for a scale, which is none.
255    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/// A gizmo as the viewer draws it: where, along which axes, what it lets
268/// the user do, which part the pointer is over and which is dragged, and
269/// what a drag under way has done so far, in words.
270#[derive(Clone, Debug, PartialEq, Serialize)]
271pub struct GizmoView {
272    pub at: Vector3,
273    /// Unit and orthogonal: the world's, or what it stands on's own.
274    pub axes: [Vector3; 3],
275    #[serde(skip)]
276    pub modes: Modes,
277    /// The parts it shows, as its modes give them: what a viewer draws,
278    /// and the pointer is tested against.
279    pub parts: Vec<GizmoPart>,
280    pub hover: Option<GizmoPart>,
281    pub active: Option<GizmoPart>,
282    pub readout: Option<String>,
283}
284
285/// The world's axes.
286fn world() -> [Vector3; 3] {
287    [0, 1, 2].map(Vector3::axis)
288}
289
290impl GizmoView {
291    /// The gizmo moved by `motion`: drawn where the part it is of is placed.
292    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    /// `gizmo` turned as `orientation` says.
299    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    /// The parts a gizmo of `modes` shows: the ball, arrows and squares
316    /// to move by, the rings to turn by, the cubes to scale by.
317    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    /// The diagonal the even scale's cube sits on.
335    pub fn diagonal(&self) -> Option<Vector3> {
336        let [a, b, c] = &self.axes;
337        a.add(b).add(c).normalize().ok()
338    }
339
340    /// Whether `part` is shown looking along `dir` (see [`size::FACING`]).
341    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    /// How large one reach is at its centre, for `pointer`.
352    pub fn reach(&self, pointer: &Pointer) -> Scalar {
353        pointer.reach_at(1.0, pointer.ray.closest_to_point(&self.at))
354    }
355
356    /// The part `pointer` is over. One it is over as drawn comes first —
357    /// the ball, a square, a cube within its own size — before one it only
358    /// passes within a reach of: a square is hit only where it is drawn, so
359    /// a cube or a ring reaching over it by that reach must not take it.
360    /// Then of the cubes and the ball, then the arrows and squares, then
361    /// the rings, the one nearest the pointer on screen.
362    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                // A cube, by its distance from the ray: over it within its
377                // own size, near it within a reach more.
378                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    /// The ring about axis `i`, as a closed polyline, a reach being `r`:
429    /// as the viewer draws it, so it is hit where it is seen.
430    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
446/// `value` snapped to `step` — a free choice of where the pointer is,
447/// made for the user.
448fn snap(value: f64, step: f64) -> f64 {
449    (value / step).round() * step
450}
451
452/// The step a translation snaps to, a reach being `reach`: 1, 2 or 5
453/// times a power of ten, at least [`GRID`] reaches.
454pub 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
464/// `value` written to as many decimals as `step` has.
465fn 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/// A drag of a gizmo under way: the gizmo as it was when the part was
472/// pressed — what every event of the drag is measured against — the part,
473/// how large a reach was there, and how far a turn has gone, to follow it
474/// past half a turn.
475#[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    /// Grabbing `part` of `view` with `pointer`.
485    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    /// What the drag from `from` to `to` did — snapped unless `free` — and
496    /// what to say of it. `None` where the pointer cannot be followed:
497    /// looking along an arrow, or along a square's plane.
498    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        // A move by `amounts` along the axes `which`.
513        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                    // Round the centre, where the pointer is in the ring's
555                    // plane — followed past half a turn: the angle nearest
556                    // the last.
557                    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                    // Seen nearly edge on, the ring is a line on screen,
569                    // and the pointer runs along it: as far along the
570                    // ring's tangent where it was grabbed — its side
571                    // towards the eye — as the ring's radius turns.
572                    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    /// How far a cube along `direction` was pulled out, as a factor: how
619    /// far out it is now over how far it was — snapped unless `free`, and
620    /// never zero or less, which would flatten or turn inside out.
621    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    /// From `origin` along `dir`, a reach being a tenth of a unit.
650    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    /// Looking straight down `-z` at `(x, y)`.
665    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    /// Seen from above, each part is hit where it is laid out: the ball,
675    /// the arrows, the squares, the rings, the cubes.
676    #[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        // The xy plane's square is the one normal to z.
684        assert_eq!(hit(0.32, 0.32), Some(GizmoPart::Plane(Axis::Z)));
685        // The ring about z, between the arrows.
686        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        // Translation alone shows no ring and no cube.
691        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    /// Where a ring or a cube reaches over a square on screen, the square
700    /// is hit: they are hit within a reach of them, a square only where it
701    /// is drawn. Over the cube itself, the cube is.
702    #[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        // From through `from` on through `to`.
710        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        // The middle of the xy plane's square.
716        let on_square = [0.325, 0.325, 0.0];
717        // On through the ring about `y`, which runs in the `zx` plane.
718        let a = (-0.62f64 / 0.7).asin();
719        let ring = through(on_square, [0.7 * a.cos(), 0.0, 0.7 * a.sin()]);
720        // On past the diagonal's cube, 0.14 beside its centre: further than
721        // its half size, 0.09, within a reach more.
722        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            // Each alone is hit there.
730            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        // Through the square and the middle of the cube, the cube.
735        assert_eq!(
736            gizmo.hit(&through(on_square, [c, c, c])),
737            Some(GizmoPart::Scale)
738        );
739    }
740
741    /// An arrow dragged moves along its axis only, snapped to the grid —
742    /// here halves, a reach being a tenth — unless shift is held.
743    #[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    /// A ring dragged a quarter round turns by 90 degrees, followed past
764    /// half a turn, and snapped to 15 degrees.
765    #[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        // Past half a turn: on, not back.
786        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    /// Seen edge on, a ring turns as far as the pointer runs along it: a
797    /// quarter of its circumference, a quarter turn.
798    #[test]
799    fn rings_seen_edge_on_follow_the_pointer_along_them() {
800        let gizmo = every();
801        // Looking along `y`, the ring about `z` is a line along `x`; its
802        // side towards the eye runs along `x`.
803        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    /// A cube pulled out to twice as far scales by two along its axis;
814    /// the diagonal's, evenly.
815    #[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    /// The steps a translation snaps to are round, and at least 20 pixels
840    /// on screen.
841    #[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    /// Turned to its own axes, the gizmo moves along them.
850    #[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}