Skip to main content

cranpose_render_common/
graph.rs

1use std::collections::HashSet;
2use std::mem::size_of;
3use std::rc::Rc;
4
5use cranpose_core::NodeId;
6use cranpose_foundation::PointerEvent;
7use cranpose_ui::text::AnnotatedString;
8use cranpose_ui::{
9    GraphicsLayer, Point, Rect, RenderEffect, RoundedCornerShape, TextLayoutOptions, TextStyle,
10};
11use cranpose_ui_graphics::{BlendMode, ColorFilter, DrawPrimitive, ShadowPrimitive};
12
13use crate::raster_cache::LayerRasterCacheHashes;
14use crate::style_shared::DrawPlacement;
15
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct ProjectiveTransform {
18    matrix: [[f32; 3]; 3],
19}
20
21impl ProjectiveTransform {
22    pub const fn identity() -> Self {
23        Self {
24            matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
25        }
26    }
27
28    pub fn translation(tx: f32, ty: f32) -> Self {
29        Self {
30            matrix: [[1.0, 0.0, tx], [0.0, 1.0, ty], [0.0, 0.0, 1.0]],
31        }
32    }
33
34    /// Uniform scale about the origin (device-scale root transform: render
35    /// graphs stay in logical dp; density applies at execution).
36    pub fn uniform_scale(scale: f32) -> Self {
37        Self {
38            matrix: [[scale, 0.0, 0.0], [0.0, scale, 0.0], [0.0, 0.0, 1.0]],
39        }
40    }
41
42    pub fn from_rect_to_quad(rect: Rect, quad: [[f32; 2]; 4]) -> Self {
43        if rect.width.abs() <= f32::EPSILON || rect.height.abs() <= f32::EPSILON {
44            return Self::translation(quad[0][0], quad[0][1]);
45        }
46
47        if let Some(axis_aligned) = axis_aligned_rect_from_quad(quad) {
48            let scale_x = axis_aligned.width / rect.width;
49            let scale_y = axis_aligned.height / rect.height;
50            return Self {
51                matrix: [
52                    [scale_x, 0.0, axis_aligned.x - rect.x * scale_x],
53                    [0.0, scale_y, axis_aligned.y - rect.y * scale_y],
54                    [0.0, 0.0, 1.0],
55                ],
56            };
57        }
58
59        let source = [
60            [rect.x, rect.y],
61            [rect.x + rect.width, rect.y],
62            [rect.x, rect.y + rect.height],
63            [rect.x + rect.width, rect.y + rect.height],
64        ];
65        let Some(coefficients) = solve_homography(source, quad) else {
66            return Self::identity();
67        };
68
69        Self {
70            matrix: [
71                [coefficients[0], coefficients[1], coefficients[2]],
72                [coefficients[3], coefficients[4], coefficients[5]],
73                [coefficients[6], coefficients[7], 1.0],
74            ],
75        }
76    }
77
78    /// Returns the composed transform that applies `self` first and `next` second.
79    pub fn then(self, next: Self) -> Self {
80        Self {
81            matrix: multiply_matrices(next.matrix, self.matrix),
82        }
83    }
84
85    pub fn inverse(self) -> Option<Self> {
86        let m = self.matrix;
87        let a = m[0][0];
88        let b = m[0][1];
89        let c = m[0][2];
90        let d = m[1][0];
91        let e = m[1][1];
92        let f = m[1][2];
93        let g = m[2][0];
94        let h = m[2][1];
95        let i = m[2][2];
96
97        let cofactor00 = e * i - f * h;
98        let cofactor01 = -(d * i - f * g);
99        let cofactor02 = d * h - e * g;
100        let cofactor10 = -(b * i - c * h);
101        let cofactor11 = a * i - c * g;
102        let cofactor12 = -(a * h - b * g);
103        let cofactor20 = b * f - c * e;
104        let cofactor21 = -(a * f - c * d);
105        let cofactor22 = a * e - b * d;
106
107        let determinant = a * cofactor00 + b * cofactor01 + c * cofactor02;
108        if determinant.abs() <= f32::EPSILON {
109            return None;
110        }
111        let inverse_determinant = 1.0 / determinant;
112
113        Some(Self {
114            matrix: [
115                [
116                    cofactor00 * inverse_determinant,
117                    cofactor10 * inverse_determinant,
118                    cofactor20 * inverse_determinant,
119                ],
120                [
121                    cofactor01 * inverse_determinant,
122                    cofactor11 * inverse_determinant,
123                    cofactor21 * inverse_determinant,
124                ],
125                [
126                    cofactor02 * inverse_determinant,
127                    cofactor12 * inverse_determinant,
128                    cofactor22 * inverse_determinant,
129                ],
130            ],
131        })
132    }
133
134    pub fn matrix(self) -> [[f32; 3]; 3] {
135        self.matrix
136    }
137
138    pub fn map_point(self, point: Point) -> Point {
139        let x = point.x;
140        let y = point.y;
141        let w = self.matrix[2][0] * x + self.matrix[2][1] * y + self.matrix[2][2];
142        let safe_w = if w.abs() <= f32::EPSILON { 1.0 } else { w };
143
144        Point {
145            x: (self.matrix[0][0] * x + self.matrix[0][1] * y + self.matrix[0][2]) / safe_w,
146            y: (self.matrix[1][0] * x + self.matrix[1][1] * y + self.matrix[1][2]) / safe_w,
147        }
148    }
149
150    pub fn map_rect(self, rect: Rect) -> [[f32; 2]; 4] {
151        [
152            self.map_point(Point {
153                x: rect.x,
154                y: rect.y,
155            }),
156            self.map_point(Point {
157                x: rect.x + rect.width,
158                y: rect.y,
159            }),
160            self.map_point(Point {
161                x: rect.x,
162                y: rect.y + rect.height,
163            }),
164            self.map_point(Point {
165                x: rect.x + rect.width,
166                y: rect.y + rect.height,
167            }),
168        ]
169        .map(|point| [point.x, point.y])
170    }
171
172    pub fn bounds_for_rect(self, rect: Rect) -> Rect {
173        quad_bounds(self.map_rect(rect))
174    }
175}
176
177fn axis_aligned_rect_from_quad(quad: [[f32; 2]; 4]) -> Option<Rect> {
178    let top_left = quad[0];
179    let top_right = quad[1];
180    let bottom_left = quad[2];
181    let bottom_right = quad[3];
182    let x_epsilon = 1e-4;
183    let y_epsilon = 1e-4;
184
185    if (top_left[1] - top_right[1]).abs() > y_epsilon
186        || (bottom_left[1] - bottom_right[1]).abs() > y_epsilon
187        || (top_left[0] - bottom_left[0]).abs() > x_epsilon
188        || (top_right[0] - bottom_right[0]).abs() > x_epsilon
189    {
190        return None;
191    }
192
193    Some(Rect {
194        x: top_left[0],
195        y: top_left[1],
196        width: top_right[0] - top_left[0],
197        height: bottom_left[1] - top_left[1],
198    })
199}
200
201impl Default for ProjectiveTransform {
202    fn default() -> Self {
203        Self::identity()
204    }
205}
206
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
208pub struct IsolationReasons {
209    pub explicit_offscreen: bool,
210    pub shape_clip: bool,
211    pub effect: bool,
212    pub backdrop: bool,
213    pub group_opacity: bool,
214    pub blend_mode: bool,
215}
216
217impl IsolationReasons {
218    pub fn has_any(self) -> bool {
219        self.explicit_offscreen
220            || self.shape_clip
221            || self.effect
222            || self.backdrop
223            || self.group_opacity
224            || self.blend_mode
225    }
226}
227
228#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
229pub enum CachePolicy {
230    #[default]
231    None,
232    Auto,
233}
234
235#[derive(Clone)]
236pub struct HitTestNode {
237    pub shape: Option<RoundedCornerShape>,
238    pub click_actions: Vec<Rc<dyn Fn(Point)>>,
239    pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
240    pub clip: Option<Rect>,
241}
242
243#[derive(Clone, Debug, PartialEq)]
244pub struct DrawPrimitiveNode {
245    pub primitive: DrawPrimitive,
246    pub clip: Option<Rect>,
247}
248
249#[derive(Clone, Debug, PartialEq)]
250pub struct TextPrimitiveNode {
251    pub node_id: NodeId,
252    pub rect: Rect,
253    /// Shared so the renderer can hand the same allocation to every draw it
254    /// emits for this node instead of deep-copying the string once per emit.
255    pub text: Rc<AnnotatedString>,
256    pub text_style: TextStyle,
257    pub font_size: f32,
258    pub layout_options: TextLayoutOptions,
259    pub clip: Option<Rect>,
260}
261
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum PrimitivePhase {
264    BeforeChildren,
265    AfterChildren,
266}
267
268#[derive(Clone, Debug, PartialEq)]
269pub enum PrimitiveNode {
270    Draw(DrawPrimitiveNode),
271    Text(Box<TextPrimitiveNode>),
272}
273
274#[derive(Clone, Debug, PartialEq)]
275pub struct PrimitiveEntry {
276    pub phase: PrimitivePhase,
277    pub node: PrimitiveNode,
278}
279
280#[derive(Clone)]
281pub struct LayerNode {
282    pub node_id: Option<NodeId>,
283    pub local_bounds: Rect,
284    pub transform_to_parent: ProjectiveTransform,
285    pub content_offset: Point,
286    pub motion_context_animated: bool,
287    pub translated_content_context: bool,
288    pub translated_content_offset: Point,
289    /// Window-space origin this layer's children are placed from, and the
290    /// accumulated ancestor graphics-layer translation, captured during the
291    /// full per-frame scene build. Read back when a dirty subtree is rebuilt in
292    /// isolation (`update_scene_from_applier`) so a scrolling field's window
293    /// origin stays live during a fling even though the ancestor chain is not
294    /// re-walked from the root. Defaults to the identity origin.
295    pub scene_children_origin: Point,
296    pub scene_children_layer_translation: Point,
297    pub graphics_layer: GraphicsLayer,
298    pub clip_to_bounds: bool,
299    pub shadow_clip: Option<Rect>,
300    pub hit_test: Option<HitTestNode>,
301    pub has_hit_targets: bool,
302    pub isolation: IsolationReasons,
303    pub cache_policy: CachePolicy,
304    pub cache_hashes: LayerRasterCacheHashes,
305    pub cache_hashes_valid: bool,
306    pub children: Vec<RenderNode>,
307}
308
309impl LayerNode {
310    pub fn clip_rect(&self) -> Option<Rect> {
311        (self.clip_to_bounds || self.graphics_layer.clip).then_some(self.local_bounds)
312    }
313
314    pub fn effect(&self) -> Option<&RenderEffect> {
315        self.graphics_layer.render_effect.as_ref()
316    }
317
318    pub fn backdrop(&self) -> Option<&RenderEffect> {
319        self.graphics_layer.backdrop_effect.as_ref()
320    }
321
322    pub fn opacity(&self) -> f32 {
323        self.graphics_layer.alpha
324    }
325
326    pub fn blend_mode(&self) -> BlendMode {
327        self.graphics_layer.blend_mode
328    }
329
330    pub fn color_filter(&self) -> Option<ColorFilter> {
331        self.graphics_layer.color_filter
332    }
333
334    pub fn target_content_hash(&self) -> u64 {
335        if self.cache_hashes_valid {
336            self.cache_hashes.target_content
337        } else {
338            crate::graph_hash::layer_raster_cache_hashes(self).target_content
339        }
340    }
341
342    pub fn motion_source_content_hash(&self) -> u64 {
343        crate::graph_hash::layer_motion_source_content_hash(self)
344    }
345
346    pub fn effect_hash(&self) -> u64 {
347        if self.cache_hashes_valid {
348            self.cache_hashes.effect
349        } else {
350            crate::graph_hash::layer_raster_cache_hashes(self).effect
351        }
352    }
353
354    pub fn recompute_raster_cache_hashes(&mut self) {
355        crate::graph_hash::recompute_layer_raster_cache_hashes(self);
356    }
357}
358
359#[derive(Clone)]
360pub enum RenderNode {
361    Primitive(PrimitiveEntry),
362    /// A whole draw command's primitives as one node. A heavy canvas records
363    /// thousands of primitives per frame; wrapping each in its own
364    /// [`RenderNode`] made the graph rebuild move every one of them twice and
365    /// free seventeen thousand nodes per frame on a stress scene. The run
366    /// keeps the recorded vector intact — semantically it is exactly that
367    /// many consecutive `Primitive` draw entries with no per-primitive clip.
368    DrawRun(DrawRunNode),
369    Layer(Box<LayerNode>),
370}
371
372/// Stable identity of the draw command a run was recorded from: the layout
373/// node owning the command, the command's index in that node's command list,
374/// and which placement pass produced this run (a `WithContent` command emits
375/// one run per placement, so the pair alone is not unique). Rendering does
376/// not read it yet; it is the key under which retained recording state lives
377/// as retention moves up to the draw-command recorder, and it must survive
378/// recording, graph construction, normalized-scene creation, and renderer
379/// cache lookup unchanged.
380#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
381pub struct DrawCommandId {
382    pub node_id: NodeId,
383    pub command_index: u32,
384    pub placement: DrawPlacement,
385}
386
387#[derive(Clone, Debug, PartialEq)]
388pub struct DrawRunNode {
389    pub phase: PrimitivePhase,
390    /// Which draw command recorded these primitives. `None` only for runs
391    /// with no per-command provenance (hand-built tests).
392    pub command: Option<DrawCommandId>,
393    /// Shared, not owned: the recording registry keyed by [`DrawCommandId`]
394    /// keeps a handle to the same buffer, so its capacity survives this
395    /// node being dropped on the next rebuild and the command re-records
396    /// into it instead of growing a fresh vector. Nothing mutates a run's
397    /// primitives after construction, which is what makes sharing sound.
398    pub primitives: std::rc::Rc<Vec<DrawPrimitive>>,
399    /// Content facts consumers keep asking per frame, answered once at
400    /// construction. Surface planning used to rescan every primitive of
401    /// every run per frame to learn "does it contain text?" — for a
402    /// 17k-primitive game canvas with no text, that was two full walks per
403    /// frame that could never early-exit.
404    pub summary: DrawRunSummary,
405    /// The command's verified retained/dynamic span structure for the frame
406    /// this node was built, in primitive space. A renderer that retains by
407    /// identity draws the run span by span — retained spans from slots
408    /// keyed (command, slot), dynamic spans from `primitives` — instead of
409    /// walking the whole vector. `None` means no verification ran or
410    /// nothing was retained: the run is all ordinary primitives.
411    pub replay: Option<Box<cranpose_ui_graphics::CommandReplayFrame>>,
412}
413
414impl DrawRunNode {
415    pub fn new(phase: PrimitivePhase, primitives: Vec<DrawPrimitive>) -> Self {
416        Self::for_command(phase, None, primitives)
417    }
418
419    pub fn for_command(
420        phase: PrimitivePhase,
421        command: Option<DrawCommandId>,
422        primitives: Vec<DrawPrimitive>,
423    ) -> Self {
424        Self::for_command_shared(phase, command, std::rc::Rc::new(primitives))
425    }
426
427    pub fn for_command_shared(
428        phase: PrimitivePhase,
429        command: Option<DrawCommandId>,
430        primitives: std::rc::Rc<Vec<DrawPrimitive>>,
431    ) -> Self {
432        Self::for_command_replayed(phase, command, primitives, None)
433    }
434
435    pub fn for_command_replayed(
436        phase: PrimitivePhase,
437        command: Option<DrawCommandId>,
438        primitives: std::rc::Rc<Vec<DrawPrimitive>>,
439        replay: Option<Box<cranpose_ui_graphics::CommandReplayFrame>>,
440    ) -> Self {
441        // Fail-closed invariant: a bypassed span has NO primitives — the
442        // frame's own `fallback` recording is the only thing that can ever
443        // draw it, so a frame carrying bypassed spans without one is
444        // unconstructible here. The builder attaches the published handle
445        // to every frame; hand-built frames that bypass must do the same.
446        debug_assert!(
447            replay.as_ref().is_none_or(|frame| {
448                frame.fallback.is_some()
449                    || !frame.spans.iter().any(|span| {
450                        matches!(
451                            span,
452                            cranpose_ui_graphics::FrameSpan::Retained {
453                                capture: false,
454                                range,
455                                ..
456                            } if range.1 <= range.0
457                        )
458                    })
459            }),
460            "a replay frame with bypassed spans must own its fallback recording"
461        );
462        let mut summary = DrawRunSummary::scan(&primitives);
463        // Bypassed retained spans have no primitives to scan, but they are
464        // drawable content — only shape records ever retain, never shadows.
465        if replay.as_ref().is_some_and(|frame| {
466            frame
467                .spans
468                .iter()
469                .any(|span| matches!(span, cranpose_ui_graphics::FrameSpan::Retained { .. }))
470        }) {
471            summary.has_non_shadow = true;
472        }
473        Self {
474            phase,
475            command,
476            primitives,
477            summary,
478            replay,
479        }
480    }
481}
482
483/// One-pass discriminant census of a draw run, recursing through `Blend`
484/// wrappers the same way the per-primitive predicates it replaces did.
485#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
486pub struct DrawRunSummary {
487    /// Any `Text` primitive, including inside `Blend` — glyph masks want
488    /// rigid snapping.
489    pub has_text: bool,
490    pub has_shadow: bool,
491    /// Any primitive besides `Shadow` (direct drawable content).
492    pub has_non_shadow: bool,
493    /// Any `Image` or `Text`, including inside `Blend` — content that
494    /// resamples badly on a fractionally offset surface.
495    pub has_pixel_sensitive: bool,
496}
497
498impl DrawRunSummary {
499    pub fn scan(primitives: &[DrawPrimitive]) -> Self {
500        fn unwrap_blend(mut primitive: &DrawPrimitive) -> &DrawPrimitive {
501            while let DrawPrimitive::Blend {
502                primitive: inner, ..
503            } = primitive
504            {
505                primitive = inner;
506            }
507            primitive
508        }
509        let mut summary = Self::default();
510        for primitive in primitives {
511            // Shadow classification looks at the outer discriminant only,
512            // text and pixel sensitivity see through `Blend` — exactly the
513            // split the per-primitive predicates this replaces made.
514            if matches!(primitive, DrawPrimitive::Shadow(_)) {
515                summary.has_shadow = true;
516                continue;
517            }
518            summary.has_non_shadow = true;
519            match unwrap_blend(primitive) {
520                DrawPrimitive::Text(_) => {
521                    summary.has_text = true;
522                    summary.has_pixel_sensitive = true;
523                }
524                DrawPrimitive::Image { .. } => summary.has_pixel_sensitive = true,
525                _ => {}
526            }
527        }
528        summary
529    }
530}
531
532#[derive(Clone)]
533pub struct RenderGraph {
534    pub root: LayerNode,
535}
536
537impl RenderGraph {
538    pub fn new(mut root: LayerNode) -> Self {
539        root.recompute_raster_cache_hashes();
540        Self { root }
541    }
542
543    pub fn node_count(&self) -> usize {
544        fn count_layer(layer: &LayerNode) -> usize {
545            1 + layer
546                .children
547                .iter()
548                .map(|child| match child {
549                    RenderNode::Primitive(_) => 1,
550                    RenderNode::DrawRun(run) => run.primitives.len(),
551                    RenderNode::Layer(child_layer) => count_layer(child_layer),
552                })
553                .sum::<usize>()
554        }
555
556        count_layer(&self.root)
557    }
558
559    pub fn heap_bytes(&self) -> usize {
560        layer_heap_bytes(&self.root)
561    }
562
563    pub fn retained_visual_observation_nodes(&self) -> HashSet<NodeId> {
564        fn collect(layer: &LayerNode, nodes: &mut HashSet<NodeId>) {
565            if let Some(node_id) = layer.node_id {
566                nodes.insert(node_id);
567            }
568            for child in &layer.children {
569                match child {
570                    RenderNode::DrawRun(run) => {
571                        if let Some(command) = run.command {
572                            nodes.insert(command.node_id);
573                        }
574                    }
575                    RenderNode::Layer(child) => collect(child, nodes),
576                    RenderNode::Primitive(_) => {}
577                }
578            }
579        }
580
581        let mut nodes = HashSet::new();
582        collect(&self.root, &mut nodes);
583        nodes
584    }
585}
586
587fn layer_heap_bytes(layer: &LayerNode) -> usize {
588    layer.hit_test.as_ref().map_or(0, hit_test_heap_bytes)
589        + size_of::<RenderNode>() * layer.children.capacity()
590        + layer
591            .children
592            .iter()
593            .map(render_node_heap_bytes)
594            .sum::<usize>()
595}
596
597fn render_node_heap_bytes(node: &RenderNode) -> usize {
598    match node {
599        RenderNode::Primitive(entry) => primitive_entry_heap_bytes(entry),
600        RenderNode::DrawRun(run) => {
601            size_of::<DrawPrimitive>() * run.primitives.capacity()
602                + run
603                    .primitives
604                    .iter()
605                    .map(draw_primitive_heap_bytes)
606                    .sum::<usize>()
607        }
608        RenderNode::Layer(layer) => size_of::<LayerNode>() + layer_heap_bytes(layer),
609    }
610}
611
612fn primitive_entry_heap_bytes(entry: &PrimitiveEntry) -> usize {
613    match &entry.node {
614        PrimitiveNode::Draw(draw) => draw_primitive_heap_bytes(&draw.primitive),
615        PrimitiveNode::Text(text) => {
616            size_of::<TextPrimitiveNode>() + annotated_string_heap_bytes(&text.text)
617        }
618    }
619}
620
621fn draw_primitive_heap_bytes(primitive: &DrawPrimitive) -> usize {
622    match primitive {
623        DrawPrimitive::Content
624        | DrawPrimitive::Rect { .. }
625        | DrawPrimitive::RoundRect { .. }
626        | DrawPrimitive::Arc { .. } => 0,
627        DrawPrimitive::Blend { primitive, .. } => {
628            size_of::<DrawPrimitive>() + draw_primitive_heap_bytes(primitive)
629        }
630        DrawPrimitive::Image { .. } => 0,
631        DrawPrimitive::Text(text) => {
632            size_of::<cranpose_ui_graphics::TextPrimitive>()
633                + text.text.len()
634                + text
635                    .style
636                    .font_family
637                    .as_ref()
638                    .map_or(0, |family| family.capacity())
639        }
640        DrawPrimitive::Shadow(shadow) => shadow_primitive_heap_bytes(shadow),
641    }
642}
643
644fn shadow_primitive_heap_bytes(shadow: &ShadowPrimitive) -> usize {
645    match shadow {
646        ShadowPrimitive::Drop { shape, .. } => {
647            size_of::<DrawPrimitive>() + draw_primitive_heap_bytes(shape)
648        }
649        ShadowPrimitive::Inner { fill, cutout, .. } => {
650            size_of::<DrawPrimitive>() * 2
651                + draw_primitive_heap_bytes(fill)
652                + draw_primitive_heap_bytes(cutout)
653        }
654    }
655}
656
657fn annotated_string_heap_bytes(text: &AnnotatedString) -> usize {
658    text.text.capacity()
659        + text.span_styles.capacity() * size_of::<usize>() * 2
660        + text.paragraph_styles.capacity() * size_of::<usize>() * 2
661        + text.string_annotations.capacity() * size_of::<usize>() * 2
662        + text.link_annotations.capacity() * size_of::<usize>() * 2
663        + text
664            .string_annotations
665            .iter()
666            .map(|annotation| {
667                annotation.item.tag.capacity() + annotation.item.annotation.capacity()
668            })
669            .sum::<usize>()
670        + text
671            .link_annotations
672            .iter()
673            .map(|annotation| match &annotation.item {
674                cranpose_ui::text::LinkAnnotation::Url(url) => url.capacity(),
675                cranpose_ui::text::LinkAnnotation::Clickable { tag, .. } => tag.capacity(),
676            })
677            .sum::<usize>()
678}
679
680fn hit_test_heap_bytes(hit_test: &HitTestNode) -> usize {
681    hit_test.click_actions.capacity() * size_of::<Rc<dyn Fn(Point)>>()
682        + hit_test.pointer_inputs.capacity() * size_of::<Rc<dyn Fn(PointerEvent)>>()
683}
684
685pub fn quad_bounds(quad: [[f32; 2]; 4]) -> Rect {
686    let mut min_x = f32::INFINITY;
687    let mut min_y = f32::INFINITY;
688    let mut max_x = f32::NEG_INFINITY;
689    let mut max_y = f32::NEG_INFINITY;
690
691    for [x, y] in quad {
692        min_x = min_x.min(x);
693        min_y = min_y.min(y);
694        max_x = max_x.max(x);
695        max_y = max_y.max(y);
696    }
697
698    Rect {
699        x: min_x,
700        y: min_y,
701        width: (max_x - min_x).max(0.0),
702        height: (max_y - min_y).max(0.0),
703    }
704}
705
706fn multiply_matrices(lhs: [[f32; 3]; 3], rhs: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
707    let mut out = [[0.0; 3]; 3];
708    for row in 0..3 {
709        for col in 0..3 {
710            out[row][col] =
711                lhs[row][0] * rhs[0][col] + lhs[row][1] * rhs[1][col] + lhs[row][2] * rhs[2][col];
712        }
713    }
714    out
715}
716
717fn solve_homography(source: [[f32; 2]; 4], target: [[f32; 2]; 4]) -> Option<[f32; 8]> {
718    let mut matrix = [[0.0f32; 9]; 8];
719    for (index, (src, dst)) in source.into_iter().zip(target).enumerate() {
720        let row = index * 2;
721        let x = src[0];
722        let y = src[1];
723        let u = dst[0];
724        let v = dst[1];
725
726        matrix[row] = [x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y, u];
727        matrix[row + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y, v];
728    }
729
730    for pivot in 0..8 {
731        let mut pivot_row = pivot;
732        let mut pivot_value = matrix[pivot][pivot].abs();
733        let mut candidate = pivot + 1;
734        while candidate < 8 {
735            let candidate_value = matrix[candidate][pivot].abs();
736            if candidate_value > pivot_value {
737                pivot_row = candidate;
738                pivot_value = candidate_value;
739            }
740            candidate += 1;
741        }
742
743        if pivot_value <= f32::EPSILON {
744            return None;
745        }
746
747        if pivot_row != pivot {
748            matrix.swap(pivot, pivot_row);
749        }
750
751        let divisor = matrix[pivot][pivot];
752        let mut col = pivot;
753        while col < 9 {
754            matrix[pivot][col] /= divisor;
755            col += 1;
756        }
757
758        for row in 0..8 {
759            if row == pivot {
760                continue;
761            }
762            let factor = matrix[row][pivot];
763            if factor.abs() <= f32::EPSILON {
764                continue;
765            }
766            let mut col = pivot;
767            while col < 9 {
768                matrix[row][col] -= factor * matrix[pivot][col];
769                col += 1;
770            }
771        }
772    }
773
774    let mut solution = [0.0f32; 8];
775    for index in 0..8 {
776        solution[index] = matrix[index][8];
777    }
778    Some(solution)
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use crate::raster_cache::LayerRasterCacheHashes;
785    use cranpose_ui_graphics::{Brush, Color, DrawPrimitive};
786
787    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
788        LayerNode {
789            node_id: None,
790            local_bounds,
791            transform_to_parent: ProjectiveTransform::identity(),
792            content_offset: Point::default(),
793            motion_context_animated: false,
794            translated_content_context: false,
795            translated_content_offset: Point::default(),
796            scene_children_origin: Point::default(),
797            scene_children_layer_translation: Point::default(),
798            graphics_layer: GraphicsLayer::default(),
799            clip_to_bounds: false,
800            shadow_clip: None,
801            hit_test: None,
802            has_hit_targets: false,
803            isolation: IsolationReasons::default(),
804            cache_policy: CachePolicy::None,
805            cache_hashes: LayerRasterCacheHashes::default(),
806            cache_hashes_valid: false,
807            children,
808        }
809    }
810
811    #[test]
812    fn projective_transform_translation_maps_points() {
813        let transform = ProjectiveTransform::translation(7.0, -3.5);
814        let mapped = transform.map_point(Point { x: 2.0, y: 4.0 });
815        assert!((mapped.x - 9.0).abs() < 1e-6);
816        assert!((mapped.y - 0.5).abs() < 1e-6);
817    }
818
819    #[test]
820    fn projective_transform_then_composes_in_parent_order() {
821        let child = ProjectiveTransform::translation(4.0, 2.0);
822        let parent = ProjectiveTransform::translation(10.0, -1.0);
823        let composed = child.then(parent);
824        let mapped = composed.map_point(Point { x: 1.0, y: 1.0 });
825        assert!((mapped.x - 15.0).abs() < 1e-6);
826        assert!((mapped.y - 2.0).abs() < 1e-6);
827    }
828
829    #[test]
830    fn homography_maps_rect_corners_to_target_quad() {
831        let rect = Rect {
832            x: 0.0,
833            y: 0.0,
834            width: 20.0,
835            height: 10.0,
836        };
837        let quad = [[5.0, 7.0], [25.0, 6.0], [7.0, 20.0], [28.0, 21.0]];
838        let transform = ProjectiveTransform::from_rect_to_quad(rect, quad);
839        let mapped = transform.map_rect(rect);
840        for (expected, actual) in quad.into_iter().zip(mapped) {
841            assert!((expected[0] - actual[0]).abs() < 1e-4);
842            assert!((expected[1] - actual[1]).abs() < 1e-4);
843        }
844    }
845
846    #[test]
847    fn axis_aligned_rect_to_quad_keeps_exact_affine_matrix() {
848        let rect = Rect {
849            x: 2.0,
850            y: 3.0,
851            width: 20.0,
852            height: 10.0,
853        };
854        let quad = [[12.0, 9.0], [32.0, 9.0], [12.0, 19.0], [32.0, 19.0]];
855        let transform = ProjectiveTransform::from_rect_to_quad(rect, quad);
856
857        assert_eq!(
858            transform.matrix(),
859            [[1.0, 0.0, 10.0], [0.0, 1.0, 6.0], [0.0, 0.0, 1.0]]
860        );
861    }
862
863    #[test]
864    fn axis_aligned_rect_to_quad_keeps_exact_axis_aligned_scale() {
865        let rect = Rect {
866            x: 4.0,
867            y: 6.0,
868            width: 10.0,
869            height: 8.0,
870        };
871        let quad = [[20.0, 18.0], [50.0, 18.0], [20.0, 42.0], [50.0, 42.0]];
872        let transform = ProjectiveTransform::from_rect_to_quad(rect, quad);
873
874        assert_eq!(
875            transform.matrix(),
876            [[3.0, 0.0, 8.0], [0.0, 3.0, 0.0], [0.0, 0.0, 1.0]]
877        );
878    }
879
880    #[test]
881    fn retained_visual_observation_nodes_collect_layers_and_command_owners() {
882        let bounds = Rect {
883            x: 0.0,
884            y: 0.0,
885            width: 20.0,
886            height: 20.0,
887        };
888        let command = |node_id| DrawCommandId {
889            node_id,
890            command_index: 0,
891            placement: DrawPlacement::Behind,
892        };
893        let mut child = test_layer(
894            bounds,
895            vec![RenderNode::DrawRun(DrawRunNode::for_command(
896                PrimitivePhase::BeforeChildren,
897                Some(command(17)),
898                Vec::new(),
899            ))],
900        );
901        child.node_id = Some(13);
902        let mut root = test_layer(
903            bounds,
904            vec![
905                RenderNode::DrawRun(DrawRunNode::for_command(
906                    PrimitivePhase::BeforeChildren,
907                    Some(command(9)),
908                    Vec::new(),
909                )),
910                RenderNode::DrawRun(DrawRunNode::new(PrimitivePhase::BeforeChildren, Vec::new())),
911                RenderNode::Layer(Box::new(child)),
912            ],
913        );
914
915        root.node_id = Some(5);
916
917        assert_eq!(
918            RenderGraph::new(root).retained_visual_observation_nodes(),
919            HashSet::from([5, 9, 13, 17])
920        );
921    }
922
923    #[test]
924    fn render_graph_new_recomputes_manual_layer_hashes() {
925        let primitive = PrimitiveEntry {
926            phase: PrimitivePhase::BeforeChildren,
927            node: PrimitiveNode::Draw(DrawPrimitiveNode {
928                primitive: DrawPrimitive::Rect {
929                    rect: Rect {
930                        x: 1.0,
931                        y: 2.0,
932                        width: 8.0,
933                        height: 6.0,
934                    },
935                    brush: Brush::solid(Color::WHITE),
936                    stroke: None,
937                },
938                clip: None,
939            }),
940        };
941        let mut root = test_layer(
942            Rect {
943                x: 0.0,
944                y: 0.0,
945                width: 20.0,
946                height: 20.0,
947            },
948            vec![RenderNode::Primitive(primitive)],
949        );
950        root.graphics_layer.render_effect = Some(RenderEffect::blur(3.0));
951        let mut expected = root.clone();
952        expected.recompute_raster_cache_hashes();
953
954        let graph = RenderGraph::new(root);
955        assert_eq!(
956            graph.root.target_content_hash(),
957            expected.target_content_hash()
958        );
959        assert_eq!(graph.root.effect_hash(), expected.effect_hash());
960    }
961
962    #[test]
963    fn motion_source_content_hash_ignores_translated_content_offset() {
964        let primitive = PrimitiveEntry {
965            phase: PrimitivePhase::BeforeChildren,
966            node: PrimitiveNode::Draw(DrawPrimitiveNode {
967                primitive: DrawPrimitive::Rect {
968                    rect: Rect {
969                        x: 1.0,
970                        y: 2.0,
971                        width: 8.0,
972                        height: 6.0,
973                    },
974                    brush: Brush::solid(Color::WHITE),
975                    stroke: None,
976                },
977                clip: None,
978            }),
979        };
980        let mut base = test_layer(
981            Rect {
982                x: 0.0,
983                y: 0.0,
984                width: 20.0,
985                height: 20.0,
986            },
987            vec![RenderNode::Primitive(primitive)],
988        );
989        base.translated_content_context = true;
990        base.translated_content_offset = Point::new(0.0, -24.0);
991        base.recompute_raster_cache_hashes();
992
993        let mut moved = base.clone();
994        moved.translated_content_offset = Point::new(0.0, -72.0);
995        moved.recompute_raster_cache_hashes();
996
997        assert_ne!(base.target_content_hash(), moved.target_content_hash());
998        assert_eq!(
999            base.motion_source_content_hash(),
1000            moved.motion_source_content_hash()
1001        );
1002    }
1003}