Skip to content

Commit b28662b

Browse files
committed
Add world-space cache and hit-testing facades to controller
Store node rects in world coordinates (screen→world conversion in handle_node_rect), eliminating the O(N) viewport resync loop. Add set_viewport, link registration, and screen-space hit-testing facades (find_link_at_screen, find_pin_at_screen, nodes_in_selection_box_screen, links_in_selection_box_screen) that handle coordinate conversion internally. Add compute_link_path_screen for world→screen bezier path generation.
1 parent a629dc7 commit b28662b

2 files changed

Lines changed: 305 additions & 9 deletions

File tree

src/controller.rs

Lines changed: 226 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,26 +67,35 @@
6767
//! ```
6868
6969
use crate::state::GeometryCache;
70+
use crate::hit_test::{find_link_at, NodeGeometry, SimpleLinkGeometry};
7071
use slint::SharedString;
7172
use std::cell::RefCell;
7273
use std::rc::Rc;
7374

7475
/// Controller that manages node editor state and provides callback implementations.
7576
///
7677
/// This provides a high-level API that handles:
77-
/// - Geometry caching (node rects, pin positions)
78-
/// - Link path computation
79-
/// - Viewport/zoom tracking
78+
/// - Geometry caching (node rects, pin positions) in **world space**
79+
/// - Link path computation (world→screen conversion done internally)
80+
/// - Hit testing facades (screen-space input, world-space internals)
81+
/// - Viewport/zoom/pan tracking
8082
/// - Drag tracking
8183
///
84+
/// The cache stores node rects in world coordinates, which are invariant to
85+
/// zoom/pan changes. This eliminates the O(N) viewport resync loop that was
86+
/// previously needed when zoom or pan changed.
87+
///
8288
/// Clone this controller to share it across callbacks.
8389
#[derive(Clone)]
8490
pub struct NodeEditorController {
8591
cache: Rc<RefCell<GeometryCache>>,
8692
zoom: Rc<RefCell<f32>>,
93+
pan_x: Rc<RefCell<f32>>,
94+
pan_y: Rc<RefCell<f32>>,
8795
bezier_offset: Rc<RefCell<f32>>,
8896
dragged_node_id: Rc<RefCell<i32>>,
8997
grid_spacing: Rc<RefCell<f32>>,
98+
links: Rc<RefCell<Vec<(i32, i32, i32)>>>,
9099
}
91100

