//! Pathfinder panel (`object.pathfinder.*`) and Object → Path commands backed by //! `vectorcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid, //! Clean Up and Divide Objects Below. use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; use super::*; pub fn specs() -> Vec { vec![ cmd!( "object.pathfinder.unite", "Unite", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Unite) ), cmd!( "object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::MinusFront) ), cmd!( "object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Intersect) ), cmd!( "object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Exclude) ), cmd!( "object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Divide) ), cmd!( "object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Trim) ), cmd!( "object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Merge) ), cmd!( "object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_multi, |s, _| run_pf(s, PathfinderOp::Crop) ), cmd!( "object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::Outline) ), cmd!( "object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, "{} operate on the selected objects (back → front) → {ids}", has_selection, |s, _| run_pf(s, PathfinderOp::MinusBack) ), cmd!( "object.path.outlineStroke", "Outline Stroke", ["Object", "Path"], None, "{} replace stroked paths with filled outlines → {ids}", has_selection, outline_stroke ), cmd!( "object.path.offsetPath", "Offset Path…", ["Object", "Path"], None, "{offset: pt (negative insets), joins?: \"miter\"|\"round\"|\"bevel\", miterLimit?: 4} → {ids} (new objects above the originals)", has_selection, offset_path ), cmd!( "object.path.simplify", "Simplify…", ["Object", "Path"], None, "{tolerance?: pt (1), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts", has_selection, simplify ), cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points), cmd!( "object.path.divideObjectsBelow", "Divide Objects Below", ["Object", "Path"], None, "{} cut the objects below with the selected path (which is removed) → {ids}", has_selection, divide_objects_below ), cmd!( "object.path.splitIntoGrid", "Split Into Grid…", ["Object", "Path"], None, "{rows?: 2, columns?: 2, gutter?: pt (12)} → {ids}", has_selection, split_into_grid ), cmd!( "object.path.cleanUp", "Clean Up…", ["Object", "Path"], None, "{strayPoints?: true, unpaintedObjects?: true, emptyTextPaths?: true} → {removed}", has_doc, clean_up ), ] } // ---------- helpers ---------- /// Number param that also accepts strings like `"10 pt"`. pub(crate) fn num_param(p: &Value, key: &str) -> Option { match p.get(key)? { Value::Number(n) => n.as_f64(), Value::String(s) => { let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect(); t.parse().ok() } _ => None, } .filter(|v: &f64| v.is_finite()) } fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } /// Paint identity carried through a Pathfinder operation. #[derive(Clone, Debug, PartialEq)] struct Style { appearance: Appearance, opacity: f32, blend: BlendMode, } impl Style { fn of(n: &Node) -> Self { Style { appearance: n.appearance.clone(), opacity: n.opacity, blend: n.blend } } fn apply(&self, n: &mut Node) { n.appearance = self.appearance.clone(); n.opacity = self.opacity; n.blend = self.blend; } } /// All subpaths of a path or compound path node, in document coordinates, with its fill rule. pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), NodeKind::Compound { children, rule } => { let mut subs = vec![]; for c in children { if let Some((p, _)) = node_path(c) { subs.extend(p.subpaths); } } Some((PathData::new(subs), *rule)) } NodeKind::Text(t) => Some((text_outline(t), FillRule::NonZero)), _ => None, } } /// Glyph outlines of a text object in document coordinates. pub(crate) fn text_outline(t: &TextObject) -> PathData { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf) } /// Appearance a text object's outlines take: the first run's fill and stroke. pub(crate) fn text_style_appearance(fill: &Paint, stroke: &Paint, width: f64) -> Appearance { let mut a = Appearance { items: vec![AppearanceItem::Fill(vectorcraft_doc::appearance::FillLayer::new(fill.clone()))], effects: vec![] }; if !stroke.is_none() && width > 0.0 { a.items.push(AppearanceItem::Stroke(StrokeLayer::new(stroke.clone(), width))); } a } fn style_of(n: &Node) -> Style { match &n.kind { NodeKind::Text(t) => { let st = t.first_style(); Style { appearance: text_style_appearance(&st.fill, &st.stroke, st.stroke_width), opacity: n.opacity, blend: n.blend } } _ => Style::of(n), } } /// Leaf shapes (paths, compound paths, text) of a subtree in paint order; guides and clipping /// paths are skipped. fn leaves(n: &Node, out: &mut Vec) { match &n.kind { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => out.push(n.clone()), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { for c in children { leaves(c, out); } } _ => {} } } /// A node for `path`: a plain path for one subpath, else a compound path. pub(crate) fn shape_node(d: &mut Document, path: PathData, style_src: Option<&Node>) -> Node { let id = d.alloc_id(); let mut n = if path.subpaths.len() <= 1 { Node::path(id, path, Appearance::default()) } else { let children = path .subpaths .into_iter() .map(|sp| { let cid = d.alloc_id(); Arc::new(Node::path(cid, PathData::single(sp), Appearance::default())) }) .collect(); Node::new(id, NodeKind::Compound { children, rule: FillRule::NonZero }) }; if let Some(src) = style_src { Style::of(src).apply(&mut n); } n } fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node { let mut n = shape_node(d, path, None); style.apply(&mut n); n } // ---------- Pathfinder ---------- fn pf_label(op: PathfinderOp) -> &'static str { match op { PathfinderOp::Unite => "Unite", PathfinderOp::MinusFront => "Minus Front", PathfinderOp::Intersect => "Intersect", PathfinderOp::Exclude => "Exclude", PathfinderOp::Divide => "Divide", PathfinderOp::Trim => "Trim", PathfinderOp::Merge => "Merge", PathfinderOp::Crop => "Crop", PathfinderOp::Outline => "Outline", PathfinderOp::MinusBack => "Minus Back", } } fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { let roots = selected_roots(s)?; let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) }; let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); let label = pf_label(op); let ids = s.edit(label, |d, sel| { // Build the stack (back → front) with a style per entry. let mut styles: Vec