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
+7 -2
View File
@@ -185,8 +185,13 @@ impl Node {
match &self.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
NodeKind::Compound { children, .. } => {
let mut bp = vectorcraft_geom::BezPath::new();
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
// Sized up front: grown by doubling, the path of a compound path of hundreds of
// thousands of anchors is copied into ever larger buffers, up to tens of MB, on
// every call.
let subpaths = || children.iter().filter_map(|c| c.path_data()).flat_map(|pd| &pd.subpaths);
let elements = subpaths().filter(|sp| !sp.anchors.is_empty()).map(|sp| 1 + sp.segment_count() + usize::from(sp.closed)).sum();
let mut bp = vectorcraft_geom::BezPath::from_vec(Vec::with_capacity(elements));
subpaths().for_each(|sp| sp.to_bezpath_into(&mut bp));
Some(bp)
}
_ => None,
+20
View File
@@ -546,3 +546,23 @@ fn fields_before_the_type_are_buffered_within_the_value_budget() {
assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}");
assert!(read(reordered).is_ok(), "no budget outside a read");
}
/// A compound path's path is its members' paths one after another, open and closed, empty
/// members and single anchors included.
#[test]
fn compound_stroke_path_is_its_members_in_order() {
use vectorcraft_geom::{Anchor, PathData, SubPath};
let open = PathData::single(SubPath::new([(0.0, 0.0), (5.0, 1.0), (9.0, 7.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), false));
let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(3.0, 3.0))], false));
let members =
[shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), open, PathData::default(), lone, shapes::ellipse(Rect::new(10.0, 10.0, 20.0, 16.0))];
let mut expected = vectorcraft_geom::BezPath::new();
for pd in &members {
expected.extend(pd.to_bezpath());
}
let children = members.iter().enumerate().map(|(i, pd)| Arc::new(Node::path(NodeId(i as u64 + 1), pd.clone(), Appearance::default()))).collect();
let n = Node::new(NodeId(99), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::EvenOdd });
let bp = n.stroke_path().unwrap();
assert_eq!(bp.elements(), expected.elements());
assert!(!bp.elements().is_empty());
}
+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");
}
+7 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (G15: clicks on paths and compound paths are culled by their control points; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (G15: a compound path's assembled path is kept between frames; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -619,6 +619,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G15 Performance budgets and a GPU renderer
- **Improved (2026-11-02):** the renderer keeps a compound path's assembled path with the node, as it keeps a plain
path's, instead of assembling its members again in every frame; the path is assembled into a buffer sized up front.
Unused geometry entries are dropped after a few frames however few there are (before, only past 1,024 entries), so
the copies a drag makes in every frame no longer stay alive with all their members
(`crates/render/src/tests/memory.rs`, `crates/doc/src/node/tests.rs`). Not timed; `vectorcraft-cli perf` not rerun.
- **Improved (2026-11-02):** a click or hover culls paths and compound paths by the box of their control points
(grown by their strokes' reach) instead of measuring along their curves, and tests a compound path's outline and
fill against only the members whose control box, grown by the tolerance, holds the point; painted strokes are
@@ -724,6 +729,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
| 2026-10-31 | minor | G12: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but not acted on. Estimate unchanged. |