Skip to main content

cranpose_render_common/
graph.rs

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