From e086cf6d83c109e4c56b2e801f817f7540924ccc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 05:51:21 -0800 Subject: [PATCH 1/2] PDF, EPS and print: brush art painting onto the page keeps its path --- crates/brush/src/lib.rs | 27 +++++++++++++ crates/eps/src/scene.rs | 5 ++- crates/eps/src/tests.rs | 53 +++++++++++++++++++++++++ crates/pdf/src/export/nodes.rs | 13 +++++-- crates/pdf/src/print/layout.rs | 12 ++++-- crates/pdf/src/tests.rs | 71 ++++++++++++++++++++++++++++++++++ crates/pdf/src/tests/print.rs | 56 +++++++++++++++++++++++++++ crates/render/src/brush_fx.rs | 8 ++-- docs/mcp.md | 8 +++- docs/target-app-parity.md | 1 + 10 files changed, 240 insertions(+), 14 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 851afc9e7a..c3a532d54b 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -1001,6 +1001,33 @@ pub fn has_brush(n: &Node) -> bool { n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && s.visible)) } +/// How far brush art can paint past the visual box of `n`: `16 × w + 8`, where `w` is the widest +/// stroke of `n` (at least 1), when `n` has a visible brushed stroke with a paint; 0 when it has +/// none, as a brushed stroke without a paint draws no brush art. This is the room the renderer and +/// the exports leave for brush art when they cull. +pub fn reach(n: &Node) -> f64 { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.is_some() && !s.paint.is_none()); + if !n.appearance.items.iter().any(paints) { + return 0.0; + } + let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max); + 16.0 * w + 8.0 +} + +/// The largest [`reach`] of `n` and of the objects in it: the members of its groups, layers and +/// live objects. Of a group or layer with a clipping path only the clipping path counts, as the +/// clip keeps its members' brush art inside it. A compound path counts its own strokes only, as +/// it is painted with its own appearance and not its paths'. Symbol art is not looked into +/// ([`vectorcraft_doc::SymbolBoxes::with_brush_art`] measures it). +pub fn reach_within(n: &Node) -> f64 { + let members = match &n.kind { + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => children.get(..1).unwrap_or_default(), + NodeKind::Compound { .. } => &[], + _ => n.children().map_or(&[][..], |c| &c[..]), + }; + members.iter().map(|c| reach_within(c)).fold(reach(n), f64::max) +} + /// Expand a brushed path: a group of [the path with its fills and unbrushed strokes (if any /// paint remains), the brush art…]. Ids are 0 (the caller re-ids). `None` if not brushed. pub fn expand(doc: &Document, n: &Node) -> Option { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 299d130f7d..8d147cb2d1 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -489,8 +489,11 @@ impl<'a> Scene<'a> { self.warn(TOO_LARGE); return; } + // Off the region, an object is still written when brush art painted along its brushed + // strokes, or those of the objects in it, can reach the region. + let off = |b: Rect, reach: f64| !overlaps(b.inflate(1.0 + reach, 1.0 + reach), self.o.region); match n.visual_bounds_with(&self.symbols) { - Some(b) if !force && !overlaps(b.inflate(1.0, 1.0), self.o.region) => return, + Some(b) if !force && off(b, 0.0) && off(b, vectorcraft_brush::reach_within(n)) => return, None if !n.is_container() => return, _ => {} } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 8547c4d015..c8a4bee003 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1581,6 +1581,59 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { assert!(page.contains("1 0 0 rg"), "{page}"); } +#[test] +fn a_path_whose_brush_art_reaches_the_region_is_written() { + // A line from (-5, 40) to (-5, 60) with a 1 pt red stroke painted by a 20 pt round nib lies + // left of the artboard, and the nib paints 10 pt either side of it, onto the artboard: alone, in + // a group and as a compound path. Without its brush, or 100 pt left of the artboard, where the + // nib does not reach it, the line is left out. + let red = |x: f64, brushed: bool| { + let mut n = Node::path( + vectorcraft_doc::NodeId(0), + shapes::line((x, 40.0).into(), (x, 60.0).into()), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0), + ); + if brushed { + n.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + } + n + }; + let cases: [(&str, f64, bool, bool); 5] = [ + ("path", -5.0, true, true), + ("group", -5.0, true, true), + ("compound path", -5.0, true, true), + ("unbrushed path", -5.0, false, false), + ("far path", -100.0, true, false), + ]; + for (what, x, brushed, written) in cases { + let mut d = Document::new(200.0, 200.0); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else { + panic!("the 3 pt. Round brush") + }; + c.size = 20.0; + vectorcraft_brush::store(&mut d, &lib); + let line = red(x, brushed); + let mut n = match what { + "group" => Node::group(vectorcraft_doc::NodeId(0), vec![Arc::new(line)]), + "compound path" => { + let mut c = Node::new( + vectorcraft_doc::NodeId(0), + NodeKind::Compound { children: vec![Arc::new(red(x, false))], rule: vectorcraft_geom::FillRule::NonZero }, + ); + c.appearance = line.appearance; + c + } + _ => line, + }; + n.id = d.alloc_id(); + let layer = d.layers[0].id; + d.insert(Some(layer), usize::MAX, n).unwrap(); + let page = page_of(&d, &opts(&d)); + assert_eq!(page.contains("1 0 0 rg") || page.contains("1 0 0 RG"), written, "{what}: {page}"); + } +} + #[test] fn level_preview_and_overprint_ids_read_back() { for l in Level::ALL { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 78930c7500..403cbc5431 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -14,6 +14,11 @@ struct Measured { members: Vec, } +/// Box `b` grown by `reach`, the room brush art can paint past it; `b` itself when `reach` is 0. +fn brushed(b: Option, reach: f64) -> Option { + if reach > 0.0 { b.map(|b| b.inflate(reach, reach)) } else { b } +} + #[cfg(test)] std::thread_local! { /// The objects this thread has measured for the walk and for the links (tests). @@ -312,15 +317,17 @@ impl Exporter<'_> { krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) } - /// The box of `n`, and of the members of a plain group or layer. + /// The box of `n`, and of the members of a plain group or layer. A box grows by the room brush + /// art can paint past the brushed strokes of `n` and of the objects in it + /// ([`vectorcraft_brush::reach_within`]), as the renderer culls it. fn measure(&self, n: &Node) -> Measured { if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { #[cfg(test)] MEASURES.with(|m| m.set(m.get() + 1)); - return Measured { bounds: n.visual_bounds_with(&self.symbols), members: vec![] }; + return Measured { bounds: brushed(n.visual_bounds_with(&self.symbols), vectorcraft_brush::reach_within(n)), members: vec![] }; } let members: Vec<_> = n.editing_children().iter().map(|c| self.measure(c)).collect(); - Measured { bounds: n.visual_of_members(members.iter().map(|m| m.bounds)), members } + Measured { bounds: brushed(n.visual_of_members(members.iter().map(|m| m.bounds)), vectorcraft_brush::reach(n)), members } } pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) { diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index a88dd89950..5d28e9e2b1 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -71,7 +71,9 @@ pub struct TileGrid { /// Calls `f` with the bounds of each object of `n` that prints: visible, not on a template /// layer and not a guide (layers are already picked by the Print Layers option). Symbol -/// instances are measured by their art from `symbols`. +/// instances are measured by their art from `symbols`. A box grows by the room brush art can +/// paint past the brushed strokes of the object and of the objects in it +/// ([`vectorcraft_brush::reach_within`]). fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) { if !n.visible { return; @@ -85,7 +87,8 @@ fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) { } _ => { if let Some(b) = n.visual_bounds_with(symbols) { - f(b); + let reach = vectorcraft_brush::reach_within(n); + f(if reach > 0.0 { b.inflate(reach, reach) } else { b }); } } } @@ -102,9 +105,10 @@ fn has_art(doc: &Document, r: Rect) -> bool { any } -/// The bounds of all the art that prints. +/// The bounds of all the art that prints, with the brush art it paints. The box of a symbol +/// instance whose art holds brushed strokes grows as in [`has_art`]. fn art_bounds(doc: &Document) -> Option { - let symbols = doc.symbol_boxes(); + let symbols = doc.symbol_boxes().with_brush_art(); let mut all: Option = None; for l in &doc.layers { printed(l, &symbols, &mut |b| all = Some(all.map_or(b, |a| a.union(b)))); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index bee80238bd..9487e1bca3 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -246,6 +246,77 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); } +/// A 200 × 100 pt document whose 3 pt. Round brush paints a 20 pt round nib. +fn doc_with_wide_nib() -> Document { + let mut d = doc(200.0, 100.0); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else { + panic!("the 3 pt. Round brush") + }; + c.size = 20.0; + vectorcraft_brush::store(&mut d, &lib); + d +} + +/// A line from (`x`, 40) to (`x`, 60) with a 1 pt black stroke, painted by the 3 pt. Round brush +/// when `brushed`. +fn nib_line(x: f64, brushed: bool) -> Node { + let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + if brushed { + stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + } + Node::path(NodeId(0), shapes::line((x, 40.0).into(), (x, 60.0).into()), stroke) +} + +#[test] +fn a_path_whose_brush_art_reaches_the_page_is_written() { + // The line at x = -5 lies left of the page, and the 20 pt nib paints 10 pt either side of it, + // onto the page: alone, in a group, in a knockout group and as a compound path. + let compound = |n: Node| { + let mut c = Node::new(NodeId(0), NodeKind::Compound { children: vec![Arc::new(nib_line(-5.0, false))], rule: FillRule::NonZero }); + c.appearance = n.appearance; + c + }; + let knockout = |n: Node| { + let mut g = Node::group(NodeId(0), vec![Arc::new(n), Arc::new(rect_node(Rect::new(150.0, 10.0, 170.0, 30.0), Color::rgb(0.0, 0.0, 1.0)))]); + g.knockout = vectorcraft_doc::Knockout::On; + g + }; + let group = |n: Node| Node::group(NodeId(0), vec![Arc::new(n)]); + let wraps: [(&str, &dyn Fn(Node) -> Node); 4] = + [("path", &|n| n), ("group", &group), ("knockout group", &knockout), ("compound path", &compound)]; + for (what, wrap) in wraps { + let mut d = doc_with_wide_nib(); + add(&mut d, wrap(nib_line(-5.0, true))); + let out = roundtrip(&d); + let nib: Vec = leaves(&out).iter().filter_map(|n| n.geometric_bounds()).filter(|b| b.x1 < 100.0).collect(); + assert_eq!(nib.len(), 1, "{what}: the nib's art, {nib:?}"); + assert!(close(nib[0], Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{what}: {nib:?}"); + } + // Without its brush, or 100 pt left of the page, where the nib does not reach it, the line is + // left out. + for line in [nib_line(-5.0, false), nib_line(-100.0, true)] { + let mut d = doc_with_wide_nib(); + add(&mut d, line); + assert!(leaves(&roundtrip(&d)).is_empty()); + } + // A clipping path left of the page keeps the nib's art off it. + let mut d = doc_with_wide_nib(); + let clip = Node::new( + NodeId(0), + NodeKind::Path { + path: shapes::rectangle(Rect::new(-30.0, 0.0, -2.0, 100.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + add(&mut d, Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(nib_line(-5.0, true))], clip: true })); + let out = roundtrip(&d); + assert!(leaves(&out).is_empty(), "{:?}", leaves(&out)); +} + /// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas /// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20. #[test] diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 5c5e1eb2c0..d7277153b3 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -451,6 +451,62 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() { assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}"); } +#[test] +fn a_brushed_paths_brush_art_keeps_its_artboard_printed_and_framed() { + // A line from (-5, 40) to (-5, 60) with a 1 pt red stroke painted by a 20 pt round nib, alone, + // in a group or as the art of a symbol instance: the line lies left of the artboard, and the nib + // paints 10 pt either side of it, onto the artboard. + let line = || { + let mut stroke = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0); + stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + Node::path(vectorcraft_doc::NodeId(0), shapes::line((-5.0, 40.0).into(), (-5.0, 60.0).into()), stroke) + }; + for what in ["path", "group", "symbol instance"] { + let mut d = doc(100.0, 100.0, &[]); + let mut lib = vectorcraft_brush::library(&d); + let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else { + panic!("the 3 pt. Round brush") + }; + c.size = 20.0; + vectorcraft_brush::store(&mut d, &lib); + let mut n = match what { + "path" => line(), + "group" => Node::group(vectorcraft_doc::NodeId(0), vec![Arc::new(line())]), + _ => { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(line()) }); + Node::new(vectorcraft_doc::NodeId(0), NodeKind::SymbolInstance { symbol: "Nib".into(), xf: vectorcraft_geom::Affine::IDENTITY }) + } + }; + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + // Skip Blank Artboards keeps the artboard, and its page holds the nib's art. + let p = pages(&job(&d, json!({"skipBlank": true})).bytes); + assert_eq!(p.len(), 1, "{what}"); + assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{what}: {p:?}"); + // Ignoring artboards, the page holds all the nib's art, 20 × 40 pt. + let pv = preview(&d, &settings(json!({"artboards": "ignore"}))).unwrap(); + let t = pv.sheets[0].trim; + assert!(t[2] - t[0] >= 20.0 && t[3] - t[1] >= 40.0, "{what}: {t:?}"); + } + // A compound path is painted with its own appearance: the brushed stroke left on its path + // paints nothing, and the artboard stays blank. + let mut d = doc(100.0, 100.0, &[]); + let kind = NodeKind::Compound { children: vec![Arc::new(line())], rule: vectorcraft_geom::FillRule::NonZero }; + let mut compound = Node::new(d.alloc_id(), kind); + compound.appearance = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0).into(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, compound).unwrap(); + assert!(preview(&d, &settings(json!({"skipBlank": true}))).is_err()); + // A brushed stroke without a paint draws no brush art: the artboard stays blank. + let mut d = doc(100.0, 100.0, &[]); + let mut unpainted = line(); + unpainted.id = d.alloc_id(); + unpainted.appearance.stroke_mut().unwrap().paint = Paint::None; + d.insert(Some(layer), 0, unpainted).unwrap(); + assert!(preview(&d, &settings(json!({"skipBlank": true}))).is_err()); +} + #[test] fn copies_collate_and_reverse() { let mut d = diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 3c6ea922d4..fe7eb0f804 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -157,17 +157,17 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO /// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it /// has a visible brushed stroke and of the objects in that art that have one /// ([`SymbolBoxes::brush_width`]) or the pieces live-effect plug-ins paint for them -/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. +/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. The room for `n`'s own strokes +/// is [`vectorcraft_brush::reach`], which the exports leave too. pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 { - let own = vectorcraft_brush::has_brush(n) - .then(|| n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max)); + let own = vectorcraft_brush::reach(n); let art = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => { symbols.brush_width(symbol).into_iter().chain(crate::fx::painted_brush_width(symbols, symbol)).reduce(f64::max).map(|w| w.max(1.0)) } _ => None, }; - own.into_iter().chain(art).reduce(f64::max).map_or(0.0, |w| 16.0 * w + 8.0) + art.map_or(own, |w| own.max(16.0 * w + 8.0)) } pub use vectorcraft_brush::instance_art; diff --git a/docs/mcp.md b/docs/mcp.md index 0cbb30e0b6..2832e7f169 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5015,8 +5015,12 @@ when its symbol or a symbol nested in its art holds an instance of itself, and w nest 32 deep in its art. PDF and EPS export and the SVG of an artboard leave out art outside the page or artboard, and Skip Blank Artboards skips an artboard with no art on it. For both, the box of an instance whose art holds brushed strokes grows by 16 times the widest stroke (at least 1 pt) of the objects in its art that have a brushed stroke, -plus 8 pt. The SVG of an artboard also keeps an object whose effects paint onto the artboard, and an object that holds -an instance when the effects in the symbol's art, or the instance's own, paint onto it. +plus 8 pt. PDF and EPS export and Skip Blank Artboards grow the box of an object with a visible, painted brushed stroke the +same way, by its own widest stroke, and the box of an object that holds one by the largest such room among them (of a +clip group, only its clipping path counts). Print with Ignore Artboards frames the page around the art with the same +room for brushed objects and for instances whose art holds brushed strokes. The SVG of an artboard also keeps an +object whose effects paint onto the artboard, and an object that holds an instance when the effects in the symbol's +art, or the instance's own, paint onto it. ## Expand diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08ce46cebf..f0349613f4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -2003,6 +2003,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Later effects on every fill or stroke a painted effect returns (G17):** when a painted effect on a fill or stroke returns several fills or strokes, a later live-effect plug-in in that fill's or stroke's stack, and a later geometry effect on strokes, runs once on each of them in paint order, given its own paint and its own `_context.item` among them. Each run's pieces replace only that fill or stroke. A run that fails, is refused, is deferred or would pass the effects' budget leaves only its own fill or stroke as it was, and the others still run; a deferred stage is listed once, at the source fill's or stroke's place. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/effects/src/tests/painted.rs`. Scores and hours unchanged. - **Painted pieces with brushed strokes are culled and measured by their brush art (G17):** a piece that keeps the object's brushed stroke draws the brush along the piece's own path at the piece's stroke width. Culling leaves VectorCraft's usual room for brush art (16 × the stroke width + 8 pt) past each piece, from the piece's width rather than the object's, and so does culling of a group, layer, symbol instance, blend, envelope or repeat that holds the object. A group or layer also leaves that room for each brushed path among its members, painted or not. Object › Rasterize takes the brush art drawn along each piece and leaves out the object's own brush art, which is not drawn. Pieces without a brush are culled as before. Regressions: `crates/render/src/tests/paintedbrush.rs`. Scores and hours unchanged. +- **Brushed paths just off the page are exported and printed:** PDF and EPS export keep a path or compound path whose brush art reaches the page while the path lies just off it, alone or in a group, by leaving the canvas's room for brush art (16 × the widest stroke + 8 pt, VectorCraft's own value) past its box. Skip Blank Artboards keeps an artboard such brush art reaches, and Print with Ignore Artboards frames the page with that room around brushed paths and around instances whose art holds brushed strokes. A clip group leaves room only for its clipping path. Art without brushed strokes is written as before. Regressions: `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/print.rs`, `crates/eps/src/tests.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. - **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.rs`. Scores and hours unchanged. - **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. From c410e3fbb55bde577ab4336309c4b5396f6dd984 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:25:25 -0800 Subject: [PATCH 2/2] Brushes: the renderer's brush cull room counts only painted brushed strokes The doc comment on pad() now says a brushed stroke needs a paint to get room, as vectorcraft_brush::reach does. --- crates/render/src/brush_fx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index fe7eb0f804..5844a2ef7f 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -155,7 +155,7 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO /// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art /// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it -/// has a visible brushed stroke and of the objects in that art that have one +/// has a visible brushed stroke with a paint and of the objects in that art that have one /// ([`SymbolBoxes::brush_width`]) or the pieces live-effect plug-ins paint for them /// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. The room for `n`'s own strokes /// is [`vectorcraft_brush::reach`], which the exports leave too.