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:
BFlat
2026-10-24 02:27:35 +07:00
committed by GitHub
11 changed files with 545 additions and 76 deletions
+72 -13
View File
@@ -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 }))
}
+2
View File
@@ -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;
+223
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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};
+48
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+12
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long