1use graph_explorer_style::interner::IdKind;
5
6#[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 pub shape: u32,
17 pub opacity: f32,
18}
19
20const MIN_HITTABLE_OPACITY: f32 = 0.05;
22
23pub 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, 2 => dx.abs() + dy.abs() <= t.radius + tolerance * std::f32::consts::SQRT_2,
36 _ => (dx * dx + dy * dy).sqrt() <= r, }
38}
39
40pub 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 let n = std::f32::consts::FRAC_1_SQRT_2; 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 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 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}