Plug-ins: a painted result names swatches and patterns of the document it draws in

The reference check now applies wherever a painted effect's geometry is
used: a result whose swatch or pattern is refused keeps its input instead
of giving its geometry. Pathfinder art of type, reshaped groups, compound
shapes, image fills, the renderer's bounds and Object > Expand look the
references up in the document, and the renderer drops its kept bounds and
geometry when the names of the swatches or patterns change.
This commit is contained in:
dotCooCoo
2026-11-08 09:04:23 -08:00
parent ca3527e780
commit 7aa7890a10
19 changed files with 306 additions and 94 deletions
+6 -5
View File
@@ -84,8 +84,9 @@ impl Default for HitOptions {
/// The shapes that an image's own fills and strokes paint, each with its fill rule and the fill or
/// stroke that paints it (none for fills and strokes that paint nothing). Effects are evaluated
/// above this crate, so the engine registers one with [`set_image_paint`].
pub type ImagePaint = fn(&Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
/// above this crate, so the engine registers one with [`set_image_paint`]. It looks up swatches and
/// patterns in the document it is given.
pub type ImagePaint = fn(Option<&Document>, &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>;
static IMAGE_PAINT: OnceLock<ImagePaint> = OnceLock::new();
@@ -98,8 +99,8 @@ pub fn set_image_paint(f: ImagePaint) {
/// The shapes that the image `n`'s own fills and strokes paint ([`set_image_paint`]), none while
/// nothing is registered.
pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
pub(crate) fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(doc, n))
}
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
@@ -860,7 +861,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
if opt.outline {
return None;
}
image_paint(n).iter().rev().find_map(|(bp, rule, item)| match item {
image_paint(Some(symbols.document()), n).iter().rev().find_map(|(bp, rule, item)| match item {
AppearanceItem::Stroke(s) => s.hits(bp, *rule, p, opt.tol).then_some(HitKind::Stroke),
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
+6 -4
View File
@@ -239,10 +239,12 @@ impl Node {
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.reach(symbols))),
// The frame and each shape the image's own fills and strokes paint, a stroke's grown by
// the farthest it can reach from its shape.
NodeKind::Image(_) => crate::hit::image_paint(self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
NodeKind::Image(_) => {
crate::hit::image_paint(symbols.map(SymbolBoxes::document), self).iter().fold(self.geometric(symbols), |acc, (bp, _, item)| {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
})
}
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
// keys and the spine.
NodeKind::Blend { children, spec } => {
+6 -2
View File
@@ -3,6 +3,7 @@ use super::{
Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize,
util, warp,
};
use vectorcraft_doc::Document;
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -22,6 +23,9 @@ pub struct GeomContext<'a> {
/// The index in `appearance.items` of the fill or stroke whose own effects these are; `None`:
/// the object's effects.
pub item: Option<usize>,
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
/// pieces are looked up in. Without one, a result that names any is refused.
pub doc: Option<&'a Document>,
}
impl<'a> GeomContext<'a> {
@@ -32,7 +36,7 @@ impl<'a> GeomContext<'a> {
_ => FillRule::NonZero,
};
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None }
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
@@ -224,7 +228,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont
use util::{join, num};
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
}
match id {
"distort.freeDistort" => distort::free_distort(path, b, p, max),
+8 -7
View File
@@ -203,12 +203,12 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
// with its own paints, as the renderer draws it.
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => {
let painted = crate::reshape::has_painted_effects(n).then(|| crate::reshape::reshape(doc, n, None)).flatten();
Some(painted.unwrap_or_else(|| reshaped(n, n)))
Some(painted.unwrap_or_else(|| reshaped(n, n, doc)))
}
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) {
Some(area) => Some(reshaped(&area, n)),
Some(area) => Some(reshaped(&area, n, doc)),
None => {
let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
crate::reshape::reshape_leaves(&mut m, &fx, doc);
without_geometry_effects(&mut m);
Some(m)
@@ -229,19 +229,20 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
_ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
crate::reshape::as_art_with(None, n, None, outline, nodes)
crate::reshape::as_art_with(None, doc, n, None, outline, nodes)
}
_ => None,
}
}
/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
/// geometry effects of `n`, without those effects.
fn reshaped(src: &Node, n: &Node) -> Node {
/// geometry effects of `n`, without those effects. The swatches and patterns a live-effect plug-in
/// that paints its pieces names are looked up in `doc`.
fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node {
let mut m = src.clone();
if let Some((path, rule)) = geometry(src) {
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext { doc, ..crate::GeomContext::of(n) });
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
}
without_geometry_effects(&mut m);
+2 -2
View File
@@ -101,6 +101,6 @@ pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geomet
#[cfg(test)]
pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within};
pub use params::{
default_params, has_geometry, has_painted_plugin_effect, has_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision,
scale_effect, upgrade_params,
any_plugin_paints_output, default_params, has_geometry, has_painted_plugin_effect, has_plugin_effect, is_geometry, is_raster, merged_params,
new_effect, plugin_revision, scale_effect, upgrade_params,
};
+5
View File
@@ -155,6 +155,11 @@ pub fn has_painted_plugin_effect(n: &Node) -> bool {
n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted))
}
/// Is a live-effect plug-in that paints the pieces it returns installed?
pub fn any_plugin_paints_output() -> bool {
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
+53 -25
View File
@@ -173,17 +173,18 @@ fn image_rows(items: &[AppearanceItem]) -> Vec<AppearanceItem> {
/// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill
/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that
/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies
/// them. Empty for anything but an image with such fills or strokes.
pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that
/// live-effect plug-ins painting their pieces name are looked up in `doc`.
pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> {
if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) {
return vec![];
}
let Some(art) = reshape(None, n, None) else { return vec![] };
let Some(art) = reshape(doc, n, None) else { return vec![] };
let mut out = vec![];
art.walk(&mut |c| {
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return };
let Some(g) = c.stroke_path() else { return };
let ctx = GeomContext::of(c);
let ctx = GeomContext { doc, ..GeomContext::of(c) };
for item in c.appearance.items.iter().filter(|i| crate::paints(i)) {
let fx = item.effects();
let shape = if crate::has_geometry(fx) && !g.elements().is_empty() {
@@ -296,14 +297,17 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
outline_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
}
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with
/// `apart`, also a path or compound path whose Transform effect makes copies
/// ([`has_transform_copies`]), as the one piece the copies are made from.
/// ([`has_transform_copies`]), as the one piece the copies are made from. `symbols` is the document
/// the symbols of the art come from; `paint_doc` is the one the swatches and patterns of painted
/// pieces are looked up in.
fn outline_art_with(
doc: Option<&Document>,
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
@@ -313,7 +317,7 @@ fn outline_art_with(
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { symbol, xf } => {
let a = match doc {
let a = match symbols {
Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?,
None => {
let mut a = symbol_art?.clone();
@@ -333,10 +337,10 @@ fn outline_art_with(
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes),
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes),
_ => return None,
};
outline_nested_text(doc, &mut art, false);
outline_nested_text(paint_doc, &mut art, false);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
@@ -491,7 +495,7 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
// effect in a group of their own (copies of copies nest), as the reference app expands them
// (#5612).
if has_transform_art(n) || has_transform_copies(n) {
return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
return as_art_opts(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
}
let own_paint = match n.kind {
NodeKind::Text(_) => !n.appearance.items.is_empty(),
@@ -505,17 +509,34 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
/// [`reshape`] without its checks (no geometry effects: just the art).
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
as_art_with(doc, doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
as_art_opts(doc, n, symbol_art, outline, nodes, false)
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). `symbols` is the
/// document the symbols of the art come from; `paint_doc` is the one the swatches and patterns of
/// painted pieces are looked up in.
pub(crate) fn as_art_with(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
) -> Option<Node> {
as_art_opts(symbols, paint_doc, n, symbol_art, outline, nodes, false)
}
/// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an
/// object of its own ([`copy_leaf`]) rather than one path of every copy.
fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option<Node> {
fn as_art_opts(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
apart: bool,
) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -543,13 +564,13 @@ fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outl
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?,
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
};
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, doc);
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
if apart {
hoist_copies(&mut art);
}
transform_items(&mut art, doc);
transform_items(&mut art, paint_doc);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
@@ -1079,7 +1100,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) };
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room);
*used = used.minus(before).plus(Budget::of(&out));
set_geometry(leaf, out, rule);
@@ -1237,14 +1258,21 @@ fn placed_count(n: &Node) -> usize {
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node {
outlined_members_with(doc, n, &outline_text, &SymbolNodes::default())
outlined_members_with(doc, doc, n, &outline_text, &SymbolNodes::default())
}
/// [`outlined_members`] with live objects and symbol instances given as art through `outline`
/// ([`evaluated_with`]).
pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
let mut art = evaluated_with(doc, n, outline, nodes);
outline_nested_text(doc, &mut art, false);
/// ([`evaluated_with`]), with the symbols of the art from `symbols` and the swatches and patterns
/// of painted pieces looked up in `paint_doc`.
pub(crate) fn outlined_members_with(
symbols: Option<&Document>,
paint_doc: Option<&Document>,
n: &Node,
outline: OutlineHook,
nodes: &SymbolNodes,
) -> Node {
let mut art = evaluated_with(symbols, n, outline, nodes);
outline_nested_text(paint_doc, &mut art, false);
art
}
+10 -9
View File
@@ -398,7 +398,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
return None;
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
@@ -408,7 +408,7 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
for item in &l.appearance.items {
// Items that paint nothing go; a gradient that can't be expanded stays.
let art = gradient_fill(item).and_then(|(f, g)| {
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
let (fl, path, bounds) = fill_shape(d, l, item, &path, bounds)?;
gradient_art(d, &fl, &path, bounds, f, g, to)
});
let Some(art) = art else {
@@ -468,14 +468,15 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
/// visible geometry effect.
fn reshaped(l: &Node) -> Option<Node> {
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
/// pieces name are looked up in `doc`.
fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
if !has_geometry(&l.appearance.effects) {
return None;
}
let (path, _) = node_path(l)?;
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext { doc: Some(doc), ..GeomContext::of(l) });
let mut m = with_path(l, g);
m.appearance.effects.retain(|e| !is_geometry(&e.id));
Some(m)
@@ -484,13 +485,13 @@ fn reshaped(l: &Node) -> Option<Node> {
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
/// `path` as a plain path, and that outline's box.
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
fn fill_shape(doc: &Document, l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
if !has_geometry(item.effects()) {
return Some((l.clone(), path.clone(), bounds));
}
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext { doc: Some(doc), ..GeomContext::of(l) }.item(item));
let b = outline.bounds()?;
Some((with_path(l, outline.clone()), outline, b))
}
@@ -777,7 +778,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &
{
return None;
}
let reshaped = reshaped(l);
let reshaped = reshaped(d, l);
let l = reshaped.as_ref().unwrap_or(l);
let (path, _) = node_path(l)?;
let bounds = path.bounds()?;
@@ -809,7 +810,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &
later -= usize::from(fill.is_some());
let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left };
let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| {
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
let (fl, _, bounds) = fill_shape(d, l, item, &path, bounds)?;
let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else {
full = true;
return None;
+130
View File
@@ -451,6 +451,136 @@ fn pathfinder_and_baked_appearance_take_painted_pieces_with_swatch_links() {
assert_eq!(fills, [(square_at(40.0).bounds(), Some(leaf))]);
}
/// A swatch-linked piece on screen is drawn when the path it comes from is off screen: the
/// renderer's bounds of the path take in the piece.
#[test]
fn a_swatch_linked_piece_is_not_culled_with_its_off_screen_path() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.culled", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.culled"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// A retained renderer's kept bounds follow the document's swatches: a swatch-linked piece on
/// screen appears when its swatch is added, though the path it comes from is off screen.
#[test]
fn a_retained_renderer_shows_a_piece_once_its_swatch_exists() {
let mut s = session();
let id = rect(&mut s, 500.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.lateleaf", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.lateleaf"}));
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = vectorcraft_render::Renderer::new();
let at_piece = |r: &mut vectorcraft_render::Renderer, s: &Session| {
r.render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(50, 50)
};
assert_eq!(at_piece(&mut r, &s), WHITE, "the piece is refused");
run(&mut s, "select.none", json!({}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(at_piece(&mut r, &s), GREEN, "the piece is drawn with its swatch");
}
/// Object › Expand of a gradient fill takes the geometry of a piece linked to the document's swatch.
#[test]
fn expanding_a_gradient_fill_takes_a_swatch_linked_piece() {
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandref", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandref"}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false}));
let img = drawn(&s);
assert_eq!(img.pixel(15, 15), WHITE, "the input path is gone");
assert_ne!(img.pixel(50, 50), WHITE, "the piece's geometry is filled");
}
/// The geometry of a painted result that names a swatch counts only when the document has that
/// swatch: without a document, and with one that lacks it, the effect keeps its input.
#[test]
fn painted_geometry_is_used_only_when_its_references_are_in_the_document() {
use vectorcraft_render::effects::{GeomContext, apply_geometry_with};
let mut s = session();
let id = rect(&mut s, 10.0, "#ff0000");
install_wat(&mut s, &tpl::returning(&painting("test.engine.geomref", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geomref"}));
let n = node(&s, id);
let path = n.path_data().unwrap().clone();
let input = path.bounds().unwrap();
let geometry = |doc: Option<&vectorcraft_doc::Document>| {
apply_geometry_with(&n.appearance.effects, &path, input, &GeomContext { doc, ..GeomContext::of(&n) }).bounds()
};
assert_eq!(geometry(None), Some(input), "no document");
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), Some(input), "no such swatch");
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
assert_eq!(geometry(Some(&s.doc().unwrap().doc)), square_at(40.0).bounds(), "the piece's geometry");
}
/// Type with a painted effect, in a group with a live Pathfinder effect: the operation takes the
/// piece linked to the document's swatch.
#[test]
fn pathfinder_takes_the_swatch_linked_pieces_of_type() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.typepaint", true), &leaf_square()));
run(&mut s, "effect.apply", json!({"ids": [t.0], "effect": "plugin.test.engine.typepaint"}));
run(&mut s, "select.set", json!({"ids": [t.0]}));
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// Type whose fill has a painted effect, in a group with a geometry effect inside a live Pathfinder
/// effect: the operation takes the piece linked to the document's swatch.
#[test]
fn pathfinder_takes_swatch_linked_pieces_of_type_in_a_reshaped_group() {
let mut s = session();
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 20}))["id"].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
install_wat(&mut s, &tpl::returning(&painting("test.engine.memberpaint", true), &leaf_square()));
run(&mut s, "select.set", json!({"ids": [t.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.memberpaint", "item": 0}));
let inner = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [inner.0], "effect": "path.offsetPath", "params": {"offset": 0}}));
let outer = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
run(&mut s, "effect.apply", json!({"ids": [outer.0], "effect": "pathfinder.divide"}));
assert_eq!(drawn(&s).pixel(50, 50), GREEN);
}
/// A painted effect on an image's own fill that links its piece to a swatch: hit tests and bounds
/// use the piece the canvas draws.
#[test]
fn an_image_s_painted_fill_is_hit_and_measured_as_drawn() {
let mut s = session();
let mut png = vec![];
image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]}));
let im = NodeId(r["ids"][0].as_u64().unwrap());
run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"}));
let mut leaf = solid("#00ff00");
leaf["swatch"] = json!("Leaf");
let out = json!({"objects": [{"path": square_at(120.0), "fills": [leaf]}]}).to_string();
install_wat(&mut s, &tpl::returning(&painting("test.engine.imagepaint", true), &out));
run(&mut s, "select.set", json!({"ids": [im.0]}));
run(&mut s, "appearance.addFill", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "convertToShape.rectangle", "item": 0}));
run(&mut s, "effect.apply", json!({"effect": "plugin.test.engine.imagepaint", "item": 0}));
assert_eq!(drawn(&s).pixel(130, 50), GREEN, "the canvas draws the piece");
let doc = &s.doc().unwrap().doc;
assert!(vectorcraft_doc::hit::hit_test(doc, Point::new(130.0, 50.0), Default::default()).is_some(), "a click on the piece hits the image");
let boxes = doc.symbol_boxes();
assert!(doc.node(im).unwrap().reach_bounds_with(&boxes).unwrap().x1 >= 140.0, "the bounds take in the piece");
}
/// A path with a painted effect takes part in a Pathfinder operation as its pieces, each with its
/// own paint and the path's opacity: Divide on a group holding it makes one face per piece,
/// painted as the piece is.
+20 -9
View File
@@ -195,9 +195,14 @@ pub fn paints_output(id: &str) -> bool {
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`), of an object painted
/// with `paints`: the new geometry, or `None` when the plug-in isn't installed, isn't an effect, or
/// fails (the caller then keeps the geometry). For an effect that returns painted pieces, the
/// geometry is every piece's path in order.
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<PathData> {
evaluate(id, params, path, bounds, rule, paints).map(|(_, out)| out.geometry())
/// geometry is every piece's path in order, and `None` when the swatches and patterns they name
/// are refused as [`apply_painted`] refuses them.
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints, doc: Option<&Document>) -> Option<PathData> {
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
if let Out::Pieces(pieces) = &out {
references_found(id, &plugin, pieces, doc)?;
}
Some(out.geometry())
}
/// Runs live-effect plug-in `id`, which declares `paintOutput`, as [`apply`] does: its painted
@@ -217,15 +222,21 @@ pub fn apply_painted(
) -> Option<Arc<Vec<Piece>>> {
let (plugin, out) = evaluate(id, params, path, bounds, rule, paints)?;
let Out::Pieces(pieces) = out else { return None };
// The document's swatches and patterns can change while the result stays cached.
let checked = painted::check_references(&pieces, doc);
if doc.is_some() {
note(id, &plugin, checked.as_ref().map(|_| ()));
}
checked.ok()?;
references_found(id, &plugin, &pieces, doc)?;
Some(pieces)
}
/// `Some` when every swatch and pattern `pieces` name is in `doc` ([`painted::check_references`]),
/// recording the outcome for [`last_error`] when there is a document. The document's swatches and
/// patterns can change while a result stays cached, so each use checks them.
fn references_found(id: &str, plugin: &crate::Plugin, pieces: &[Piece], doc: Option<&Document>) -> Option<()> {
let checked = painted::check_references(pieces, doc);
if doc.is_some() {
note(id, plugin, checked.as_ref().map(|_| ()));
}
checked.ok()
}
/// Runs plug-in `id` as [`apply`] says, or takes its result from the cache: the plug-in and what
/// it gave.
fn evaluate(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule, paints: Paints) -> Option<(Arc<crate::Plugin>, Out)> {
+1
View File
@@ -74,6 +74,7 @@ pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reac
#[cfg(test)]
pub(crate) use entries::Drawn;
pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry};
pub(crate) use pixels::paint_names;
pub(crate) use predicates::{has_fx, has_object_fx};
pub(crate) use raster::PixelEntry;
#[cfg(test)]
+13 -12
View File
@@ -28,9 +28,10 @@ pub(super) fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezP
effects::apply_geometry_bez(effects, bp, bp.bounding_box(), ctx)
}
/// The object-level effected geometry of `n` (base path `bp`).
pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &GeomContext::of(n))
/// The object-level effected geometry of `n` (base path `bp`), with the swatches and patterns that
/// live-effect plug-ins painting their pieces name looked up in `doc`.
pub(crate) fn effected_path(doc: &Document, n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &GeomContext { doc: Some(doc), ..GeomContext::of(n) })
}
pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] {
@@ -50,8 +51,8 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
});
}
let bp = node_bezpath(n)?;
let ctx = GeomContext::of(n);
let g = effected_path(n, &bp);
let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) };
let g = effected_path(symbols.document(), n, &bp);
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
for item in &n.appearance.items {
let fx = item_effects(item);
@@ -86,21 +87,21 @@ fn paints(item: &AppearanceItem) -> bool {
pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = art_bounds(symbols, n)?;
let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty());
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r)))
}
/// The bounds of what `item`, a fill or stroke of the path or compound path `n`, paints: the path
/// with `n`'s and `item`'s geometry effects, and for a stroke its width. `None` for other objects,
/// 3D art, a brushed stroke and an item that draws no path.
fn item_bounds(n: &Node, item: &AppearanceItem) -> Option<Rect> {
fn item_bounds(doc: &Document, n: &Node, item: &AppearanceItem) -> Option<Rect> {
let stroke = matches!(item, AppearanceItem::Stroke(_));
if !has_fx(n) || effects::has_revolve(n) || effects::has_solid(n) || (stroke && vectorcraft_brush::has_brush(n)) {
return None;
}
let g = effected_path(n, &node_bezpath(n)?);
let g = effected_path(doc, n, &node_bezpath(n)?);
let fx = item_effects(item);
if effects::has_geometry(fx) {
let ig = apply(fx, &g, &GeomContext::of(n).item(item));
let ig = apply(fx, &g, &GeomContext { doc: Some(doc), ..GeomContext::of(n) }.item(item));
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
return (!ig.elements().is_empty()).then(|| ig.bounding_box().inflate(o, o));
}
@@ -155,14 +156,14 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
return drawn_bounds(symbols, &art);
}
let g = effected_path(n, &node_bezpath(n)?);
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).
let mut plain = n.appearance.clone();
plain.items.retain(|i| !(paints(i) && effects::has_geometry(item_effects(i))));
let on_path = !n.appearance.items.iter().any(paints) || plain.items.iter().any(paints);
let mut r = (on_path && !g.elements().is_empty()).then(|| plain.stroked_bounds(g.bounding_box(), || g.clone()));
let ctx = GeomContext::of(n);
let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) };
for item in n.appearance.items.iter().filter(|i| paints(i) && effects::has_geometry(item_effects(i))) {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let o = if let AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
@@ -227,7 +228,7 @@ pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRu
}
match &n.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } if effects::has_geometry(&n.appearance.effects) => {
Some((effected_path(n, &node_bezpath(n)?), *rule))
Some((effected_path(doc, n, &node_bezpath(n)?), *rule))
}
_ => effects::clip_outline(n),
}
+9 -4
View File
@@ -11,6 +11,14 @@ use super::{
RasterFx, Rect, RenderContext, Renderer, Shape, blend_mode, effects, fill_rule, paint, peniko,
};
/// A hash of the names of `doc`'s swatches and patterns.
pub(crate) fn paint_names(doc: &Document) -> u64 {
let mut h = DefaultHasher::new();
doc.swatches_iter().for_each(|s| s.name.hash(&mut h));
doc.patterns.iter().for_each(|p| p.name.hash(&mut h));
h.finish()
}
impl Renderer {
/// Paint `content` through the content-changing effects of `rfx` (Gaussian Blur, Feather, the
/// Photoshop-style filters and Rasterize) in stack order on its pixels: rendered offscreen over
@@ -472,10 +480,7 @@ impl Renderer {
(key, Arc::as_ptr(&blob.bytes) as usize, blob.proxy.as_ref().map(|p| Arc::as_ptr(p) as usize)).hash(h);
}
});
let names = hash(&mut |h| {
doc.swatches_iter().for_each(|s| s.name.hash(h));
doc.patterns.iter().for_each(|p| p.name.hash(h));
});
let names = paint_names(doc);
let found = (patterns, symbols, images, names, Arc::as_ptr(&cms) as usize);
let d = match &self.doc_inputs {
Some((_, d)) if (d.patterns, d.symbols, d.images, d.names, d.cms) == found => d.clone(),
+3 -3
View File
@@ -339,13 +339,13 @@ impl Renderer {
let stamp = self.stamp;
let e = match self.fx_geom.remove(&key) {
Some(e) if Arc::ptr_eq(&e.node, &o) => e,
_ => FxGeom { g: Arc::new(effected_path(n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp },
_ => FxGeom { g: Arc::new(effected_path(f.symbols.document(), n, bp)), items: vec![None; n.appearance.items.len()], node: o, stamp },
};
(key, FxGeom { stamp, ..e })
});
let g = match &cached {
Some((_, e)) => e.g.clone(),
None => Arc::new(effected_path(n, bp)),
None => Arc::new(effected_path(f.symbols.document(), n, bp)),
};
self.draw_shape_fx_geom(ctx, f, n, &g, rule, cached.as_mut().map(|(_, e)| &mut e.items));
if let Some((key, e)) = cached {
@@ -370,7 +370,7 @@ impl Renderer {
self.hairline(ctx, f, g, [0, 0, 0, 255]);
return;
}
let gctx = GeomContext::of(n);
let gctx = GeomContext { doc: Some(f.symbols.document()), ..GeomContext::of(n) };
let rfx = effects::raster_effects(&n.appearance.effects);
// Document-space reach of the painted geometry (strokes, arrowheads…).
let outset = n.appearance.outset();
+6
View File
@@ -75,6 +75,12 @@ pub struct Renderer {
/// The plug-in revision ([`effects::plugin_revision`]) that `geom`, `fx_geom` and `fx_arts` were
/// last checked against (see [`Self::check_plugins`]).
plugin_revision: u64,
/// Is a live-effect plug-in that paints its pieces installed (found when `plugin_revision`
/// changes)?
painting_plugins: bool,
/// The hash of the document's swatch and pattern names at the last check, kept while
/// `painting_plugins` holds (see [`Self::check_plugins`]).
paint_names: u64,
/// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits,
/// tolerance level): panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<StrokeKey, StrokeEntry>,
+1 -1
View File
@@ -185,7 +185,7 @@ fn rasterize(
let opts = RenderOptions { mark_undecoded_images, ..Default::default() };
r.image_left_out = Some(false);
let pattern_images_left_out = images_left_out();
r.check_plugins();
r.check_plugins(doc);
r.check_symbols(doc);
let symbols = doc.symbol_boxes().quick();
r.stamp += 1;
+5 -3
View File
@@ -43,6 +43,8 @@ impl Renderer {
kept: PtrMap::default(),
symbols: vec![],
plugin_revision: 0,
painting_plugins: false,
paint_names: 0,
strokes: PtrMap::default(),
moved_strokes: PtrMap::default(),
char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0),
@@ -113,7 +115,7 @@ impl Renderer {
return Rendered { width: w, height: h, pixels };
}
}
self.check_plugins();
self.check_plugins(doc);
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
self.check_symbols(doc);
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
@@ -334,7 +336,7 @@ impl Renderer {
/// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]).
pub fn render_node_with(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option<Rendered> {
self.check_plugins();
self.check_plugins(doc);
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
@@ -373,7 +375,7 @@ impl Renderer {
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
self.check_plugins();
self.check_plugins(doc);
self.check_symbols(doc);
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16;
+20 -7
View File
@@ -98,15 +98,28 @@ impl Renderer {
/// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold
/// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`,
/// `live::resources_key`).
pub(crate) fn check_plugins(&mut self) {
///
/// The same caches are dropped when a live-effect plug-in that paints its pieces is installed
/// and the names of `doc`'s swatches or patterns have changed since the last check: whether its
/// pieces are used depends on those names.
pub(crate) fn check_plugins(&mut self, doc: &Document) {
let revision = effects::plugin_revision();
if revision == self.plugin_revision {
return;
let mut stale = false;
if revision != self.plugin_revision {
self.plugin_revision = revision;
self.painting_plugins = effects::any_plugin_paints_output();
stale = true;
}
if self.painting_plugins {
let names = fx::paint_names(doc);
stale |= names != self.paint_names;
self.paint_names = names;
}
if stale {
self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node));
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node));
}
self.plugin_revision = revision;
self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node));
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node));
}
/// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]):
+2 -1
View File
@@ -424,7 +424,8 @@ is a piece drawn by itself, in the order returned, with paints of its own:
- The effects after it in the stack apply to the pieces together, the reference box being the bounds of all of them.
Bounds, hit tests, raster effects, Pathfinder effects, compound shapes and Envelope Options › Distort Appearance use
the pieces as they are drawn. For an effect on a fill or stroke, Pathfinder effects and compound shapes use the
object's path, as they do for every other effect on a fill or stroke. Each piece counts against the budget of the
object's path, as they do for every other effect on a fill or stroke. A 3D effect later in the stack uses the
object's input path when the pieces name a swatch or pattern. Each piece counts against the budget of the
object's geometry effects as well as its anchors, so all the painted effects in a stack make at most 100,000 pieces
together; a run whose pieces would pass the budget draws its input as it was.
- The result is checked whole before any of it is drawn: the limits of a filter's output (100,000 objects, 4,000,000