Skip to main content

geop_ops/ui/
hit.rs

1//! Hit tests: which [`Visual`] a [`Pointer`] is over, shared by every
2//! operation so a click means the same thing whatever is being edited.
3//!
4//! What counts as over something is measured in the pointer's reach (see
5//! [`super::Reach`]), so "near" means near on screen however far away or
6//! zoomed in the view is. The geometry itself — how near a ray passes to a
7//! point, a segment, a triangle — is [`geop_core_math::primitives::Ray`]'s.
8
9use std::cmp::Ordering;
10
11use geop_core_math::scalars::Scalar;
12
13use super::{Pointer, Shape, Visual};
14
15/// A handle's radius, in reaches; its arrows reach three times as far along
16/// its direction.
17pub const HANDLE: f64 = 0.8;
18/// How near a label's center a click has to land, in reaches.
19pub const LABEL: f64 = 1.2;
20
21/// Orders two distances for picking the nearest. Which of two overlapping
22/// enclosures is "nearer" is no geometric claim, only which of two things
23/// under the pointer to prefer, so their midpoints decide.
24pub fn nearer<S: Scalar>(a: S, b: S) -> Ordering {
25    a.to_f64().total_cmp(&b.to_f64())
26}
27
28/// A visual the pointer is over.
29#[derive(Clone, Copy, Debug)]
30pub struct VisualHit<'a, S: Scalar> {
31    pub visual: &'a Visual<S>,
32    /// How far along the ray.
33    pub t: S,
34}
35
36/// How near the pointer is to `visual`, in reaches, and where along the
37/// ray — `None` if not near enough to count. `rank` orders kinds of shapes:
38/// what is drawn small and on top wins over what is drawn large.
39fn distance<S: Scalar>(pointer: &Pointer<S>, visual: &Visual<S>) -> Option<(u8, S, S)> {
40    let ray = &pointer.ray;
41    let within = |dist: S, t: S, reaches: f64| {
42        pointer
43            .within(dist, t, reaches)
44            .then(|| (dist.div(pointer.reach.at(t)).unwrap_or(S::ZERO), t))
45    };
46    match &visual.shape {
47        Shape::Handle { at, direction } => {
48            let radius = pointer.reach_at(HANDLE, ray.closest_to_point(at));
49            let (dist, t) = match direction.normalize() {
50                Ok(d) => {
51                    let reach = d.prod_scalar(radius.mul(S::from_f64(3.0)));
52                    ray.distance_to_segment(&at.sub(&reach), &at.add(&reach))
53                }
54                Err(_) => ray.distance_to_point(at),
55            };
56            within(dist, t, HANDLE).map(|(p, t)| (0, p, t))
57        }
58        Shape::Label { at, offset, .. } => {
59            let center = at.add(&offset.prod_scalar(pointer.reach.at(ray.closest_to_point(at))));
60            let (dist, t) = ray.distance_to_point(&center);
61            within(dist, t, LABEL).map(|(p, t)| (0, p, t))
62        }
63        Shape::Point { at } => {
64            let (dist, t) = ray.distance_to_point(at);
65            within(dist, t, 1.0).map(|(p, t)| (1, p, t))
66        }
67        Shape::Polyline { points } => points
68            .windows(2)
69            .filter_map(|w| {
70                let (dist, t) = ray.distance_to_segment(&w[0], &w[1]);
71                within(dist, t, 1.0)
72            })
73            .min_by(|a, b| nearer(a.0, b.0))
74            .map(|(p, t)| (2, p, t)),
75        Shape::Triangles { triangles } => triangles
76            .iter()
77            .filter_map(|[a, b, c]| ray.intersect_triangle(a, b, c))
78            .min_by(|&a, &b| nearer(a, b))
79            .map(|t| (3, S::ZERO, t)),
80    }
81}
82
83/// The visual among those `accept` takes that the pointer is over: handles
84/// and labels first, then points, curves and areas — within each, the one
85/// nearest the pointer on screen.
86pub fn hit_visuals<'a, S: Scalar>(
87    visuals: &'a [Visual<S>],
88    pointer: &Pointer<S>,
89    accept: impl Fn(&Visual<S>) -> bool,
90) -> Option<VisualHit<'a, S>> {
91    visuals
92        .iter()
93        .filter(|v| accept(v))
94        .filter_map(|v| distance(pointer, v).map(|(rank, reaches, t)| (rank, reaches, t, v)))
95        .min_by(|a, b| a.0.cmp(&b.0).then(nearer(a.1, b.1)))
96        .map(|(_, _, t, visual)| VisualHit { visual, t })
97}
98
99#[cfg(test)]
100mod tests {
101    use geop_core_math::{primitives::Ray, scalars::ScalInF64, vector::Vector3};
102
103    use super::*;
104    use crate::ui::{Reach, Style};
105
106    type S = ScalInF64;
107
108    fn v(x: f64, y: f64, z: f64) -> Vector3<S> {
109        Vector3::from_array([x, y, z].map(S::from_f64))
110    }
111
112    /// Looking straight down `-z` from `(x, y, 10)`, reaching a tenth of a
113    /// unit wherever it looks.
114    fn down(x: f64, y: f64) -> Pointer<S> {
115        Pointer {
116            ray: Ray::try_new(v(x, y, 10.0), v(0.0, 0.0, -1.0)).unwrap(),
117            reach: Reach::Tube {
118                radius: S::from_f64(0.1),
119            },
120        }
121    }
122
123    fn point(key: &str, at: Vector3<S>) -> Visual<S> {
124        Visual::new(key, Shape::Point { at }, Style::Free)
125    }
126
127    /// A point wins over the curve it lies on, within reach of it; further
128    /// along, the curve is hit.
129    #[test]
130    fn points_win_over_curves() {
131        let visuals = [
132            Visual::new(
133                "line",
134                Shape::Polyline {
135                    points: vec![v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)],
136                },
137                Style::Free,
138            ),
139            point("end", v(1.0, 0.0, 0.0)),
140        ];
141        let key = |x: f64, y: f64| {
142            hit_visuals(&visuals, &down(x, y), |_| true).map(|h| h.visual.key.clone())
143        };
144        assert_eq!(key(0.95, 0.0).as_deref(), Some("end"));
145        assert_eq!(key(0.5, 0.05).as_deref(), Some("line"));
146        assert_eq!(key(0.5, 0.2), None);
147    }
148
149    /// A label is hit where it is drawn: moved from its point by its
150    /// offset, in reaches.
151    #[test]
152    fn labels_are_hit_where_they_are_drawn() {
153        let visuals = [Visual::new(
154            "k1",
155            Shape::Label {
156                at: v(0.0, 0.0, 0.0),
157                text: "H".into(),
158                offset: v(2.0, 1.0, 0.0),
159            },
160            Style::Free,
161        )];
162        assert!(hit_visuals(&visuals, &down(0.2, 0.1), |_| true).is_some());
163        assert!(hit_visuals(&visuals, &down(0.0, 0.0), |_| true).is_none());
164    }
165
166    /// In perspective, what is further away is hit from further off.
167    #[test]
168    fn cones_widen_with_distance() {
169        let visuals = [point("p", v(0.3, 0.0, 0.0))];
170        let from = |z: f64| Pointer {
171            ray: Ray::try_new(v(0.0, 0.0, z), v(0.0, 0.0, -1.0)).unwrap(),
172            reach: Reach::Cone {
173                slope: S::from_f64(0.01),
174            },
175        };
176        assert!(hit_visuals(&visuals, &from(10.0), |_| true).is_none());
177        assert!(hit_visuals(&visuals, &from(40.0), |_| true).is_some());
178    }
179}