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:59:34 +07:00
co-authored by Claude Opus 5.5
parent 25c8f32fdd
commit 8a837fd4b9
35 changed files with 1282 additions and 245 deletions
+26 -11
View File
@@ -3,17 +3,17 @@
//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a
//! copy of the document in which every geometry effect — object level, per fill/stroke and
//! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows,
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData};
use crate::{
GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape,
};
use crate::group::has_own_paint;
use crate::{GeomContext, apply_geometry_with, evaluate_container, has_geometry, has_pathfinder, is_geometry, needs_outline, reshape};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item {
@@ -32,6 +32,7 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
@@ -79,14 +80,16 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
if let Some(result) = pathfinder_children(n, None) {
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
let children = result
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
if let Some(mut m) = evaluate_container(n) {
let mut seen = std::collections::HashSet::from([m.id]);
let children = m.children().cloned().unwrap_or_default();
let children = children
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
c.id = d.alloc_id();
fresh_ids(d, &mut c, &mut seen);
Arc::new(bake_node(d, &c).unwrap_or(c))
})
.collect();
@@ -111,6 +114,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
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> {
let (base, rule) = geometry(n)?;
let ctx = GeomContext::of(n);
@@ -136,7 +151,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
Arc::new(Node {
id,
name: None,
appearance: Appearance { items: vec![it], effects: vec![] },
appearance: Appearance { items: vec![it], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,
+154 -9
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,
//! Subtract, Intersect, Exclude, Minus Back) a child group acts as one shape — the union of its
//! leaves, styled like its top leaf — as in the Pathfinder panel. The result replaces the children
//! at render time; each result keeps the appearance of the object whose paint it takes.
//! **Effect → Pathfinder**: live Pathfinder operations on a group (or layer). The group's direct
//! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect,
//! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its
//! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each
//! result keeps the appearance of the object whose paint it takes.
//!
//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes
//! paint every member's geometry, below or above the members as its contents slot says
//! ([`Appearance::contents_at`]); its geometry effects reshape the members as one piece; its raster
//! effects (one combined shadow) apply to the composite and stay for the renderer and exporters.
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
@@ -74,11 +80,11 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// A result path styled like `src` (its appearance, opacity and blend mode).
fn styled(src: &Node, path: PathData, outline: bool) -> Node {
let mut appearance = Appearance { items: src.appearance.items.clone(), effects: vec![] };
let mut appearance = Appearance { items: src.appearance.items.clone(), ..Default::default() };
if outline {
let fill = src.appearance.fill_paint();
let paint = if fill.is_none() { src.appearance.stroke_paint() } else { fill };
appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] };
appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() };
}
let mut n = Node::path(src.id, path, appearance);
n.opacity = src.opacity;
@@ -130,6 +136,145 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
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)]
mod tests {
use super::*;
+6 -2
View File
@@ -14,6 +14,8 @@
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines.
//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into
//! art painting its members (its raster effects then apply to the composite).
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers.
//!
@@ -39,9 +41,11 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use bake::{bake_document, needs_bake};
pub use bake::{bake_document, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children,
};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{needs_outline, outline_art, outline_text, reshape};
pub use warp::{WarpStyle, warp_point};
+52 -21
View File
@@ -9,7 +9,7 @@
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
@@ -29,11 +29,8 @@ pub fn needs_outline(n: &Node) -> bool {
)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// then (when the object has its own fills/strokes) the whole outline with the object's stack,
/// painted over the characters as the renderer does.
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
@@ -43,13 +40,22 @@ pub fn outline_text(n: &Node) -> Option<Node> {
}
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 mut children: Vec<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| {
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] };
let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], ..Default::default() };
if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 {
ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width)));
}
@@ -57,8 +63,14 @@ pub fn outline_text(n: &Node) -> Option<Node> {
})
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] };
children.push(Arc::new(Node::path(n.id, path(&all), ap)));
let (below, above) = n.appearance.split_contents();
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
if !below.is_empty() {
children.insert(0, glyphs(below));
}
if !above.is_empty() {
children.push(glyphs(above));
}
}
Some(Node::group(n.id, children))
}
@@ -107,7 +119,7 @@ fn outline_nested_text(n: &mut Node) {
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<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) {
return None;
}
@@ -123,16 +135,7 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
}
_ => outline_art(n, symbol_art)?,
};
for e in fx {
// Every leaf takes the effect with the whole art's bounds as its reference box.
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(&mut art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
set_geometry(leaf, out, rule);
});
}
reshape_leaves(&mut art, &fx);
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
@@ -146,6 +149,34 @@ pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
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).
fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
match &mut n.kind {
@@ -159,7 +190,7 @@ fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
}
}
fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {