diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 55a904d02c..2b4953d151 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -104,6 +104,15 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { } } +thread_local! { + /// How many symbol instances deep [`bake_node`] is baking, through the art of instances whose + /// effects reshape it. + static INSTANCE_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of + /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. + static NESTED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. @@ -143,8 +152,27 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } _ => None, }; + // Instances baked inside the art of others stop at the depth limit, or once their art + // passes the objects one export bakes that way: an instance of a symbol whose art holds + // instances of itself is kept as it is there. + let instance = matches!(n.kind, NodeKind::SymbolInstance { .. }); + if instance && INSTANCE_DEPTH.with(|c| c.get()) > 0 { + let count = symbol.as_ref().map_or(0, |a| a.count() as u64); + let spent = NESTED_NODES.with(|c| c.get()).saturating_add(count); + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || spent > crate::SYMBOL_ART_MAX_NODES { + return None; + } + NESTED_NODES.with(|c| c.set(spent)); + } let m = reshape(Some(d), n, symbol.as_ref())?; - return Some(bake_node(d, &m).unwrap_or(m)); + if instance { + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + } + let baked = bake_node(d, &m); + if instance { + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + } + return Some(baked.unwrap_or(m)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning @@ -531,7 +559,9 @@ fn hoist_transparency(mut p: Node) -> Node { } /// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a -/// group's own fills and strokes or outlined type), in place. +/// group's own fills and strokes or outlined type), in place. A group or layer in it with an +/// appearance of its own ([`crate::has_container_appearance`]) is first made into the art that +/// appearance paints ([`crate::evaluate_container`]). pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { if let Some(m) = expand_leaf(d, n, stroke_art) { @@ -539,6 +569,11 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { } return; } + if crate::has_container_appearance(n) + && let Some(m) = crate::evaluate_container(Some(&*d), n) + { + *n = m; + } for c in n.children_mut().into_iter().flatten() { expand_art(d, Arc::make_mut(c), stroke_art); } @@ -661,6 +696,7 @@ pub fn bake_document(doc: &Document) -> Option { }; } let mut d = placed.into_owned(); + NESTED_NODES.with(|c| c.set(0)); let layers = d.layers.clone(); d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); Some(d) diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 16c271dca0..c7da7beafa 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -130,19 +130,142 @@ pub fn has_painted_effects(n: &Node) -> bool { } /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a -/// visible geometry effect, or an image with a fill or stroke of its own that paints a shape -/// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke -/// paints ([`outline_text`], which leaves inline graphics out). +/// visible geometry effect, an image with a fill or stroke of its own that paints a shape +/// ([`image_rows`]), or a symbol instance whose active fill or stroke has a visible geometry effect +/// ([`active_fill`], [`active_stroke`])? On type, such an effect reshapes the whole outline that its +/// fill or stroke paints ([`outline_text`], which leaves inline graphics out). On a symbol +/// instance, it reshapes the fills or strokes of the art it places ([`add_instance_row_effects`]). pub fn has_item_geometry(n: &Node) -> bool { match &n.kind { NodeKind::Text(t) => { t.runs.iter().all(|r| r.inline.is_none()) && n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) } NodeKind::Image(_) => n.appearance.items.iter().any(shapes_image), + NodeKind::SymbolInstance { .. } => { + active_fill(n).is_some_and(|f| crate::has_geometry(&f.effects)) || active_stroke(n).is_some_and(|s| crate::has_geometry(&s.effects)) + } _ => false, } } +/// The fill of symbol instance `n` whose effects its art takes: its topmost fill, the one that +/// stains the art, when it is visible and its opacity is above 0. A lower fill's effects do not +/// reach the art. +fn active_fill(n: &Node) -> Option<&vectorcraft_doc::FillLayer> { + n.appearance.fill().filter(|f| f.visible && f.opacity > 0.0) +} + +/// The stroke of symbol instance `n` whose effects its art takes: its topmost stroke, when it is +/// visible and its opacity is above 0. +fn active_stroke(n: &Node) -> Option<&vectorcraft_doc::StrokeLayer> { + n.appearance.stroke().filter(|s| s.visible && s.opacity > 0.0) +} + +/// The visible geometry effects in `fx`, in order. +fn geometry_of(fx: &[Effect]) -> Vec { + fx.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect() +} + +/// `art`, the art symbol instance `n` places, with the visible geometry effects of `n`'s active +/// fill added after the effects of every fill in it that paints, and those of its active stroke +/// after the effects of every stroke in it that paints ([`active_fill`], [`active_stroke`]). Each +/// fill or stroke keeps its paint, and the others keep their shapes. Live objects in it that are +/// reached are evaluated first (with the symbols of `doc`), so the effects reshape what they draw. +/// Clip groups' clipping paths, guides and the art of opacity masks keep their shapes. A symbol +/// instance in the art takes the effects on its own active fill or stroke, or on a new fill or +/// stroke without paint when it has none, and passes them on to its own art in turn; an instance +/// of a symbol whose art (in `doc`) holds an instance of `n`'s symbol, at any depth, keeps its +/// appearance, so the effects never pass round a symbol that holds itself. Without `doc`, no +/// instance in the art takes them. +fn add_instance_row_effects(doc: Option<&Document>, n: &Node, art: &mut Node) { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return }; + let fill = active_fill(n).map_or(vec![], |f| geometry_of(&f.effects)); + let stroke = active_stroke(n).map_or(vec![], |s| geometry_of(&s.effects)); + if !fill.is_empty() || !stroke.is_empty() { + add_row_effects(doc, symbol, art, &fill, &stroke); + } +} + +/// Does the art of the symbol named `from` in `doc` hold an instance of the symbol named `to`, at +/// any depth of the symbols placed in it? `seen` holds the symbols already looked into. +fn places_symbol(doc: &Document, from: &str, to: &str, seen: &mut HashSet) -> bool { + if !seen.insert(from.to_string()) { + return false; + } + let Some(sym) = doc.symbols.iter().find(|s| s.name == from) else { return false }; + let mut names = vec![]; + sym.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + names.iter().any(|name| name == to || places_symbol(doc, name, to, seen)) +} + +/// [`add_instance_row_effects`] of `fill` and `stroke` to `n` and the art in it, the art of an +/// instance of the symbol named `outer`. +fn add_row_effects(doc: Option<&Document>, outer: &str, n: &mut Node, fill: &[Effect], stroke: &[Effect]) { + match &n.kind { + NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => return, + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(d) = doc + && !places_symbol(d, symbol, outer, &mut HashSet::new()) + { + add_to_instance(n, fill, stroke); + } + return; + } + _ if vectorcraft_doc::live::is_live(n) => *n = evaluated(doc, n), + _ => {} + } + let takes = |i: &AppearanceItem| { + crate::paints(i) && !matches!(i, AppearanceItem::Fill(_) if fill.is_empty()) && !matches!(i, AppearanceItem::Stroke(_) if stroke.is_empty()) + }; + if n.appearance.items.iter().any(takes) { + for item in n.appearance.items.iter_mut().filter(|i| takes(i)) { + let fx = if matches!(item, AppearanceItem::Fill(_)) { fill } else { stroke }; + item.effects_mut().extend_from_slice(fx); + } + } + // A compound path paints its members with its own fills and strokes. + if matches!(n.kind, NodeKind::Compound { .. }) { + return; + } + // A clip group's first member is its clipping path, whatever its kind. + let clips = n.clips(); + for (i, c) in n.children_mut().into_iter().flatten().enumerate() { + if !(clips && i == 0) { + add_row_effects(doc, outer, Arc::make_mut(c), fill, stroke); + } + } +} + +/// Add `fill` to the effects of symbol instance `n`'s active fill and `stroke` to those of its +/// active stroke ([`active_fill`], [`active_stroke`]); without one, to a new topmost fill or stroke +/// without paint, which neither stains nor strokes its art. +fn add_to_instance(n: &mut Node, fill: &[Effect], stroke: &[Effect]) { + if !fill.is_empty() { + match n.appearance.fill_mut().filter(|f| f.visible && f.opacity > 0.0) { + Some(f) => f.effects.extend_from_slice(fill), + None => { + let mut f = vectorcraft_doc::FillLayer::new(vectorcraft_doc::color::Paint::None); + f.effects = fill.to_vec(); + n.appearance.items.push(AppearanceItem::Fill(f)); + } + } + } + if !stroke.is_empty() { + match n.appearance.stroke_mut().filter(|s| s.visible && s.opacity > 0.0) { + Some(s) => s.effects.extend_from_slice(stroke), + None => { + let mut s = vectorcraft_doc::StrokeLayer::new(vectorcraft_doc::color::Paint::None, 0.0); + s.effects = stroke.to_vec(); + n.appearance.items.push(AppearanceItem::Stroke(s)); + } + } + } +} + /// Does effect `e` give an image's fill or stroke a shape to paint (a visible Convert to Shape or /// Outline Object)? fn gives_shape(e: &Effect) -> bool { @@ -302,7 +425,9 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them). +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), its +/// fills and strokes carrying the geometry effects of the instance's active fill and stroke +/// ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -351,6 +476,9 @@ fn outline_art_with( _ => return None, }; outline_nested_text(paint_doc, &mut art, false); + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + add_instance_row_effects(symbols, n, &mut art); + } art.opacity = 1.0; art.blend = Default::default(); art.isolate = false; @@ -497,8 +625,10 @@ pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> O /// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand /// Appearance: [`reshape`]d when it has -/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills -/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these. +/// geometry effects, else type with fills or strokes of its own outlined, an image with fills or +/// strokes of its own that paint a shape, or a symbol instance's art reshaped by the geometry +/// effects of its active fill or stroke ([`has_item_geometry`]), in the same form. `None` when it +/// has none of these. pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { // A path whose Transform effect copies it, or scales its strokes or moves its patterns: each // copy an object of its own with the path's fills and strokes, the copies of each Transform diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8040b0016c..72a72e6411 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -94,7 +94,7 @@ fn effect_specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too; a symbol instance whose topmost fill or stroke is visible, has an opacity above 0 and has a geometry effect becomes a group of its art, that fill's effects reshaping the art's filled shapes and that stroke's effects reshaping its stroked shapes, as the canvas draws them. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -848,8 +848,9 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> { /// Has `n` an appearance Expand Appearance turns into objects: a path that isn't basic (several /// fills or strokes, effects, transparency of a fill or stroke, a brush), a group or layer with -/// fills, strokes or effects of its own or such members, other objects with effects, or type with -/// fills or strokes of its own? +/// fills, strokes or effects of its own or such members, other objects with effects, type with +/// fills or strokes of its own, or a symbol instance whose active fill or stroke has a geometry +/// effect ([`effects::has_item_geometry`])? pub(crate) fn expandable(n: &Node) -> bool { expandable_except(n, &[]) } @@ -866,7 +867,7 @@ pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool { !n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip)) } NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(), - NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(), _ => !n.appearance.effects.is_empty(), } } @@ -939,6 +940,38 @@ fn expand_pieces( expanded } +/// Replace each symbol instance in `m`, art an instance placed (`depth` instances deep), whose +/// active fill or stroke has a geometry effect ([`effects::has_item_geometry`]) by its art as +/// [`effects::expand_outlined`] makes it, and the instances in that art in turn, so +/// [`expand_pieces`] bakes their effects with the rest. An instance stays as it is when it is more +/// than [`vectorcraft_doc::depth::MAX_DEPTH`] instances deep, its symbol is missing, or its art +/// would take the objects placed so far past `budget`. +fn expand_nested_instances(d: &Document, m: &mut Node, depth: usize, budget: &mut u64) { + for c in m.children_mut().into_iter().flatten() { + if !matches!(c.kind, NodeKind::SymbolInstance { .. }) { + if c.children().is_some() { + expand_nested_instances(d, Arc::make_mut(c), depth, budget); + } + continue; + } + let NodeKind::SymbolInstance { symbol, .. } = &c.kind else { continue }; + if depth >= vectorcraft_doc::depth::MAX_DEPTH || !effects::has_item_geometry(c) { + continue; + } + let Some(sym) = d.symbols.iter().find(|s| s.name == *symbol) else { continue }; + let count = sym.art.count() as u64; + if count > *budget { + continue; + } + *budget -= count; + let art = vectorcraft_render::instance_art(&sym.art, c); + if let Some(mut a) = effects::expand_outlined(Some(d), c, Some(&art)) { + expand_nested_instances(d, &mut a, depth + 1, budget); + *c = Arc::new(a); + } + } +} + /// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids? fn repeats_an_id(n: &Node, seen: &std::collections::HashSet) -> bool { let mut ids = std::collections::HashSet::new(); @@ -1094,6 +1127,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br _ => None, }; effects::expand_outlined(Some(&*d), &v, symbol.as_ref()).map(|mut m| { + let mut budget = vectorcraft_doc::MAX_SYMBOL_NODES; + expand_nested_instances(d, &mut m, 1, &mut budget); expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 038b8844bb..3fd187016f 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -126,6 +126,302 @@ fn a_symbol_instance_fill_with_no_opacity_keeps_swatch_links_when_expanded() { assert!(linked > 0, "the expanded art keeps the Brand swatch link"); } +/// The square of [`square`] made a symbol, its instance selected, with a new fill (`fill`) or +/// stroke on top that has no paint: it neither stains nor strokes the art. Returns the instance and +/// the new row's index. +fn instance_with_row(s: &mut Session, name: &str, fill: bool) -> (NodeId, usize) { + square(s); + let id = NodeId(run(s, "symbol.new", json!({"name": name}))["id"].as_u64().unwrap()); + run(s, if fill { "appearance.addFill" } else { "appearance.addStroke" }, json!({"ids": [id.0]})); + let row = node(s, id).appearance.items.len() - 1; + run(s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + (id, row) +} + +fn offset_row(s: &mut Session, id: NodeId, row: usize, offset: f64) { + run(s, "effect.apply", json!({"ids": [id.0], "item": row, "effect": "path.offsetPath", "params": {"offset": offset}})); +} + +fn near(p: [u8; 4], q: [u8; 4]) -> bool { + p.iter().zip(q.iter()).all(|(x, y)| x.abs_diff(*y) <= 24) +} + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// No object in `n` keeps an effect of its own or on a fill or stroke. +fn no_effects_left(n: &Node) { + n.walk(&mut |part| { + assert!(part.appearance.effects.is_empty(), "{:?} keeps object effects", part.id); + assert!(part.appearance.items.iter().all(|i| i.effects().is_empty()), "{:?} keeps fill or stroke effects", part.id); + }); +} + +/// An effect on a symbol instance's fill reshapes the filled shapes of its art, keeping their +/// colors, and leaves their strokes where they were; Expand Appearance bakes the same look into +/// editable art in one undo step. +#[test] +fn a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + assert!(!expandable(&s)); + let plain = render(&s); + assert!(near(plain.pixel(44, 100), WHITE)); + offset_row(&mut s, id, row, 10.0); + assert!(expandable(&s), "the fill's effect can be expanded"); + let before = render(&s); + assert!(near(before.pixel(44, 100), RED), "the art's red fill grows by the offset: {:?}", before.pixel(44, 100)); + assert!(near(before.pixel(50, 100), BLUE), "the blue stroke stays on the square: {:?}", before.pixel(50, 100)); + + run(&mut s, "effect.expandAppearance", json!({})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + no_effects_left(&n); + let mut paints = (0, 0); + n.walk(&mut |part| { + if part.appearance.fill().is_some_and(|f| f.paint.color().is_some_and(|c| c.to_hex() == "#ff0000")) { + paints.0 += 1; + } + if part.appearance.stroke().is_some_and(|st| st.paint.color().is_some_and(|c| c.to_hex() == "#0000ff")) { + paints.1 += 1; + } + }); + assert!(paints.0 > 0 && paints.1 > 0, "the art keeps its red fill and blue stroke: {paints:?}"); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); + + run(&mut s, "edit.undo", json!({})); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::SymbolInstance { .. }), "one undo gives the instance back"); + assert!(n.appearance.items[row].effects().iter().any(|e| e.id == "path.offsetPath")); +} + +/// Export bakes a symbol instance's fill effect into its art as the canvas draws it. +#[test] +fn export_bakes_a_symbol_instance_fill_effect() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 10.0); + let doc = s.doc().unwrap().doc.clone(); + let baked = vectorcraft_render::effects::bake_document(&doc).expect("the instance is baked"); + let n = baked.node(id).expect("the baked art keeps the instance's id").clone(); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + no_effects_left(&n); + let b = n.geometric_bounds().expect("the baked art has bounds"); + assert!((b.x0 - 40.0).abs() < 1.0 && (b.x1 - 160.0).abs() < 1.0, "the fill grows by the offset: {b:?}"); +} + +/// An effect on a symbol instance's stroke reshapes the stroked shapes of its art and leaves +/// their fills where they were. +#[test] +fn a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", false); + assert!(near(render(&s).pixel(40, 100), WHITE)); + offset_row(&mut s, id, row, 10.0); + assert!(expandable(&s)); + let before = render(&s); + assert!(near(before.pixel(40, 100), BLUE), "the stroke moves out with the offset: {:?}", before.pixel(40, 100)); + assert!(near(before.pixel(45, 100), WHITE), "the fill keeps its shape: {:?}", before.pixel(45, 100)); + assert!(near(before.pixel(100, 100), RED)); + + run(&mut s, "effect.expandAppearance", json!({"ids": [id.0]})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + no_effects_left(&n); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + +/// A dynamic symbol's instance that paints a stroke of its own on art without one: the instance's +/// stroke effect reshapes that stroke, and its bounds take in where the stroke is drawn. +#[test] +fn a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds() { + let mut s = session(); + square(&mut s); + run(&mut s, "paint.setStroke", json!({"none": true})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Art", "type": "dynamic"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + let blue = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + s.edit("paint", |d, _| { + let o = vectorcraft_doc::symbol_overrides::SymbolOverride { path: vec![], stroke: Some(blue), ..Default::default() }; + d.node_mut(id).ok_or(EngineError::NoNode(id))?.extras_mut().symbol_overrides.push(o); + Ok(()) + }) + .unwrap(); + // The 1 pt stroke's center moves to x = 30.5, so it covers the pixel column from 30 to 31. + offset_row(&mut s, id, row, 19.5); + assert!(near(render(&s).pixel(30, 100), BLUE), "the added stroke moves out with the offset: {:?}", render(&s).pixel(30, 100)); + let doc = s.doc().unwrap().doc.clone(); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x0 <= 31.0 && b.x1 >= 169.0, "the bounds take in the offset stroke: {b:?}"); + let drawn = vectorcraft_render::drawn_bounds(&doc, doc.node(id).unwrap()).expect("the instance is drawn"); + assert!(drawn.x0 <= 31.0 && drawn.x1 >= 169.0, "the drawn bounds take in the offset stroke: {drawn:?}"); +} + +/// A group in the symbol's art with a fill of its own: the instance's fill effect reshapes what +/// that fill paints, and the instance's bounds take it in. +#[test] +fn a_fill_effect_reshapes_the_fill_of_a_group_in_the_art() { + let mut s = session(); + square(&mut s); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [g.0]})); + run(&mut s, "paint.setFill", json!({"ids": [g.0], "item": 0, "color": "#ff0000"})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Group"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + offset_row(&mut s, id, row, 20.0); + assert!(near(render(&s).pixel(35, 100), RED), "the group's fill grows by the offset: {:?}", render(&s).pixel(35, 100)); + let doc = s.doc().unwrap().doc.clone(); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x0 <= 31.0 && b.x1 >= 169.0, "the bounds take in the group's grown fill: {b:?}"); + let drawn = vectorcraft_render::drawn_bounds(&doc, doc.node(id).unwrap()).expect("the instance is drawn"); + assert!(drawn.x0 <= 31.0 && drawn.x1 >= 169.0, "the drawn bounds take in the group's grown fill: {drawn:?}"); + + let before = render(&s); + run(&mut s, "effect.expandAppearance", json!({})); + no_effects_left(&node(&s, id)); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + +/// Of several fills, only the topmost one's effects reach the art, and only while it is visible. +#[test] +fn only_the_topmost_visible_instance_fill_reshapes_the_art() { + let mut s = session(); + let (id, lower) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, lower, 30.0); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let top = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "none": true})); + assert!(!expandable(&s), "a lower fill's effect does not reach the art"); + offset_row(&mut s, id, top, 10.0); + let both = render(&s); + assert!(near(both.pixel(44, 100), RED), "the top fill's offset shows"); + assert!(near(both.pixel(25, 100), WHITE), "the lower fill's offset does not: {:?}", both.pixel(25, 100)); + + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "visible": false})); + assert!(near(render(&s).pixel(44, 100), WHITE), "a hidden top fill reshapes nothing"); + assert!(!expandable(&s)); + + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": top, "visible": true})); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + assert!(differing(&both, &after, 8) < 200, "expanding changed {} pixels", differing(&both, &after, 8)); + let n = node(&s, id); + no_effects_left(&n); +} + +/// An instance inside a symbol's art passes the outer instance's fill effect on to its own art. +#[test] +fn a_nested_symbol_instance_takes_the_outer_fill_effect() { + let mut s = session(); + square(&mut s); + run(&mut s, "symbol.new", json!({"name": "Inner"})); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Outer"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let row = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": row, "none": true})); + offset_row(&mut s, id, row, 40.0); + let before = render(&s); + assert!(near(before.pixel(20, 100), RED), "the inner art grows by the offset: {:?}", before.pixel(20, 100)); + assert!(expandable(&s)); + // A view that shows only the grown part, right of the square: the instance is still drawn. + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let right = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::translate((-155.0, 0.0)), &opts); + assert!(near(right.pixel(20, 100), RED), "the grown art past the square's box is drawn: {:?}", right.pixel(20, 100)); + let b = vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).expect("the instance has bounds"); + assert!(b.x0 <= 11.0 && b.x1 >= 189.0, "the bounds take in the inner art's offset: {b:?}"); + + run(&mut s, "effect.expandAppearance", json!({})); + let n = node(&s, id); + assert!(!matches!(n.kind, NodeKind::SymbolInstance { .. })); + n.walk(&mut |part| assert!(!matches!(part.kind, NodeKind::SymbolInstance { .. }), "the inner instance is expanded too")); + no_effects_left(&n); + let after = render(&s); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + +/// A symbol whose art holds an instance of itself draws, exports and expands without running away. +/// The engine keeps no such document after an edit, so Expand Appearance refuses it and the +/// instance stays as it was. +#[test] +fn a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 4.0); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let inst = d.node(id).unwrap().clone(); + let sym = d.symbols.iter_mut().find(|sy| sy.name == "Square").unwrap(); + sym.art = std::sync::Arc::new(Node::group(NodeId(90_000), vec![sym.art.clone(), std::sync::Arc::new(inst)])); + } + let doc = s.doc().unwrap().doc.clone(); + assert!(vectorcraft_render::effects::bake_document(&doc).is_some(), "export bakes the instance"); + assert!(vectorcraft_render::painted_bounds(&doc, doc.node(id).unwrap()).is_some(), "the instance is measured"); + render(&s); + assert!(expandable(&s)); + let e = s.execute("effect.expandAppearance", &json!({})).expect_err("the symbol nests without end"); + assert!(e.to_string().contains(crate::TOO_DEEP), "{e}"); + assert!(matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. }), "the instance stays as it was"); +} + +/// A clip group's clipping path in the art keeps its shape, a compound one included, while the art +/// it clips takes the instance's fill effect. +#[test] +fn a_compound_clipping_path_in_the_art_keeps_its_shape() { + let mut s = session(); + let (id, row) = instance_with_row(&mut s, "Square", true); + offset_row(&mut s, id, row, 10.0); + let red = || { + let mut ap = vectorcraft_doc::Appearance::default(); + ap.items.push(vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(vectorcraft_color::Paint::solid( + vectorcraft_color::Color::rgb(1.0, 0.0, 0.0), + )))); + ap + }; + let square = |id: u64, x: f64| { + let rect = vectorcraft_geom::Rect::new(x, 50.0, x + 100.0, 150.0); + std::sync::Arc::new(Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(rect), red())) + }; + let mut clip = Node::new(NodeId(90_001), NodeKind::Compound { children: vec![square(90_002, 40.0)], rule: Default::default() }); + clip.appearance = red().into(); + let group = Node::new(NodeId(90_003), NodeKind::Group { children: vec![std::sync::Arc::new(clip), square(90_004, 50.0)], clip: true }); + let doc = { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sym = d.symbols.iter_mut().find(|sy| sy.name == "Square").unwrap(); + sym.art = std::sync::Arc::new(group); + d.clone() + }; + let inst = doc.node(id).unwrap(); + let art = doc.symbols.iter().find(|sy| sy.name == "Square").unwrap().art.clone(); + let out = vectorcraft_render::effects::outline_art(Some(&doc), inst, Some(&art)).expect("the instance's art"); + let mut seen = (false, false); + out.walk(&mut |part| { + let offset = part.appearance.items.iter().any(|i| i.effects().iter().any(|e| e.id == "path.offsetPath")); + match part.id.0 { + 90_001 => { + seen.0 = true; + assert!(!offset, "the compound clipping path keeps its shape"); + } + 90_004 => { + seen.1 = true; + assert!(offset, "the clipped square takes the fill effect"); + } + _ => {} + } + }); + assert_eq!(seen, (true, true)); +} + #[test] fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { let mut s = session(); diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 58c8714306..6fbd9f37ac 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -4,6 +4,7 @@ use super::{ Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, PathEl, Rect, Renderer, Shape, SymbolBoxes, effects, }; +use vectorcraft_doc::symbol_overrides::dynamic_art; fn node_bezpath(n: &Node) -> Option { match &n.kind { @@ -203,11 +204,14 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { // effects in a symbol's art and a live object's content paint past it. _ if has_object_fx(n) => { let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| dynamic_art(&s.art, n)) + } _ => None, }; - let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref()); let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r), Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) } @@ -230,6 +234,55 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } } +/// The bounds of `art`, the art [`effects::reshape`] made of a symbol instance whose active fill or +/// stroke has geometry effects ([`effects::has_item_geometry`]): its paths measured with the +/// geometry effects of their fills and strokes ([`visual_bounds`]), the symbol instances in it that +/// took those effects by their own reshaped art in turn, its groups and layers with their own fills +/// and strokes by the art those paint ([`effects::evaluate_container`]), its clip groups by their +/// clipping paths and other objects as [`Node::visual_bounds_with`] measures them. Hidden objects are left out. +/// An instance more than [`vectorcraft_doc::depth::MAX_DEPTH`] instances deep, or whose art would +/// take the objects measured so far past [`effects::SYMBOL_ART_MAX_NODES`], is measured as +/// [`Node::visual_bounds_with`] measures it. +fn instance_piece_bounds(symbols: &SymbolBoxes, art: &Node) -> Option { + let mut budget = effects::SYMBOL_ART_MAX_NODES; + pieces_within(symbols, art, 1, &mut budget) +} + +/// [`instance_piece_bounds`] of `art`, `depth` instances deep, with `budget` objects left to measure. +fn pieces_within(symbols: &SymbolBoxes, art: &Node, depth: usize, budget: &mut u64) -> Option { + if !art.visible { + return None; + } + match &art.kind { + NodeKind::Group { clip: false, .. } | NodeKind::Layer { clip: false, .. } => { + // A group's or layer's own fills and strokes as the art among its members that they paint. + let evaluated = effects::has_container_appearance(art).then(|| effects::evaluate_container(Some(symbols.document()), art)).flatten(); + let g = evaluated.as_ref().unwrap_or(art); + if g.clips() { + return g.visual_bounds_with(symbols); + } + g.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces_within(symbols, c, depth, budget))) + } + NodeKind::Path { .. } | NodeKind::Compound { .. } if has_fx(art) => visual_bounds(symbols, art), + NodeKind::SymbolInstance { symbol, .. } if depth < vectorcraft_doc::depth::MAX_DEPTH && effects::has_item_geometry(art) => { + let doc = symbols.document(); + let reshaped = doc.symbols.iter().find(|s| s.name == *symbol).and_then(|s| { + let count = s.art.count() as u64; + if count > *budget { + return None; + } + *budget -= count; + effects::reshape(Some(doc), art, Some(&dynamic_art(&s.art, art))) + }); + match reshaped { + Some(r) => pieces_within(symbols, &r, depth + 1, budget), + None => art.visual_bounds_with(symbols), + } + } + _ => art.visual_bounds_with(symbols), + } +} + /// [`drawn_bounds`] of `n` grown by how far its own raster effects paint past them. With Crop /// Marks, the [`drawn_bounds`] of its art: the marks and the object, which paints its own raster /// effects inside the art. @@ -406,11 +459,14 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio // symbol instance's art paint past that art. _ if has_object_fx(n) => { let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| dynamic_art(&s.art, n)) + } _ => None, }; - let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_ref()); let b = match &reshaped { + Some(r) if matches!(n.kind, NodeKind::SymbolInstance { .. }) && effects::has_item_geometry(n) => instance_piece_bounds(symbols, r), Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) } diff --git a/docs/mcp.md b/docs/mcp.md index e9fff8805c..050dbef158 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4298,7 +4298,10 @@ document's raster effects resolution (shadows and outer glows under the art; a b the object with the image). Type with a geometry effect (Outline Object included) or with fills and strokes of its own is outlined; type that has no fills or strokes of its own and whose only effects are shadows or outer glows stays type, with the image under it. A group's or layer's own fills and strokes become objects among its members, and its -members are expanded too. The result is `{ids}`; when an image in the art can't be decoded, the images made from +members are expanded too. A symbol instance whose topmost fill or topmost stroke is visible, has an opacity above 0 +and has a geometry effect becomes a group of its art: that fill's effects reshape the filled shapes of the art and that +stroke's effects reshape its stroked shapes, which keep their colors, as the canvas draws them (VectorCraft's own +rule); the instance's other fills and strokes are dropped. The result is `{ids}`; when an image in the art can't be decoded, the images made from raster effects do not show it, and the result also carries the `warning` that Flatten Transparency gives. A path's strokes stay strokes, as the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b8c3d5eda0..3c69dc836e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1426,6 +1426,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. + A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page describes effects on one fill or stroke, and the help page describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. Revolve's edge, Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's Color Model, Background and Anti-aliasing and the channel of Levels and Curves are dropdowns too, with the labels their own dialogs show. `effect.apply` and `effect.setParams` refuse a Levels or Curves `channel` other than `rgb`, `red`, `green` or `blue` (or `r`, `g` and `b`, in any letter case). Object › Expand Appearance keeps each stroke of a path a stroke (weight, caps, joins, dashes, arrowheads, alignment and width profile) on a copy of the path with the geometry effects baked in, and Object › Expand… is dimmed, with a tooltip, while the selection has appearance attributes, so a stroked line with Zig Zag becomes a stroked zig-zag line and only Expand then outlines it, the two steps the [Expand objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html) help page describes (#3089). Brushed strokes still become their brush art; `object.expand` still bakes and outlines in one step for agents.