M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows

A group's or layer's own fills and strokes paint every member's geometry, below or above the
members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape
the members as one piece and its raster effects apply to the composite (one combined shadow),
respecting knockout groups. Type's own fills and strokes paint under or over its characters.
Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike.

Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids;
appearance.moveItem moves the Contents slot (from: "contents") or an item across it
(contents); appearance.targetContents selects a group's members. Graphic styles applied to a
group now style the group itself. The Appearance panel shows Contents/Characters rows that
drag, and double-click to target the members or edit the characters' paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 00:33:01 +07:00
parent 25c8f32fdd
commit 8a837fd4b9
35 changed files with 1282 additions and 245 deletions

View File

@ -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}; use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter};
fn fill(c: Color) -> Appearance { 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 { fn path(p: PathData, c: Color) -> Node {

View File

@ -432,7 +432,7 @@ pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
/// A filled path node painted with `paint` (brush output). /// A filled path node painted with `paint` (brush output).
pub(crate) fn filled(path: vectorcraft_geom::PathData, paint: &Paint) -> Node { 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). /// A sample stroke for previews (a gentle S-curve in a `w`×`h` box).

View File

@ -375,12 +375,17 @@ pub struct Appearance {
pub items: Vec<AppearanceItem>, pub items: Vec<AppearanceItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub effects: Vec<Effect>, pub effects: Vec<Effect>,
/// 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<usize>,
} }
impl Appearance { impl Appearance {
/// Illustrator's "basic appearance": one fill below one stroke. /// Illustrator's "basic appearance": one fill below one stroke.
pub fn basic(fill: Paint, stroke: Paint, width: f64) -> Self { 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). /// White fill, 1 pt black stroke (the default for new art).
pub fn default_art() -> Self { pub fn default_art() -> Self {
@ -410,7 +415,7 @@ impl Appearance {
pub fn set_fill(&mut self, p: Paint) { pub fn set_fill(&mut self, p: Paint) {
match self.fill_mut() { match self.fill_mut() {
Some(f) => f.paint = p, 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. /// 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 { pub fn is_basic(&self) -> bool {
let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count(); let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count();
self.effects.is_empty() self.effects.is_empty()
&& self.contents_index.is_none()
&& count(true) <= 1 && count(true) <= 1
&& count(false) <= 1 && count(false) <= 1
&& self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal) && 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<AppearanceItem> {
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<usize> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -744,7 +803,7 @@ mod tests {
#[test] #[test]
fn basic_means_one_plain_fill_and_stroke() { fn basic_means_one_plain_fill_and_stroke() {
let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); 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"); assert!(!a.is_basic(), "two strokes");
a.items.pop(); a.items.pop();
assert!(a.is_basic() && Appearance::default().is_basic()); assert!(a.is_basic() && Appearance::default().is_basic());
@ -831,4 +890,40 @@ mod tests {
a.stroke_mut().unwrap().align = StrokeAlign::Inside; a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 0.0); 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::<Appearance>(&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"));
}
} }

View File

@ -456,7 +456,9 @@ impl Node {
children.first().and_then(|c| c.geometric_bounds()) children.first().and_then(|c| c.geometric_bounds())
} }
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { 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 } => { NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())); 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)? /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool { fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs(); let [m0, m1, m2, m3, _, _] = a.as_coeffs();

View File

@ -109,7 +109,7 @@ impl CharStyle {
pub fn appearance(&self) -> crate::Appearance { pub fn appearance(&self) -> crate::Appearance {
let mut fill = crate::FillLayer::new(self.fill.clone()); let mut fill = crate::FillLayer::new(self.fill.clone());
fill.overprint = self.overprint_fill; 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 { if !self.stroke.is_none() && self.stroke_width > 0.0 {
let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width); let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width);
st.overprint = self.overprint_stroke; st.overprint = self.overprint_stroke;

View File

@ -3,17 +3,17 @@
//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a //! 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 //! 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 //! 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, //! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). //! 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 std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_geom::{FillRule, PathData};
use crate::{ use crate::group::has_own_paint;
GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape, 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] { fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item { match item {
@ -32,6 +32,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking? /// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool { pub fn needs_bake(n: &Node) -> bool {
has_pathfinder(n) has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects) || has_geometry(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) || 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<Node> {
let m = reshape(n, symbol.as_deref())?; let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m)); return Some(bake_node(d, &m).unwrap_or(m));
} }
if let Some(result) = pathfinder_children(n, None) { // Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
let mut m = n.clone(); // pieces (results, outlines, the fills' and strokes' art) get ids of their own.
m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if let Some(mut m) = evaluate_container(n) {
let children = result let mut seen = std::collections::HashSet::from([m.id]);
let children = m.children().cloned().unwrap_or_default();
let children = children
.into_iter() .into_iter()
.map(|c| { .map(|c| {
let mut c = Arc::unwrap_or_clone(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)) Arc::new(bake_node(d, &c).unwrap_or(c))
}) })
.collect(); .collect();
@ -111,6 +114,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
bake_leaf(d, n) 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<vectorcraft_doc::NodeId>) {
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<Node> { fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (base, rule) = geometry(n)?; let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n); let ctx = GeomContext::of(n);
@ -136,7 +151,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
Arc::new(Node { Arc::new(Node {
id, id,
name: None, name: None,
appearance: Appearance { items: vec![it], effects: vec![] }, appearance: Appearance { items: vec![it], ..Default::default() },
opacity: 1.0, opacity: 1.0,
blend: Default::default(), blend: Default::default(),
isolate: false, isolate: false,

View File

@ -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, //! **Effect → Pathfinder**: live Pathfinder operations on a group (or layer). The group's direct
//! Subtract, Intersect, Exclude, Minus Back) a child group acts as one shape — the union of its //! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect,
//! leaves, styled like its top leaf — as in the Pathfinder panel. The result replaces the children //! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its
//! at render time; each result keeps the appearance of the object whose paint it takes. //! 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 std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, StrokeLayer}; use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp}; use vectorcraft_pathops::{self as po, PathfinderOp};
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order. /// 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). /// A result path styled like `src` (its appearance, opacity and blend mode).
fn styled(src: &Node, path: PathData, outline: bool) -> Node { 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 { if outline {
let fill = src.appearance.fill_paint(); let fill = src.appearance.fill_paint();
let paint = if fill.is_none() { src.appearance.stroke_paint() } else { fill }; 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); let mut n = Node::path(src.id, path, appearance);
n.opacity = src.opacity; n.opacity = src.opacity;
@ -130,6 +136,145 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
Some(stack) Some(stack)
} }
fn is_container(n: &Node) -> 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<Rect>) -> Option<Node> {
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<Node> {
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<Node> = &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<Arc<Node>> { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -14,6 +14,8 @@
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. //! 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 //! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines. //! 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 //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers. //! PDF writers.
//! //!
@ -39,9 +41,11 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; 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 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 raster::{RasterFx, outset, raster_effects};
pub use reshape::{needs_outline, outline_art, outline_text, reshape}; pub use reshape::{needs_outline, outline_art, outline_text, reshape};
pub use warp::{WarpStyle, warp_point}; pub use warp::{WarpStyle, warp_point};

View File

@ -9,7 +9,7 @@
use std::sync::Arc; 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 vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params}; 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, /// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
/// then (when the object has its own fills/strokes) the whole outline with the object's stack, pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
/// painted over the characters as the renderer does.
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new(); let mut all = kurbo::BezPath::new();
@ -43,13 +40,22 @@ pub fn outline_text(n: &Node) -> Option<Node> {
} }
all.extend(g.outline.iter()); 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<Node> {
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 path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
let mut children: Vec<Arc<Node>> = runs let mut children: Vec<Arc<Node>> = runs
.iter() .iter()
.zip(&t.runs) .zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty()) .filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| { .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 { 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))); 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<Node> {
}) })
.collect(); .collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() { if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] }; let (below, above) = n.appearance.split_contents();
children.push(Arc::new(Node::path(n.id, path(&all), ap))); 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)) 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` /// 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`). /// 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<Node> { pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
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) { if fx.is_empty() || !needs_outline(n) {
return None; return None;
} }
@ -123,16 +135,7 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
} }
_ => outline_art(n, symbol_art)?, _ => outline_art(n, symbol_art)?,
}; };
for e in fx { reshape_leaves(&mut art, &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, &params, &path, bounds, &GeomContext::of(leaf));
set_geometry(leaf, out, rule);
});
}
// The art is a group with `n`'s id (a clip group for an image). // The art is a group with `n`'s id (a clip group for an image).
let mut out = art; let mut out = art;
out.name = n.name.clone(); out.name = n.name.clone();
@ -146,6 +149,34 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
Some(out) 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, &params, &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<Node> = &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). /// 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)) { fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
match &mut n.kind { 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 { match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => { NodeKind::Compound { children, rule } => {

View File

@ -8,6 +8,14 @@
//! current selection and no `ids` are given, else the topmost fill or stroke; `null` always means //! 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, //! 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. //! 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 serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_color::{BlendMode, Paint};
@ -26,8 +34,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill", "Add New Fill",
["Window", "Appearance"], ["Window", "Appearance"],
None, None,
"{ids?} add a fill on top of each selected object's own stack", "{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_selection, has_doc,
|s, p| add_item(s, p, true) |s, p| add_item(s, p, true)
), ),
cmd!( cmd!(
@ -35,8 +43,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke", "Add New Stroke",
["Window", "Appearance"], ["Window", "Appearance"],
None, None,
"{ids?} add a stroke on top of each selected object's own stack", "{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_selection, has_doc,
|s, p| add_item(s, p, false) |s, p| add_item(s, p, false)
), ),
cmd!( cmd!(
@ -44,8 +52,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance", "Clear Appearance",
["Window", "Appearance"], ["Window", "Appearance"],
None, None,
"{ids?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode", "{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_selection, has_doc,
clear_appearance clear_appearance
), ),
cmd!( cmd!(
@ -53,8 +61,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance", "Reduce to Basic Appearance",
["Window", "Appearance"], ["Window", "Appearance"],
None, 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", "{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_selection, has_doc,
reduce_basic reduce_basic
), ),
cmd!( cmd!(
@ -62,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item", "Appearance Item",
[], [],
None, 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, has_doc,
set_item set_item
), ),
@ -71,8 +79,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item", "Remove Item",
[], [],
None, None,
"{index | indices: [..] (paint-order item indices), ids?} remove those fills/strokes from each target object's own stack", "{index | indices: [..] (paint-order item indices), ids?, target?} remove those fills/strokes from each target object's own stack",
has_selection, has_doc,
remove_item remove_item
), ),
cmd!( cmd!(
@ -80,8 +88,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Effect", "Add Effect",
[], [],
None, None,
"same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?} → {ids, index, item}", "same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
has_selection, has_doc,
super::effectcmd::apply super::effectcmd::apply
), ),
cmd!( cmd!(
@ -89,11 +97,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item", "Duplicate Item",
["Window", "Appearance"], ["Window", "Appearance"],
None, 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", "{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_selection, has_doc,
duplicate_item 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!( cmd!(
"appearance.copyFrom", "appearance.copyFrom",
"Eyedropper", "Eyedropper",
@ -117,10 +133,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All Hidden Attributes", "Show All Hidden Attributes",
["Window", "Appearance"], ["Window", "Appearance"],
None, 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}", "{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_selection, has_doc,
show_all_hidden 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() .as_u64()
.map(|i| ItemTarget::Item { index: i as usize, explicit: true }) .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"))), .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 })), 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 /// Whether the `target` param asks for the contents of groups and layers (see the module docs).
/// (a selected compound-path member stands for its compound). Layers are skipped. fn targets_contents(p: &Value) -> Result<bool> {
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> { match p.get("target") {
if p.get("ids").is_none() && p.get("id").is_none() { None | Some(Value::Null) => Ok(false),
return selected_roots(s); 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<Vec<NodeId>> {
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<Vec<NodeId>> {
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 /// 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<Value> {
} }
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> { fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
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, _| { s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { 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 { let item = if fill {
AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint())) AppearanceItem::Fill(FillLayer::new(paint))
} else { } 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); n.appearance.items.push(item);
} }
@ -323,7 +387,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
} }
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> { fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = appearance_targets(s, p)?; let ids = require_targets(s, p, "appearance.clear")?;
s.edit("Clear Appearance", |d, _| { s.edit("Clear Appearance", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
@ -340,12 +404,13 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
ok() ok()
} }
/// A group or layer (no fill or stroke rows of its own unless added).
fn is_group(n: &Node) -> bool { 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<Value> { fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = appearance_targets(s, p)?; let ids = require_targets(s, p, "appearance.reduceToBasic")?;
s.edit("Reduce to Basic Appearance", |d, _| { s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
@ -356,7 +421,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
// keeps the rows it had. // keeps the rows it had.
let fill = let fill =
(fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone())))); (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 { if let Some(AppearanceItem::Stroke(mut st)) = stroke {
st.effects.clear(); st.effects.clear();
st.opacity = 1.0; st.opacity = 1.0;
@ -433,16 +498,13 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> { fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = item_indices(p, "appearance.removeItem")?; 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, _| { s.edit("Remove Item", |d, _| {
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
let items = &mut n.appearance.items;
// From the top down, so the lower indices stay valid. // From the top down, so the lower indices stay valid.
for &i in idx.iter().rev() { for &i in idx.iter().rev() {
if i < items.len() { n.appearance.remove_item(i);
items.remove(i);
}
} }
} }
} }
@ -459,20 +521,20 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
if to.is_some() && idx.len() > 1 { if to.is_some() && idx.len() > 1 {
return Err(bad(C, "`to` takes a single `index`")); 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). // Where the first object's copy landed (the active item lives in the first object's stack).
let mut first_at = None; let mut first_at = None;
s.edit("Duplicate Item", |d, _| { s.edit("Duplicate Item", |d, _| {
let mut any = false; let mut any = false;
for id in &ids { for id in &ids {
let Some(n) = d.node_mut(*id) else { continue }; 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. // From the top down, so the lower indices stay valid.
for &i in idx.iter().rev() { for &i in idx.iter().rev() {
if let Some(item) = items.get(i).cloned() { if let Some(item) = ap.items.get(i).cloned() {
let at = to.map_or(i + 1, |t| t.min(items.len())); let at = to.map_or(i + 1, |t| t.min(ap.items.len()));
first_at.get_or_insert(at); first_at.get_or_insert(at);
items.insert(at, item); ap.insert_item(at, item);
any = true; any = true;
} }
} }
@ -490,7 +552,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
} }
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> { fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?; let roots = require_targets(s, p, "appearance.showAllHidden")?;
let ids = s.edit("Show All Hidden Attributes", |d, _| { let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect(); let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
if shown.is_empty() { if shown.is_empty() {
@ -503,20 +565,21 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
fn move_item(s: &mut Session, p: &Value) -> Result<Value> { fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem"; const C: &str = "appearance.moveItem";
let from = index_param(p, "from", C)?;
let to = index_param(p, "to", 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; let mut landed = to;
s.edit("Reorder Appearance", |d, _| { s.edit("Reorder Appearance", |d, _| {
for id in &ids { for id in &ids {
let Some(n) = d.node_mut(*id) else { continue }; let Some(n) = d.node_mut(*id) else { continue };
let items = &mut n.appearance.items; landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
if from >= items.len() { if let Some(k) = contents.filter(|_| n.contents_label().is_some()) {
return Err(bad(C, format!("no item at index {from}"))); n.appearance.set_contents_at(k);
} }
let it = items.remove(from);
landed = to.min(items.len());
items.insert(landed, it);
} }
Ok(()) Ok(())
})?; })?;
@ -532,7 +595,38 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
a 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<Value> {
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<Value> {
let roots = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = 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::<Vec<_>>() }))
} }
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> { fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -198,11 +198,11 @@ fn finite(p: Point) -> bool {
} }
fn fill_only(paint: Paint) -> Appearance { 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 { 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). /// A point strictly inside a filled path (for re-finding faces after a rebuild).

View File

@ -1,7 +1,9 @@
//! Effect menu commands (live effects) and Object → Expand Appearance. //! Effect menu commands (live effects) and Object → Expand Appearance.
//! //!
//! Effects are stored on the object's appearance (`appearance.effects`) and evaluated at render //! 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; use std::sync::Arc;
@ -20,8 +22,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Effect", "Apply Effect",
[], [],
None, 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}", "{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_selection, has_doc,
apply apply
), ),
cmd!( cmd!(
@ -39,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
[], [],
None, 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}", "{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 remove
), ),
cmd!( cmd!(
@ -48,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
[], [],
None, None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, item?: appearance item index|null (as effect.remove), ids?: [..]} → {ids}", "{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 set_params
), ),
cmd!( cmd!(
@ -56,8 +58,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand Appearance", "Expand Appearance",
["Object"], ["Object"],
None, None,
"{ids?: [..]} bake geometry effects into the paths and drop them (raster effects stay live) → {ids}", "{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_selection, has_doc,
expand_appearance expand_appearance
), ),
cmd!( cmd!(
@ -66,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
[], [],
None, None,
"{index: int, item?: appearance item index|null (as effect.remove), ids?: [..]} insert a copy of the effect right after it → {ids}", "{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 duplicate
), ),
cmd!( cmd!(
@ -75,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
[], [],
None, 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}", "{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 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<NodeId>) { fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return }; let Some(n) = d.node(id).cloned() else { return };
// Effect → Pathfinder: the group's members become the Pathfinder result. if matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
if let Some(result) = effects::pathfinder_children(&n, None) { if let Some(mut m) = effects::evaluate_container(&n) {
let children = result let mut seen = std::collections::HashSet::from([m.id]);
.into_iter() for c in m.children_mut().into_iter().flatten() {
.map(|c| { effects::fresh_ids(d, Arc::make_mut(c), &mut seen);
let mut c = Arc::unwrap_or_clone(c); }
c.id = d.alloc_id(); let Some(slot) = d.node_mut(id) else { return };
Arc::new(c) *slot = m;
}) out.push(id);
.collect();
let Some(m) = d.node_mut(id) else { return };
if let Some(ch) = m.children_mut() {
*ch = children;
} }
m.appearance.effects.retain(|e| !effects::is_pathfinder(&e.id)); let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
out.push(id); for c in children {
return;
}
if let Some(children) = n.children()
&& matches!(n.kind, NodeKind::Group { .. })
{
for c in children.iter().map(|c| c.id).collect::<Vec<_>>() {
expand_node(d, c, out); expand_node(d, c, out);
} }
return; return;

View File

@ -363,7 +363,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
// Outline: open edges stroked with the face's former fill. // Outline: open edges stroked with the face's former fill.
let fill = st.appearance.fill_paint(); let fill = st.appearance.fill_paint();
let paint = if fill.is_none() { st.appearance.stroke_paint() } else { fill }; 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)); children.push(Arc::new(n));
} }
@ -483,7 +483,7 @@ fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path:
overprint: st.overprint, overprint: st.overprint,
..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()) ..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) Some(n)
} }
@ -499,7 +499,7 @@ fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &N
let mut fills: Vec<AppearanceItem> = vec![]; let mut fills: Vec<AppearanceItem> = vec![];
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| { let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
let mut f = d.reid(l); let mut f = d.reid(l);
f.appearance = Appearance { items, effects: vec![] }; f.appearance = Appearance { items, ..Default::default() };
f.opacity = 1.0; f.opacity = 1.0;
f.blend = BlendMode::Normal; f.blend = BlendMode::Normal;
f.name = None; f.name = None;

View File

@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style", "Apply Graphic Style",
["Window", "Graphic Styles"], ["Window", "Graphic Styles"],
None, 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, has_doc,
style_apply 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). /// Does `n` still have style `g`'s look (its transparency, and the style's appearance as its own;
fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) { /// a group or layer with no appearance of its own: on every painted object inside it, the look a
if !descends(n, top) { /// style made from it captures)? Linked objects that don't are no longer linked.
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.
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool { 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![]; let mut leaves = vec![];
painted(n, true, &mut leaves); 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). /// The style `n` is linked to (and still looks like).
@ -214,10 +211,10 @@ fn captured(n: &Node) -> Appearance {
ap 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) { fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
if let Some(n) = d.node_mut(id) { 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); g.apply_transparency(n);
n.graphic_style = Some(g.id); n.graphic_style = Some(g.id);
} }
@ -270,11 +267,9 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let g = d.graphic_styles[i].clone(); let g = d.graphic_styles[i].clone();
for id in &ids { for id in &ids {
if let Some(n) = d.node_mut(*id) { if let Some(n) = d.node_mut(*id) {
paint_mut(n, true, &mut |l| { let ap = g.appearance_on(n).into_owned();
let ap = g.appearance_on(l).into_owned(); n.appearance.items.extend(ap.items);
l.appearance.items.extend(ap.items); n.appearance.effects.extend(ap.effects);
l.appearance.effects.extend(ap.effects);
});
n.graphic_style = None; n.graphic_style = None;
} }
} }

View File

@ -752,6 +752,8 @@ mod tests_colorguide;
#[cfg(test)] #[cfg(test)]
mod tests_colormgmt; mod tests_colormgmt;
#[cfg(test)] #[cfg(test)]
mod tests_containers;
#[cfg(test)]
mod tests_dashalign; mod tests_dashalign;
#[cfg(test)] #[cfg(test)]
mod tests_distort; mod tests_distort;

View File

@ -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);
}

View File

@ -99,12 +99,18 @@ fn a_style_from_a_group_or_type_is_not_empty() {
run(&mut s, "graphicStyle.new", json!({"name": "Type Look"})); run(&mut s, "graphicStyle.new", json!({"name": "Type Look"}));
assert!(!style(&s, "Type Look").appearance.fill_paint().is_none()); 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 c = rect(&mut s, 200.0);
let before = node(&s, a).appearance;
run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]})); 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!(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); 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] #[test]

View File

@ -678,6 +678,18 @@ impl Exporter<'_> {
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let tb = t.xf.transform_rect_bbox(layout.bounds); 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)); s.push_transform(&xf(t.xf));
for (i, run) in t.runs.iter().enumerate() { for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new(); let mut bp = BezPath::new();
@ -701,34 +713,36 @@ impl Exporter<'_> {
s.set_fill(None); s.set_fill(None);
s.set_stroke(None); s.set_stroke(None);
s.pop(); s.pop();
if !n.appearance.items.is_empty() { if let Some(path) = &all {
let mut all = layout.to_bezpath(); self.text_items(s, above, path, tb);
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);
} }
} }
/// 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<Image> { fn load_image(&mut self, key: &str) -> Option<Image> {
if let Some(i) = self.images.get(key) { if let Some(i) = self.images.get(key) {
return i.clone(); return i.clone();

View File

@ -461,7 +461,7 @@ impl<'a> Device<'a> for Builder {
PathDrawMode::Fill(rule) => { PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint); let mut f = FillLayer::new(paint);
f.opacity = opacity; 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 { if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = fill_rule(*rule); *r = fill_rule(*rule);
} }
@ -480,7 +480,7 @@ impl<'a> Device<'a> for Builder {
Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st)); Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st));
return; 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); self.push_node(n);
} }
} }

