diff --git a/crates/brush/src/defaults.rs b/crates/brush/src/defaults.rs index 19b09a1f..8bd4e09d 100644 --- a/crates/brush/src/defaults.rs +++ b/crates/brush/src/defaults.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath, shapes} use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter}; fn fill(c: Color) -> Appearance { - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], effects: vec![] } + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], ..Default::default() } } fn path(p: PathData, c: Color) -> Node { diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 55487bb3..183fadb5 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -432,7 +432,7 @@ pub fn expand(doc: &Document, n: &Node) -> Option { /// A filled path node painted with `paint` (brush output). pub(crate) fn filled(path: vectorcraft_geom::PathData, paint: &Paint) -> Node { - Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], effects: vec![] }) + Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], ..Default::default() }) } /// A sample stroke for previews (a gentle S-curve in a `w`×`h` box). diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 775410bf..32f44069 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -375,12 +375,17 @@ pub struct Appearance { pub items: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub effects: Vec, + /// Groups, layers and type: how many items paint below the object's contents (its members, or + /// type's characters), the slot of the Appearance panel's Contents (Characters) row. `None`: + /// every item paints above the contents (see [`Self::contents_at`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contents_index: Option, } impl Appearance { /// Illustrator's "basic appearance": one fill below one stroke. pub fn basic(fill: Paint, stroke: Paint, width: f64) -> Self { - Self { items: vec![AppearanceItem::Fill(FillLayer::new(fill)), AppearanceItem::Stroke(StrokeLayer::new(stroke, width))], effects: vec![] } + Self { items: vec![AppearanceItem::Fill(FillLayer::new(fill)), AppearanceItem::Stroke(StrokeLayer::new(stroke, width))], ..Self::default() } } /// White fill, 1 pt black stroke (the default for new art). pub fn default_art() -> Self { @@ -410,7 +415,7 @@ impl Appearance { pub fn set_fill(&mut self, p: Paint) { match self.fill_mut() { Some(f) => f.paint = p, - None => self.items.insert(0, AppearanceItem::Fill(FillLayer::new(p))), + None => self.insert_item(0, AppearanceItem::Fill(FillLayer::new(p))), } } /// Set the top stroke's paint, creating a 1 pt stroke if there is none. @@ -428,6 +433,7 @@ impl Appearance { pub fn is_basic(&self) -> bool { let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count(); self.effects.is_empty() + && self.contents_index.is_none() && count(true) <= 1 && count(false) <= 1 && self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal) @@ -679,6 +685,59 @@ impl Appearance { } } +/// The contents slot: where a group's or layer's members (type: its characters) paint among the +/// object's own fills and strokes. +impl Appearance { + /// How many items paint below the contents (the Contents/Characters row sits above them). + pub fn contents_at(&self) -> usize { + self.contents_index.unwrap_or(0).min(self.items.len()) + } + /// Put the contents above the bottom `k` items (clamped; 0, all items above, is stored as `None`). + pub fn set_contents_at(&mut self, k: usize) { + let k = k.min(self.items.len()); + self.contents_index = (k > 0).then_some(k); + } + /// The items painted below the contents and those painted above them. + pub fn split_contents(&self) -> (&[AppearanceItem], &[AppearanceItem]) { + self.items.split_at(self.contents_at()) + } + /// Insert `item` at paint-order index `at` (clamped). The contents stay between the same items; + /// an item landing right above them joins the item below it (below the contents if that one is). + pub fn insert_item(&mut self, at: usize, item: AppearanceItem) { + let k = self.contents_at(); + let at = at.min(self.items.len()); + self.items.insert(at, item); + if at < k || (at == k && k > 0) { + self.set_contents_at(k + 1); + } + } + /// Remove item `i` (if it exists), keeping the contents between the same items. + pub fn remove_item(&mut self, i: usize) -> Option { + let k = self.contents_at(); + (i < self.items.len()).then(|| { + let it = self.items.remove(i); + if i < k { + self.set_contents_at(k - 1); + } + it + }) + } + /// Move item `from` to paint-order index `to` (clamped) and return where it landed, or `None` + /// when there is no item `from`. The contents stay between the same other items, and the moved + /// item keeps its side of them when it lands right next to them. + pub fn move_item(&mut self, from: usize, to: usize) -> Option { + let k = self.contents_at(); + let below = from < k; + let it = self.remove_item(from)?; + let others_below = self.contents_at(); + let to = to.min(self.items.len()); + self.items.insert(to, it); + let now_below = if below { to <= others_below } else { to < others_below }; + self.set_contents_at(others_below + usize::from(now_below)); + Some(to) + } +} + #[cfg(test)] mod tests { use super::*; @@ -744,7 +803,7 @@ mod tests { #[test] fn basic_means_one_plain_fill_and_stroke() { let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); - let mut a = Appearance { items: vec![stroke(), stroke()], effects: vec![] }; + let mut a = Appearance { items: vec![stroke(), stroke()], ..Appearance::default() }; assert!(!a.is_basic(), "two strokes"); a.items.pop(); assert!(a.is_basic() && Appearance::default().is_basic()); @@ -831,4 +890,40 @@ mod tests { a.stroke_mut().unwrap().align = StrokeAlign::Inside; assert_eq!(a.outset(), 0.0); } + + #[test] + fn contents_slot_follows_item_edits_and_round_trips() { + let fill = || AppearanceItem::Fill(FillLayer::new(Paint::None)); + let mut a = Appearance { items: vec![fill(), fill(), fill()], ..Appearance::default() }; + assert_eq!((a.contents_at(), a.split_contents().1.len()), (0, 3), "all items above by default"); + a.set_contents_at(1); + assert!(!a.is_basic()); + // [0 | 1 2]: removing below shifts the slot, removing above doesn't. + a.remove_item(2); + assert_eq!(a.contents_at(), 1); + a.remove_item(0); + assert_eq!((a.contents_at(), a.contents_index), (0, None)); + // Inserting next to the slot joins the item below it. + a.items.push(fill()); + a.set_contents_at(1); // [0 | 1] + a.insert_item(1, fill()); + assert_eq!(a.contents_at(), 2); + a.insert_item(3, fill()); // [0 1 | 2 3] + assert_eq!(a.contents_at(), 2); + // Moves keep their side when landing next to the slot, cross it otherwise. + assert_eq!(a.move_item(3, 2), Some(2)); + assert_eq!(a.contents_at(), 2, "stays above"); + a.move_item(3, 0); + assert_eq!(a.contents_at(), 3, "crossed below"); + a.move_item(0, 3); + assert_eq!(a.contents_at(), 2, "crossed above"); + assert_eq!(a.move_item(9, 0), None); + // Saved only when set; older files load with every item above. + let json = serde_json::to_string(&a).unwrap(); + assert!(json.contains("\"contents_index\":2")); + assert_eq!(serde_json::from_str::(&json).unwrap(), a); + let old: Appearance = serde_json::from_str(r#"{"items":[]}"#).unwrap(); + assert_eq!(old.contents_index, None); + assert!(!serde_json::to_string(&Appearance::default_art()).unwrap().contains("contents_index")); + } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 7f6617e6..28601a5c 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -456,7 +456,9 @@ impl Node { children.first().and_then(|c| c.geometric_bounds()) } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { - children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())) + // The container's own strokes paint around its members. + let o = self.appearance.outset(); + children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o)) } NodeKind::Blend { children, spec } => { let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())); @@ -793,6 +795,19 @@ impl Node { } } +impl Node { + /// The Appearance panel's row for what this object's own fills and strokes paint around: + /// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects + /// whose fills and strokes paint their own geometry. + pub fn contents_label(&self) -> Option<&'static str> { + match self.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"), + NodeKind::Text(_) => Some("Characters"), + _ => None, + } + } +} + /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index af75a976..994dff13 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -109,7 +109,7 @@ impl CharStyle { pub fn appearance(&self) -> crate::Appearance { let mut fill = crate::FillLayer::new(self.fill.clone()); fill.overprint = self.overprint_fill; - let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], effects: vec![] }; + let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], ..Default::default() }; if !self.stroke.is_none() && self.stroke_width > 0.0 { let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width); st.overprint = self.overprint_stroke; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2c1bc171..188c6200 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -3,17 +3,17 @@ //! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a //! copy of the document in which every geometry effect — object level, per fill/stroke and //! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their -//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows, -//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). +//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills, +//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects +//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; -use crate::{ - GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape, -}; +use crate::group::has_own_paint; +use crate::{GeomContext, apply_geometry_with, evaluate_container, has_geometry, has_pathfinder, is_geometry, needs_outline, reshape}; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { @@ -32,6 +32,7 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { has_pathfinder(n) + || has_own_paint(n) || has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) || n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) @@ -79,14 +80,16 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { let m = reshape(n, symbol.as_deref())?; return Some(bake_node(d, &m).unwrap_or(m)); } - if let Some(result) = pathfinder_children(n, None) { - let mut m = n.clone(); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); - let children = result + // Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new + // pieces (results, outlines, the fills' and strokes' art) get ids of their own. + if let Some(mut m) = evaluate_container(n) { + let mut seen = std::collections::HashSet::from([m.id]); + let children = m.children().cloned().unwrap_or_default(); + let children = children .into_iter() .map(|c| { let mut c = Arc::unwrap_or_clone(c); - c.id = d.alloc_id(); + fresh_ids(d, &mut c, &mut seen); Arc::new(bake_node(d, &c).unwrap_or(c)) }) .collect(); @@ -111,6 +114,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { bake_leaf(d, n) } +/// Give `n` and its descendants a new id wherever one was already `seen` (the pieces evaluated art +/// copies from its source keep ids of their own). +pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::HashSet) { + if !seen.insert(n.id) { + n.id = d.alloc_id(); + seen.insert(n.id); + } + for c in n.children_mut().into_iter().flatten() { + fresh_ids(d, Arc::make_mut(c), seen); + } +} + fn bake_leaf(d: &mut Document, n: &Node) -> Option { let (base, rule) = geometry(n)?; let ctx = GeomContext::of(n); @@ -136,7 +151,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { Arc::new(Node { id, name: None, - appearance: Appearance { items: vec![it], effects: vec![] }, + appearance: Appearance { items: vec![it], ..Default::default() }, opacity: 1.0, blend: Default::default(), isolate: false, diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index f9dfebaf..6cce4012 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -1,14 +1,20 @@ -//! Effect → Pathfinder: live Pathfinder operations on a group (or layer). +//! The appearance of groups and layers. //! -//! The group's direct children are the Pathfinder stack (back → front). In the shape modes (Add, -//! Subtract, Intersect, Exclude, Minus Back) a child group acts as one shape — the union of its -//! leaves, styled like its top leaf — as in the Pathfinder panel. The result replaces the children -//! at render time; each result keeps the appearance of the object whose paint it takes. +//! **Effect → Pathfinder**: live Pathfinder operations on a group (or layer). The group's direct +//! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect, +//! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its +//! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each +//! result keeps the appearance of the object whose paint it takes. +//! +//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes +//! paint every member's geometry, below or above the members as its contents slot says +//! ([`Appearance::contents_at`]); its geometry effects reshape the members as one piece; its raster +//! effects (one combined shadow) apply to the composite and stay for the renderer and exporters. use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, StrokeLayer}; -use vectorcraft_geom::{FillRule, PathData}; +use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops::{self as po, PathfinderOp}; /// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order. @@ -74,11 +80,11 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> { /// A result path styled like `src` (its appearance, opacity and blend mode). fn styled(src: &Node, path: PathData, outline: bool) -> Node { - let mut appearance = Appearance { items: src.appearance.items.clone(), effects: vec![] }; + let mut appearance = Appearance { items: src.appearance.items.clone(), ..Default::default() }; if outline { let fill = src.appearance.fill_paint(); let paint = if fill.is_none() { src.appearance.stroke_paint() } else { fill }; - appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] }; + appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() }; } let mut n = Node::path(src.id, path, appearance); n.opacity = src.opacity; @@ -130,6 +136,145 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) +} + +/// Does `item` paint anything? +fn paints(item: &AppearanceItem) -> bool { + item.visible() + && !item.paint().is_none() + && match item { + AppearanceItem::Stroke(s) => s.width > 0.0, + AppearanceItem::Fill(_) => true, + } +} + +/// Do the own fills or strokes of `n`, a group or layer, paint anything? +pub(crate) fn has_own_paint(n: &Node) -> bool { + is_container(n) && n.appearance.items.iter().any(paints) +} + +/// Does `n`, a group or layer, have an appearance of its own to evaluate: a fill or stroke that +/// paints, or a visible effect other than Pathfinder (evaluated by [`pathfinder_children`])? +pub fn has_container_appearance(n: &Node) -> bool { + is_container(n) && (has_own_paint(n) || n.appearance.effects.iter().any(|e| e.visible && !is_pathfinder(&e.id))) +} + +/// 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. +pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> { + fn push(n: &Node, out: &mut Vec<(PathData, FillRule)>) { + if !n.visible { + return; + } + match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)), + NodeKind::Text(t) => { + let (_, all) = crate::reshape::glyph_outlines(t); + if !all.elements().is_empty() { + out.push((PathData::from_bezpath(&all).transformed(t.xf), FillRule::NonZero)); + } + } + NodeKind::Group { .. } | NodeKind::Layer { .. } => members(n, out), + _ if vectorcraft_doc::live::is_live(n) => members(&crate::reshape::outlined_members(n), out), + _ => {} + } + } + fn members(n: &Node, out: &mut Vec<(PathData, FillRule)>) { + let clip = usize::from(n.clips()); + for c in n.children().into_iter().flatten().skip(clip) { + push(c, out); + } + } + let mut out = vec![]; + members(n, &mut out); + out +} + +/// A path node of `id` on `path` (filled under `rule`) painted by `ap`. +fn shape_node(id: NodeId, path: PathData, rule: FillRule, ap: Appearance) -> Node { + let mut n = Node::path(id, path, ap); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + n +} + +/// One of a container's own fills or strokes as art: `item` painting every shape of `shapes`, an +/// unplaced gradient placed across `bounds` (the whole group). Several shapes share the item's +/// opacity and blend mode as one layer, as one fill would. `None` when it paints nothing. +fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], bounds: Option) -> Option { + if !paints(item) || shapes.is_empty() { + return None; + } + let mut ap = Appearance { items: vec![item.clone()], ..Default::default() }; + if let Some(b) = bounds { + ap.pin_gradients(b); + } + if let [(path, rule)] = shapes { + return Some(shape_node(id, path.clone(), *rule, ap)); + } + let (opacity, blend) = (item.opacity(), item.blend()); + match &mut ap.items[0] { + AppearanceItem::Fill(f) => (f.opacity, f.blend) = (1.0, Default::default()), + AppearanceItem::Stroke(s) => (s.opacity, s.blend) = (1.0, Default::default()), + } + let children = shapes.iter().map(|(p, r)| Arc::new(shape_node(id, p.clone(), *r, ap.clone()))).collect(); + let mut g = Node::group(id, children); + (g.opacity, g.blend) = (opacity, blend); + Some(g) +} + +/// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects +/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its +/// 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. +pub fn evaluate_container(n: &Node) -> Option { + if !is_container(n) { + return None; + } + let pathfinder = has_pathfinder(n); + let fx = crate::reshape::geometry_effects(n); + let paint = has_own_paint(n); + if !pathfinder && fx.is_empty() && !paint { + return None; + } + 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(); + if let Some(ch) = m.children_mut() { + *ch = result; + } + } + if !fx.is_empty() { + m = crate::reshape::outlined_members(&m); + crate::reshape::reshape_leaves(&mut m, &fx); + } + m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + if paint { + let shapes = member_shapes(&m); + let bounds = m.geometric_bounds(); + let art = + |items: &[AppearanceItem]| -> Vec> { items.iter().filter_map(|i| item_art(n.id, i, &shapes, bounds)).map(Arc::new).collect() }; + let (below, above) = m.appearance.split_contents(); + let (below, above) = (art(below), art(above)); + let at = usize::from(m.clips()); + if let Some(ch) = m.children_mut() { + let at = at.min(ch.len()); + ch.splice(at..at, below); + ch.extend(above); + } + } + m.appearance.items.clear(); + m.appearance.contents_index = None; + Some(m) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d30ada80..6f164527 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -14,6 +14,8 @@ //! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. //! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects //! through their outlines. +//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into +//! art painting its members (its raster effects then apply to the composite). //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! PDF writers. //! @@ -39,9 +41,11 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; -pub use bake::{bake_document, needs_bake}; +pub use bake::{bake_document, fresh_ids, needs_bake}; pub use clip::clip_outline; -pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; +pub use group::{ + OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children, +}; pub use raster::{RasterFx, outset, raster_effects}; pub use reshape::{needs_outline, outline_art, outline_text, reshape}; pub use warp::{WarpStyle, warp_point}; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 84d902da..6de86c5a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -9,7 +9,7 @@ use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect, shapes}; use crate::{GeomContext, apply_one, is_geometry, merged_params}; @@ -29,11 +29,8 @@ pub fn needs_outline(n: &Node) -> bool { ) } -/// Type as glyph outlines in document space: one path per run with the run's fill and stroke, -/// then (when the object has its own fills/strokes) the whole outline with the object's stack, -/// painted over the characters as the renderer does. -pub fn outline_text(n: &Node) -> Option { - let NodeKind::Text(t) = &n.kind else { return None }; +/// The glyph outlines of type `t` in text space: one path per run, and all of them as one. +pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath) { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; let mut all = kurbo::BezPath::new(); @@ -43,13 +40,22 @@ pub fn outline_text(n: &Node) -> Option { } all.extend(g.outline.iter()); } + (runs, all) +} + +/// Type as glyph outlines in document space: one path per run with the run's fill and stroke, +/// and (when the object has its own fills/strokes) the whole outline with the object's items below +/// the characters under them and the others over them, as the renderer paints them. +pub fn outline_text(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let (runs, all) = glyph_outlines(t); let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); let mut children: Vec> = runs .iter() .zip(&t.runs) .filter(|(bp, _)| !bp.elements().is_empty()) .map(|(bp, run)| { - let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] }; + let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], ..Default::default() }; if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 { ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width))); } @@ -57,8 +63,14 @@ pub fn outline_text(n: &Node) -> Option { }) .collect(); if !n.appearance.items.is_empty() && !all.elements().is_empty() { - let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] }; - children.push(Arc::new(Node::path(n.id, path(&all), ap))); + let (below, above) = n.appearance.split_contents(); + let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() })); + if !below.is_empty() { + children.insert(0, glyphs(below)); + } + if !above.is_empty() { + children.push(glyphs(above)); + } } Some(Node::group(n.id, children)) } @@ -107,7 +119,7 @@ fn outline_nested_text(n: &mut Node) { /// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` /// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - let fx: Vec<_> = n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + let fx = geometry_effects(n); if fx.is_empty() || !needs_outline(n) { return None; } @@ -123,16 +135,7 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { } _ => outline_art(n, symbol_art)?, }; - for e in fx { - // Every leaf takes the effect with the whole art's bounds as its reference box. - let Some(bounds) = art.geometric_bounds() else { break }; - let params = merged_params(&e.id, &e.params); - for_each_leaf(&mut art, &mut |leaf| { - let Some((path, rule)) = leaf_geometry(leaf) else { return }; - let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext::of(leaf)); - set_geometry(leaf, out, rule); - }); - } + reshape_leaves(&mut art, &fx); // The art is a group with `n`'s id (a clip group for an image). let mut out = art; out.name = n.name.clone(); @@ -146,6 +149,34 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { Some(out) } +/// The visible geometry effects of `n`'s own effect list, in order. +pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { + n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect() +} + +/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as +/// its reference box. +pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { + for e in fx { + let Some(bounds) = art.geometric_bounds() else { break }; + let params = merged_params(&e.id, &e.params); + for_each_leaf(art, &mut |leaf| { + let Some((path, rule)) = leaf_geometry(leaf) else { return }; + let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext::of(leaf)); + set_geometry(leaf, out, rule); + }); + } +} + +/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on +/// it reach every member. +pub(crate) fn outlined_members(n: &Node) -> Node { + let hook: &dyn Fn(&Node) -> Option = &outline_text; + let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook)); + outline_nested_text(&mut art); + art +} + /// Run `f` on every path and compound path under `n` (guides skipped). fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) { match &mut n.kind { @@ -159,7 +190,7 @@ fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) { } } -fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> { +pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), NodeKind::Compound { children, rule } => { diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 4544c981..b5ae5dcc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -8,6 +8,14 @@ //! current selection and no `ids` are given, else the topmost fill or stroke; `null` always means //! the topmost one. A targeted item lives in the selected objects' own stacks (a group's own fill, //! not its contents'), so item edits apply to the selected objects themselves. +//! +//! **Object or contents.** The `appearance.*` and `effect.*` commands edit the stacks of the +//! selected objects themselves (`target: "object"`, the default; with `ids`, layers too) or, with +//! `target: "contents"`, of the painted objects inside the selected groups and layers. A group's or +//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one +//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes +//! paint below the members and which above ([`Appearance::contents_at`], moved by +//! `appearance.moveItem {from: "contents"}`). use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; @@ -26,8 +34,8 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], None, - "{ids?} add a fill on top of each selected object's own stack", - has_selection, + "{ids?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack (a copy of its top fill; type without one: its characters' fill; else the default fill)", + has_doc, |s, p| add_item(s, p, true) ), cmd!( @@ -35,8 +43,8 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], None, - "{ids?} add a stroke on top of each selected object's own stack", - has_selection, + "{ids?, target?} add a stroke on top of each selected object's own stack (a copy of its top stroke, else the default stroke)", + has_doc, |s, p| add_item(s, p, false) ), cmd!( @@ -44,8 +52,8 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", - has_selection, + "{ids?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", + has_doc, clear_appearance ), cmd!( @@ -53,8 +61,8 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", - has_selection, + "{ids?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects", + has_doc, reduce_basic ), cmd!( @@ -62,7 +70,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids? (default: the selection), opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack", + "{index: paint-order item index, ids? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill)} edit one fill/stroke of each target object's own appearance stack", has_doc, set_item ), @@ -71,8 +79,8 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack", - has_selection, + "{index | indices: [..] (paint-order item indices), ids?, target?} remove those fills/strokes from each target object's own stack", + has_doc, remove_item ), cmd!( @@ -80,8 +88,8 @@ pub fn specs() -> Vec { "Add Effect", [], None, - "same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?} → {ids, index, item}", - has_selection, + "same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}", + has_doc, super::effectcmd::apply ), cmd!( @@ -89,11 +97,19 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?} copy fills/strokes with their effects", - has_selection, + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it), ids?, target?} copy fills/strokes with their effects", + has_doc, duplicate_item ), - cmd!("appearance.moveItem", "Reorder Appearance Item", [], None, "{from, to, ids?} (paint-order indices)", has_selection, move_item), + cmd!( + "appearance.moveItem", + "Reorder Appearance Item", + [], + None, + "{from: paint-order item index | \"contents\", to, contents?: count, ids?, target?} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}", + has_doc, + move_item + ), cmd!( "appearance.copyFrom", "Eyedropper", @@ -117,10 +133,19 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", - has_selection, + "{ids?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}", + has_doc, show_all_hidden ), + cmd!( + "appearance.targetContents", + "Target Contents", + [], + None, + "{ids?} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}", + has_doc, + target_contents + ), ] } @@ -182,7 +207,8 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re .as_u64() .map(|i| ItemTarget::Item { index: i as usize, explicit: true }) .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), - None if p.get("ids").is_some() || p.get("id").is_some() => Ok(ItemTarget::Top), + // The active item is a row of the first selected object's own stack. + None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false })), } } @@ -234,14 +260,38 @@ impl ItemTarget { } } -/// Objects whose own appearance stack a command edits: `ids`/`id`, else the selected objects -/// (a selected compound-path member stands for its compound). Layers are skipped. -pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> { - if p.get("ids").is_none() && p.get("id").is_none() { - return selected_roots(s); +/// Whether the `target` param asks for the contents of groups and layers (see the module docs). +fn targets_contents(p: &Value) -> Result { + match p.get("target") { + None | Some(Value::Null) => Ok(false), + Some(v) => match v.as_str() { + Some("object") => Ok(false), + Some("contents") => Ok(true), + _ => Err(bad("appearance", format!("`target` must be \"object\" or \"contents\", not {v}"))), + }, } - let d = &s.doc()?.doc; - Ok(targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect()) +} + +/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too), else the selected +/// objects (a selected compound-path member stands for its compound); with `target: "contents"` +/// the painted objects inside the groups and layers among them ([`leaf_targets`]). +pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result> { + let roots = if p.get("ids").is_none() && p.get("id").is_none() { + selected_roots(s)? + } else { + let d = &s.doc()?.doc; + targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect() + }; + if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) } +} + +/// [`appearance_targets`] of `cmd`, which needs at least one. +fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = appearance_targets(s, p)?; + if ids.is_empty() { + return Err(bad(cmd, "select objects or give ids")); + } + Ok(ids) } /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke @@ -305,14 +355,28 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result { } fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { - let ids = appearance_targets(s, p)?; + let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?; + let defaults = s.paint.clone(); s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { + // A copy of the top fill (stroke); without one, type's characters' and otherwise the + // default paint, so a new fill on a group or type shows. + let paint = match (n.appearance.paint_at(None, fill), &n.kind) { + (Some(p), _) => p.clone(), + (None, NodeKind::Text(_)) => super::paint::proxy_paint(n, !fill, None), + (None, _) => { + if fill { + defaults.fill.clone() + } else { + defaults.stroke.clone() + } + } + }; let item = if fill { - AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint())) + AppearanceItem::Fill(FillLayer::new(paint)) } else { - AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0))) + AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0))) }; n.appearance.items.push(item); } @@ -323,7 +387,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { } fn clear_appearance(s: &mut Session, p: &Value) -> Result { - let ids = appearance_targets(s, p)?; + let ids = require_targets(s, p, "appearance.clear")?; s.edit("Clear Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -340,12 +404,13 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { ok() } +/// A group or layer (no fill or stroke rows of its own unless added). fn is_group(n: &Node) -> bool { - matches!(n.kind, NodeKind::Group { .. }) + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } fn reduce_basic(s: &mut Session, p: &Value) -> Result { - let ids = appearance_targets(s, p)?; + let ids = require_targets(s, p, "appearance.reduceToBasic")?; s.edit("Reduce to Basic Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { @@ -356,7 +421,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { // keeps the rows it had. let fill = (fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone())))); - n.appearance = Appearance { items: fill.into_iter().collect(), effects: vec![] }; + n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() }; if let Some(AppearanceItem::Stroke(mut st)) = stroke { st.effects.clear(); st.opacity = 1.0; @@ -433,16 +498,13 @@ fn item_indices(p: &Value, cmd: &str) -> Result> { fn remove_item(s: &mut Session, p: &Value) -> Result { let idx = item_indices(p, "appearance.removeItem")?; - let ids = appearance_targets(s, p)?; + let ids = require_targets(s, p, "appearance.removeItem")?; s.edit("Remove Item", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - let items = &mut n.appearance.items; // From the top down, so the lower indices stay valid. for &i in idx.iter().rev() { - if i < items.len() { - items.remove(i); - } + n.appearance.remove_item(i); } } } @@ -459,20 +521,20 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { if to.is_some() && idx.len() > 1 { return Err(bad(C, "`to` takes a single `index`")); } - let ids = appearance_targets(s, p)?; + let ids = require_targets(s, p, C)?; // Where the first object's copy landed (the active item lives in the first object's stack). let mut first_at = None; s.edit("Duplicate Item", |d, _| { let mut any = false; for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; - let items = &mut n.appearance.items; + let ap = &mut n.appearance; // From the top down, so the lower indices stay valid. for &i in idx.iter().rev() { - if let Some(item) = items.get(i).cloned() { - let at = to.map_or(i + 1, |t| t.min(items.len())); + if let Some(item) = ap.items.get(i).cloned() { + let at = to.map_or(i + 1, |t| t.min(ap.items.len())); first_at.get_or_insert(at); - items.insert(at, item); + ap.insert_item(at, item); any = true; } } @@ -490,7 +552,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { } fn show_all_hidden(s: &mut Session, p: &Value) -> Result { - let roots = appearance_targets(s, p)?; + let roots = require_targets(s, p, "appearance.showAllHidden")?; let ids = s.edit("Show All Hidden Attributes", |d, _| { let shown: Vec = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); if shown.is_empty() { @@ -503,20 +565,21 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.moveItem"; - let from = index_param(p, "from", C)?; let to = index_param(p, "to", C)?; - let ids = appearance_targets(s, p)?; + let ids = require_targets(s, p, C)?; + if p.get("from").and_then(Value::as_str) == Some("contents") { + return move_contents(s, &ids, to); + } + let from = index_param(p, "from", C)?; + let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?; let mut landed = to; s.edit("Reorder Appearance", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; - let items = &mut n.appearance.items; - if from >= items.len() { - return Err(bad(C, format!("no item at index {from}"))); + landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?; + if let Some(k) = contents.filter(|_| n.contents_label().is_some()) { + n.appearance.set_contents_at(k); } - let it = items.remove(from); - landed = to.min(items.len()); - items.insert(landed, it); } Ok(()) })?; @@ -532,7 +595,38 @@ fn move_item(s: &mut Session, p: &Value) -> Result { a }) }); - ok() + Ok(json!({ "index": landed })) +} + +/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that +/// has one above its bottom `to` items. +fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result { + let slot = s.edit("Reorder Appearance", |d, _| { + let mut slot = None; + for id in ids { + let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue }; + n.appearance.set_contents_at(to); + slot.get_or_insert(n.appearance.contents_at()); + } + slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers and type have a Contents (Characters) row")) + })?; + Ok(json!({ "contents": slot })) +} + +fn target_contents(s: &mut Session, p: &Value) -> Result { + let roots = appearance_targets(s, p)?; + let d = &s.doc()?.doc; + let ids: Vec = roots + .iter() + .filter_map(|id| d.node(*id)) + .filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) + .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)) + .collect(); + if ids.is_empty() { + return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members")); + } + s.select(|_, sel| sel.set(ids.iter().copied()))?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } fn copy_from(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 48207f44..afb2d91d 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -198,11 +198,11 @@ fn finite(p: Point) -> bool { } fn fill_only(paint: Paint) -> Appearance { - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], effects: vec![] } + Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() } } fn stroke_only(paint: Paint, width: f64) -> Appearance { - Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], effects: vec![] } + Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() } } /// A point strictly inside a filled path (for re-finding faces after a rebuild). diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 4f59a850..1315f0fa 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -1,7 +1,9 @@ //! Effect menu commands (live effects) and Object → Expand Appearance. //! //! Effects are stored on the object's appearance (`appearance.effects`) and evaluated at render -//! time by `vectorcraft-effects` (re-exported by the renderer). +//! time by `vectorcraft-effects` (re-exported by the renderer). The commands edit the objects' +//! own stacks, groups and layers included (layers through `ids`), or with `target: "contents"` +//! the objects inside them (see `appearance`). use std::sync::Arc; @@ -20,8 +22,8 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..]} append a live effect to each selected object's appearance → {ids, index, item}", - has_selection, + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}", + has_doc, apply ), cmd!( @@ -39,7 +41,7 @@ pub fn specs() -> Vec { [], None, "{index: int (position in the effect list), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?: [..]} → {ids}", - has_selection, + has_doc, remove ), cmd!( @@ -48,7 +50,7 @@ pub fn specs() -> Vec { [], None, "{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}", - has_selection, + has_doc, set_params ), cmd!( @@ -56,8 +58,8 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?: [..]} bake geometry effects into the paths and drop them (raster effects stay live) → {ids}", - has_selection, + "{ids?: [..], target?} bake geometry effects into the paths and drop them (raster effects stay live); a group's or layer's own fills and strokes become paths among its members → {ids}", + has_doc, expand_appearance ), cmd!( @@ -66,7 +68,7 @@ pub fn specs() -> Vec { [], None, "{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}", - has_selection, + has_doc, duplicate ), cmd!( @@ -75,7 +77,7 @@ pub fn specs() -> Vec { [], None, "{from: int (position in the source list), to: int (its position in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?: [..]} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", - has_selection, + has_doc, move_effect ), ] @@ -256,31 +258,22 @@ fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> { } } -/// Bake the object-level geometry effects of `id` (and, recursively, of group members). +/// Bake the object-level geometry effects of `id` (and, recursively, of group members). A group's +/// or layer's Pathfinder and geometry effects and its own fills and strokes become its members. fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec) { let Some(n) = d.node(id).cloned() else { return }; - // Effect → Pathfinder: the group's members become the Pathfinder result. - if let Some(result) = effects::pathfinder_children(&n, None) { - let children = result - .into_iter() - .map(|c| { - let mut c = Arc::unwrap_or_clone(c); - c.id = d.alloc_id(); - Arc::new(c) - }) - .collect(); - let Some(m) = d.node_mut(id) else { return }; - if let Some(ch) = m.children_mut() { - *ch = children; + if matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) { + if let Some(mut m) = effects::evaluate_container(&n) { + let mut seen = std::collections::HashSet::from([m.id]); + for c in m.children_mut().into_iter().flatten() { + effects::fresh_ids(d, Arc::make_mut(c), &mut seen); + } + let Some(slot) = d.node_mut(id) else { return }; + *slot = m; + out.push(id); } - m.appearance.effects.retain(|e| !effects::is_pathfinder(&e.id)); - out.push(id); - return; - } - if let Some(children) = n.children() - && matches!(n.kind, NodeKind::Group { .. }) - { - for c in children.iter().map(|c| c.id).collect::>() { + let children: Vec = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); + for c in children { expand_node(d, c, out); } return; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 8dce208e..bf2f907f 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -363,7 +363,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { // Outline: open edges stroked with the face's former fill. let fill = st.appearance.fill_paint(); let paint = if fill.is_none() { st.appearance.stroke_paint() } else { fill }; - n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] }; + n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() }; } children.push(Arc::new(n)); } @@ -483,7 +483,7 @@ fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: overprint: st.overprint, ..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()) }; - n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], effects: vec![] }; + n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }; Some(n) } @@ -499,7 +499,7 @@ fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &N let mut fills: Vec = vec![]; let fill_node = |d: &mut Document, items: Vec| { let mut f = d.reid(l); - f.appearance = Appearance { items, effects: vec![] }; + f.appearance = Appearance { items, ..Default::default() }; f.opacity = 1.0; f.blend = BlendMode::Normal; f.name = None; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 09920a44..c35b8a5c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks", + "{name, add?: bool, ids? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks", has_doc, style_apply ), @@ -126,22 +126,19 @@ fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { } } -/// [`painted`], mutably (copying only the nodes on the way). -fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) { - if !descends(n, top) { - return f(n); - } - for c in n.children_mut().into_iter().flatten() { - paint_mut(std::sync::Arc::make_mut(c), false, f); - } -} - -/// Does `n` still have style `g`'s look (its transparency, and its appearance on every painted -/// object)? Linked objects that don't are no longer linked. +/// Does `n` still have style `g`'s look (its transparency, and the style's appearance as its own; +/// a group or layer with no appearance of its own: on every painted object inside it, the look a +/// style made from it captures)? Linked objects that don't are no longer linked. pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { + if !g.transparency_matches(n) { + return false; + } + if g.appearance_on(n).approx_eq(&n.appearance) { + return true; + } let mut leaves = vec![]; painted(n, true, &mut leaves); - g.transparency_matches(n) && leaves.iter().all(|l| g.appearance_on(l).approx_eq(&l.appearance)) + n.appearance == Appearance::default() && leaves.iter().all(|l| l.id != n.id && g.appearance_on(l).approx_eq(&l.appearance)) } /// The style `n` is linked to (and still looks like). @@ -214,10 +211,10 @@ fn captured(n: &Node) -> Appearance { ap } -/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it. +/// Give `id` style `g` (a group or layer too: its own appearance, above its contents) and link it. fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) { if let Some(n) = d.node_mut(id) { - paint_mut(n, true, &mut |l| l.appearance = g.appearance_on(l).into_owned()); + n.appearance = g.appearance_on(n).into_owned(); g.apply_transparency(n); n.graphic_style = Some(g.id); } @@ -270,11 +267,9 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let g = d.graphic_styles[i].clone(); for id in &ids { if let Some(n) = d.node_mut(*id) { - paint_mut(n, true, &mut |l| { - let ap = g.appearance_on(l).into_owned(); - l.appearance.items.extend(ap.items); - l.appearance.effects.extend(ap.effects); - }); + let ap = g.appearance_on(n).into_owned(); + n.appearance.items.extend(ap.items); + n.appearance.effects.extend(ap.effects); n.graphic_style = None; } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 635f123d..4c407247 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -752,6 +752,8 @@ mod tests_colorguide; #[cfg(test)] mod tests_colormgmt; #[cfg(test)] +mod tests_containers; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_distort; diff --git a/crates/engine/src/tests_containers.rs b/crates/engine/src/tests_containers.rs new file mode 100644 index 00000000..557a2047 --- /dev/null +++ b/crates/engine/src/tests_containers.rs @@ -0,0 +1,147 @@ +//! Group, layer and type appearance through commands: their own fills, strokes and effects, the +//! Contents (Characters) slot, `target: "contents"`, Target Contents and effects on layers. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Node, NodeKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A selected group of two 40×40 squares at (20, 20) and (100, 20) → (group, [members]). +fn group(s: &mut Session) -> (NodeId, [NodeId; 2]) { + let a = id_of(&run(s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40}))); + let b = id_of(&run(s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))); + run(s, "select.set", json!({"ids": [a.0, b.0]})); + run(s, "paint.setFill", json!({"color": "#ff0000"})); + (id_of(&run(s, "object.group", json!({}))), [a, b]) +} + +fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y) +} + +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; + +#[test] +fn a_group_fill_paints_both_members_and_the_contents_slot_moves() { + let mut s = session(); + let (g, [a, _]) = group(&mut s); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "paint.setFill", json!({"color": "#00ff00", "item": 0})); + let n = node(&s, g); + assert_eq!((n.appearance.items.len(), n.appearance.contents_at()), (1, 0)); + // The members keep their own fill; the group's paints over both of them. + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap())); + assert_eq!((pixel(&s, 40, 40), pixel(&s, 120, 40), pixel(&s, 80, 40)), (GREEN, GREEN, [255; 4])); + // Contents above the fill: the members show again; one undo step brings it back. + assert_eq!(run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}))["contents"], 1); + assert_eq!((node(&s, g).appearance.contents_index, pixel(&s, 40, 40)), (Some(1), RED)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(pixel(&s, 40, 40), GREEN); + // A stroke dragged under the contents in one step with `contents`. + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 0, "contents": 1})); + let ap = node(&s, g).appearance; + assert!(!ap.items[0].is_fill() && ap.contents_at() == 1, "{ap:?}"); + // Paths have no Contents row. + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(s.execute("appearance.moveItem", &json!({"from": "contents", "to": 1})).is_err()); +} + +#[test] +fn appearance_commands_target_the_object_or_its_contents() { + let mut s = session(); + let (g, [a, b]) = group(&mut s); + let before = node(&s, a).appearance.items.len(); + run(&mut s, "appearance.addStroke", json!({"target": "contents"})); + assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (before + 1, before + 1)); + assert!(node(&s, g).appearance.items.is_empty()); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "target": "contents"})); + assert_eq!((node(&s, a).appearance.effects.len(), node(&s, g).appearance.effects.len()), (1, 0)); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"})); + assert_eq!(node(&s, g).appearance.effects.len(), 1); + assert!(s.execute("appearance.addFill", &json!({"target": "inside"})).is_err()); + // Target Contents selects the members (what double-clicking the Contents row does). + assert_eq!(run(&mut s, "appearance.targetContents", json!({}))["ids"], json!([a.0, b.0])); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert!(s.execute("appearance.targetContents", &json!({})).is_err(), "paths have no members"); +} + +#[test] +fn effects_and_fills_apply_to_layers_by_id() { + let mut s = session(); + let (g, _) = group(&mut s); + let layer = s.doc().unwrap().doc.layers[0].id; + run(&mut s, "select.none", json!({})); + let r = run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [layer.0]})); + assert_eq!(r["ids"], json!([layer.0])); + run(&mut s, "appearance.addFill", json!({"ids": [layer.0]})); + let l = node(&s, layer); + assert_eq!((l.appearance.effects.len(), l.appearance.items.len()), (1, 1)); + assert!(node(&s, g).appearance.effects.is_empty()); + // Two undo steps take both off again. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, layer).appearance, Default::default()); + // Nothing selected and no ids: an error, not a silent no-op. + assert!(s.execute("appearance.addFill", &json!({})).is_err()); +} + +#[test] +fn type_fills_go_above_or_below_the_characters() { + let mut s = session(); + let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 100, "text": "Hi"}))); + run(&mut s, "select.set", json!({"ids": [t.0]})); + // A new fill on type copies its characters' fill (it has none of its own). + run(&mut s, "appearance.addFill", json!({})); + let n = node(&s, t); + let NodeKind::Text(tx) = &n.kind else { unreachable!() }; + assert_eq!(n.appearance.fill_paint(), tx.runs[0].style.fill); + run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1})); + assert_eq!(node(&s, t).appearance.contents_at(), 1); +} + +#[test] +fn group_appearance_round_trips_and_expands() { + let mut s = session(); + let (g, _) = group(&mut s); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveV": 50}})); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(back.node(g).unwrap().appearance, doc.node(g).unwrap().appearance); + assert_eq!(back.node(g).unwrap().appearance.contents_index, Some(1)); + // Expand Appearance: the fill and stroke become paths below and above the moved members. + run(&mut s, "effect.expandAppearance", json!({})); + let n = node(&s, g); + assert!(n.appearance.items.is_empty() && n.appearance.effects.is_empty()); + let ch = n.children().unwrap(); + assert_eq!(ch.len(), 4, "fill, two members, stroke"); + // Each is a group of one path per member (sharing the item's opacity and blend mode). + let first = |n: &Node| n.children().unwrap()[0].appearance.items[0].is_fill(); + assert!(first(&ch[0]) && !first(&ch[3]) && ch[0].children().unwrap().len() == 2); + assert!((n.geometric_bounds().unwrap().y0 - 70.0).abs() < 1e-6); +} diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index d9cec078..bb1643e3 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -99,12 +99,18 @@ fn a_style_from_a_group_or_type_is_not_empty() { run(&mut s, "graphicStyle.new", json!({"name": "Type Look"})); assert!(!style(&s, "Type Look").appearance.fill_paint().is_none()); - // Applying to a group styles its contents and links the group. + // Applying to a group gives the group itself the style (its fills paint the members, which + // keep their own look) and links it; `target: "contents"` styles the members instead. let c = rect(&mut s, 200.0); + let before = node(&s, a).appearance; run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]})); - assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance); + assert_eq!(node(&s, g).appearance, style(&s, "Black Outline").appearance); + assert_eq!(node(&s, a).appearance, before); assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id)); + assert_eq!(linked(&mut s, "Black Outline"), vec![g.0]); assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance); + run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0], "target": "contents"})); + assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance); } #[test] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ebf48ca8..c7aa3882 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -678,6 +678,18 @@ impl Exporter<'_> { fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let tb = t.xf.transform_rect_bbox(layout.bounds); + // The object's own fills and strokes paint the whole outline: those below the Characters + // row under the characters, the others over them. + let (below, above) = n.appearance.split_contents(); + let all = (!n.appearance.items.is_empty()).then(|| { + let mut all = layout.to_bezpath(); + all.apply_affine(t.xf); + to_path(&all) + }); + let all = all.flatten(); + if let Some(path) = &all { + self.text_items(s, below, path, tb); + } s.push_transform(&xf(t.xf)); for (i, run) in t.runs.iter().enumerate() { let mut bp = BezPath::new(); @@ -701,34 +713,36 @@ impl Exporter<'_> { s.set_fill(None); s.set_stroke(None); s.pop(); - if !n.appearance.items.is_empty() { - let mut all = layout.to_bezpath(); - all.apply_affine(t.xf); - let Some(path) = to_path(&all) else { return }; - for item in &n.appearance.items { - match item { - AppearanceItem::Fill(fl) if fl.visible => { - if let Some(paint) = self.paint(&fl.paint, tb) { - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); - s.draw_path(&path); - } - } - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { - if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) { - s.set_fill(None); - s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() })); - s.draw_path(&path); - } - } - _ => {} - } - } - s.set_fill(None); - s.set_stroke(None); + if let Some(path) = &all { + self.text_items(s, above, path, tb); } } + /// A type object's own fills and strokes `items` on its glyph outlines `path` (`tb`: their bounds). + fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], path: &Path, tb: Rect) { + for item in items { + match item { + AppearanceItem::Fill(fl) if fl.visible => { + if let Some(paint) = self.paint(&fl.paint, tb) { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); + s.draw_path(path); + } + } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { + if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) { + s.set_fill(None); + s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() })); + s.draw_path(path); + } + } + _ => {} + } + } + s.set_fill(None); + s.set_stroke(None); + } + fn load_image(&mut self, key: &str) -> Option { if let Some(i) = self.images.get(key) { return i.clone(); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 384e74c0..af78fb9f 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -461,7 +461,7 @@ impl<'a> Device<'a> for Builder { PathDrawMode::Fill(rule) => { let mut f = FillLayer::new(paint); f.opacity = opacity; - let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] }); + let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() }); if let NodeKind::Path { rule: r, .. } = &mut n.kind { *r = fill_rule(*rule); } @@ -480,7 +480,7 @@ impl<'a> Device<'a> for Builder { Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st)); return; } - let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }); + let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); self.push_node(n); } } diff --git a/crates/pdf/src/tests_dashalign.rs b/crates/pdf/src/tests_dashalign.rs index fbce3e3e..0e8fe5a2 100644 --- a/crates/pdf/src/tests_dashalign.rs +++ b/crates/pdf/src/tests_dashalign.rs @@ -37,7 +37,7 @@ fn fitted_dashes_on_a_rectangle_look_as_on_the_canvas() { let mut n = Node::path( NodeId(0), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)), - Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], effects: vec![] }, + Appearance { items: vec![AppearanceItem::Stroke(dashed(true))], ..Default::default() }, ); n.id = d.alloc_id(); let layer = d.default_layer().unwrap(); diff --git a/crates/pdf/src/tests_stroke.rs b/crates/pdf/src/tests_stroke.rs index 25c413b9..91436a09 100644 --- a/crates/pdf/src/tests_stroke.rs +++ b/crates/pdf/src/tests_stroke.rs @@ -13,7 +13,7 @@ pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) { let mut n = Node::path( NodeId(0), shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)), - Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, + Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }, ); n.id = d.alloc_id(); let layer = d.default_layer().unwrap(); diff --git a/crates/pdf/src/tests_strokeout.rs b/crates/pdf/src/tests_strokeout.rs index 23a8ea8a..0cbefd08 100644 --- a/crates/pdf/src/tests_strokeout.rs +++ b/crates/pdf/src/tests_strokeout.rs @@ -56,7 +56,7 @@ fn outside_strokes_keep_their_miter_spikes() { s.join = LineJoin::Miter; s.miter_limit = 10.0; }); - let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }); + let mut n = Node::path(NodeId(0), PathData::single(tri), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); n.id = d.alloc_id(); let layer = d.default_layer().unwrap(); d.insert(Some(layer), 0, n).unwrap(); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 9066238e..6329da4d 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -15,7 +15,10 @@ //! //! The same applies to one fill or stroke's own raster effects (around that item alone) and to //! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is -//! drawn offscreen in the filter layers, reshaped first by their geometry effects. +//! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and +//! layers with an appearance of their own take the same path with their evaluated art +//! ([`effects::evaluate_container`]: their fills and strokes painting the members, their geometry +//! effects reshaping them), so their raster effects apply to the composite (one combined shadow). use std::sync::Arc; @@ -45,9 +48,15 @@ pub(crate) fn has_fx(n: &Node) -> bool { } /// Does `n` carry visible object-level effects that only apply through its art (type, images, -/// symbol instances, live objects; see [`Renderer::draw_object_fx`])? +/// symbol instances, live objects), or is it a group or layer with an appearance of its own (see +/// [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { - effects::needs_outline(n) && visible(&n.appearance.effects) + (effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n) +} + +/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? +fn is_container(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } /// What [`Renderer::raster_fx`] paints effects around. @@ -127,6 +136,18 @@ pub(crate) fn visual_bounds(n: &Node) -> Option { /// the subtree (a shadow can reach the view while its object is outside it). pub(crate) fn cull_bounds(n: &Node) -> Option { match &n.kind { + // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. + _ if effects::has_container_appearance(n) => { + let art = effects::evaluate_container(n); + let art = art.as_ref().unwrap_or(n); + let b = if art.clips() { + art.visual_bounds() + } else { + art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) + }?; + let o = effects::outset(&n.appearance.effects); + Some(b.inflate(o, o)) + } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) } @@ -375,7 +396,8 @@ impl Renderer { /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): /// reshaped by its geometry effects, else type and images as themselves and the others as - /// their evaluated art; without the object's transparency and effects. + /// their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their + /// knockout setting); without the object's transparency and effects. fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; if cache @@ -390,8 +412,10 @@ impl Renderer { } _ => None, }; + let container = is_container(a); let art = match effects::reshape(a, symbol.as_ref()) { Some(r) => r, + None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(), None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; @@ -399,8 +423,9 @@ impl Renderer { opacity: 1.0, blend: Default::default(), isolate: false, - // One element: its pieces never knock each other out. - knockout: vectorcraft_doc::Knockout::Off, + // One element: its pieces never knock each other out (a group's members still do as + // its knockout setting says). + knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off }, mask: None, appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance }, ..art diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9654d292..73c1b109 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -456,7 +456,7 @@ impl Renderer { return e.bounds; } let b = match &a.kind { - NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => { let mut acc: Option = None; for c in children { if c.visible { @@ -920,7 +920,17 @@ impl Renderer { ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() }); } }; - // Object-level appearance fills/strokes apply on top of character fills (like Illustrator). + // The object's own fills and strokes paint the whole outline: those below the Characters + // row under the characters' own fill and stroke, the others over them. + let (below, above) = n.appearance.split_contents(); + let all = (!n.appearance.items.is_empty()).then(|| { + let mut all = g.all.clone(); + all.apply_affine(t.xf); + all + }); + if let Some(all) = &all { + self.draw_text_items(ctx, f, below, all, tb); + } for (i, run) in t.runs.iter().enumerate() { let Some(path) = g.runs.get(i) else { continue }; if path.elements().is_empty() { @@ -943,31 +953,34 @@ impl Renderer { } } overprint(ctx, false); - if !n.appearance.items.is_empty() { - let mut all = g.all.clone(); - all.apply_affine(t.xf); - for item in n.appearance.items.iter().filter(|i| i.visible() && !i.paint().is_none()) { - // Each item composites with its own opacity and blend mode. - let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); + if let Some(all) = &all { + self.draw_text_items(ctx, f, above, all, tb); + } + } + + /// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document + /// space; `tb` their bounds), each with its own opacity and blend mode. + fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) { + for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) { + let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); + } + ctx.set_transform(f.view); + match item { + AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(all); } - ctx.set_transform(f.view); - match item { - AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(&all); - } - AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => { - ctx.set_stroke(kurbo::Stroke::new(st.width)); - ctx.stroke_path(&all); - } - _ => {} - } - if layered { - ctx.pop_layer(); + AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => { + ctx.set_stroke(kurbo::Stroke::new(st.width)); + ctx.stroke_path(all); } + _ => {} + } + if layered { + ctx.pop_layer(); } } } @@ -1141,6 +1154,8 @@ mod tests_blend; #[cfg(test)] mod tests_clip; #[cfg(test)] +mod tests_container; +#[cfg(test)] mod tests_isolation; #[cfg(test)] mod tests_knockout; diff --git a/crates/render/src/tests_container.rs b/crates/render/src/tests_container.rs new file mode 100644 index 00000000..a0e7319a --- /dev/null +++ b/crates/render/src/tests_container.rs @@ -0,0 +1,188 @@ +//! The appearance of groups, layers and type: a container's own fills and strokes paint its +//! members' geometry below or above them (the contents slot), its effects apply to the composite +//! (one combined shadow) and reshape its members as one piece; type's own fills paint under or +//! over its characters. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Knockout, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Point, shapes}; + +use super::*; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn solid(r: f32, g: f32, b: f32) -> Paint { + Paint::solid(Color::rgb(r, g, b)) +} + +/// A rectangle filled with `paint` and no stroke. +fn rect(d: &mut Document, r: Rect, paint: Paint) -> Arc { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0))) +} + +/// A 100×100 document holding a group of a red square (10..40) and a blue one (50..80), on a row +/// from y 10 to 40, given its own appearance by `style`. +fn group_doc(style: impl FnOnce(&mut Node)) -> Document { + let mut d = Document::new(100.0, 100.0); + let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), solid(1.0, 0.0, 0.0)); + let b = rect(&mut d, Rect::new(50.0, 10.0, 80.0, 40.0), solid(0.0, 0.0, 1.0)); + let mut g = Node::group(d.alloc_id(), vec![a, b]); + style(&mut g); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + d +} + +fn render_threads(d: &Document, threads: u16) -> Rendered { + let mut r = Renderer::new(); + r.threads = threads; + r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn render(d: &Document) -> Rendered { + render_threads(d, 0) +} + +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const RED: [u8; 4] = [255, 0, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; +const WHITE: [u8; 4] = [255, 255, 255, 255]; + +fn green_fill(g: &mut Node) { + g.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0)))); +} + +#[test] +fn a_group_fill_paints_every_member_above_or_below_them() { + // Above the contents (the default): both members turn green; the gap between them stays white. + let img = render(&group_doc(green_fill)); + assert_eq!((img.pixel(25, 25), img.pixel(65, 25), img.pixel(45, 25)), (GREEN, GREEN, WHITE)); + // Below the contents the members' own fills cover it. + let img = render(&group_doc(|g| { + green_fill(g); + g.appearance.set_contents_at(1); + })); + assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (RED, BLUE)); + // A group stroke strokes each member's outline. + let img = render(&group_doc(|g| { + g.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(solid(0.0, 1.0, 0.0), 4.0))); + })); + assert_eq!((img.pixel(10, 25), img.pixel(80, 25), img.pixel(25, 25)), (GREEN, GREEN, RED)); + // Hidden fills paint nothing. + let img = render(&group_doc(|g| { + green_fill(g); + if let AppearanceItem::Fill(f) = &mut g.appearance.items[0] { + f.visible = false; + } + })); + assert_eq!(img.pixel(25, 25), RED); +} + +#[test] +fn a_group_fill_with_opacity_composites_as_one_layer() { + // Two overlapping members under a 50 % group fill: the overlap is as light as the rest. + let mut d = Document::new(100.0, 100.0); + let a = rect(&mut d, Rect::new(10.0, 10.0, 60.0, 40.0), Paint::None); + let b = rect(&mut d, Rect::new(40.0, 10.0, 90.0, 40.0), Paint::None); + let mut g = Node::group(d.alloc_id(), vec![a, b]); + let mut f = FillLayer::new(Paint::solid(Color::BLACK)); + f.opacity = 0.5; + g.appearance.items.push(AppearanceItem::Fill(f)); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let img = render(&d); + let (single, both) = (img.pixel(20, 25), img.pixel(50, 25)); + assert!(single[0].abs_diff(128) <= 2 && single == both, "{single:?} vs {both:?}"); +} + +#[test] +fn a_group_shadow_is_one_combined_shadow() { + // The members' shadows (offset 30 pt right) overlap at x 65 but not at x 80; one combined + // shadow is equally dark at both, two separate ones would double up at 65. + let mut d = Document::new(100.0, 100.0); + let a = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), Paint::solid(Color::BLACK)); + let b = rect(&mut d, Rect::new(30.0, 10.0, 60.0, 40.0), Paint::solid(Color::BLACK)); + let mut g = Node::group(d.alloc_id(), vec![a, b]); + g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 50, "mode": "normal"}))); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + for threads in [0, 3] { + let img = render_threads(&d, threads); + let (overlap, single) = (img.pixel(65, 25), img.pixel(80, 25)); + assert!(single[0] < 160 && single[0] > 100, "{single:?}"); + assert!(overlap[0].abs_diff(single[0]) <= 2, "one shadow, not two: {overlap:?} vs {single:?}"); + assert_eq!(img.pixel(95, 25), WHITE); + } +} + +#[test] +fn group_geometry_effects_move_the_members_as_one_piece() { + let img = render(&group_doc(|g| g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50}))))); + assert_eq!((img.pixel(25, 25), img.pixel(25, 75), img.pixel(65, 75)), (WHITE, RED, BLUE)); + // The group's own fill paints the moved members, and the shadow follows them. + let img = render(&group_doc(|g| { + g.appearance.effects.push(fx("distort.transform", json!({"moveV": 50}))); + green_fill(g); + })); + assert_eq!((img.pixel(25, 75), img.pixel(65, 75)), (GREEN, GREEN)); +} + +#[test] +fn a_layer_paints_its_own_fill_and_a_knockout_group_keeps_knocking_out() { + let mut d = group_doc(|_| {}); + let l = Arc::make_mut(&mut d.layers[0]); + l.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(0.0, 1.0, 0.0)))); + let img = render(&d); + assert_eq!((img.pixel(25, 25), img.pixel(65, 25)), (GREEN, GREEN)); + + // A knockout group with a drop shadow: its half-transparent members still knock each other + // out (the overlap shows the top one over the backdrop), and it casts its shadow. + let mut d = Document::new(100.0, 100.0); + let half = |d: &mut Document, r: Rect, p: Paint| { + let mut n = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(p, Paint::None, 0.0)); + n.opacity = 0.5; + Arc::new(n) + }; + let a = half(&mut d, Rect::new(10.0, 10.0, 50.0, 40.0), solid(1.0, 0.0, 0.0)); + let b = half(&mut d, Rect::new(30.0, 10.0, 70.0, 40.0), solid(0.0, 0.0, 1.0)); + let mut g = Node::group(d.alloc_id(), vec![a, b]); + g.knockout = Knockout::On; + g.appearance.effects.push(fx("stylize.dropShadow", json!({"x": 0, "y": 40, "blur": 0, "opacity": 100, "mode": "normal"}))); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let img = render(&d); + let overlap = img.pixel(40, 25); + assert!(overlap[0] > 120 && overlap[2] > 250, "the blue knocks the red out: {overlap:?}"); + // The shadow of the composite: as opaque as the knocked-out art (half), the overlap too. + assert!(img.pixel(40, 65)[0].abs_diff(127) <= 2 && img.pixel(20, 65) == img.pixel(40, 65), "the shadow: {:?}", img.pixel(40, 65)); +} + +/// "IIII" in black at 40 pt with its own red fill; `below`: under the characters. +fn type_doc(below: bool) -> Document { + let mut d = Document::new(100.0, 100.0); + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style)))); + n.appearance.items.push(AppearanceItem::Fill(FillLayer::new(solid(1.0, 0.0, 0.0)))); + if below { + n.appearance.set_contents_at(1); + } + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn red_pixels(img: &Rendered) -> usize { + (0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| matches!(img.pixel(x, y), [r, g, _, _] if r > 200 && g < 60)).count() +} + +#[test] +fn a_fill_below_the_characters_is_hidden_by_the_glyphs() { + assert!(red_pixels(&render(&type_doc(false))) > 50, "above: the red fill covers the glyphs"); + assert_eq!(red_pixels(&render(&type_doc(true))), 0, "below: the black glyphs cover it"); +} diff --git a/crates/render/tests/dash_align.rs b/crates/render/tests/dash_align.rs index e6250c6e..41c1c8a2 100644 --- a/crates/render/tests/dash_align.rs +++ b/crates/render/tests/dash_align.rs @@ -14,7 +14,7 @@ fn doc(path: PathData, width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Document let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); f(&mut st); let mut b = DocBuilder::new(160.0, 120.0); - b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, |_| {}); + b.path(path, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }, |_| {}); b.build() } diff --git a/crates/render/tests/golden.rs b/crates/render/tests/golden.rs index d56c8713..7cd2645a 100644 --- a/crates/render/tests/golden.rs +++ b/crates/render/tests/golden.rs @@ -32,7 +32,7 @@ fn stroke_only(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); st.cap = LineCap::Butt; f(&mut st); - Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] } + Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() } } // ---------------------------------------------------------------- gradients diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/stroke_geometry.rs index 80f326e1..8eae3b8c 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/stroke_geometry.rs @@ -15,7 +15,7 @@ const S: f64 = 4.0; fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); f(&mut st); - Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] } + Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() } } /// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard. diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 474f9965..4a6a300e 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -357,7 +357,7 @@ impl Importer { usvg::PaintOrder::FillAndStroke => [fill, stroke], usvg::PaintOrder::StrokeAndFill => [stroke, fill], }; - (Appearance { items: items.into_iter().flatten().collect(), effects: vec![] }, r) + (Appearance { items: items.into_iter().flatten().collect(), ..Default::default() }, r) } fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option { diff --git a/crates/svg/tests/container_export.rs b/crates/svg/tests/container_export.rs new file mode 100644 index 00000000..9f3afc74 --- /dev/null +++ b/crates/svg/tests/container_export.rs @@ -0,0 +1,63 @@ +//! The appearance of groups and layers in SVG export: their own fills and strokes are baked into +//! paths painting the members (below or above them as the contents slot says), their geometry +//! effects into the members, and their raster effects filter the whole group. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node}; +use vectorcraft_geom::{Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export}; + +/// A group of a red and a blue square with a green fill of its own (`below`: under the members). +fn doc(below: bool, effects: Vec) -> Document { + let mut d = Document::new(200.0, 200.0); + let sq = |d: &mut Document, x: f64, hex: &str| { + let fill = Paint::solid(Color::from_hex(hex).unwrap()); + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 20.0, x + 40.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0))) + }; + let (a, b) = (sq(&mut d, 20.0, "#ff0000"), sq(&mut d, 100.0, "#0000ff")); + let mut g = Node::group(d.alloc_id(), vec![a, b]); + g.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::from_hex("#00ff00").unwrap())))); + if below { + g.appearance.set_contents_at(1); + } + g.appearance.effects = effects; + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + d +} + +#[test] +fn a_group_fill_exports_as_paths_over_or_under_the_members() { + let svg = export(&doc(false, vec![]), &ExportOptions::default()); + // Two green paths, one per member, after both members. + assert_eq!(svg.matches("fill=\"#00ff00\"").count(), 2, "{svg}"); + let green = svg.find("#00ff00").unwrap(); + assert!(svg.find("#ff0000").unwrap() < green && svg.find("#0000ff").unwrap() < green, "{svg}"); + // Below the contents: before them. + let svg = export(&doc(true, vec![]), &ExportOptions::default()); + let green = svg.rfind("#00ff00").unwrap(); + assert!(green < svg.find("#ff0000").unwrap(), "{svg}"); +} + +#[test] +fn group_effects_bake_into_the_members_and_filter_the_group() { + let fx = |id: &str, params| Effect { id: id.into(), params, visible: true }; + let svg = + export(&doc(false, vec![fx("distort.transform", json!({"moveV": 100})), fx("stylize.dropShadow", json!({}))]), &ExportOptions::default()); + // One filter around the whole group (one combined shadow). + assert_eq!(svg.matches(" Document { let n = Node::path( d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)), - Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }, + Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }, ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index eb58a190..c8b82223 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -192,7 +192,7 @@ fn stroke_attributes_roundtrip() { s.cap = LineCap::Round; s.join = LineJoin::Bevel; s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.5, align_corners: false }); - let ap = Appearance { items: vec![AppearanceItem::Stroke(s)], effects: vec![] }; + let ap = Appearance { items: vec![AppearanceItem::Stroke(s)], ..Default::default() }; let id = d.alloc_id(); let n = Node::path(id, shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 50.0)), ap); let d = doc_with(vec![n]); @@ -213,7 +213,7 @@ fn stroke_attributes_roundtrip() { let n = Node::path( NodeId(99), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), - Appearance { items: vec![AppearanceItem::Stroke(m)], effects: vec![] }, + Appearance { items: vec![AppearanceItem::Stroke(m)], ..Default::default() }, ); let r = roundtrip(&doc_with(vec![n])); assert!((art(&r)[0].appearance.stroke().unwrap().miter_limit - 7.0).abs() < 1e-6); @@ -284,7 +284,7 @@ fn multiple_fills_stack_in_paint_order() { AppearanceItem::Fill(FillLayer::new(solid("#00ff00"))), AppearanceItem::Stroke(StrokeLayer::new(solid("#0000ff"), 2.0)), ], - effects: vec![], + ..Default::default() }; let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), ap); let s = export(&doc_with(vec![n]), &ExportOptions::default()); diff --git a/crates/svg/tests/stroke_export.rs b/crates/svg/tests/stroke_export.rs index d38b4d75..47a5d592 100644 --- a/crates/svg/tests/stroke_export.rs +++ b/crates/svg/tests/stroke_export.rs @@ -11,7 +11,7 @@ use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; fn add(d: &mut Document, path: PathData, f: impl FnOnce(&mut StrokeLayer)) { let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 4.0); f(&mut st); - let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }); + let n = Node::path(d.alloc_id(), path, Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); let l = d.layers[0].id; d.insert(Some(l), usize::MAX, n).unwrap(); } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index add1a881..7c9d9cd0 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -2,10 +2,14 @@ //! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight, //! brush/dash/profile notes) with "Opacity: Default" sub-rows, fx rows (the name opens the //! effect's dialog; the disclosure an inline editor), the object's Opacity row and the bottom bar. +//! Groups and layers show a Contents row and type a Characters row where their members +//! (characters) paint among the object's own fills and strokes: double-clicking Contents targets +//! the members, double-clicking Characters edits the characters' paint. //! -//! Rows drag: a fill/stroke row reorders the stack, an effect row reorders its list or moves into -//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and -//! Shift-click select several fills/strokes for Duplicate and Delete. +//! Rows drag: a fill/stroke row reorders the stack (across the Contents row too), the Contents row +//! moves between the fills and strokes, an effect row reorders its list or moves into another item +//! (dropped on its row) or onto the object; Alt copies instead. Cmd-click and Shift-click select +//! several fills/strokes for Duplicate and Delete. use std::sync::OnceLock; @@ -35,6 +39,8 @@ pub enum Sel { Effect(usize), /// Effect `k` of fill/stroke item `i`. ItemEffect(usize, usize), + /// The Contents (type: Characters) row: paint edits go to the members (characters). + Contents, } impl Sel { @@ -42,7 +48,7 @@ impl Sel { fn item(self) -> Option { match self { Sel::Item(i) | Sel::ItemEffect(i, _) => Some(i), - Sel::None | Sel::Effect(_) => None, + Sel::None | Sel::Effect(_) | Sel::Contents => None, } } /// `{item, index}` of a selected effect row (`item: null` for the object's own effects). @@ -50,9 +56,13 @@ impl Sel { match self { Sel::Effect(k) => Some(json!({"item": null, "index": k})), Sel::ItemEffect(i, k) => Some(json!({"item": i, "index": k})), - Sel::None | Sel::Item(_) => None, + Sel::None | Sel::Item(_) | Sel::Contents => None, } } + /// Is a fill/stroke or effect row selected (what Duplicate and Delete act on)? + fn editable(self) -> bool { + self.item().is_some() || self.effect().is_some() + } } /// The selected row of `node` (the first selected object): a selected effect row while that @@ -65,6 +75,7 @@ fn current_sel(app: &VectorcraftApp, ctx: &egui::Context, node: Option<&Node>) - (Some(i), Sel::ItemEffect(j, k)) if i == j && exists(Some(i), k) => sel, (Some(i), _) => Sel::Item(i), (None, Sel::Effect(k)) if exists(None, k) => sel, + (None, Sel::Contents) if owner == Some(n.id) && n.contents_label().is_some() => sel, (None, _) => Sel::None, } } @@ -301,6 +312,8 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Dragged { Item(usize), + /// The Contents (Characters) row. + Contents, /// Effect `k` of fill/stroke `item` (`None`: of the object). Effect(Option, usize), } @@ -314,6 +327,8 @@ enum Slot { Effect(Option, usize), /// The object row (`top`) or the object's Opacity row. Object { top: bool }, + /// The Contents (Characters) row. + Contents, } fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) { @@ -330,7 +345,15 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: } stopped |= resp.drag_stopped(); }; + let contents = n.contents_label(); + let below = n.appearance.contents_at(); for (i, it) in n.appearance.items.iter().enumerate().rev() { + // The Contents row sits right above the items painted below the members. + if let Some(label) = contents.filter(|_| i + 1 == below) { + let (r, resp) = contents_row(app, ui, n, label, sel); + rows.push((Slot::Contents, r)); + track(&resp, Dragged::Contents); + } let open: bool = pstate(ui.ctx(), &format!("ap-open-{i}")); let (r, resp) = row(ui, items.contains(&i)); rows.push((Slot::Item { index: i, sub: false }, r)); @@ -392,6 +415,11 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: } } } + if let Some(label) = contents.filter(|_| below == 0) { + let (r, resp) = contents_row(app, ui, n, label, sel); + rows.push((Slot::Contents, r)); + track(&resp, Dragged::Contents); + } // Object-level effects. for (k, e) in n.appearance.effects.iter().enumerate() { let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); @@ -415,7 +443,10 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: (Dragged::Effect(..), _) => { ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); } + // Onto the Contents row: an item from above lands just below it, one from below just above. + (Dragged::Item(from), Slot::Contents) => line(if from >= below { r.bottom() } else { r.top() }), (Dragged::Item(from), _) => line(if item_at(n, slot) > from { r.top() } else { r.bottom() }), + (Dragged::Contents, _) => line(if contents_slot(n, slot).is_some_and(|k| k > below) { r.top() } else { r.bottom() }), } } if !ui.ctx().input(|i| i.pointer.any_down()) { @@ -439,12 +470,27 @@ fn slot_at(rows: &[(Slot, Rect)], p: egui::Pos2) -> Option<(Slot, Rect)> { } /// Paint-order index a dragged fill/stroke lands on when dropped on row `slot`: that row's item; -/// the object row stands above the topmost item, the rows below the items under item 0. +/// the object row stands above the topmost item, the rows below the items under item 0, the +/// Contents row for the first item above it. fn item_at(n: &Node, slot: Slot) -> usize { match slot { Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index, Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1), Slot::Object { top: false } | Slot::Effect(None, _) => 0, + Slot::Contents => n.appearance.contents_at(), + } +} + +/// How many items paint below the Contents row after it is dropped on row `slot`: it goes above a +/// fill or stroke above it and below one below it; the object row is above everything, the rows +/// under the stack below everything. `None` on itself. +fn contents_slot(n: &Node, slot: Slot) -> Option { + let k = n.appearance.contents_at(); + match slot { + Slot::Item { index, .. } | Slot::Effect(Some(index), _) => Some(if index >= k { index + 1 } else { index }), + Slot::Object { top: true } => Some(n.appearance.items.len()), + Slot::Object { top: false } | Slot::Effect(None, _) => Some(0), + Slot::Contents => None, } } @@ -459,16 +505,38 @@ fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, c // The copy goes where the row was dropped: above that row when dragged up, below it // when dragged down. let at = if to >= from { to + 1 } else { to }; - Some(("appearance.duplicateItem", json!({"index": from, "to": at}))) - } else { - (to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to}))) + return Some(("appearance.duplicateItem", json!({"index": from, "to": at}))); } + // The Contents row ends up below an item dropped on the object row, above one dropped + // under the stack, and on the far side of one dropped on it (elsewhere it stays put). + let k = n.appearance.contents_at(); + let others = k - usize::from(from < k); + let contents = match slot { + Slot::Contents if from < k => Some(others), + Slot::Contents | Slot::Object { top: false } | Slot::Effect(None, _) => Some(others + 1), + Slot::Object { top: true } => Some(others), + _ => None, + } + .filter(|_| n.contents_label().is_some()); + let to = if slot == Slot::Contents { others } else { to }; + if to == from && contents.is_none_or(|c| c == k) { + return None; + } + let mut params = json!({"from": from, "to": to}); + if let Some(c) = contents { + params["contents"] = json!(c); + } + Some(("appearance.moveItem", params)) + } + Dragged::Contents => { + let to = contents_slot(n, slot)?; + (to != n.appearance.contents_at()).then(|| ("appearance.moveItem", json!({"from": "contents", "to": to}))) } Dragged::Effect(from_item, k) => { let (item, pos) = match slot { Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)), Slot::Item { index, .. } => (Some(index), n.appearance.effects_at(Some(index))?.len()), - Slot::Object { .. } => (None, n.appearance.effects.len()), + Slot::Object { .. } | Slot::Contents => (None, n.appearance.effects.len()), }; // Within its own list the effect leaves its old place first. let same = item == from_item && !copy; @@ -481,6 +549,34 @@ fn drop_command(n: &Node, rows: &[(Slot, Rect)], what: Dragged, p: egui::Pos2, c } } +/// The Contents (type: Characters) row of `n`: clicking it sends paint edits to the members +/// (characters); double-clicking Contents targets the members, Characters opens the Color panel +/// on the characters' paint (whose chips it shows). Returns its rect and response (for dragging). +fn contents_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, label: &str, sel: Sel) -> (Rect, egui::Response) { + let (r, resp) = row(ui, sel == Sel::Contents); + eye(ui, r, "ap-contents-eye", true, false); + text(ui, pos2(r.left() + EYE_W + 24.0, r.center().y), label, false); + let characters = matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)); + if characters { + for (k, stroke) in [false, true].into_iter().enumerate() { + let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 100.0 + 24.0 * k as f32, r.center().y - 9.0), vec2(18.0, 18.0)); + chip(ui, cr, n.proxy_paint(stroke, None).map_or(&Paint::None, |(p, ..)| p)); + } + } + let resp = resp.on_hover_text(if characters { "Double-click to edit the characters' paint" } else { "Double-click to target the contents" }); + if resp.clicked() || resp.double_clicked() { + select_row(app, ui.ctx(), Sel::Contents); + } + if resp.double_clicked() { + if characters { + app.ui.open_panel = Some("color".into()); + } else { + app.run("appearance.targetContents", json!({})).ok(); + } + } + (r, resp) +} + /// A stroke row's notes after the weight: its brush, "Dashed" and its width profile (drawn). fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) { let t = Tokens::get(ui.ctx()); @@ -686,7 +782,7 @@ fn bottom(app: &mut VectorcraftApp, ui: &mut Ui, node: Option<&Node>, sel: Sel) if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() { app.run("appearance.clear", json!({})).ok(); } - let can = has && sel != Sel::None; + let can = has && sel.editable(); if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() { duplicate_selected(app, ui, node, sel); } @@ -753,10 +849,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("appearance.addStroke", json!({})).ok(); } ui.separator(); - if menu_item(ui, "Duplicate Item", has && sel != Sel::None, false) { + if menu_item(ui, "Duplicate Item", has && sel.editable(), false) { duplicate_selected(app, ui, node.as_ref(), sel); } - if menu_item(ui, "Remove Item", has && sel != Sel::None, false) { + if menu_item(ui, "Remove Item", has && sel.editable(), false) { delete_selected(app, ui, node.as_ref(), sel); } if menu_item(ui, "Clear Appearance", has, false) { @@ -840,4 +936,77 @@ mod tests { assert_eq!(drop(Dragged::Effect(None, 0), at(3, false), false), mv(0, Value::Null, 0, json!(1), false)); assert_eq!(drop(Dragged::Effect(None, 0), pos2(500.0, 10.0), false), None, "outside the stack"); } + + /// One headless frame of the panel: the texts drawn, top to bottom. + fn frame(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec<(f32, String)>) { + match s { + egui::Shape::Text(t) => out.push((t.pos.y, t.galley.text().to_string())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v.into_iter().map(|(_, t)| t).collect() + } + + #[test] + fn groups_show_a_contents_row_and_type_a_characters_row_in_their_slot() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + let b = run(&mut app, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "object.group", json!({})); + run(&mut app, "appearance.addFill", json!({})); + run(&mut app, "appearance.addStroke", json!({})); + let rows = + |t: Vec| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::>(); + assert_eq!(rows(frame(&mut app)), ["Stroke:", "Fill:", "Contents"]); + run(&mut app, "appearance.moveItem", json!({"from": "contents", "to": 1})); + assert_eq!(rows(frame(&mut app)), ["Stroke:", "Contents", "Fill:"]); + let t = run(&mut app, "text.create", json!({"x": 0, "y": 60, "text": "Hi"}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [t]})); + assert_eq!(rows(frame(&mut app)), ["Characters"]); + run(&mut app, "appearance.addFill", json!({})); + assert_eq!(rows(frame(&mut app)), ["Fill:", "Characters"]); + } + + #[test] + fn drops_move_items_across_the_contents_row_and_the_row_itself() { + use vectorcraft_doc::FillLayer; + // A group [F0 | F1 F2]: the Contents row sits above item 0. + let mut n = Node::group(NodeId(1), vec![]); + n.appearance.items = (0..3).map(|_| AppearanceItem::Fill(FillLayer::new(Paint::None))).collect(); + n.appearance.set_contents_at(1); + let slots = [ + Slot::Object { top: true }, + Slot::Item { index: 2, sub: false }, + Slot::Item { index: 1, sub: false }, + Slot::Contents, + Slot::Item { index: 0, sub: false }, + Slot::Object { top: false }, + ]; + let rows: Vec<(Slot, Rect)> = + slots.iter().enumerate().map(|(i, s)| (*s, Rect::from_min_size(pos2(0.0, i as f32 * ROW), vec2(200.0, ROW)))).collect(); + let at = |i: usize| pos2(50.0, i as f32 * ROW + ROW / 2.0); + let drop = |what, i| drop_command(&n, &rows, what, at(i), false); + // Onto the Contents row: from above it lands just below, from below just above. + assert_eq!(drop(Dragged::Item(2), 3), Some(("appearance.moveItem", json!({"from": 2, "to": 1, "contents": 2})))); + assert_eq!(drop(Dragged::Item(0), 3), Some(("appearance.moveItem", json!({"from": 0, "to": 0, "contents": 0})))); + // Onto the object row: above everything; under the stack: below everything. + assert_eq!(drop(Dragged::Item(0), 0), Some(("appearance.moveItem", json!({"from": 0, "to": 2, "contents": 0})))); + assert_eq!(drop(Dragged::Item(2), 5), Some(("appearance.moveItem", json!({"from": 2, "to": 0, "contents": 2})))); + // The row itself: above item 2, below item 0; nowhere new on itself. + assert_eq!(drop(Dragged::Contents, 1), Some(("appearance.moveItem", json!({"from": "contents", "to": 3})))); + assert_eq!(drop(Dragged::Contents, 4), Some(("appearance.moveItem", json!({"from": "contents", "to": 0})))); + assert_eq!(drop(Dragged::Contents, 3), None); + assert_eq!(drop(Dragged::Contents, 2), Some(("appearance.moveItem", json!({"from": "contents", "to": 2})))); + } } diff --git a/docs/mcp.md b/docs/mcp.md index cdbc5d6f..457983f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -283,3 +283,24 @@ patterns out. The Blend commands also grade gradient meshes, which keep their sh {"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}} {"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}} ``` + +## Group, layer and type appearance + +Groups and layers carry fills, strokes and effects of their own, as in the reference app: their fills and strokes +paint every member's geometry, and their effects apply to the members as one piece (one combined drop shadow; a +Transform or Warp moves or bends the whole group). Their Contents row (type: Characters) is a slot in the stack: +fills and strokes above it paint over the members (characters), those below under them; new items go above it. +`appearance.moveItem {"from": "contents", "to": n}` puts the row above the bottom `n` items, and an item move takes +`contents` to say where the row ends up in the same undo step; `document.node` shows the slot as +`appearance.contents_index`. The `appearance.*`, `effect.*` and `graphicStyle.apply` commands edit the selected +objects themselves (a group's own stack; layers through `ids`) or, with `target: "contents"`, the objects inside the +groups and layers. `appearance.targetContents` selects a group's members (double-clicking the Contents row). +SVG and PDF export bake a group's own fills, strokes and geometry effects into paths; its raster effects stay a +filter on the whole group. + +```json +{"name":"run_command","arguments":{"command":"appearance.addFill","params":{"ids":[7]}}} +{"name":"run_command","arguments":{"command":"appearance.moveItem","params":{"ids":[7],"from":"contents","to":1}}} +{"name":"run_command","arguments":{"command":"effect.apply","params":{"ids":[2],"effect":"stylize.dropShadow"}}} +{"name":"run_command","arguments":{"command":"appearance.addStroke","params":{"ids":[7],"target":"contents"}}} +```