mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -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());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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())
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+14
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user