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()) {
+5 -1
View File
@@ -25,7 +25,6 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
use vectorcraft_geom::Affine;
use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::EyedropperOptions;
pub use cmd::clipboard::Clipboard;
pub use cmd::create::constrain_click;
pub use cmd::distortcmds::perspective_click;
@@ -33,6 +32,7 @@ pub use cmd::params::{check_keys, check_params};
pub use cmd::pathops::auto_simplify_level;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use cmd::{EyedropperOptions, PathfinderOptions};
pub use journal::Journal;
pub use tooling::{UiRequest, ViewInfo};
pub use vectorcraft_doc as doc;
@@ -1112,6 +1112,9 @@ pub struct Prefs {
/// Eyedropper Options (`eyedropper.setOptions`; a preference group, see
/// [`cmd::prefscmds::PREF_GROUPS`]).
pub eyedropper: EyedropperOptions,
/// Pathfinder panel › Pathfinder Options (`object.pathfinder.options`; a preference group, see
/// [`cmd::prefscmds::PREF_GROUPS`]).
pub pathfinder: PathfinderOptions,
/// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored).
/// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*`
/// edit it.
@@ -1337,6 +1340,7 @@ impl Default for Prefs {
Color::rgb8(r, g, b).to_hex()
},
eyedropper: Default::default(),
pathfinder: Default::default(),
flattener_presets: vec![],
width_profiles: vec![],
japanese_crop_marks: false,
+184
View File
@@ -1098,3 +1098,187 @@ fn create_outlines_turns_colr_glyphs_into_layer_groups() {
let mut seen = std::collections::HashSet::new();
assert!(nodes.iter().all(|(id, ..)| d.node(*id).is_some() && seen.insert(*id)), "ids of the document's, each once");
}
/// Pathfinder panel › Pathfinder Options: `object.pathfinder.options` reads and sets the options
/// (kept in the preferences as the `pathfinder` group) and refuses values out of range or of the
/// wrong type, changing nothing.
#[test]
fn pathfinder_options_round_trip_and_refuse_bad_values() {
let mut s = session();
let defaults = json!({"precision": 0.01, "removeRedundantPoints": false, "removeUnpainted": false});
assert_eq!(s.execute("object.pathfinder.options", &json!({})).unwrap(), defaults);
let set = json!({"precision": 0.5, "removeRedundantPoints": true, "removeUnpainted": true});
assert_eq!(s.execute("object.pathfinder.options", &set).unwrap(), set);
assert_eq!(s.execute("prefs.get", &json!({"key": "pathfinder"})).unwrap(), set);
let mm = s.execute("object.pathfinder.options", &json!({"precision": "1 mm"})).unwrap()["precision"].as_f64().unwrap();
assert!((mm - 72.0 / 25.4).abs() < 1e-9, "a length with a unit: {mm}");
s.execute("object.pathfinder.options", &json!({"precision": 0.5})).unwrap();
for bad in [
json!({"precision": 0}),
json!({"precision": 11}),
json!({"precision": "x"}),
json!({"precision": -1}),
json!({"removeRedundantPoints": "yes"}),
json!({"removeUnpainted": 1}),
json!({"tolerance": 1}),
] {
assert!(s.execute("object.pathfinder.options", &bad).is_err(), "{bad}");
assert!(s.execute("prefs.set", &json!({"key": "pathfinder", "value": bad})).is_err(), "prefs.set {bad}");
assert_eq!(s.execute("object.pathfinder.options", &json!({})).unwrap(), set, "{bad} changes nothing");
}
s.execute("prefs.set", &json!({"key": "pathfinder", "value": {"removeUnpainted": false}})).unwrap();
assert!(!s.prefs.pathfinder.remove_unpainted);
assert!(s.prefs.pathfinder.remove_redundant_points, "a partial object keeps the rest");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.execute("object.pathfinder.options", &json!({})).unwrap(), defaults);
}
/// A filled square and, crossing it, a rectangle with no fill, selected.
fn filled_and_unfilled(s: &mut Session) {
let a = rect(s, 0.0, 0.0, 100.0, 100.0);
let b = rect(s, 50.0, 25.0, 100.0, 50.0);
set_fill(s, a, Color::rgb(1.0, 0.0, 0.0));
s.edit("t", |d, _| {
d.node_mut(b).unwrap().appearance.set_fill(Paint::None);
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
}
/// The fill of each piece of the group `op` makes from [`filled_and_unfilled`], and of each
/// piece's stroke.
fn pieces(s: &mut Session, op: &str) -> Vec<(Paint, Paint)> {
let r = ids(&s.execute(op, &json!({})).unwrap());
node(s, r[0]).children().unwrap().iter().map(|c| (c.appearance.fill_paint(), c.appearance.stroke_paint())).collect()
}
/// Pathfinder Options › Divide and Outline Will Remove Unpainted Artwork: off, Divide and Outline
/// keep the pieces of an object with no fill; on, they leave them out.
#[test]
fn divide_and_outline_remove_unpainted_artwork_when_asked() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
for on in [false, true] {
let mut s = session();
s.execute("object.pathfinder.options", &json!({"removeUnpainted": on})).unwrap();
filled_and_unfilled(&mut s);
let divided = pieces(&mut s, "object.pathfinder.divide");
let unfilled = divided.iter().filter(|(fill, _)| fill.is_none()).count();
// The overlap takes the paint of the rectangle in front, so it has no fill either.
assert_eq!(unfilled, if on { 0 } else { 2 }, "Divide, option {on}: {divided:?}");
assert_eq!(divided.len() - unfilled, 1, "the square's piece stays");
s.execute("edit.undo", &json!({})).unwrap();
let outlined = pieces(&mut s, "object.pathfinder.outline");
let from_unfilled = outlined.iter().filter(|(_, stroke)| *stroke != red).count();
assert_eq!(from_unfilled > 0, !on, "Outline, option {on}: {outlined:?}");
assert!(outlined.iter().any(|(_, stroke)| *stroke == red), "the square's edges stay");
}
}
/// Pathfinder Options › Remove Redundant Points: Unite of a square with an anchor in the middle of
/// its top edge and a square inside it keeps the extra anchor unless the option is on.
#[test]
fn remove_redundant_points_drops_anchors_that_shape_nothing() {
for on in [false, true] {
let mut s = session();
s.execute("object.pathfinder.options", &json!({"removeRedundantPoints": on})).unwrap();
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
let a = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap()["id"].as_u64().unwrap());
let b = rect(&mut s, 20.0, 20.0, 60.0, 60.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = ids(&s.execute("object.pathfinder.unite", &json!({})).unwrap());
let anchors = path_of(&s, r[0]).0.anchor_count();
if on {
assert_eq!(anchors, 4, "option on");
} else {
assert!(anchors > 4, "option off keeps the anchor: {anchors}");
}
assert!((area_of(&s, r[0]) - 10_000.0).abs() < 1e-6);
}
}
/// Pathfinder Options › Precision: Divide drops a 0.5 pt overlap at a precision of 1 pt and keeps
/// it at the default.
#[test]
fn precision_decides_whether_a_thin_overlap_is_a_piece() {
for (precision, faces) in [(None, 3), (Some(1.0), 2)] {
let mut s = session();
if let Some(p) = precision {
s.execute("object.pathfinder.options", &json!({"precision": p})).unwrap();
}
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 0.0, 99.5, 100.0, 100.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let r = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap());
assert_eq!(node(&s, r[0]).children().unwrap().len(), faces, "precision {precision:?}");
}
}
/// A hidden fill paints nothing: with Divide and Outline Will Remove Unpainted Artwork on, the
/// pieces of an object whose only fill is hidden go too.
#[test]
fn remove_unpainted_counts_a_hidden_fill_as_unpainted() {
let mut s = session();
s.execute("object.pathfinder.options", &json!({"removeUnpainted": true})).unwrap();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let b = rect(&mut s, 50.0, 25.0, 100.0, 50.0);
set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0));
set_fill(&mut s, b, Color::rgb(0.0, 0.0, 1.0));
s.edit("t", |d, _| {
d.node_mut(b).unwrap().appearance.fill_mut().unwrap().visible = false;
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let divided = pieces(&mut s, "object.pathfinder.divide");
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
assert_eq!(divided.len(), 1, "only the red square's piece: {divided:?}");
assert_eq!(divided[0].0, red);
}
/// Shape Modes unite a group selected with other objects first: at a fine precision a group
/// member thinner than the default precision (0.005 pt) is kept there too.
#[test]
fn a_thin_group_member_survives_the_group_union_at_a_fine_precision() {
for (precision, subpaths) in [(None, 2), (Some(0.001), 3)] {
let mut s = session();
if let Some(p) = precision {
s.execute("object.pathfinder.options", &json!({"precision": p})).unwrap();
}
let (a, thin) = (rect(&mut s, 0.0, 0.0, 50.0, 50.0), rect(&mut s, 0.0, 100.0, 100.0, 0.005));
s.execute("select.set", &json!({"ids": [a.0, thin.0]})).unwrap();
let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
let other = rect(&mut s, 200.0, 0.0, 50.0, 50.0);
s.execute("select.set", &json!({"ids": [g, other.0]})).unwrap();
let r = ids(&s.execute("object.pathfinder.unite", &json!({})).unwrap());
assert_eq!(path_of(&s, r[0]).0.subpaths.len(), subpaths, "precision {precision:?}");
}
}
/// A Pathfinder operation records the options it used in its journal entry, so replaying the
/// journal with other preferences gives the same art.
#[test]
fn pathfinder_journal_entries_replay_with_the_options_they_used() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 99.5, "width": 100, "height": 50})).unwrap();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.prefs.pathfinder = PathfinderOptions { precision: 1.0, remove_redundant_points: true, remove_unpainted: true };
let r = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap());
assert_eq!(node(&s, r[0]).children().unwrap().len(), 1, "the thin overlap and the unfilled piece go");
let (id, p) = s.journal.last().unwrap().clone();
assert_eq!(id, "object.pathfinder.divide");
assert_eq!((&p["precision"], &p["removeRedundantPoints"], &p["removeUnpainted"]), (&json!(1.0), &json!(true), &json!(true)));
let mut replay = Session::new();
for (id, p) in &s.journal {
replay.execute(id, p).unwrap();
}
assert_eq!(replay.prefs.pathfinder, PathfinderOptions::default());
let doc = |s: &Session| serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
assert_eq!(doc(&replay), doc(&s));
// Options given as params are checked as object.pathfinder.options checks them.
assert!(s.execute("object.pathfinder.divide", &json!({"precision": 20})).is_err());
}