M9.3: Effect → Pathfinder (Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline) as live effects on groups/layers; rendered via the live cache, baked by Expand Appearance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 20:57:14 -04:00
parent e20fe0a200
commit 1c1fd0f139
8 changed files with 242 additions and 6 deletions

1
Cargo.lock generated
View File

@ -4087,6 +4087,7 @@ version = "0.1.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",

View File

@ -14,3 +14,6 @@ vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
vectorcraft-color = { workspace = true }

181
crates/effects/src/group.rs Normal file
View File

@ -0,0 +1,181 @@
//! 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.
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops::{self as po, PathfinderOp};
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
("pathfinder.intersect", "Intersect", PathfinderOp::Intersect),
("pathfinder.exclude", "Exclude", PathfinderOp::Exclude),
("pathfinder.subtract", "Subtract", PathfinderOp::MinusFront),
("pathfinder.minusBack", "Minus Back", PathfinderOp::MinusBack),
("pathfinder.divide", "Divide", PathfinderOp::Divide),
("pathfinder.trim", "Trim", PathfinderOp::Trim),
("pathfinder.merge", "Merge", PathfinderOp::Merge),
("pathfinder.crop", "Crop", PathfinderOp::Crop),
("pathfinder.outline", "Outline", PathfinderOp::Outline),
];
/// Is `id` an Effect → Pathfinder effect?
pub fn is_pathfinder(id: &str) -> bool {
id.starts_with("pathfinder.")
}
fn op_of(id: &str) -> Option<PathfinderOp> {
PATHFINDER_EFFECTS.iter().find(|e| e.0 == id).map(|e| e.2)
}
/// Does `n` (a group or layer) carry a visible Pathfinder effect?
pub fn has_pathfinder(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
}
/// Converts objects that aren't paths (e.g. text) to an outline path node, if possible.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children {
leaves(c, hook, out);
}
}
_ => {
if let Some(p) = hook.and_then(|h| h(n)) {
out.push(p);
}
}
}
}
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => 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,
}
}
/// 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![] };
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![] };
}
let mut n = Node::path(src.id, path, appearance);
n.opacity = src.opacity;
n.blend = src.blend;
n
}
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect.
pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
if !has_pathfinder(group) {
return None;
}
let mut stack: Vec<Arc<Node>> = group.children()?.to_vec();
for e in group.appearance.effects.iter().filter(|e| e.visible) {
let Some(op) = op_of(&e.id) else { continue };
let shape_mode =
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
// (style source, shape) per stack entry; keys index `sources`.
let mut sources: Vec<Node> = vec![];
let mut shapes: Vec<po::Shape> = vec![];
for c in &stack {
let mut lv = vec![];
leaves(c, hook, &mut lv);
if shape_mode && lv.len() > 1 {
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(geometry).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
shapes.push(po::Shape::new(po::unite_all(&refs), FillRule::NonZero, sources.len() as u64));
sources.push(lv.pop().expect("non-empty"));
} else {
for l in lv {
if let Some((p, r)) = geometry(&l) {
shapes.push(po::Shape::new(p, r, sources.len() as u64));
sources.push(l);
}
}
}
}
shapes.retain(|s| !s.path.is_empty() && s.path.subpaths.iter().all(|sp| sp.anchors.iter().all(|a| a.p.x.is_finite() && a.p.y.is_finite())));
if shapes.is_empty() {
return Some(vec![]);
}
stack = po::pathfinder(op, &shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(styled(&sources[r.key as usize], r.path, op == PathfinderOp::Outline)))
.collect();
}
Some(stack)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
fn sq(id: u64, x: f64, color: Color) -> Arc<Node> {
Arc::new(Node::path(
NodeId(id),
shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)),
Appearance::basic(Paint::solid(color), Paint::None, 0.0),
))
}
fn group_with(effect: &str) -> Node {
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))]);
g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true });
g
}
fn area(n: &Node) -> f64 {
po::area(n.path_data().unwrap(), FillRule::NonZero).abs()
}
#[test]
fn shape_modes_follow_the_pathfinder_panel() {
let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap();
assert_eq!(add.len(), 1);
assert!((area(&add[0]) - 150.0).abs() < 1e-3);
// Add keeps the front object's paint, Subtract the back one's.
assert_eq!(add[0].id, NodeId(2));
let sub = pathfinder_children(&group_with("pathfinder.subtract"), None).unwrap();
assert_eq!(sub[0].id, NodeId(1));
assert!((area(&sub[0]) - 50.0).abs() < 1e-3);
let int = pathfinder_children(&group_with("pathfinder.intersect"), None).unwrap();
assert!((area(&int[0]) - 50.0).abs() < 1e-3);
let div = pathfinder_children(&group_with("pathfinder.divide"), None).unwrap();
assert_eq!(div.len(), 3);
}
#[test]
fn hidden_effect_or_plain_group_is_untouched() {
let mut g = group_with("pathfinder.add");
g.appearance.effects[0].visible = false;
assert!(pathfinder_children(&g, None).is_none());
assert!(!has_pathfinder(&sq(1, 0.0, Color::BLACK)));
}
}