92101
impl Default for NodeEditorController {
@@ -101,9 +110,12 @@ impl NodeEditorController {
101110
Self {
102111
cache: Rc::new(RefCell::new(GeometryCache::new())),
103112
zoom: Rc::new(RefCell::new(1.0)),
113+
pan_x: Rc::new(RefCell::new(0.0)),
114+
pan_y: Rc::new(RefCell::new(0.0)),
104115
bezier_offset: Rc::new(RefCell::new(50.0)),
105116
dragged_node_id: Rc::new(RefCell::new(0)),
106117
grid_spacing: Rc::new(RefCell::new(24.0)),
118+
links: Rc::new(RefCell::new(Vec::new())),
107119
}
108120
}
109121

@@ -135,14 +147,25 @@ impl NodeEditorController {
135147
// === Callback factories ===
136148

137149
/// Returns a callback for `compute-link-path`.
150+
///
151+
/// Computes screen-space bezier paths from world-space cache data.
138152
pub fn compute_link_path_callback(&self) -> impl Fn(i32, i32, i32) -> SharedString {
139153
let cache = self.cache.clone();
140154
let zoom = self.zoom.clone();
155+
let pan_x = self.pan_x.clone();
156+
let pan_y = self.pan_y.clone();
141157
let bezier_offset = self.bezier_offset.clone();
142158
move |start_pin, end_pin, _version| {
143159
cache
144160
.borrow()
145-
.compute_link_path(start_pin, end_pin, *zoom.borrow(), *bezier_offset.borrow())
161+
.compute_link_path_screen(
162+
start_pin,
163+
end_pin,
164+
*zoom.borrow(),
165+
*pan_x.borrow(),
166+
*pan_y.borrow(),
167+
*bezier_offset.borrow(),
168+
)
146169
.unwrap_or_default()
147170
.into()
148171
}
@@ -158,9 +181,22 @@ impl NodeEditorController {
158181

159182
// === Direct handlers ===
160183

161-
/// Handle node-rect-changed: update cache.
184+
/// Handle node-rect-changed: convert screen→world and update cache.
185+
///
186+
/// The UI reports node rects in screen coordinates. This method converts
187+
/// to world coordinates before caching, making the cache zoom/pan invariant.
162188
pub fn handle_node_rect(&self, id: i32, x: f32, y: f32, w: f32, h: f32) {
163-
self.cache.borrow_mut().handle_node_rect_report(id, x, y, w, h);
189+
let zoom = *self.zoom.borrow();
190+
let pan_x = *self.pan_x.borrow();
191+
let pan_y = *self.pan_y.borrow();
192+
let z = if zoom > 0.0 { zoom } else { 1.0 };
193+
let world_x = (x - pan_x) / z;
194+
let world_y = (y - pan_y) / z;
195+
let world_w = w / z;
196+
let world_h = h / z;
197+
self.cache
198+
.borrow_mut()
199+
.handle_node_rect_report(id, world_x, world_y, world_w, world_h);
164200
}
165201

166202
/// Handle pin-position-changed: update cache.
@@ -178,14 +214,42 @@ impl NodeEditorController {
178214
*self.zoom.borrow_mut() = zoom;
179215
}
180216

181-
/// Compute link path for given pins.
217+
/// Set viewport state: zoom, pan_x, pan_y.
218+
///
219+
/// This replaces the old pattern of calling `set_zoom` followed by an O(N)
220+
/// loop to resync all node positions. Since the cache stores world-space
221+
/// coordinates, changing zoom/pan requires no per-node updates.
222+
pub fn set_viewport(&self, zoom: f32, pan_x: f32, pan_y: f32) {
223+
*self.zoom.borrow_mut() = zoom;
224+
*self.pan_x.borrow_mut() = pan_x;
225+
*self.pan_y.borrow_mut() = pan_y;
226+
}
227+
228+
/// Register a link for hit testing.
229+
pub fn register_link(&self, id: i32, start_pin: i32, end_pin: i32) {
230+
self.links.borrow_mut().push((id, start_pin, end_pin));
231+
}
232+
233+
/// Unregister a link by ID.
234+
pub fn unregister_link(&self, id: i32) {
235+
self.links.borrow_mut().retain(|&(lid, _, _)| lid != id);
236+
}
237+
238+
/// Clear all registered links.
239+
pub fn clear_links(&self) {
240+
self.links.borrow_mut().clear();
241+
}
242+
243+
/// Compute link path for given pins (screen-space output from world-space cache).
182244
pub fn compute_link_path(&self, start_pin: i32, end_pin: i32) -> SharedString {
183245
self.cache
184246
.borrow()
185-
.compute_link_path(
247+
.compute_link_path_screen(
186248
start_pin,
187249
end_pin,
188250
*self.zoom.borrow(),
251+
*self.pan_x.borrow(),
252+
*self.pan_y.borrow(),
189253
*self.bezier_offset.borrow(),
190254
)
191255
.unwrap_or_default()
@@ -209,4 +273,158 @@ impl NodeEditorController {
209273
pub fn generate_initial_grid(&self, width: f32, height: f32) -> SharedString {
210274
crate::generate_grid_commands(width, height, 1.0, 0.0, 0.0, *self.grid_spacing.borrow()).into()
211275
}
276+
277+
// === Screen-space hit-testing facades ===
278+
//
279+
// These methods accept screen-space mouse coordinates and handle all
280+
// coordinate conversion internally using the stored viewport state.
281+
282+
/// Find the link closest to the given screen-space position.
283+
///
284+
/// Returns the link ID, or -1 if no link is within `hover_distance`.
285+
/// Internally converts world-space cache data to screen space for accurate
286+
/// bezier hit testing that matches the rendered curves.
287+
pub fn find_link_at_screen(
288+
&self,
289+
mouse_x: f32,
290+
mouse_y: f32,
291+
hover_distance: f32,
292+
bezier_min_offset: f32,
293+
hit_samples: usize,
294+
) -> i32 {
295+
let zoom = *self.zoom.borrow();
296+
let pan_x = *self.pan_x.borrow();
297+
let pan_y = *self.pan_y.borrow();
298+
let cache = self.cache.borrow();
299+
let links = self.links.borrow();
300+
301+
let link_geometries: Vec<SimpleLinkGeometry> = links
302+
.iter()
303+
.filter_map(|&(id, start_pin, end_pin)| {
304+
let start_pos = cache.pin_positions.get(&start_pin)?;
305+
let end_pos = cache.pin_positions.get(&end_pin)?;
306+
let start_rect = cache.node_rects.get(&start_pos.node_id)?.rect();
307+
let end_rect = cache.node_rects.get(&end_pos.node_id)?.rect();
308+
309+
// World→screen: node_world * zoom + pan + pin_rel (already screen-scaled)
310+
Some(SimpleLinkGeometry {
311+
id,
312+
start_x: start_rect.0 * zoom + pan_x + start_pos.rel_x,
313+
start_y: start_rect.1 * zoom + pan_y + start_pos.rel_y,
314+
end_x: end_rect.0 * zoom + pan_x + end_pos.rel_x,
315+
end_y: end_rect.1 * zoom + pan_y + end_pos.rel_y,
316+
})
317+
})
318+
.collect();
319+
320+
find_link_at(
321+
mouse_x,
322+
mouse_y,
323+
link_geometries,
324+
hover_distance,
325+
zoom,
326+
bezier_min_offset,
327+
hit_samples,
328+
)
329+
}
330+
331+
/// Find the pin closest to the given screen-space position.
332+
///
333+
/// Returns the pin ID, or 0 if no pin is within `hit_radius`.
334+
pub fn find_pin_at_screen(&self, mouse_x: f32, mouse_y: f32, hit_radius: f32) -> i32 {
335+
let zoom = *self.zoom.borrow();
336+
let pan_x = *self.pan_x.borrow();
337+
let pan_y = *self.pan_y.borrow();
338+
let cache = self.cache.borrow();
339+
340+
let pins = cache.pin_positions.iter().filter_map(|(&pin_id, pin)| {
341+
let rect = cache.node_rects.get(&pin.node_id)?.rect();
342+
// World→screen
343+
let sx = rect.0 * zoom + pan_x + pin.rel_x;
344+
let sy = rect.1 * zoom + pan_y + pin.rel_y;
345+
Some(crate::hit_test::SimplePinGeometry {
346+
id: pin_id,
347+
x: sx,
348+
y: sy,
349+
})
350+
});
351+
352+
crate::hit_test::find_pin_at(mouse_x, mouse_y, pins, hit_radius)
353+
}
354+
355+
/// Find all nodes whose world-space rect intersects the given screen-space selection box.
356+
///
357+
/// Converts the selection box from screen→world and performs AABB intersection.
358+
pub fn nodes_in_selection_box_screen(
359+
&self,
360+
sx: f32,
361+
sy: f32,
362+
sw: f32,
363+
sh: f32,
364+
) -> Vec<i32> {
365+
let zoom = *self.zoom.borrow();
366+
let pan_x = *self.pan_x.borrow();
367+
let pan_y = *self.pan_y.borrow();
368+
let z = if zoom > 0.0 { zoom } else { 1.0 };
369+
370+
let world_x = (sx - pan_x) / z;
371+
let world_y = (sy - pan_y) / z;
372+
let world_w = sw / z;
373+
let world_h = sh / z;
374+
375+
self.cache
376+
.borrow()
377+
.nodes_in_selection_box(world_x, world_y, world_w, world_h)
378+
}
379+
380+
/// Find all links that have at least one endpoint inside the given screen-space selection box.
381+
///
382+
/// Converts the box and link endpoints to world space for comparison.
383+
pub fn links_in_selection_box_screen(
384+
&self,
385+
sx: f32,
386+
sy: f32,
387+
sw: f32,
388+
sh: f32,
389+
) -> Vec<i32> {
390+
let zoom = *self.zoom.borrow();
391+
let pan_x = *self.pan_x.borrow();
392+
let pan_y = *self.pan_y.borrow();
393+
let z = if zoom > 0.0 { zoom } else { 1.0 };
394+
395+
let world_x = (sx - pan_x) / z;
396+
let world_y = (sy - pan_y) / z;
397+
let world_w = sw / z;
398+
let world_h = sh / z;
399+
400+
let cache = self.cache.borrow();
401+
let links = self.links.borrow();
402+
403+
// Compute world-space link endpoints: node_world + pin_rel/zoom
404+
let link_geometries: Vec<SimpleLinkGeometry> = links
405+
.iter()
406+
.filter_map(|&(id, start_pin, end_pin)| {
407+
let start_pos = cache.pin_positions.get(&start_pin)?;
408+
let end_pos = cache.pin_positions.get(&end_pin)?;
409+
let start_rect = cache.node_rects.get(&start_pos.node_id)?.rect();
410+
let end_rect = cache.node_rects.get(&end_pos.node_id)?.rect();
411+
412+
Some(SimpleLinkGeometry {
413+
id,
414+
start_x: start_rect.0 + start_pos.rel_x / z,
415+
start_y: start_rect.1 + start_pos.rel_y / z,
416+
end_x: end_rect.0 + end_pos.rel_x / z,
417+
end_y: end_rect.1 + end_pos.rel_y / z,
418+
})
419+
})
420+
.collect();
421+
422+
crate::hit_test::links_in_selection_box(
423+
world_x,
424+
world_y,
425+
world_w,
426+
world_h,
427+
link_geometries,
428+
)
429+
}
212430
}

0 commit comments

Comments
 (0)