From b109378ae6861f7ce70391ea12014185ca6a1c7d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 23:22:11 -0800 Subject: [PATCH 01/10] Plug-ins: live-effect plug-ins at API level 2 can paint the pieces they return A live effect whose manifest has "api": 2 and "paintOutput": true returns {"objects": [...]} as pieces, each drawn by itself in order with fills and strokes of its own in the forms a filter returns. A piece without fills or strokes keeps the ones the effect was given, and an empty list paints none. An effect on a fill returns only fills, one on a stroke only strokes. The whole result is checked before any of it is drawn: the output limits, finite numbers in range with nothing clamped, known keys only, and every swatch and pattern 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. Each piece counts against the geometry-effect budget, so the painted effects of one stack make at most 100,000 pieces. 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. The plug-in API level rises to 2; plugin.list and plugin.info give api and paintOutput. Effects without the key behave as before. --- crates/effects/src/group.rs | 2 +- crates/effects/src/reshape.rs | 213 ++++++++++++++++-- crates/engine/src/cmd/plugin.rs | 8 +- crates/engine/src/tests/plugins.rs | 160 ++++++++++++++ crates/engine/tests/plugin_fuzz.rs | 26 +++ crates/plugins/src/effect.rs | 128 +++++++++-- crates/plugins/src/lib.rs | 4 +- crates/plugins/src/manifest.rs | 51 ++++- crates/plugins/src/objects.rs | 10 +- crates/plugins/src/painted.rs | 323 ++++++++++++++++++++++++++++ crates/plugins/src/painted/tests.rs | 149 +++++++++++++ crates/plugins/src/permissions.rs | 4 +- crates/ui-egui/src/dialogs/tests.rs | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 7 +- docs/plugins.md | 68 +++++- docs/target-app-parity.md | 1 + 38 files changed, 1111 insertions(+), 66 deletions(-) create mode 100644 crates/plugins/src/painted.rs create mode 100644 crates/plugins/src/painted/tests.rs diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index ce8c38077..aa222809d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -711,7 +711,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - crate::reshape::reshape_leaves(&mut m, &fx); + crate::reshape::reshape_leaves_in(&mut m, &fx, doc); } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index b3acf07a5..a84d37bd0 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -102,10 +102,27 @@ fn reaches_appearance(e: &Effect) -> bool { } /// Is `n` a path or compound path that its Transform effects make into art of separate objects -/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through -/// that art ([`reshape`]). +/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose +/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and +/// expanded through that art ([`reshape`]). pub fn has_transform_art(n: &Node) -> bool { - has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) + has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) +} + +/// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id, +/// else `None`. +fn painted_plugin(e: &Effect) -> Option<&str> { + let id = vectorcraft_plugins::effect::plugin_id(&e.id).filter(|_| e.visible)?; + vectorcraft_plugins::effect::paints_output(id).then_some(id) +} + +/// Is `n` a path or compound path with a live-effect plug-in that paints what it returns +/// ([`painted_plugin`]), its own or on a fill or stroke that paints? Each piece the plug-in returns +/// is then an object of its own with its own paints ([`paint_leaf`], [`transform_item_leaf`]). +pub fn has_painted_effects(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) + && (n.appearance.effects.iter().any(|e| painted_plugin(e).is_some()) + || n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(|e| painted_plugin(e).is_some()))) } /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a @@ -527,11 +544,11 @@ fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outl } _ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?, }; - reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart); + reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, doc); if apart { hoist_copies(&mut art); } - transform_items(&mut art); + transform_items(&mut art, doc); // Type and symbols in perspective: the art projected onto their plane. if let Some(h) = n.projection() { project_leaves(&mut art, &h); @@ -594,9 +611,15 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { reshape_leaves_within(art, fx, Budget::EFFECTS); } +/// [`reshape_leaves`], with the swatches and patterns that painted live-effect plug-ins name looked +/// up in `doc` ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves_in(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { + reshape_leaves_apart(art, fx, Budget::EFFECTS, false, doc); +} + /// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { - reshape_leaves_apart(art, fx, budget, false); + reshape_leaves_apart(art, fx, budget, false, None); } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -616,8 +639,10 @@ fn hoist_copies(art: &mut Node) { } /// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects -/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. -fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) { +/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect +/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]), +/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused). +fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) { if fx.is_empty() { return; } @@ -647,6 +672,17 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); + if let Some(plugin) = painted_plugin(e) { + for_each_leaf(art, &mut |leaf| { + let Some((path, _)) = leaf_geometry(leaf) else { return }; + let before = Budget::of(&path); + let room = max.minus(used.minus(before)); + if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) { + used = used.minus(before).plus(made); + } + }); + continue; + } let transform = (e.id == "distort.transform").then(|| { let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); @@ -772,15 +808,111 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling made } +/// Live-effect plug-in `plugin` (one that paints what it returns) with `params` run on `leaf`, a +/// path or compound path, as one of its object's effects with reference box `bounds`: `leaf` +/// becomes a group with its id and name of one object for each piece returned, in order, each with +/// the piece's geometry and the leaf's appearance with the piece's fills and strokes in place of +/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces +/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost +/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are +/// refused, or they would pass `budget`. +fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option { + let (path, rule) = leaf_geometry(leaf)?; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None }; + let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; + let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); + if !budget.fits(made) { + return None; + } + let children = pieces + .iter() + .map(|p| { + let mut one = leaf.clone(); + one.name = None; + one.appearance = vectorcraft_plugins::painted::appearance(&leaf.appearance, p).into(); + set_geometry(&mut one, p.path.clone(), p.rule); + Arc::new(one) + }) + .collect(); + let mut g = Node::group(leaf.id, children); + g.name = leaf.name.clone(); + *leaf = g; + Some(made) +} + +/// What a painted piece with geometry `path` costs: its anchors and lines, plus a share of the +/// effects' budget for the object it becomes, so that a budget of [`Budget::EFFECTS`] holds at most +/// [`MAX_OBJECTS`](vectorcraft_plugins::objects::MAX_OBJECTS) pieces however small their paths. +fn piece_cost(path: &PathData) -> Budget { + let max = vectorcraft_plugins::objects::MAX_OBJECTS; + Budget::of(path).plus(Budget { anchors: Budget::EFFECTS.anchors / max, lines: Budget::EFFECTS.lines / max }) +} + +/// Live-effect plug-in `plugin` (one that paints what it returns) with `params`, a later effect of +/// `item`, run on `leaf`, a path of [`transform_piece`] painted by copies of `item`, with reference +/// box `bounds`: `leaf` becomes a group of one path for each piece returned, in order. A piece that +/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item` with those +/// paints ([`vectorcraft_plugins::painted::items`]); one that gives none keeps the leaf's. Returns +/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, +/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass +/// `budget`. +fn paint_piece( + leaf: &mut Node, + item: &AppearanceItem, + plugin: &str, + params: &serde_json::Value, + bounds: Rect, + budget: Budget, + doc: Option<&Document>, +) -> Option { + let (path, rule) = leaf_geometry(leaf)?; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) }; + let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?; + let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus); + if !budget.fits(made) { + return None; + } + let mut children = vec![]; + for p in pieces.iter() { + let gives = match item { + AppearanceItem::Fill(_) => p.fills.is_some(), + AppearanceItem::Stroke(_) => p.strokes.is_some(), + }; + let items = if gives { vectorcraft_plugins::painted::items(item, p) } else { leaf.appearance.items.clone() }; + if items.is_empty() { + continue; + } + let mut one = leaf.clone(); + one.appearance.items = items; + set_geometry(&mut one, p.path.clone(), p.rule); + children.push(Arc::new(one)); + } + *leaf = Node::group(leaf.id, children); + Some(made) +} + /// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose /// own Transform effect has Transform Patterns or Scale Strokes & Effects on -/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]). -/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is -/// more). -fn transform_items(art: &mut Node) { +/// ([`item_transform_options`]), or whose own live-effect plug-in paints what it returns +/// ([`painted_plugin`]), made into a group of objects of its own ([`transform_item_leaf`]). The +/// new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is more). +/// The swatches and patterns painted pieces name must be in `doc`; without one they may name none. +fn transform_items(art: &mut Node, doc: Option<&Document>) { let mut used = art_cost(art); let max = Budget::EFFECTS.at_least(used); - for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used)); + for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used, doc)); +} + +/// Is `e` an effect that makes a fill's or stroke's piece of a path an object of its own: a +/// Transform effect that [`reaches_appearance`], or a live-effect plug-in that paints what it +/// returns ([`painted_plugin`])? +fn makes_piece(e: &Effect) -> bool { + reaches_appearance(e) || painted_plugin(e).is_some() +} + +/// Does `item` paint and carry an effect that [`makes_piece`]? +fn item_makes_piece(item: &AppearanceItem) -> bool { + crate::paints(item) && item.effects().iter().any(makes_piece) } /// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform @@ -795,8 +927,17 @@ fn transform_items(art: &mut Node) { /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` /// what it has spent. -fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { - if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) { +/// +/// A fill or stroke whose first such effect is a live-effect plug-in that paints what it returns +/// ([`painted_plugin`]) becomes, in place of a transformed path, one path for each piece the +/// plug-in returns, painted by copies of the fill or stroke with the piece's paints +/// ([`vectorcraft_plugins::painted::items`]); the plug-in is given the geometry before it and told +/// which fill or stroke it is on. When it fails (or names a swatch or pattern not in `doc`, or any +/// without a document), the +/// fill or stroke paints that geometry as it is. The geometry effects after it then apply to all +/// the pieces as one ([`transform_piece`]). +fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Option<&Document>) { + if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_makes_piece) { return; } let Some((path, rule)) = leaf_geometry(leaf) else { return }; @@ -824,12 +965,12 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { let mut pieces: Vec> = vec![]; let mut plain: Vec = vec![]; // Which items become a transformed piece of their own; every other run of items is one plain piece. - let transformed: Vec = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect(); + let transformed: Vec = original.appearance.items.iter().map(item_makes_piece).collect(); // The number of pieces after item `i` in paint order: each costs at least the leaf's geometry. let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count(); for (i, item) in original.appearance.items.iter().enumerate() { let fx = item.effects(); - let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else { + let Some(at) = fx.iter().position(makes_piece).filter(|_| crate::paints(item)) else { plain.push(item.clone()); continue; }; @@ -850,6 +991,28 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { }; let Some(tb) = g.bounds() else { continue }; let params = merged_params(&fx[at].id, &fx[at].params); + if let Some(plugin) = painted_plugin(&fx[at]) { + let mut it = item.clone(); + let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest; + let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) }; + let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc) + .map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::>()) + .filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus))); + // A failed run draws the fill or stroke on the geometry it was given. + let returned = painted.unwrap_or_else(|| vec![(g.clone(), rule, vec![it])]); + let mut group: Vec> = vec![]; + for (path, rule, items) in returned.into_iter().filter(|(_, _, items)| !items.is_empty()) { + *used = used.plus(piece_cost(&path)); + let mut p = piece(items); + set_geometry(&mut p, path, rule); + group.push(Arc::new(p)); + } + let mut p = Node::group(leaf.id, group); + transform_piece(&mut p, ctx.seed, limit, used, doc); + pieces.push(Arc::new(p)); + continue; + } let (_, copies) = crate::distort::transform_map(tb, ¶ms, ctx.seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); @@ -860,7 +1023,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { set_geometry(&mut p, g.clone(), rule); let made = transform_leaf(&mut p, &g, map, copies, options, room); *used = used.plus(made); - transform_piece(&mut p, ctx.seed, limit, used); + transform_piece(&mut p, ctx.seed, limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -879,9 +1042,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) { /// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of /// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with /// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. -/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep -/// to `max` together with the rest of the art (`used`). -fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { +/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path +/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and +/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art +/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. +fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { @@ -897,6 +1062,12 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) { // What the other paths leave of the budget. let before = Budget::of(&path); let room = max.minus(used.minus(before)); + if let Some(plugin) = painted_plugin(&e) { + if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) { + *used = used.minus(before).plus(made); + } + return; + } if reaches_appearance(&e) { let params = merged_params(&e.id, &e.params); let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 565eded7f..68a0bfde2 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -58,6 +58,10 @@ pub fn describe(p: &Plugin) -> Value { if m.budget == Budget::Heavy { v["budget"] = json!("heavy"); } + v["api"] = json!(m.api); + if m.paint_output { + v["paintOutput"] = json!(true); + } v } @@ -493,7 +497,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), api (the plug-in API level its manifest is written for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -502,7 +506,7 @@ pub fn specs() -> Vec { "Plug-in Info", [], None, - "{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}", + "{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null; for a live effect with paintOutput, its name and the refused field, such as objects[1].strokes[0].width)}", always, info ), diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36694ef87..7c96a458b 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -269,6 +269,166 @@ fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() { assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills"); } +/// The manifest of a test live effect `id` that declares `paintOutput` (at `api` 2), or that +/// doesn't (`paints` off). +fn painting(id: &str, paints: bool) -> String { + let declared = if paints { r#","api":2,"paintOutput":true"# } else { "" }; + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect"{declared},"params":{{}}}}"#) +} + +/// 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)) +} + +fn solid(hex: &str) -> Value { + serde_json::to_value(Paint::solid(Color::from_hex(hex).unwrap())).unwrap() +} + +/// The 200 × 200 top left of the document, drawn on white. +fn drawn(s: &Session) -> vectorcraft_render::Rendered { + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts) +} + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// A live effect that declares `paintOutput` draws each piece it returns by itself, with the paints +/// it gives (a piece without `fills` keeps the object's), in place of the object; the same output +/// from an effect that doesn't declare it is one path painted by the object's fill. Drawing and +/// export leave the document alone, and Expand Appearance writes the pieces as ordinary paths in +/// one undo step. +#[test] +fn a_painted_live_effect_draws_its_pieces_with_their_own_paints() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}, {"path": square_at(80.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.paints", true), &out)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.geometry", false), &out)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.geometry"})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (RED, RED, WHITE), "without paintOutput the paint is ignored"); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.paints"})); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(90, 50), img.pixel(15, 15)), (GREEN, RED, WHITE), "each piece with its own paint"); + // An upstream paint edit reaches the piece that keeps the object's fill; Undo brings it back. + run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": "#0000ff"})); + assert_eq!((drawn(&s).pixel(50, 50), drawn(&s).pixel(90, 50)), (GREEN, BLUE)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(drawn(&s).pixel(90, 50), RED); + run(&mut s, "edit.redo", json!({})); + assert_eq!(drawn(&s).pixel(90, 50), BLUE); + run(&mut s, "edit.undo", json!({})); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains("#00ff00") && svg.contains("#ff0000"), "{svg}"); + assert_eq!(s.doc().unwrap().doc, before, "drawing and export don't change the document"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + // Expanded: two ordinary paths, green and red, and no live effect, as one undo step. + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "effect.expandAppearance", json!({})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + let mut paths = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let Some(p) = n.path_data() { + assert!(n.appearance.effects.is_empty(), "no live effect left"); + paths.push((p.bounds(), n.appearance.fill().map(|f| f.paint.clone()))); + } + }); + let fill = |hex: &str| Some(Paint::solid(Color::from_hex(hex).unwrap())); + assert_eq!(paths, [(square_at(40.0).bounds(), fill("#00ff00")), (square_at(80.0).bounds(), fill("#ff0000"))]); + assert_eq!(drawn(&s).pixel(50, 50), GREEN); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).appearance.effects[0].id, "plugin.test.engine.paints"); +} + +/// On a fill, a painted effect returns only fills: they replace that fill on each piece. A result +/// that breaks a rule (strokes from an effect on a fill, a swatch the document doesn't have) is +/// refused whole: the object is drawn as if the effect weren't there, and `plugin.info` names the +/// plug-in and the field. +#[test] +fn a_painted_effect_on_a_fill_and_refused_results() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let fills = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#00ff00")]}]}).to_string(); + let strokes = json!({"objects": [{"path": square_at(40.0), "strokes": [{"paint": solid("#00ff00"), "width": 2}]}]}).to_string(); + let mut gone = solid("#00ff00"); + gone["swatch"] = json!("Not a swatch"); + let unknown = json!({"objects": [{"path": square_at(40.0), "fills": [gone]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.fillpaint", true), &fills)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.strokepaint", true), &strokes)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.swatchpaint", true), &unknown)); + let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.fillpaint", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the fill's piece, green"); + assert_eq!(run(&mut s, "plugin.info", json!({"id": "test.engine.fillpaint"}))["lastError"], Value::Null); + // A second painted effect on the same fill paints what it returns too. + let blue = json!({"objects": [{"path": square_at(80.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.bluepaint", true), &blue)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.bluepaint", "item": fill})); + let img = drawn(&s); + assert_eq!((img.pixel(90, 50), img.pixel(50, 50), img.pixel(15, 15)), (BLUE, WHITE, WHITE), "the second effect's piece, blue"); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 1, "item": fill})); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": fill})); + for (effect, item, field) in + [("test.engine.strokepaint", json!(fill), "objects[0].strokes"), ("test.engine.swatchpaint", Value::Null, "objects[0].fills[0].swatch")] + { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": format!("plugin.{effect}"), "item": item})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (WHITE, RED), "{effect}: drawn as it is"); + let e = run(&mut s, "plugin.info", json!({"id": effect}))["lastError"].as_str().unwrap_or_default().to_string(); + assert!(e.starts_with(&format!("Test {effect}: ")) && e.contains(field), "{effect}: {e}"); + let n = node(&s, id); + let kept = if item.is_null() { n.appearance.effects.clone() } else { n.appearance.items[fill].effects().to_vec() }; + assert_eq!(kept.iter().map(|e| e.id.as_str()).collect::>(), [format!("plugin.{effect}")], "the effect record stays"); + run(&mut s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": item})); + } +} + +/// A painted effect on a group may link its pieces to the document's swatches. +#[test] +fn a_painted_group_effect_links_to_the_document_s_swatches() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00", "global": true})); + run(&mut s, "select.all", json!({})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + let mut leaf = solid("#00ff00"); + leaf["swatch"] = json!("Leaf"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.grouppaint", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [g.0], "effect": "plugin.test.engine.grouppaint"})); + let img = drawn(&s); + assert_eq!((img.pixel(50, 50), img.pixel(15, 15)), (GREEN, WHITE), "the group's piece, green"); +} + +/// Every painted piece counts against the effects' budget, however small its path: two painted +/// effects in a stack that each return 350 tiny triangles make no more than 100,000 objects, not +/// 350 × 350 of them. +#[test] +fn painted_pieces_keep_to_the_effects_budget() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let tiny = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.1, 0.0), Point::new(0.0, 0.1)], true)); + let out = json!({"objects": vec![json!({"path": tiny}); 350]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.many", true), &out)); + for _ in 0..2 { + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.many"})); + } + let st = s.doc().unwrap(); + let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); + let mut paths = 0; + art.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }))); + assert!((350..=100_000).contains(&paths), "{paths} paths"); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 1f29627e0..652de8c19 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -242,6 +242,32 @@ proptest! { renders(&s, &out)?; } + /// A live effect that declares `paintOutput` returning hostile pieces, on objects or on a fill: + /// drawing, export and Expand Appearance never crash. + #[test] + fn hostile_painted_effect_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + on_fill in any::(), + ) { + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let out = output(&objects, &[], junk); + let manifest = r#"{"id":"fuzz.painted","name":"Fuzz","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{}}"#; + install(&mut s, &wat::parse_str(tpl::returning(manifest, &out)).unwrap()).unwrap(); + let item = if on_fill { json!(0) } else { Value::Null }; + let r = catch_quiet(|| s.execute("effect.apply", &json!({"effect": "plugin.fuzz.painted", "item": item})).map(|_| ())); + if let Err(msg) = r { + return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))); + } + renders(&s, &out)?; + let r = catch_quiet(|| s.execute("effect.expandAppearance", &json!({})).map(|_| ())); + if let Err(msg) = r { + return Err(TestCaseError::fail(format!("expanding panicked on {out}: {msg}"))); + } + renders(&s, &out)?; + } + #[test] fn mutated_and_garbage_modules_never_crash( flips in prop::collection::vec((any::(), any::()), 0..8), diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index b49f5b831..0246f609a 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -5,20 +5,25 @@ //! sees them ([`Paints`]): an effect can draw as wide as the stroke it sits on, or tell its fills //! apart, without asking for them again as parameters. //! +//! An effect whose manifest declares `"paintOutput": true` returns painted pieces instead +//! ([`apply_painted`], [`crate::painted`]): each is drawn by itself with paints of its own. +//! //! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by //! plug-in installation, parameters, input geometry and paints: the plug-in runs again only when //! one of them changes. Failures are cached too (the geometry is then drawn unchanged) and -//! reported by [`last_error`]. +//! reported by [`last_error`]. The swatches and patterns a painted result names are checked +//! against the document each time it is used. use std::collections::HashMap; use std::hash::{Hash, Hasher}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; -use vectorcraft_doc::{Appearance, AppearanceItem}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document}; use vectorcraft_geom::{FillRule, PathData, Rect}; use crate::objects::{decode, output_geometry, paint_lists}; +use crate::painted::{self, Piece, Scope}; use crate::{Error, Kind, Limits, Result, registry}; /// The effect id prefix of live-effect plug-ins: `plugin.`. @@ -82,6 +87,15 @@ impl Paints<'_> { }; Given { fills, strokes, item } } + + /// Where the effect sits: what the pieces of an effect that declares `paintOutput` may paint. + fn scope(&self) -> Scope { + match self.appearance.zip(self.item).and_then(|(ap, i)| ap.items.get(i)) { + Some(AppearanceItem::Fill(_)) => Scope::Fill, + Some(AppearanceItem::Stroke(_)) => Scope::Stroke, + None => Scope::Object, + } + } } #[derive(PartialEq)] @@ -111,9 +125,27 @@ impl Key { } } +/// What one run of a live effect gave: geometry, or the painted pieces of an effect that declares +/// `paintOutput` ([`crate::painted`]). +#[derive(Clone, Debug, PartialEq)] +enum Out { + Path(PathData), + Pieces(Arc>), +} + +impl Out { + /// The geometry: the path, or every piece's path in order. + fn geometry(&self) -> PathData { + match self { + Out::Path(p) => p.clone(), + Out::Pieces(pieces) => PathData::new(pieces.iter().flat_map(|p| p.path.subpaths.iter().cloned()).collect()), + } + } +} + #[derive(Default)] struct Cache { - entries: HashMap)>, + entries: HashMap)>, /// The anchors of the entries' inputs and results ([`CACHE_ANCHORS`]). anchors: usize, } @@ -125,10 +157,16 @@ fn anchors(p: &PathData) -> usize { p.subpaths.iter().map(|sp| sp.anchors.len() + 1).sum() } -/// Cache units an entry takes: its input and result paths, and the serialized parameters and -/// paints its key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for). -fn weight(key: &Key, out: Option<&PathData>) -> usize { - anchors(&key.path) + out.map_or(0, anchors) + (key.params.len() + key.paints.len()) / 50 + 1 +/// Cache units an entry takes: its input and result paths, the serialized parameters and paints its +/// key owns (one unit per [`CACHE_ANCHORS`]'s 50 bytes an anchor stands for), and one for each +/// paint, gradient stop and freeform point a painted result holds. +fn weight(key: &Key, out: Option<&Out>) -> usize { + let out = match out { + None => 0, + Some(Out::Path(p)) => anchors(p), + Some(Out::Pieces(pieces)) => pieces.iter().map(|p| anchors(&p.path) + 1).sum::() + painted::paint_count(pieces), + }; + anchors(&key.path) + out + (key.params.len() + key.paints.len()) / 50 + 1 } static ERRORS: Mutex>> = Mutex::new(None); @@ -137,19 +175,60 @@ pub fn last_error(id: &str) -> Option { ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned() } -fn note(id: &str, r: &Result) { +/// Records how a run of plug-in `id` went: a failure as its last error (a painted effect's named +/// after the plug-in, [`painted::named`]), a success clearing it. +fn note(id: &str, plugin: &crate::Plugin, r: std::result::Result<(), &Error>) { let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); let errors = errors.get_or_insert_with(HashMap::new); match r { - Ok(_) => errors.remove(id), + Ok(()) => errors.remove(id), + Err(e) if plugin.manifest().paint_output => errors.insert(id.to_string(), painted::named(&plugin.manifest().name, e)), Err(e) => errors.insert(id.to_string(), e.to_string()), }; } +/// Does live-effect plug-in `id` return painted pieces (its manifest declares `paintOutput`)? +pub fn paints_output(id: &str) -> bool { + registry::get(id).is_some_and(|p| p.manifest().kind == Kind::Effect && p.manifest().paint_output) +} + /// 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). +/// 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 { + evaluate(id, params, path, bounds, rule, paints).map(|(_, out)| out.geometry()) +} + +/// Runs live-effect plug-in `id`, which declares `paintOutput`, as [`apply`] does: its painted +/// pieces, each to draw by itself in order with the paints it gives ([`painted::appearance`], +/// [`painted::items`]). Every swatch and pattern the pieces name must be in `doc`; without a +/// document, pieces that name one are refused ([`painted::check_references`]). `None` when the +/// plug-in isn't installed, doesn't declare `paintOutput`, or fails; the caller then draws its input +/// unchanged, and [`last_error`] says why (a refusal for want of a document is not recorded). +pub fn apply_painted( + id: &str, + params: &Value, + path: &PathData, + bounds: Rect, + rule: FillRule, + paints: Paints, + doc: Option<&Document>, +) -> Option>> { + 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()?; + Some(pieces) +} + +/// 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, Out)> { let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?; let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?); let given = paints.given(); @@ -165,19 +244,20 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill if let Some((k, out)) = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().and_then(|c| c.entries.get(&h)) && *k == key { - return out.clone(); + return out.clone().map(|o| (plugin, o)); } - let r = run(&plugin, resolved, path, bounds, rule, given); + let scope = paints.scope(); + let r = run(&plugin, resolved, path, bounds, rule, given, scope); // A drawing that was cancelled (an export stopped half way) says nothing about the plug-in: // the next drawing runs it again. if matches!(r, Err(Error::Cancelled)) { return None; } - note(id, &r); + note(id, &plugin, r.as_ref().map(|_| ())); let out = r.ok(); let size = weight(&key, out.as_ref()); if size > CACHE_ANCHORS / 4 { - return out; + return out.map(|o| (plugin, o)); } let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); let cache = cache.get_or_insert_with(Cache::default); @@ -188,22 +268,28 @@ pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: Fill cache.anchors = cache.anchors.saturating_sub(weight(&old, old_out.as_ref())); } cache.anchors += size; - out + out.map(|o| (plugin, o)) } -/// One run of `plugin` on `path`, with the object's paints as `given`. -fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given) -> Result { +/// One run of `plugin` on `path`, with the object's paints as `given`: geometry, or for an effect +/// that declares `paintOutput` its pieces, checked for an effect in `scope`. +fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule, given: Given, scope: Scope) -> Result { let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]); params["_context"] = json!({"mode": "effect", "bounds": b, "item": given.item}); - let rule = match rule { + let rule_name = match rule { FillRule::NonZero => "nonzero", FillRule::EvenOdd => "evenodd", }; - let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b, "fills": given.fills, "strokes": given.strokes}]}); + let input = + json!({"objects": [{"type": "path", "path": path, "fillRule": rule_name, "bounds": b, "fills": given.fills, "strokes": given.strokes}]}); let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms)); let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) }; let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }; - Ok(output_geometry(decode(&plugin.run_limited(&input, ¶ms, &limits)?)?)) + let output = plugin.run_limited(&input, ¶ms, &limits)?; + if plugin.manifest().paint_output { + return Ok(Out::Pieces(Arc::new(painted::decode(&output, scope, rule)?))); + } + Ok(Out::Path(output_geometry(decode(&output)?))) } #[cfg(test)] diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 9e6f9e934..17d87a7df 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -18,7 +18,8 @@ //! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns //! the objects to keep, change or add, applied to the document as one edit //! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each -//! time it is drawn, from an appearance stack; an **exporter** ([`export`]) writes the whole +//! time it is drawn, from an appearance stack, and at `api` 2 may paint what it returns +//! ([`painted`]); an **exporter** ([`export`]) writes the whole //! document in a file format of its own. The process-wide [`registry`] holds the installed //! plug-ins. A plug-in's parameters ([`manifest`]) come with what the host's dialogs need to show //! them well: labels, units, groups, help and conditions ([`dialog`]). What a plug-in may do is @@ -30,6 +31,7 @@ pub mod effect; pub mod export; pub mod manifest; pub mod objects; +pub mod painted; pub mod permissions; pub mod registry; mod runtime; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 75a76cdbb..8907b18c9 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -260,6 +260,18 @@ pub struct Manifest { /// How much a run may compute: only a filter may be [`Budget::Heavy`]. #[serde(default, skip_serializing_if = "Budget::is_light")] pub budget: Budget, + /// A live effect at `api` 2 that returns painted pieces ([`crate::painted`]): `true` when the + /// manifest has `"paintOutput": true`, the only value the key may hold. + #[serde(default, rename = "paintOutput", deserialize_with = "true_only", skip_serializing_if = "std::ops::Not::not")] + pub paint_output: bool, +} + +/// Reads a key that may only be `true` when present (`paintOutput`). +fn true_only<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + match Value::deserialize(d)? { + Value::Bool(true) => Ok(true), + _ => Err(serde::de::Error::custom("`paintOutput` may only be true")), + } } impl Manifest { @@ -288,6 +300,7 @@ impl Manifest { params: Vec::new(), extensions: Vec::new(), budget: Budget::Light, + paint_output: false, } } @@ -310,6 +323,9 @@ impl Manifest { if let Some(names) = &self.permissions { permissions::validate(self.kind, names)?; } + if self.paint_output && (self.kind != Kind::Effect || self.api < 2) { + return Err(Error::Manifest("`paintOutput` is only for a live effect (`kind` \"effect\") at `api` 2".into())); + } self.validate_extensions()?; match (self.budget, self.kind) { (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} @@ -654,7 +670,7 @@ mod tests { (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), (r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"), (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), - (r#"{"id":"a","name":"x","kind":"filter","api":2}"#, "`api` is 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), ] { @@ -663,6 +679,39 @@ mod tests { } } + /// A live effect at `api` 2 may declare `"paintOutput": true`, and only that value; any other + /// kind or level, and `false`, `null` or another type, is refused naming the key. Without the + /// key, at any level, the effect returns geometry only. + #[test] + fn only_a_level_2_live_effect_declares_paint_output() { + let painted = Manifest::parse(br#"{"id":"org.example.tone","name":"Tone","kind":"effect","api":2,"paintOutput":true}"#).unwrap(); + assert!(painted.paint_output); + assert_eq!(serde_json::to_value(&painted).unwrap()["paintOutput"], json!(true)); + let listed = + Manifest::parse(br#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read","effect.render"]}"#) + .unwrap(); + assert!(listed.paint_output); + for plain in [r#"{"id":"a","name":"x","kind":"effect","api":2}"#, r#"{"id":"a","name":"x","kind":"effect"}"#] { + let m = Manifest::parse(plain.as_bytes()).unwrap(); + assert!(!m.paint_output, "{plain}"); + assert!(serde_json::to_value(&m).unwrap().get("paintOutput").is_none(), "{plain}"); + } + for bad in [ + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":false}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":null}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":1}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":"true"}"#, + r#"{"id":"a","name":"x","kind":"effect","paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"effect","api":1,"paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"filter","api":2,"paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"exporter","api":2,"extensions":["plt"],"paintOutput":true}"#, + r#"{"id":"a","name":"x","kind":"effect","api":2,"paintOutput":true,"permissions":["doc.read"]}"#, + ] { + let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); + assert!(e.contains("paintOutput") || e.contains("effect.render"), "{bad}: {e}"); + } + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index 037591f8b..011d8a7d0 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -162,11 +162,11 @@ pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default() } -fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { +pub(crate) fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { Error::Output(format!("{at}: {msg}")) } -fn check_point(p: Point, at: &str) -> Result<()> { +pub(crate) fn check_point(p: Point, at: &str) -> Result<()> { if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD { Ok(()) } else { @@ -176,7 +176,7 @@ fn check_point(p: Point, at: &str) -> Result<()> { /// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty /// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`]. -fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { +pub(crate) fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; path.subpaths.retain(|sp| !sp.anchors.is_empty()); *anchors = anchors.saturating_add(path.anchor_count()); @@ -486,7 +486,7 @@ fn admit_paint(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Resul /// Sets the paints of `ap`'s fills (`fill`) or strokes to `new` (a fill keeps everything but its /// paint), in paint order: extra items are removed (from the top), missing ones added above the /// last item of their kind (fills at the bottom, strokes on top when the object has none). -fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { +pub(crate) fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { let idx: Vec = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect(); for &i in idx.iter().skip(new.len()).rev() { ap.remove_item(i); @@ -513,7 +513,7 @@ fn set_items(ap: &mut Appearance, fill: bool, new: Vec) { } /// Gives stroke `s` the paint of `o` and each of its weight, join, cap and miter limit it has. -fn style(s: &mut StrokeLayer, o: OutStroke) { +pub(crate) fn style(s: &mut StrokeLayer, o: OutStroke) { s.paint = o.paint; s.width = o.width.unwrap_or(s.width); s.join = o.join.unwrap_or(s.join); diff --git a/crates/plugins/src/painted.rs b/crates/plugins/src/painted.rs new file mode 100644 index 000000000..700936ff0 --- /dev/null +++ b/crates/plugins/src/painted.rs @@ -0,0 +1,323 @@ +//! What a live effect that declares `"paintOutput": true` (at `api` 2) returns: painted pieces. +//! +//! Such an effect returns `{"objects": [...]}` as any live effect does, but each entry is a piece +//! drawn by itself, in the order returned, with paints of its own: `{"path", "fillRule"?, +//! "fills"?, "strokes"?}`. Fills and strokes take the forms a filter returns ([`crate::objects`]). +//! A piece without `fills` (or `strokes`) keeps the ones it was given; an empty list paints none. +//! An effect on a fill may return only `fills`, one on a stroke only `strokes`. +//! +//! The result is checked whole before any of it is used ([`decode`]): every number finite and in +//! range (nothing is clamped), every key known, and every swatch and pattern a paint names in the +//! document ([`check_references`]). One fault refuses the whole result, naming the field. + +use serde_json::Value; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, LineCap, LineJoin}; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::objects::{MAX_COORD, MAX_ITEMS, MAX_OBJECTS, MAX_STROKE_WIDTH, MITER_LIMIT, OutStroke, check_point, out_err}; +use crate::{Error, Result}; + +/// The keys a piece may have. `type` and `bounds` are read back from the input as it was given and +/// say nothing about the piece. +const PIECE_KEYS: [&str; 6] = ["type", "path", "fillRule", "bounds", "fills", "strokes"]; +/// The keys a stroke of a piece may have. +const STROKE_KEYS: [&str; 5] = ["paint", "width", "join", "cap", "miterLimit"]; + +/// One piece a live effect returned: its geometry and fill rule, and its fills and strokes in paint +/// order (`None`: the ones it was given). +#[derive(Clone, Debug, PartialEq)] +pub struct Piece { + pub path: PathData, + pub rule: FillRule, + pub fills: Option>, + pub strokes: Option>, +} + +/// Where in an appearance the effect sits, which says what its pieces may paint. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Scope { + /// One of the object's effects: a piece may return fills and strokes. + Object, + /// One of a fill's own effects: a piece may return only `fills`. + Fill, + /// One of a stroke's own effects: a piece may return only `strokes`. + Stroke, +} + +/// Parses and checks a painted result: `{"objects": [...]}`, or `{"error": "message"}` when the +/// plug-in declined. `rule` is the fill rule a piece without `fillRule` takes. Any other top-level +/// key, an unknown key in a piece or stroke, a list or count past its limit, a number out of range +/// or a paint of the wrong form refuses the whole result, naming the field. +pub fn decode(bytes: &[u8], scope: Scope, rule: FillRule) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| out_err("output", format!("not JSON: {e}")))?; + if let Some(e) = crate::objects::declined(&v) { + return Err(e); + } + let top = v.as_object().ok_or_else(|| out_err("output", "expected {\"objects\": [...]}"))?; + if let Some(k) = top.keys().find(|k| !matches!(k.as_str(), "objects" | "error")) { + return Err(out_err(k, "a painted live effect returns only `objects`")); + } + let objects = top.get("objects").and_then(Value::as_array).ok_or_else(|| out_err("objects", "expected {\"objects\": [...]}"))?; + if objects.len() > MAX_OBJECTS { + return Err(out_err("objects", format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); + } + let mut anchors = 0; + objects.iter().enumerate().map(|(i, o)| piece(o, &format!("objects[{i}]"), scope, rule, &mut anchors)).collect() +} + +fn piece(v: &Value, at: &str, scope: Scope, rule: FillRule, anchors: &mut usize) -> Result { + let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?; + if let Some(k) = o.keys().find(|k| !PIECE_KEYS.contains(&k.as_str())) { + return Err(out_err(&format!("{at}.{k}"), "a painted live effect's piece has only `path`, `fillRule`, `fills` and `strokes`")); + } + if let Some(t) = o.get("type") + && !matches!(t.as_str(), Some("path" | "compound")) + { + return Err(out_err(&format!("{at}.type"), "`type` must be \"path\" or \"compound\"")); + } + let path = o.get("path").ok_or_else(|| out_err(&format!("{at}.path"), "each piece needs a `path`"))?; + // At most MAX_ANCHORS over all pieces. + let path = crate::objects::decode_path(path, &format!("{at}.path"), anchors)?; + let rule = match o.get("fillRule") { + None => rule, + Some(r) => match r.as_str() { + Some("nonzero") => FillRule::NonZero, + Some("evenodd") => FillRule::EvenOdd, + _ => return Err(out_err(&format!("{at}.fillRule"), "`fillRule` must be \"nonzero\" or \"evenodd\"")), + }, + }; + let list = |key: &str| -> Result>> { + match o.get(key) { + None => Ok(None), + Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)), + Some(_) => Err(out_err(&format!("{at}.{key}"), format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))), + } + }; + let (fills, strokes) = (list("fills")?, list("strokes")?); + match scope { + Scope::Fill if strokes.is_some() => return Err(out_err(&format!("{at}.strokes"), "an effect on a fill returns only `fills`")), + Scope::Stroke if fills.is_some() => return Err(out_err(&format!("{at}.fills"), "an effect on a stroke returns only `strokes`")), + _ => {} + } + let fills = fills.map(|f| f.iter().enumerate().map(|(i, p)| paint(p, &format!("{at}.fills[{i}]"))).collect::>>()).transpose()?; + let strokes = + strokes.map(|s| s.iter().enumerate().map(|(i, s)| stroke(s, &format!("{at}.strokes[{i}]"))).collect::>>()).transpose()?; + Ok(Piece { path, rule, fills, strokes }) +} + +/// A stroke `{paint, width?, join?, cap?, miterLimit?}`: a width of 0–[`MAX_STROKE_WIDTH`] and a +/// miter limit in [`MITER_LIMIT`]. +fn stroke(v: &Value, at: &str) -> Result { + let s = v.as_object().ok_or_else(|| out_err(at, "a stroke must be {\"paint\", \"width\"?, \"join\"?, \"cap\"?, \"miterLimit\"?}"))?; + if let Some(k) = s.keys().find(|k| !STROKE_KEYS.contains(&k.as_str())) { + return Err(out_err(&format!("{at}.{k}"), "a stroke has only `paint`, `width`, `join`, `cap` and `miterLimit`")); + } + let number = |key: &str, range: std::ops::RangeInclusive| -> Result> { + let Some(v) = s.get(key) else { return Ok(None) }; + match v.as_f64().filter(|x| x.is_finite() && range.contains(x)) { + Some(x) => Ok(Some(x)), + None => Err(out_err(&format!("{at}.{key}"), format!("`{key}` must be a number from {} to {}", range.start(), range.end()))), + } + }; + let join = match s.get("join").map(Value::as_str) { + None => None, + Some(Some("miter")) => Some(LineJoin::Miter), + Some(Some("round")) => Some(LineJoin::Round), + Some(Some("bevel")) => Some(LineJoin::Bevel), + Some(_) => return Err(out_err(&format!("{at}.join"), "`join` must be \"miter\", \"round\" or \"bevel\"")), + }; + let cap = match s.get("cap").map(Value::as_str) { + None => None, + Some(Some("butt")) => Some(LineCap::Butt), + Some(Some("round")) => Some(LineCap::Round), + Some(Some("square")) => Some(LineCap::Square), + Some(_) => return Err(out_err(&format!("{at}.cap"), "`cap` must be \"butt\", \"round\" or \"square\"")), + }; + let paint = paint(s.get("paint").unwrap_or(&Value::Null), &format!("{at}.paint"))?; + Ok(OutStroke { paint, width: number("width", 0.0..=MAX_STROKE_WIDTH)?, join, cap, miter_limit: number("miterLimit", MITER_LIMIT)? }) +} + +/// Is `x` finite and in `lo..=hi`? +fn within(x: f64, lo: f64, hi: f64) -> bool { + x.is_finite() && (lo..=hi).contains(&x) +} + +fn in_range(x: f32, lo: f32, hi: f32, at: &str, what: &str) -> Result<()> { + if within(f64::from(x), f64::from(lo), f64::from(hi)) { Ok(()) } else { Err(out_err(at, format!("{what} must be from {lo} to {hi}"))) } +} + +/// A color with every component in range: 0–1, or Lab's L 0–100 and a, b −128–127. +fn color(c: &Color, at: &str) -> Result<()> { + let unit = |x: f32| in_range(x, 0.0, 1.0, at, "a color component"); + match *c { + Color::Rgb { r, g, b } => [r, g, b].into_iter().try_for_each(unit), + Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().try_for_each(unit), + Color::Gray { k } => unit(k), + Color::Lab { l, a, b } => { + in_range(l, 0.0, 100.0, at, "Lab's L")?; + in_range(a, -128.0, 127.0, at, "Lab's a")?; + in_range(b, -128.0, 127.0, at, "Lab's b") + } + } +} + +/// A paint as the filter form has it (`null`: none), checked rather than clamped: colors, tints, +/// stops, opacities, midpoints and placements in range. +fn paint(v: &Value, at: &str) -> Result { + if v.is_null() { + return Ok(Paint::None); + } + let p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; + let unit = |x: f32, at: &str, what: &str| in_range(x, 0.0, 1.0, at, what); + match &p { + Paint::None => {} + Paint::Solid { color: c, tint, .. } => { + color(c, &format!("{at}.color"))?; + unit(*tint, &format!("{at}.tint"), "`tint`")?; + } + Paint::Gradient(g) => { + let stops = &g.gradient.stops; + if stops.is_empty() || stops.len() > MAX_ITEMS { + return Err(out_err(&format!("{at}.gradient.stops"), format!("a gradient needs 1-{MAX_ITEMS} stops"))); + } + for (i, s) in stops.iter().enumerate() { + let at = format!("{at}.gradient.stops[{i}]"); + unit(s.offset, &format!("{at}.offset"), "`offset`")?; + unit(s.opacity, &format!("{at}.opacity"), "`opacity`")?; + in_range(s.midpoint, 0.13, 0.87, &format!("{at}.midpoint"), "`midpoint`")?; + unit(s.tint, &format!("{at}.tint"), "`tint`")?; + color(&s.color, &format!("{at}.color"))?; + for x in s.plates.iter().flatten() { + unit(*x, &format!("{at}.plates"), "a plate")?; + } + } + if stops.windows(2).any(|w| w[1].offset < w[0].offset) { + return Err(out_err(&format!("{at}.gradient.stops"), "stops must be in order of `offset`")); + } + if !g.angle.is_finite() { + return Err(out_err(&format!("{at}.angle"), "`angle` must be a number")); + } + if let Some(geom) = &g.geom { + check_point(geom.start, &format!("{at}.geom.start"))?; + check_point(geom.end, &format!("{at}.geom.end"))?; + if let Some(f) = geom.focal { + check_point(f, &format!("{at}.geom.focal"))?; + } + if !within(geom.aspect, 0.01, 100.0) { + return Err(out_err(&format!("{at}.geom.aspect"), "`aspect` must be from 0.01 to 100")); + } + } + if let Some(ff) = &g.freeform { + if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) { + return Err(out_err(&format!("{at}.freeform"), format!("a freeform gradient holds at most {MAX_ITEMS} points and lines"))); + } + if ff.lines.iter().flatten().any(|i| *i >= ff.points.len()) { + return Err(out_err(&format!("{at}.freeform.lines"), "a freeform gradient line names a point it doesn't have")); + } + for (i, pt) in ff.points.iter().enumerate() { + let at = format!("{at}.freeform.points[{i}]"); + check_point(pt.at, &format!("{at}.at"))?; + color(&pt.color, &format!("{at}.color"))?; + unit(pt.opacity, &format!("{at}.opacity"), "`opacity`")?; + unit(pt.spread, &format!("{at}.spread"), "`spread`")?; + } + } + } + Paint::Pattern { xf, .. } => { + if !xf.as_coeffs().iter().all(|c| within(*c, -MAX_COORD, MAX_COORD)) { + return Err(out_err(&format!("{at}.xf"), "pattern transform out of range")); + } + } + } + Ok(p) +} + +/// Checks that every swatch a solid paint, gradient or gradient stop of `pieces` links to is a +/// swatch of `doc`, and every pattern a pattern paint names is one of its patterns. Without a +/// document (`None`), any swatch or pattern a piece names is refused. +pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> { + let swatch = |name: &Option, at: &str| match name { + Some(n) if doc.and_then(|d| d.swatch(n)).is_none() => Err(out_err(at, format!("the document has no swatch {n:?}"))), + _ => Ok(()), + }; + let check = |p: &Paint, at: &str| -> Result<()> { + match p { + Paint::None => Ok(()), + Paint::Solid { swatch: s, .. } => swatch(s, &format!("{at}.swatch")), + Paint::Gradient(g) => { + swatch(&g.swatch, &format!("{at}.swatch"))?; + g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| swatch(&s.swatch, &format!("{at}.gradient.stops[{i}].swatch"))) + } + Paint::Pattern { pattern, .. } if doc.and_then(|d| d.pattern(pattern)).is_none() => { + Err(out_err(&format!("{at}.pattern"), format!("the document has no pattern {pattern:?}"))) + } + Paint::Pattern { .. } => Ok(()), + } + }; + for (i, p) in pieces.iter().enumerate() { + for (k, f) in p.fills.iter().flatten().enumerate() { + check(f, &format!("objects[{i}].fills[{k}]"))?; + } + for (k, s) in p.strokes.iter().flatten().enumerate() { + check(&s.paint, &format!("objects[{i}].strokes[{k}].paint"))?; + } + } + Ok(()) +} + +/// How many paints, gradient stops and freeform points `pieces` hold: what a cache counts them as +/// besides their anchors. +pub(crate) fn paint_count(pieces: &[Piece]) -> usize { + let one = |p: &Paint| match p { + Paint::Gradient(g) => 1 + g.gradient.stops.len() + g.freeform.as_ref().map_or(0, |f| f.points.len()), + _ => 1, + }; + pieces.iter().map(|p| p.fills.iter().flatten().map(one).sum::() + p.strokes.iter().flatten().map(|s| one(&s.paint)).sum::()).sum() +} + +/// The appearance piece `p` of an object-level effect is drawn with: `base` (the object's) with +/// its fills, then its strokes, replaced by the piece's where it has them, as a filter's output +/// sets them (an existing item keeps everything but what the piece gives; new ones take the +/// defaults). +pub fn appearance(base: &Appearance, p: &Piece) -> Appearance { + let mut ap = base.clone(); + if let Some(fills) = &p.fills { + crate::objects::set_items(&mut ap, true, fills.iter().cloned().map(OutStroke::paint).collect()); + } + if let Some(strokes) = &p.strokes { + crate::objects::set_items(&mut ap, false, strokes.clone()); + } + ap +} + +/// The items piece `p` of an effect on `item` (a fill or a stroke) is drawn with, in paint order: +/// `item` itself when the piece keeps it, else one copy of it for each paint or stroke the piece +/// gives, with that paint (and that stroke's weight, join, cap and miter limit where given). +pub fn items(item: &AppearanceItem, p: &Piece) -> Vec { + match item { + AppearanceItem::Fill(f) => match &p.fills { + None => vec![item.clone()], + Some(fills) => fills.iter().map(|paint| AppearanceItem::Fill(vectorcraft_doc::FillLayer { paint: paint.clone(), ..f.clone() })).collect(), + }, + AppearanceItem::Stroke(s) => match &p.strokes { + None => vec![item.clone()], + Some(strokes) => strokes + .iter() + .map(|o| { + let mut s = s.clone(); + crate::objects::style(&mut s, o.clone()); + AppearanceItem::Stroke(s) + }) + .collect(), + }, + } +} + +/// An error naming `name`, the plug-in, before what went wrong (`plugin.info`'s `lastError`). +pub(crate) fn named(name: &str, e: &Error) -> String { + format!("{name}: {e}") +} + +#[cfg(test)] +mod tests; diff --git a/crates/plugins/src/painted/tests.rs b/crates/plugins/src/painted/tests.rs new file mode 100644 index 000000000..ff72d330f --- /dev/null +++ b/crates/plugins/src/painted/tests.rs @@ -0,0 +1,149 @@ +use serde_json::json; +use vectorcraft_color::{Color, Paint, Swatch}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, StrokeLayer}; + +use super::*; + +const SQUARE: &str = r#"{"subpaths":[{"closed":true,"anchors":[{"p":[0,0]},{"p":[10,0]},{"p":[10,10]},{"p":[0,10]}]}]}"#; + +fn red() -> Value { + serde_json::to_value(Paint::solid(Color::rgb(1.0, 0.0, 0.0))).unwrap() +} + +fn out(pieces: Value) -> Vec { + serde_json::to_vec(&json!({ "objects": pieces })).unwrap() +} + +fn square() -> Value { + serde_json::from_str(SQUARE).unwrap() +} + +/// Each piece keeps its own paints in the order returned; a piece without `fills` or `strokes` +/// keeps the ones it was given (`None`) and an empty list paints none. +#[test] +fn pieces_keep_their_own_paints_in_order() { + let blue = serde_json::to_value(Paint::solid(Color::rgb(0.0, 0.0, 1.0))).unwrap(); + let pieces = decode( + &out(json!([ + {"path": square(), "fills": [red()], "strokes": [{"paint": blue, "width": 4, "join": "round", "cap": "square", "miterLimit": 2}]}, + {"type": "path", "path": square(), "fillRule": "evenodd", "fills": [], "bounds": [0, 0, 10, 10]}, + {"path": square()}, + ])), + Scope::Object, + FillRule::NonZero, + ) + .unwrap(); + assert_eq!(pieces.len(), 3); + assert_eq!(pieces[0].fills, Some(vec![Paint::solid(Color::rgb(1.0, 0.0, 0.0))])); + let s = &pieces[0].strokes.as_ref().unwrap()[0]; + assert_eq!((s.width, s.join, s.cap, s.miter_limit), (Some(4.0), Some(LineJoin::Round), Some(LineCap::Square), Some(2.0))); + assert_eq!((pieces[1].rule, pieces[1].fills.as_deref(), &pieces[1].strokes), (FillRule::EvenOdd, Some(&[][..]), &None)); + assert_eq!((pieces[2].rule, &pieces[2].fills, &pieces[2].strokes), (FillRule::NonZero, &None, &None)); +} + +/// What a piece is drawn with: the object's appearance with the paints the piece gives in place of +/// its own (kept, cleared or replaced), and for an effect on one fill or stroke, copies of that item. +#[test] +fn a_piece_s_appearance_inherits_or_replaces() { + let base = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::WHITE))), + AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 3.0)), + ], + ..Appearance::default() + }; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let piece = + |fills: Option>, strokes: Option>| Piece { path: PathData::default(), rule: FillRule::NonZero, fills, strokes }; + assert_eq!(appearance(&base, &piece(None, None)), base, "inherited"); + let ap = appearance(&base, &piece(Some(vec![red.clone()]), Some(vec![]))); + assert_eq!(ap.items.len(), 1); + assert_eq!(ap.items[0].paint(), &red); + let ap = appearance(&base, &piece(None, Some(vec![OutStroke { width: Some(8.0), ..OutStroke::paint(red.clone()) }]))); + let AppearanceItem::Stroke(s) = &ap.items[1] else { panic!("a stroke") }; + assert_eq!((s.width, &s.paint), (8.0, &red)); + let two = items(&base.items[0], &piece(Some(vec![red.clone(), Paint::None]), None)); + assert_eq!(two.iter().map(|i| i.paint().clone()).collect::>(), [red.clone(), Paint::None]); + assert_eq!(items(&base.items[1], &piece(None, None)), vec![base.items[1].clone()]); + let none = items(&base.items[1], &piece(None, Some(vec![]))); + assert!(none.is_empty()); +} + +/// An effect on a fill returns only fills, one on a stroke only strokes. +#[test] +fn an_item_s_effect_paints_only_its_kind() { + let fills = out(json!([{"path": square(), "fills": [red()]}])); + let strokes = out(json!([{"path": square(), "strokes": [{"paint": red()}]}])); + assert!(decode(&fills, Scope::Fill, FillRule::NonZero).is_ok()); + assert!(decode(&strokes, Scope::Stroke, FillRule::NonZero).is_ok()); + let e = decode(&strokes, Scope::Fill, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].strokes"), "{e}"); + let e = decode(&fills, Scope::Stroke, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills"), "{e}"); +} + +/// Every fault refuses the whole result and names its field; nothing is clamped. +#[test] +fn faults_refuse_the_whole_result_by_field() { + let ok = json!({"path": square(), "fills": [red()]}); + let stop = |offset: f64| json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": offset, "color": {"model": "rgb", "r": 0, "g": 0, "b": 0}}]}}); + let many: Vec = (0..=MAX_ITEMS).map(|_| red()).collect(); + let stops: Vec = (0..=MAX_ITEMS).map(|_| json!({"offset": 0, "color": {"model": "gray", "k": 0}})).collect(); + for (piece, at) in [ + (json!({"path": square(), "fills": [{"type": "plaid"}]}), "objects[1].fills[0]"), + (json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "rgb", "r": 2, "g": 0, "b": 0}}]}), "objects[1].fills[0].color"), + (json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "rgb", "r": 1e300, "g": 0, "b": 0}}]}), "objects[1].fills[0].color"), + (json!({"path": square(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}, "tint": 1.5}]}), "objects[1].fills[0].tint"), + (json!({"path": square(), "strokes": [{"paint": red(), "width": 1001}]}), "objects[1].strokes[0].width"), + (json!({"path": square(), "strokes": [{"paint": red(), "width": -1}]}), "objects[1].strokes[0].width"), + (json!({"path": square(), "strokes": [{"paint": red(), "miterLimit": 0.5}]}), "objects[1].strokes[0].miterLimit"), + (json!({"path": square(), "strokes": [{"paint": red(), "join": "wobbly"}]}), "objects[1].strokes[0].join"), + (json!({"path": square(), "strokes": [{"paint": red(), "dashes": [1, 2]}]}), "objects[1].strokes[0].dashes"), + (json!({"path": square(), "fills": [stop(1.5)]}), "objects[1].fills[0].gradient.stops[0].offset"), + ( + json!({"path": square(), "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": stops}}]}), + "objects[1].fills[0].gradient.stops", + ), + (json!({"path": square(), "fills": many}), "objects[1].fills"), + (json!({"path": square(), "fills": [red()], "opacity": 0.5}), "objects[1].opacity"), + (json!({"path": square(), "effects": []}), "objects[1].effects"), + (json!({"path": square(), "guide": true}), "objects[1].guide"), + (json!({"path": square(), "locked": true}), "objects[1].locked"), + (json!({"fills": [red()]}), "objects[1].path"), + (json!({"path": square(), "fillRule": "inside"}), "objects[1].fillRule"), + (json!({"path": {"subpaths": [{"closed": true, "anchors": [{"p": [1e9, 0]}]}]}}), "objects[1].path"), + ] { + let e = decode(&out(json!([ok, piece])), Scope::Object, FillRule::NonZero).unwrap_err(); + let e = e.to_string(); + assert!(e.contains(&format!("{at}:")), "{at}: {e}"); + } + let e = decode(&serde_json::to_vec(&json!({"objects": [ok], "documentData": {}})).unwrap(), Scope::Object, FillRule::NonZero).unwrap_err(); + assert!(e.to_string().contains("documentData"), "{e}"); + let declined = decode(br#"{"error":"nothing to do"}"#, Scope::Object, FillRule::NonZero).unwrap_err(); + assert_eq!(declined, Error::Failed("nothing to do".into())); +} + +/// The swatches and patterns a piece's paints name must be in the document. +#[test] +fn named_swatches_and_patterns_must_exist() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(Swatch { name: "Brand".into(), paint: Paint::solid(Color::rgb(0.2, 0.4, 0.6)), global: true, spot: false }); + let linked = |name: &str| json!({"type": "solid", "color": {"model": "rgb", "r": 0.2, "g": 0.4, "b": 0.6}, "swatch": name}); + let pieces = |pieces: Value| decode(&out(pieces), Scope::Object, FillRule::NonZero).unwrap(); + let check = |p: Value| check_references(&pieces(p), Some(&doc)); + assert!(check(json!([{"path": square(), "fills": [linked("Brand")]}])).is_ok()); + let e = check(json!([{"path": square()}, {"path": square(), "strokes": [{"paint": linked("Gone")}]}])).unwrap_err().to_string(); + assert!(e.contains("objects[1].strokes[0].paint.swatch"), "{e}"); + let stop = + json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": {"model": "gray", "k": 0}, "swatch": "Gone"}]}}); + let e = check(json!([{"path": square(), "fills": [stop]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[0].swatch"), "{e}"); + let e = check(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].pattern"), "{e}"); + // Without a document to look them up in, no swatch or pattern may be named. + assert!(check_references(&pieces(json!([{"path": square(), "fills": [red()]}])), None).is_ok()); + let e = check_references(&pieces(json!([{"path": square(), "fills": [linked("Brand")]}])), None).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].swatch"), "{e}"); + let e = check_references(&pieces(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])), None).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].pattern"), "{e}"); +} diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 1d6b0664e..18d8da809 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -12,8 +12,8 @@ use serde::{Deserialize, Serialize}; use crate::{Error, Kind, Result}; /// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most -/// this. -pub const API_LEVEL: u32 = 1; +/// this. Level 2 adds a live effect's `paintOutput` ([`crate::painted`]). +pub const API_LEVEL: u32 = 2; /// Most permissions a manifest may list. pub const MAX_PERMISSIONS: usize = 16; diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5c9b74c85..64ae87821 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -597,6 +597,7 @@ impl PluginIntField { params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), budget: Default::default(), + paint_output: false, }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 324750c44..d635f91ce 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5825,6 +5825,7 @@ digits Malayalam Malaiàlam Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte Write the document as a file in its own format Escriure el document com un fitxer en un format propi @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` només és per a un efecte dinàmic (`kind` "effect") amb `api` 2 @msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos @msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un complement del tipus `{_1}` necessita el permís `{_2}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 78e9cdd6e..4e467f412 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5802,6 +5802,7 @@ axis Y Axis osa Y Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` je jen pro živý efekt (`kind` "effect") s `api` 2 @msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění @msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` zásuvný modul druhu `{_1}` potřebuje oprávnění `{_2}` diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b22912c68..afdefcc0d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5384,6 +5384,7 @@ axis Y Axis Y-Achse Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` gilt nur für einen Live-Effekt (`kind` "effect") mit `api` 2 @msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein @msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` ein Plug-in der Art `{_1}` braucht die Berechtigung `{_2}` diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 71e4f499f..2413df55c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5753,6 +5753,7 @@ axis Y Axis Άξονας Y Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 το `paintOutput` ισχύει μόνο για ζωντανό εφέ (`kind` "effect") με `api` 2 @msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων @msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` ένα πρόσθετο τύπου `{_1}` χρειάζεται το δικαίωμα `{_2}` diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 72ad8d982..287354443 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5743,6 +5743,7 @@ axis Y Axis Eje Y Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto Write the document as a file in its own format Escribir el documento como un archivo en su propio formato @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` solo es para un efecto dinámico (`kind` "effect") con `api` 2 @msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos @msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un plugin de tipo `{_1}` necesita el permiso `{_2}` diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f4eeaaff1..5ea7377d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5832,6 +5832,7 @@ axis Y Axis Y-akseli Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` on vain reaaliaikaiselle tehosteelle (`kind` "effect") tasolla `api` 2 @msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä @msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` lajin `{_1}` laajennus tarvitsee käyttöoikeuden `{_2}` diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d9e77d68d..8e497f293 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5797,6 +5797,7 @@ axis Y Axis Axe Y Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué Write the document as a file in its own format Écrire le document dans un fichier à son propre format @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` est réservé à un effet dynamique (`kind` "effect") avec `api` 2 @msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations @msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b5791f4a4..069177641 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5199,6 +5199,7 @@ Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` केवल `api` 2 पर लाइव इफ़ेक्ट (`kind` "effect") के लिए है @msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए @msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` प्रकार के प्लग-इन को `{_2}` अनुमति चाहिए diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 58d649442..1219c19ae 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5785,6 +5785,7 @@ axis Y Axis Y tengely Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 a `paintOutput` csak élő effektushoz (`kind` "effect") használható, `api` 2 mellett @msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie @msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` egy `{_1}` fajtájú bővítménynek szüksége van a(z) `{_2}` engedélyre diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d11fcb22d..1d0f3d256 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5418,6 +5418,7 @@ axis Y Axis Asse Y Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto Write the document as a file in its own format Scrivere il documento come file in un formato proprio @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` vale solo per un effetto dinamico (`kind` "effect") con `api` 2 @msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni @msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un plug-in di tipo `{_1}` richiede l’autorizzazione `{_2}` diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6bb917eec..a7e1bcca6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5483,6 +5483,7 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` は `api` 2 のライブ効果 (`kind` "effect") 専用です @msg `grant` must be a list of permission names `grant` は権限名のリストにしてください @msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています @msg a plug-in of kind `{_1}` needs the permission `{_2}` 種類 `{_1}` のプラグインには権限 `{_2}` が必要です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 483955091..6ff4427d1 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5725,6 +5725,7 @@ axis Y Axis Y ღერძი Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` მხოლოდ ცოცხალი ეფექტისთვისაა (`kind` "effect") `api` 2-ზე @msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს @msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` სახის პლაგინს სჭირდება ნებართვა `{_2}` diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9a6a465c9..5cc2a4740 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5379,6 +5379,7 @@ axis Y Axis Y축 Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput`은 `api` 2의 라이브 효과(`kind` "effect")에만 쓸 수 있습니다 @msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 @msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 종류의 플러그인에는 `{_2}` 권한이 필요합니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f98e35520..9f0f0375c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5655,6 +5655,7 @@ axis Y Axis Oś Y Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` dotyczy tylko efektu na żywo (`kind` "effect") z `api` 2 @msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień @msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` wtyczka rodzaju `{_1}` potrzebuje uprawnienia `{_2}` diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ce6919f8d..3f34dac48 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5717,6 +5717,7 @@ axis Y Axis Eixo Y Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` é só para um efeito dinâmico (`kind` "effect") com `api` 2 @msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões @msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` um plug-in do tipo `{_1}` precisa da permissão `{_2}` diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a1ae4746c..fae6ad832 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5413,6 +5413,7 @@ axis Y Axis Ось Y Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект Write the document as a file in its own format Записывать документ в файл собственного формата @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` допустим только для динамического эффекта (`kind` "effect") с `api` 2 @msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений @msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю вида `{_1}` нужно разрешение `{_2}` diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b13141b8e..385b5d0cf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5825,6 +5825,7 @@ axis Y Axis Y-axel Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` gäller bara en liveeffekt (`kind` "effect") med `api` 2 @msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn @msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` ett plugin av typen `{_1}` behöver behörigheten `{_2}` diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d3fc04823..c11452966 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5759,6 +5759,7 @@ axis Y Axis Y ekseni Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` yalnızca `api` 2 düzeyindeki canlı efekt (`kind` "effect") içindir @msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır @msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` türündeki bir eklentinin `{_2}` iznine ihtiyacı vardır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c256b0064..538e155b6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5398,6 +5398,7 @@ axis Y Axis Вісь Y Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект Write the document as a file in its own format Записувати документ у файл власного формату @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` дозволено лише для динамічного ефекту (`kind` "effect") з `api` 2 @msg `grant` must be a list of permission names `grant` має бути списком назв дозволів @msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` модулю виду `{_1}` потрібен дозвіл `{_2}` diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 887d9a0f6..f25ba5ac0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5399,6 +5399,7 @@ axis Y Axis Trục Y Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` chỉ dành cho hiệu ứng trực tiếp (`kind` "effect") ở `api` 2 @msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền @msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` plug-in loại `{_1}` cần quyền `{_2}` diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d99db0948..63acd4acf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5640,6 +5640,7 @@ axis Y Axis Y 轴 Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 Write the document as a file in its own format 将文档写为其自有格式的文件 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` 仅适用于 `api` 2 的实时效果 (`kind` "effect") @msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 类型的增效工具需要权限 `{_2}` diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index eb6b40a80..2f8a637c4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5436,6 +5436,7 @@ axis Y Axis Y 軸 Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 Write the document as a file in its own format 將文件寫成其自有格式的檔案 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} +@msg `paintOutput` is only for a live effect (`kind` "effect") at `api` 2 `paintOutput` 僅適用於 `api` 2 的即時效果 (`kind` "effect") @msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` `{_1}` 類型的增效模組需要權限 `{_2}` diff --git a/docs/mcp.md b/docs/mcp.md index 481570141..98e198dc1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5968,8 +5968,11 @@ them again at startup (`plugin.remove` deletes them there): schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and -`effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): -`document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions +`effect.list` lists it. One whose manifest declares `paintOutput` (at plug-in API level 2; `plugin.list` gives `api` +and `paintOutput: true`) draws the pieces it returns with paints of their own, and `effect.expandAppearance` writes them +as ordinary paths with those paints; a result it refuses is drawn as if the effect weren't there, and `plugin.info`'s +`lastError` names the plug-in and the refused field. An exporter adds a write-only file format `plugin.` (listed +by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. A failing plug-in returns an error and leaves the document as it was (an export writes no file). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its `plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run` diff --git a/docs/plugins.md b/docs/plugins.md index 96f8ac6e3..625ea1316 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -11,7 +11,8 @@ There are three kinds of plug-in: and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path clean-up, halftones, generative patterns and the like. - **Live effects** rewrite one object's geometry each time it is drawn, from its appearance stack, like the - built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows. + built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows. A live + effect may also paint what it returns ([painted live effects](#painted-live-effects)). - **Exporters** write the whole document in a file format of their own, for pen plotters, cutting machines, laser cutters and the like. File › Export › Export As lists them after the built-in formats, and the host writes the bytes they return: a plug-in still never touches a file. @@ -23,7 +24,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, api, paintOutput?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `api` is the manifest's plug-in API level, and `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | @@ -140,8 +141,9 @@ capability model proposed for PhotoCraft plug-ins - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. -- `api` is the plug-in API level the manifest is written for: `1` (the default) is the only level this host - implements, and a higher one is refused. +- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds a live effect's + `paintOutput`. A higher one is refused. A level 2 effect that declares `paintOutput` needs no other permission than + `doc.read` and `effect.render`. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -158,8 +160,10 @@ takes as many instructions the first time as the next. Charged to the run, as wa could fail it: when the 20 M-instruction slice the run was in ran out while a function compiled, wasmi ended the run with an error rather than a pause the host resumes, and the run stopped with most of its budget left. -Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters and input -geometry: a plug-in runs again only when one of them changes. +Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters, input +geometry, fill rule, reference box and the paints the effect is given: a plug-in runs again only when one of them +changes. Failures are cached too. The swatches and patterns a painted result names are checked against the document +each time it is drawn. ## ABI v1 @@ -204,6 +208,11 @@ pointers are offsets into the module's memory. filters](#heavy-filters)). Hosts from before it ignore the key, so there the filter gets a light run's budget. - `api`: the plug-in API level, `1` when left out. `permissions`: what the plug-in may do, a list of names ([permissions](#permissions)); without it, what its kind implies. +- `paintOutput` (live effects at `api` 2 only): `true` makes the effect return [painted pieces](#painted-live-effects) + instead of geometry alone. `true` is the only value the key takes; `false`, `null`, another type, another kind or + `api` 1 is refused when the module is installed, and the error names `paintOutput`. Without the key the effect returns + geometry, at any level. Hosts at `api` 1 refuse the manifest for its `api`, so such an effect never draws unpainted + there. - `extensions` (exporters only, and required): 1–8 file extensions, lower-case letters and digits without the dot (`["plt"]`); the first is the one Export As gives the file. A built-in format keeps an extension both list (`svg`): `document.export` then writes the exporter's format only when asked for by its format id. @@ -247,7 +256,8 @@ guides too) and clipping paths are left out, and so are other kinds of object (t touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and `strokes`, as a filter gets them (hosts from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a -fill's colour to decide its shape, but it changes only the geometry. +fill's colour to decide its shape, but it changes only the geometry, unless it declares `paintOutput` ([painted live +effects](#painted-live-effects)). An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object, @@ -351,7 +361,45 @@ document, so it can read what every plug-in keeps there, not only its own. A liv is worked out from the object alone. For a **live effect**, the geometry of every returned object's `path` (their subpaths, in order) replaces the -object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry. +object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry. A live effect +that declares `paintOutput` returns painted pieces instead ([below](#painted-live-effects)). + +#### Painted live effects + +A live effect whose manifest has `"api": 2` and `"paintOutput": true` returns `{"objects": [...]}` too, but each entry +is a piece drawn by itself, in the order returned, with paints of its own: + +```json +{"objects": [ + {"path": {…}, "fills": [{"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0.6, "b": 0.3}}]}, + {"path": {…}, "fillRule": "evenodd", "strokes": [{"paint": null}, {"paint": {…}, "width": 4, "join": "round"}]}, + {"path": {…}} +]} +``` + +- `path` is required; `fillRule` defaults to the object's. `type` and `bounds` may be handed back as given and are + ignored. Any other key (`opacity`, `effects`, `guide`, `locked`, `name`, a top-level `documentData`…) refuses the + result. +- `fills` and `strokes` take the forms a filter returns ([Input](#input)). A piece without `fills` (or `strokes`) is + painted with the ones the effect was given; an empty list paints none. Given ones replace the object's in order, as + a filter's output sets them: an existing fill or stroke keeps its own effects, opacity and blending, and a new stroke + starts at 1 pt, miter join, butt cap, miter limit 10. +- An effect on a fill (`_context.item` `{"fill": i}`) returns only `fills`, which replace that fill on each piece; an + effect on a stroke returns only `strokes`. The object's other fills and strokes stay where they are. +- 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 and raster effects use the pieces as they are drawn. 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 + anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped + (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke + widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every pattern named in + the document. One fault refuses the whole result: that step of the stack draws its input as it was, the effect + record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example + `Test Tone: plug-in output: objects[1].strokes[0].width: …`. +- The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG + export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an + ordinary path or compound path with its fills and strokes, as one undo step. An **exporter** returns `{"dataBase64": "…", "warnings"?: ["…"]}`: the file, in base64, which the host writes as it is, and what the export left out or changed, for the user (at most 100 warnings of at most 500 characters each, shown @@ -388,6 +436,6 @@ per change of their input, parameters or paints (results are cached), not per fr - Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host callbacks (a plug-in reporting progress or asking whether it was cancelled), importers (plug-in formats that open files). -- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or - symbols. +- Live effects that change opacity, blending or effects (at `api` 2 one may [paint](#painted-live-effects) the pieces + it returns), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ab98af00d..aaca063ea 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1445,6 +1445,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.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. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From d8afed1562b34ecc224351d10ec215b2bbdba4ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 05:52:39 -0800 Subject: [PATCH 02/10] Plug-ins: painted pieces keep their swatches in Pathfinder and baked art Pathfinder effects, compound shapes, Expand Appearance and pattern expansion now pass the document to the effects they evaluate, so a painted piece that names a swatch or pattern takes part as drawn instead of being refused. A path with a painted effect takes part in Pathfinder and compound shapes as its pieces, each with its own paint, inside the path's opacity and blend mode. The renderer's kept effect art and live evaluations now count the document's swatch and pattern names for objects with a plug-in effect, so deleting or restoring a swatch a piece names redraws the object. --- crates/effects/src/bake.rs | 29 +++--- crates/effects/src/bake/tests.rs | 12 +-- crates/effects/src/group.rs | 92 ++++++++++-------- crates/effects/src/group/tests.rs | 40 ++++---- crates/effects/src/lib.rs | 3 +- crates/effects/src/live.rs | 3 +- crates/effects/src/params.rs | 8 ++ crates/effects/src/reshape.rs | 31 +++--- crates/effects/src/revolve.rs | 2 +- crates/effects/src/solids/tests.rs | 4 +- crates/effects/src/tests.rs | 10 +- crates/engine/src/cmd/compoundshape.rs | 6 +- crates/engine/src/cmd/expand.rs | 33 ++++--- crates/engine/src/cmd/pathops.rs | 2 +- crates/engine/src/tests/compound_shape.rs | 16 +-- crates/engine/src/tests/plugins.rs | 113 ++++++++++++++++++++++ crates/eps/src/import/ai/tests.rs | 2 +- crates/render/src/fx/entries.rs | 18 ++-- crates/render/src/fx/pixels.rs | 10 +- crates/render/src/fx/predicates.rs | 8 +- crates/render/src/live.rs | 37 ++++--- docs/plugins.md | 4 +- 22 files changed, 318 insertions(+), 165 deletions(-) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index cdde7d135..a2cefbd74 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -333,16 +333,16 @@ fn moved_type_group(n: &Node) -> Option { /// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { - fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); if !matches!(c.kind, NodeKind::Text(_)) { if has_pathfinder(c) || has_own_paint(c) || !geometry_effects(c).is_empty() { - outline_nested_text(Arc::make_mut(c), clipping); + outline_nested_text(Some(d), Arc::make_mut(c), clipping); } else if c.children().is_some() { - keep(Arc::make_mut(c), moves, clipping, kept); + keep(d, Arc::make_mut(c), moves, clipping, kept); } continue; } @@ -357,7 +357,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { } } None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - None => as_art(None, c, None), + None => as_art(Some(d), c, None), }; if let Some(o) = art { *c = Arc::new(o); @@ -376,11 +376,11 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { } let mut m = crate::reshape::evaluated(Some(d), n); let mut kept = false; - keep(&mut m, &moves, false, &mut kept); + keep(d, &mut m, &moves, false, &mut kept); if !kept { return None; } - reshape_leaves(&mut m, &fx); + reshape_leaves(&mut m, &fx, Some(d)); m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; @@ -530,9 +530,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { /// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as /// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path /// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`] -/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor -/// a geometry effect, so bending first changes nothing. -pub fn bake_appearance(n: &Node) -> Option { +/// makes of it, and each copy is baked this way; the swatches and patterns that live-effect plug-ins +/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to +/// outline nor a geometry effect, so bending first changes nothing. +pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option { if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { return Some(m); } @@ -543,9 +544,9 @@ pub fn bake_appearance(n: &Node) -> Option { // patterns: the art Expand Appearance makes of it (each copy an object with its own strokes, // patterns and gradients), each piece baked in turn. if crate::has_transform_art(n) - && let Some(mut m) = reshape(None, n, None) + && let Some(mut m) = reshape(doc, n, None) { - bake_appearance_pieces(&mut m); + bake_appearance_pieces(doc, &mut m); return Some(m); } let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); @@ -601,15 +602,15 @@ pub fn bake_appearance(n: &Node) -> Option { } /// [`bake_appearance`] every path and compound path in `n` that it changes, in place. -fn bake_appearance_pieces(n: &mut Node) { +fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node) { if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { - if let Some(m) = bake_appearance(n) { + if let Some(m) = bake_appearance(doc, n) { *n = m; } return; } for c in n.children_mut().into_iter().flatten() { - bake_appearance_pieces(Arc::make_mut(c)); + bake_appearance_pieces(doc, Arc::make_mut(c)); } } diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index e16742270..7f933f412 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -67,7 +67,7 @@ fn a_baked_stroke_keeps_its_raster_effects() { s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); s.effects.push(fx("stylize.dropShadow", json!({}))); } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); @@ -82,7 +82,7 @@ fn a_baked_stroke_leaves_dither_behind() { g.gradient.dither = true; s.paint = Paint::Gradient(Box::new(g)); } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; @@ -98,7 +98,7 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline s.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; s.opacity = 0.5; } - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let ch = m.children().unwrap(); let NodeKind::Group { children, clip: true } = &ch[1].kind else { panic!("a clip group: {:?}", ch[1].kind) }; assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); @@ -131,7 +131,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { // Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline // is 204 pt wide. let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 2, "{w:?}"); assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}"); @@ -142,7 +142,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() { fn a_baked_transform_copy_keeps_its_moved_pattern() { let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY }; let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let xs: Vec = baked_paths(&m) .iter() .map(|p| match p.appearance.fill_paint() { @@ -157,7 +157,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() { fn a_baked_transform_without_its_options_outlines_the_copies_together() { // Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares. let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1})); - let m = bake_appearance(&n).unwrap(); + let m = bake_appearance(None, &n).unwrap(); let w = widths(&m); assert_eq!(w.len(), 1, "{w:?}"); assert!((w[0] - 202.0).abs() < 0.5, "{w:?}"); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index aa222809d..d34a7fcef 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -67,25 +67,28 @@ pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option; /// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as /// the art it draws, and type and symbol instances as their art through `hook`. Past /// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further -/// instances add nothing. -pub fn member_leaves(n: &Node, hook: Option) -> Vec { +/// instances add nothing. The swatches and patterns that live-effect plug-ins painting their +/// pieces name are looked up in `doc` ([`shape_art`]). +pub fn member_leaves(doc: Option<&Document>, n: &Node, hook: Option) -> Vec { let mut out = vec![]; - leaves(n, hook, &SymbolNodes::default(), &mut out); + leaves(doc, n, hook, &SymbolNodes::default(), &mut out); out } /// [`member_leaves`], with the symbol art given so far counted in `nodes`. -fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { +fn leaves(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) { if !n.visible { return; } // A blend, an envelope or an object with a warp takes part as the art it draws. - if let Some(art) = shape_art_in(n, hook, nodes) { + if let Some(art) = shape_art_in(doc, n, hook, nodes) { let first = out.len(); - leaves(&art, hook, nodes, out); - // Every path of a live object's, type's or symbol instance's art is drawn inside the - // object's opacity and blend mode. - if crate::needs_outline(n) { + leaves(doc, &art, hook, nodes, out); + // Every path of a live object's, type's or symbol instance's art, and every piece of a path + // whose live-effect plug-in paints its pieces, is drawn inside the object's opacity and + // blend mode. + let pieces = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && matches!(art.kind, NodeKind::Group { .. }); + if crate::needs_outline(n) || pieces { for l in out.iter_mut().skip(first) { within(l, n); } @@ -95,11 +98,11 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()), // A compound shape takes part as its area, styled like it. - NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(doc, n, hook, nodes) { Some(art) => out.push(art), None => { for c in children { - leaves(c, hook, nodes, out); + leaves(doc, c, hook, nodes, out); } } }, @@ -114,7 +117,7 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve let art = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => { let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); - crate::reshape::outline_nested_text(&mut art, false); + crate::reshape::outline_nested_text(doc, &mut art, false); transparency_into_members(&mut art); art } @@ -122,7 +125,7 @@ fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Ve _ => art, }; let first = out.len(); - leaves(&art, None, nodes, out); + leaves(doc, &art, None, nodes, out); for l in out.iter_mut().skip(first) { within(l, n); } @@ -176,29 +179,37 @@ fn transparency_into_members(n: &mut Node) { /// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art /// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their /// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images, -/// for symbol instances without geometry effects, and for one without art from `hook`. -pub fn shape_art(n: &Node, hook: Option) -> Option { - shape_art_in(n, hook, &SymbolNodes::default()) +/// for symbol instances without geometry effects, and for one without art from `hook`. A +/// live-effect plug-in that paints its pieces makes a path, and each path of a group's or symbol's +/// art, a group of its pieces, whose swatches and patterns must be in `doc` (without one, pieces +/// that name one are refused and the path stays as it is). +pub fn shape_art(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + shape_art_in(doc, n, hook, &SymbolNodes::default()) } /// [`shape_art`], with the symbol art given so far counted in `nodes`. -fn shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { - let mut art = reshaped_art(n, hook, nodes)?; +fn shape_art_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let mut art = reshaped_art(doc, n, hook, nodes)?; transparency_into_members(&mut art); Some(art) } /// [`shape_art_in`] before the groups inside the art pass on their transparency. -fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { +fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { let fx = crate::reshape::geometry_effects(n); let outline = hook.unwrap_or(&crate::outline_text); match &n.kind { - NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)), - NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) { + // A path with a live-effect plug-in that paints its pieces: the group of its pieces, each + // 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))) + } + NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(doc, n, hook, nodes) { Some(area) => Some(reshaped(&area, n)), None => { let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes); - crate::reshape::reshape_leaves(&mut m, &fx); + crate::reshape::reshape_leaves(&mut m, &fx, doc); without_geometry_effects(&mut m); Some(m) } @@ -209,8 +220,8 @@ fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Opt let h = hook?; let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); - crate::reshape::outline_nested_text(&mut art, false); - crate::reshape::reshape_leaves(&mut art, &fx); + crate::reshape::outline_nested_text(doc, &mut art, false); + crate::reshape::reshape_leaves(&mut art, &fx, doc); (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate); art.appearance.effects = n.appearance.effects.clone(); without_geometry_effects(&mut art); @@ -270,12 +281,12 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp { /// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a /// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`], /// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)). -pub fn compound_shape_area(n: &Node, hook: Option) -> Option { - compound_shape_area_in(n, hook, &SymbolNodes::default()) +pub fn compound_shape_area(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + compound_shape_area_in(doc, n, hook, &SymbolNodes::default()) } /// [`compound_shape_area`], with the symbol art given so far counted in `nodes`. -fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { +fn compound_shape_area_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { if !n.is_compound_shape() { return None; } @@ -284,7 +295,7 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod let mut members: Vec<(po::BoolOp, usize)> = vec![]; for c in n.children()? { let before = all.len(); - leaves(c, hook, nodes, &mut all); + leaves(doc, c, hook, nodes, &mut all); if all.len() > before { members.push((shape_op(c.extras().shape_mode), all.len() - before)); } @@ -310,13 +321,13 @@ fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNod /// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance, /// opacity and blend mode: how it takes part in a Pathfinder operation. -pub fn compound_shape_art(n: &Node, hook: Option) -> Option { - compound_shape_art_in(n, hook, &SymbolNodes::default()) +pub fn compound_shape_art(doc: Option<&Document>, n: &Node, hook: Option) -> Option { + compound_shape_art_in(doc, n, hook, &SymbolNodes::default()) } /// [`compound_shape_art`], with the symbol art given so far counted in `nodes`. -fn compound_shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { - let area = compound_shape_area_in(n, hook, nodes)?; +fn compound_shape_art_in(doc: Option<&Document>, n: &Node, hook: Option, nodes: &SymbolNodes) -> Option { + let area = compound_shape_area_in(doc, n, hook, nodes)?; let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() }); (art.opacity, art.blend) = (n.opacity, n.blend); Some(art) @@ -422,15 +433,16 @@ fn within_budget<'a>(leaves: impl IntoIterator) -> bool { /// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects. /// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`], /// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for -/// each effect) it returns the children as they are. -pub fn pathfinder_children(group: &Node, hook: Option) -> Option>> { +/// each effect) it returns the children as they are. The swatches and patterns that live-effect +/// plug-ins painting their pieces name are looked up in `doc` ([`shape_art`]). +pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option) -> Option>> { if !has_pathfinder(group) { return None; } let source: &[Arc] = group.children()?; let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. - match compound_shape_area(group, hook) { + match compound_shape_area(doc, group, hook) { Some(area) if area.is_empty() => vec![], Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))], None => return Some(source.to_vec()), @@ -442,7 +454,7 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option) -> Option, n: &Node) -> Vec<(PathData, FillRul NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)), NodeKind::Group { .. } if n.is_compound_shape() => { let hook = |c: &Node| crate::outline_object(doc, c); - match compound_shape_area(n, Some(&hook)) { + match compound_shape_area(doc, n, Some(&hook)) { Some(area) => out.push((area, FillRule::NonZero)), None => members(doc, n, out), } @@ -702,7 +714,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { let mut m = n.clone(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -711,7 +723,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - crate::reshape::reshape_leaves_in(&mut m, &fx, doc); + crate::reshape::reshape_leaves(&mut m, &fx, doc); } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index b67279bbb..d04a1f4ec 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -27,7 +27,7 @@ fn area(n: &Node) -> f64 { } fn overlap_cmyk(g: &Node) -> [f32; 4] { - let faces = pathfinder_children(g, None).unwrap(); + let faces = pathfinder_children(None, g, None).unwrap(); assert_eq!(faces.len(), 3); let overlap = faces .iter() @@ -61,7 +61,7 @@ fn trap_effect_adds_an_overprinting_strip_above_the_members() { let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 3, "both squares and one trap"); assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); let b = out[2].geometric_bounds().unwrap(); @@ -89,17 +89,17 @@ fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { #[test] fn shape_modes_follow_the_pathfinder_panel() { - let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); + let add = pathfinder_children(None, &group_with("pathfinder.add"), None).unwrap(); assert_eq!(add.len(), 1); assert!((area(&add[0]) - 150.0).abs() < 1e-3); // Add keeps the front object's paint, Subtract the back one's. assert_eq!(add[0].id, NodeId(2)); - let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap(); + let sub = pathfinder_children(None, &group_with("pathfinder.subtract"), None).unwrap(); assert_eq!(sub[0].id, NodeId(1)); assert!((area(&sub[0]) - 50.0).abs() < 1e-3); - let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap(); + let int = pathfinder_children(None, &group_with("pathfinder.intersect"), None).unwrap(); assert!((area(&int[0]) - 50.0).abs() < 1e-3); - let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap(); + let div = pathfinder_children(None, &group_with("pathfinder.divide"), None).unwrap(); assert_eq!(div.len(), 3); } @@ -112,7 +112,7 @@ fn divide_and_outline_draw_nothing_for_unpainted_members() { for effect in ["pathfinder.divide", "pathfinder.outline"] { let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), unpainted.clone()]); g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert!(out.iter().any(|n| n.id == NodeId(1)) && out.iter().any(|n| n.id == NodeId(2)), "{effect}: {out:#?}"); for n in out.iter().filter(|n| n.id == NodeId(2)) { assert!(n.appearance.items.iter().all(|i| i.paint().is_none()), "{effect}: {n:#?}"); @@ -138,7 +138,7 @@ fn square_and(front: Option, effect: &str) -> Node { /// Returns the Pathfinder results of `g`, with type outlined as the renderer outlines it. fn results(g: &Node) -> Vec> { - pathfinder_children(g, crate::text_outliner()).unwrap() + pathfinder_children(None, g, crate::text_outliner()).unwrap() } #[test] @@ -167,7 +167,7 @@ fn type_changes_the_result_of_every_pathfinder_effect() { assert!((with_type("pathfinder.intersect") + with_type("pathfinder.minusBack") - glyph_area).abs() < 0.5, "{glyph_area}"); // Outline art nested one group deeper takes part as well. let nested: OutlineHook = &|n: &Node| crate::outline_text(n).map(|g| Node::group(n.id, vec![Arc::new(g)])); - let sub = pathfinder_children(&square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); + let sub = pathfinder_children(None, &square_and(Some(type_h()), "pathfinder.subtract"), Some(nested)).unwrap(); assert!((sub.iter().map(|n| area(n)).sum::() - with_type("pathfinder.subtract")).abs() < 1e-6); } @@ -243,7 +243,7 @@ fn type_in_a_group_keeps_its_look_when_the_groups_geometry_effects_outline_it() fn hidden_effect_or_plain_group_is_untouched() { let mut g = group_with("pathfinder.add"); g.appearance.effects[0].visible = false; - assert!(pathfinder_children(&g, None).is_none()); + assert!(pathfinder_children(None, &g, None).is_none()); assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK))); } @@ -328,7 +328,7 @@ fn pathfinder_past_its_budget_draws_the_source_art() { // n² crossings past the cap: no evaluation (#3591), the source art as it is. for n in [130, 800] { let g = crossing_bars(n, "pathfinder.divide"); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); let ids: Vec = out.iter().map(|c| c.id).collect(); let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); @@ -343,7 +343,7 @@ fn pathfinder_within_its_budget_still_divides() { // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. for n in [8, 128] { - let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); + let out = pathfinder_children(None, &crossing_bars(n, "pathfinder.divide"), None).unwrap(); assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); } } @@ -361,7 +361,7 @@ fn pathfinder_on_many_stacked_bars_still_runs() { .collect(); let mut g = Node::group(NodeId(10), children); g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 1); } @@ -382,9 +382,9 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] { let g = compound_shape(mode); assert!(has_pathfinder(&g), "{mode:?}"); - let area_path = compound_shape_area(&g, None).unwrap(); + let area_path = compound_shape_area(None, &g, None).unwrap(); assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}"); - let out = pathfinder_children(&g, None).unwrap(); + let out = pathfinder_children(None, &g, None).unwrap(); assert_eq!(out.len(), 1, "{mode:?}"); assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it"); let art = evaluate_container(None, &g).unwrap(); @@ -401,7 +401,7 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { back.extras_mut().shape_mode = ShapeMode::Subtract; ch[0] = Arc::new(back); } - assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01); // Hidden members take no part; the mark on a clip group is ignored. let mut g = compound_shape(ShapeMode::Subtract); if let Some(ch) = g.children_mut() { @@ -409,10 +409,10 @@ fn compound_shapes_draw_the_area_their_shape_modes_make() { front.visible = false; ch[1] = Arc::new(front); } - assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01); let mut clip = compound_shape(ShapeMode::Add); clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; - assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none()); + assert!(!clip.is_compound_shape() && compound_shape_area(None, &clip, None).is_none()); } /// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area, @@ -425,10 +425,10 @@ fn nested_compound_shapes_take_part_as_their_area() { let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]); outer.extras_mut().compound_shape = true; // (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high. - assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01); + assert!((po::area(&compound_shape_area(None, &outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01); let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]); effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); - let out = pathfinder_children(&effect, None).unwrap(); + let out = pathfinder_children(None, &effect, None).unwrap(); assert_eq!(out.len(), 1); assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b251b15a6..1d85d9814 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -101,5 +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_plugin_effect, is_geometry, is_raster, merged_params, new_effect, plugin_revision, scale_effect, upgrade_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, }; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index c4b6be4b7..03b335f01 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -43,7 +43,8 @@ fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { vectorcraft_text::register_envelope_ink(); let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); - f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) + let appearance = |n: &Node| bake_appearance(doc, n); + f(Hooks { outline: Some(&outline), appearance: Some(&appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index fbf22ff6b..75272c3e1 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -147,6 +147,14 @@ pub fn has_plugin_effect(n: &Node) -> bool { n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin)) } +/// Does `n` have a visible live-effect plug-in effect, its own or a fill's or stroke's, whose +/// plug-in paints the pieces it returns? What such an effect draws also depends on the document's +/// swatches and patterns, which its pieces may name. +pub fn has_painted_plugin_effect(n: &Node) -> bool { + let painted = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some_and(vectorcraft_plugins::effect::paints_output); + n.appearance.effects.iter().any(painted) || n.appearance.items.iter().any(|i| i.effects().iter().any(painted)) +} + /// 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() diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index a84d37bd0..af3d5d655 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -336,7 +336,7 @@ fn outline_art_with( _ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes), _ => return None, }; - outline_nested_text(&mut art, false); + outline_nested_text(doc, &mut art, false); art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -443,21 +443,22 @@ fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes /// its plain outlines ([`outline_text`]) with its visibility, which is what -/// [`crate::clip_outline`] clips by. -pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { +/// [`crate::clip_outline`] clips by. The swatches and patterns that live-effect plug-ins painting +/// their pieces name are looked up in `doc`. +pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { let clipping = clipping || (clips && i == 0); let art = match &c.kind { NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - NodeKind::Text(_) => as_art(None, c, None), + NodeKind::Text(_) => as_art(doc, c, None), _ => None, }; if let Some(o) = art { *c = Arc::new(o); } else if c.children().is_some() { - outline_nested_text(Arc::make_mut(c), clipping); + outline_nested_text(doc, Arc::make_mut(c), clipping); } } } @@ -606,20 +607,16 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry /// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The /// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path -/// whose effect would pass what the others leave of it keeps its shape. -pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { - reshape_leaves_within(art, fx, Budget::EFFECTS); -} - -/// [`reshape_leaves`], with the swatches and patterns that painted live-effect plug-ins name looked -/// up in `doc` ([`reshape_leaves_apart`]). -pub(crate) fn reshape_leaves_in(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { - reshape_leaves_apart(art, fx, Budget::EFFECTS, false, doc); +/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns +/// that live-effect plug-ins painting their pieces name are looked up in `doc` +/// ([`reshape_leaves_apart`]). +pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) { + reshape_leaves_within(art, fx, Budget::EFFECTS, doc); } /// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. -pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { - reshape_leaves_apart(art, fx, budget, false, None); +pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) { + reshape_leaves_apart(art, fx, budget, false, doc); } /// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding @@ -1247,7 +1244,7 @@ pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { /// ([`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(&mut art, false); + outline_nested_text(doc, &mut art, false); art } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b2528818b..d2ad1e66c 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -434,7 +434,7 @@ mod tests { let expanded = expand_revolve_art(&n).unwrap(); let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len(); assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed"); - assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface"); + assert_eq!(crate::bake_appearance(None, &n), Some(live.clone()), "other baking retains the full surface"); n.appearance.effects[0].params["expandVisibleOnly"] = json!(false); assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting"); assert_eq!(expand_revolve_art(&n), Some(live)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index a9997bb5d..e5f42b4e8 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -325,7 +325,7 @@ fn export_and_expand_bake_faces_with_their_own_ids() { let mut n = square_node(); n.id = d.alloc_id(); n.appearance.effects.push(fx(id, json!({"bevel": "rounded"}))); - assert_eq!(crate::bake_appearance(&n), solid_art(&n), "{id}"); + assert_eq!(crate::bake_appearance(None, &n), solid_art(&n), "{id}"); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); let mut ids = std::collections::HashSet::new(); @@ -351,7 +351,7 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() { let params = json!({"twist": 120, "taper": 50, "cap": false, "bothSides": true, "rotationX": -20, "rotationY": 35}); n.appearance.effects.push(fx(INFLATE, params)); let art = solid_art(&n).unwrap(); - assert_eq!(crate::bake_appearance(&n), Some(art.clone())); + assert_eq!(crate::bake_appearance(None, &n), Some(art.clone())); assert!(faces(&art).len() > 20, "{} faces", faces(&art).len()); let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None; let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap(); diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ea3843f0e..f9ca9aa1f 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -394,7 +394,7 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() { let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default())); let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect()); let rough = fx("distort.roughen", json!({"size": 50, "detail": 100})); - crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }); + crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }, None); let mut total = 0; let mut reshaped = 0; for c in art.children().into_iter().flatten() { @@ -473,7 +473,7 @@ fn reshape_leaves_within_optimizes_transform_before_terminal_offset_path() { let offset = fx("path.offsetPath", json!({"offset": 4.0, "joins": "round"})); let start = std::time::Instant::now(); - crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art, &[&transform, &offset], Budget::EFFECTS, None); let elapsed = start.elapsed(); assert!(elapsed < std::time::Duration::from_secs(2)); @@ -500,7 +500,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let mut art_evenodd = Node::group(id, vec![Arc::new(leaf)]); let transform = fx("distort.transform", json!({"moveH": 2.0, "moveV": 2.0, "copies": 2})); let offset = fx("path.offsetPath", json!({"offset": 4.0})); - crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_evenodd, &[&transform, &offset], Budget::EFFECTS, None); let p = art_evenodd.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "EvenOdd does not swap; copies union into 1 outer subpath"); @@ -508,7 +508,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let leaf_round = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default())); let mut art_round = Node::group(id, vec![leaf_round]); let round_corners = fx("stylize.roundCorners", json!({"radius": 2.0})); - crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_round, &[&transform, &offset, &round_corners], Budget::EFFECTS, None); let p = art_round.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "Non-terminal Offset Path does not swap; copies union into 1 outer subpath"); @@ -516,7 +516,7 @@ fn reshape_leaves_within_preserves_order_when_not_terminal_or_not_style_safe() { let leaf_miter = Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default())); let mut art_miter = Node::group(id, vec![leaf_miter]); let offset_miter = fx("path.offsetPath", json!({"offset": 4.0, "joins": "miter"})); - crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS); + crate::reshape::reshape_leaves_within(&mut art_miter, &[&transform, &offset_miter], Budget::EFFECTS, None); let p = art_miter.children().unwrap()[0].path_data().unwrap(); assert_eq!(p.subpaths.len(), 1, "Miter join does not swap; copies union into 1 outer subpath"); } diff --git a/crates/engine/src/cmd/compoundshape.rs b/crates/engine/src/cmd/compoundshape.rs index 52c19a9a5..bc93b399a 100644 --- a/crates/engine/src/cmd/compoundshape.rs +++ b/crates/engine/src/cmd/compoundshape.rs @@ -189,7 +189,7 @@ fn make(s: &mut Session, p: &Value) -> Result { /// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or /// a warped group, also takes that object's opacity and blend mode. fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option