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());
}
+12 -2
View File
@@ -687,6 +687,11 @@ pub fn boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool
/// [`unite_all`] that reports a refused input instead of leaving it out of the union or returning
/// an empty path.
pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result<PathData, PathOpsError> {
try_unite_all_with(paths, DEFAULT_PRECISION)
}
/// [`try_unite_all`] refit to `precision`, dropping slivers thinner than it.
pub fn try_unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> Result<PathData, PathOpsError> {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
@@ -700,13 +705,18 @@ pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result<PathData, PathOp
if all.elements().is_empty() {
return Ok(PathData::default());
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
Ok(all_contours_to_path(&normalize_bez(&all, FillRule::NonZero)?, &tidy))
}
/// Union of many filled paths. Each input is first normalised on its own, then all are swept
/// together with a single integer winding number, which keeps this fast for thousands of shapes.
pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
unite_all_with(paths, DEFAULT_PRECISION)
}
/// [`unite_all`] refit to `precision`, dropping slivers thinner than it.
pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathData {
let mut all = BezPath::new();
for (p, r) in paths {
let bp = fill_bezpath(p);
@@ -721,7 +731,7 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData {
if all.elements().is_empty() {
return PathData::default();
}
let tidy = Tidy::keeping(DEFAULT_PRECISION, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
let tidy = Tidy::keeping(precision, paths.iter().map(|(p, _)| fill_bezpath(p)).collect::<Vec<_>>().iter());
match normalize_bez(&all, FillRule::NonZero) {
Ok(c) => all_contours_to_path(&c, &tidy),
Err(_) => PathData::default(),
+3 -2
View File
@@ -16,14 +16,15 @@ mod planar;
mod trap;
pub use boolean::{
BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_area, try_boolean, try_boolean_n, try_normalize, try_unite_all, unite_all,
BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_area, try_boolean, try_boolean_n, try_normalize, try_unite_all,
try_unite_all_with, unite_all, unite_all_with,
};
pub use edit::{
AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor,
remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid,
};
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions};
pub use planar::{
BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point,
live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap,
+79 -9
View File
@@ -8,7 +8,8 @@ use linesweeper::topology::ContourIdx;
use vectorcraft_geom::{FillRule, PathData};
use crate::boolean::{
Arrangement, Multi, Seg, Tidy, all_contours_to_path, contours_to_path, eps_for, fill_bezpath, normalize_bez, segs_to_subpath, to_seg, unite_all,
Arrangement, DEFAULT_PRECISION, Multi, Seg, Tidy, all_contours_to_path, contours_to_path, eps_for, fill_bezpath, normalize_bez, segs_to_subpath,
to_seg, unite_all, unite_all_with,
};
/// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour);
@@ -78,9 +79,12 @@ impl Region {
}
}
fn arrangement(shapes: &[Shape]) -> Option<Arrangement> {
/// The arrangement of `shapes`, its faces refit to `precision` and its slivers thinner than it dropped.
fn arrangement(shapes: &[Shape], precision: f64) -> Option<Arrangement> {
let bps: Vec<(BezPath, FillRule)> = shapes.iter().map(|s| (fill_bezpath(&s.path), s.rule)).collect();
Arrangement::new(bps).ok()
let mut arr = Arrangement::new(bps).ok()?;
arr.tidy.precision = precision;
Some(arr)
}
/// The one item of `it`, if it has exactly one.
@@ -95,6 +99,14 @@ fn one(path: PathData, key: u64) -> Vec<Shape> {
/// Run a Pathfinder operation over `shapes` (back → front).
pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
pathfinder_with(op, shapes, DEFAULT_PRECISION)
}
/// [`pathfinder`] at Pathfinder Options › Precision: results are refit to within `precision`
/// (document units) and pieces thinner than it are dropped. A smaller value follows the inputs more
/// closely. A value that isn't positive and finite is [`DEFAULT_PRECISION`].
pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Vec<Shape> {
let precision = if precision.is_finite() && precision > 0.0 { precision } else { DEFAULT_PRECISION };
let n = shapes.len();
if n == 0 {
return Vec::new();
@@ -102,7 +114,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
let front_key = shapes[n - 1].key;
let unite = || {
let v: Vec<(&PathData, FillRule)> = shapes.iter().map(|s| (&s.path, s.rule)).collect();
one(unite_all(&v), front_key)
one(unite_all_with(&v, precision), front_key)
};
// Unite doesn't need the arrangement.
if op == PathfinderOp::Unite {
@@ -111,7 +123,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
// Open paths are cutting edges for Divide; the filled-only operations keep their
// implicit closure. Reuse Shape Builder's bounded planar map and its fallback.
if op == PathfinderOp::Divide && crate::has_open_path(shapes) {
return crate::shape_builder(shapes, false)
return crate::planar::builder(shapes, false, 0.0, precision)
.regions
.into_iter()
.filter_map(|r| {
@@ -120,7 +132,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
})
.collect();
}
let Some(arr) = arrangement(shapes) else { return Vec::new() };
let Some(arr) = arrangement(shapes, precision) else { return Vec::new() };
let p = &arr.tidy;
match op {
PathfinderOp::Unite => unite(),
@@ -307,7 +319,12 @@ fn vertex_key(p: Point) -> (u64, u64) {
/// All faces of the planar arrangement of `shapes` (every area covered by at least one shape,
/// split wherever coverage changes).
pub fn regions(shapes: &[Shape]) -> Vec<Region> {
arrangement(shapes).map(|a| regions_of(&a)).unwrap_or_default()
regions_with(shapes, DEFAULT_PRECISION)
}
/// [`regions`] refit to `precision`.
pub(crate) fn regions_with(shapes: &[Shape], precision: f64) -> Vec<Region> {
arrangement(shapes, precision).map(|a| regions_of(&a)).unwrap_or_default()
}
/// The face under `point`, if any (Shape Builder hover/click).
@@ -348,7 +365,12 @@ impl FaceMerger {
// The Shape Builder's faces come from the planar map only up to its cap.
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let lines = (segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new()));
Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines, snap: std::cell::OnceCell::new() }
Self {
arr: if has_open { None } else { arrangement(shapes, DEFAULT_PRECISION) },
shapes: shapes.len(),
lines,
snap: std::cell::OnceCell::new(),
}
}
/// Merge faces found with Gap Detection using the same temporary bridges as Live Paint.
@@ -851,7 +873,7 @@ mod tests {
}
fn same_as_one_mask_at_a_time(shapes: &[Shape]) {
let arr = arrangement(shapes).expect("arrangement");
let arr = arrangement(shapes, DEFAULT_PRECISION).expect("arrangement");
assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr));
}
@@ -859,6 +881,54 @@ mod tests {
shapes::rectangle(Rect::new(x0, y0, x1, y1))
}
/// Pathfinder Options › Precision: pieces thinner than the precision are dropped, so a 0.5 pt
/// strip that the default keeps goes at a precision of 1 pt, whether the Pathfinder works on
/// the arrangement (Divide), the planar map (Divide with an open path) or the union (Unite).
#[test]
fn precision_drops_pieces_thinner_than_it() {
let overlapping =
[Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 1), Shape::new(rect(0.0, 99.5, 100.0, 200.0), FillRule::NonZero, 2)];
let line = PathData::single(SubPath::polyline(&[Point::new(-10.0, 99.5), Point::new(110.0, 99.5)], false));
let cut = [Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 1), Shape::new(line, FillRule::NonZero, 2)];
let apart =
[Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 1), Shape::new(rect(0.0, 150.0, 100.0, 150.5), FillRule::NonZero, 2)];
for (what, op, shapes, kept) in [
("Divide", PathfinderOp::Divide, &overlapping, 3),
("Divide with an open path", PathfinderOp::Divide, &cut, 2),
("Unite", PathfinderOp::Unite, &apart, 2),
] {
let count = |r: Vec<Shape>| r.iter().map(|s| s.path.subpaths.len()).sum::<usize>();
assert_eq!(pathfinder_with(op, shapes, DEFAULT_PRECISION), pathfinder(op, shapes), "{what}: the default is the plain Pathfinder");
assert_eq!(count(pathfinder(op, shapes)), kept, "{what}: the default keeps the strip");
assert_eq!(count(pathfinder_with(op, shapes, 1.0)), kept - 1, "{what}: 1 pt drops it");
assert_eq!(pathfinder_with(op, shapes, f64::NAN), pathfinder(op, shapes), "{what}: an invalid precision is the default");
}
}
/// The planar map that Divide with an open path uses merges two edges between the same points
/// only when they are closer than the precision: a lens 0.005 pt thick between two curves is a
/// piece at a precision of 0.001 pt, and not at the default.
#[test]
fn open_path_divide_keeps_a_lens_thicker_than_the_precision() {
// Curves monotonic in x and y, so the sweep keeps each one whole between the shared ends;
// their middles are 0.005 pt apart.
let shift = 0.005 / 0.75 / std::f64::consts::SQRT_2;
let curve = |d: f64| [Point::new(0.0, 0.0), Point::new(d, 50.0 - d), Point::new(50.0 + d, 100.0 - d), Point::new(100.0, 100.0)];
let (top, bottom) = (curve(0.0), curve(shift));
let mut bp = kurbo::BezPath::new();
bp.move_to(top[0]);
bp.curve_to(top[1], top[2], top[3]);
bp.curve_to(bottom[2], bottom[1], bottom[0]);
bp.close_path();
let lens = Shape::new(PathData::from_bezpath(&bp), FillRule::NonZero, 1);
let line = PathData::single(SubPath::polyline(&[Point::new(200.0, 0.0), Point::new(300.0, 0.0)], false));
let shapes = [lens, Shape::new(line, FillRule::NonZero, 2)];
let area = |r: &[Shape]| r.iter().map(|s| crate::area(&s.path, FillRule::NonZero)).sum::<f64>();
let fine = pathfinder_with(PathfinderOp::Divide, &shapes, 0.001);
assert!(area(&fine) > 0.1, "the lens is a piece: {} pt²", area(&fine));
assert_eq!(area(&pathfinder(PathfinderOp::Divide, &shapes)), 0.0, "thinner than the default precision");
}
/// Shapes on a coarse grid, so that edges and corners coincide: rectangles, rectangles with
/// a hole, ellipses and polygons that may cross themselves, under either fill rule.
fn arb_shape() -> impl Strategy<Value = Shape> {
+34 -14
View File
@@ -465,7 +465,7 @@ fn find(parent: &mut [usize], mut v: usize) -> usize {
/// wherever they meet another path, keyed by the input they came from (the front-most where
/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate).
pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
build(shapes).unwrap_or_default()
build(shapes, DEFAULT_PRECISION).unwrap_or_default()
}
/// [`live_paint`] with Gap Detection: the faces are those of `shapes` with a line across each gap
@@ -809,22 +809,27 @@ pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec<Shape> {
/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece
/// of `lines` or `edges`.
pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement {
builder(shapes, edges, gap, DEFAULT_PRECISION)
}
/// [`shape_builder_with_gap`] refitting its results to `precision` (Pathfinder Options › Precision).
pub(crate) fn builder(shapes: &[Shape], edges: bool, gap: f64, precision: f64) -> BuilderArrangement {
let has_open = has_open_path(shapes);
let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum();
let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS)
.then(|| {
let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect();
if has_open && gap.is_finite() && gap > 0.0 {
let (faces, pieces) = live_paint(&gap_inputs(&keyed, gap));
let (faces, pieces) = build(&gap_inputs(&keyed, gap), precision).unwrap_or_default();
// Gap targets split original outlines too, but the bridges themselves aren't art.
(faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect())
} else {
live_paint(&keyed)
build(&keyed, precision).unwrap_or_default()
}
})
.filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty());
let Some((faces, pieces)) = planar else {
return BuilderArrangement { regions: filled_regions(shapes), ..Default::default() };
return BuilderArrangement { regions: filled_regions(shapes, precision), ..Default::default() };
};
let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed));
let (lines, outline): (Vec<Shape>, Vec<Shape>) = pieces.into_iter().partition(|e| is_line(e.key));
@@ -832,7 +837,7 @@ pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> Builde
faces
} else {
// The filled areas from their arrangement, whose faces merge exactly, then the gaps.
let mut regions = crate::regions(shapes);
let mut regions = crate::pathfinder::regions_with(shapes, precision);
regions.extend(faces.into_iter().filter(|f| f.sources.is_empty()));
regions
};
@@ -842,14 +847,14 @@ pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> Builde
/// The regions of the shapes' filled areas. A shape that fills nothing (open paths that enclose no
/// area, such as cutting lines) is left out, so a flood of them cannot push the arrangement past its
/// size budget and take the filled shapes down with it; the regions keep the original indices.
fn filled_regions(shapes: &[Shape]) -> Vec<crate::Region> {
fn filled_regions(shapes: &[Shape], precision: f64) -> Vec<crate::Region> {
let fills = |s: &Shape| s.path.subpaths.iter().any(|sp| sp.closed || encloses_area(sp));
let keep: Vec<usize> = (0..shapes.len()).filter(|&i| shapes.get(i).is_some_and(fills)).collect();
if keep.len() == shapes.len() {
return crate::regions(shapes);
return crate::pathfinder::regions_with(shapes, precision);
}
let kept: Vec<Shape> = keep.iter().filter_map(|&i| shapes.get(i).cloned()).collect();
crate::regions(&kept)
crate::pathfinder::regions_with(&kept, precision)
.into_iter()
.map(|mut r| {
r.sources = r.sources.iter().filter_map(|&s| keep.get(s).copied()).collect();
@@ -1080,6 +1085,11 @@ pub(crate) struct Map {
/// With `cover`, also finds which inputs cover each sliver, which only [`Map::merge`] reads;
/// without it the slivers' lists of inputs stay empty.
pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option<Map> {
map_with(shapes, cover, DEFAULT_PRECISION)
}
/// [`map`] whose faces and pieces are refit to `precision`, and whose slivers thinner than it are dropped.
fn map_with(shapes: &[Shape], cover: bool, precision: f64) -> Option<Map> {
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
for (i, s) in shapes.iter().enumerate() {
let mut bp = BezPath::new();
@@ -1110,7 +1120,7 @@ pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option<Map> {
let sweep =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::<Crossings>::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps)));
let top = sweep.ok()?.ok()?;
let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0));
let tidy = Tidy::keeping(precision, inputs.iter().map(|p| &p.0));
// The planar graph: vertices are the sweep's points, edges its segments. Points closer than
// the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge
@@ -1229,7 +1239,7 @@ pub(crate) fn map(shapes: &[Shape], cover: bool) -> Option<Map> {
let twin_of = between.get(&key).and_then(|v| {
v.iter().copied().find(|&e| {
let other = edges.get(e).and_then(|e| mid(&e.segs));
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION)
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < precision)
})
});
if let Some(e) = twin_of {
@@ -1448,8 +1458,8 @@ impl Map {
}
}
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
let m = map(shapes, false)?;
fn build(shapes: &[Shape], precision: f64) -> Option<(Vec<Region>, Vec<Shape>)> {
let m = map_with(shapes, false, precision)?;
let Map { edges, around, faces, holes, face_holes, tidy, anchors, snap, .. } = &m;
let snap = *snap;
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
@@ -1465,7 +1475,11 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
let path = unshear(PathData::new(subpaths), anchors, snap);
let sources =
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
// A face finer than the default precision can need a finer outline to find a point in.
interior_point(&path)
.or_else(|| (precision < DEFAULT_PRECISION).then(|| interior_point_within(&path, precision / 4.0)).flatten())
.map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect())
.unwrap_or_default();
Some(Region { path, sources })
})
.collect();
@@ -1530,11 +1544,17 @@ fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding
/// a Live Paint face after a rebuild).
pub fn interior_point(path: &PathData) -> Option<Point> {
let bb = path.bounds()?;
interior_point_within(path, (bb.width().max(bb.height()) * 1e-3).max(1e-4))
}
/// [`interior_point`] with the path's curves flattened to within `tolerance`.
fn interior_point_within(path: &PathData, tolerance: f64) -> Option<Point> {
let bb = path.bounds()?;
let bp = path.to_bezpath();
let mut edges: Vec<(Point, Point)> = vec![];
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
kurbo::flatten(bp.iter(), tolerance, |el| match el {
kurbo::PathEl::MoveTo(p) => {
if last != first {
edges.push((last, first));
+2
View File
@@ -64,6 +64,7 @@ pub mod new_swatch;
pub mod office_export;
pub mod package;
pub(crate) mod path_ops;
pub mod pathfinder_options;
pub mod pdf_presets;
pub mod perspective_grid;
pub mod perspective_options;
@@ -369,6 +370,7 @@ registry! {
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
LayersPanelOptions: [layers_panel_options::KIND] => layers_panel_options::SPEC,
PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC,
RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC,
ShortcutImport: [shortcut_import::KIND] => DialogSpec::window(shortcut_import::show, shortcut_import::confirm),
}
@@ -0,0 +1,49 @@
//! Pathfinder panel › Pathfinder Options…: Precision, Remove Redundant Points and Divide and Outline
//! Will Remove Unpainted Artwork. OK runs `object.pathfinder.options` (the options live in the
//! preferences).
//!
//! Fields: `precision` (pt, 0.001–10), `removeRedundantPoints` and `removeUnpainted`.
use serde_json::{Value, json};
use super::DialogSpec;
use super::swatch_options::{grid, label};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Pathfinder Options.
pub const KIND: &str = "pathfinderOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Pathfinder Options").into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM };
/// Open the dialog with the current options.
pub fn open(app: &mut VectorcraftApp) {
app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.pathfinder)));
}
fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, text: &str) {
let v = d.bool(key);
if widgets::check(ui, text, v, true) {
d.fields.insert(key.into(), json!(!v));
}
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let range = vectorcraft_engine::cmd::PATHFINDER_PRECISION_RANGE;
grid(ui, |ui| {
label(ui, tl!("Precision:"));
let v = d.f64("precision", vectorcraft_engine::PathfinderOptions::default().precision);
if let Some(v) = widgets::range_field(ui, "pathfinder-precision", v, range, " pt", 3, 80.0) {
d.fields.insert("precision".into(), json!(v));
}
ui.end_row();
});
ui.add_space(8.0);
check(ui, d, "removeRedundantPoints", tl!("Remove Redundant Points"));
check(ui, d, "removeUnpainted", tl!("Divide and Outline Will Remove Unpainted Artwork"));
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
super::run_and_close(app, "object.pathfinder.options", Value::Object(d.fields.clone()))
}
+4
View File
@@ -5481,6 +5481,10 @@ axis Y Axis Y ос
Rectangular Grid Tool Options Опции на инструмента „Правоъгълна мрежа“
Polar Grid Tool Options Опции на инструмента „Полярна мрежа“
Fill Line Запълване на линия
Pathfinder Options Опции за търсач на пътища
Divide and Outline Will Remove Unpainted Artwork „Разделяне“ и „Контур“ премахват небоядисаната графика
@msg precision must be from 0.001 to 10 pt precision трябва да е от 0,001 до 10 pt
@msg `pathfinder` must be an object `pathfinder` трябва да бъде обект
Add to Brushes Добавяне към четки
Brush Library Библиотека с четки
Save Brush Library Запази библиотека с четки
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5557,6 +5557,10 @@ axis Y Axis osa Y
Rectangular Grid Tool Options Možnosti nástroje Obdélníková mřížka
Polar Grid Tool Options Možnosti nástroje Polární mřížka
Fill Line Výplň úsečky
Pathfinder Options Možnosti operací s cestami
Divide and Outline Will Remove Unpainted Artwork Rozdělit a Obrys odstraní nenatřenou kresbu
@msg precision must be from 0.001 to 10 pt precision musí být od 0,001 do 10 pt
@msg `pathfinder` must be an object `pathfinder` musí být objekt
Add to Brushes Přidat do štětců
Brush Library Knihovna štětců
Save Brush Library Uložit knihovnu štětců
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5497,6 +5497,10 @@ axis Y Axis Y-akse
Rectangular Grid Tool Options Indstillinger for rektangulært gitterværktøj
Polar Grid Tool Options Indstillinger for polært gitterværktøj
Fill Line Udfyld linje
Pathfinder Options Pathfinder-indstillinger
Divide and Outline Will Remove Unpainted Artwork Opdel og Kontur fjerner grafik uden maling
@msg precision must be from 0.001 to 10 pt precision skal være fra 0,001 til 10 pt
@msg `pathfinder` must be an object `pathfinder` skal være et objekt
Add to Brushes Føj til pensler
Brush Library Penselbibliotek
Save Brush Library Gem penselbibliotek
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5139,6 +5139,10 @@ axis Y Axis Y-Achse
Rectangular Grid Tool Options Optionen für das Rechteckiges-Raster-Werkzeug
Polar Grid Tool Options Optionen für das Radiales-Raster-Werkzeug
Fill Line Linie füllen
Pathfinder Options Pathfinder-Optionen
Divide and Outline Will Remove Unpainted Artwork „Fläche aufteilen“ und „Pfadansicht“ entfernen ungefärbtes Bildmaterial
@msg precision must be from 0.001 to 10 pt precision muss zwischen 0,001 und 10 pt liegen
@msg `pathfinder` must be an object `pathfinder` muss ein Objekt sein
Add to Brushes Zu Pinseln hinzufügen
Brush Library Pinsel-Bibliothek
Save Brush Library Pinsel-Bibliothek speichern
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5509,6 +5509,10 @@ axis Y Axis Άξονας Y
Rectangular Grid Tool Options Επιλογές εργαλείου ορθογώνιου πλέγματος
Polar Grid Tool Options Επιλογές εργαλείου πολικού πλέγματος
Fill Line Γέμισμα γραμμής
Pathfinder Options Επιλογές Pathfinder
Divide and Outline Will Remove Unpainted Artwork Η Διαίρεση και το Περίγραμμα αφαιρούν τα γραφικά χωρίς χρώμα
@msg precision must be from 0.001 to 10 pt το precision πρέπει να είναι από 0,001 έως 10 pt
@msg `pathfinder` must be an object Το `pathfinder` πρέπει να είναι αντικείμενο
Add to Brushes Προσθήκη στα πινέλα
Brush Library Βιβλιοθήκη πινέλων
Save Brush Library Αποθήκευση βιβλιοθήκης πινέλων
Can't render this file because it contains an unexpected character in line 2069 and column 19.
+4
View File
@@ -5499,6 +5499,10 @@ axis Y Axis Eje Y
Rectangular Grid Tool Options Opciones de la herramienta Cuadrícula rectangular
Polar Grid Tool Options Opciones de la herramienta Cuadrícula polar
Fill Line Rellenar línea
Pathfinder Options Opciones de Buscatrazos
Divide and Outline Will Remove Unpainted Artwork Dividir y Contorno eliminarán la ilustración sin pintar
@msg precision must be from 0.001 to 10 pt precision debe estar entre 0,001 y 10 pt
@msg `pathfinder` must be an object `pathfinder` debe ser un objeto
Add to Brushes Agregar a pinceles
Brush Library Biblioteca de pinceles
Save Brush Library Guardar biblioteca de pinceles
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5588,6 +5588,10 @@ axis Y Axis Y-akseli
Rectangular Grid Tool Options Suorakulmaisen ruudukon työkalun asetukset
Polar Grid Tool Options Napakoordinaattiruudukon työkalun asetukset
Fill Line Täytä viiva
Pathfinder Options Polunetsimen asetukset
Divide and Outline Will Remove Unpainted Artwork Jaa ja Ääriviiva poistavat maalaamattoman kuvituksen
@msg precision must be from 0.001 to 10 pt precision-arvon on oltava 0,001–10 pt
@msg `pathfinder` must be an object `pathfinder` on oltava objekti
Add to Brushes Lisää siveltimiin
Brush Library Sivellinkirjasto
Save Brush Library Tallenna sivellinkirjasto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5556,6 +5556,10 @@ axis Y Axis Axe Y
Rectangular Grid Tool Options Options de l’outil Grille rectangulaire
Polar Grid Tool Options Options de l’outil Grille à coordonnées polaires
Fill Line Remplir le trait
Pathfinder Options Options Pathfinder
Divide and Outline Will Remove Unpainted Artwork Division et Tracés suppriment l’illustration non peinte
@msg precision must be from 0.001 to 10 pt precision doit être compris entre 0,001 et 10 pt
@msg `pathfinder` must be an object `pathfinder` doit être un objet
Add to Brushes Ajouter aux formes
Brush Library Bibliothèque de formes
Save Brush Library Enregistrer la bibliothèque de formes
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5543,6 +5543,10 @@ axis Y Axis Y tengely
Rectangular Grid Tool Options Téglalaprács beállításai
Polar Grid Tool Options Polárrács beállításai
Fill Line Szakasz kitöltése
Pathfinder Options Útvonalkezelő beállításai
Divide and Outline Will Remove Unpainted Artwork A Felosztás és a Vázlat eltávolítja a festetlen grafikát
@msg precision must be from 0.001 to 10 pt a precision értéke 0,001 és 10 pt között lehet
@msg `pathfinder` must be an object `pathfinder`: objektum kell
Add to Brushes Hozzáadás az ecsetekhez
Brush Library Ecsetkönyvtár
Save Brush Library Ecsetkönyvtár mentése
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5497,6 +5497,10 @@ axis Y Axis Sumbu Y
Rectangular Grid Tool Options Opsi alat kisi persegi panjang
Polar Grid Tool Options Opsi alat kisi polar
Fill Line Isi garis
Pathfinder Options Opsi Pathfinder
Divide and Outline Will Remove Unpainted Artwork Bagi dan Garis luar menghapus karya seni tanpa warna
@msg precision must be from 0.001 to 10 pt precision harus dari 0,001 sampai 10 pt
@msg `pathfinder` must be an object `pathfinder` harus berupa objek
Add to Brushes Tambahkan ke kuas
Brush Library Pustaka kuas
Save Brush Library Simpan pustaka kuas
Can't render this file because it contains an unexpected character in line 3838 and column 19.
+4
View File
@@ -5173,6 +5173,10 @@ axis Y Axis Asse Y
Rectangular Grid Tool Options Opzioni strumento Griglia rettangolare
Polar Grid Tool Options Opzioni strumento Griglia polare
Fill Line Riempi linea
Pathfinder Options Opzioni elaborazione tracciati
Divide and Outline Will Remove Unpainted Artwork Dividi e Contorno rimuovono la grafica senza colore
@msg precision must be from 0.001 to 10 pt precision deve essere compreso tra 0,001 e 10 pt
@msg `pathfinder` must be an object `pathfinder` deve essere un oggetto
Add to Brushes Aggiungi ai pennelli
Brush Library Libreria pennelli
Save Brush Library Salva libreria pennelli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5235,6 +5235,10 @@ axis Y Axis Y 軸
Rectangular Grid Tool Options 長方形グリッドツールオプション
Polar Grid Tool Options 同心円グリッドツールオプション
Fill Line 線を塗りつぶす
Pathfinder Options パスファインダーオプション
Divide and Outline Will Remove Unpainted Artwork 分割およびアウトラインで塗りのないアートワークを削除
@msg precision must be from 0.001 to 10 pt precision は 0.001~10 pt にしてください
@msg `pathfinder` must be an object `pathfinder` にはオブジェクトを指定してください
Add to Brushes ブラシに追加
Brush Library ブラシライブラリ
Save Brush Library ブラシライブラリを保存
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5473,6 +5473,10 @@ axis Y Axis Y ღერძი
Rectangular Grid Tool Options მართკუთხა ბადის ხელსაწყოს პარამეტრები
Polar Grid Tool Options პოლარული ბადის ხელსაწყოს პარამეტრები
Fill Line ხაზის შევსება
Pathfinder Options ტრაექტორიების ოპერაციების პარამეტრები
Divide and Outline Will Remove Unpainted Artwork დაყოფა და კონტურები წაშლის შეუღებავ ნახატს
@msg precision must be from 0.001 to 10 pt precision უნდა იყოს 0,001-დან 10 pt-მდე
@msg `pathfinder` must be an object `pathfinder` უნდა იყოს ობიექტი
Add to Brushes ფუნჯებში დამატება
Brush Library ფუნჯების ბიბლიოთეკა
Save Brush Library ფუნჯების ბიბლიოთეკის შენახვა
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5136,6 +5136,10 @@ axis Y Axis Y축
Rectangular Grid Tool Options 사각형 격자 도구 옵션
Polar Grid Tool Options 극좌표 격자 도구 옵션
Fill Line 선 채우기
Pathfinder Options 패스파인더 옵션
Divide and Outline Will Remove Unpainted Artwork 나누기 및 윤곽선에서 칠하지 않은 아트워크 제거
@msg precision must be from 0.001 to 10 pt precision는 0.001~10pt여야 합니다
@msg `pathfinder` must be an object `pathfinder`는 개체여야 합니다
Add to Brushes 브러시에 추가
Brush Library 브러시 라이브러리
Save Brush Library 브러시 라이브러리 저장
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5541,6 +5541,10 @@ axis Y Axis Y-as
Rectangular Grid Tool Options Opties rechthoekig-rastergereedschap
Polar Grid Tool Options Opties polair-rastergereedschap
Fill Line Lijn vullen
Pathfinder Options Pathfinder-opties
Divide and Outline Will Remove Unpainted Artwork Verdelen en Omtrek verwijderen ongeverfd artwork
@msg precision must be from 0.001 to 10 pt precision moet tussen 0,001 en 10 pt liggen
@msg `pathfinder` must be an object `pathfinder` moet een object zijn
Add to Brushes Aan penselen toevoegen
Brush Library Penseelbibliotheek
Save Brush Library Penseelbibliotheek opslaan
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5409,6 +5409,10 @@ axis Y Axis Oś Y
Rectangular Grid Tool Options Opcje narzędzia Siatka prostokątna
Polar Grid Tool Options Opcje narzędzia Siatka biegunowa
Fill Line Wypełnij odcinek
Pathfinder Options Opcje odnajdywania ścieżek
Divide and Outline Will Remove Unpainted Artwork Podziel i Kontur usuwają niepomalowaną grafikę
@msg precision must be from 0.001 to 10 pt precision musi wynosić od 0,001 do 10 pt
@msg `pathfinder` must be an object `pathfinder` musi być obiektem
Add to Brushes Dodaj do pędzli
Brush Library Biblioteka pędzli
Save Brush Library Zapisz bibliotekę pędzli
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5474,6 +5474,10 @@ axis Y Axis Eixo Y
Rectangular Grid Tool Options Opções da Ferramenta Grade Retangular
Polar Grid Tool Options Opções da Ferramenta Grade Polar
Fill Line Preencher linha
Pathfinder Options Opções do Localizador de Caminhos
Divide and Outline Will Remove Unpainted Artwork Dividir e Contorno removem a ilustração sem pintura
@msg precision must be from 0.001 to 10 pt precision deve estar entre 0,001 e 10 pt
@msg `pathfinder` must be an object `pathfinder` deve ser um objeto
Add to Brushes Adicionar aos Pincéis
Brush Library Biblioteca de Pincéis
Save Brush Library Salvar Biblioteca de Pincéis
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -5169,6 +5169,10 @@ axis Y Axis Ось Y
Rectangular Grid Tool Options Параметры инструмента «Прямоугольная сетка»
Polar Grid Tool Options Параметры инструмента «Полярная сетка»
Fill Line Заливка отрезка
Pathfinder Options Параметры обработки контуров
Divide and Outline Will Remove Unpainted Artwork «Разделить» и «Контур» удаляют неокрашенную графику
@msg precision must be from 0.001 to 10 pt precision должно быть от 0,001 до 10 pt
@msg `pathfinder` must be an object `pathfinder` должно быть объектом
Add to Brushes Добавить в кисти
Brush Library Библиотека кистей
Save Brush Library Сохранить библиотеку кистей
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5581,6 +5581,10 @@ axis Y Axis Y-axel
Rectangular Grid Tool Options Alternativ för verktyget för rektangulärt rutnät
Polar Grid Tool Options Alternativ för verktyget för polärt rutnät
Fill Line Fyll linje
Pathfinder Options Alternativ för banfinnare
Divide and Outline Will Remove Unpainted Artwork Dela upp och Konturvy tar bort omålat bildmaterial
@msg precision must be from 0.001 to 10 pt precision måste vara från 0,001 till 10 pt
@msg `pathfinder` must be an object `pathfinder` måste vara ett objekt
Add to Brushes Lägg till i penslar
Brush Library Penselbibliotek
Save Brush Library Spara penselbibliotek
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -5514,6 +5514,10 @@ axis Y Axis Y ekseni
Rectangular Grid Tool Options Dikdörtgen Izgara Aracı Seçenekleri
Polar Grid Tool Options Yuvarlak Izgara Aracı Seçenekleri
Fill Line Çizgiyi doldur
Pathfinder Options Yol Bulucu Seçenekleri
Divide and Outline Will Remove Unpainted Artwork Böl ve Taslak boyanmamış çizimi kaldırır
@msg precision must be from 0.001 to 10 pt precision 0,001 ile 10 pt arasında olmalıdır
@msg `pathfinder` must be an object `pathfinder` bir nesne olmalıdır
Add to Brushes Fırçalara Ekle
Brush Library Fırça Kitaplığı
Save Brush Library Fırça Kitaplığını Kaydet
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -5153,6 +5153,10 @@ axis Y Axis Вісь Y
Rectangular Grid Tool Options Параметри інструменту «Прямокутна сітка»
Polar Grid Tool Options Параметри інструменту «Полярна сітка»
Fill Line Заливка відрізка
Pathfinder Options Параметри обробки контурів
Divide and Outline Will Remove Unpainted Artwork «Розділити» і «Контур» вилучають нефарбовану графіку
@msg precision must be from 0.001 to 10 pt precision має бути від 0,001 до 10 pt
@msg `pathfinder` must be an object `pathfinder` має бути об’єктом
Add to Brushes Додати до пензлів
Brush Library Бібліотека пензлів
Save Brush Library Зберегти бібліотеку пензлів
Can't render this file because it contains an unexpected character in line 748 and column 40.
+4
View File
@@ -5153,6 +5153,10 @@ axis Y Axis Trục Y
Rectangular Grid Tool Options Tùy chọn công cụ Lưới chữ nhật
Polar Grid Tool Options Tùy chọn công cụ Lưới cực
Fill Line Tô đoạn thẳng
Pathfinder Options Tùy chọn Pathfinder
Divide and Outline Will Remove Unpainted Artwork Chia và Đường bao sẽ xóa tác phẩm không tô màu
@msg precision must be from 0.001 to 10 pt precision phải từ 0,001 đến 10 pt
@msg `pathfinder` must be an object `pathfinder` phải là một đối tượng
Add to Brushes Thêm vào cọ
Brush Library Thư viện cọ
Save Brush Library Lưu thư viện cọ
Can't render this file because it contains an unexpected character in line 4052 and column 10.
+4
View File
@@ -5396,6 +5396,10 @@ axis Y Axis Y 轴
Rectangular Grid Tool Options 矩形网格工具选项
Polar Grid Tool Options 极坐标网格工具选项
Fill Line 填充线段
Pathfinder Options 路径查找器选项
Divide and Outline Will Remove Unpainted Artwork 分割和轮廓将删除未上色图稿
@msg precision must be from 0.001 to 10 pt precision 必须介于 0.001 到 10 pt 之间
@msg `pathfinder` must be an object `pathfinder` 必须是对象
Add to Brushes 添加到画笔
Brush Library 画笔库
Save Brush Library 保存画笔库
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -5192,6 +5192,10 @@ axis Y Axis Y 軸
Rectangular Grid Tool Options 矩形格線工具選項
Polar Grid Tool Options 極座標格點工具選項
Fill Line 填色線段
Pathfinder Options 路徑管理員選項
Divide and Outline Will Remove Unpainted Artwork 分割和外框將移除未上色的圖稿
@msg precision must be from 0.001 to 10 pt precision 必須介於 0.001 到 10 pt 之間
@msg `pathfinder` must be an object `pathfinder` 必須是物件
Add to Brushes 新增至筆刷
Brush Library 筆刷資料庫
Save Brush Library 儲存筆刷資料庫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -3792,6 +3792,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
crate::dialogs::variables::open(app, id, None);
return;
}
// Pathfinder Options: a dialog with the current options.
if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::pathfinder_options::open(app);
return;
}
// Repeat Options: a dialog with the selected repeat's current values.
if id == "object.repeat.options"
&& p.as_object().is_none_or(|o| o.is_empty())
+39 -1
View File
@@ -99,7 +99,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
{
run(app, ui, &op, &l);
}
menu_item(ui, tl!("Pathfinder Options…"), false, false);
if menu_item(ui, tl!("Pathfinder Options…"), true, false) {
crate::menus::invoke(app, "object.pathfinder.options", json!({}));
}
ui.separator();
menu_item(ui, tl!("Make Compound Shape"), false, false);
menu_item(ui, tl!("Release Compound Shape"), false, false);
@@ -110,6 +112,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
mod tests {
use super::*;
use crate::widgets::accessibility_tests::{named, tree};
use serde_json::Value;
use vectorcraft_engine::doc::{NodeId, NodeKind};
/// #1635: a stroked object expanded inside a group leaves a group holding one group (its fill
@@ -188,4 +191,39 @@ mod tests {
assert!((bb.x0 - 60.0).abs() < 0.02 && (bb.x1 - 62.0).abs() < 0.02, "2 pt into the blue: {bb:?}");
assert!(trap.appearance.fill().is_some_and(|f| f.overprint));
}
/// The panel menu's Pathfinder Options… is enabled and opens the options as they are; OK keeps
/// them, and the next Divide leaves out the pieces of the object with no fill.
#[test]
fn pathfinder_options_open_and_ok_sets_what_the_next_divide_uses() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
let b = app.run("shape.rectangle", json!({"x": 60, "y": 30, "width": 100, "height": 50})).unwrap()["id"].clone();
app.run("paint.setFill", json!({"none": true})).unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let items = tree(|ui| menu(&mut app, ui));
assert!(!named(&items, " Pathfinder Options…").is_disabled());
crate::menus::invoke(&mut app, "object.pathfinder.options", json!({}));
let d = app.ui.dialog.as_ref().expect("the Pathfinder Options");
assert_eq!(d.kind, crate::dialogs::pathfinder_options::KIND);
assert_eq!(Value::Object(d.fields.clone()), json!({"precision": 0.01, "removeRedundantPoints": false, "removeUnpainted": false}));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(Default::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
out.textures_delta.clear();
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("removeUnpainted".into(), json!(true));
d.fields.insert("precision".into(), json!(0.1));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let o = app.session.prefs.pathfinder;
assert!(o.remove_unpainted && !o.remove_redundant_points && o.precision == 0.1, "{o:?}");
let id = app.run("object.pathfinder.divide", json!({})).unwrap()["ids"][0].as_u64().unwrap();
let group = app.session.active().unwrap().doc.node(NodeId(id)).unwrap();
let pieces = group.children().unwrap();
assert_eq!(pieces.len(), 1, "only the square's piece outside the unfilled rectangle");
assert!(pieces.iter().all(|p| !p.appearance.fill_paint().is_none()));
}
}
+3 -1
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -2051,6 +2051,20 @@ old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` a
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}}
```
Pathfinder Options: `object.pathfinder.options {precision?, removeRedundantPoints?, removeUnpainted?}` (or
`prefs.get`/`prefs.set` with key `pathfinder`) reads and sets the options every later Shape Mode and Pathfinder
operation uses: `precision` (0.001–10 pt, default 0.01; a string with a unit also reads) is how closely results follow
the paths (they are refit to within it and pieces thinner than it are dropped), `removeRedundantPoints` takes the
anchors that don't change a result's shape off it, and `removeUnpainted` makes Divide and Outline leave out the pieces
of objects with no fill (or only hidden fills). Both default to false; `{}` reads the options. Each Shape Mode and
Pathfinder command (`object.pathfinder.unite` … `minusBack`) also takes `precision`, `removeRedundantPoints` and
`removeUnpainted` for one run; the values it used join its journal entry, so a replay or an action gives the same art
whatever the options are then.
```json
{"name":"run_command","arguments":{"command":"object.pathfinder.options","params":{"precision":0.1,"removeUnpainted":true}}}
```
The Eyedropper: `appearance.copyFrom {source}` copies what the Eyedropper Options pick up and apply from `source` to the
selection (`ids`); `reverse: true` copies the selection's attributes onto `source` (Alt-click) and `append: true` adds
the source's fills and strokes on top of each target's stack (Shift+Alt-click). The options live in the preferences:
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long