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};