From d324440afb64736cd4949d7c1f475d187d126e62 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 15:22:32 +0700 Subject: [PATCH] Pathfinder: blends, envelopes and warped objects can be compound shape members Squashed from #5226 onto main. --- crates/effects/src/group.rs | 224 ++++++++-- crates/effects/src/lib.rs | 8 +- crates/effects/src/live.rs | 7 +- crates/effects/src/reshape.rs | 113 +++++- crates/engine/src/cmd/compoundshape.rs | 52 ++- crates/engine/src/cmd/pathops.rs | 41 +- crates/engine/src/tests/compound_shape.rs | 474 +++++++++++++++++++++- crates/render/src/live.rs | 3 +- docs/mcp.md | 8 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 11 files changed, 867 insertions(+), 67 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index cad97ca56..ce8c38077 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -19,11 +19,13 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops::{self as po, PathfinderOp}; +use crate::reshape::SymbolNodes; + /// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order. pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [ ("pathfinder.add", "Add", PathfinderOp::Unite), @@ -61,35 +63,188 @@ pub fn has_pathfinder(n: &Node) -> bool { /// with the object's opacity and blend mode. pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option; -fn leaves(n: &Node, hook: Option, out: &mut Vec) { +/// The paths and compound paths `n` takes part in a compound shape or a Pathfinder operation as, +/// 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 { + let mut out = vec![]; + leaves(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) { 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) { + 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) { + for l in out.iter_mut().skip(first) { + within(l, n); + } + } + return; + } 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(n, hook) { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) { Some(art) => out.push(art), None => { for c in children { - leaves(c, hook, out); + leaves(c, hook, nodes, out); } } }, _ => { - // Every path of the outline art, at any depth, takes the object's opacity and blend - // mode. - if let Some(art) = hook.and_then(|h| h(n)) { + // Every path of the outline art, at any depth, is drawn inside the object's opacity + // and blend mode. A symbol's art comes with the live objects and the instances of + // other symbols inside it given as their art, and the type inside it outlined, so the + // art has nothing left for `hook` to give. + if let Some(h) = hook + && let Some(art) = h(n) + { + 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); + transparency_into_members(&mut art); + art + } + NodeKind::SymbolInstance { .. } => return, + _ => art, + }; let first = out.len(); - leaves(&art, None, out); + leaves(&art, None, nodes, out); for l in out.iter_mut().skip(first) { - (l.opacity, l.blend) = (n.opacity, n.blend); + within(l, n); } } } } } +/// `l`, a path drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend mode when it is +/// not Normal. +fn within(l: &mut Node, n: &Node) { + drawn_in(l, n.opacity, n.blend); +} + +/// `l` drawn inside a group of `opacity` and `blend`: its opacity multiplied by `opacity`, and +/// `blend` when it is not Normal. +fn drawn_in(l: &mut Node, opacity: f32, blend: BlendMode) { + l.opacity *= opacity; + if blend != BlendMode::Normal { + l.blend = blend; + } +} + +/// Move the opacity and blend mode of every group and layer inside `n` (not `n` itself, and not +/// compound shapes, which take part with their own) onto the objects it holds ([`drawn_in`]), so +/// that each path of `n` carries the transparency it is drawn with. +fn transparency_into_members(n: &mut Node) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut() { + if !is_container(c) || c.is_compound_shape() { + continue; + } + let c = Arc::make_mut(c); + let (opacity, blend) = (c.opacity, c.blend); + if let Some(inner) = c.children_mut() { + for g in inner.iter_mut() { + drawn_in(Arc::make_mut(g), opacity, blend); + } + } + (c.opacity, c.blend) = (1.0, BlendMode::Normal); + transparency_into_members(c); + } +} + +/// `n` as the art its visible geometry gives a compound shape or a Pathfinder operation, where that +/// is not its own paths: a live object evaluated (a blend's steps, an envelope's distorted art, +/// type inside it outlined), and a path, compound path, group, compound shape, live object, type or +/// symbol instance with visible geometry effects (such as a warp) reshaped by them, as the renderer +/// draws it. Type and symbol instances inside the art are given as their art by `hook` (without +/// one, type is outlined and symbol instances stay as they are); +/// [`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()) +} + +/// [`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)?; + 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 { + 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) { + 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); + without_geometry_effects(&mut m); + Some(m) + } + }, + // A symbol instance with a warp: its symbol's art placed by the instance, with the type + // inside it outlined, reshaped as one piece, as the renderer draws it. + NodeKind::SymbolInstance { symbol, .. } if !fx.is_empty() => { + 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); + (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate); + art.appearance.effects = n.appearance.effects.clone(); + without_geometry_effects(&mut art); + Some(art) + } + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None, + _ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => { + crate::reshape::as_art_with(None, n, None, outline, nodes) + } + _ => None, + } +} + +/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible +/// geometry effects of `n`, without those effects. +fn reshaped(src: &Node, n: &Node) -> Node { + let mut m = src.clone(); + if let Some((path, rule)) = geometry(src) { + let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); + let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n)); + m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false }; + } + without_geometry_effects(&mut m); + m +} + +fn without_geometry_effects(n: &mut Node) { + if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { + let mut ap = (*n.appearance).clone(); + ap.effects.retain(|e| !crate::is_geometry(&e.id)); + n.appearance = ap.into(); + } +} + fn geometry(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), @@ -114,8 +269,13 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp { /// as its outlines through `hook`, open paths closed) combined with the area of the members below /// 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`]). +/// [`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()) +} + +/// [`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 { if !n.is_compound_shape() { return None; } @@ -124,12 +284,12 @@ pub fn compound_shape_area(n: &Node, hook: Option) -> Option = vec![]; for c in n.children()? { let before = all.len(); - leaves(c, hook, &mut all); + leaves(c, hook, nodes, &mut all); if all.len() > before { members.push((shape_op(c.extras().shape_mode), all.len() - before)); } } - if !within_budget(&all) { + if nodes.spent() || !within_budget(&all) { return None; } if all.is_empty() { @@ -151,7 +311,12 @@ pub fn compound_shape_area(n: &Node, hook: Option) -> Option) -> Option { - let area = compound_shape_area(n, hook)?; + compound_shape_art_in(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)?; 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) @@ -256,7 +421,8 @@ 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`]) it returns the children as they are. +/// [`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>> { if !has_pathfinder(group) { return None; @@ -274,11 +440,11 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option, Vec) = lv @@ -295,15 +461,16 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option> = stack .iter() .map(|c| { let mut lv = vec![]; - leaves(c, hook, &mut lv); + leaves(c, hook, &nodes, &mut lv); lv }) .collect(); - if !within_budget(all.iter().flatten()) { + if nodes.spent() || !within_budget(all.iter().flatten()) { return Some(source.to_vec()); } // (style source, shape) per stack entry; keys index `sources`. @@ -440,9 +607,10 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live -/// objects evaluated; the members of a clip group or clipping layer without its clipping path. -/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of -/// `doc`'s symbols that its instances show toward its instance budget. +/// objects evaluated; a compound shape as its area, with the symbol instances among its members +/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its +/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the +/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { if !n.visible { @@ -451,8 +619,8 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul match &n.kind { NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)), NodeKind::Group { .. } if n.is_compound_shape() => { - let hook: &dyn Fn(&Node) -> Option = &crate::outline_text; - match compound_shape_area(n, Some(hook)) { + let hook = |c: &Node| crate::outline_object(doc, c); + match compound_shape_area(n, Some(&hook)) { Some(area) => out.push((area, FillRule::NonZero)), None => members(doc, n, out), } @@ -518,7 +686,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its /// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live -/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]). +/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]), and +/// a Pathfinder operation or compound shape takes the symbol instances among its members as their +/// symbols' art from `doc`. pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { if !is_container(n) { return None; @@ -531,8 +701,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } let mut m = n.clone(); if pathfinder { - let hook: &dyn Fn(&Node) -> Option = &crate::outline_text; - let result = pathfinder_children(n, Some(hook)).unwrap_or_default(); + let hook = |c: &Node| crate::outline_object(doc, c); + let result = pathfinder_children(n, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b15a642c3..9af1cfffd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -71,16 +71,16 @@ pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_lea pub use clip::clip_outline; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, - is_pathfinder, member_shapes, paints, pathfinder_children, + is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art, }; pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset}; -pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; +pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; pub use reshape::{ - expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, 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_transform_art, 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/live.rs b/crates/effects/src/live.rs index 8767d5741..ee88f485c 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -21,8 +21,9 @@ pub fn text_outliner() -> Outliner<'static> { /// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance, /// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None` -/// past its object limit). -fn outline(doc: Option<&Document>, n: &Node) -> Option { +/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and +/// Pathfinder operations the symbol instances among their members. +pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { let doc = doc?; @@ -38,7 +39,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option { /// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a /// document, symbol instances and pattern tiles stay as they are). fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { - let outline = |n: &Node| outline(doc, n); + 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 }) } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 69de857cb..fa77c5702 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::live::Hooks; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; -use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params}; +use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params}; /// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is /// neither a path nor a container)? @@ -249,6 +249,11 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { + outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default()) +} + +/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]). +fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, NodeKind::SymbolInstance { symbol, xf } => { @@ -272,7 +277,7 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) one.appearance.effects.clear(); Node::group(n.id, vec![Arc::new(one)]) } - _ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n), + _ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes), _ => return None, }; outline_nested_text(&mut art, false); @@ -290,8 +295,93 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) /// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show /// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]). pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node { - let hook: &dyn Fn(&Node) -> Option = &outline_text; - vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() }) + evaluated_with(doc, n, &outline_text, &SymbolNodes::default()) +} + +/// Most objects of symbol art that one compound shape or live Pathfinder operation gives its +/// members in all ([`SymbolNodes`]): four for each segment the operation takes in +/// ([`crate::group::PATHFINDER_MAX_SEGMENTS`]). +pub const SYMBOL_ART_MAX_NODES: u64 = 4 * crate::group::PATHFINDER_MAX_SEGMENTS as u64; + +/// The objects of symbol art given to one compound shape or live Pathfinder operation so far, +/// counted against [`SYMBOL_ART_MAX_NODES`]. Instances of symbols that hold several instances of +/// others multiply at each level, which the depth limit does not stop. +#[derive(Debug, Default)] +pub(crate) struct SymbolNodes(std::cell::Cell); + +impl SymbolNodes { + /// Count `art`'s objects: `false` once they would pass [`SYMBOL_ART_MAX_NODES`], and from then + /// on for all art. + pub(crate) fn charge(&self, art: &Node) -> bool { + let total = self.0.get().saturating_add(art.count() as u64); + let fits = total <= SYMBOL_ART_MAX_NODES; + self.0.set(if fits { total } else { u64::MAX }); + fits + } + + /// Has some symbol art been left out for passing [`SYMBOL_ART_MAX_NODES`]? + pub(crate) fn spent(&self) -> bool { + self.0.get() > SYMBOL_ART_MAX_NODES + } +} + +/// [`evaluated`] with type and symbol instances inside live objects given as their art by +/// `outline`, and every symbol instance left in the result (a blend's steps, an instance in a +/// plain group) replaced by its art from `outline`, with the instance's name, visibility and +/// transparency. The live objects and symbol instances inside that art are given as their art in +/// turn, except an instance of a symbol that holds an instance of itself, of a symbol nested +/// more than [`vectorcraft_doc::depth::MAX_DEPTH`] deep, or whose art would take `nodes` past +/// [`SYMBOL_ART_MAX_NODES`], which stays as it is. With [`outline_text`], symbol instances stay as +/// they are. +pub(crate) fn evaluated_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node { + resolved(doc, n, outline, nodes, &[]) +} + +/// `art`, the art `outline` gave an instance of the symbol named `symbol`, with the live objects +/// and symbol instances inside it given as their art as [`evaluated_with`] does. +pub(crate) fn symbol_art_with(art: &Node, outline: OutlineHook, nodes: &SymbolNodes, symbol: &str) -> Node { + resolved(None, art, outline, nodes, &[symbol.to_string()]) +} + +/// [`evaluated_with`] inside the art of the symbols named in `shown`, outermost first. +fn resolved(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Node { + let inner = |m: &Node| art_of(doc, m, outline, nodes, shown); + let mut art = vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(&inner), symbols: doc, ..Default::default() }); + instances_as_art(doc, &mut art, outline, nodes, shown); + art +} + +/// `n`'s art from `outline`, inside the art of the symbols named in `shown`. For a symbol instance +/// it is the symbol's art with the live objects and instances inside it given as their art in turn +/// ([`resolved`]); `None` for an instance of a symbol in `shown` (one that holds an instance of +/// itself), for every instance once `shown` holds [`vectorcraft_doc::depth::MAX_DEPTH`] symbols, +/// and for an instance whose symbol's art `nodes` cannot take ([`SymbolNodes::charge`]). +fn art_of(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Option { + let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return outline(n) }; + if shown.len() >= vectorcraft_doc::depth::MAX_DEPTH || shown.contains(symbol) { + return None; + } + let art = outline(n).filter(|art| nodes.charge(art))?; + let mut inside = shown.to_vec(); + inside.push(symbol.clone()); + Some(resolved(doc, &art, outline, nodes, &inside)) +} + +/// Replace every symbol instance under `n` for which [`art_of`] gives art by that art. `shown` +/// names the symbols whose art `n` is in, outermost first. +fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut() { + if matches!(c.kind, NodeKind::SymbolInstance { .. }) { + if let Some(mut o) = art_of(doc, c, outline, nodes, shown) { + o.name = c.name.clone(); + (o.visible, o.opacity, o.blend, o.isolate) = (c.visible, c.opacity, c.blend, c.isolate); + *c = Arc::new(o); + } + } else if c.children().is_some() { + instances_as_art(doc, Arc::make_mut(c), outline, nodes, shown); + } + } } /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look @@ -356,6 +446,11 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod /// [`reshape`] without its checks (no geometry effects: just the art). pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { + as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default()) +} + +/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]). +pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -383,7 +478,7 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node> Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect()) } } - _ => outline_art(doc, n, symbol_art)?, + _ => outline_art_with(doc, n, symbol_art, outline, nodes)?, }; reshape_leaves(&mut art, &fx); transform_items(&mut art); @@ -897,7 +992,13 @@ fn placed_count(n: &Node) -> usize { /// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on /// it reach every member. pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { - let mut art = evaluated(doc, n); + outlined_members_with(doc, n, &outline_text, &SymbolNodes::default()) +} + +/// [`outlined_members`] with live objects and symbol instances given as art through `outline` +/// ([`evaluated_with`]). +pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node { + let mut art = evaluated_with(doc, n, outline, nodes); outline_nested_text(&mut art, false); art } diff --git a/crates/engine/src/cmd/compoundshape.rs b/crates/engine/src/cmd/compoundshape.rs index 7c5aa3f5b..52c19a9a5 100644 --- a/crates/engine/src/cmd/compoundshape.rs +++ b/crates/engine/src/cmd/compoundshape.rs @@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ShapeMode}; use vectorcraft_render::effects; use super::edit::{roots_of, selected_roots}; -use super::pathops::{pf_leaves, shape_node, style_of}; +use super::pathops::{Style, pf_leaves, shape_node, style_of}; use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_multi}; const MODE_NAMES: [&str; 4] = ["add", "subtract", "intersect", "exclude"]; @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Make Compound Shape", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}", + "{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type, blends, envelopes) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed; a blend as its steps, an envelope as its distorted art, an object with a warp or other geometry effect as it draws), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}", has_multi, make ), @@ -140,19 +140,24 @@ fn make(s: &mut Session, p: &Value) -> Result { let mut members = Vec::with_capacity(roots.len()); for id in &roots { let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; - if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Text(_)) { - return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes and type")); + if !matches!( + n.kind, + NodeKind::Path { guide: false, .. } + | NodeKind::Compound { .. } + | NodeKind::Group { .. } + | NodeKind::Text(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + ) { + return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes")); } members.push(n); } // The paint and transparency of the topmost member in Add, Intersect or Exclude mode: as they // all have `mode`, the topmost, or the backmost when they all subtract. let order: Vec<&Node> = if mode == ShapeMode::Subtract { members } else { members.into_iter().rev().collect() }; - let style = order.into_iter().find_map(|n| { - let mut lv = vec![]; - pf_leaves(n, &mut lv); - lv.last().map(style_of) - }); + let hook = |c: &Node| effects::outline_object(Some(d), c); + let style = order.into_iter().find_map(|n| top_style(d, n, &hook)); let id = s.edit("Make Compound Shape", |d, sel| { let gid = d.alloc_id(); let id = d.wrap(&roots, |children| { @@ -177,6 +182,32 @@ fn make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0 })) } +/// The paint and transparency of the frontmost shape `n` adds to a compound shape's area, from the +/// same objects the area takes: a blend, an envelope or an object with a warp gives those of the +/// art it draws, without its geometry effects; a symbol instance those of its symbol's art (with +/// `hook`); type its first style; a compound shape its own; a group those of its frontmost member +/// 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