Skip to main content

jellyflow_runtime/runtime/geometry/
hit_test.rs

1use jellyflow_core::core::CanvasPoint;
2
3use super::paths::{EdgePath, PathCommand};
4
5/// Options for numeric edge hit testing.
6#[derive(Debug, Clone, Copy, PartialEq)]
7pub struct EdgeHitTestOptions {
8    pub interaction_width: f32,
9    pub curve_samples: usize,
10}
11
12impl EdgeHitTestOptions {
13    pub fn new(interaction_width: f32, curve_samples: usize) -> Self {
14        let width = if interaction_width.is_finite() && interaction_width > 0.0 {
15            interaction_width
16        } else {
17            1.0
18        };
19        let samples = curve_samples.max(1);
20        Self {
21            interaction_width: width,
22            curve_samples: samples,
23        }
24    }
25
26    fn radius(self) -> f32 {
27        self.interaction_width * 0.5
28    }
29}
30
31impl Default for EdgeHitTestOptions {
32    fn default() -> Self {
33        Self {
34            interaction_width: 12.0,
35            curve_samples: 24,
36        }
37    }
38}
39
40pub fn edge_path_contains_point(
41    path: &EdgePath,
42    point: CanvasPoint,
43    options: EdgeHitTestOptions,
44) -> bool {
45    edge_path_distance(path, point, options).is_some_and(|distance| distance <= options.radius())
46}
47
48pub fn edge_path_distance(
49    path: &EdgePath,
50    point: CanvasPoint,
51    options: EdgeHitTestOptions,
52) -> Option<f32> {
53    if !point.is_finite() {
54        return None;
55    }
56
57    let mut current: Option<CanvasPoint> = None;
58    let mut min_distance = f32::INFINITY;
59    let samples = options.curve_samples.max(1);
60
61    for command in &path.commands {
62        match *command {
63            PathCommand::MoveTo(to) => {
64                if !to.is_finite() {
65                    return None;
66                }
67                current = Some(to);
68            }
69            PathCommand::LineTo(to) => {
70                let from = current?;
71                if !to.is_finite() {
72                    return None;
73                }
74                min_distance = min_distance.min(distance_to_segment(point, from, to)?);
75                current = Some(to);
76            }
77            PathCommand::CubicTo {
78                control1,
79                control2,
80                to,
81            } => {
82                let from = current?;
83                if !control1.is_finite() || !control2.is_finite() || !to.is_finite() {
84                    return None;
85                }
86                let mut prev = from;
87                for step in 1..=samples {
88                    let t = step as f32 / samples as f32;
89                    let next = cubic_point(from, control1, control2, to, t);
90                    min_distance = min_distance.min(distance_to_segment(point, prev, next)?);
91                    prev = next;
92                }
93                current = Some(to);
94            }
95        }
96    }
97
98    min_distance.is_finite().then_some(min_distance)
99}
100
101fn distance_to_segment(point: CanvasPoint, a: CanvasPoint, b: CanvasPoint) -> Option<f32> {
102    let dx = b.x - a.x;
103    let dy = b.y - a.y;
104    let len_sq = dx * dx + dy * dy;
105    if !len_sq.is_finite() {
106        return None;
107    }
108    if len_sq <= f32::EPSILON {
109        return distance(point, a);
110    }
111
112    let t = (((point.x - a.x) * dx + (point.y - a.y) * dy) / len_sq).clamp(0.0, 1.0);
113    distance(
114        point,
115        CanvasPoint {
116            x: a.x + t * dx,
117            y: a.y + t * dy,
118        },
119    )
120}
121
122fn distance(a: CanvasPoint, b: CanvasPoint) -> Option<f32> {
123    let dx = b.x - a.x;
124    let dy = b.y - a.y;
125    let distance = (dx * dx + dy * dy).sqrt();
126    distance.is_finite().then_some(distance)
127}
128
129fn cubic_point(
130    start: CanvasPoint,
131    control1: CanvasPoint,
132    control2: CanvasPoint,
133    end: CanvasPoint,
134    t: f32,
135) -> CanvasPoint {
136    let mt = 1.0 - t;
137    let mt2 = mt * mt;
138    let t2 = t * t;
139    CanvasPoint {
140        x: start.x * mt2 * mt
141            + control1.x * 3.0 * mt2 * t
142            + control2.x * 3.0 * mt * t2
143            + end.x * t2 * t,
144        y: start.y * mt2 * mt
145            + control1.y * 3.0 * mt2 * t
146            + control2.y * 3.0 * mt * t2
147            + end.y * t2 * t,
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::{EdgeHitTestOptions, edge_path_contains_point, edge_path_distance};
154    use crate::io::NodeGraphInteractionState;
155    use crate::runtime::geometry::{
156        BezierEdgeOptions, EdgeEndpointPosition, HandlePosition, bezier_edge_path,
157        straight_edge_path,
158    };
159    use jellyflow_core::core::CanvasPoint;
160
161    #[test]
162    fn hit_test_uses_edge_path_distance_for_straight_and_bezier_paths() {
163        let source = EdgeEndpointPosition {
164            point: CanvasPoint { x: 0.0, y: 0.0 },
165            position: HandlePosition::Right,
166        };
167        let target = EdgeEndpointPosition {
168            point: CanvasPoint { x: 100.0, y: 0.0 },
169            position: HandlePosition::Left,
170        };
171        let straight = straight_edge_path(source, target).expect("straight path");
172        let options = EdgeHitTestOptions::new(12.0, 24);
173
174        assert!(edge_path_contains_point(
175            &straight,
176            CanvasPoint { x: 50.0, y: 5.0 },
177            options
178        ));
179        assert!(!edge_path_contains_point(
180            &straight,
181            CanvasPoint { x: 50.0, y: 7.0 },
182            options
183        ));
184
185        let bezier =
186            bezier_edge_path(source, target, BezierEdgeOptions::default()).expect("bezier path");
187        let distance = edge_path_distance(&bezier, CanvasPoint { x: 50.0, y: 0.0 }, options)
188            .expect("distance");
189        assert!(distance <= 1.0e-3);
190    }
191
192    #[test]
193    fn hit_test_options_are_derived_from_interaction_state() {
194        let mut state = NodeGraphInteractionState::default();
195        state.edge_interaction_width = 18.0;
196        state.bezier_hit_test_steps = 32;
197
198        let options = state.edge_hit_test_options();
199        assert!((options.interaction_width - 18.0).abs() <= 1.0e-6);
200        assert_eq!(options.curve_samples, 32);
201    }
202}