Merge pull request #7281 from dotCooCoo/fix/path-precision-picks-one-pass

Path Precision Tools: group selected segments and crossings by subpath in one pass
This commit is contained in:
BFlat
2026-11-14 05:40:21 +07:00
committed by GitHub
2 changed files with 190 additions and 4 deletions
+14 -3
View File
@@ -611,7 +611,7 @@ fn remove_keep_shape(s: &mut Session, _: &Value) -> Result<Value> {
fn picked_segments(segments: Option<&BTreeSet<(usize, usize)>>, si: usize, sp: &SubPath, anchors: &BTreeSet<usize>) -> BTreeSet<usize> {
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(),
}
}
@@ -683,6 +683,18 @@ pub(super) fn close(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- 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<Vec<(usize, f64)>> {
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<Value> {
const C: &str = "addon.pathtools.crossings";
let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?);
@@ -704,8 +716,7 @@ fn crossings(s: &mut Session, p: &Value) -> Result<Value> {
}
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);
+176 -1
View File
@@ -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::<Vec<_>>(), 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<Vec<(f64, f64)>> {
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<usize> = (0..4).collect();
let picked = |si| super::picked_segments(Some(&set), si, &sp, &all).into_iter().collect::<Vec<_>>();
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<usize> = [0, 1, 3].into();
for none in [None, Some(&BTreeSet::new())] {
assert_eq!(super::picked_segments(none, 0, &sp, &anchors).into_iter().collect::<Vec<_>>(), [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<Vec<Vec<(f64, f64)>>> = 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<Vec<Vec<(f64, f64)>>> = 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();