Skip to main content

cranpose_render_common/
graph.rs

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