Files
figcraft/crates/render/src/retained_tests.rs
T
samhyunandClaude Opus 5.5 81f89b314b M8: a group's effects reach past what its children paint
A group or an unclipped frame applies its layer blur, drop shadows and
texture to everything its children paint, including children reaching past
its box. The renderer sized the container's layer from the union of its
children without growing it by the container's own effects, so the blur or
shadow was cut where a child's paint ended; exports of unclipped frames
lost part of the shadow the same way.

bounds_within now grows the children's union by the node's effects reach,
and render_bounds_at grows an unclipped frame's children by its effects.
The retained partial-render test drew this very cut (61 levels in its group
child scene); with the shadow counted the damage passes the partial share
of its 300 x 220 view, so the view is widened to keep it a partial render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:51:57 +09:00

459 lines
22 KiB
Rust

//! 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<Document>,
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<Document>, f: impl FnOnce(&mut Document)) -> Arc<Document> {
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<Document>, 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, past the group's box with the
// child (a view large enough that this stays a part of it).
let g1 = s.doc.find_by_name("G1", None)[0];
let v = View { origin: (260.0, 0.0), scale: 1.0, width: 400, height: 300 };
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}");
}
/// Draw a full render through [`Retained::begin`] `band_h` rows at a time; how many steps it took.
fn stepped(r: &mut Retained, doc: &Arc<Document>, page: NodeId, v: &View, band_h: u32) -> (View, Pixmap, usize) {
match r.begin(doc, page, v, &Options::default(), band_h).unwrap() {
crate::Begun::Done(shown, pix, work) => {
assert_eq!(work, Work::Full, "one piece only when a blur can't be measured");
(shown, pix.clone(), 0)
}
crate::Begun::Steps(mut steps) => {
let mut n = 1;
while !steps.step() {
n += 1;
assert!(n < 1000, "steps end");
}
let (shown, pix) = r.finish(*steps);
(shown, pix.clone(), n)
}
}
}
#[test]
fn a_render_drawn_band_by_band_matches_one_in_one_piece() {
for cut_sensitive in [false, true] {
let s = scene(cut_sensitive);
// The whole scene in view, so the glass's backdrop is never cut by the view.
let v = View { origin: (-10.0, -10.0), scale: 1.0, width: 380, height: 360 };
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 40);
assert_eq!(n, shown.height.div_ceil(40) as usize, "a band a step");
let full = render_view(&s.doc, s.page, &shown, &Options::default()).unwrap();
assert_same(&pix, &full, "bands");
// Kept as a full render: the next pan patches it.
assert!(partial(check(&mut r, &s.doc, s.page, &View { origin: (0.0, -10.0), ..v })));
}
}
#[test]
fn bands_thinner_than_a_blur_reaches_grow_to_twice_its_reach() {
let s = scene(true);
let v = View { origin: (-10.0, -10.0), scale: 4.0, width: 380, height: 360 };
let reach = crate::band_overlap(&s.doc, &v, u32::MAX).unwrap();
assert!(reach > 4, "the blur reaches past the bands asked for");
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 4);
assert_eq!(n, shown.height.div_ceil(4.max(reach.min(shown.height) * 2)) as usize, "bands twice the reach");
assert_same(&pix, &render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(), "taller bands");
}
#[test]
fn a_reused_or_patched_render_needs_no_steps() {
let s = scene(false);
let v = view(0.0, 0.0, 1.0);
let mut r = Retained::default();
check(&mut r, &s.doc, s.page, &v);
assert!(matches!(r.begin(&s.doc, s.page, &v, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Reused))));
let panned = View { origin: (12.0, 0.0), ..v };
assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. }))));
}
/// Background blurs stacked on each other (the upper reads the lower's blurred pixels) across the
/// band boundaries: the bands' overlap, the reach of the largest blur, is enough for the image to
/// match one drawn in one piece, as it is for the desktop's parallel bands.
#[test]
fn stacked_backdrop_blurs_draw_the_same_in_bands() {
fonts();
let mut d = Document::new("stacked");
let page = d.first_page().unwrap();
// Stripes to blur, then three translucent blurred panels overlapping each other.
for i in 0..40 {
let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap();
solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" });
}
for (x, y) in [(40.0, 60.0), (120.0, 120.0), (60.0, 200.0)] {
let b = d.create(page, NodeKind::Rectangle, "B", x, y, 220.0, 160.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.25))];
d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(24.0)), visible: true });
}
let doc = Arc::new(d);
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 };
for band_h in [64, 100, 200] {
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h);
assert!(n >= 3, "{n} bands of {band_h} rows");
assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows"));
}
}
/// Layer blurs stacked on one layer apply one after another, so they reach as far as all of them
/// together: a layer cut at a band's edge keeps that much margin, and the bands (the browser's,
/// the desktop's) match a render in one piece, as does a view cut across the blur.
#[test]
fn sequential_layer_blurs_draw_the_same_in_bands() {
fonts();
for (count, radius) in [(4, 25.0), (4, 100.0), (8, 10.0)] {
let mut d = Document::new("sequential");
let page = d.first_page().unwrap();
for i in 0..40 {
let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap();
solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" });
}
let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 150.0, 200.0, 100.0).unwrap();
solid(d.get_mut(b).unwrap(), "20C040");
for _ in 0..count {
d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(radius)), visible: true });
}
let doc = Arc::new(d);
let what = format!("{count} blurs of {radius}");
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 };
for band_h in [64, 100] {
let mut r = Retained::default();
let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h);
assert!(n >= 4, "{what}: {n} bands of {band_h} rows");
let one = crate::render_view_single(&doc, page, &shown, &Options::default()).unwrap();
assert_within(&pix, &one, 0, 2, &format!("{what}, bands of {band_h} rows"));
}
// The desktop splits a view this large into parallel bands.
let big = View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 800 };
let one = crate::render_view_single(&doc, page, &big, &Options::default()).unwrap();
assert_within(&render_view(&doc, page, &big, &Options::default()).unwrap(), &one, 0, 2, &format!("{what}, parallel bands"));
// A view starting halfway down the blur: its rows as in the whole view.
let cut = View { origin: (0.0, 200.0), scale: 1.0, width: 800, height: 600 };
let part = crate::render_view_single(&doc, page, &cut, &Options::default()).unwrap();
assert_within(&part, &one, 200, 2, &format!("{what}, view cut across the blur"));
}
}
/// A drop shadow is cast from the blurred layer: a layer cut at the view keeps the blur's reach on
/// top of the shadow's, or a rectangle above the view casts a shadow into it from a cut blur.
#[test]
fn a_shadow_cast_from_a_blurred_layer_draws_the_same_in_a_cut_view() {
fonts();
let mut d = Document::new("shadowed blur");
let page = d.first_page().unwrap();
let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 0.0, 200.0, 100.0).unwrap();
solid(d.get_mut(b).unwrap(), "20C040");
let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 300.0), radius: 0.0, ..Shadow::default() };
d.get_mut(b).unwrap().effects = vec![
Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(50.0)), visible: true },
Effect { kind: EffectKind::DropShadow(shadow), visible: true },
];
let whole = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 600 };
let one = crate::render_view_single(&d, page, &whole, &Options::default()).unwrap();
// The view starts below the rectangle, near the bottom of its shadow: a margin of the shadow's
// reach alone would cut the layer across the rectangle.
let cut = View { origin: (0.0, 390.0), scale: 1.0, width: 400, height: 210 };
let part = crate::render_view_single(&d, page, &cut, &Options::default()).unwrap();
assert_within(&part, &one, 390, 2, "the shadow of a blurred layer above the view");
}
/// Reaches too large to add (texture of 1e308, twice) each count up to the cap and add up: a margin
/// that stays finite, not none, and not the largest one alone.
#[test]
fn effect_margins_too_large_to_add_stay_large() {
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "T", 10.0, 10.0);
let t = figcraft_doc::Texture { radius: 1e308, ..Default::default() };
n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }];
let m = crate::draw::effect_margin(&n);
assert_eq!(m, 2.0 * crate::draw::MAX_REACH);
}
/// Every channel of `a` within `tol` of the same channel of `b` from row `rows_down` on.
fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) {
assert_eq!(a.width(), b.width(), "{what}: widths");
let row = a.width() as usize * 4;
let below = b.data().get(rows_down as usize * row..).unwrap();
let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= tol, "{what}: differs by {worst} levels");
}