Files
vectorcraft/crates/render/src/tests_memory.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

202 lines
9.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! What renderers keep between frames: decoded images shared by all of them, image mips, and
//! caches swept by every kind of render.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use super::*;
/// A `w`×`h` PNG whose pixels depend on `seed` (each test its own image: the store is shared by
/// the tests running alongside).
fn png(w: u32, h: u32, seed: u8) -> Vec<u8> {
let img = image::RgbaImage::from_fn(w, h, |x, y| {
let v = (x * 7 + y * 13) as u8;
image::Rgba([v ^ seed, v.wrapping_mul(3), seed, 255])
});
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// A document showing `bytes` as image `key` of `w`×`h` pixels, one point a pixel.
fn doc_with_image(key: &str, bytes: Vec<u8>, w: u32, h: u32) -> Document {
let mut d = Document::new(w as f64, h as f64);
d.images.insert(key.into(), ImageBlob::new("image/png", bytes));
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() };
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap();
d
}
#[test]
fn renderers_share_one_decode_of_an_image() {
let bytes = png(16, 16, 101);
let a = doc_with_image("img", bytes.clone(), 16, 16);
// Another document holding the same image under another key (its own copy of the bytes).
let b = doc_with_image("photo", bytes, 16, 16);
let (mut r1, mut r2) = (Renderer::new(), Renderer::new());
let (k1, p1) = r1.image_pixmap(&a, "img", false, true).unwrap();
let (k2, p2) = r2.image_pixmap(&b, "photo", false, true).unwrap();
assert_eq!(k1, k2);
assert!(Arc::ptr_eq(&p1, &p2), "decoded once for both renderers");
// Drawing goes through the store too: the pixels stay the ones decoded.
let _ = r2.render(&b, 16, 16, Affine::IDENTITY, &RenderOptions::default());
assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", false, true).unwrap().1, &p1));
// A document using the same key for other pixels gets its own.
let c = doc_with_image("img", png(16, 16, 102), 16, 16);
let (k3, p3) = r1.image_pixmap(&c, "img", false, true).unwrap();
assert!(k3 != k1 && !Arc::ptr_eq(&p3, &p1));
assert!(
r1.render(&c, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels
!= r1.render(&a, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels
);
}
/// Thumbnails and single-node renders end their frame as full renders do: entries of nodes no
/// longer drawn are dropped, with the nodes they kept alive.
#[test]
fn node_renders_sweep_stale_cache_entries() {
let mut d = Document::new(10.0, 10.0);
let id = d.alloc_id();
let square = |x: f64| {
let p = vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0));
Arc::new(Node::path(id, p, vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)))
};
let first = square(0.0);
let mut r = Renderer::new();
r.render_node(&d, &first, 10, 10, Affine::IDENTITY).unwrap();
assert!(Arc::strong_count(&first) > 1, "cached while drawn");
for i in 0..1100 {
let n = square(i as f64);
if i % 2 == 0 {
r.render_node(&d, &n, 10, 10, Affine::IDENTITY).unwrap();
} else {
// A thumbnail of a group caches its member's geometry.
r.render_node_thumbnail(&d, &Node::group(id, vec![n]), 16, None).unwrap();
}
}
assert_eq!(Arc::strong_count(&first), 1, "swept: the renderer no longer keeps the node");
assert!(r.geom.len() <= 1025, "{} geometry entries", r.geom.len());
}
/// With `image_mips` off (the default; exports) an image draws from its full pixels whatever the
/// store holds; on, a reduced image draws from a box-filtered half (quarter…) instead, and at
/// more than half size from the full pixels as before.
#[test]
fn image_mips_are_opt_in_and_change_nothing_off() {
let d = doc_with_image("img", png(64, 64, 103), 64, 64);
let (off, on) = (RenderOptions::default(), RenderOptions { image_mips: true, ..Default::default() });
let quarter = Affine::scale(0.25);
let cold = Renderer::new().render(&d, 16, 16, quarter, &off);
// Mips made by another renderer don't change what this one draws with them off.
let mipped = Renderer::new().render(&d, 16, 16, quarter, &on);
let mut warm = Renderer::new();
assert!(warm.render(&d, 16, 16, quarter, &off).pixels == cold.pixels);
assert!(warm.render(&d, 16, 16, quarter, &off).pixels == cold.pixels, "warm");
// On, the quarter-size image samples the quarter-size mip: close to the full pixels' average
// but not the same pixels.
assert!(mipped.pixels != cold.pixels);
let (key, _) = warm.image_pixmap(&d, "img", false, true).unwrap();
let mip = images::store().peek(&images::ImageKey { mip: 2, ..key }).expect("quarter mip kept");
assert_eq!((mip.width(), mip.height()), (16, 16));
// Above half size, mips on draw the full pixels.
let near = Affine::scale(0.75);
assert!(Renderer::new().render(&d, 48, 48, near, &on).pixels == Renderer::new().render(&d, 48, 48, near, &off).pixels);
}
/// A document of `n` point type objects reading `word`, in rows.
fn text_doc(n: usize, word: &str) -> Document {
use vectorcraft_doc::text::{CharStyle, TextObject};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
for i in 0..n {
let at = vectorcraft_geom::Point::new(10.0 + (i % 8) as f64 * 48.0, 30.0 + (i / 8) as f64 * 30.0);
let style = CharStyle { size: 18.0, ..Default::default() };
let mut t = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(at, word, style))));
t.id = d.alloc_id();
d.insert(Some(l), 0, t).unwrap();
}
d
}
/// The cache keys of `d`'s type objects (their nodes' addresses).
fn text_keys(d: &Document) -> Vec<usize> {
let mut keys = vec![];
d.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
keys.push(n as *const Node as usize);
}
});
keys
}
/// More type than the glyph cache's budget holds: the entries kept stay from frame to frame (the
/// rest is laid out again), where emptying the cache when full laid out every object every frame.
#[test]
fn type_over_the_text_budget_keeps_its_entries_between_frames() {
let d = text_doc(40, "Type");
let opts = RenderOptions::default();
let mut r = Renderer::new();
let want = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels;
assert_eq!(r.texts.len(), 40);
let all = r.texts.bytes();
let mut r = Renderer::new();
r.texts = FrameCache::new(all / 2, 1024);
let keys = text_keys(&d);
let kept = |r: &Renderer| keys.iter().filter(|k| r.texts.contains_key(*k)).copied().collect::<Vec<_>>();
assert_eq!(r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels, want);
let first = kept(&r);
assert!(!first.is_empty() && first.len() < 40 && r.texts.bytes() <= all / 2, "{} entries, {} bytes", first.len(), r.texts.bytes());
for _ in 0..3 {
assert_eq!(r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels, want);
assert_eq!(kept(&r), first, "the same entries stay");
}
// Other type: its entries replace the earlier frames' ones, within the budget.
let e = text_doc(40, "Other");
r.render(&e, 400, 400, Affine::IDENTITY, &opts);
assert!(kept(&r).is_empty(), "entries of earlier frames went first");
assert!(!r.texts.is_empty() && r.texts.bytes() <= all / 2);
}
/// Type no longer drawn leaves the glyph cache after a few frames, and its bytes with it.
#[test]
fn swept_type_entries_give_back_their_bytes() {
let d = text_doc(12, "Swept");
let mut r = Renderer::new();
r.texts = FrameCache::new(lru::TEXT_BUDGET, 0);
r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions::default());
let full = r.texts.bytes();
assert!(full > 0 && r.texts.len() == 12);
let empty = Document::new(10.0, 10.0);
for _ in 0..5 {
r.render(&empty, 10, 10, Affine::IDENTITY, &RenderOptions::default());
}
assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0));
}
/// Each run draws exactly the outlines it drew when kept as a path of its own: its glyphs and
/// decorations in layout order (the whole text's path is kept once, the runs as spans of it).
#[test]
fn text_runs_draw_their_own_glyphs_from_the_shared_outlines() {
use vectorcraft_doc::text::{CharStyle, TextObject, TextRun};
let plain = CharStyle { size: 24.0, ..Default::default() };
let under = CharStyle { underline: true, ..plain.clone() };
let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Ab", plain.clone());
t.runs.push(TextRun::new("cd", under));
t.runs.push(TextRun::new("ef", plain));
let g = text_geom(&t);
let db = vectorcraft_text::FontDb::global();
let layout = vectorcraft_text::layout(db, &t);
let mut want = vec![BezPath::new(); t.runs.len()];
for gl in &layout.glyphs {
want[gl.run].extend(gl.outline.iter());
}
for (run, bar) in vectorcraft_text::decorations(&layout, db, &t) {
want[run].extend(bar.iter());
}
for (i, w) in want.iter().enumerate() {
assert_eq!(g.run(i).elements(), w.elements(), "run {i}");
}
assert!(g.run(7).elements().is_empty());
}