diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bdd065e11..411f2004a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -566,6 +566,9 @@ pub use opacitymask::TransparencyInfo; /// Eyedropper Options (`Prefs::eyedropper`). pub use xform::{AppearanceAttrs, EyedropperAttrs, EyedropperOptions, FillAttrs, StrokeAttrs}; +/// Pathfinder Options (`Prefs::pathfinder`). +pub use pathops::{PATHFINDER_PRECISION_RANGE, PathfinderOptions}; + /// The Attributes panel's state ([`Session::attributes_info`]). pub use overprint::AttributesInfo; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index dbe1c868e..8f3e0f117 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -23,90 +23,90 @@ pub fn specs() -> Vec { "Unite", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Unite) + |s, p| run_pf(s, p, PathfinderOp::Unite) ), cmd!( "object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::MinusFront) + |s, p| run_pf(s, p, PathfinderOp::MinusFront) ), cmd!( "object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Intersect) + |s, p| run_pf(s, p, PathfinderOp::Intersect) ), cmd!( "object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Exclude) + |s, p| run_pf(s, p, PathfinderOp::Exclude) ), cmd!( "object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Divide) + |s, p| run_pf(s, p, PathfinderOp::Divide) ), cmd!( "object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Trim) + |s, p| run_pf(s, p, PathfinderOp::Trim) ), cmd!( "object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Merge) + |s, p| run_pf(s, p, PathfinderOp::Merge) ), cmd!( "object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Crop) + |s, p| run_pf(s, p, PathfinderOp::Crop) ), cmd!( "object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::Outline) + |s, p| run_pf(s, p, PathfinderOp::Outline) ), cmd!( "object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, - "{ids?: [id…] (selected first; default: the selection)} operate on the selected objects (back → front) → {ids}", + "{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, _| run_pf(s, PathfinderOp::MinusBack) + |s, p| run_pf(s, p, PathfinderOp::MinusBack) ), cmd!( "object.pathfinder.trap", @@ -117,6 +117,15 @@ pub fn specs() -> Vec { 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", @@ -379,6 +388,82 @@ pub(crate) fn remove_each(d: &mut Document, ids: &[NodeId]) -> Result<()> { // ---------- 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", @@ -396,7 +481,25 @@ fn pf_label(op: PathfinderOp) -> &'static str { } } -fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { +/// 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())); @@ -404,6 +507,7 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { 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