Fix #3426: the Pathfinder panel's Pathfinder Options… sets Precision, Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork

Squashed from #4141 onto main.
This commit is contained in:
Taha-Mahmoodi
2026-10-26 15:28:51 +07:00
committed by bflatastic
parent a8345dcfb1
commit cfd085f34a
41 changed files with 686 additions and 60 deletions
+3
View File
@@ -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;
+143 -23
View File
@@ -23,90 +23,90 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
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<f64> = 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<Self, String> {
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<Value> {
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<Value> {
/// 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<PathfinderOptions> {
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<String, Value> =
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<Value> {
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<Value> {
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<Style> = vec![];
@@ -452,7 +556,12 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
let name = n.name.clone().or_else(|| lv.iter().find_map(|l| l.name.clone()));
push(po::try_unite_all(&refs).map_err(|e| EngineError::Other(e.to_string()))?, FillRule::NonZero, style_of(top), name);
push(
po::try_unite_all_with(&refs, options.precision).map_err(|e| EngineError::Other(e.to_string()))?,
FillRule::NonZero,
style_of(top),
name,
);
} else {
for l in &lv {
if let Some((p, r)) = node_path(l) {
@@ -467,7 +576,18 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
if shapes.iter().any(|s| s.path.subpaths.iter().any(|sp| sp.anchors.iter().any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())))) {
return Err(EngineError::Other("paths contain invalid coordinates".into()));
}
let results = po::pathfinder(op, &shapes);
let mut results = po::pathfinder_with(op, &shapes, options.precision);
if options.remove_unpainted && matches!(op, PathfinderOp::Divide | PathfinderOp::Outline) {
// Pathfinder Options › Divide and Outline Will Remove Unpainted Artwork: the pieces
// of objects with no fill (or only hidden ones) go.
let filled = |st: &Style| st.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none()));
results.retain(|r| styles.get(r.key as usize).is_some_and(filled));
}
if options.remove_redundant_points {
for r in &mut results {
r.path = po::remove_redundant_points(&r.path, options.precision);
}
}
if results.iter().all(|r| r.path.is_empty()) {
return Err(EngineError::Other(format!("{label} produced an empty result")));
}
+9 -5
View File
@@ -384,11 +384,11 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> {
}
/// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`) and the
/// Perspective Grid Options (`perspective.widget.options`). `prefs.get`
/// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset`
/// without a category resets them.
pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget"];
/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`), the
/// Perspective Grid Options (`perspective.widget.options`) and the Pathfinder Options
/// (`object.pathfinder.options`). `prefs.get` and `prefs.set` take them by key (a partial object
/// updates what it names) and `prefs.reset` without a category resets them.
pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget", "pathfinder"];
/// Validate a value for preference group `key` against `current`.
fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<Value, String> {
@@ -397,6 +397,10 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result<
let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default();
cur.merged(v).map(|o| json!(o))
}
"pathfinder" => {
let cur: super::PathfinderOptions = serde_json::from_value(current.clone()).unwrap_or_default();
cur.merged(v).map(|o| json!(o))
}
"perspectiveWidget" => {
let mut merged = current.clone();
if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) {