Skip to main content

cranpose_render_common/
graph_scene.rs

1use std::cell::RefCell;
2use std::cmp::Reverse;
3use std::collections::HashMap;
4use std::rc::Rc;
5
6use cranpose_core::NodeId;
7use cranpose_foundation::{PointerEvent, PointerEventKind};
8use cranpose_ui_graphics::{Point, Rect, RoundedCornerShape};
9
10use crate::graph::{ProjectiveTransform, RenderGraph};
11use crate::{HitTestTarget, RenderScene};
12
13#[derive(Clone)]
14pub enum ClickAction {
15    Simple(Rc<RefCell<dyn FnMut()>>),
16    WithPoint(Rc<dyn Fn(Point)>),
17}
18
19impl ClickAction {
20    fn invoke(&self, local_position: Point) {
21        match self {
22            ClickAction::Simple(handler) => (handler.borrow_mut())(),
23            ClickAction::WithPoint(handler) => handler(local_position),
24        }
25    }
26}
27
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct HitClip {
30    pub quad: [[f32; 2]; 4],
31    pub bounds: Rect,
32}
33
34#[derive(Clone)]
35pub struct HitGeometry {
36    pub rect: Rect,
37    pub quad: [[f32; 2]; 4],
38    pub local_bounds: Rect,
39    pub world_to_local: ProjectiveTransform,
40    pub hit_clip_bounds: Option<Rect>,
41    pub hit_clips: Vec<HitClip>,
42}
43
44#[derive(Clone)]
45pub struct HitRegion {
46    pub node_id: NodeId,
47    pub rect: Rect,
48    pub quad: [[f32; 2]; 4],
49    pub local_bounds: Rect,
50    pub world_to_local: ProjectiveTransform,
51    pub shape: Option<RoundedCornerShape>,
52    pub click_actions: Vec<ClickAction>,
53    pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
54    pub z_index: usize,
55    pub hit_clip_bounds: Option<Rect>,
56    pub hit_clips: Vec<HitClip>,
57}
58
59impl HitRegion {
60    fn contains(&self, x: f32, y: f32) -> bool {
61        if !self.rect.contains(x, y) {
62            return false;
63        }
64
65        if let Some(clip_bounds) = self.hit_clip_bounds {
66            if !clip_bounds.contains(x, y) {
67                return false;
68            }
69        }
70
71        let point = Point { x, y };
72        if !point_in_quad(point, self.quad) {
73            return false;
74        }
75
76        for clip in &self.hit_clips {
77            if !point_in_quad(point, clip.quad) {
78                return false;
79            }
80        }
81
82        let local_point = self.world_to_local.map_point(point);
83        if let Some(shape) = self.shape {
84            point_in_rounded_rect(local_point, self.local_bounds, shape)
85        } else {
86            self.local_bounds.contains(local_point.x, local_point.y)
87        }
88    }
89}
90
91impl HitTestTarget for HitRegion {
92    fn node_id(&self) -> NodeId {
93        self.node_id
94    }
95
96    fn dispatch(&self, event: PointerEvent) {
97        if event.is_consumed() {
98            return;
99        }
100
101        let x = event.global_position.x;
102        let y = event.global_position.y;
103        let kind = event.kind;
104        let local = self.world_to_local.map_point(Point { x, y });
105        let local_position = Point {
106            x: local.x - self.local_bounds.x,
107            y: local.y - self.local_bounds.y,
108        };
109        let local_event = event.copy_with_local_position(local_position);
110        for handler in &self.pointer_inputs {
111            if local_event.is_consumed() {
112                break;
113            }
114            handler(local_event.clone());
115        }
116
117        if kind == PointerEventKind::Down && !local_event.is_consumed() {
118            for action in &self.click_actions {
119                action.invoke(local_position);
120            }
121        }
122    }
123}
124
125pub struct Scene {
126    pub graph: Option<RenderGraph>,
127    pub hits: Vec<HitRegion>,
128    pub next_hit_z: usize,
129    pub node_index: HashMap<NodeId, usize>,
130}
131
132impl Scene {
133    pub fn new() -> Self {
134        Self {
135            graph: None,
136            hits: Vec::new(),
137            next_hit_z: 0,
138            node_index: HashMap::new(),
139        }
140    }
141
142    pub fn push_hit(
143        &mut self,
144        node_id: NodeId,
145        geometry: HitGeometry,
146        shape: Option<RoundedCornerShape>,
147        click_actions: Vec<ClickAction>,
148        pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
149    ) {
150        if click_actions.is_empty() && pointer_inputs.is_empty() {
151            return;
152        }
153
154        let z_index = self.next_hit_z;
155        self.next_hit_z += 1;
156        let hit_index = self.hits.len();
157        let HitGeometry {
158            rect,
159            quad,
160            local_bounds,
161            world_to_local,
162            hit_clip_bounds,
163            hit_clips,
164        } = geometry;
165        self.hits.push(HitRegion {
166            node_id,
167            rect,
168            quad,
169            local_bounds,
170            world_to_local,
171            shape,
172            click_actions,
173            pointer_inputs,
174            z_index,
175            hit_clip_bounds,
176            hit_clips,
177        });
178        self.node_index.insert(node_id, hit_index);
179    }
180}
181
182impl Default for Scene {
183    fn default() -> Self {
184        Self::new()
185    }
186}
187
188impl RenderScene for Scene {
189    type HitTarget = HitRegion;
190
191    fn clear(&mut self) {
192        self.graph = None;
193        self.hits.clear();
194        self.node_index.clear();
195        self.next_hit_z = 0;
196    }
197
198    fn hit_test(&self, x: f32, y: f32) -> Vec<Self::HitTarget> {
199        let mut hit_indices: Vec<usize> = self
200            .hits
201            .iter()
202            .enumerate()
203            .filter_map(|(index, hit)| hit.contains(x, y).then_some(index))
204            .collect();
205
206        hit_indices.sort_by_key(|&index| Reverse(self.hits[index].z_index));
207        hit_indices
208            .into_iter()
209            .map(|index| self.hits[index].clone())
210            .collect()
211    }
212
213    fn find_target(&self, node_id: NodeId) -> Option<Self::HitTarget> {
214        self.node_index
215            .get(&node_id)
216            .and_then(|&index| self.hits.get(index))
217            .cloned()
218    }
219}
220
221fn point_in_rounded_rect(point: Point, rect: Rect, shape: RoundedCornerShape) -> bool {
222    if !rect.contains(point.x, point.y) {
223        return false;
224    }
225
226    let local_x = point.x - rect.x;
227    let local_y = point.y - rect.y;
228    let radii = shape.resolve(rect.width, rect.height);
229    let tl = radii.top_left;
230    let tr = radii.top_right;
231    let bl = radii.bottom_left;
232    let br = radii.bottom_right;
233
234    if local_x < tl && local_y < tl {
235        let dx = tl - local_x;
236        let dy = tl - local_y;
237        return dx * dx + dy * dy <= tl * tl;
238    }
239
240    if local_x > rect.width - tr && local_y < tr {
241        let dx = local_x - (rect.width - tr);
242        let dy = tr - local_y;
243        return dx * dx + dy * dy <= tr * tr;
244    }
245
246    if local_x < bl && local_y > rect.height - bl {
247        let dx = bl - local_x;
248        let dy = local_y - (rect.height - bl);
249        return dx * dx + dy * dy <= bl * bl;
250    }
251
252    if local_x > rect.width - br && local_y > rect.height - br {
253        let dx = local_x - (rect.width - br);
254        let dy = local_y - (rect.height - br);
255        return dx * dx + dy * dy <= br * br;
256    }
257
258    true
259}
260
261fn point_in_quad(point: Point, quad: [[f32; 2]; 4]) -> bool {
262    point_in_triangle(point, quad[0], quad[1], quad[3])
263        || point_in_triangle(point, quad[0], quad[3], quad[2])
264}
265
266fn point_in_triangle(point: Point, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool {
267    let d1 = triangle_sign(point, a, b);
268    let d2 = triangle_sign(point, b, c);
269    let d3 = triangle_sign(point, c, a);
270    let has_negative = d1 < -f32::EPSILON || d2 < -f32::EPSILON || d3 < -f32::EPSILON;
271    let has_positive = d1 > f32::EPSILON || d2 > f32::EPSILON || d3 > f32::EPSILON;
272    !(has_negative && has_positive)
273}
274
275fn triangle_sign(point: Point, a: [f32; 2], b: [f32; 2]) -> f32 {
276    (point.x - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (point.y - b[1])
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::cell::Cell;
283
284    fn rect_to_quad(rect: Rect) -> [[f32; 2]; 4] {
285        [
286            [rect.x, rect.y],
287            [rect.x + rect.width, rect.y],
288            [rect.x, rect.y + rect.height],
289            [rect.x + rect.width, rect.y + rect.height],
290        ]
291    }
292
293    fn translated_world_to_local(rect: Rect) -> ProjectiveTransform {
294        ProjectiveTransform::translation(-rect.x, -rect.y)
295    }
296
297    fn local_bounds_for_rect(rect: Rect) -> Rect {
298        Rect {
299            x: 0.0,
300            y: 0.0,
301            width: rect.width,
302            height: rect.height,
303        }
304    }
305
306    fn hit_geometry_for_rect(rect: Rect) -> HitGeometry {
307        HitGeometry {
308            rect,
309            quad: rect_to_quad(rect),
310            local_bounds: local_bounds_for_rect(rect),
311            world_to_local: translated_world_to_local(rect),
312            hit_clip_bounds: None,
313            hit_clips: Vec::new(),
314        }
315    }
316
317    fn make_handler(counter: Rc<Cell<u32>>, consume: bool) -> Rc<dyn Fn(PointerEvent)> {
318        Rc::new(move |event: PointerEvent| {
319            counter.set(counter.get() + 1);
320            if consume {
321                event.consume();
322            }
323        })
324    }
325
326    #[test]
327    fn hit_test_respects_hit_clip() {
328        let mut scene = Scene::new();
329        let rect = Rect {
330            x: 0.0,
331            y: 0.0,
332            width: 100.0,
333            height: 100.0,
334        };
335        let clip = Rect {
336            x: 0.0,
337            y: 0.0,
338            width: 40.0,
339            height: 40.0,
340        };
341        scene.push_hit(
342            1,
343            HitGeometry {
344                hit_clip_bounds: Some(clip),
345                hit_clips: vec![HitClip {
346                    quad: rect_to_quad(clip),
347                    bounds: clip,
348                }],
349                ..hit_geometry_for_rect(rect)
350            },
351            None,
352            Vec::new(),
353            vec![Rc::new(|_event: PointerEvent| {})],
354        );
355
356        assert!(scene.hit_test(60.0, 20.0).is_empty());
357        assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
358    }
359
360    #[test]
361    fn hit_test_sorts_by_z_without_duplicating_hit_storage() {
362        let mut scene = Scene::new();
363        let rect = Rect {
364            x: 0.0,
365            y: 0.0,
366            width: 50.0,
367            height: 50.0,
368        };
369
370        scene.push_hit(
371            1,
372            hit_geometry_for_rect(rect),
373            None,
374            Vec::new(),
375            vec![Rc::new(|_event: PointerEvent| {})],
376        );
377        scene.push_hit(
378            2,
379            hit_geometry_for_rect(rect),
380            None,
381            Vec::new(),
382            vec![Rc::new(|_event: PointerEvent| {})],
383        );
384
385        assert_eq!(scene.node_index.get(&1), Some(&0));
386        assert_eq!(scene.node_index.get(&2), Some(&1));
387
388        let hits = scene.hit_test(10.0, 10.0);
389        assert_eq!(
390            hits.iter().map(|hit| hit.node_id).collect::<Vec<_>>(),
391            vec![2, 1]
392        );
393        assert_eq!(scene.find_target(1).map(|hit| hit.node_id), Some(1));
394        assert_eq!(scene.find_target(2).map(|hit| hit.node_id), Some(2));
395    }
396
397    #[test]
398    fn hit_test_rejects_points_in_rounded_corner_cutout() {
399        let mut scene = Scene::new();
400        let rect = Rect {
401            x: 0.0,
402            y: 0.0,
403            width: 40.0,
404            height: 40.0,
405        };
406        scene.push_hit(
407            1,
408            hit_geometry_for_rect(rect),
409            Some(RoundedCornerShape::uniform(20.0)),
410            Vec::new(),
411            vec![Rc::new(|_event: PointerEvent| {})],
412        );
413
414        assert!(scene.hit_test(1.0, 1.0).is_empty());
415        assert_eq!(scene.hit_test(20.0, 20.0).len(), 1);
416    }
417
418    #[test]
419    fn dispatch_stops_after_event_consumed() {
420        let count_first = Rc::new(Cell::new(0));
421        let count_second = Rc::new(Cell::new(0));
422
423        let hit = HitRegion {
424            node_id: 1,
425            rect: Rect {
426                x: 0.0,
427                y: 0.0,
428                width: 50.0,
429                height: 50.0,
430            },
431            quad: rect_to_quad(Rect {
432                x: 0.0,
433                y: 0.0,
434                width: 50.0,
435                height: 50.0,
436            }),
437            local_bounds: Rect {
438                x: 0.0,
439                y: 0.0,
440                width: 50.0,
441                height: 50.0,
442            },
443            world_to_local: ProjectiveTransform::identity(),
444            shape: None,
445            click_actions: Vec::new(),
446            pointer_inputs: vec![
447                make_handler(count_first.clone(), true),
448                make_handler(count_second.clone(), false),
449            ],
450            z_index: 0,
451            hit_clip_bounds: None,
452            hit_clips: Vec::new(),
453        };
454
455        let event = PointerEvent::new(
456            PointerEventKind::Down,
457            Point { x: 10.0, y: 10.0 },
458            Point { x: 10.0, y: 10.0 },
459        );
460        hit.dispatch(event);
461
462        assert_eq!(count_first.get(), 1);
463        assert_eq!(count_second.get(), 0);
464    }
465
466    #[test]
467    fn dispatch_triggers_click_action_on_down() {
468        let click_count = Rc::new(Cell::new(0));
469        let click_count_for_handler = Rc::clone(&click_count);
470        let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
471            click_count_for_handler.set(click_count_for_handler.get() + 1);
472        })));
473
474        let hit = HitRegion {
475            node_id: 1,
476            rect: Rect {
477                x: 0.0,
478                y: 0.0,
479                width: 50.0,
480                height: 50.0,
481            },
482            quad: rect_to_quad(Rect {
483                x: 0.0,
484                y: 0.0,
485                width: 50.0,
486                height: 50.0,
487            }),
488            local_bounds: Rect {
489                x: 0.0,
490                y: 0.0,
491                width: 50.0,
492                height: 50.0,
493            },
494            world_to_local: ProjectiveTransform::identity(),
495            shape: None,
496            click_actions: vec![click_action],
497            pointer_inputs: Vec::new(),
498            z_index: 0,
499            hit_clip_bounds: None,
500            hit_clips: Vec::new(),
501        };
502
503        hit.dispatch(PointerEvent::new(
504            PointerEventKind::Down,
505            Point { x: 10.0, y: 10.0 },
506            Point { x: 10.0, y: 10.0 },
507        ));
508        hit.dispatch(PointerEvent::new(
509            PointerEventKind::Move,
510            Point { x: 10.0, y: 10.0 },
511            Point { x: 12.0, y: 12.0 },
512        ));
513
514        assert_eq!(click_count.get(), 1);
515    }
516
517    #[test]
518    fn dispatch_passes_local_position_to_click_action() {
519        let local_positions = Rc::new(RefCell::new(Vec::new()));
520        let local_positions_for_handler = Rc::clone(&local_positions);
521        let click_action = ClickAction::WithPoint(Rc::new(move |point| {
522            local_positions_for_handler.borrow_mut().push(point);
523        }));
524
525        let hit = HitRegion {
526            node_id: 1,
527            rect: Rect {
528                x: 10.0,
529                y: 12.0,
530                width: 50.0,
531                height: 50.0,
532            },
533            quad: rect_to_quad(Rect {
534                x: 10.0,
535                y: 12.0,
536                width: 50.0,
537                height: 50.0,
538            }),
539            local_bounds: Rect {
540                x: 0.0,
541                y: 0.0,
542                width: 50.0,
543                height: 50.0,
544            },
545            world_to_local: ProjectiveTransform::translation(-10.0, -12.0),
546            shape: None,
547            click_actions: vec![click_action],
548            pointer_inputs: Vec::new(),
549            z_index: 0,
550            hit_clip_bounds: None,
551            hit_clips: Vec::new(),
552        };
553
554        hit.dispatch(PointerEvent::new(
555            PointerEventKind::Down,
556            Point { x: 15.0, y: 17.0 },
557            Point { x: 15.0, y: 17.0 },
558        ));
559
560        assert_eq!(*local_positions.borrow(), vec![Point { x: 5.0, y: 5.0 }]);
561    }
562
563    #[test]
564    fn dispatch_does_not_trigger_click_action_when_consumed() {
565        let click_count = Rc::new(Cell::new(0));
566        let click_count_for_handler = Rc::clone(&click_count);
567        let click_action = ClickAction::Simple(Rc::new(RefCell::new(move || {
568            click_count_for_handler.set(click_count_for_handler.get() + 1);
569        })));
570
571        let hit = HitRegion {
572            node_id: 1,
573            rect: Rect {
574                x: 0.0,
575                y: 0.0,
576                width: 50.0,
577                height: 50.0,
578            },
579            quad: rect_to_quad(Rect {
580                x: 0.0,
581                y: 0.0,
582                width: 50.0,
583                height: 50.0,
584            }),
585            local_bounds: Rect {
586                x: 0.0,
587                y: 0.0,
588                width: 50.0,
589                height: 50.0,
590            },
591            world_to_local: ProjectiveTransform::identity(),
592            shape: None,
593            click_actions: vec![click_action],
594            pointer_inputs: vec![Rc::new(|event: PointerEvent| event.consume())],
595            z_index: 0,
596            hit_clip_bounds: None,
597            hit_clips: Vec::new(),
598        };
599
600        hit.dispatch(PointerEvent::new(
601            PointerEventKind::Down,
602            Point { x: 10.0, y: 10.0 },
603            Point { x: 10.0, y: 10.0 },
604        ));
605
606        assert_eq!(click_count.get(), 0);
607    }
608
609    #[test]
610    fn hit_test_uses_exact_quad_for_transformed_region() {
611        let mut scene = Scene::new();
612        let rect = Rect {
613            x: 0.0,
614            y: 0.0,
615            width: 40.0,
616            height: 20.0,
617        };
618        let quad = [[10.0, 10.0], [50.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
619        let world_to_local = ProjectiveTransform::from_rect_to_quad(rect, quad)
620            .inverse()
621            .expect("transformed hit region should be invertible");
622        scene.push_hit(
623            1,
624            HitGeometry {
625                rect: Rect {
626                    x: 10.0,
627                    y: 10.0,
628                    width: 50.0,
629                    height: 20.0,
630                },
631                quad,
632                local_bounds: rect,
633                world_to_local,
634                hit_clip_bounds: None,
635                hit_clips: Vec::new(),
636            },
637            None,
638            Vec::new(),
639            vec![Rc::new(|_event: PointerEvent| {})],
640        );
641
642        assert!(
643            scene.hit_test(15.0, 28.0).is_empty(),
644            "point inside the quad bounds but outside the transformed quad must not hit"
645        );
646        assert_eq!(scene.hit_test(30.0, 20.0).len(), 1);
647    }
648
649    #[test]
650    fn dispatch_uses_inverse_transform_for_local_position() {
651        let local_positions = Rc::new(RefCell::new(Vec::new()));
652        let local_positions_for_handler = Rc::clone(&local_positions);
653        let click_action = ClickAction::WithPoint(Rc::new(move |point| {
654            local_positions_for_handler.borrow_mut().push(point);
655        }));
656        let local_bounds = Rect {
657            x: 0.0,
658            y: 0.0,
659            width: 20.0,
660            height: 10.0,
661        };
662        let quad = [[20.0, 10.0], [60.0, 10.0], [20.0, 30.0], [60.0, 30.0]];
663        let world_to_local = ProjectiveTransform::from_rect_to_quad(local_bounds, quad)
664            .inverse()
665            .expect("translated quad should be invertible");
666        let hit = HitRegion {
667            node_id: 1,
668            rect: Rect {
669                x: 20.0,
670                y: 10.0,
671                width: 40.0,
672                height: 20.0,
673            },
674            quad,
675            local_bounds,
676            world_to_local,
677            shape: None,
678            click_actions: vec![click_action],
679            pointer_inputs: Vec::new(),
680            z_index: 0,
681            hit_clip_bounds: None,
682            hit_clips: Vec::new(),
683        };
684
685        hit.dispatch(PointerEvent::new(
686            PointerEventKind::Down,
687            Point { x: 25.0, y: 17.0 },
688            Point { x: 25.0, y: 17.0 },
689        ));
690
691        assert_eq!(*local_positions.borrow(), vec![Point { x: 2.5, y: 3.5 }]);
692    }
693}