mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 21:14:51 +00:00
Path Precision Tools gains the Shared Edge Merge Tool, kept out of the toolbars (All Tools drawer and Object › Plug-ins › Path Precision Tools, no shortcut), and Keep Shared Points Together: - Shared Edge Merge Tool (sharedEdge): over two paths touching at two coincident anchors (tolerance 0 to 3 pt) hovering shows the two stretches and the merged edge; a click replaces both with it, half way between them, or following the upper (Shift) or lower (Alt) path. Both paths stay closed or open, anchors either stretch has inside are matched on the other, one undo step. - Making ends: near anchors meet on a click and an anchor one path lacks is added to match; Shift+Alt asks for this inside a stretch. - Keep Shared Points Together: an option that makes Slide Anchor Along Path, Extend Path and Set Anchor take coincident anchors along. Nothing else in the app changes. - Shared Edges section in the Anchor Inspector: brush diameter, coincidence and the option. - Commands addon.pathtools.mergeEdge and movePointsTogether, params validated and covered by the command sweep. - Cursor stand-in, catalog rows in every language, docs and tests.
271 lines
13 KiB
Rust
271 lines
13 KiB
Rust
use kurbo::{ParamCurve, Point};
|
|
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
|
|
|
use super::*;
|
|
|
|
fn pt(x: f64, y: f64) -> Point {
|
|
Point::new(x, y)
|
|
}
|
|
|
|
/// A closed shape above y = 0 whose lower edge runs right to left from (100, 0) to (0, 0), curved
|
|
/// down by `bulge` control-point height.
|
|
fn above(bulge: f64) -> SubPath {
|
|
SubPath::new(
|
|
vec![
|
|
Anchor::corner(pt(0.0, -50.0)),
|
|
Anchor::corner(pt(100.0, -50.0)),
|
|
Anchor::with_handles(pt(100.0, 0.0), pt(100.0, 0.0), pt(70.0, bulge)),
|
|
Anchor::with_handles(pt(0.0, 0.0), pt(30.0, bulge), pt(0.0, 0.0)),
|
|
],
|
|
true,
|
|
)
|
|
}
|
|
|
|
/// A closed shape below y = 0 whose upper edge runs left to right, curved up by `bulge`.
|
|
fn below(bulge: f64) -> SubPath {
|
|
SubPath::new(
|
|
vec![
|
|
Anchor::with_handles(pt(0.0, 0.0), pt(0.0, 0.0), pt(30.0, -bulge)),
|
|
Anchor::with_handles(pt(100.0, 0.0), pt(70.0, -bulge), pt(100.0, 0.0)),
|
|
Anchor::corner(pt(100.0, 50.0)),
|
|
Anchor::corner(pt(0.0, 50.0)),
|
|
],
|
|
true,
|
|
)
|
|
}
|
|
|
|
fn single(sp: SubPath) -> PathData {
|
|
PathData::single(sp)
|
|
}
|
|
|
|
/// The plan at `at` with a 20 pt brush.
|
|
fn plan(a: &PathData, b: &PathData, at: Point) -> MergePlan {
|
|
stretches_at(a, b, at, 10.0, COINCIDENCE).expect("a stretch")
|
|
}
|
|
|
|
/// The outline of `chain` run one way, sampled.
|
|
fn samples(chain: &[CubicBez]) -> Vec<Point> {
|
|
let n = chain.len() * 16;
|
|
let at = |k: usize| {
|
|
let f = k as f64 / n as f64 * chain.len() as f64;
|
|
let i = (f.floor() as usize).min(chain.len() - 1);
|
|
chain[i].eval(f - i as f64)
|
|
};
|
|
(0..=n).map(at).collect()
|
|
}
|
|
|
|
/// The edge as `path` now has it between anchors `from` (inclusive) and `to`, along the path.
|
|
fn edge(path: &PathData, from: usize, segments: usize) -> Vec<CubicBez> {
|
|
let sp = &path.subpaths[0];
|
|
(0..segments).map(|k| sp.segment((from + k) % sp.segment_count())).collect()
|
|
}
|
|
|
|
/// The farthest apart two chains are, sampled alike.
|
|
fn apart(x: &[CubicBez], y: &[CubicBez]) -> f64 {
|
|
samples(x).iter().zip(samples(y)).map(|(p, q)| p.distance(q)).fold(0.0, f64::max)
|
|
}
|
|
|
|
#[test]
|
|
fn the_merged_edge_lies_half_way_and_both_paths_keep_their_topology() {
|
|
let (a, b) = (single(above(6.0)), single(below(2.0)));
|
|
let plan = plan(&a, &b, pt(50.0, 0.0));
|
|
assert_eq!(
|
|
plan,
|
|
MergePlan::Edge {
|
|
a: Stretch { subpath: 0, start: 2, segments: 1, reversed: false },
|
|
b: Stretch { subpath: 0, start: 0, segments: 1, reversed: true },
|
|
}
|
|
);
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
|
|
assert!(na.subpaths[0].closed && nb.subpaths[0].closed);
|
|
assert_eq!((na.anchor_count(), nb.anchor_count()), (4, 4));
|
|
// Everything else is where it was.
|
|
assert_eq!((na.subpaths[0].anchors[0], na.subpaths[0].anchors[1]), (a.subpaths[0].anchors[0], a.subpaths[0].anchors[1]));
|
|
assert_eq!((nb.subpaths[0].anchors[2], nb.subpaths[0].anchors[3]), (b.subpaths[0].anchors[2], b.subpaths[0].anchors[3]));
|
|
// One edge, run opposite ways by the two paths, between the two it replaced.
|
|
let (ea, eb) = (edge(&na, 2, 1), edge(&nb, 0, 1));
|
|
assert!(apart(&ea, &backward(&eb)) < 1e-9);
|
|
let (upper, lower) = (a.subpaths[0].segment(2), b.subpaths[0].segment(0));
|
|
for k in 0..=16 {
|
|
let t = k as f64 / 16.0;
|
|
let (p, u, l) = (ea[0].eval(t), upper.eval(t), lower.eval(1.0 - t));
|
|
assert!(p.distance(u.midpoint(l)) < 1e-9, "{p:?} is half way between {u:?} and {l:?}");
|
|
assert!(l.y - 1e-9 <= p.y && p.y <= u.y + 1e-9);
|
|
}
|
|
// Merged: there is nothing left to do, and it says so.
|
|
let again = stretches_at(&na, &nb, pt(50.0, 0.0), 10.0, COINCIDENCE).unwrap();
|
|
assert!(matches!(merge_preview(&na, &nb, &again, Follow::Average), Some(MergePreview::Edge { done: true, .. })));
|
|
let (ma, mb) = apply_merge(&na, &nb, &again, Follow::Average).unwrap();
|
|
assert!(apart(&edge(&ma, 2, 1), &edge(&na, 2, 1)) < 1e-9 && apart(&edge(&mb, 0, 1), &edge(&nb, 0, 1)) < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn following_a_path_keeps_it_and_the_other_takes_its_edge() {
|
|
let (a, b) = (single(above(6.0)), single(below(2.0)));
|
|
let plan = plan(&a, &b, pt(50.0, 0.0));
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::First).unwrap();
|
|
assert_eq!(na, a);
|
|
assert!(apart(&edge(&nb, 0, 1), &backward(&edge(&a, 2, 1))) < 1e-9);
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Second).unwrap();
|
|
assert_eq!(nb, b);
|
|
assert!(apart(&edge(&na, 2, 1), &backward(&edge(&b, 0, 1))) < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn stretches_of_different_anchor_counts_get_the_same_anchors() {
|
|
let (with_two, _) = insert_at(&above(6.0), &[(2, 0.3)]);
|
|
assert_eq!(with_two.anchors.len(), 5);
|
|
let (a, b) = (single(with_two), single(below(2.0)));
|
|
let plan = plan(&a, &b, pt(50.0, 0.0));
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
|
|
assert_eq!((na.anchor_count(), nb.anchor_count()), (5, 5));
|
|
let (ea, eb) = (edge(&na, 2, 2), edge(&nb, 0, 2));
|
|
assert!(apart(&ea, &backward(&eb)) < 1e-9);
|
|
assert_eq!(ea[0].p3, eb[0].p3);
|
|
// The anchors were smooth on the one and a point of a curve on the other: the joint stays smooth.
|
|
assert_eq!(na.subpaths[0].anchors[3].kind, AnchorKind::Smooth);
|
|
// Anchors at nearly the same place stand for one.
|
|
let (b2, _) = insert_at(&below(2.0), &[(0, 0.7)]);
|
|
let (a2, b2) = (a.clone(), single(b2));
|
|
let (na, nb) = apply_merge(&a2, &b2, &stretches_at(&a2, &b2, pt(50.0, 0.0), 10.0, COINCIDENCE).unwrap(), Follow::Average).unwrap();
|
|
assert_eq!((na.anchor_count(), nb.anchor_count()), (5, 5), "one anchor inside, not two");
|
|
}
|
|
|
|
#[test]
|
|
fn open_paths_stay_open_and_keep_what_lies_beyond_the_stretch() {
|
|
let a = single(SubPath::polyline(&[pt(-50.0, 0.0), pt(0.0, 0.0), pt(50.0, 4.0), pt(100.0, 0.0), pt(150.0, 40.0)], false));
|
|
let b = single(SubPath::polyline(&[pt(100.0, 0.0), pt(50.0, -4.0), pt(0.0, 0.0), pt(-20.0, 60.0)], false));
|
|
let plan = plan(&a, &b, pt(25.0, 0.0));
|
|
assert_eq!(
|
|
plan,
|
|
MergePlan::Edge {
|
|
a: Stretch { subpath: 0, start: 1, segments: 2, reversed: false },
|
|
b: Stretch { subpath: 0, start: 0, segments: 2, reversed: true },
|
|
}
|
|
);
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
|
|
assert!(!na.subpaths[0].closed && !nb.subpaths[0].closed);
|
|
let (pa, pb) = (&na.subpaths[0].anchors, &nb.subpaths[0].anchors);
|
|
assert_eq!(pa.iter().map(|x| x.p).collect::<Vec<_>>(), [pt(-50.0, 0.0), pt(0.0, 0.0), pt(50.0, 0.0), pt(100.0, 0.0), pt(150.0, 40.0)]);
|
|
assert_eq!(pb.iter().map(|x| x.p).collect::<Vec<_>>(), [pt(100.0, 0.0), pt(50.0, 0.0), pt(0.0, 0.0), pt(-20.0, 60.0)]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stretch_over_the_start_of_a_closed_path_stays_closed_and_whole() {
|
|
// The edge along y = 0 runs from anchor 3 over anchor 0, which has no partner, to anchor 1.
|
|
let a = single(SubPath::polyline(&[pt(50.0, 1.0), pt(100.0, 0.0), pt(100.0, -50.0), pt(0.0, 0.0)], true));
|
|
let b = single(SubPath::polyline(&[pt(100.0, 0.0), pt(0.0, 0.0), pt(0.0, 50.0), pt(100.0, 50.0)], true));
|
|
let plan = plan(&a, &b, pt(25.0, 0.5));
|
|
let MergePlan::Edge { a: sa, .. } = plan else { panic!("{plan:?}") };
|
|
assert_eq!((sa.start, sa.segments), (3, 2));
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
|
|
assert!(na.subpaths[0].closed && nb.subpaths[0].closed);
|
|
assert_eq!((na.anchor_count(), nb.anchor_count()), (4, 5));
|
|
// Every anchor of the new outlines is where one was, or on the merged edge at y 0.5.
|
|
let at = |path: &PathData, p: Point| path.anchors().any(|(_, _, x)| x.p.distance(p) < 1e-4);
|
|
assert!(at(&na, pt(100.0, -50.0)) && at(&nb, pt(0.0, 50.0)) && at(&nb, pt(100.0, 50.0)));
|
|
assert!(at(&na, pt(50.0, 0.5)) && at(&nb, pt(50.0, 0.5)), "{na:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_stretch_under_the_brush_is_the_one_along_the_shared_edge_not_the_long_way_round() {
|
|
let square = |x: f64| single(SubPath::polyline(&[pt(x, 0.0), pt(x + 100.0, 0.0), pt(x + 100.0, 100.0), pt(x, 100.0)], true));
|
|
let (a, b) = (square(0.0), square(100.0));
|
|
// Above the corner, each path's top edge is nearest: their long ways round face each other.
|
|
let MergePlan::Edge { a: sa, b: sb } = plan(&a, &b, pt(99.0, -2.0)) else { panic!() };
|
|
assert_eq!((sa.start, sa.segments), (1, 1));
|
|
assert_eq!((sb.start, sb.segments), (3, 1));
|
|
}
|
|
|
|
#[test]
|
|
fn stretches_too_unlike_or_without_two_coincident_ends_are_left_alone() {
|
|
let (a, b) = (single(above(6.0)), single(below(2.0)));
|
|
assert_eq!(stretches_at(&a, &b, pt(50.0, 40.0), 10.0, COINCIDENCE), None, "the brush doesn't touch both");
|
|
// One coincident anchor only.
|
|
let c = single(SubPath::polyline(&[pt(0.0, 0.0), pt(101.0, 0.0)], false));
|
|
assert_eq!(stretches_at(&a, &c, pt(50.0, 0.0), 10.0, COINCIDENCE), None);
|
|
// Coincident within a wider tolerance.
|
|
assert!(stretches_at(&a, &c, pt(50.0, 2.0), 10.0, 1.5).is_some());
|
|
// One side twice as long as the other, both ends coincident.
|
|
let hump = single(SubPath::polyline(&[pt(0.0, 0.0), pt(50.0, 5.0), pt(50.0, 60.0), pt(100.0, 0.0)], false));
|
|
let flat = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false));
|
|
assert_eq!(stretches_at(&hump, &flat, pt(25.0, 1.0), 10.0, COINCIDENCE), None);
|
|
// Past the limits, or not finite.
|
|
let mut bad = a.clone();
|
|
bad.subpaths[0].anchors[0].p.x = f64::NAN;
|
|
assert_eq!(stretches_at(&bad, &b, pt(50.0, 0.0), 10.0, COINCIDENCE), None);
|
|
assert_eq!(ends_at(&bad, &b, pt(50.0, 0.0), 10.0), None);
|
|
assert_eq!(stretches_at(&a, &b, pt(f64::NAN, 0.0), 10.0, COINCIDENCE), None);
|
|
assert_eq!(stretches_at(&PathData::default(), &b, pt(50.0, 0.0), 10.0, COINCIDENCE), None);
|
|
assert_eq!(ends_at(&PathData::default(), &PathData::default(), pt(50.0, 0.0), 10.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn near_anchors_meet_half_way_or_on_the_path_followed() {
|
|
let a = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false));
|
|
let b = single(SubPath::polyline(&[pt(104.0, 2.0), pt(200.0, 0.0)], false));
|
|
let plan = ends_at(&a, &b, pt(102.0, 1.0), 10.0).unwrap();
|
|
let ends = |f| {
|
|
let (na, nb) = apply_merge(&a, &b, &plan, f).unwrap();
|
|
(na.subpaths[0].anchors[1].p, nb.subpaths[0].anchors[0].p)
|
|
};
|
|
assert_eq!(ends(Follow::Average), (pt(102.0, 1.0), pt(102.0, 1.0)));
|
|
assert_eq!(ends(Follow::First), (pt(100.0, 0.0), pt(100.0, 0.0)));
|
|
assert_eq!(ends(Follow::Second), (pt(104.0, 2.0), pt(104.0, 2.0)));
|
|
assert_eq!(merge_preview(&a, &b, &plan, Follow::Average), Some(MergePreview::Ends { a: pt(100.0, 0.0), b: pt(104.0, 2.0), to: pt(102.0, 1.0) }));
|
|
// Together already: nothing to do.
|
|
let c = single(SubPath::polyline(&[pt(100.0, 0.0), pt(100.0, 90.0)], false));
|
|
assert_eq!(ends_at(&a, &c, pt(100.0, 1.0), 10.0), None);
|
|
// Nothing there.
|
|
assert_eq!(ends_at(&a, &b, pt(50.0, 1.0), 10.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn an_anchor_one_path_lacks_is_added_to_match() {
|
|
let a = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 2.0), pt(100.0, 90.0)], false));
|
|
let b = single(SubPath::polyline(&[pt(0.0, 0.0), pt(200.0, 0.0)], false));
|
|
let plan = ends_at(&a, &b, pt(100.0, 1.0), 10.0).unwrap();
|
|
assert!(matches!(plan, MergePlan::Ends { a: EndSide::Anchor { anchor: 1, .. }, b: EndSide::Insert { .. } }), "{plan:?}");
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Average).unwrap();
|
|
assert_eq!(na, a, "the anchor stays");
|
|
assert_eq!(nb.anchor_count(), 3);
|
|
assert!(nb.subpaths[0].anchors[1].p.distance(pt(100.0, 2.0)) < 1e-9);
|
|
// Following the path that lacks it, the anchor comes to it.
|
|
let (na, nb) = apply_merge(&a, &b, &plan, Follow::Second).unwrap();
|
|
assert!(na.subpaths[0].anchors[1].p.distance(pt(100.0, 0.0)) < 1e-6);
|
|
assert!(nb.subpaths[0].anchors[1].p.distance(na.subpaths[0].anchors[1].p) < 1e-9);
|
|
// The same the other way round.
|
|
let swapped = ends_at(&b, &a, pt(100.0, 1.0), 10.0).unwrap();
|
|
let (nb, na) = apply_merge(&b, &a, &swapped, Follow::Average).unwrap();
|
|
assert_eq!((na.anchor_count(), nb.anchor_count()), (3, 3));
|
|
// With both paths' ends made, the stretch between can merge.
|
|
let c = single(SubPath::polyline(&[pt(100.0, 2.0), pt(100.0, 90.0)], false));
|
|
let d = single(SubPath::polyline(&[pt(95.0, 2.0), pt(95.0, 90.0)], false));
|
|
assert_eq!(stretches_at(&c, &d, pt(97.0, 50.0), 10.0, COINCIDENCE), None);
|
|
let first = ends_at(&c, &d, pt(97.0, 3.0), 10.0).unwrap();
|
|
let (c, d) = apply_merge(&c, &d, &first, Follow::Average).unwrap();
|
|
let second = ends_at(&c, &d, pt(97.0, 89.0), 10.0).unwrap();
|
|
let (c, d) = apply_merge(&c, &d, &second, Follow::Average).unwrap();
|
|
assert!(stretches_at(&c, &d, pt(97.5, 50.0), 10.0, COINCIDENCE).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_smooth_end_whose_handles_part_turns_into_a_corner() {
|
|
let a = single(SubPath::new(
|
|
vec![Anchor::smooth(pt(0.0, 0.0), pt(30.0, 10.0)), Anchor::with_handles(pt(100.0, 0.0), pt(70.0, 10.0), pt(100.0, 0.0))],
|
|
true,
|
|
));
|
|
let b = single(SubPath::polyline(&[pt(0.0, 0.0), pt(100.0, 0.0)], false));
|
|
let plan = plan(&a, &b, pt(50.0, 3.0));
|
|
let (na, _) = apply_merge(&a, &b, &plan, Follow::Second).unwrap();
|
|
assert_eq!(na.subpaths[0].anchors[0].kind, AnchorKind::Corner);
|
|
}
|
|
|
|
#[test]
|
|
fn follow_names_round_trip() {
|
|
for f in Follow::ALL {
|
|
assert_eq!(Follow::parse(f.id()), Some(f));
|
|
}
|
|
assert_eq!(Follow::parse("up"), None);
|
|
}
|