mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
The issue asked for non-destructive colour adjustments in the Effect menu and the Appearance panel that work on vector paints, live type and embedded images. Only the destructive Edit Colors commands existed, and the live-effect pipeline only knew geometry and raster effects. Effect > Color Adjustments adds six effects (adjust.*). They recolour, in stack order, everything an object paints with: fills and strokes (gradient stops too), type, gradient meshes, embedded images and a group's members, each colour keeping its model. The Edit Colors walk over an object's colours moved to vectorcraft_doc::recolor so the commands and the live effects share it. The renderer draws adjusted art through its object-effect path (recoloured images cached within a memory budget), exports bake the adjusted colours (images as recoloured copies), and Expand Appearance makes them permanent. The effect dialog shows sliders, a channel menu and a draggable Curves graph, all previewed live as one undo step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
14 KiB
Rust
350 lines
14 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).
|
|
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
|
|
//! become images of their own).
|
|
//!
|
|
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
|
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
|
|
use vectorcraft_geom::{FillRule, PathData};
|
|
|
|
use crate::group::{has_own_paint, paints};
|
|
use crate::{
|
|
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, 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_adjustment(n)
|
|
|| has_crop_marks(n)
|
|
|| 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;
|
|
}
|
|
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
|
if has_adjustment(n)
|
|
&& let Some(m) = adjust_in_document(d, n)
|
|
{
|
|
return Some(bake_node(d, &m).unwrap_or(m));
|
|
}
|
|
// Crop marks: the object and its marks.
|
|
if let Some(m) = crop_marks_art(n) {
|
|
return Some(bake_pieces(d, m));
|
|
}
|
|
// 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(m) = evaluate_container(n) {
|
|
return Some(bake_pieces(d, 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)
|
|
}
|
|
|
|
/// `m`, art evaluated from an object, with its children baked and given ids of their own (the
|
|
/// pieces copied from the source keep the source's ids until then).
|
|
fn bake_pieces(d: &mut Document, mut m: Node) -> Node {
|
|
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;
|
|
}
|
|
m
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|
|
|
|
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
|
|
/// none of `n`'s transparency.
|
|
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
|
|
let id = d.alloc_id();
|
|
let kind = path_kind(d, path, rule);
|
|
Node {
|
|
id,
|
|
name: None,
|
|
appearance: Appearance { items: vec![item], ..Default::default() },
|
|
opacity: 1.0,
|
|
blend: Default::default(),
|
|
isolate: false,
|
|
knockout: Default::default(),
|
|
knockout_shape: false,
|
|
mask: None,
|
|
trace: None,
|
|
wrap: None,
|
|
graph: None,
|
|
// The group keeps the URL (one link around the pieces).
|
|
attrs: None,
|
|
kind,
|
|
..n.clone()
|
|
}
|
|
}
|
|
|
|
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
|
|
/// its effects, and `n` without those effects.
|
|
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, 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));
|
|
Some((g, rule, ctx, m))
|
|
}
|
|
|
|
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
|
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
|
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.
|
|
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);
|
|
Arc::new(piece(d, n, ig, rule, it))
|
|
})
|
|
.collect();
|
|
m.appearance.items.clear();
|
|
m.kind = NodeKind::Group { children, clip: false };
|
|
Some(m)
|
|
}
|
|
|
|
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
|
|
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
|
|
/// paint, or its brush art. `None` when it paints nothing.
|
|
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
|
|
|
|
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
|
|
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
|
|
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
|
|
/// its own), in paint order, as the members of a group that keeps
|
|
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
|
|
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
|
/// effects are dropped. `None` for other kinds of objects.
|
|
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
|
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
|
m.appearance.effects.retain(|e| e.visible);
|
|
let mut pieces = vec![];
|
|
for item in n.appearance.items.iter().filter(|i| paints(i)) {
|
|
let ig = apply(item.effects(), &g, &ctx.item(item));
|
|
let mut it = item.clone();
|
|
clear_item_effects(&mut it);
|
|
it.effects_mut().retain(|e| e.visible);
|
|
let p = match &it {
|
|
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
|
|
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
|
|
};
|
|
pieces.extend(p.map(hoist_transparency));
|
|
}
|
|
m.appearance.items.clear();
|
|
m.appearance.contents_index = None;
|
|
match pieces.as_slice() {
|
|
[one] if m.appearance.effects.is_empty() && one.has_default_transparency() && one.appearance.effects.is_empty() => {
|
|
m.kind = one.kind.clone();
|
|
m.appearance = one.appearance.clone();
|
|
}
|
|
_ => m.kind = NodeKind::Group { children: pieces.into_iter().map(Arc::new).collect(), clip: false },
|
|
}
|
|
Some(m)
|
|
}
|
|
|
|
/// `p` with the opacity and blend mode of its only fill or stroke as its own (the same look, and a
|
|
/// basic appearance).
|
|
fn hoist_transparency(mut p: Node) -> Node {
|
|
if p.has_default_transparency()
|
|
&& let [item] = p.appearance.items.as_mut_slice()
|
|
{
|
|
let (opacity, blend) = match item {
|
|
AppearanceItem::Fill(f) => (&mut f.opacity, &mut f.blend),
|
|
AppearanceItem::Stroke(s) => (&mut s.opacity, &mut s.blend),
|
|
};
|
|
p.opacity = std::mem::replace(opacity, 1.0);
|
|
p.blend = std::mem::take(blend);
|
|
}
|
|
p
|
|
}
|
|
|
|
/// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a
|
|
/// group's own fills and strokes or outlined type), in place.
|
|
pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
|
if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
|
if let Some(m) = expand_leaf(d, n, stroke_art) {
|
|
*n = m;
|
|
}
|
|
return;
|
|
}
|
|
for c in n.children_mut().into_iter().flatten() {
|
|
expand_art(d, Arc::make_mut(c), stroke_art);
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|