mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
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:
committed by
bflatastic
parent
a8345dcfb1
commit
cfd085f34a
@@ -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;
|
||||
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user