mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3814 from Taha-Mahmoodi/feat/simplify-part-of-path
Simplify: with part of a path direct-selected, Object › Path › Simplify refits only the selected runs of segments
This commit is contained in:
@@ -2,12 +2,13 @@
|
||||
//! `vectorcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid,
|
||||
//! Clean Up and Divide Objects Below.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{AnchorRef, Appearance, Document, Node, NodeId, NodeKind, SegmentRef, TextObject};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, PathfinderOp};
|
||||
@@ -130,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Simplify…",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts, and the level used with level or auto",
|
||||
"{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool, wholePaths?: bool (false: a path with segments direct-selected, or between direct-selected anchors, simplifies only each run of them between its end anchors, which stay as they are, and keeps the runs selected; true simplifies those paths whole)} → {ids, before, after} anchor counts, and the level used with level or auto",
|
||||
has_selection,
|
||||
simplify
|
||||
),
|
||||
@@ -761,12 +762,23 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it: → (their ids, how
|
||||
/// The direct selection a path keeps after an edit of part of it: (its selected anchors, and its
|
||||
/// selected segments when they were picked as segments). `None` selects the path whole.
|
||||
type PartSelection = Option<(BTreeSet<AnchorRef>, Option<BTreeSet<SegmentRef>>)>;
|
||||
|
||||
/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its
|
||||
/// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how
|
||||
/// many anchors they have before and after). A path that `f` would empty or returns unchanged is
|
||||
/// left as it is, with its live shape and its direct-selected anchors; a replaced path loses both.
|
||||
fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
/// left as it is, with its live shape and its direct selection; a replaced path loses its live
|
||||
/// shape and has the direct selection `f` gives it.
|
||||
fn edit_paths(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
f: impl Fn(&PathData, Option<&BTreeSet<AnchorRef>>, Option<&BTreeSet<SegmentRef>>) -> (PathData, PartSelection),
|
||||
) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let st = s.doc()?;
|
||||
let (d, sel) = (&st.doc, &st.selection);
|
||||
let ids = path_ids(d, &roots);
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other(format!("{label}: select paths")));
|
||||
@@ -775,22 +787,29 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Path { path, .. }) = d.node(*id).map(|n| &n.kind) else { continue };
|
||||
before += path.anchor_count();
|
||||
let np = f(path);
|
||||
let (np, part) = f(path, sel.anchors.get(id), sel.segments.get(id));
|
||||
if (np.is_empty() && !path.is_empty()) || np == *path {
|
||||
after += path.anchor_count();
|
||||
continue;
|
||||
}
|
||||
after += np.anchor_count();
|
||||
changed.push((*id, np));
|
||||
changed.push((*id, np, part));
|
||||
}
|
||||
// When no path changes, the document and its undo history stay as they are.
|
||||
if !changed.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for (id, np) in changed {
|
||||
for (id, np, part) in changed {
|
||||
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
// Refit and subdivision change what the old component indexes refer to.
|
||||
// Keep the object selected whole so its new points remain editable.
|
||||
// Refit and subdivision change what the old component indexes refer to: the
|
||||
// path is selected whole, or with the part `f` says.
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
if let Some((anchors, segments)) = part.filter(|(a, _)| a.len() < np.anchor_count()) {
|
||||
sel.anchors.insert(id, anchors);
|
||||
if let Some(segments) = segments {
|
||||
sel.segments.insert(id, segments);
|
||||
}
|
||||
}
|
||||
*path = np;
|
||||
*live = None;
|
||||
}
|
||||
@@ -801,6 +820,31 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -
|
||||
Ok((ids, before, after))
|
||||
}
|
||||
|
||||
/// The segments of `path` a direct selection picks: the `segments` picked as such, or else every
|
||||
/// segment with both its anchors among `anchors`. `None` when the path is selected whole, or when
|
||||
/// no segment of it is selected.
|
||||
fn selected_segments(
|
||||
path: &PathData,
|
||||
anchors: Option<&BTreeSet<AnchorRef>>,
|
||||
segments: Option<&BTreeSet<SegmentRef>>,
|
||||
) -> Option<BTreeSet<SegmentRef>> {
|
||||
let anchors = anchors?;
|
||||
let picked: BTreeSet<SegmentRef> = match segments {
|
||||
Some(segs) => segs.iter().copied().filter(|&(si, i)| path.subpaths.get(si).is_some_and(|sp| i < sp.segment_count())).collect(),
|
||||
None => path
|
||||
.subpaths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(si, sp)| {
|
||||
(0..sp.segment_count())
|
||||
.filter(move |&i| anchors.contains(&(si, i)) && anchors.contains(&(si, (i + 1) % sp.anchors.len())))
|
||||
.map(move |i| (si, i))
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
(!picked.is_empty()).then_some(picked)
|
||||
}
|
||||
|
||||
/// Auto-Simplify's level for the selection: [`po::auto_simplify_level`] for the longer side of the
|
||||
/// selected objects' bounds. Object › Path › Simplify's slider opens at it, and
|
||||
/// `object.path.simplify {auto: true}` simplifies at it.
|
||||
@@ -826,7 +870,22 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
|
||||
straight_lines: bool_or(p, "straightLines", false),
|
||||
};
|
||||
let (ids, before, after) = edit_paths(s, "Simplify", |path| po::simplify_with(path, &opts))?;
|
||||
let whole = bool_or(p, "wholePaths", false);
|
||||
// A path with segments direct-selected simplifies only their runs, and keeps those runs (and the
|
||||
// other anchors it had selected) direct-selected.
|
||||
let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| {
|
||||
let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else {
|
||||
return (po::simplify_with(path, &opts), None);
|
||||
};
|
||||
let part = po::simplify_part(path, &picked, &opts);
|
||||
let mut kept: BTreeSet<AnchorRef> = anchors.into_iter().flatten().filter_map(|a| part.anchors.get(a).copied()).collect();
|
||||
for &(si, i) in &part.segments {
|
||||
let m = part.path.subpaths.get(si).map_or(1, |sp| sp.anchors.len().max(1));
|
||||
kept.extend([(si, i), (si, (i + 1) % m)]);
|
||||
}
|
||||
let segs = segments.map(|_| part.segments.clone());
|
||||
(part.path, Some((kept, segs)))
|
||||
})?;
|
||||
let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after });
|
||||
if let Some(l) = level {
|
||||
out["level"] = json!(l);
|
||||
@@ -835,7 +894,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn add_anchor_points(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (ids, before, after) = edit_paths(s, "Add Anchor Points", po::add_anchor_points)?;
|
||||
let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||
}
|
||||
|
||||
|
||||
@@ -2572,6 +2572,8 @@ mod tests_shaper;
|
||||
#[cfg(test)]
|
||||
mod tests_shortcutset;
|
||||
#[cfg(test)]
|
||||
mod tests_simplify_part;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_smartguides;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Simplify of the direct-selected part of a path: with anchors or segments of a path
|
||||
//! direct-selected, `object.path.simplify` refits only each run of selected segments between its end
|
||||
//! anchors, as the Simplify help page describes for part of a path picked with Direct Selection
|
||||
//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
use vectorcraft_geom::{ParamCurve, PathData, SubPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn path_of(s: &Session, id: NodeId) -> PathData {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Path { path, .. } => path.clone(),
|
||||
_ => panic!("not a path"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A path through `points`, closed or open, selected whole: its id.
|
||||
fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = points.iter().map(|&(x, y)| json!({"x": x, "y": y})).collect();
|
||||
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A sine wave of 101 anchors, 3 pt apart, 40 pt high.
|
||||
fn wave(s: &mut Session) -> NodeId {
|
||||
let pts: Vec<(f64, f64)> = (0..=100).map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin())).collect();
|
||||
polyline(s, &pts, false)
|
||||
}
|
||||
|
||||
/// Direct-select anchors `ranges` (inclusive) of the first subpath of `id`.
|
||||
fn select_anchors(s: &mut Session, id: NodeId, ranges: &[(usize, usize)]) {
|
||||
let anchors: Vec<Value> = ranges.iter().flat_map(|&(a, b)| (a..=b).map(|i| json!([0, i]))).collect();
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors})).unwrap();
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// The farthest any of 11 points along each of `original`'s segments `segs` lies from `fitted`'s
|
||||
/// segments `fit`.
|
||||
fn worst_offset(original: &SubPath, segs: impl Iterator<Item = usize>, fitted: &SubPath, fit: std::ops::Range<usize>) -> f64 {
|
||||
let off = |p| fit.clone().map(|i| fitted.segment(i % fitted.segment_count()).nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
segs.flat_map(|i| (0..=10).map(move |k| original.segment(i).eval(f64::from(k) / 10.0))).map(off).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// With anchors 20 to 60 of a 101-anchor wave direct-selected, Simplify refits only the 40
|
||||
/// segments between them: anchors 0–19 and 61–100 and the segments outside the run stay exactly as
|
||||
/// they were, anchors 20 and 60 stay where they were with their handles on the side away from the
|
||||
/// run, the run stays within the tolerance of the original, and it stays direct-selected. It is one
|
||||
/// undo step, and undo brings back the path and the selection.
|
||||
#[test]
|
||||
fn simplify_refits_only_the_run_between_direct_selected_anchors() {
|
||||
let mut s = session();
|
||||
let id = wave(&mut s);
|
||||
let original = path_of(&s, id).subpaths[0].clone();
|
||||
select_anchors(&mut s, id, &[(20, 60)]);
|
||||
let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), undo_len(&s));
|
||||
let out = s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap();
|
||||
let p = path_of(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
let len = sp.anchors.len();
|
||||
assert_eq!(out["after"].as_u64(), Some(len as u64));
|
||||
assert!(len < 101 - 30, "{len}");
|
||||
let end = len - 41;
|
||||
assert_eq!(sp.anchors[..20], original.anchors[..20], "the anchors before the run");
|
||||
assert_eq!(sp.anchors[end + 1..], original.anchors[61..], "the anchors after the run");
|
||||
assert_eq!((sp.anchors[20].p, sp.anchors[20].h_in), (original.anchors[20].p, original.anchors[20].h_in));
|
||||
assert_eq!((sp.anchors[end].p, sp.anchors[end].h_out), (original.anchors[60].p, original.anchors[60].h_out));
|
||||
for i in 0..20 {
|
||||
assert_eq!(sp.segment(i), original.segment(i));
|
||||
assert_eq!(sp.segment(end + i), original.segment(60 + i));
|
||||
}
|
||||
assert!(worst_offset(&original, 20..60, sp, 20..end) <= 1.0 + 1e-6);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect()), "the run stays direct-selected");
|
||||
assert_eq!(undo_len(&s), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, doc);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
}
|
||||
|
||||
/// Two runs of one path simplify independently, in one undo step: the result is the first run as
|
||||
/// Simplify makes it alone, the anchors between the runs as they were, and the second run as
|
||||
/// Simplify makes it alone.
|
||||
#[test]
|
||||
fn separate_runs_on_one_path_simplify_independently_in_one_step() {
|
||||
let alone = |ranges: &[(usize, usize)]| {
|
||||
let mut s = session();
|
||||
let id = wave(&mut s);
|
||||
select_anchors(&mut s, id, ranges);
|
||||
s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap();
|
||||
path_of(&s, id).subpaths[0].anchors.clone()
|
||||
};
|
||||
let (first, second) = (alone(&[(10, 30)]), alone(&[(60, 80)]));
|
||||
let mut s = session();
|
||||
let id = wave(&mut s);
|
||||
let original = path_of(&s, id).subpaths[0].clone();
|
||||
select_anchors(&mut s, id, &[(10, 30), (60, 80)]);
|
||||
let undo = undo_len(&s);
|
||||
s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step");
|
||||
let both = path_of(&s, id).subpaths[0].anchors.clone();
|
||||
// Where old anchor 30 is in the first result, and old anchor 80 in the second.
|
||||
let (a30, a80) = (first.len() - 71, second.len() - 21);
|
||||
assert!(a30 < 30 && a80 < 80, "both runs lose anchors");
|
||||
let mid = a30 + 30;
|
||||
assert_eq!(both[..=a30], first[..=a30]);
|
||||
assert_eq!(both[a30 + 1..mid], original.anchors[31..60], "the anchors between the runs");
|
||||
assert_eq!(both[mid..], second[60..]);
|
||||
let sel: BTreeSet<_> = (10..=a30).chain(mid..=mid + a80 - 60).map(|i| (0, i)).collect();
|
||||
assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel));
|
||||
}
|
||||
|
||||
/// On a closed 80-gon, a run from anchor 70 through the start point to anchor 10 simplifies as one
|
||||
/// run: anchor 0 goes, anchors 11–69 stay exactly as they were (the path now starts at anchor 10),
|
||||
/// anchors 10 and 70 stay where they were with their outer handles, and the path stays closed.
|
||||
#[test]
|
||||
fn a_run_across_the_start_of_a_closed_path_simplifies_as_one() {
|
||||
let mut s = session();
|
||||
let pts: Vec<(f64, f64)> =
|
||||
(0..80).map(|i| (f64::from(i) * std::f64::consts::TAU / 80.0).sin_cos()).map(|(y, x)| (300.0 + 100.0 * x, 300.0 + 100.0 * y)).collect();
|
||||
let id = polyline(&mut s, &pts, true);
|
||||
let original = path_of(&s, id).subpaths[0].clone();
|
||||
select_anchors(&mut s, id, &[(70, 79), (0, 10)]);
|
||||
s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap();
|
||||
let sp = path_of(&s, id).subpaths[0].clone();
|
||||
assert!(sp.closed);
|
||||
assert!(sp.anchors.len() < 80 - 10, "{}", sp.anchors.len());
|
||||
assert!(!sp.anchors.iter().any(|a| a.p == original.anchors[0].p), "anchor 0 was inside the run");
|
||||
assert_eq!(sp.anchors[1..60], original.anchors[11..70]);
|
||||
assert_eq!((sp.anchors[0].p, sp.anchors[0].h_out), (original.anchors[10].p, original.anchors[10].h_out));
|
||||
assert_eq!((sp.anchors[60].p, sp.anchors[60].h_in), (original.anchors[70].p, original.anchors[70].h_in));
|
||||
for i in 0..60 {
|
||||
assert_eq!(sp.segment(i), original.segment(10 + i));
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
assert!(worst_offset(&original, (70..80).chain(0..10), &sp, 60..n) <= 1.0 + 1e-6);
|
||||
let sel: BTreeSet<_> = std::iter::once((0, 0)).chain((60..n).map(|i| (0, i))).collect();
|
||||
assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel));
|
||||
}
|
||||
|
||||
/// Segments picked with the Direct Selection tool simplify the same way, and stay picked as
|
||||
/// segments.
|
||||
#[test]
|
||||
fn picked_segments_simplify_and_stay_picked() {
|
||||
let mut s = session();
|
||||
let id = wave(&mut s);
|
||||
let original = path_of(&s, id).subpaths[0].clone();
|
||||
let segs: Vec<Value> = (20..60).map(|i| json!([0, i])).collect();
|
||||
s.execute("select.segments", &json!({"id": id.0, "segments": segs})).unwrap();
|
||||
s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap();
|
||||
let sp = path_of(&s, id).subpaths[0].clone();
|
||||
let end = sp.anchors.len() - 41;
|
||||
assert_eq!(sp.anchors[..20], original.anchors[..20]);
|
||||
assert_eq!(sp.anchors[end + 1..], original.anchors[61..]);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!(sel.segments.get(&id), Some(&(20..end).map(|i| (0, i)).collect()));
|
||||
assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect()));
|
||||
}
|
||||
|
||||
/// `wholePaths` simplifies a partly selected path whole, as Simplify with it selected whole does.
|
||||
#[test]
|
||||
fn whole_paths_ignores_the_direct_selection() {
|
||||
let mut s = session();
|
||||
let id = wave(&mut s);
|
||||
let original = path_of(&s, id);
|
||||
select_anchors(&mut s, id, &[(20, 60)]);
|
||||
s.execute("object.path.simplify", &json!({"tolerance": 1, "wholePaths": true})).unwrap();
|
||||
let opts = vectorcraft_pathops::SimplifyOptions { tolerance: 1.0, ..Default::default() };
|
||||
assert_eq!(path_of(&s, id), vectorcraft_pathops::simplify_with(&original, &opts));
|
||||
assert!(s.doc().unwrap().selection.anchors.is_empty());
|
||||
}
|
||||
|
||||
/// The Simplify slider's preview, at a level or at Auto-Simplify's, simplifies only the run too, and
|
||||
/// committing it is what the command does.
|
||||
#[test]
|
||||
fn slider_preview_and_auto_simplify_only_the_run() {
|
||||
for p in [json!({"level": 40}), json!({"auto": true})] {
|
||||
let mut s = session();
|
||||
let id = wave(&mut s);
|
||||
let original = path_of(&s, id).subpaths[0].clone();
|
||||
select_anchors(&mut s, id, &[(20, 60)]);
|
||||
let undo = undo_len(&s);
|
||||
s.begin_interaction("Simplify").unwrap();
|
||||
s.preview("object.path.simplify", &json!({"level": 90})).unwrap();
|
||||
s.preview("object.path.simplify", &p).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 1, "{p}");
|
||||
let previewed = path_of(&s, id);
|
||||
let sp = &previewed.subpaths[0];
|
||||
assert!(sp.anchors.len() < 101, "{p}");
|
||||
assert_eq!(sp.anchors[..20], original.anchors[..20], "{p}");
|
||||
assert_eq!(sp.anchors[sp.anchors.len() - 40..], original.anchors[61..], "{p}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.path.simplify", &p).unwrap();
|
||||
assert_eq!(path_of(&s, id), previewed, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A run Simplify can't make simpler (two sides of a rectangle, which meet at a corner) is left as
|
||||
/// it is: no undo step, and the rectangle stays a live shape.
|
||||
#[test]
|
||||
fn a_run_that_cannot_be_simpler_changes_nothing() {
|
||||
let mut s = session();
|
||||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
select_anchors(&mut s, id, &[(0, 2)]);
|
||||
let undo = undo_len(&s);
|
||||
s.execute("object.path.simplify", &json!({"tolerance": 5})).unwrap();
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
+170
-52
@@ -1,8 +1,10 @@
|
||||
//! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average,
|
||||
//! Join, Split Into Grid.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use kurbo::{CubicBez, ParamCurveArclen, Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
use crate::boolean::{Seg, segs_to_subpath};
|
||||
use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent};
|
||||
@@ -69,64 +71,180 @@ fn turn_deg(a: &Seg, b: &Seg) -> f64 {
|
||||
|
||||
/// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold.
|
||||
pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData {
|
||||
let tol = opts.tolerance.max(1e-6);
|
||||
let mut out = Vec::new();
|
||||
for sp in &path.subpaths {
|
||||
let mut segs = subpath_segs(sp);
|
||||
let n = segs.len();
|
||||
if n == 0 {
|
||||
out.push(sp.clone());
|
||||
continue;
|
||||
}
|
||||
let corner_at = |segs: &[Seg], i: usize| -> bool {
|
||||
if !sp.closed && (i == 0 || i == n) {
|
||||
return true;
|
||||
}
|
||||
turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg
|
||||
};
|
||||
let mut corners: Vec<usize> = (0..n).filter(|&i| corner_at(&segs, i)).collect();
|
||||
if sp.closed {
|
||||
if let Some(&k) = corners.first() {
|
||||
segs.rotate_left(k);
|
||||
corners = corners.iter().map(|&c| c - k).collect();
|
||||
} else {
|
||||
corners = vec![0];
|
||||
}
|
||||
corners.push(n);
|
||||
} else if corners.last() != Some(&n) {
|
||||
corners.push(n);
|
||||
}
|
||||
let mut result: Vec<Seg> = Vec::new();
|
||||
for w in corners.windows(2) {
|
||||
let run = &segs[w[0]..w[1]];
|
||||
if run.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
for (k, s) in run.iter().enumerate() {
|
||||
let len = s.c.arclen(1e-6);
|
||||
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
|
||||
sample(&s.c, m, &mut pts, k == 0);
|
||||
}
|
||||
if opts.straight_lines {
|
||||
let keep = rdp(&pts, tol);
|
||||
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
|
||||
continue;
|
||||
}
|
||||
let t0 = start_tangent(&run[0].c);
|
||||
let t1 = end_tangent(&run[run.len() - 1].c);
|
||||
for c in fit_cubics(&pts, t0, t1, tol) {
|
||||
let line = is_straight(&c, tol * 0.02);
|
||||
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
|
||||
}
|
||||
}
|
||||
if let Some(s) = segs_to_subpath(&result, sp.closed) {
|
||||
out.push(if opts.straight_lines { s } else { no_worse(sp, s) });
|
||||
if let Some(s) = simplify_subpath(sp, opts) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
PathData::new(out)
|
||||
}
|
||||
|
||||
/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it.
|
||||
fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> {
|
||||
let mut segs = subpath_segs(sp);
|
||||
let n = segs.len();
|
||||
if n == 0 {
|
||||
return Some(sp.clone());
|
||||
}
|
||||
if sp.closed {
|
||||
// Start the loop at its first corner, so no fit runs across one.
|
||||
let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0);
|
||||
segs.rotate_left(k);
|
||||
}
|
||||
let result = simplify_run(&segs, opts);
|
||||
let s = segs_to_subpath(&result, sp.closed)?;
|
||||
Some(if opts.straight_lines { s } else { no_worse(sp, s) })
|
||||
}
|
||||
|
||||
/// The refit of the consecutive segments `run`, split at its corners sharper than the threshold:
|
||||
/// it starts and ends where `run` does, leaving the same way.
|
||||
fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec<Seg> {
|
||||
let tol = opts.tolerance.max(1e-6);
|
||||
let mut cuts: Vec<usize> = (1..run.len()).filter(|&i| turn_deg(&run[i - 1], &run[i]) > opts.corner_angle_deg).collect();
|
||||
cuts.insert(0, 0);
|
||||
cuts.push(run.len());
|
||||
let mut result: Vec<Seg> = Vec::new();
|
||||
for w in cuts.windows(2) {
|
||||
let piece = &run[w[0]..w[1]];
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut pts = Vec::new();
|
||||
for (k, s) in piece.iter().enumerate() {
|
||||
let len = s.c.arclen(1e-6);
|
||||
let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256);
|
||||
sample(&s.c, m, &mut pts, k == 0);
|
||||
}
|
||||
if opts.straight_lines {
|
||||
let keep = rdp(&pts, tol);
|
||||
result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1])));
|
||||
continue;
|
||||
}
|
||||
let t0 = start_tangent(&piece[0].c);
|
||||
let t1 = end_tangent(&piece[piece.len() - 1].c);
|
||||
for c in fit_cubics(&pts, t0, t1, tol) {
|
||||
let line = is_straight(&c, tol * 0.02);
|
||||
result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } });
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// What [`simplify_part`] made of a path.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SimplifiedPart {
|
||||
pub path: PathData,
|
||||
/// The segments of the simplified runs, as (subpath, segment) in `path`.
|
||||
pub segments: BTreeSet<(usize, usize)>,
|
||||
/// Where each anchor of the original path that is still there went: (subpath, anchor) in the
|
||||
/// original → in `path`. Anchors inside a simplified run are gone; every other anchor is kept.
|
||||
pub anchors: BTreeMap<(usize, usize), (usize, usize)>,
|
||||
}
|
||||
|
||||
/// Simplify only the `segments` (subpath, segment) of `path`: each run of consecutive segments
|
||||
/// among them (a closed subpath's run may cross its start point) is refitted on its own between its
|
||||
/// two end anchors, which stay where they are with their handles on the side away from the run.
|
||||
/// The other segments and anchors stay exactly as they are. A subpath whose every segment is in
|
||||
/// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more
|
||||
/// segments than it had is left as it is (except with Convert to Straight Lines).
|
||||
pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart {
|
||||
let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new());
|
||||
let mut subpaths = Vec::with_capacity(path.subpaths.len());
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let n = sp.segment_count();
|
||||
let picked: Vec<bool> = (0..n).map(|i| segments.contains(&(si, i))).collect();
|
||||
let ni = subpaths.len();
|
||||
if !picked.contains(&true) {
|
||||
anchors.extend((0..sp.anchors.len()).map(|ai| ((si, ai), (ni, ai))));
|
||||
subpaths.push(sp.clone());
|
||||
} else if !picked.contains(&false) {
|
||||
if let Some(s) = simplify_subpath(sp, opts) {
|
||||
runs.extend((0..s.segment_count()).map(|i| (ni, i)));
|
||||
subpaths.push(s);
|
||||
}
|
||||
} else {
|
||||
let (s, segs, kept) = simplify_runs(sp, &picked, opts);
|
||||
runs.extend(segs.into_iter().map(|i| (ni, i)));
|
||||
anchors.extend(kept.into_iter().map(|(old, new)| ((si, old), (ni, new))));
|
||||
subpaths.push(s);
|
||||
}
|
||||
}
|
||||
SimplifiedPart { path: PathData::new(subpaths), segments: runs, anchors }
|
||||
}
|
||||
|
||||
/// [`simplify_part`] for a subpath with some but not all segments `picked`: → (the subpath, the
|
||||
/// segments of its refitted runs, old → new index of each anchor kept).
|
||||
fn simplify_runs(sp: &SubPath, picked: &[bool], opts: &SimplifyOptions) -> (SubPath, Vec<usize>, Vec<(usize, usize)>) {
|
||||
let m = sp.anchors.len();
|
||||
let n = picked.len();
|
||||
// An anchor inside a run (both its segments picked) goes; a closed subpath's walk starts at the
|
||||
// first anchor that stays, so that no run crosses the start of the walk.
|
||||
let inside = |a: usize| (sp.closed && a == 0 && picked[n - 1] && picked[0]) || (a > 0 && a < n && picked[a - 1] && picked[a]);
|
||||
let start = (0..m).find(|&a| !inside(a)).unwrap_or(0);
|
||||
let mut anchors = vec![sp.anchors[start]];
|
||||
let mut kept = vec![(start, 0)];
|
||||
let mut runs = vec![];
|
||||
let mut k = 0;
|
||||
while k < n {
|
||||
let j = (start + k) % n;
|
||||
if !picked[j] {
|
||||
let next = (j + 1) % m;
|
||||
if k + 1 < n || !sp.closed {
|
||||
kept.push((next, anchors.len()));
|
||||
anchors.push(sp.anchors[next]);
|
||||
}
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let len = (k..n).take_while(|&i| picked[(start + i) % n]).count();
|
||||
let run: Vec<Seg> = (k..k + len).map(|i| (start + i) % n).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect();
|
||||
let end = (start + k + len) % m;
|
||||
let fitted = simplify_run(&run, opts);
|
||||
let keep_original = fitted.is_empty() || !opts.straight_lines && fitted.len() > run.len();
|
||||
let first = anchors.len() - 1;
|
||||
if keep_original {
|
||||
anchors.extend((k + 1..k + len).map(|i| sp.anchors[(start + i) % m]));
|
||||
} else {
|
||||
let a = anchors[first];
|
||||
anchors[first] = with_handle(a, true, if fitted[0].line { a.p } else { fitted[0].c.p1 });
|
||||
for w in fitted.windows(2) {
|
||||
let p = w[1].c.p0;
|
||||
let h_in = if w[0].line { p } else { w[0].c.p2 };
|
||||
let h_out = if w[1].line { p } else { w[1].c.p1 };
|
||||
anchors.push(Anchor::with_handles(p, h_in, h_out));
|
||||
}
|
||||
}
|
||||
// The run's segments, from its first anchor on: a run back to the walk's start ends with
|
||||
// the closing segment, the last index.
|
||||
runs.extend(first..first + if keep_original { run.len() } else { fitted.len() });
|
||||
let end_seg = fitted.last().filter(|_| !keep_original);
|
||||
if sp.closed && k + len == n {
|
||||
// The run ends where the walk started.
|
||||
if let Some(s) = end_seg {
|
||||
anchors[0] = with_handle(anchors[0], false, if s.line { anchors[0].p } else { s.c.p2 });
|
||||
}
|
||||
} else {
|
||||
let a = sp.anchors[end];
|
||||
kept.push((end, anchors.len()));
|
||||
anchors.push(match end_seg {
|
||||
Some(s) => with_handle(a, false, if s.line { a.p } else { s.c.p2 }),
|
||||
None => a,
|
||||
});
|
||||
}
|
||||
k += len;
|
||||
}
|
||||
(SubPath::new(anchors, sp.closed), runs, kept)
|
||||
}
|
||||
|
||||
/// `a` with its out (`out`) or in handle at `h` and the other as it is: still smooth when it was
|
||||
/// and its handles still line up, a corner otherwise.
|
||||
fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor {
|
||||
let (h_in, h_out) = if out { (a.h_in, h) } else { (h, a.h_out) };
|
||||
let kind = if a.kind == AnchorKind::Smooth { Anchor::with_handles(a.p, h_in, h_out).kind } else { AnchorKind::Corner };
|
||||
Anchor { p: a.p, h_in, h_out, kind }
|
||||
}
|
||||
|
||||
/// Ramer–Douglas–Peucker polyline reduction.
|
||||
fn rdp(pts: &[Point], tol: f64) -> Vec<Point> {
|
||||
if pts.len() < 3 {
|
||||
|
||||
@@ -15,8 +15,8 @@ mod planar;
|
||||
|
||||
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
|
||||
pub use edit::{
|
||||
AverageAxis, SIMPLIFY_LEVEL, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, remove_redundant_points,
|
||||
simplify, simplify_level_tolerance, simplify_with, smooth, split_into_grid,
|
||||
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};
|
||||
|
||||
@@ -479,6 +479,54 @@ fn simplify_slider_auto_simplify_and_a_click_outside_keep_the_result() {
|
||||
assert_ne!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// With anchors 10 to 30 of a 41-anchor wave direct-selected, the Simplify slider previews and
|
||||
/// Enter keeps a refit of only the segments between them, as one undo step: the anchors before and
|
||||
/// after the run stay exactly as they were. The Simplify dialog (More Options) does the same.
|
||||
#[test]
|
||||
fn simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path() {
|
||||
let ctx = ctx();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 20 + i * 8, "y": 150.0 + 60.0 * (f64::from(i) / 6.0).sin()})).collect();
|
||||
let id = app.run("path.create", json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap();
|
||||
let path = |app: &VectorcraftApp| {
|
||||
app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).and_then(|n| n.path_data()).expect("a path").subpaths[0].anchors.clone()
|
||||
};
|
||||
let original = path(&app);
|
||||
let run: Vec<_> = (10..=30).map(|i| json!([0, i])).collect();
|
||||
app.run("select.anchors", json!({"id": id, "anchors": run})).unwrap();
|
||||
let undo_len = app.session.doc().unwrap().history.undo.len();
|
||||
let kept = |app: &VectorcraftApp, how: &str| {
|
||||
let now = path(app);
|
||||
assert!(now.len() < 41, "{how}: the run loses anchors");
|
||||
assert_eq!(now[..10], original[..10], "{how}: the anchors before the run");
|
||||
assert_eq!(now[10].p, original[10].p, "{how}: the run's start anchor");
|
||||
assert_eq!(now[now.len() - 10..], original[31..], "{how}: the anchors after the run");
|
||||
assert_eq!(now[now.len() - 11].p, original[30].p, "{how}: the run's end anchor");
|
||||
};
|
||||
crate::menus::invoke(&mut app, "object.path.simplify", json!({}));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("level".into(), json!(20));
|
||||
settle(&ctx, &mut app);
|
||||
assert_eq!(simplify_preview(&app), json!({"level": 20.0}));
|
||||
kept(&app, "the slider's preview");
|
||||
frame(
|
||||
&ctx,
|
||||
&mut app,
|
||||
vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
|
||||
);
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1);
|
||||
kept(&app, "the slider");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(path(&app), original);
|
||||
open_simplify_dialog(&mut app);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("tolerance".into(), json!(4));
|
||||
settle(&ctx, &mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1);
|
||||
kept(&app, "the dialog");
|
||||
}
|
||||
|
||||
/// More Options opens the Simplify dialog at the slider's tolerance. With "Open this dialog with
|
||||
/// these settings next time" ticked, OK makes the menu item open the dialog on its settings; with
|
||||
/// it unticked, the slider again.
|
||||
|
||||
+4
-2
File diff suppressed because one or more lines are too long
+12
@@ -2318,6 +2318,18 @@ whose tolerance is at most 1/400 of the selection's longer side, or level 100 wh
|
||||
{"name":"run_command","arguments":{"command":"object.path.simplify","params":{"auto":true}}}
|
||||
```
|
||||
|
||||
With segments of a path direct-selected (`select.segments`, or `select.anchors` with both anchors of a segment), Simplify
|
||||
refits only each run of consecutive selected segments, between the run's end anchors: they stay where they are, with
|
||||
their handles on the side away from the run, and every other anchor and segment of the path stays exactly as it was. A
|
||||
closed path's run may cross its start point; the path then starts at the run's end anchor. Separate runs on one path
|
||||
simplify independently, in one undo step, and the runs stay direct-selected (as segments when segments were picked).
|
||||
A path with only lone anchors selected, and every path with `wholePaths: true`, simplifies whole:
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"select.anchors","params":{"id":12,"anchors":[[0,20],[0,21],[0,22],[0,23]]}}}
|
||||
{"name":"run_command","arguments":{"command":"object.path.simplify","params":{"auto":true}}}
|
||||
```
|
||||
|
||||
In the desktop app, Object › Path › Simplify opens the slider (`ui.menuDialog {command: "object.path.simplify"}`,
|
||||
dialog `simplifySlider` with field `level`) at the Auto-Simplify level for the selection and previews the result until
|
||||
`ui.dialog.confirm`. `ui.simplifyAuto` moves the open slider back to the Auto-Simplify level, and `ui.simplifyMoreOptions`
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
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