//! The retained renderer: every partial render must match a full render of the same view. use std::sync::{Arc, Once}; use figcraft_color::{Color, Paint}; use figcraft_doc::{Document, Effect, EffectKind, Node, NodeId, NodeKind, Shadow, TextAutoResize, TextData}; use figcraft_geom::Affine; use crate::{Options, Pixmap, Retained, View, Work, render_view, retained::snap}; /// System fonts load once, before any render here, so text has glyphs and fonts never change /// mid-test (a font change makes the next render full). fn fonts() { static ONCE: Once = Once::new(); ONCE.call_once(figcraft_text::scan_system_fonts); } struct Scene { doc: Arc, page: NodeId, rect: NodeId, text: NodeId, frame: NodeId, child: NodeId, } fn solid(n: &mut Node, hex: &str) { n.fills = vec![Paint::solid(Color::from_hex(hex).unwrap())]; } /// Shapes, a clipping frame with children, a group with a drop shadow and fixed-width text; with /// `cut_sensitive`, also layers whose look depends on where a render cuts them (inner shadow and /// noise, a background blur). A full render cuts those at the view's edge, so tests compare them /// only in views that contain them whole. fn scene(cut_sensitive: bool) -> Scene { fonts(); let mut d = Document::new("retained"); let page = d.first_page().unwrap(); let rect = d.create(page, NodeKind::Rectangle, "R", 20.0, 20.0, 80.0, 50.0).unwrap(); solid(d.get_mut(rect).unwrap(), "E03030"); let ell = d.create(page, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "E", 140.0, 30.0, 60.0, 60.0).unwrap(); solid(d.get_mut(ell).unwrap(), "3070E0"); let frame = d.create(page, NodeKind::Frame, "F", 40.0, 120.0, 200.0, 120.0).unwrap(); let child = d.create(frame, NodeKind::Rectangle, "C", 150.0, 20.0, 90.0, 40.0).unwrap(); solid(d.get_mut(child).unwrap(), "20A060"); let group = d.create(page, NodeKind::Group, "G", 280.0, 40.0, 60.0, 60.0).unwrap(); let g1 = d.create(group, NodeKind::Rectangle, "G1", 0.0, 0.0, 60.0, 60.0).unwrap(); solid(d.get_mut(g1).unwrap(), "F0B020"); d.get_mut(group) .unwrap() .effects .push(Effect { kind: EffectKind::DropShadow(Shadow { offset: (12.0, 16.0), radius: 6.0, ..Shadow::default() }), visible: true }); let mut td = TextData::new("A line of text that overflows its box"); td.auto_resize = TextAutoResize::None; td.style.font_size = 14.0; let text = d.create(page, NodeKind::Text(td), "T", 20.0, 270.0, 60.0, 20.0).unwrap(); if cut_sensitive { let inner = d.create(page, NodeKind::Rectangle, "Inner", 180.0, 150.0, 70.0, 50.0).unwrap(); solid(d.get_mut(inner).unwrap(), "FFFFFF"); d.get_mut(inner).unwrap().effects.push(Effect::inner_shadow()); d.get_mut(inner).unwrap().effects.push(Effect { kind: EffectKind::Noise(figcraft_doc::Noise { size: 1.0, density: 0.3, color: Color::BLACK, ..Default::default() }), visible: true, }); let glass = d.create(page, NodeKind::Rectangle, "Glass", 120.0, 280.0, 120.0, 40.0).unwrap(); d.get_mut(glass).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.3))]; d.get_mut(glass).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(8.0)), visible: true }); } Scene { doc: Arc::new(d), page, rect, text, frame, child } } fn view(x: f64, y: f64, scale: f64) -> View { View { origin: (x, y), scale, width: 300, height: 220 } } fn edit(doc: &Arc, f: impl FnOnce(&mut Document)) -> Arc { let mut d = (**doc).clone(); f(&mut d); Arc::new(d) } /// Render through `r` and check the image against a full render of the same (snapped) view. fn check(r: &mut Retained, doc: &Arc, page: NodeId, v: &View) -> Work { let (shown, pix, work) = r.render(doc, page, v, &Options::default()).unwrap(); let pix = pix.clone(); let key = |v: &View| (v.origin, v.scale, v.width, v.height); if let Some((snapped, _)) = snap(v) { assert_eq!(key(&shown), key(&snapped), "the snapped view is what's shown"); } let full = render_view(doc, page, &shown, &Options::default()).unwrap(); assert_same(&pix, &full, &format!("{work:?}")); work } /// Equal up to anti-aliasing: a part rendered on its own sees its paths translated by a whole /// number of pixels, and float rounding can move an edge sample (a fraction of one pixel's /// coverage along a curve). Stale or missing areas differ by whole colours, over many pixels. pub(crate) fn assert_same(a: &Pixmap, b: &Pixmap, what: &str) { assert_eq!((a.width(), a.height()), (b.width(), b.height())); let (mut slight, mut big, mut first) = (0usize, 0usize, None); for (i, (x, y)) in a.data().iter().zip(b.data()).enumerate() { let d = x.abs_diff(*y); slight += usize::from(d > 2); big += usize::from(d > 96); if d > 2 && first.is_none() { first = Some(((i / 4) as u32 % a.width(), (i / 4) as u32 / a.width(), d)); } } assert!(slight <= 200 && big == 0, "{what}: partial render differs from a full one ({slight} channels over 2, {big} over 96, first {first:?})"); } fn partial(w: Work) -> bool { matches!(w, Work::Partial { .. }) } #[test] fn unchanged_view_is_reused() { let s = scene(false); let mut r = Retained::default(); assert_eq!(check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.0)), Work::Full); assert_eq!(check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.0)), Work::Reused); // A sub-pixel move of the view stays on the same pixel grid. assert_eq!(check(&mut r, &s.doc, s.page, &view(0.3, 0.2, 1.0)), Work::Reused); } #[test] fn pans_render_only_the_uncovered_strips() { let s = scene(false); let mut r = Retained::default(); check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.0)); for (x, y) in [(37.0, 0.0), (37.0, 23.0), (-12.5, 41.0), (40.0, 80.0), (10.0, 30.0), (0.0, 0.0)] { eprintln!("pan to {x} {y}"); let w = check(&mut r, &s.doc, s.page, &view(x, y, 1.0)); assert!(partial(w), "pan to ({x}, {y}): {w:?}"); } // At another zoom too (the grid is that zoom's). check(&mut r, &s.doc, s.page, &view(10.0, 10.0, 1.7)); assert!(partial(check(&mut r, &s.doc, s.page, &view(25.0, -8.0, 1.7)))); // A jump further than the view renders it whole. assert_eq!(check(&mut r, &s.doc, s.page, &view(2000.0, 0.0, 1.7)), Work::Full); } #[test] fn edits_render_only_what_changed() { let s = scene(false); let mut r = Retained::default(); let v = view(0.0, 0.0, 1.0); check(&mut r, &s.doc, s.page, &v); let moved = edit(&s.doc, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((60.0, 40.0))); let w = check(&mut r, &moved, s.page, &v); let Work::Partial { pixels } = w else { panic!("move: {w:?}") }; assert!(pixels < 300 * 220 / 4, "only around the layer: {pixels}"); let recolored = edit(&moved, |d| solid(d.get_mut(s.child).unwrap(), "000000")); assert!(partial(check(&mut r, &recolored, s.page, &v))); // Out of the clipping frame and back. let out = edit(&recolored, |d| d.get_mut(s.child).unwrap().transform = Affine::translate((180.0, 90.0))); assert!(partial(check(&mut r, &out, s.page, &v))); let hidden = edit(&out, |d| d.get_mut(s.child).unwrap().visible = false); assert!(partial(check(&mut r, &hidden, s.page, &v))); let removed = edit(&hidden, |d| d.remove(s.child).unwrap()); assert!(partial(check(&mut r, &removed, s.page, &v))); let added = edit(&removed, |d| { let k = d.create(s.frame, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "K", 10.0, 10.0, 30.0, 30.0).unwrap(); solid(d.get_mut(k).unwrap(), "8040C0"); }); assert!(partial(check(&mut r, &added, s.page, &v))); // Undo: back to an older version of the document. assert!(partial(check(&mut r, &s.doc, s.page, &v))); } #[test] fn edits_under_and_beside_effects_match() { let s = scene(true); let mut r = Retained::default(); // Holds the inner shadow + noise layer (180, 150) and the background blur (120, 280) whole. let v = view(0.0, 100.0, 1.0); check(&mut r, &s.doc, s.page, &v); // Under the background blur, then across the inner shadow + noise layer. let under = edit(&s.doc, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((150.0, 260.0))); assert!(partial(check(&mut r, &under, s.page, &v))); // Panning the blur (over the red layer) in from the view's edge, where a full render cut its // backdrop off (so only the view after it compares). let mut fresh = Retained::default(); assert_eq!(fresh.render(&under, s.page, &view(0.0, 75.0, 1.0), &Options::default()).map(|r| r.2), Some(Work::Full)); assert!(partial(check(&mut fresh, &under, s.page, &v))); let across = edit(&under, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((200.0, 170.0))); assert!(partial(check(&mut r, &across, s.page, &v))); let beside = edit(&across, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((10.0, 110.0))); assert!(partial(check(&mut r, &beside, s.page, &v))); // A child of a group with a drop shadow: the shadow moves too. let g1 = s.doc.find_by_name("G1", None)[0]; let v = View { origin: (260.0, 0.0), scale: 1.0, width: 300, height: 220 }; check(&mut r, &beside, s.page, &v); let shadowed = edit(&beside, |d| d.get_mut(g1).unwrap().transform = Affine::translate((30.0, 20.0))); assert!(partial(check(&mut r, &shadowed, s.page, &v))); let shadowed2 = edit(&shadowed, |d| d.get_mut(g1).unwrap().transform = Affine::translate((10.0, 60.0))); assert!(partial(check(&mut r, &shadowed2, s.page, &v))); } #[test] fn text_and_mask_edits_match() { let s = scene(false); let mut r = Retained::default(); let v = view(0.0, 120.0, 1.5); check(&mut r, &s.doc, s.page, &v); // Longer text overflows further than its box. let longer = edit(&s.doc, |d| { if let NodeKind::Text(t) = &mut d.get_mut(s.text).unwrap().kind { t.characters = "Overflowing text, much longer than before, runs on and on".into(); } }); assert!(partial(check(&mut r, &longer, s.page, &v))); // Pans whose new strips hold only the overflowing part of the text. for x in [30.0, 60.0, 90.0] { assert!(partial(check(&mut r, &longer, s.page, &View { origin: (x, 120.0), ..v.clone() }))); } check(&mut r, &longer, s.page, &v); // A mask clips the layers above it, wherever they are. let masked = edit(&longer, |d| { let m = d.create(s.frame, NodeKind::Rectangle, "M", 0.0, 0.0, 60.0, 60.0).unwrap(); d.reorder(m, 0).unwrap(); d.get_mut(m).unwrap().is_mask = true; }); check(&mut r, &masked, s.page, &v); let unmasked = edit(&masked, |d| { let m = d.find_by_name("M", None)[0]; d.get_mut(m).unwrap().is_mask = false; }); check(&mut r, &unmasked, s.page, &v); } #[test] fn zoom_page_and_options_render_full() { let s = scene(false); let mut r = Retained::default(); let v = view(0.0, 0.0, 1.0); check(&mut r, &s.doc, s.page, &v); assert_eq!(check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.25)), Work::Full); // Page background: the whole page changes. let bg = edit(&s.doc, |d| { if let NodeKind::Page { background, .. } = &mut d.get_mut(s.page).unwrap().kind { *background = Color::BLACK; } }); assert_eq!(check(&mut r, &bg, s.page, &view(0.0, 0.0, 1.25)), Work::Full); // Another page. let two = edit(&bg, |d| { d.add_page("Two"); }); let p2 = two.pages[1]; check(&mut r, &two, s.page, &view(0.0, 0.0, 1.25)); assert_eq!(check(&mut r, &two, p2, &view(0.0, 0.0, 1.25)), Work::Full); // Outline mode. let (_, _, w) = r.render(&two, p2, &view(0.0, 0.0, 1.25), &Options { outline_mode: true, ..Default::default() }).unwrap(); assert_eq!(w, Work::Full); } #[test] fn junk_views_never_panic() { let s = scene(false); let mut r = Retained::default(); for v in [view(f64::NAN, 0.0, 1.0), view(0.0, 0.0, f64::INFINITY), view(1e300, -1e300, 1.0), view(0.0, 0.0, 0.0), view(0.0, 0.0, 1.0)] { let _ = r.render(&s.doc, s.page, &v, &Options::default()); } let tiny = View { origin: (0.0, 0.0), scale: 1.0, width: 0, height: 0 }; assert!(r.render(&s.doc, s.page, &tiny, &Options::default()).is_some()); } #[test] fn overflowing_text_is_drawn_while_its_box_is_out_of_view() { fonts(); let mut d = Document::new("overflow"); let page = d.first_page().unwrap(); // An auto-width box whose stored width is stale (a file saved where fonts measured narrower): // one line, far wider than the box. let mut td = TextData::new("Overflowing far to the right of a one pixel box"); td.auto_resize = TextAutoResize::WidthAndHeight; td.style.font_size = 16.0; let t = d.create(page, NodeKind::Text(td), "T", 100.0, 10.0, 1.0, 20.0).unwrap(); let Some((layout, _)) = figcraft_text::layout_node(d.node(t).unwrap()) else { return }; if layout.glyphs.is_empty() || layout.width < 120.0 { return; // no fonts on this machine } // The box (x 100..101) is left of the view; the glyphs run on into it. let v = View { origin: (150.0, 0.0), scale: 1.0, width: 100, height: 40 }; let pix = render_view(&d, page, &v, &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap(); let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); assert!(dark > 20, "glyphs drawn: {dark}"); }