Render: a compound path's members are assembled once and kept with the node

A compound path was assembled from its members in every frame it was
drawn, and Node::stroke_path grew its buffer by doubling, copying the
path into ever larger buffers for a compound path of many anchors. The
renderer's geometry cache was swept only once it held more than 1,024
entries, so while an object was dragged (a new node, and layer, in
every frame) the old copies stayed alive with all their members.

Now:
- a compound path is drawn from the path the renderer keeps for it in
  its geometry cache, as a plain path already was, and is assembled
  only when the node changes;
- Node::stroke_path sizes a compound path's buffer up front from its
  members' segment counts (the same elements, in the same order);
- unused geometry entries are dropped after a few frames however few
  entries there are.

Tests:
- crates/render/src/tests/memory.rs: the second frame draws the kept
  path and the same pixels as the first and as a new renderer; after a
  five-frame move the renderer no longer holds the compound path drawn
  before it. The test fails without the renderer change.
- crates/doc/src/node/tests.rs: a compound path's path is its members'
  paths in order (open, closed, empty and single-anchor members).

Not timed; vectorcraft-cli perf was not rerun.
This commit is contained in:
Ahmed Gomaa
2026-11-03 16:03:40 +07:00
committed by bflatastic
parent f0e72fb11a
commit 1d011ab8ad
6 changed files with 99 additions and 17 deletions
+5 -3
View File
@@ -172,9 +172,11 @@ impl Renderer {
/// so the nodes they hold go with them).
fn end_frame(&mut self) {
let g = self.stamp;
if self.geom.len() > 1024 {
self.geom.retain(|_, e| g - e.stamp <= 3);
}
// Swept however few the entries: each keeps its node alive, and an object dragged or
// edited makes a new node (with its layer) in every frame. Kept until there were 1,024 of
// them, a drag left up to that many old copies of the object alive, each with all its
// members (and, for a compound path, its assembled path).
self.geom.retain(|_, e| g - e.stamp <= 3);
if self.strokes.len() > 1024 {
self.strokes.retain(|_, e| g - e.stamp <= 3);
}
+16 -11
View File
@@ -114,7 +114,16 @@ impl Renderer {
/// The cached path of path or compound path `n` ([`Self::path_of`]) when the frame is in
/// Outline view and `n` is `fx_owner`, else `None`.
fn outline_path(&mut self, f: &Frame, n: &Node) -> Option<Arc<BezPath>> {
let owner = self.fx_owner.as_ref().filter(|o| f.opts.outline && std::ptr::eq(&***o, n)).cloned()?;
if !f.opts.outline {
return None;
}
self.owner_path(f, n)
}
/// The cached path of path or compound path `n` ([`Self::path_of`]) when `n` is `fx_owner`,
/// else `None`.
fn owner_path(&mut self, f: &Frame, n: &Node) -> Option<Arc<BezPath>> {
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned()?;
self.path_of(f.symbols, &owner)
}
@@ -470,17 +479,13 @@ impl Renderer {
self.draw_shape(ctx, f, n, &bp, *rule);
}
}
NodeKind::Compound { children, rule } => {
if let Some(bp) = self.outline_path(f, n) {
return self.draw_shape(ctx, f, n, &bp, *rule);
NodeKind::Compound { rule, .. } => {
// Its members assembled once and kept with the node in `geom`, as plain paths are
// (`path_of`): a compound path of thousands of members is not assembled again in
// every frame.
if let Some(bp) = self.owner_path(f, n).or_else(|| n.stroke_path().map(Arc::new)) {
self.draw_shape(ctx, f, n, &bp, *rule);
}
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
self.draw_shape(ctx, f, n, &bp, *rule);
}
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(n, f.ink), n.extras().pixelated),
+44
View File
@@ -2378,3 +2378,47 @@ fn text_runs_draw_their_own_glyphs_from_the_shared_outlines() {
}
assert!(g.run(7).elements().is_empty());
}
/// A compound path is drawn from the path the renderer keeps for it, as a plain path is: the next
/// frame draws the same path, and draws what a new renderer draws.
/// Once the compound path changes (each frame of a drag makes a new one), the entries of the old
/// one, which keep it alive, go after a few frames, however few entries the renderer holds.
#[test]
fn compound_paths_draw_from_kept_paths_and_let_old_ones_go() {
let _fonts = fonts_steady();
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
let members: Vec<Arc<Node>> = (0..30)
.map(|i| {
let x = f64::from(i % 6) * 15.0;
let y = f64::from(i / 6) * 15.0;
let p = vectorcraft_geom::shapes::ellipse(Rect::new(x, y, x + 10.0, y + 10.0));
Arc::new(Node::path(d.alloc_id(), p, vectorcraft_doc::Appearance::default()))
})
.collect();
let id = d.alloc_id();
let mut compound = Node::new(id, NodeKind::Compound { children: members, rule: vectorcraft_geom::FillRule::EvenOdd });
compound.appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
d.insert(Some(layer), 0, compound).unwrap();
let opts = RenderOptions::default();
let kept = |r: &Renderer, d: &Document| {
let n = d.layers[0].children().unwrap()[0].clone();
r.geom.get(&(Arc::as_ptr(&n) as usize)).and_then(|e| e.path.clone())
};
let mut r = Renderer::new();
let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels;
let path = kept(&r, &d).expect("the compound path's path is kept");
assert!(r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels == first);
assert!(kept(&r, &d).is_some_and(|p| Arc::ptr_eq(&p, &path)), "the second frame draws the kept path");
assert!(first == Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels);
// Moved: a new compound path (and layer) in each frame.
let old = d.layers[0].children().unwrap()[0].clone();
let mut moved = d.clone();
for _ in 0..5 {
moved.node_mut(id).unwrap().transform(Affine::translate((1.0, 0.0)), false);
r.render(&moved, 100, 100, Affine::IDENTITY, &opts);
}
drop(d);
assert!(r.geom.len() < 1024);
assert_eq!(Arc::strong_count(&old), 1, "the renderer no longer keeps the compound path drawn before the move");
}