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;
13
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct ProjectiveTransform {
16    matrix: [[f32; 3]; 3],
17}
18
19impl ProjectiveTransform {
20    pub const fn identity() -> Self {
21        Self {
22            matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
23        }
24    }
25
26    pub fn translation(tx: f32, ty: f32) -> Self {
27        Self {
28            matrix: [[1.0, 0.0, tx], [0.0, 1.0, ty], [0.0, 0.0, 1.0]],
29        }
30    }
31
32    /// Uniform scale about the origin (device-scale root transform: render
33    /// graphs stay in logical dp; density applies at execution).
34    pub fn uniform_scale(scale: f32) -> Self {
35        Self {
36            matrix: [[scale, 0.0, 0.0], [0.0, scale, 0.0], [0.0, 0.0, 1.0]],
37        }
38    }
39
40    pub fn from_rect_to_quad(rect: Rect, quad: [[f32; 2]; 4]) -> Self {
41        if rect.width.abs() <= f32::EPSILON || rect.height.abs() <= f32::EPSILON {
42            return Self::translation(quad[0][0], quad[0][1]);
43        }
44
45        if let Some(axis_aligned) = axis_aligned_rect_from_quad(quad) {
46            let scale_x = axis_aligned.width / rect.width;
47            let scale_y = axis_aligned.height / rect.height;
48            return Self {
49                matrix: [
50                    [scale_x, 0.0, axis_aligned.x - rect.x * scale_x],
51                    [0.0, scale_y, axis_aligned.y - rect.y * scale_y],
52                    [0.0, 0.0, 1.0],
53                ],
54            };
55        }
56
57        let source = [
58            [rect.x, rect.y],
59            [rect.x + rect.width, rect.y],
60            [rect.x, rect.y + rect.height],
61            [rect.x + rect.width, rect.y + rect.height],
62        ];
63        let Some(coefficients) = solve_homography(source, quad) else {
64            return Self::identity();
65        };
66
67        Self {
68            matrix: [
69                [coefficients[0], coefficients[1], coefficients[2]],
70                [coefficients[3], coefficients[4], coefficients[5]],
71                [coefficients[6], coefficients[7], 1.0],
72            ],
73        }
74    }
75
76    /// Returns the composed transform that applies `self` first and `next` second.
77    pub fn then(self, next: Self) -> Self {
78        Self {
79            matrix: multiply_matrices(next.matrix, self.matrix),
80        }
81    }
82
83    pub fn inverse(self) -> Option<Self> {
84        let m = self.matrix;
85        let a = m[0][0];
86        let b = m[0][1];
87        let c = m[0][2];
88        let d = m[1][0];
89        let e = m[1][1];
90        let f = m[1][2];
91        let g = m[2][0];
92        let h = m[2][1];
93        let i = m[2][2];
94
95        let cofactor00 = e * i - f * h;
96        let cofactor01 = -(d * i - f * g);
97        let cofactor02 = d * h - e * g;
98        let cofactor10 = -(b * i - c * h);
99        let cofactor11 = a * i - c * g;
100        let cofactor12 = -(a * h - b * g);
101        let cofactor20 = b * f - c * e;
102        let cofactor21 = -(a * f - c * d);
103        let cofactor22 = a * e - b * d;
104
105        let determinant = a * cofactor00 + b * cofactor01 + c * cofactor02;
106        if determinant.abs() <= f32::EPSILON {
107            return None;
108        }
109        let inverse_determinant = 1.0 / determinant;
110
111        Some(Self {
112            matrix: [
113                [
114                    cofactor00 * inverse_determinant,
115                    cofactor10 * inverse_determinant,
116                    cofactor20 * inverse_determinant,
117                ],
118                [
119                    cofactor01 * inverse_determinant,
120                    cofactor11 * inverse_determinant,
121                    cofactor21 * inverse_determinant,
122                ],
123                [
124                    cofactor02 * inverse_determinant,
125                    cofactor12 * inverse_determinant,
126                    cofactor22 * inverse_determinant,
127                ],
128            ],
129        })
130    }
131
132    pub fn matrix(self) -> [[f32; 3]; 3] {
133        self.matrix
134    }
135
136    pub fn map_point(self, point: Point) -> Point {
137        let x = point.x;
138        let y = point.y;
139        let w = self.matrix[2][0] * x + self.matrix[2][1] * y + self.matrix[2][2];
140        let safe_w = if w.abs() <= f32::EPSILON { 1.0 } else { w };
141
142        Point {
143            x: (self.matrix[0][0] * x + self.matrix[0][1] * y + self.matrix[0][2]) / safe_w,
144            y: (self.matrix[1][0] * x + self.matrix[1][1] * y + self.matrix[1][2]) / safe_w,
145        }
146    }
147
148    pub fn map_rect(self, rect: Rect) -> [[f32; 2]; 4] {
149        [
150            self.map_point(Point {
151                x: rect.x,
152                y: rect.y,
153            }),
154            self.map_point(Point {
155                x: rect.x + rect.width,
156                y: rect.y,
157            }),
158            self.map_point(Point {
159                x: rect.x,
160                y: rect.y + rect.height,
161            }),
162            self.map_point(Point {
163                x: rect.x + rect.width,
164                y: rect.y + rect.height,
165            }),
166        ]
167        .map(|point| [point.x, point.y])
168    }
169
170    pub fn bounds_for_rect(self, rect: Rect) -> Rect {
171        quad_bounds(self.map_rect(rect))
172    }
173}
174
175fn axis_aligned_rect_from_quad(quad: [[f32; 2]; 4]) -> Option<Rect> {
176    let top_left = quad[0];
177    let top_right = quad[1];
178    let bottom_left = quad[2];
179    let bottom_right = quad[3];
180    let x_epsilon = 1e-4;
181    let y_epsilon = 1e-4;
182
183    if (top_left[1] - top_right[1]).abs() > y_epsilon
184        || (bottom_left[1] - bottom_right[1]).abs() > y_epsilon
185        || (top_left[0] - bottom_left[0]).abs() > x_epsilon
186        || (top_right[0] - bottom_right[0]).abs() > x_epsilon
187    {
188        return None;
189    }
190
191    Some(Rect {
192        x: top_left[0],
193        y: top_left[1],
194        width: top_right[0] - top_left[0],
195        height: bottom_left[1] - top_left[1],
196    })
197}
198
199impl Default for ProjectiveTransform {
200    fn default() -> Self {
201        Self::identity()
202    }
203}
204
205#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
206pub struct IsolationReasons {
207    pub explicit_offscreen: bool,
208    pub shape_clip: bool,
209    pub effect: bool,
210    pub backdrop: bool,
211    pub group_opacity: bool,
212    pub blend_mode: bool,
213}
214
215impl IsolationReasons {
216    pub fn has_any(self) -> bool {
217        self.explicit_offscreen
218            || self.shape_clip
219            || self.effect
220            || self.backdrop
221            || self.group_opacity
222            || self.blend_mode
223    }
224}
225
226#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
227pub enum CachePolicy {
228    #[default]
229    None,
230    Auto,
231}
232
233#[derive(Clone)]
234pub struct HitTestNode {
235    pub shape: Option<RoundedCornerShape>,
236    pub click_actions: Vec<Rc<dyn Fn(Point)>>,
237    pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
238    pub clip: Option<Rect>,
239}
240
241#[derive(Clone, Debug, PartialEq)]
242pub struct DrawPrimitiveNode {
243    pub primitive: DrawPrimitive,
244    pub clip: Option<Rect>,
245}
246
247#[derive(Clone, Debug, PartialEq)]
248pub struct TextPrimitiveNode {
249    pub node_id: NodeId,
250    pub rect: Rect,
251    pub text: AnnotatedString,
252    pub text_style: TextStyle,
253    pub font_size: f32,
254    pub layout_options: TextLayoutOptions,
255    pub clip: Option<Rect>,
256}
257
258#[derive(Clone, Copy, Debug, PartialEq, Eq)]
259pub enum PrimitivePhase {
260    BeforeChildren,
261    AfterChildren,
262}
263
264#[derive(Clone, Debug, PartialEq)]
265pub enum PrimitiveNode {
266    Draw(DrawPrimitiveNode),
267    Text(Box<TextPrimitiveNode>),
268}
269
270#[derive(Clone, Debug, PartialEq)]
271pub struct PrimitiveEntry {
272    pub phase: PrimitivePhase,
273    pub node: PrimitiveNode,
274}
275
276#[derive(Clone)]
277pub struct LayerNode {
278    pub node_id: Option<NodeId>,
279    pub local_bounds: Rect,
280    pub transform_to_parent: ProjectiveTransform,
281    pub content_offset: Point,
282    pub motion_context_animated: bool,
283    pub translated_content_context: bool,
284    pub translated_content_offset: Point,
285    /// Window-space origin this layer's children are placed from, and the
286    /// accumulated ancestor graphics-layer translation, captured during the
287    /// full per-frame scene build. Read back when a dirty subtree is rebuilt in
288    /// isolation (`update_scene_from_applier`) so a scrolling field's window
289    /// origin stays live during a fling even though the ancestor chain is not
290    /// re-walked from the root. Defaults to the identity origin.
291    pub scene_children_origin: Point,
292    pub scene_children_layer_translation: Point,
293    pub graphics_layer: GraphicsLayer,
294    pub clip_to_bounds: bool,
295    pub shadow_clip: Option<Rect>,
296    pub hit_test: Option<HitTestNode>,
297    pub has_hit_targets: bool,
298    pub isolation: IsolationReasons,
299    pub cache_policy: CachePolicy,
300    pub cache_hashes: LayerRasterCacheHashes,
301    pub cache_hashes_valid: bool,
302    pub children: Vec<RenderNode>,
303}
304
305impl LayerNode {
306    pub fn clip_rect(&self) -> Option<Rect> {
307        (self.clip_to_bounds || self.graphics_layer.clip).then_some(self.local_bounds)
308    }
309
310    pub fn effect(&self) -> Option<&RenderEffect> {
311        self.graphics_layer.render_effect.as_ref()
312    }
313
314    pub fn backdrop(&self) -> Option<&RenderEffect> {
315        self.graphics_layer.backdrop_effect.as_ref()
316    }
317
318    pub fn opacity(&self) -> f32 {
319        self.graphics_layer.alpha
320    }
321
322    pub fn blend_mode(&self) -> BlendMode {
323        self.graphics_layer.blend_mode
324    }
325
326    pub fn color_filter(&self) -> Option<ColorFilter> {
327        self.graphics_layer.color_filter
328    }
329
330    pub fn target_content_hash(&self) -> u64 {
331        if self.cache_hashes_valid {
332            self.cache_hashes.target_content
333        } else {
334            crate::graph_hash::layer_raster_cache_hashes(self).target_content
335        }
336    }
337
338    pub fn motion_source_content_hash(&self) -> u64 {
339        crate::graph_hash::layer_motion_source_content_hash(self)
340    }
341
342    pub fn effect_hash(&self) -> u64 {
343        if self.cache_hashes_valid {
344            self.cache_hashes.effect
345        } else {
346            crate::graph_hash::layer_raster_cache_hashes(self).effect
347        }
348    }
349
350    pub fn recompute_raster_cache_hashes(&mut self) {
351        crate::graph_hash::recompute_layer_raster_cache_hashes(self);
352    }
353}
354
355#[derive(Clone)]
356pub enum RenderNode {
357    Primitive(PrimitiveEntry),
358    Layer(Box<LayerNode>),
359}
360
361#[derive(Clone)]
362pub struct RenderGraph {
363    pub root: LayerNode,
364}
365
366impl RenderGraph {
367    pub fn new(mut root: LayerNode) -> Self {
368        root.recompute_raster_cache_hashes();
369        Self { root }
370    }
371
372    pub fn node_count(&self) -> usize {
373        fn count_layer(layer: &LayerNode) -> usize {
374            1 + layer
375                .children
376                .iter()
377                .map(|child| match child {
378                    RenderNode::Primitive(_) => 1,
379                    RenderNode::Layer(child_layer) => count_layer(child_layer),
380                })
381                .sum::<usize>()
382        }
383
384        count_layer(&self.root)
385    }
386
387    pub fn heap_bytes(&self) -> usize {
388        layer_heap_bytes(&self.root)
389    }
390}
391
392fn layer_heap_bytes(layer: &LayerNode) -> usize {
393    layer.hit_test.as_ref().map_or(0, hit_test_heap_bytes)
394        + size_of::<RenderNode>() * layer.children.capacity()
395        + layer
396            .children
397            .iter()
398            .map(render_node_heap_bytes)
399            .sum::<usize>()
400}
401
402fn render_node_heap_bytes(node: &RenderNode) -> usize {
403    match node {
404        RenderNode::Primitive(entry) => primitive_entry_heap_bytes(entry),
405        RenderNode::Layer(layer) => size_of::<LayerNode>() + layer_heap_bytes(layer),
406    }
407}
408
409fn primitive_entry_heap_bytes(entry: &PrimitiveEntry) -> usize {
410    match &entry.node {
411        PrimitiveNode::Draw(draw) => draw_primitive_heap_bytes(&draw.primitive),
412        PrimitiveNode::Text(text) => {
413            size_of::<TextPrimitiveNode>() + annotated_string_heap_bytes(&text.text)
414        }
415    }
416}
417
418fn draw_primitive_heap_bytes(primitive: &DrawPrimitive) -> usize {
419    match primitive {
420        DrawPrimitive::Content | DrawPrimitive::Rect { .. } | DrawPrimitive::RoundRect { .. } => 0,
421        DrawPrimitive::Blend { primitive, .. } => {
422            size_of::<DrawPrimitive>() + draw_primitive_heap_bytes(primitive)
423        }
424        DrawPrimitive::Image { .. } => 0,
425        DrawPrimitive::Shadow(shadow) => shadow_primitive_heap_bytes(shadow),
426    }
427}
428
429fn shadow_primitive_heap_bytes(shadow: &ShadowPrimitive) -> usize {
430    match shadow {
431        ShadowPrimitive::Drop { shape, .. } => {
432            size_of::<DrawPrimitive>() + draw_primitive_heap_bytes(shape)
433        }
434        ShadowPrimitive::Inner { fill, cutout, .. } => {
435            size_of::<DrawPrimitive>() * 2
436                + draw_primitive_heap_bytes(fill)
437                + draw_primitive_heap_bytes(cutout)
438        }
439    }
440}
441
442fn annotated_string_heap_bytes(text: &AnnotatedString) -> usize {
443    text.text.capacity()
444        + text.span_styles.capacity() * size_of::<usize>() * 2
445        + text.paragraph_styles.capacity() * size_of::<usize>() * 2
446        + text.string_annotations.capacity() * size_of::<usize>() * 2
447        + text.link_annotations.capacity() * size_of::<usize>() * 2
448        + text
449            .string_annotations
450            .iter()
451            .map(|annotation| {
452                annotation.item.tag.capacity() + annotation.item.annotation.capacity()
453            })
454            .sum::<usize>()
455        + text
456            .link_annotations
457            .iter()
458            .map(|annotation| match &annotation.item {
459                cranpose_ui::text::LinkAnnotation::Url(url) => url.capacity(),
460                cranpose_ui::text::LinkAnnotation::Clickable { tag, .. } => tag.capacity(),
461            })
462            .sum::<usize>()
463}
464
465fn hit_test_heap_bytes(hit_test: &HitTestNode) -> usize {
466    hit_test.click_actions.capacity() * size_of::<Rc<dyn Fn(Point)>>()
467        + hit_test.pointer_inputs.capacity() * size_of::<Rc<dyn Fn(PointerEvent)>>()
468}
469
470pub fn quad_bounds(quad: [[f32; 2]; 4]) -> Rect {
471    let mut min_x = f32::INFINITY;
472    let mut min_y = f32::INFINITY;
473    let mut max_x = f32::NEG_INFINITY;
474    let mut max_y = f32::NEG_INFINITY;
475
476    for [x, y] in quad {
477        min_x = min_x.min(x);
478        min_y = min_y.min(y);
479        max_x = max_x.max(x);
480        max_y = max_y.max(y);
481    }
482
483    Rect {
484        x: min_x,
485        y: min_y,
486        width: (max_x - min_x).max(0.0),
487        height: (max_y - min_y).max(0.0),
488    }
489}
490
491fn multiply_matrices(lhs: [[f32; 3]; 3], rhs: [[f32; 3]; 3]) -> [[f32; 3]; 3] {
492    let mut out = [[0.0; 3]; 3];
493    for row in 0..3 {
494        for col in 0..3 {
495            out[row][col] =
496                lhs[row][0] * rhs[0][col] + lhs[row][1] * rhs[1][col] + lhs[row][2] * rhs[2][col];
497        }
498    }
499    out
500}
501
502fn solve_homography(source: [[f32; 2]; 4], target: [[f32; 2]; 4]) -> Option<[f32; 8]> {
503    let mut matrix = [[0.0f32; 9]; 8];
504    for (index, (src, dst)) in source.into_iter().zip(target).enumerate() {
505        let row = index * 2;
506        let x = src[0];
507        let y = src[1];
508        let u = dst[0];
509        let v = dst[1];
510
511        matrix[row] = [x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y, u];
512        matrix[row + 1] = [0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y, v];
513    }
514
515    for pivot in 0..8 {
516        let mut pivot_row = pivot;
517        let mut pivot_value = matrix[pivot][pivot].abs();
518        let mut candidate = pivot + 1;
519        while candidate < 8 {
520            let candidate_value = matrix[candidate][pivot].abs();
521            if candidate_value > pivot_value {
522                pivot_row = candidate;
523                pivot_value = candidate_value;
524            }
525            candidate += 1;
526        }
527
528        if pivot_value <= f32::EPSILON {
529            return None;
530        }
531
532        if pivot_row != pivot {
533            matrix.swap(pivot, pivot_row);
534        }
535
536        let divisor = matrix[pivot][pivot];
537        let mut col = pivot;
538        while col < 9 {
539            matrix[pivot][col] /= divisor;
540            col += 1;
541        }
542
543        for row in 0..8 {
544            if row == pivot {
545                continue;
546            }
547            let factor = matrix[row][pivot];
548            if factor.abs() <= f32::EPSILON {
549                continue;
550            }
551            let mut col = pivot;
552            while col < 9 {
553                matrix[row][col] -= factor * matrix[pivot][col];
554                col += 1;
555            }
556        }
557    }
558
559    let mut solution = [0.0f32; 8];
560    for index in 0..8 {
561        solution[index] = matrix[index][8];
562    }
563    Some(solution)
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::raster_cache::LayerRasterCacheHashes;
570    use cranpose_ui_graphics::{Brush, Color, DrawPrimitive};
571
572    fn test_layer(local_bounds: Rect, children: Vec<RenderNode>) -> LayerNode {
573        LayerNode {
574            node_id: None,
575            local_bounds,
576            transform_to_parent: ProjectiveTransform::identity(),
577            content_offset: Point::default(),
578            motion_context_animated: false,
579            translated_content_context: false,
580            translated_content_offset: Point::default(),
581            scene_children_origin: Point::default(),
582            scene_children_layer_translation: Point::default(),
583            graphics_layer: GraphicsLayer::default(),
584            clip_to_bounds: false,
585            shadow_clip: None,
586            hit_test: None,
587            has_hit_targets: false,
588            isolation: IsolationReasons::default(),
589            cache_policy: CachePolicy::None,
590            cache_hashes: LayerRasterCacheHashes::default(),
591            cache_hashes_valid: false,
592            children,
593        }
594    }
595
596    #[test]
597    fn projective_transform_translation_maps_points() {
598        let transform = ProjectiveTransform::translation(7.0, -3.5);
599        let mapped = transform.map_point(Point { x: 2.0, y: 4.0 });
600        assert!((mapped.x - 9.0).abs() < 1e-6);
601        assert!((mapped.y - 0.5).abs() < 1e-6);
602    }
603
604    #[test]
605    fn projective_transform_then_composes_in_parent_order() {
606        let child = ProjectiveTransform::translation(4.0, 2.0);
607        let parent = ProjectiveTransform::translation(10.0, -1.0);
608        let composed = child.then(parent);
609        let mapped = composed.map_point(Point { x: 1.0, y: 1.0 });
610        assert!((mapped.x - 15.0).abs() < 1e-6);
611        assert!((mapped.y - 2.0).abs() < 1e-6);
612    }
613
614    #[test]
615    fn homography_maps_rect_corners_to_target_quad() {
616        let rect = Rect {
617            x: 0.0,
618            y: 0.0,
619            width: 20.0,
620            height: 10.0,
621        };
622        let quad = [[5.0, 7.0], [25.0, 6.0], [7.0, 20.0], [28.0, 21.0]];
623        let transform = ProjectiveTransform::from_rect_to_quad(rect, quad);
624        let mapped = transform.map_rect(rect);
625        for (expected, actual) in quad.into_iter().zip(mapped) {
626            assert!((expected[0] - actual[0]).abs() < 1e-4);
627            assert!((expected[1] - actual[1]).abs() < 1e-4);
628        }
629    }
630
631    #[test]
632    fn axis_aligned_rect_to_quad_keeps_exact_affine_matrix() {
633        let rect = Rect {
634            x: 2.0,
635            y: 3.0,
636            width: 20.0,
637            height: 10.0,
638        };
639        let quad = [[12.0, 9.0], [32.0, 9.0], [12.0, 19.0], [32.0, 19.0]];
640        let transform = ProjectiveTransform::from_rect_to_quad(rect, quad);
641
642        assert_eq!(
643            transform.matrix(),
644            [[1.0, 0.0, 10.0], [0.0, 1.0, 6.0], [0.0, 0.0, 1.0]]
645        );
646    }
647
648    #[test]
649    fn axis_aligned_rect_to_quad_keeps_exact_axis_aligned_scale() {
650        let rect = Rect {
651            x: 4.0,
652            y: 6.0,
653            width: 10.0,
654            height: 8.0,
655        };
656        let quad = [[20.0, 18.0], [50.0, 18.0], [20.0, 42.0], [50.0, 42.0]];
657        let transform = ProjectiveTransform::from_rect_to_quad(rect, quad);
658
659        assert_eq!(
660            transform.matrix(),
661            [[3.0, 0.0, 8.0], [0.0, 3.0, 0.0], [0.0, 0.0, 1.0]]
662        );
663    }
664
665    #[test]
666    fn render_graph_new_recomputes_manual_layer_hashes() {
667        let primitive = PrimitiveEntry {
668            phase: PrimitivePhase::BeforeChildren,
669            node: PrimitiveNode::Draw(DrawPrimitiveNode {
670                primitive: DrawPrimitive::Rect {
671                    rect: Rect {
672                        x: 1.0,
673                        y: 2.0,
674                        width: 8.0,
675                        height: 6.0,
676                    },
677                    brush: Brush::solid(Color::WHITE),
678                },
679                clip: None,
680            }),
681        };
682        let mut root = test_layer(
683            Rect {
684                x: 0.0,
685                y: 0.0,
686                width: 20.0,
687                height: 20.0,
688            },
689            vec![RenderNode::Primitive(primitive)],
690        );
691        root.graphics_layer.render_effect = Some(RenderEffect::blur(3.0));
692        let mut expected = root.clone();
693        expected.recompute_raster_cache_hashes();
694
695        let graph = RenderGraph::new(root);
696        assert_eq!(
697            graph.root.target_content_hash(),
698            expected.target_content_hash()
699        );
700        assert_eq!(graph.root.effect_hash(), expected.effect_hash());
701    }
702
703    #[test]
704    fn motion_source_content_hash_ignores_translated_content_offset() {
705        let primitive = PrimitiveEntry {
706            phase: PrimitivePhase::BeforeChildren,
707            node: PrimitiveNode::Draw(DrawPrimitiveNode {
708                primitive: DrawPrimitive::Rect {
709                    rect: Rect {
710                        x: 1.0,
711                        y: 2.0,
712                        width: 8.0,
713                        height: 6.0,
714                    },
715                    brush: Brush::solid(Color::WHITE),
716                },
717                clip: None,
718            }),
719        };
720        let mut base = test_layer(
721            Rect {
722                x: 0.0,
723                y: 0.0,
724                width: 20.0,
725                height: 20.0,
726            },
727            vec![RenderNode::Primitive(primitive)],
728        );
729        base.translated_content_context = true;
730        base.translated_content_offset = Point::new(0.0, -24.0);
731        base.recompute_raster_cache_hashes();
732
733        let mut moved = base.clone();
734        moved.translated_content_offset = Point::new(0.0, -72.0);
735        moved.recompute_raster_cache_hashes();
736
737        assert_ne!(base.target_content_hash(), moved.target_content_hash());
738        assert_eq!(
739            base.motion_source_content_hash(),
740            moved.motion_source_content_hash()
741        );
742    }
743}