From 4fae51023ef36e90619f7fed9955559c7b554174 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 06:54:55 -0800 Subject: [PATCH] Paths: Remove Anchor Points removes many anchors of a subpath in one pass Object > Path > Remove Anchor Points removed each selected anchor with its own Vec::remove, shifting the rest of the subpath every time, so removing k anchors from an n-anchor subpath took O(k*n) moves. - pathops: remove_anchors removes a subpath's anchors the last first, reading each anchor's current neighbors, handles, length and closed state as removing them one at a time does, and compacts the anchor list once. remove_anchor and remove_anchors share one refit, so the result is bit for bit the same. - engine: the command groups the selected anchors by subpath, works out the next selected anchor with each subpath reading only its own selected anchors, and takes emptied paths out of the selection together (Selection::remove_all). - Tests compare remove_anchors bit for bit with remove_anchor over 600 generated subpaths and focused cases, count the anchors moved (2001 anchors, every other one removed: at most one move per anchor left, 500,500 one at a time), and compare the next-anchor selection and the command on many paths and subpaths with removing one at a time. --- crates/doc/src/selection.rs | 19 ++ crates/doc/src/selection/tests.rs | 29 +++ crates/engine/src/cmd/draw2/anchors.rs | 102 +++++++++-- crates/engine/src/tests/draw2.rs | 43 +++++ crates/pathops/src/edit.rs | 241 +++++++++++++++++++++++-- crates/pathops/src/lib.rs | 4 +- 6 files changed, 397 insertions(+), 41 deletions(-) diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 6f0db556e0..4ef6446247 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -144,6 +144,25 @@ impl Selection { self.key = None; } } + /// [`Self::remove`] for each of `ids`, going through the selected objects once. + pub fn remove_all(&mut self, ids: impl IntoIterator) { + let ids: HashSet = ids.into_iter().collect(); + if ids.is_empty() { + return; + } + self.target = None; + self.slices.clear(); + self.symbol_part = None; + self.on_path_member = None; + self.objects.retain(|x| !ids.contains(x)); + for id in &ids { + self.anchors.remove(id); + self.segments.remove(id); + } + if self.key.is_some_and(|k| ids.contains(&k)) { + self.key = None; + } + } /// Select slices `ids` (see [`Selection::slices`]) and nothing else. pub fn set_slices(&mut self, ids: impl IntoIterator) { self.clear(); diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d9df845129..b6090f8993 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -16,6 +16,35 @@ fn basic_ops() { assert!(s.is_empty()); } +#[test] +fn remove_all_does_what_removing_each_does() { + let mut s = Selection::default(); + for i in 1..=6 { + s.add(NodeId(i)); + } + for i in [2, 4, 5] { + s.anchors.insert(NodeId(i), BTreeSet::from([(0, 1)])); + s.segments.insert(NodeId(i), BTreeSet::from([(0, 0)])); + } + s.key = Some(NodeId(4)); + s.target = Some(NodeId(3)); + s.slices = vec![NodeId(9)]; + s.guides = vec![NodeId(10)]; + s.on_path_member = Some(NodeId(11)); + let mut one_by_one = s.clone(); + for id in [NodeId(4), NodeId(1), NodeId(4), NodeId(7)] { + one_by_one.remove(id); + } + let mut all = s.clone(); + all.remove_all([NodeId(4), NodeId(1), NodeId(4), NodeId(7)]); + assert_eq!(all, one_by_one); + assert_eq!(all.objects, [NodeId(2), NodeId(3), NodeId(5), NodeId(6)]); + // Removing nothing leaves the selection as it was. + let mut none = s.clone(); + none.remove_all([]); + assert_eq!(none, s); +} + #[test] fn guides_are_selected_on_their_own() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 10254f3cd3..5b80fcc80f 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -116,8 +116,11 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no such anchor".into())); } let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?; + let removed = remove_anchors_of(d, sel, &mut emptied, id, vec![(si, ai)], curvature)?; emptied.finish(d)?; + if removed { + sel.remove(id); + } Ok(removed) })?; Ok(json!({ "removedObject": removed })) @@ -136,14 +139,14 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { return Err(bad("path.removeAnchors", "direct-select anchor points of a path")); } let removed = s.edit("Remove Anchor Points", |d, sel| { - let mut removed = 0; + let mut gone = Vec::new(); let mut emptied = Beside::replacing(d, &jobs.iter().map(|(id, _)| *id).collect::>()); for (id, anchors) in jobs { let shape: Vec<(usize, bool)> = d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); let next = following_anchors(&shape, &anchors); if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? { - removed += 1; + gone.push(id); } else if !next.is_empty() { // The next anchor along is selected, so the next Remove Anchor Points goes on from // there without picking it first (#2241). @@ -151,7 +154,8 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { } } emptied.finish(d)?; - Ok(removed) + sel.remove_all(gone.iter().copied()); + Ok(gone.len()) })?; Ok(json!({ "removedObjects": removed })) } @@ -160,19 +164,32 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { /// path whose subpaths have `shape` (anchor count, closed): in each subpath, the anchor after its /// last removed one, round the end of a closed subpath, or the one before it at an open subpath's /// end. Indexes are those after the removal, without the subpaths left with fewer than two anchors. +/// `removed` is in ascending order without repeats, as a selection holds it, and each subpath reads +/// only its own pairs. fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { let mut next = BTreeSet::new(); let mut kept = 0; + let mut rest = removed; for (si, &(n, closed)) in shape.iter().enumerate() { - let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count()); + rest = after; + let gone = &mine[..mine.partition_point(|&(_, a)| a < n)]; if n - gone.len() < 2 { continue; } - if let Some(&last) = gone.last() { - let wrap = if closed { 0..last } else { 0..0 }; - let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); - if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { - next.insert((kept, a - gone.range(..a).count())); + if let Some(&(_, last)) = gone.last() { + // Anchors past `last` are all left. Below it, the first one left (round a closed + // subpath's end) is past the removed run from 0, and the last one left is below the + // removed run down from `last`. + let a = if last + 1 < n { + Some(last + 1) + } else if closed { + Some(gone.iter().zip(0..).take_while(|&(&(_, g), k)| g == k).count()) + } else { + last.checked_sub(gone.iter().rev().zip((0..=last).rev()).take_while(|&(&(_, g), k)| g == k).count()) + }; + if let Some(a) = a { + next.insert((kept, a - gone.partition_point(|&(_, g)| g < a))); } } kept += 1; @@ -183,34 +200,37 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr /// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round /// each one, or with `curvature` re-curving it as the Curvature tool curves its points /// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed, -/// and a path left with no subpath is removed through `emptied`. True when the path went. +/// and a path left with no subpath is removed through `emptied`; the caller then takes it out of +/// the selection. True when the path went. fn remove_anchors_of( d: &mut Document, sel: &mut Selection, emptied: &mut Beside, id: NodeId, - anchors: impl IntoIterator, + mut anchors: Vec<(usize, usize)>, curvature: bool, ) -> Result { - let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); // The last first, so the indexes still to come stay valid. anchors.sort_unstable_by(|a, b| b.cmp(a)); anchors.dedup(); let path = path_mut(d, id)?; - for (si, ai) in anchors { - if let Some(sp) = path.subpaths.get_mut(si) { - if curvature { + let mut rest = &anchors[..]; + while let Some(&(si, _)) = rest.first() { + let (mine, after) = rest.split_at(rest.iter().take_while(|&&(s, _)| s == si).count()); + rest = after; + let Some(sp) = path.subpaths.get_mut(si) else { continue }; + if curvature { + for &(_, ai) in mine { vectorcraft_tools::draw2::curvature_remove(sp, ai); - } else { - po::remove_anchor(sp, ai); } + } else { + po::remove_anchors(sp, &mine.iter().map(|&(_, ai)| ai).collect::>()); } } path.subpaths.retain(|sp| sp.anchors.len() >= 2); let gone = path.is_empty(); if gone { emptied.put(d, id, vec![])?; - sel.remove(id); } else { sel.anchors.remove(&id); sel.segments.remove(&id); @@ -277,3 +297,47 @@ pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { })?; ok() } + +#[cfg(test)] +mod tests { + use super::*; + + /// [`following_anchors`] by its definition: for each subpath, a search of the anchors after + /// its last removed one, round a closed end, then before it. + fn following_by_search(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTreeSet<(usize, usize)> { + let mut next = BTreeSet::new(); + let mut kept = 0; + for (si, &(n, closed)) in shape.iter().enumerate() { + let gone: BTreeSet = removed.iter().filter(|&&(s, a)| s == si && a < n).map(|&(_, a)| a).collect(); + if n - gone.len() < 2 { + continue; + } + if let Some(&last) = gone.last() { + let wrap = if closed { 0..last } else { 0..0 }; + let after = (last + 1..n).chain(wrap).find(|a| !gone.contains(a)); + if let Some(a) = after.or_else(|| (0..last).rev().find(|a| !gone.contains(a))) { + next.insert((kept, a - gone.range(..a).count())); + } + } + kept += 1; + } + next + } + + #[test] + fn following_anchors_matches_a_search_along_each_subpath() { + let mut seed = 0x2545_f491_4f6c_dd1d_u64; + let mut rnd = |m: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % m) as usize + }; + for case in 0..2000 { + let shape: Vec<(usize, bool)> = (0..rnd(5) + 1).map(|_| (rnd(9), rnd(2) == 0)).collect(); + let removed: BTreeSet<(usize, usize)> = (0..rnd(14)).map(|_| (rnd(shape.len() as u64 + 1), rnd(11))).collect(); + let removed: Vec<_> = removed.into_iter().collect(); + assert_eq!(following_anchors(&shape, &removed), following_by_search(&shape, &removed), "case {case}: {shape:?} removing {removed:?}"); + } + } +} diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 5986b618fd..104c2c144c 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2533,6 +2533,49 @@ fn remove_anchor_points_selects_one_next_anchor_per_subpath_and_per_path() { assert_eq!(selected_anchors(&s, other), [(0, 0)]); } +#[test] +fn remove_anchor_points_on_many_paths_and_subpaths_matches_removing_one_at_a_time() { + // Many anchors over many subpaths and paths, curved and straight, open and closed: each + // subpath ends as removing its anchors one at a time, the last first, leaves it. Paths left + // with nothing go, the rest stay selected in order, and one undo step brings all back. + let mut s = session(); + let curvy = "M0 0 C10 20 30 20 40 0 C50 -20 70 -20 80 0 C90 20 110 20 120 0 C130 -20 150 -20 160 0 Z \ + M0 100 L40 120 C60 140 80 80 100 100 L140 90 L180 110 L220 100"; + let ids: Vec = (0..4).map(|i| path_from(&mut s, if i == 2 { "M0 0 L10 0 L20 5" } else { curvy })).collect(); + let picks: [&[(usize, usize)]; 4] = + [&[(0, 0), (0, 2), (0, 3), (1, 1), (1, 2), (1, 5)], &[(0, 1), (1, 0), (1, 6)], &[(0, 0), (0, 1)], &[(0, 3), (1, 3), (1, 4)]]; + for (k, (&id, &anchors)) in ids.iter().zip(&picks).enumerate() { + let mode = if k == 0 { "set" } else { "add" }; + s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors, "mode": mode})).unwrap(); + } + let before: Vec = ids.iter().map(|&id| path(&s, id)).collect(); + assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 1); + for (k, &id) in ids.iter().enumerate() { + let mut want = before[k].clone(); + let mut order = picks[k].to_vec(); + order.sort_unstable_by(|a, b| b.cmp(a)); + for (si, ai) in order { + vectorcraft_pathops::remove_anchor(&mut want.subpaths[si], ai); + } + want.subpaths.retain(|sp| sp.anchors.len() >= 2); + if want.subpaths.is_empty() { + assert!(s.doc().unwrap().doc.node(id).is_none(), "path {k} goes"); + } else { + assert_eq!(path(&s, id).subpaths, want.subpaths, "path {k}"); + } + } + let kept = [ids[0], ids[1], ids[3]]; + assert_eq!(s.doc().unwrap().selection.objects, kept); + s.execute("edit.undo", &json!({})).unwrap(); + for (k, &id) in ids.iter().enumerate() { + assert_eq!(path(&s, id), before[k]); + assert_eq!(selected_anchors(&s, id), picks[k]); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(ids[2]).is_none()); + assert_eq!(s.doc().unwrap().selection.objects, kept); +} + #[test] fn remove_anchor_point_tool_and_delete_still_select_the_remainder_whole() { // Only Remove Anchor Points moves the selection on. The Delete Anchor Point tool leaves the diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index d8d121ecb9..87b9885f75 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -860,27 +860,10 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { return false; } if (sp.closed || (index > 0 && index + 1 < n)) && n >= 3 { - let (pi, ni) = ((index + n - 1) % n, (index + 1) % n); - if !(sp.segment_is_line(pi) && sp.segment_is_line(index)) { - let (left, right) = (sp.segment(pi), sp.segment(index)); - const N: usize = 16; - let mut pts = Vec::with_capacity(2 * N + 1); - sample(&left, N, &mut pts, true); - sample(&right, N, &mut pts, false); - let (t0, t1) = (start_tangent(&left), end_tangent(&right)); - // The new curve passes the removed point at some parameter k: the left samples sit - // at k·i/N, the right ones at k + (1 − k)·i/N. Search k for the closest fit. - let fit = |k: f64| { - let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); - fit_single_from(&pts, u, t0, t1) - }; - let (c, _, _) = golden_min(fit, 0.0, 1.0); - if c.p1.is_finite() && c.p2.is_finite() { - sp.anchors[pi].h_out = c.p1; - sp.anchors[ni].h_in = c.p2; - } - } + refit_across(&mut sp.anchors, (index + n - 1) % n, index, (index + 1) % n); } + #[cfg(test)] + tests::count_moves(n - index - 1); sp.anchors.remove(index); if !sp.closed { // A new end has nothing beyond it. @@ -897,6 +880,101 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { true } +/// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that +/// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as +/// they are, and so are the handles when the fit is not finite. +fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) { + let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return }; + let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in(); + if line(&a, &b) && line(&b, &c) { + return; + } + let (left, right) = (CubicBez::new(a.p, a.h_out, b.h_in, b.p), CubicBez::new(b.p, b.h_out, c.h_in, c.p)); + const N: usize = 16; + let mut pts = Vec::with_capacity(2 * N + 1); + sample(&left, N, &mut pts, true); + sample(&right, N, &mut pts, false); + let (t0, t1) = (start_tangent(&left), end_tangent(&right)); + // The new curve passes the removed point at some parameter k: the left samples sit at k·i/N, + // the right ones at k + (1 − k)·i/N. Search k for the closest fit. + let fit = |k: f64| { + let u = (0..=N).map(|i| k * i as f64 / N as f64).chain((1..=N).map(|i| k + (1.0 - k) * i as f64 / N as f64)).collect(); + fit_single_from(&pts, u, t0, t1) + }; + let (curve, _, _) = golden_min(fit, 0.0, 1.0); + if curve.p1.is_finite() && curve.p2.is_finite() { + anchors[pi].h_out = curve.p1; + anchors[ni].h_in = curve.p2; + } +} + +/// [`remove_anchor`] for each of `indexes`, the last first, with the same result, moving each +/// anchor that stays at most once: removing many anchors from a long subpath one at a time shifts +/// the rest of it each time. Indexes out of range and repeats are skipped. Returns how many +/// anchors were removed. +pub fn remove_anchors(sp: &mut SubPath, indexes: &[usize]) -> usize { + let total = sp.anchors.len(); + let mut targets: Vec = indexes.iter().copied().filter(|&i| i < total).collect(); + targets.sort_unstable_by(|a, b| b.cmp(a)); + targets.dedup(); + if targets.is_empty() { + return 0; + } + // Every anchor below the one being removed is still there, so it keeps its index, the one + // before it is `index - 1` and the first one left is anchor 0 until anchor 0 itself goes. + let mut gone = vec![false; total]; + let mut len = total; + let mut last = total - 1; + // The lowest anchor removed so far and the first one left after it. + let (mut lowest, mut after_lowest) = (total, None); + for &index in &targets { + let next = if index + 1 < lowest { Some(index + 1) } else { after_lowest }; + if (sp.closed || (index > 0 && next.is_some())) && len >= 3 { + let pi = if index > 0 { index - 1 } else { last }; + refit_across(&mut sp.anchors, pi, index, next.unwrap_or(0)); + } + gone[index] = true; + len -= 1; + (lowest, after_lowest) = (index, next); + if next.is_none() { + last = index.saturating_sub(1); + } + if len == 0 { + continue; + } + let first = if index > 0 { 0 } else { next.unwrap_or(0) }; + if !sp.closed { + // A new end has nothing beyond it. + if let Some(a) = sp.anchors.get_mut(first) { + a.h_in = a.p; + } + if let Some(a) = sp.anchors.get_mut(last) { + a.h_out = a.p; + } + } + if len < 3 { + let ends = [first, last].map(|i| sp.anchors.get(i).is_some_and(|a| a.has_in() || a.has_out())); + sp.closed = sp.closed && len == 2 && (ends[0] || ends[1]); + } + } + if len == 0 { + sp.closed = false; + } + let mut kept = 0; + for (i, &g) in gone.iter().enumerate() { + if !g { + if kept != i { + sp.anchors[kept] = sp.anchors[i]; + #[cfg(test)] + tests::count_moves(1); + } + kept += 1; + } + } + sp.anchors.truncate(kept); + targets.len() +} + /// The fit `f` (curve, error, worst point) with the least error for an argument in `[a, b]`, by a /// golden-section search (a bounded number of steps). fn golden_min(f: impl Fn(f64) -> (CubicBez, f64, usize), mut a: f64, mut b: f64) -> (CubicBez, f64, usize) { @@ -1107,6 +1185,129 @@ fn end_point(sp: &SubPath, last: bool) -> Option { mod tests { use super::*; + std::thread_local! { + /// Anchors moved within a subpath's anchor list by [`remove_anchor`] and [`remove_anchors`]. + static MOVES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + pub(super) fn count_moves(n: usize) { + MOVES.set(MOVES.get() + n); + } + + /// A subpath of `n` anchors from `seed`: corners, smooth points and points with one handle. + fn random_subpath(seed: &mut u64, n: usize, closed: bool) -> SubPath { + let mut rnd = || { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * 20.0 + (rnd() % 100) as f64 / 10.0, (rnd() % 400) as f64 / 4.0); + let off = Vec2::new((rnd() % 300) as f64 / 10.0 - 15.0, (rnd() % 300) as f64 / 10.0 - 15.0); + match rnd() % 4 { + 0 => Anchor::corner(p), + 1 => Anchor::smooth(p, p + off), + 2 => Anchor::with_handles(p, p, p + off), + _ => Anchor::with_handles(p, p - off, p), + } + }) + .collect(); + SubPath::new(anchors, closed) + } + + /// `sp` with [`remove_anchor`] run for each of `indexes`, the last first. + fn one_at_a_time(sp: &SubPath, indexes: &[usize]) -> SubPath { + let mut order = indexes.to_vec(); + order.sort_unstable_by(|a, b| b.cmp(a)); + order.dedup(); + let mut out = sp.clone(); + for i in order { + remove_anchor(&mut out, i); + } + out + } + + fn assert_same_bits(got: &SubPath, want: &SubPath, what: &str) { + let bits = |sp: &SubPath| -> Vec<[u64; 6]> { + sp.anchors.iter().map(|a| [a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits)).collect() + }; + assert_eq!(got.closed, want.closed, "closed: {what}"); + assert_eq!(bits(got), bits(want), "{what}"); + assert_eq!(got.anchors.iter().map(|a| a.kind).collect::>(), want.anchors.iter().map(|a| a.kind).collect::>(), "{what}"); + } + + #[test] + fn removing_anchors_together_gives_what_removing_them_one_at_a_time_gives() { + let mut seed = 0x9e37_79b9_7f4a_7c15_u64; + for case in 0..600 { + let n = case % 40 + 1; + let closed = case % 2 == 0; + let sp = random_subpath(&mut seed, n, closed); + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + // Up to n + 2 picks over 0..n + 2: runs of neighbors, both ends, everything, repeats + // and indexes past the end. + let picks = (seed % (n as u64 + 3)) as usize; + let mut indexes: Vec = (0..picks).map(|k| ((seed >> (k % 48)) as usize).wrapping_add(k * 7) % (n + 2)).collect(); + if case % 5 == 0 { + indexes = (0..n).collect(); + } + let mut got = sp.clone(); + let removed = remove_anchors(&mut got, &indexes); + let want = one_at_a_time(&sp, &indexes); + let what = format!("case {case}: {n} anchors, closed {closed}, removing {indexes:?}"); + assert_same_bits(&got, &want, &what); + assert_eq!(removed, sp.anchors.len() - want.anchors.len(), "{what}"); + } + } + + #[test] + fn removing_anchors_together_keeps_each_step_of_one_at_a_time() { + let curve = |p: Point, d: f64| Anchor::smooth(p, p + Vec2::new(d, 25.0)); + let pts = [Point::new(0.0, 0.0), Point::new(50.0, 40.0), Point::new(100.0, 0.0), Point::new(150.0, 40.0), Point::new(200.0, 0.0)]; + let cases: Vec<(SubPath, Vec)> = vec![ + // A refit at one removal makes the next segment curved, which the next removal refits. + ( + SubPath::new( + vec![Anchor::corner(pts[0]), Anchor::corner(pts[1]), curve(pts[2], 20.0), Anchor::corner(pts[3]), Anchor::corner(pts[4])], + false, + ), + vec![1, 2, 3], + ), + // The last anchor of an open path goes, then the one before it becomes the end. + (SubPath::new(pts.iter().map(|&p| curve(p, 15.0)).collect(), false), vec![2, 3, 4]), + // The first anchor of a closed path refits across the wrap. + (SubPath::new(pts.iter().map(|&p| curve(p, -10.0)).collect(), true), vec![0, 4]), + // A closed path down to two anchors stays closed with a handle, and opens without one. + (SubPath::new(pts.iter().map(|&p| curve(p, 10.0)).collect(), true), vec![1, 2, 3]), + (SubPath::polyline(&pts, true), vec![1, 2, 3]), + (SubPath::polyline(&pts, true), vec![0, 1, 2, 3]), + (SubPath::polyline(&pts[..1], false), vec![0]), + (SubPath::new(vec![], true), vec![0]), + ]; + for (sp, indexes) in cases { + let mut got = sp.clone(); + remove_anchors(&mut got, &indexes); + assert_same_bits(&got, &one_at_a_time(&sp, &indexes), &format!("{sp:?} removing {indexes:?}")); + } + } + + #[test] + fn removing_anchors_together_moves_each_anchor_left_at_most_once() { + let n = 2001; + let pts: Vec = (0..n).map(|i| Point::new(i as f64, (i % 2) as f64)).collect(); + let mut sp = SubPath::polyline(&pts, false); + let every_other: Vec = (1..n).step_by(2).collect(); + MOVES.set(0); + assert_eq!(remove_anchors(&mut sp, &every_other), n / 2); + assert_eq!(sp.anchors.len(), n - n / 2); + let moves = MOVES.get(); + assert!(moves < n, "{moves} anchors moved to remove {} of {n}", n / 2); + } + /// The samples Object › Path › Smooth at `level` smooths for `sp`, a subpath with no corner, /// and the spread it smooths them with. fn smooth_samples(sp: &SubPath, level: f64) -> (Vec, f64) { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 291c956786..49b7c051f1 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -23,8 +23,8 @@ pub use boolean::{ }; pub use edit::{ AverageAxis, SIMPLIFY_LEVEL, SMOOTH_AUTO_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, smooth_level, smooth_options, - split_into_grid, + remove_anchor, remove_anchors, remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, smooth_level, + smooth_options, split_into_grid, }; pub use fit::{fit_cubics, fit_cubics_through}; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path};