From cfd085f34ab459983a4b098905e294664f6b2e98 Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:28:51 +0700 Subject: [PATCH] =?UTF-8?q?Fix=20#3426:=20the=20Pathfinder=20panel's=20Pat?= =?UTF-8?q?hfinder=20Options=E2=80=A6=20sets=20Precision,=20Remove=20Redun?= =?UTF-8?q?dant=20Points=20and=20Divide=20and=20Outline=20Will=20Remove=20?= =?UTF-8?q?Unpainted=20Artwork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #4141 onto main. --- crates/engine/src/cmd/mod.rs | 3 + crates/engine/src/cmd/pathops.rs | 166 +++++++++++++--- crates/engine/src/cmd/prefscmds.rs | 14 +- crates/engine/src/lib.rs | 6 +- crates/engine/src/tests_pathops.rs | 184 ++++++++++++++++++ crates/pathops/src/boolean.rs | 14 +- crates/pathops/src/lib.rs | 5 +- crates/pathops/src/pathfinder.rs | 88 ++++++++- crates/pathops/src/planar.rs | 48 +++-- crates/ui-egui/src/dialogs/mod.rs | 2 + .../ui-egui/src/dialogs/pathfinder_options.rs | 49 +++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/pathfinder.rs | 40 +++- docs/gaps.md | 4 +- docs/mcp.md | 14 ++ docs/target-app-parity.md | 4 +- docs/ui-parity.md | 4 +- 41 files changed, 686 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/pathfinder_options.rs 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