diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 755cb51c4..07899196c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -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, diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 882018e1d..1669de1a2 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -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()); +} diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 218ef0620..4d619e045 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -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); } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 25f626091..79455cd75 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -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> { - 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> { + 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), diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 8b3648604..f0c4b40e1 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -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> = (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"); +} diff --git a/docs/gaps.md b/docs/gaps.md index dc081640d..783dc9711 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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. |