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:
parent
e20fe0a200
commit
1c1fd0f139
|
|
@ -4087,6 +4087,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue