Files
vectorcraft/crates/effects/src/bake.rs
T
bflatasticandClaude Opus 5.5 8a837fd4b9 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>
2026-10-05 00:59:34 +07:00

240 lines
9.3 KiB
Rust

//! Baking live geometry effects for export.
//!
//! 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, 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::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 {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
fn clear_item_effects(item: &mut AppearanceItem) {
match item {
AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)),
AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)),
}
}
/// 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)))
}
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> PathData {
if !has_geometry(effects) || path.is_empty() {
return path.clone();
}
let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
apply_geometry_with(effects, path, b, ctx)
}
/// A path node of `kind`'s flavour (compound when the result has several subpaths).
fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
if path.subpaths.len() > 1 {
let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path, rule, live: None, clipping: false, guide: false }
}
}
/// `n` with its geometry effects evaluated (`None` = unchanged).
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
return None;
}
// Type, images, symbol instances and live objects: reshaped through their outlines.
if needs_outline(n) && has_geometry(&n.appearance.effects) {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// 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);
fresh_ids(d, &mut c, &mut seen);
Arc::new(bake_node(d, &c).unwrap_or(c))
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = children;
}
return Some(m);
}
if let Some(ch) = n.children() {
if matches!(n.kind, NodeKind::Compound { .. })
&& (has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))))
{
return bake_leaf(d, n);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
if let Some(ch) = m.children_mut() {
*ch = baked;
}
return Some(m);
}
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);
let g = apply(&n.appearance.effects, &base, &ctx);
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_geometry(&e.id));
if !n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) {
m.kind = path_kind(d, g, rule);
return Some(m);
}
// Per-item geometry: one path per fill/stroke, grouped under the object's transparency and
// raster effects (what Expand Appearance produces).
let children = n
.appearance
.items
.iter()
.map(|item| {
let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
let id = d.alloc_id();
let kind = path_kind(d, ig, rule);
Arc::new(Node {
id,
name: None,
appearance: Appearance { items: vec![it], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
trace: None,
wrap: None,
graph: None,
kind,
..n.clone()
})
})
.collect();
m.appearance.items.clear();
m.kind = NodeKind::Group { children, clip: false };
Some(m)
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
if !doc.layers.iter().any(|l| needs_bake(l)) {
return None;
}
let mut d = doc.clone();
let layers = doc.layers.clone();
d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
Some(d)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
fn fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
fn doc_with(n: Node) -> Document {
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
#[test]
fn plain_documents_are_not_copied() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()));
assert!(bake_document(&d).is_none());
}
#[test]
fn object_effects_become_geometry_and_raster_effects_stay() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0})));
n.appearance.effects.push(fx("stylize.dropShadow", json!({})));
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let b = m.geometric_bounds().unwrap();
assert!((b.width() - 120.0).abs() < 0.5, "{b:?}");
assert_eq!(m.appearance.effects.len(), 1);
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
}
#[test]
fn item_effects_split_into_one_path_per_item() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
}
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let ch = m.children().unwrap();
assert_eq!(ch.len(), 2);
assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6);
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
}
}