From 427624546927620c7d4a74a679d43648af690397 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 12:39:13 -0800 Subject: [PATCH] Path Precision Tools: group selected segments and crossings by subpath in one pass Add Points and Divide into Dashes read each subpath's selected segments with a range lookup in the path's ordered segment set instead of filtering the whole set once per subpath. Add Points at Crossings and Split at Crossings sort a path's crossing places into a list per subpath once, in the order they come, instead of filtering the whole list for every subpath. Places on a subpath that no longer exists are still left out. The results are unchanged. Tests cover paths with several subpaths, an empty subpath, an untouched subpath and selection on the last subpath, for all four commands, plus the anchor fallback and the grouping of out-of-order crossing places. --- crates/engine/src/cmd/addons/pathtools.rs | 17 +- .../engine/src/cmd/addons/pathtools/tests.rs | 177 +++++++++++++++++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs index 9ddcc43559..5a38f73289 100644 --- a/crates/engine/src/cmd/addons/pathtools.rs +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -609,7 +609,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet) -> BTreeSet { let n = sp.anchors.len().max(1); match segments.filter(|set| !set.is_empty()) { - Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + Some(set) => super::super::path::subpath_refs(set, si).collect(), None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n))).collect(), } } @@ -681,6 +681,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result { // ---------- Split / Add Points at Crossings ---------- +/// The crossing places `on` of one path, as `(segment, t)` for each of its `subpaths` subpaths in +/// the order they come in `on`. A place on a subpath past the last is left out. +fn places_by_subpath(on: &[(usize, usize, f64)], subpaths: usize) -> Vec> { + let mut by_sub = vec![vec![]; subpaths]; + for &(si, seg, t) in on { + if let Some(here) = by_sub.get_mut(si) { + here.push((seg, t)); + } + } + by_sub +} + fn crossings(s: &mut Session, p: &Value) -> Result { const C: &str = "addon.pathtools.crossings"; let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?); @@ -702,8 +714,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result { } let mut subs = vec![]; let mut marks = vec![]; - for (si, sp) in path.subpaths.iter().enumerate() { - let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + for (sp, here) in path.subpaths.iter().zip(places_by_subpath(&on, path.subpaths.len())) { let (out, m) = pt::insert_at(sp, &here); count += m.len(); subs.push(out); diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index e31aadf56e..f98880e204 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -6,7 +6,7 @@ use std::collections::BTreeSet; use serde_json::{Value, json}; use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; use vectorcraft_geom::shapes::CornerKind; -use vectorcraft_geom::{AnchorKind, PathData, Point}; +use vectorcraft_geom::{AnchorKind, PathData, Point, SubPath}; use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::{Session, ViewInfo}; @@ -275,6 +275,152 @@ fn crossings_add_anchors_or_cut_in_one_step() { refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]); } +/// A selected path with an open polyline subpath through each of `subs`; an empty list makes an +/// empty subpath. +fn compound(s: &mut Session, subs: &[&[(f64, f64)]]) -> NodeId { + let id = line(s, &[(0.0, 0.0), (1.0, 0.0)], false); + let pd = + PathData::new(subs.iter().map(|pts| SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), false)).collect()); + s.edit("Test", |d, _| { + super::set_path(d, id, pd); + Ok(()) + }) + .unwrap(); + select(s, &[id]); + id +} + +/// The anchor points of each subpath of `pd`, rounded to a millionth. +fn points(pd: &PathData) -> Vec> { + let r = |v: f64| (v * 1e6).round() / 1e6; + pd.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| (r(a.p.x), r(a.p.y))).collect()).collect() +} + +/// A path's subpaths: A (0), an empty one (1), C (2) and D (3, the last). +const SUBS: [&[(f64, f64)]; 4] = + [&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], &[], &[(0.0, 50.0), (100.0, 50.0)], &[(0.0, 100.0), (100.0, 100.0), (200.0, 100.0)]]; + +/// Direct-select `segments` of `id` and the anchors `anchors`. +fn select_segments(s: &mut Session, id: NodeId, anchors: &[(usize, usize)], segments: &[(usize, usize)]) { + s.select(|_, sel| { + sel.set([id]); + sel.anchors.insert(id, anchors.iter().copied().collect()); + sel.segments.insert(id, segments.iter().copied().collect()); + }) + .unwrap(); +} + +#[test] +fn picked_segments_reads_only_its_own_subpath() { + let set: BTreeSet<(usize, usize)> = [(0, 0), (0, 2), (1, 1), (3, 0), (3, 1)].into(); + let sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)], false); + let all: BTreeSet = (0..4).collect(); + let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::>(); + assert_eq!([picked(0), picked(1), picked(2), picked(3), picked(4)], [vec![0, 2], vec![1], vec![], vec![0, 1], vec![]]); + // Without direct-selected segments, the segments between two selected anchors. + let anchors: BTreeSet = [0, 1, 3].into(); + for none in [None, Some(&BTreeSet::new())] { + assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::>(), [0]); + } +} + +#[test] +fn places_by_subpath_keeps_the_order_and_leaves_out_missing_subpaths() { + let on = [(3, 1, 0.75), (0, 0, 0.5), (3, 1, 0.25), (7, 0, 0.5), (0, 1, 0.1), (3, 0, 0.5), (0, 0, 0.2)]; + let by_sub = super::places_by_subpath(&on, 4); + assert_eq!(by_sub, [vec![(0, 0.5), (1, 0.1), (0, 0.2)], vec![], vec![], vec![(1, 0.75), (1, 0.25), (0, 0.5)]]); + // The same places as filtering the whole list for each subpath. + for (si, here) in by_sub.iter().enumerate() { + let filtered: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + assert_eq!(*here, filtered); + } + assert!(super::places_by_subpath(&on, 0).is_empty()); +} + +#[test] +fn add_points_adds_to_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(2)); + let want = vec![ + vec![(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (200.0, 0.0)], + vec![], + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)], + ]; + let after = path(&s, c); + assert_eq!(points(&after), want); + assert_eq!(steps(&s), n + 1); + let st = s.doc().unwrap(); + assert_eq!((st.selection.objects.clone(), st.selection.anchors.get(&c), st.selection.segments.get(&c)), (vec![c], None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path(&s, c), after); + // Without selected segments: those between two selected anchors, here on the last subpath only. + for segments in [None, Some(BTreeSet::new())] { + s.execute("edit.undo", &json!({})).unwrap(); + s.select(|_, sel| { + sel.set([c]); + sel.anchors.insert(c, [(0, 0), (3, 1), (3, 2)].into()); + sel.segments.remove(&c); + if let Some(set) = segments.clone() { + sel.segments.insert(c, set); + } + }) + .unwrap(); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({})).unwrap()["added"], json!(1)); + assert_eq!( + points(&path(&s, c)), + [points(&before)[..3].to_vec(), vec![vec![(0.0, 100.0), (100.0, 100.0), (150.0, 100.0), (200.0, 100.0)]]].concat() + ); + } +} + +#[test] +fn crossings_add_anchors_on_each_subpath_of_a_compound_path() { + let mut s = session(); + // A and the last subpath cross both lines; the empty subpath and C, which ends short of them, do not. + let c = compound(&mut s, &[&[(0.0, 50.0), (200.0, 50.0)], &[], &[(0.0, 150.0), (40.0, 150.0)], &[(0.0, 250.0), (400.0, 250.0)]]); + let q = line(&mut s, &[(100.0, 0.0), (100.0, 300.0)], false); + let r = line(&mut s, &[(150.0, 0.0), (150.0, 300.0)], false); + select(&mut s, &[c, q, r]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(8)); + let want = vec![ + vec![(0.0, 50.0), (100.0, 50.0), (150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0), (150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), want); + assert_eq!(points(&path(&s, q)), [vec![(100.0, 0.0), (100.0, 50.0), (100.0, 250.0), (100.0, 300.0)]]); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + // Cut: the compound path keeps its pieces as subpaths, in order; each line becomes three paths. + let out = ids(&s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap()); + assert_eq!(out.len(), 7); + assert_eq!(out[0], c); + let pieces = vec![ + vec![(0.0, 50.0), (100.0, 50.0)], + vec![(100.0, 50.0), (150.0, 50.0)], + vec![(150.0, 50.0), (200.0, 50.0)], + vec![], + vec![(0.0, 150.0), (40.0, 150.0)], + vec![(0.0, 250.0), (100.0, 250.0)], + vec![(100.0, 250.0), (150.0, 250.0)], + vec![(150.0, 250.0), (400.0, 250.0)], + ]; + assert_eq!(points(&path(&s, c)), pieces); + let qs: Vec>> = out[1..4].iter().map(|id| points(&path(&s, *id))).collect(); + assert_eq!(qs, [[vec![(100.0, 0.0), (100.0, 50.0)]], [vec![(100.0, 50.0), (100.0, 250.0)]], [vec![(100.0, 250.0), (100.0, 300.0)]]]); + assert_eq!(steps(&s), n + 1); +} + // ---------- Parametric Shape Tool ---------- /// The parametric shape object `id` is now, as the tool reads it. @@ -887,6 +1033,35 @@ fn divide_into_dashes_divides_only_the_selected_segments() { assert_eq!(layer, pieces, "in the path's place, in order"); } +#[test] +fn divide_into_dashes_divides_the_selected_segments_of_each_subpath() { + let mut s = session(); + let c = compound(&mut s, &SUBS); + let before = path(&s, c); + // The first segment of A and the second of D; a segment of C without its anchors selected. + select_segments(&mut s, c, &[(0, 0), (0, 1), (0, 2), (3, 0), (3, 1), (3, 2)], &[(0, 0), (2, 0), (3, 1)]); + let n = steps(&s); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(r["dashes"], json!(8)); + let made: Vec>> = ids(&r).iter().map(|id| points(&path(&s, *id))).collect(); + let piece = |x0: f64, x1: f64, y: f64| vec![vec![(x0, y), (x1, y)]]; + let mut want = vec![vec![vec![], vec![(0.0, 50.0), (100.0, 50.0)]]]; + want.extend([(0.0, 25.0), (25.0, 50.0), (50.0, 75.0), (75.0, 100.0)].map(|(a, b)| piece(a, b, 0.0))); + want.push(vec![vec![(100.0, 0.0), (200.0, 0.0)]]); + want.push(vec![vec![(0.0, 100.0), (100.0, 100.0)]]); + want.extend([(100.0, 125.0), (125.0, 150.0), (150.0, 175.0), (175.0, 200.0)].map(|(a, b)| piece(a, b, 100.0))); + assert_eq!(made, want, "what stays of the path, then each subpath's pieces in order"); + assert_eq!(s.doc().unwrap().selection.objects, ids(&r)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, c), before); + // With no segments selected: those between two selected anchors, here on the last subpath only. + select_segments(&mut s, c, &[(0, 0), (3, 1), (3, 2)], &[]); + let r = s.execute("addon.pathtools.divideDashes", &json!({"dash": 25})).unwrap(); + assert_eq!((ids(&r).len(), r["dashes"].clone()), (6, json!(4))); + assert_eq!(points(&path(&s, ids(&r)[0])), points(&before)[..3]); +} + #[test] fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() { let mut s = session();