View File

@ -16,6 +16,7 @@
#![forbid(unsafe_code)]
mod distort;
mod group;
mod raster;
mod stylize;
mod util;
@ -28,6 +29,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_geom::{BezPath, PathData, Rect};
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
pub use raster::{RasterFx, outset, raster_effects};
pub use warp::{WarpStyle, warp_point};
@ -56,6 +58,7 @@ const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
pub const WARP_STYLES: [(&str, &str); 15] = [
@ -199,6 +202,9 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
};
v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
}
for (id, label, _) in PATHFINDER_EFFECTS {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
}
v
}
@ -239,7 +245,7 @@ pub fn is_raster(id: &str) -> bool {
/// Does `id` change geometry?
pub fn is_geometry(id: &str) -> bool {
!is_raster(id) && effect_info(id).is_some()
!is_raster(id) && !is_pathfinder(id) && effect_info(id).is_some()
}
/// Any visible geometry effect in the list?

View File

@ -188,6 +188,24 @@ fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// Bake the object-level geometry effects of `id` (and, recursively, of group members).
fn expand_node(d: &mut vectorcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
// Effect → Pathfinder: the group's members become the Pathfinder result.
if let Some(result) = effects::pathfinder_children(&n, None) {
let children = result
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
c.id = d.alloc_id();
Arc::new(c)
})
.collect();
let Some(m) = d.node_mut(id) else { return };
if let Some(ch) = m.children_mut() {
*ch = children;
}
m.appearance.effects.retain(|e| !effects::is_pathfinder(&e.id));
out.push(id);
return;
}
if let Some(children) = n.children()
&& matches!(n.kind, NodeKind::Group { .. })
{
@ -315,4 +333,24 @@ mod tests {
let b = n.geometric_bounds().unwrap();
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
let (mut s, a) = session_with_rect();
let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b["id"]]})).unwrap();
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
s.execute("effect.apply", &json!({"effect": "pathfinder.subtract"})).unwrap();
// Live: the renderer shows only the back square minus the front one.
let doc = s.doc().unwrap().doc.clone();
let img = vectorcraft_render::Renderer::new().render(&doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default());
assert!(img.pixel(125, 150)[3] > 0);
assert_eq!(img.pixel(200, 150)[3], 0);
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, g);
assert!(n.appearance.effects.is_empty());
let ch = n.children().unwrap();
assert_eq!(ch.len(), 1);
assert!((ch[0].geometric_bounds().unwrap().width() - 50.0).abs() < 1e-6);
}
}

View File

@ -467,7 +467,7 @@ impl Renderer {
self.stats.drawn += 1;
return;
}
if vectorcraft_doc::live::is_live(a) {
if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) {
return self.draw_live(ctx, f, a);
}
self.draw_node(ctx, f, a, true);

View File

@ -86,7 +86,11 @@ impl Renderer {
e.2 = self.live.stamp;
return e.1.clone();
}
let v: Expanded = Arc::new(expand_live(a).into_iter().map(Arc::new).collect());
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let v: Expanded = match crate::effects::pathfinder_children(a, Some(hook)) {
Some(children) => Arc::new(children),
None => Arc::new(expand_live(a).into_iter().map(Arc::new).collect()),
};
if cache {
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp));
}
@ -129,7 +133,7 @@ impl Renderer {
pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
match &a.kind {
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => {
let items = self.live_expanded(a, cache);
for c in items.iter() {
self.draw_arc(ctx, f, c);

View File

@ -1386,7 +1386,11 @@ fn effect_menu() -> Vec<Item> {
let items: Vec<Item> = cat
.iter()
.filter(|e| e.menu.last().copied() == Some(sub_name))
.map(|e| Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })))
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
// No options (Effect → Pathfinder): apply directly, like Illustrator.
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
})
.collect();
if sub_name == "Blur" {
if !items.is_empty() {
@ -1399,7 +1403,6 @@ fn effect_menu() -> Vec<Item> {
if items.is_empty() {
let placeholder = match sub_name {
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
"Pathfinder" => vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract")],
"SVG Filters" => vec![todo("Apply SVG Filter…")],
_ => continue,
};