Merge pull request #7109 from dotCooCoo/fix/painted-fx-brush-reach

Effects: culling a painted piece leaves room for its own brush art
This commit is contained in:
BFlat
2026-11-13 23:01:55 +07:00
committed by GitHub
7 changed files with 397 additions and 21 deletions
+15
View File
@@ -87,6 +87,8 @@ pub struct SymbolBoxes<'a> {
sliced_placed: Cell<u64>,
/// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`].
sliced_most: u64,
/// The widths [`Self::measured_width`] has measured, by kind and symbol name.
widths: RefCell<HashMap<(&'static str, String), Option<f64>>>,
}
impl Document {
@@ -107,6 +109,7 @@ impl Document {
sliced: RefCell::default(),
sliced_placed: Cell::new(0),
sliced_most: MAX_SYMBOL_NODES,
widths: RefCell::default(),
}
}
}
@@ -247,6 +250,18 @@ impl<'a> SymbolBoxes<'a> {
self.art(name)?.brush
}
/// The width `measure` gives for the symbol named `name`, measured once for these boxes for each
/// `kind`, which names what `measure` measures. `measure` may call this again for other names.
pub fn measured_width(&self, kind: &'static str, name: &str, measure: impl FnOnce() -> Option<f64>) -> Option<f64> {
let key = (kind, name.to_string());
if let Some(w) = self.widths.borrow().get(&key) {
return *w;
}
let w = measure();
self.widths.borrow_mut().insert(key, w);
w
}
/// How far the visual box of an instance of the symbol named `name` grows in boxes made by
/// [`Self::with_brush_art`]: `16 × max(w, 1) + 8` for the widest stroke `w` that
/// [`Self::brush_width`] finds, as far as the renderer grows the cull bounds of a brushed path
+13 -4
View File
@@ -125,9 +125,15 @@ impl BrushCache {
}
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs), symbol
/// instances measured by their art from `symbols`, with the brushed strokes in that art.
/// instances measured by their art from `symbols`, with the brushed strokes in that art. A path
/// whose live-effect plug-ins paint their pieces leaves that room past each piece
/// ([`crate::fx::painted_cull_bounds`]). A group or layer leaves that room past each of its
/// members, as the renderer measures a group by its members (`Renderer::bounds_of`).
pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = crate::fx::cull_bounds(symbols, n)?;
if let Some(b) = crate::fx::painted_cull_bounds(symbols, n) {
return b;
}
let b = crate::fx::brushed_cull_bounds(symbols, n)?;
let pad = pad(symbols, n);
Some(b.inflate(pad, pad))
}
@@ -145,12 +151,15 @@ 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
/// ([`SymbolBoxes::brush_width`]), and 0 when neither has 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.
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 art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.brush_width(symbol).map(|w| w.max(1.0)),
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)
+4 -1
View File
@@ -70,7 +70,10 @@ mod renderer;
#[cfg(test)]
pub(crate) use bounds::visual_bounds;
pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds, three_d_art_bounds, with_own_raster_reach};
pub(crate) use bounds::{
brushed_cull_bounds, cull_bounds, drawn_outline, drawn_outlines, painted_brush_width, painted_cull_bounds, painted_reach, path_cull_bounds,
three_d_art_bounds, with_own_raster_reach,
};
#[cfg(test)]
pub(crate) use entries::Drawn;
pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry};
+125 -16
View File
@@ -99,6 +99,72 @@ fn painted_art(symbols: &SymbolBoxes, n: &Node) -> Option<Node> {
effects::has_painted_effects(n).then(|| effects::reshape(Some(symbols.document()), n, None)).flatten()
}
/// Calls `f` on each object in `art`, the art [`painted_art`] makes, that has a visible brushed
/// stroke, looking into the visible members of its groups. Hidden objects are left out.
fn each_brushed_piece(art: &Node, f: &mut impl FnMut(&Node)) {
if !art.visible {
return;
}
match &art.kind {
NodeKind::Group { children, .. } => children.iter().for_each(|c| each_brushed_piece(c, f)),
_ if vectorcraft_brush::has_brush(art) => f(art),
_ => {}
}
}
/// [`cull_bounds`] of path or compound path `n` whose live-effect plug-ins paint their pieces
/// ([`painted_art`]), with room for brush art past each piece's brushed strokes
/// ([`crate::brush_fx::pad`] of that piece, from the piece's own stroke widths) and the reach of
/// `n`'s raster effects past them. `None` when the renderer measures `n` otherwise: it has no such
/// pieces, or has Crop Marks, Vector Halftone or a 3D effect.
pub(crate) fn painted_cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Option<Rect>> {
if !effects::has_painted_effects(n)
|| effects::has_crop_marks(n)
|| effects::addons::halftone::has_halftone(n)
|| effects::has_revolve(n)
|| effects::has_solid(n)
{
return None;
}
let art = painted_art(symbols, n)?;
let mut b = art.visual_bounds_with(symbols);
each_brushed_piece(&art, &mut |p| {
let pad = crate::brush_fx::pad(symbols, p);
b = vectorcraft_geom::union_opt(b, p.visual_bounds_with(symbols).map(|r| r.inflate(pad, pad)));
});
Some(b.map(|b| with_raster_reach(symbols, n, b)))
}
/// The widest stroke of the pieces with a visible brushed stroke that live-effect plug-ins paint
/// ([`painted_art`]) for the paths in the art of the symbol named `symbol`, and in the art of the
/// symbols the instances in it show, each symbol measured once for `symbols`
/// ([`SymbolBoxes::measured_width`]). `None` when no such piece has one; symbols whose art has no
/// visible effect ([`SymbolBoxes::has_effects`]) are not looked at.
pub(crate) fn painted_brush_width(symbols: &SymbolBoxes, symbol: &str) -> Option<f64> {
symbols.measured_width("painted brush", symbol, || {
if !symbols.has_effects(symbol) {
return None;
}
let mut width: Option<f64> = None;
symbols.measure_art(symbol, |art| {
art.walk(&mut |n| {
let nested = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => painted_brush_width(symbols, symbol),
_ => None,
};
width = width.into_iter().chain(nested).reduce(f64::max);
if let Some(pieces) = painted_art(symbols, n) {
each_brushed_piece(&pieces, &mut |p| {
let widths = p.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None });
width = Some(widths.fold(width.unwrap_or(0.0), f64::max));
});
}
})
});
width
})
}
/// Does `item` paint (as [`Renderer::paint_items`] draws it)?
fn paints(item: &AppearanceItem) -> bool {
item.visible() && !item.paint().is_none()
@@ -152,8 +218,18 @@ fn spread(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> Rect {
}
/// [`drawn_bounds`] before the raster effects of `n`'s fills and strokes: its art, and the art of
/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches).
/// its brushed strokes (a symbol's brushed art as far as [`crate::brush_fx::pad`] reaches). A path
/// whose live-effect plug-ins paint their pieces is measured by those pieces, each with the art
/// its brushed strokes draw along it.
fn art_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = painted_art(symbols, n) {
let mut b = art.visual_bounds_with(symbols);
each_brushed_piece(&art, &mut |p| {
let brushed = vectorcraft_brush::expand(symbols.document(), p).and_then(|a| a.visual_bounds_with(symbols));
b = vectorcraft_geom::union_opt(b, brushed);
});
return b;
}
let b = shape_bounds(symbols, n);
let brushed = vectorcraft_brush::has_brush(n)
.then(|| vectorcraft_brush::expand(symbols.document(), n))
@@ -180,9 +256,6 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n),
_ if has_fx(n) => {
if let Some(art) = painted_art(symbols, n) {
return art.visual_bounds_with(symbols);
}
let g = effected_path(symbols.document(), n, &node_bezpath(n)?);
// The path as the fills and strokes without geometry effects of their own paint it
// (as every fill and stroke would when none paints).
@@ -396,7 +469,28 @@ fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 {
/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are
/// measured by their art from `symbols`.
pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
cull_bounds_in(symbols, n, true)
cull_bounds_in(symbols, n, true, false)
}
/// [`cull_bounds`] with room for the brush art of the members of the groups and layers in `n`, each
/// member measured as [`crate::brush_fx::cull_bounds`] measures it. The room for `n`'s own brushed
/// strokes is left to the caller.
pub(crate) fn brushed_cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
cull_bounds_in(symbols, n, true, true)
}
/// [`cull_bounds_in`] of `c`, a member of a group or layer; with `brush`, grown by the room its
/// brushed strokes leave for brush art, as [`crate::brush_fx::cull_bounds`] measures it.
fn member_cull_bounds(symbols: &SymbolBoxes, c: &Node, after_reshape: bool, brush: bool) -> Option<Rect> {
if !brush {
return cull_bounds_in(symbols, c, after_reshape, false);
}
if let Some(b) = painted_cull_bounds(symbols, c) {
return b;
}
let b = cull_bounds_in(symbols, c, after_reshape, true)?;
let pad = crate::brush_fx::pad(symbols, c);
Some(b.inflate(pad, pad))
}
/// [`cull_bounds`] of path or compound path `n` made from `visual`, its visual bounds as
@@ -420,16 +514,21 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO
/// with geometry effects of its own are measured on the art as those effects reshape it
/// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art
/// before those effects run ([`art_effects_reach`]), which `symbols` keeps for each symbol and
/// linear part.
fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Option<Rect> {
let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape);
/// linear part. With `brush` true, the members of a group or layer are measured with the room
/// their brushed strokes leave for brush art ([`member_cull_bounds`]).
fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool, brush: bool) -> Option<Rect> {
let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape, false);
let member = |c: &Node| member_cull_bounds(symbols, c, after_reshape, brush);
// The art that a group's or layer's crop marks or Vector Halftone draw holds its members. The
// caller leaves the room for an object's own brushed strokes.
let held = |art: &Node| cull_bounds_in(symbols, art, after_reshape, brush && is_container(n));
match &n.kind {
// The object and its crop marks.
_ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)),
_ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| held(&art)),
// The object Vector Halftone samples, and as far as its dots reach past it.
_ if effects::addons::halftone::has_halftone(n) => {
let reach = effects::addons::halftone::reach(n);
inner(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach))
held(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach))
}
// A 3D effect's art, as the renderer draws it, and the raster effects' reach: the solid of
// type or of a group's members reaches past the letters or members the arms below measure.
@@ -445,12 +544,12 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio
let b = if art.clips() {
art.visual_bounds_with(symbols)
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c)))
}?;
Some(with_own_raster_reach(symbols, n, b))
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, member(c)))
}
_ if has_fx(n) => visual_bounds(symbols, n),
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art; type's
@@ -568,14 +667,24 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option<Rect> {
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far
/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any
/// other kind of object.
/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side; for a path
/// whose live-effect plug-ins paint their pieces, how far its pieces and their brush art reach past
/// it ([`painted_cull_bounds`]). 0 for any other kind of object.
fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
if !vectorcraft_doc::live::is_live(n) {
return 0.0;
}
let mut reach = 0.0f64;
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c)));
n.walk(&mut |c| {
let own = match painted_cull_bounds(symbols, c) {
Some(b) => match (b, c.visual_bounds_with(symbols)) {
(Some(b), Some(v)) => [v.x0 - b.x0, v.y0 - b.y0, b.x1 - v.x1, b.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
},
None => art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c),
};
reach = reach.max(own);
});
reach
}
@@ -628,7 +737,7 @@ fn reshaped_art_effects_reach(symbols: &SymbolBoxes, n: &Node, r: &Node) -> f64
if !symbols.has_effects(symbol) {
return 0.0;
}
let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false)));
let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false, false)));
match (art, r.visual_bounds_with(symbols)) {
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
+1
View File
@@ -27,6 +27,7 @@ mod longstroke;
mod memory;
mod movedstroke;
mod objectfx;
mod paintedbrush;
mod placed_document;
mod rasterize;
mod setup;
+238
View File
@@ -0,0 +1,238 @@
//! A path whose live-effect plug-in paints its pieces draws each piece's brushed stroke along that
//! piece, at the piece's stroke width. Culling leaves room for that brush art past each piece, in a
//! symbol instance and a live object too, and Object › Rasterize measures it.
use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeKind, Symbol};
use vectorcraft_geom::{Affine, PathData, Point, SubPath};
use crate::{RenderOptions, Renderer};
const BRUSH: &str = "3 pt. Round";
/// A 20 × 20 square at `x`, 40.
fn square_at(x: f64) -> PathData {
PathData::single(SubPath::polyline(&[Point::new(x, 40.0), Point::new(x + 20.0, 40.0), Point::new(x + 20.0, 60.0), Point::new(x, 60.0)], true))
}
/// Installs live-effect plug-in `id`, which declares `paintOutput` and returns one piece: the
/// square at 120, 40 with a black stroke 10 pt wide.
fn install(id: &str) {
use vectorcraft_plugins::wat as tpl;
let paint = serde_json::to_value(Paint::solid(Color::BLACK)).unwrap();
let output = json!({"objects": [{"path": square_at(120.0), "strokes": [{"paint": paint, "width": 10.0}]}]}).to_string();
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#);
let module = wat::parse_str(tpl::returning(&manifest, &output)).unwrap();
vectorcraft_plugins::registry::install_bytes(&module).unwrap();
}
/// A document whose brush `3 pt. Round` paints a round nib 10 pt across for each point of stroke
/// weight: 100 pt across on a piece's 10 pt stroke, reaching from 70 to 190 across the piece.
fn document() -> Document {
let mut d = Document::new(300.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == BRUSH).map(|b| &mut b.kind) else {
panic!("the {BRUSH} brush")
};
c.size = 10.0;
vectorcraft_brush::store(&mut d, &lib);
d
}
/// The square at 0, 40 with a black 1 pt stroke, painted by the brush when `brushed`, with
/// live-effect plug-in `plugin` as its own effect, or as its stroke's when `on_stroke`.
fn source(d: &mut Document, plugin: &str, on_stroke: bool, brushed: bool) -> Node {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
let effect = Effect { id: format!("plugin.{plugin}"), params: json!({}), visible: true };
let stroke = ap.stroke_mut().unwrap();
stroke.brush = brushed.then(|| BRUSH.to_string());
if on_stroke {
stroke.effects = vec![effect];
} else {
ap.effects = vec![effect];
}
Node::path(d.alloc_id(), square_at(0.0), ap)
}
/// `d` with `n` added to its first layer.
fn with(mut d: Document, n: Node) -> Document {
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// Does a new renderer draw `d` dark at (`x` + 5, 50), in a view of 10 × 20 pt from (`x`, 40)?
fn dark_at(d: &Document, x: f64) -> bool {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let img = Renderer::new().render(d, 10, 20, Affine::translate((-x, -40.0)), &opts);
let p = img.pixel(5, 10);
(p[0] as u32 + p[1] as u32 + p[2] as u32) < 200
}
/// The brush art of the piece reaches 50 pt past it, to 190; the room left for the source's 1 pt
/// brushed stroke ends at 169. A view from 175 shows that art whether the plug-in is the path's own
/// effect or its stroke's, and culling leaves `16 × 10 + 8` pt past the piece's 10 pt stroke. A
/// renderer that draws the whole document draws it there too.
#[test]
fn a_painted_piece_is_culled_by_its_own_brush_art() {
for (id, on_stroke) in [("test.render.paintedbrush.object", false), ("test.render.paintedbrush.stroke", true)] {
install(id);
let mut d = document();
let n = source(&mut d, id, on_stroke, true);
let d = with(d, n.clone());
assert!(dark_at(&d, 175.0), "on the stroke: {on_stroke}; the brush art past the source's room is drawn");
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let whole = Renderer::new().render(&d, 300, 100, Affine::IDENTITY, &opts).pixel(180, 50);
assert!((whole[0] as u32 + whole[1] as u32 + whole[2] as u32) < 200, "on the stroke: {on_stroke}; {whole:?}");
let b = crate::brush_fx::cull_bounds(&d.symbol_boxes(), &n).unwrap();
assert!(b.x0 <= -52.9 && b.x1 >= 312.9 && b.y0 <= -132.9 && b.y1 >= 232.9, "on the stroke: {on_stroke}; {b:?}");
vectorcraft_plugins::registry::remove(id);
}
}
/// Object › Rasterize measures the brush art drawn along the piece, from 70 to 190, and not the
/// brush art of the source, which is not drawn.
#[test]
fn a_painted_piece_s_brush_art_is_in_its_drawn_bounds() {
const ID: &str = "test.render.paintedbrush.crop";
install(ID);
let mut d = document();
let n = source(&mut d, ID, false, true);
let d = with(d, n.clone());
let b = crate::drawn_bounds(&d, &n).unwrap();
assert!(b.x0 > 65.0 && b.x0 < 75.0 && b.x1 > 185.0 && b.x1 < 195.0, "{b:?}");
vectorcraft_plugins::registry::remove(ID);
}
/// An instance of a symbol whose art is the path, and a one-by-one grid repeat of it, are drawn
/// where the piece's brush art reaches.
#[test]
fn a_symbol_and_a_repeat_holding_a_painted_path_are_culled_by_its_pieces_brush_art() {
const ID: &str = "test.render.paintedbrush.held";
install(ID);
let mut d = document();
let art = source(&mut d, ID, false, true);
d.symbols.push(Symbol { dynamic: false, name: "Painted".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Painted".into(), xf: Affine::IDENTITY });
let d = with(d, instance);
assert!(dark_at(&d, 175.0), "an instance");
let mut d = document();
let held = source(&mut d, ID, false, true);
let kind = vectorcraft_doc::RepeatKind::Grid {
h_spacing: 0.0,
v_spacing: 0.0,
rows: 1,
cols: 1,
grid_type: vectorcraft_doc::TileType::Grid,
flip_rows: false,
flip_cols: false,
};
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec { kind, source: vec![Arc::new(held)] }));
let d = with(d, repeat);
assert!(dark_at(&d, 175.0), "a repeat");
vectorcraft_plugins::registry::remove(ID);
}
/// The room an instance leaves for the pieces' brush art is measured once for a symbol's art: the
/// second instance measured with the same boxes measures no art.
#[test]
fn the_pieces_brush_width_is_measured_once_for_a_symbol() {
const ID: &str = "test.render.paintedbrush.once";
install(ID);
let mut d = document();
let art = source(&mut d, ID, false, true);
d.symbols.push(Symbol { dynamic: false, name: "Painted".into(), art: Arc::new(art) });
let instance =
|d: &mut Document, x: f64| Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Painted".into(), xf: Affine::translate((x, 0.0)) });
let (a, b) = (instance(&mut d, 0.0), instance(&mut d, 30.0));
let symbols = d.symbol_boxes();
assert_eq!(crate::brush_fx::pad(&symbols, &a), 168.0, "16 × the piece's 10 pt + 8");
let measured = symbols.art_measured();
assert_eq!(crate::brush_fx::pad(&symbols, &b), 168.0);
assert_eq!(symbols.art_measured(), measured);
vectorcraft_plugins::registry::remove(ID);
}
/// The square at 0, 40 with a black 1 pt stroke painted by the brush, without effects.
fn brushed_path(d: &mut Document) -> Node {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some(BRUSH.to_string());
Node::path(d.alloc_id(), square_at(0.0), ap)
}
/// A group leaves the room each member's brushed strokes leave for brush art, as the renderer
/// measures the group by its members: a plain brushed path (`16 × 1 + 8` pt past its 1 pt stroke),
/// a painted path (the room past its piece), and the brushed path in a group with an effect of its
/// own.
#[test]
fn a_group_is_culled_by_its_members_brush_art() {
const ID: &str = "test.render.paintedbrush.group";
install(ID);
for painted in [false, true] {
let mut d = document();
let member = if painted { source(&mut d, ID, false, true) } else { brushed_path(&mut d) };
let g = Node::group(d.alloc_id(), vec![Arc::new(member.clone())]);
let d = with(d, g);
let g = d.layers[0].children().unwrap()[0].clone();
let symbols = d.symbol_boxes();
let own = crate::brush_fx::cull_bounds(&symbols, &member);
assert!(own.is_some_and(|b| b.x0 < -24.0), "painted: {painted}; {own:?}");
assert_eq!(crate::brush_fx::cull_bounds(&symbols, &g), own, "painted: {painted}");
assert_eq!(crate::painted_bounds(&d, &g), own, "painted: {painted}");
assert_eq!(Renderer::new().bounds_of(&symbols, &g), own, "painted: {painted}");
}
vectorcraft_plugins::registry::remove(ID);
// A 4 pt stroke leaves `16 × 4 + 8` pt, past the crop marks. An empty effect id stands for a
// stroke of the group's own.
for effect in ["adjust.hueSaturation", "cropMarks", ""] {
let mut d = document();
let mut member = brushed_path(&mut d);
member.appearance.stroke_mut().unwrap().width = 4.0;
let mut g = Node::group(d.alloc_id(), vec![Arc::new(member.clone())]);
if effect.is_empty() {
g.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0).into();
} else {
g.appearance.effects = vec![Effect { id: effect.into(), params: json!({}), visible: true }];
}
let d = with(d, g);
let g = d.layers[0].children().unwrap()[0].clone();
assert!(crate::fx::has_object_fx(&g), "{effect}: an effect of the group's own");
let own = crate::brush_fx::cull_bounds(&d.symbol_boxes(), &member).unwrap();
assert!(own.x0 <= -73.9, "{own:?}");
let b = crate::painted_bounds(&d, &g);
assert!(b.is_some_and(|b| b.union(own) == b), "{effect}: {b:?} holds {own:?}");
}
}
/// A layer leaves the room its members' brushed strokes leave for brush art.
#[test]
fn a_layer_is_culled_by_its_members_brush_art() {
let mut d = document();
let member = brushed_path(&mut d);
let d = with(d, member.clone());
let layer = d.layers[0].clone();
let symbols = d.symbol_boxes();
let own = crate::brush_fx::cull_bounds(&symbols, &member);
assert!(own.is_some_and(|b| b.x0 <= -24.4 && b.x1 >= 44.4), "{own:?}");
assert_eq!(crate::brush_fx::cull_bounds(&symbols, &layer), own);
assert_eq!(crate::painted_bounds(&d, &layer), own);
assert_eq!(Renderer::new().bounds_of(&symbols, &layer), own);
}
/// Pieces without a brushed stroke are culled by the pieces alone, as before.
#[test]
fn painted_pieces_without_a_brush_take_no_room_for_one() {
const ID: &str = "test.render.paintedbrush.plain";
install(ID);
let mut d = document();
let n = source(&mut d, ID, false, false);
let d = with(d, n.clone());
let symbols = d.symbol_boxes();
let b = crate::brush_fx::cull_bounds(&symbols, &n);
assert_eq!(b, crate::fx::cull_bounds(&symbols, &n));
assert!(b.is_some_and(|b| b.x0 > 100.0 && b.x1 < 150.0), "{b:?}");
vectorcraft_plugins::registry::remove(ID);
}
+1
View File
@@ -1826,6 +1826,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **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.
- **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.
- **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.