Merge pull request #5262 from thisisgomaa/perf/compound-path-geom-cache

Render: a compound path's members are assembled once and kept with the node
This commit is contained in:
BFlat
2026-11-03 16:36:11 +07:00
committed by GitHub
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. |