jellyflow_runtime/runtime/geometry/
hit_test.rs1use jellyflow_core::core::CanvasPoint;
2
3use super::paths::{EdgePath, PathCommand};
4
5#[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}