mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas, PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes). - Layer and background blur gain a progressive type: the radius ramps linearly from a start radius at a start point to the end radius at an end point (unit-box points, like gradient handles). Rendered by blending a few geometric blur levels, each blurred only around the pixels that use it; bands and partial renders match one piece. Design panel type menu and Start/End fields, on-canvas handles while the settings are open, effect.add/effect.set params, startRadius variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset), Inspect row; CSS leaves progressive blurs out. - SVG export embeds renders for what filters can't match: noise grains over the vector layer (crisp, blended), texture and progressive layer blur whole, background blur and glass from what lies under the layer among the exported layers. PDF and SVG share the renderer's helpers. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
13 KiB
Rust
290 lines
13 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.
|
|
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}");
|
|
}
|