diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bdf334ae1..09ed5b0e6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -79,8 +79,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; pub use reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options, - image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, + has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 3f743993a..82c8a91f8 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -869,8 +869,9 @@ fn piece_cost(path: &PathData) -> Budget { /// 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 +/// gives fills (on a fill) or strokes (on a stroke) is painted by copies of `item`, the leaf's first +/// fill or stroke, with those paints ([`vectorcraft_plugins::painted::items`]), under the leaf's +/// other fills and strokes; 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`. @@ -896,7 +897,13 @@ fn paint_piece( 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() }; + let items = if gives { + let mut items = vectorcraft_plugins::painted::items(item, p); + items.extend_from_slice(leaf.appearance.items.get(1..).unwrap_or_default()); + items + } else { + leaf.appearance.items.clone() + }; if items.is_empty() { continue; } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 972fd8d1a..e6e75c1f3 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -283,6 +283,7 @@ fn painted_runs<'a>(d: &Document, n: &'a Node) -> Option<(&'a vectorcraft_doc::T /// it) or of type whose layout draws nothing (`painted_runs`). An image's fills and strokes paint /// only where an effect gives them a shape. fn own_patterns<'a>( + d: &Document, n: &'a Node, members: &HashSet<*const Node>, painted_runs: Option<&(&vectorcraft_doc::TextObject, BTreeSet)>, @@ -296,7 +297,7 @@ fn own_patterns<'a>( // The patterns an image's shaped fills and strokes paint (their effects can transform the // pattern, so they are matched by name). let shaped: Option> = matches!(n.kind, NodeKind::Image(_)).then(|| { - vectorcraft_render::effects::image_paint(n) + vectorcraft_render::effects::image_paint(Some(d), n) .into_iter() .filter_map(|(_, _, item)| match item.paint() { Paint::Pattern { pattern, .. } => Some(pattern.clone()), @@ -415,7 +416,7 @@ fn info(s: &mut Session, p: &Value) -> Result { // The patterns this object's fills and strokes use, each once. let mut used_patterns: Vec<&str> = vec![]; let painted_runs = painted_runs(d, n); - for pattern in own_patterns(n, &members, painted_runs.as_ref()) { + for pattern in own_patterns(d, n, &members, painted_runs.as_ref()) { patterns.insert(pattern.to_string()); used_patterns.push(pattern); } diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 1f28107f0..6791862c1 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -393,8 +393,17 @@ fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> { /// Path `l` with each gradient fill turned into art, in appearance order: the other items stay /// on plain copies of the path. The path's geometry effects ([`reshaped`]) and each fill's own /// ([`fill_shape`]) shape the outline the art fills first, as on the canvas. `None` when no -/// gradient fill can be expanded. +/// gradient fill can be expanded. A path that live-effect plug-ins paint is expanded piece by piece +/// ([`expand_pieces`]). fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { + if vectorcraft_render::effects::has_painted_effects(l) { + return expand_pieces(d, l, usize::MAX, |d, piece, _| expand_piece_fills(d, piece, to)); + } + expand_piece_fills(d, l, to) +} + +/// [`expand_fills`] of a path with no live-effect plug-in that paints what it returns. +fn expand_piece_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option { if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) { return None; } @@ -466,10 +475,50 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F Some(art) } +/// Path `l`, which live-effect plug-ins paint, as the art the canvas draws +/// ([`reshape`](vectorcraft_render::effects::reshape)) with ids of its own, each piece of the art +/// replaced by what `expand` makes of it (`expand` gets the piece and how many objects it may become; +/// `None` keeps the piece with its own paints). `None` when the art would hold more than `budget` +/// objects or no piece expands. +fn expand_pieces(d: &mut Document, l: &Node, budget: usize, mut expand: impl FnMut(&mut Document, &Node, usize) -> Option) -> Option { + let art = vectorcraft_render::effects::reshape(Some(d), l, None)?; + let mut art = d.reid(&art); + art.locked = l.locked; + let mut left = budget.checked_sub(art.count_with_masks())?; + let mut expanded = false; + expand_piece_leaves(d, &mut art, &mut left, &mut expanded, &mut expand); + expanded.then_some(art) +} + +/// Replace each path and compound path under `n` (`n` included) by what `expand` makes of it, in +/// paint order. `left` is how many more objects the art may hold, and `expanded` is set when a path +/// is replaced. +fn expand_piece_leaves( + d: &mut Document, + n: &mut Node, + left: &mut usize, + expanded: &mut bool, + expand: &mut impl FnMut(&mut Document, &Node, usize) -> Option, +) { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) { + let room = *left + n.count_with_masks(); + if let Some(new) = expand(d, n, room) { + *left = room.saturating_sub(new.count_with_masks()); + *n = new; + *expanded = true; + } + return; + } + for c in n.children_mut().into_iter().flatten() { + expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand); + } +} + /// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its /// path (a plain path then), and every geometry effect of its own removed. `None` when it has no /// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their -/// pieces name are looked up in `doc`. +/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a +/// path that one paints goes through [`expand_pieces`] instead. fn reshaped(doc: &Document, l: &Node) -> Option { use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry}; if !has_geometry(&l.appearance.effects) { @@ -771,8 +820,17 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option { } /// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other -/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects. +/// items, as does a pattern fill whose tiles would take the tiles of `l` past `budget` objects. A +/// path that live-effect plug-ins paint is expanded piece by piece ([`expand_pieces`]). fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option { + if vectorcraft_render::effects::has_painted_effects(l) { + return expand_pieces(d, l, budget, |d, piece, room| expand_piece_patterns(d, piece, &which, room)); + } + expand_piece_patterns(d, l, which, budget) +} + +/// [`expand_patterns_where`] of a path with no live-effect plug-in that paints what it returns. +fn expand_piece_patterns(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool, budget: usize) -> Option { if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i)) { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d232f2ca..79e8eba32 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -482,20 +482,96 @@ fn a_retained_renderer_shows_a_piece_once_its_swatch_exists() { assert_eq!(at_piece(&mut r, &s), GREEN, "the piece is drawn with its swatch"); } -/// Object › Expand of a gradient fill takes the geometry of a piece linked to the document's swatch. +/// Object › Expand of a gradient fill works piece by piece, for a painted effect on the object and +/// on the fill: a piece that keeps the gradient has it expanded, and a piece with a paint of its own, +/// here linked to the document's swatch, keeps that paint on its own path. #[test] -fn expanding_a_gradient_fill_takes_a_swatch_linked_piece() { +fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() { + for on_fill in [false, true] { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}})); + run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"})); + let mut leaf = solid("#00ff00"); + leaf["swatch"] = json!("Leaf"); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [leaf]}, {"path": square_at(100.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.expandref", true), &out)); + let item = if on_fill { json!(node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap()) } else { Value::Null }; + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandref", "item": item})); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false})); + let img = drawn(&s); + assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, GREEN), "on_fill {on_fill}: the input is gone and the piece keeps its paint"); + assert!(![WHITE, GREEN].contains(&img.pixel(110, 50)), "on_fill {on_fill}: the other piece shows the gradient"); + let mut own = Paint::solid(Color::from_hex("#00ff00").unwrap()); + if let Paint::Solid { swatch, .. } = &mut own { + *swatch = Some("Leaf".into()); + } + let mut kept = vec![]; + let mut plugins = 0; + s.doc().unwrap().doc.walk(|n| { + plugins += n + .appearance + .effects + .iter() + .chain(n.appearance.items.iter().flat_map(|i| i.effects())) + .filter(|e| e.id.starts_with("plugin.")) + .count(); + if n.path_data().is_some_and(|p| p.bounds() == square_at(40.0).bounds()) { + kept.push(n.appearance.fill().map(|f| f.paint.clone())); + } + }); + assert_eq!(kept, [Some(own)], "on_fill {on_fill}"); + assert_eq!(plugins, 0, "on_fill {on_fill}: no live effect is left"); + } +} + +/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its +/// tiles expanded, and a piece with a paint of its own keeps that paint. +#[test] +fn expanding_a_pattern_fill_keeps_each_piece_s_own_paint() { let mut s = session(); let id = rect(&mut s, 10.0, "#ff0000"); - run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}})); - run(&mut s, "swatch.new", json!({"name": "Leaf", "color": "#00ff00"})); - install_wat(&mut s, &tpl::returning(&painting("test.engine.expandref", true), &leaf_square())); - run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandref"})); + let tile = rect(&mut s, 300.0, "#00ff00"); + run(&mut s, "object.pattern.make", json!({"ids": [tile.0], "name": "Tiles", "edit": false})); + run(&mut s, "paint.setFill", json!({"ids": [id.0], "swatch": "Tiles"})); + let out = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}, {"path": square_at(100.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.expandtiles", true), &out)); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandtiles"})); run(&mut s, "select.set", json!({"ids": [id.0]})); run(&mut s, "object.expand", json!({"object": false, "fill": true, "stroke": false})); let img = drawn(&s); - assert_eq!(img.pixel(15, 15), WHITE, "the input path is gone"); - assert_ne!(img.pixel(50, 50), WHITE, "the piece's geometry is filled"); + assert_eq!((img.pixel(15, 15), img.pixel(50, 50)), (WHITE, BLUE), "the input is gone and the piece keeps its paint"); + assert!(near(img.pixel(110, 50), GREEN), "the other piece shows the tiles"); + let mut patterns = 0; + s.doc().unwrap().doc.walk(|n| patterns += n.appearance.items.iter().filter(|i| matches!(i.paint(), Paint::Pattern { .. })).count()); + assert_eq!(patterns, 0, "the tiles are objects"); +} + +/// Two painted effects on one fill, the first returning a piece with two fills: the second replaces +/// the first of them and the other fill stays over it. +#[test] +fn a_second_painted_effect_on_a_fill_keeps_the_piece_s_other_fills() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let two = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#ff0000"), solid("#00ff00")]}]}).to_string(); + let one = json!({"objects": [{"path": square_at(40.0), "fills": [solid("#0000ff")]}]}).to_string(); + install_wat(&mut s, &tpl::returning(&painting("test.engine.twofills", true), &two)); + install_wat(&mut s, &tpl::returning(&painting("test.engine.onefill", true), &one)); + 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.twofills", "item": fill})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onefill", "item": fill})); + assert_eq!(drawn(&s).pixel(50, 50), GREEN, "the other fill paints over the replaced one"); + let st = s.doc().unwrap(); + let art = vectorcraft_render::effects::reshape(Some(&st.doc), st.doc.node(id).unwrap(), None).unwrap(); + let mut paints = vec![]; + art.walk(&mut |n| { + if n.path_data().is_some() { + paints.push(n.appearance.items.iter().filter(|i| i.is_fill()).map(|i| i.paint().clone()).collect::>()); + } + }); + let fill = |hex: &str| Paint::solid(Color::from_hex(hex).unwrap()); + assert!(paints.contains(&vec![fill("#0000ff"), fill("#00ff00")]), "{paints:?}"); } /// The geometry of a painted result that names a swatch counts only when the document has that diff --git a/docs/plugins.md b/docs/plugins.md index d37d3c37b..69a1f42aa 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -437,7 +437,8 @@ is a piece drawn by itself, in the order returned, with paints of its own: `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. + ordinary path or compound path with its fills and strokes, as one undo step. Object › Expand of gradient and pattern + fills expands the fills of each piece, and leaves a piece with other paints as it is drawn. 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