diff --git a/Cargo.lock b/Cargo.lock index 4dad99b1..bb082d96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4087,6 +4087,7 @@ version = "0.1.0" dependencies = [ "kurbo 0.13.1", "serde_json", + "vectorcraft-color", "vectorcraft-doc", "vectorcraft-geom", "vectorcraft-pathops", diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 244b7da5..4ab1efaf 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -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 } diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs new file mode 100644 index 00000000..f9dfebaf --- /dev/null +++ b/crates/effects/src/group.rs @@ -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 { + 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; + +fn leaves(n: &Node, hook: Option, out: &mut Vec) { + 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) -> Option>> { + if !has_pathfinder(group) { + return None; + } + let mut stack: Vec> = 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 = vec![]; + let mut shapes: Vec = 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 { + 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))); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index ae625891..c0ccd7ba 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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 { }; 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? diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 0584dc73..39b3c271 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -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) { 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); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e1187449..55d9846e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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); diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index f510fd06..78777b8a 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -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 = &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, 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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 38086766..2858fa5b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1386,7 +1386,11 @@ fn effect_menu() -> Vec { let items: Vec = 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 { 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, };