View File

@ -37,7 +37,7 @@ fn fitted_dashes_on_a_rectangle_look_as_on_the_canvas() {
let mut n = Node::path( let mut n = Node::path(
NodeId(0), NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.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(); n.id = d.alloc_id();
let layer = d.default_layer().unwrap(); let layer = d.default_layer().unwrap();

View File

@ -13,7 +13,7 @@ pub(crate) fn line_doc(st: StrokeLayer) -> (Document, Node) {
let mut n = Node::path( let mut n = Node::path(
NodeId(0), NodeId(0),
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.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(); n.id = d.alloc_id();
let layer = d.default_layer().unwrap(); let layer = d.default_layer().unwrap();

View File

@ -56,7 +56,7 @@ fn outside_strokes_keep_their_miter_spikes() {
s.join = LineJoin::Miter; s.join = LineJoin::Miter;
s.miter_limit = 10.0; 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(); n.id = d.alloc_id();
let layer = d.default_layer().unwrap(); let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap(); d.insert(Some(layer), 0, n).unwrap();

View File

@ -15,7 +15,10 @@
//! //!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to //! 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 //! 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; 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, /// 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 { 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. /// What [`Renderer::raster_fx`] paints effects around.
@ -127,6 +136,18 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
/// the subtree (a shadow can reach the view while its object is outside it). /// the subtree (a shadow can reach the view while its object is outside it).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> { pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
match &n.kind { 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 } => { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) 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`): /// 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 /// 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<Node>, cache: bool) -> Arc<Node> { fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize; let key = Arc::as_ptr(a) as usize;
if cache if cache
@ -390,8 +412,10 @@ impl Renderer {
} }
_ => None, _ => None,
}; };
let container = is_container(a);
let art = match effects::reshape(a, symbol.as_ref()) { let art = match effects::reshape(a, symbol.as_ref()) {
Some(r) => r, 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 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![])), 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, opacity: 1.0,
blend: Default::default(), blend: Default::default(),
isolate: false, isolate: false,
// One element: its pieces never knock each other out. // One element: its pieces never knock each other out (a group's members still do as
knockout: vectorcraft_doc::Knockout::Off, // its knockout setting says).
knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off },
mask: None, mask: None,
appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance }, appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance },
..art ..art

View File

@ -456,7 +456,7 @@ impl Renderer {
return e.bounds; return e.bounds;
} }
let b = match &a.kind { 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<Rect> = None; let mut acc: Option<Rect> = None;
for c in children { for c in children {
if c.visible { if c.visible {
@ -920,7 +920,17 @@ impl Renderer {
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() }); 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() { for (i, run) in t.runs.iter().enumerate() {
let Some(path) = g.runs.get(i) else { continue }; let Some(path) = g.runs.get(i) else { continue };
if path.elements().is_empty() { if path.elements().is_empty() {
@ -943,31 +953,34 @@ impl Renderer {
} }
} }
overprint(ctx, false); overprint(ctx, false);
if !n.appearance.items.is_empty() { if let Some(all) = &all {
let mut all = g.all.clone(); self.draw_text_items(ctx, f, above, all, tb);
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; /// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document
if layered { /// space; `tb` their bounds), each with its own opacity and blend mode.
ctx.set_transform(Affine::IDENTITY); fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); 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); AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => {
match item { ctx.set_stroke(kurbo::Stroke::new(st.width));
AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { ctx.stroke_path(all);
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();
} }
_ => {}
}
if layered {
ctx.pop_layer();
} }
} }
} }
@ -1141,6 +1154,8 @@ mod tests_blend;
#[cfg(test)] #[cfg(test)]
mod tests_clip; mod tests_clip;
#[cfg(test)] #[cfg(test)]
mod tests_container;
#[cfg(test)]
mod tests_isolation; mod tests_isolation;
#[cfg(test)] #[cfg(test)]
mod tests_knockout; mod tests_knockout;

View File

@ -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<Node> {
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");
}

View File

@ -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); let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st); f(&mut st);
let mut b = DocBuilder::new(160.0, 120.0); 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() b.build()
} }

View File

@ -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); let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
st.cap = LineCap::Butt; st.cap = LineCap::Butt;
f(&mut st); f(&mut st);
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] } Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }
} }
// ---------------------------------------------------------------- gradients // ---------------------------------------------------------------- gradients

View File

@ -15,7 +15,7 @@ const S: f64 = 4.0;
fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance { fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st); 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. /// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard.

View File

@ -357,7 +357,7 @@ impl Importer {
usvg::PaintOrder::FillAndStroke => [fill, stroke], usvg::PaintOrder::FillAndStroke => [fill, stroke],
usvg::PaintOrder::StrokeAndFill => [stroke, fill], 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<Node> { fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option<Node> {

View File

@ -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<Effect>) -> 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("<filter ").count(), 1, "{svg}");
let open = svg.find("<g filter=\"url(#").expect("a filter group");
assert!(svg[open..].contains("#ff0000") && svg[open..].contains("#0000ff") && svg[open..].contains("#00ff00"), "{svg}");
// The members moved down by 100 pt.
let doc = vectorcraft_effects::bake_document(&doc(false, vec![fx("distort.transform", json!({"moveV": 100}))])).unwrap();
let g = &doc.layers[0].children().unwrap()[0];
let b = g.geometric_bounds().unwrap();
assert!((b.y0 - 120.0).abs() < 1e-6, "{b:?}");
// Every baked piece has an id of its own and the group has no fills or effects left.
let mut ids = std::collections::HashSet::new();
g.walk(&mut |n| assert!(ids.insert(n.id), "duplicate id {}", n.id));
assert!(g.appearance.items.is_empty() && g.appearance.effects.is_empty());
}

View File

@ -14,7 +14,7 @@ fn rect_doc(align_corners: bool) -> Document {
let n = Node::path( let n = Node::path(
d.alloc_id(), d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 70.0)), 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; let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap(); d.insert(Some(l), 0, n).unwrap();

View File

@ -192,7 +192,7 @@ fn stroke_attributes_roundtrip() {
s.cap = LineCap::Round; s.cap = LineCap::Round;
s.join = LineJoin::Bevel; s.join = LineJoin::Bevel;
s.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.5, align_corners: false }); 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 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 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]); let d = doc_with(vec![n]);
@ -213,7 +213,7 @@ fn stroke_attributes_roundtrip() {
let n = Node::path( let n = Node::path(
NodeId(99), NodeId(99),
shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), 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])); let r = roundtrip(&doc_with(vec![n]));
assert!((art(&r)[0].appearance.stroke().unwrap().miter_limit - 7.0).abs() < 1e-6); 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::Fill(FillLayer::new(solid("#00ff00"))),
AppearanceItem::Stroke(StrokeLayer::new(solid("#0000ff"), 2.0)), 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 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()); let s = export(&doc_with(vec![n]), &ExportOptions::default());

View File

@ -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)) { 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); let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 4.0);
f(&mut st); 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; let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap(); d.insert(Some(l), usize::MAX, n).unwrap();
} }

View File

@ -2,10 +2,14 @@
//! the appearance onto art), each stroke/fill row (eye, disclosure, link label, swatch, weight, //! 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 //! 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. //! 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 //! Rows drag: a fill/stroke row reorders the stack (across the Contents row too), the Contents row
//! another item (dropped on its row) or onto the object; Alt copies instead. Cmd-click and //! moves between the fills and strokes, an effect row reorders its list or moves into another item
//! Shift-click select several fills/strokes for Duplicate and Delete. //! (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; use std::sync::OnceLock;
@ -35,6 +39,8 @@ pub enum Sel {
Effect(usize), Effect(usize),
/// Effect `k` of fill/stroke item `i`. /// Effect `k` of fill/stroke item `i`.
ItemEffect(usize, usize), ItemEffect(usize, usize),
/// The Contents (type: Characters) row: paint edits go to the members (characters).
Contents,
} }
impl Sel { impl Sel {
@ -42,7 +48,7 @@ impl Sel {
fn item(self) -> Option<usize> { fn item(self) -> Option<usize> {
match self { match self {
Sel::Item(i) | Sel::ItemEffect(i, _) => Some(i), 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). /// `{item, index}` of a selected effect row (`item: null` for the object's own effects).
@ -50,9 +56,13 @@ impl Sel {
match self { match self {
Sel::Effect(k) => Some(json!({"item": null, "index": k})), Sel::Effect(k) => Some(json!({"item": null, "index": k})),
Sel::ItemEffect(i, k) => Some(json!({"item": i, "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 /// 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::ItemEffect(j, k)) if i == j && exists(Some(i), k) => sel,
(Some(i), _) => Sel::Item(i), (Some(i), _) => Sel::Item(i),
(None, Sel::Effect(k)) if exists(None, k) => sel, (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, (None, _) => Sel::None,
} }
} }
@ -301,6 +312,8 @@ fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) {
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Dragged { enum Dragged {
Item(usize), Item(usize),
/// The Contents (Characters) row.
Contents,
/// Effect `k` of fill/stroke `item` (`None`: of the object). /// Effect `k` of fill/stroke `item` (`None`: of the object).
Effect(Option<usize>, usize), Effect(Option<usize>, usize),
} }
@ -314,6 +327,8 @@ enum Slot {
Effect(Option<usize>, usize), Effect(Option<usize>, usize),
/// The object row (`top`) or the object's Opacity row. /// The object row (`top`) or the object's Opacity row.
Object { top: bool }, Object { top: bool },
/// The Contents (Characters) row.
Contents,
} }
fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: Rect) { 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(); 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() { 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 open: bool = pstate(ui.ctx(), &format!("ap-open-{i}"));
let (r, resp) = row(ui, items.contains(&i)); let (r, resp) = row(ui, items.contains(&i));
rows.push((Slot::Item { index: i, sub: false }, r)); 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. // Object-level effects.
for (k, e) in n.appearance.effects.iter().enumerate() { for (k, e) in n.appearance.effects.iter().enumerate() {
let (r, resp) = effect_row(app, ui, None, k, e, sel == Sel::Effect(k)); 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(..), _) => { (Dragged::Effect(..), _) => {
ui.painter().rect_stroke(r.shrink(1.0), 0.0, stroke, StrokeKind::Inside); 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::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()) { 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; /// 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 { fn item_at(n: &Node, slot: Slot) -> usize {
match slot { match slot {
Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index, Slot::Item { index, .. } | Slot::Effect(Some(index), _) => index,
Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1), Slot::Object { top: true } => n.appearance.items.len().saturating_sub(1),
Slot::Object { top: false } | Slot::Effect(None, _) => 0, 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<usize> {
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 // The copy goes where the row was dropped: above that row when dragged up, below it
// when dragged down. // when dragged down.
let at = if to >= from { to + 1 } else { to }; let at = if to >= from { to + 1 } else { to };
Some(("appearance.duplicateItem", json!({"index": from, "to": at}))) return Some(("appearance.duplicateItem", json!({"index": from, "to": at})));
} else {
(to != from).then(|| ("appearance.moveItem", json!({"from": from, "to": to})))
} }
// 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) => { Dragged::Effect(from_item, k) => {
let (item, pos) = match slot { let (item, pos) = match slot {
Slot::Effect(item, k2) => (item, k2 + usize::from(p.y > r.center().y)), 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::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. // Within its own list the effect leaves its old place first.
let same = item == from_item && !copy; 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). /// 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) { fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
let t = Tokens::get(ui.ctx()); 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() { if widgets::icon_button_enabled(ui, "dc-clear", "Clear Appearance", false, has, 24.0).clicked() {
app.run("appearance.clear", json!({})).ok(); 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() { if widgets::icon_button_enabled(ui, "dc-new-item", "Duplicate Selected Item", false, can, 24.0).clicked() {
duplicate_selected(app, ui, node, sel); 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(); app.run("appearance.addStroke", json!({})).ok();
} }
ui.separator(); 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); 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); delete_selected(app, ui, node.as_ref(), sel);
} }
if menu_item(ui, "Clear Appearance", has, false) { 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), 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"); 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<String> {
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<String>| t.into_iter().filter(|t| ["Fill:", "Stroke:", "Contents", "Characters"].contains(&t.as_str())).collect::<Vec<_>>();
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}))));
}
} }

View File

@ -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":"recolor.apply","params":{"map":{"#ff0000":"#0055ff"},"includeImages":false}}}
{"name":"run_command","arguments":{"command":"edit.colors.adjustBalance","params":{"mode":"cmyk","m":-20,"k":10}}} {"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"}}}
```