6767//! ```
6868
6969use crate :: state:: GeometryCache ;
70+ use crate :: hit_test:: { find_link_at, NodeGeometry , SimpleLinkGeometry } ;
7071use slint:: SharedString ;
7172use std:: cell:: RefCell ;
7273use 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 ) ]
8490pub 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
92101impl 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