//! 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::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; use vectorcraft_doc::{AnchorRef, Appearance, Document, Node, NodeId, NodeKind, SegmentRef, 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, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Unite) ), cmd!( "object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::MinusFront) ), cmd!( "object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Intersect) ), cmd!( "object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Exclude) ), cmd!( "object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Divide) ), cmd!( "object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Trim) ), cmd!( "object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Merge) ), cmd!( "object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_multi, |s, p| run_pf(s, p, PathfinderOp::Crop) ), cmd!( "object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::Outline) ), cmd!( "object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), precision?: pt 0.001..10, removeRedundantPoints?: bool, removeUnpainted?: bool (default: the Pathfinder Options, see object.pathfinder.options; the values used join the journal entry)} operate on the selected objects (back → front) → {ids}", has_selection, |s, p| run_pf(s, p, PathfinderOp::MinusBack) ), cmd!( "object.pathfinder.trap", "Trap…", ["Window", "Pathfinder"], None, "{ids?: [id…] (selected first; default: the selection), thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black)} adds overprinting traps where the selected objects' visible areas of different solid colours meet, grouped directly above the topmost selected object and selected; the objects stay as they are → {ids}", has_selection, trap ), cmd!( "object.pathfinder.options", "Pathfinder Options…", ["Window", "Pathfinder"], None, "{precision?: pt 0.001..10 (0.01; how closely the Pathfinder operations follow the paths: results are refit to within it and pieces thinner than it are dropped), removeRedundantPoints?: bool (false; the results lose the anchors that don't change their shape), removeUnpainted?: bool (false; Divide and Outline leave out the pieces of objects with no fill)} Pathfinder Options, kept in the preferences and used by every later Shape Mode and Pathfinder operation (not Trap); {} reads them → the options", always, pathfinder_options ), 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), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool, wholePaths?: bool (false: a path with segments direct-selected, or between direct-selected anchors, simplifies only each run of them between its end anchors, which stay as they are, and keeps the runs selected; true simplifies those paths whole)} → {ids, before, after} anchor counts, and the level used with level or auto", 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; rowGutter?, columnGutter?: one of the two), totalHeight?, totalWidth?: pt (the grid's size; the selected paths' when left out), height?, width?: pt (each row's and column's size, which then sets the total), addGuides?: false (ruler guides along the row and column edges)} one grid of rectangles over the selected paths replaces them, painted as the topmost one → {ids, guides: count}", 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()) } /// A distance param in points: a number, or a string with a unit (`"5 mm"`; bare numbers are /// points). pub(crate) fn len_param(p: &Value, key: &str) -> Option { match p.get(key)? { Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| num_param(p, key)), _ => num_param(p, key), } } 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().into(); 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) } fn style_of(n: &Node) -> Style { match &n.kind { NodeKind::Text(t) => Style { appearance: t.first_style().appearance(), 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) { each_leaf(n, &mut |l| out.push(l.clone())); } /// Calls `f` with each leaf shape of [`leaves`], in its order. fn each_leaf(n: &Node, f: &mut impl FnMut(&Node)) { match &n.kind { NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {} NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => f(n), NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { for c in children { each_leaf(c, f); } } _ => {} } } /// How many shapes a Pathfinder operation takes from `n` selected alone: its leaf shapes, however /// deep its groups nest (an expanded stroke is a group inside the group, #1635). pub fn shape_count(n: &Node) -> usize { // Counted without copying the shapes: the panel asks every frame. let mut count = 0; each_leaf(n, &mut |_| count += 1); count } /// 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 } /// Whether `ids` are separate objects, as [`Document::remove_many`] and [`Document::insert_beside`] /// take them: each one is in the tree and listed once, and none lies inside another listed one. pub(crate) fn separate(d: &Document, ids: &[NodeId]) -> bool { let paths = d.index_paths(ids.iter().copied()); let mut found: Vec<_> = ids.iter().filter_map(|id| paths.get(id)).collect(); found.sort_unstable(); found.len() == ids.len() && found.windows(2).all(|w| !w[1].starts_with(w[0])) } /// Lists of nodes that go in place of objects, or directly above them; an empty list in place of /// an object removes it. When the objects are [`separate`], [`Beside::put`] holds each list and /// leaves the tree as it is, and [`Beside::finish`] inserts them all in one pass over the children /// of each parent ([`Document::insert_beside`]). Otherwise [`Beside::put`] inserts each list at /// once, where its object is at that moment. pub(crate) struct Beside { replace: bool, waiting: Option)>>, } impl Beside { /// Lists that go in place of objects among `ids`. pub(crate) fn replacing(d: &Document, ids: &[NodeId]) -> Self { Beside { replace: true, waiting: separate(d, ids).then(Vec::new) } } /// Lists that go directly above objects among `ids`. pub(crate) fn above(d: &Document, ids: &[NodeId]) -> Self { Beside { replace: false, waiting: separate(d, ids).then(Vec::new) } } /// Put `nodes`, in their order, in place of object `id` or directly above it. pub(crate) fn put(&mut self, d: &mut Document, id: NodeId, nodes: Vec) -> Result<()> { if let Some(waiting) = &mut self.waiting { waiting.push((id, nodes)); return Ok(()); } let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?; let at = if self.replace { d.remove(id)?; idx } else { idx + 1 }; for (k, n) in nodes.into_iter().enumerate() { d.insert(parent, at + k, n)?; } Ok(()) } /// Insert the lists that [`Beside::put`] held. pub(crate) fn finish(self, d: &mut Document) -> Result<()> { match self.waiting { Some(waiting) if !waiting.is_empty() => Ok(d.insert_beside(waiting, self.replace)?), _ => Ok(()), } } } /// Remove `ids` through a [`Beside`], which removes them in one pass when they are [`separate`]. pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> { let mut gone = Beside::replacing(d, ids); for id in ids { gone.put(d, *id, vec![])?; } gone.finish(d) } // ---------- Pathfinder ---------- /// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`, the `pathfinder` preference /// group), which every Shape Mode and Pathfinder operation of the panel uses. The defaults leave /// the results as they were before the options existed. #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct PathfinderOptions { /// How closely results follow the paths (pt): they are refit to within it and pieces thinner /// than it are dropped ([`po::pathfinder_with`]). pub precision: f64, /// Results lose the anchors that don't change their shape (to within `precision`). pub remove_redundant_points: bool, /// Divide and Outline leave out the pieces of objects with no fill. pub remove_unpainted: bool, } impl Default for PathfinderOptions { fn default() -> Self { Self { precision: po::DEFAULT_PRECISION, remove_redundant_points: false, remove_unpainted: false } } } /// The range of Pathfinder Options › Precision (pt). pub const PATHFINDER_PRECISION_RANGE: std::ops::RangeInclusive = 0.001..=10.0; impl PathfinderOptions { /// These options with those `p` names (`precision` in pt or a string with a unit), or why `p` /// can't set them. pub(crate) fn merged(&self, p: &Value) -> std::result::Result { let given = match p { Value::Null => return Ok(*self), Value::Object(given) => given, _ => return Err("`pathfinder` must be an object".into()), }; let mut o = *self; for (key, v) in given { let flag = || v.as_bool().ok_or_else(|| format!("`{key}` must be true or false")); match key.as_str() { "precision" => { o.precision = len_param(p, key).filter(|v| PATHFINDER_PRECISION_RANGE.contains(v)).ok_or("precision must be from 0.001 to 10 pt")?; } "removeRedundantPoints" => o.remove_redundant_points = flag()?, "removeUnpainted" => o.remove_unpainted = flag()?, k => { let path = "pathfinder"; return Err(format!("unknown option `{path}.{k}`")); } } } Ok(o) } } fn pathfinder_options(s: &mut Session, p: &Value) -> Result { s.prefs.pathfinder = s.prefs.pathfinder.merged(p).map_err(|e| bad("object.pathfinder.options", e))?; Ok(json!(s.prefs.pathfinder)) } /// The last part of the id of `op`'s command (`object.pathfinder.…`). fn pf_id(op: PathfinderOp) -> &'static str { match op { PathfinderOp::Unite => "unite", PathfinderOp::MinusFront => "minusFront", PathfinderOp::Intersect => "intersect", PathfinderOp::Exclude => "exclude", PathfinderOp::Divide => "divide", PathfinderOp::Trim => "trim", PathfinderOp::Merge => "merge", PathfinderOp::Crop => "crop", PathfinderOp::Outline => "outline", PathfinderOp::MinusBack => "minusBack", PathfinderOp::HardMix => "hardMix", PathfinderOp::SoftMix => "softMix", } } 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", PathfinderOp::HardMix => "Hard Mix", PathfinderOp::SoftMix => "Soft Mix", } } /// The Pathfinder Options an operation uses: those `p` gives, else the preferences'. They join the /// command's journal entry, so replaying it gives the same result whatever the preferences are then. fn pf_options(s: &mut Session, p: &Value, id: &str) -> Result { let mut stored = s.prefs.pathfinder; if !PATHFINDER_PRECISION_RANGE.contains(&stored.precision) { // A preferences file edited by hand: the default, rather than refitting everything away. stored.precision = po::DEFAULT_PRECISION; } let keys = ["precision", "removeRedundantPoints", "removeUnpainted"]; let given: serde_json::Map = p.as_object().into_iter().flatten().filter(|(k, _)| keys.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); let options = stored.merged(&Value::Object(given)).map_err(|e| bad(id, e))?; s.note_journal("precision", json!(options.precision)); s.note_journal("removeRedundantPoints", json!(options.remove_redundant_points)); s.note_journal("removeUnpainted", json!(options.remove_unpainted)); Ok(options) } fn run_pf(s: &mut Session, p: &Value, op: PathfinderOp) -> Result { let roots = selected_roots(s)?; if roots.is_empty() { 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 options = pf_options(s, p, &format!("object.pathfinder.{}", pf_id(op)))?; let ids = s.edit(label, |d, sel| { // Build the stack (back → front); names are separate from paint/style identity. let mut styles: Vec