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 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 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 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}