Skip to main content

graph_explorer_render/
hit.rs

1//! Pure hit-test geometry: does a world point fall inside a node's drawn shape?
2//! Lives here (not in graph-explorer-wasm) so it is testable without wasm.
3
4use graph_explorer_style::interner::IdKind;
5
6/// One thing on screen that a pointer can hit. Identity is the interned
7/// `NodeIndex`; `kind` was classified once at intern time, replacing the
8/// per-pick string prefix tests.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct HitTarget {
11    pub index: u32,
12    pub kind: IdKind,
13    pub pos: [f32; 2],
14    pub radius: f32,
15    /// 0 circle, 1 square, 2 diamond — matches `NodeInstanceData::shape`.
16    pub shape: u32,
17    pub opacity: f32,
18}
19
20/// Targets fainter than this are mid-fade-out; clicking a ghost would surprise.
21const MIN_HITTABLE_OPACITY: f32 = 0.05;
22
23/// Whether `point` (world space) falls inside `t`'s drawn shape, widened by
24/// `tolerance` world units. The shape maths must match what the renderer draws,
25/// or the clickable area disagrees with the visible one.
26pub fn contains(t: &HitTarget, point: [f32; 2], tolerance: f32) -> bool {
27    let r = t.radius + tolerance;
28    let dx = point[0] - t.pos[0];
29    let dy = point[1] - t.pos[1];
30    match t.shape {
31        1 => dx.abs() <= r && dy.abs() <= r,      // square
32        // A diamond's edge runs at 45 degrees, so |dx|+|dy| overshoots true
33        // perpendicular distance by sqrt(2). Scale the slop to match, or the
34        // documented tolerance is only tolerance/sqrt(2) px for diamonds.
35        2 => dx.abs() + dy.abs() <= t.radius + tolerance * std::f32::consts::SQRT_2,
36        _ => (dx * dx + dy * dy).sqrt() <= r,     // circle (default)
37    }
38}
39
40/// The topmost hittable target at `point`, or `None` — returned as a slot
41/// into `targets` so the caller resolves identity however it likes.
42///
43/// Iterates in reverse so later-drawn (visually on top) targets win. Loading
44/// placeholders are inert by design; near-invisible targets are leaving the
45/// scene (mid-fade ghosts — clicking one would surprise).
46pub fn pick(targets: &[HitTarget], point: [f32; 2], tolerance: f32) -> Option<usize> {
47    targets
48        .iter()
49        .enumerate()
50        .rev()
51        .find(|(_, t)| {
52            t.kind != IdKind::Loading
53                && t.opacity >= MIN_HITTABLE_OPACITY
54                && contains(t, point, tolerance)
55        })
56        .map(|(i, _)| i)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn t(index: u32, pos: [f32; 2], radius: f32, shape: u32) -> HitTarget {
64        HitTarget { index, kind: IdKind::Node, pos, radius, shape, opacity: 1.0 }
65    }
66
67    #[test]
68    fn circle_containment() {
69        let c = t(0, [0.0, 0.0], 10.0, 0);
70        assert!(contains(&c, [0.0, 0.0], 0.0));
71        assert!(contains(&c, [9.9, 0.0], 0.0));
72        assert!(!contains(&c, [10.1, 0.0], 0.0));
73        assert!(!contains(&c, [8.0, 8.0], 0.0), "corner is outside a circle");
74    }
75
76    #[test]
77    fn square_containment() {
78        let s = t(0, [0.0, 0.0], 10.0, 1);
79        assert!(contains(&s, [8.0, 8.0], 0.0), "corner IS inside a square");
80        assert!(contains(&s, [-9.9, 9.9], 0.0));
81        assert!(!contains(&s, [10.1, 0.0], 0.0));
82    }
83
84    #[test]
85    fn diamond_containment() {
86        let d = t(0, [0.0, 0.0], 10.0, 2);
87        assert!(contains(&d, [9.9, 0.0], 0.0));
88        assert!(contains(&d, [4.0, 4.0], 0.0));
89        assert!(!contains(&d, [8.0, 8.0], 0.0), "|dx|+|dy| > r is outside a diamond");
90    }
91
92    #[test]
93    fn tolerance_widens_the_target() {
94        let c = t(0, [0.0, 0.0], 10.0, 0);
95        assert!(!contains(&c, [12.0, 0.0], 0.0));
96        assert!(contains(&c, [12.0, 0.0], 4.0), "tolerance makes it hittable");
97    }
98
99    #[test]
100    fn diamond_tolerance_is_perpendicular_distance() {
101        let d = t(0, [0.0, 0.0], 10.0, 2);
102        // Straight out along +x, the edge is at x == radius, so `tolerance`
103        // away perpendicular from that corner... use the 45-degree edge, where
104        // the naive `radius + tolerance` test under-widens: the point on the
105        // outward normal 4.0 away from the edge midpoint (5,5).
106        let n = std::f32::consts::FRAC_1_SQRT_2; // outward normal (n, n)
107        // 3.9px perpendicular from the edge midpoint (5,5): inside a 4px slop.
108        // The old `radius + tolerance` form only reached 2.83px and missed it.
109        assert!(contains(&d, [5.0 + 3.9 * n, 5.0 + 3.9 * n], 4.0));
110        assert!(!contains(&d, [5.0 + 4.1 * n, 5.0 + 4.1 * n], 4.0), "beyond the slop is outside");
111    }
112
113    #[test]
114    fn offset_position_is_respected() {
115        let c = t(0, [100.0, -50.0], 5.0, 0);
116        assert!(contains(&c, [102.0, -50.0], 0.0));
117        assert!(!contains(&c, [0.0, 0.0], 0.0));
118    }
119
120    #[test]
121    fn topmost_wins_on_overlap() {
122        // Later in the list == drawn later == on top; pick returns its slot.
123        let targets = vec![t(7, [0.0, 0.0], 10.0, 0), t(8, [0.0, 0.0], 10.0, 0)];
124        assert_eq!(pick(&targets, [0.0, 0.0], 0.0), Some(1));
125    }
126
127    #[test]
128    fn misses_return_none() {
129        let targets = vec![t(0, [0.0, 0.0], 5.0, 0)];
130        assert_eq!(pick(&targets, [100.0, 100.0], 0.0), None);
131    }
132
133    #[test]
134    fn loading_placeholders_are_not_hittable() {
135        let mut loading = t(0, [0.0, 0.0], 10.0, 0);
136        loading.kind = IdKind::Loading;
137        assert_eq!(pick(&[loading], [0.0, 0.0], 0.0), None);
138    }
139
140    #[test]
141    fn other_synthetic_targets_are_hittable() {
142        // Contract from the interner docs: only `Loading` is pick-inert.
143        // An unrecognized `"__"`-prefixed id (OtherSynthetic) hit-tests as an
144        // ordinary node, exactly as the old string code treated it.
145        let mut synth = t(3, [0.0, 0.0], 10.0, 0);
146        synth.kind = IdKind::OtherSynthetic;
147        assert_eq!(pick(&[synth], [0.0, 0.0], 0.0), Some(0));
148    }
149
150    #[test]
151    fn faded_targets_are_not_hittable() {
152        let mut ghost = t(0, [0.0, 0.0], 10.0, 0);
153        ghost.opacity = 0.02;
154        assert_eq!(pick(&[ghost], [0.0, 0.0], 0.0), None);
155    }
156}