mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Discord feedback: Alt with the Pen didn't move a direction handle on its own, and there was no
way to break a path at an anchor and pull the ends apart.
- Pen: Alt held over a handle end or an anchor of a selected path (the one being drawn too) hands
the gesture to the Anchor Point tool, with the convert cursor: dragging a handle moves it alone,
clicking a smooth anchor makes it a corner, dragging an anchor pulls out symmetric handles.
Drawing goes on afterwards. Handles hidden by a partial anchor selection aren't grabbed.
- path.cutAtAnchors cuts at the direct-selected anchors in one undo step: a closed path opens
there, an open one becomes one path per piece, and one anchor of each cut stays selected so a
drag pulls the path apart. It answers {ids} and refuses (no undo step) when only open ends are
selected. path.convertAnchors checks `to`, skips stale anchor refs instead of indexing past
them, and keeps a smooth anchor's handles when converting to smooth.
- SubPath::cut_at and SubPath::smooth_anchor (geom) now back path.split, path.cutAtAnchors,
path.convertAnchor and path.convertAnchors, replacing three copies of that logic.
- Control bar and Properties panel: "Convert:" corner/smooth and "Anchors:" remove, connect
(Join), cut buttons when anchors are selected; Cut Path on the task bar and the context menu.
New original icon dc-anchor-smooth.svg (ASSETS.md row).
- ja, zh-hans, zh-hant and es strings (cs for the context menu item), es @msg rows; docs/mcp.md
section; ROADMAP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
307 lines
14 KiB
Rust
307 lines
14 KiB
Rust
//! Geometry property tests: PathData ↔ BezPath, bounds, hit testing, anchors and shapes.
|
||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||
|
||
use proptest::prelude::*;
|
||
use vectorcraft_geom::hit::{distance_to_outline, fill_contains, stroke_contains};
|
||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes};
|
||
use vectorcraft_testkit::geom::{grid, hausdorff, polygon_area, rect_contains, sample, sample_path};
|
||
use vectorcraft_testkit::strategies::{arb_closed_shape, arb_convex_polygon, arb_path_data, arb_point, arb_rect, arb_star_polygon, polygon_path};
|
||
|
||
fn els_close(a: &BezPath, b: &BezPath, eps: f64) -> bool {
|
||
let (a, b) = (a.elements(), b.elements());
|
||
if a.len() != b.len() {
|
||
return false;
|
||
}
|
||
let pc = |p: Point, q: Point| p.distance(q) <= eps;
|
||
a.iter().zip(b).all(|(x, y)| match (x, y) {
|
||
(PathEl::MoveTo(p), PathEl::MoveTo(q)) | (PathEl::LineTo(p), PathEl::LineTo(q)) => pc(*p, *q),
|
||
(PathEl::CurveTo(p1, p2, p3), PathEl::CurveTo(q1, q2, q3)) => pc(*p1, *q1) && pc(*p2, *q2) && pc(*p3, *q3),
|
||
(PathEl::QuadTo(p1, p2), PathEl::QuadTo(q1, q2)) => pc(*p1, *q1) && pc(*p2, *q2),
|
||
(PathEl::ClosePath, PathEl::ClosePath) => true,
|
||
_ => false,
|
||
})
|
||
}
|
||
|
||
/// Winding number computed independently: ray-cast crossings of a flattened polyline.
|
||
fn crossing_winding(bp: &BezPath, p: Point) -> i32 {
|
||
let mut w = 0;
|
||
let mut pts: Vec<Vec<Point>> = vec![];
|
||
kurbo::flatten(bp, 1e-4, |el| match el {
|
||
PathEl::MoveTo(q) => pts.push(vec![q]),
|
||
PathEl::LineTo(q) => pts.last_mut().unwrap().push(q),
|
||
PathEl::ClosePath => {}
|
||
other => panic!("unexpected {other:?}"),
|
||
});
|
||
for poly in pts {
|
||
for i in 0..poly.len() {
|
||
let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
|
||
if a.y <= p.y {
|
||
if b.y > p.y && (b - a).cross(p - a) > 0.0 {
|
||
w += 1;
|
||
}
|
||
} else if b.y <= p.y && (b - a).cross(p - a) < 0.0 {
|
||
w -= 1;
|
||
}
|
||
}
|
||
}
|
||
w
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, ..ProptestConfig::default() })]
|
||
|
||
/// PathData → BezPath → PathData → BezPath is the identity on the Bézier geometry.
|
||
#[test]
|
||
fn pathdata_bezpath_roundtrip(p in arb_path_data()) {
|
||
let bp = p.to_bezpath();
|
||
let back = PathData::from_bezpath(&bp);
|
||
prop_assert!(els_close(&bp, &back.to_bezpath(), 1e-9), "{bp:?}\nvs\n{:?}", back.to_bezpath());
|
||
prop_assert_eq!(back.subpaths.len(), p.subpaths.len());
|
||
for (a, b) in p.subpaths.iter().zip(&back.subpaths) {
|
||
prop_assert_eq!(a.closed, b.closed);
|
||
prop_assert_eq!(a.anchors.len(), b.anchors.len());
|
||
}
|
||
}
|
||
|
||
/// kurbo shapes survive BezPath → PathData → BezPath.
|
||
#[test]
|
||
fn kurbo_shapes_roundtrip(r in arb_rect(), rad in 0.0..20.0f64) {
|
||
for bp in [r.to_path(1e-6), kurbo::RoundedRect::from_rect(r, rad).to_path(1e-6), kurbo::Ellipse::from_rect(r).to_path(1e-6)] {
|
||
let pd = PathData::from_bezpath(&bp);
|
||
prop_assert!(hausdorff(&bp, &pd.to_bezpath(), 8) < 1e-6);
|
||
prop_assert!((pd.to_bezpath().area() - bp.area()).abs() < 1e-6 * bp.area().abs().max(1.0));
|
||
}
|
||
}
|
||
|
||
/// Tight bounds contain every sampled curve point and are attained (within a sampling slack).
|
||
#[test]
|
||
fn bounds_contain_samples(p in arb_path_data()) {
|
||
let b = p.bounds().unwrap();
|
||
let pts = sample_path(&p, 64);
|
||
for q in &pts {
|
||
prop_assert!(rect_contains(b, Rect::from_points(*q, *q), 1e-9), "{q:?} outside {b:?}");
|
||
}
|
||
let sb = pts.iter().fold(Rect::from_points(pts[0], pts[0]), |r, q| r.union_pt(*q));
|
||
prop_assert!(rect_contains(sb, b, 0.5), "bounds {b:?} not tight vs samples {sb:?}");
|
||
// Control bounds contain the tight bounds.
|
||
prop_assert!(rect_contains(p.control_bounds().unwrap(), b, 1e-9));
|
||
}
|
||
|
||
/// Bounds commute with translation and uniform scale.
|
||
#[test]
|
||
fn bounds_transform_covariant(p in arb_path_data(), d in arb_point(-100.0, 100.0), s in 0.1..5.0f64) {
|
||
let a = Affine::translate(d.to_vec2()) * Affine::scale(s);
|
||
let b1 = p.transformed(a).bounds().unwrap();
|
||
let b0 = p.bounds().unwrap();
|
||
let want = Rect::new(b0.x0 * s + d.x, b0.y0 * s + d.y, b0.x1 * s + d.x, b0.y1 * s + d.y);
|
||
let eps = 1e-7 * (1.0 + want.width().abs() + want.height().abs() + d.x.abs() + d.y.abs());
|
||
prop_assert!(rect_contains(b1, want, eps) && rect_contains(want, b1, eps), "{b1:?} vs {want:?}");
|
||
}
|
||
|
||
/// fill_contains agrees with an independent crossing-number winding on random points.
|
||
#[test]
|
||
fn fill_contains_matches_winding(p in arb_closed_shape(), q in arb_point(0.0, 220.0)) {
|
||
let bp = p.to_bezpath();
|
||
// Skip points on (or extremely near) the outline, where flattening decides.
|
||
prop_assume!(distance_to_outline(&bp, q) > 1e-2);
|
||
let w = crossing_winding(&bp, q);
|
||
prop_assert_eq!(fill_contains(&bp, FillRule::NonZero, q), w != 0);
|
||
prop_assert_eq!(fill_contains(&bp, FillRule::EvenOdd, q), w % 2 != 0);
|
||
}
|
||
|
||
/// Even-odd ⊆ non-zero; reversing a path never changes containment.
|
||
#[test]
|
||
fn fill_rules_and_reverse(p in arb_path_data(), q in arb_point(0.0, 200.0)) {
|
||
let bp = p.to_bezpath();
|
||
prop_assume!(distance_to_outline(&bp, q) > 1e-3);
|
||
if fill_contains(&bp, FillRule::EvenOdd, q) {
|
||
prop_assert!(fill_contains(&bp, FillRule::NonZero, q));
|
||
}
|
||
let mut r = p.clone();
|
||
r.reverse();
|
||
let rb = r.to_bezpath();
|
||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||
prop_assert_eq!(fill_contains(&bp, rule, q), fill_contains(&rb, rule, q));
|
||
}
|
||
// Reversal flips signed area, and reversing twice is the identity.
|
||
prop_assert!((rb.area() + bp.area()).abs() < 1e-6 * bp.area().abs().max(1.0));
|
||
r.reverse();
|
||
prop_assert_eq!(r, p);
|
||
}
|
||
|
||
/// Convex polygon containment equals the half-plane test.
|
||
#[test]
|
||
fn convex_polygon_hit(pts in arb_convex_polygon(), q in arb_point(0.0, 200.0)) {
|
||
let bp = polygon_path(&pts).to_bezpath();
|
||
prop_assume!(distance_to_outline(&bp, q) > 1e-6);
|
||
let n = pts.len();
|
||
let sign = (pts[1] - pts[0]).cross(pts[2] - pts[1]).signum();
|
||
let inside = (0..n).all(|i| (pts[(i + 1) % n] - pts[i]).cross(q - pts[i]) * sign > 0.0);
|
||
prop_assert_eq!(fill_contains(&bp, FillRule::NonZero, q), inside);
|
||
}
|
||
|
||
/// Points sampled on the outline are within any positive stroke; distance is ~0 there.
|
||
#[test]
|
||
fn stroke_contains_outline(p in arb_path_data(), w in 0.1..10.0f64) {
|
||
let bp = p.to_bezpath();
|
||
for q in sample(&bp, 5) {
|
||
prop_assert!(distance_to_outline(&bp, q) < 1e-6);
|
||
prop_assert!(stroke_contains(&bp, w, 0.0, q));
|
||
}
|
||
}
|
||
|
||
/// A point offset along the normal by more than half the width is outside the stroke of an
|
||
/// isolated line.
|
||
#[test]
|
||
fn stroke_excludes_far_points(a in arb_point(0.0, 100.0), d in arb_point(10.0, 100.0), w in 0.5..10.0f64, t in 0.1..0.9f64) {
|
||
let b = a + d.to_vec2();
|
||
let line = shapes::line(a, b).to_bezpath();
|
||
let dir = (b - a).normalize();
|
||
let n = Vec2::new(-dir.y, dir.x);
|
||
let m = a.lerp(b, t);
|
||
prop_assert!(stroke_contains(&line, w, 0.0, m + n * (w / 2.0 * 0.9)));
|
||
prop_assert!(!stroke_contains(&line, w, 0.0, m + n * (w / 2.0 * 1.1 + 1e-6)));
|
||
}
|
||
|
||
/// Inserting an anchor never changes the curve, adds exactly one anchor and keeps closure.
|
||
#[test]
|
||
fn insert_anchor_preserves_shape(p in arb_path_data(), t in 0.05..0.95f64, pick in 0usize..100) {
|
||
let si = pick % p.subpaths.len();
|
||
let seg_count = p.subpaths[si].segment_count();
|
||
prop_assume!(seg_count > 0);
|
||
let seg = pick % seg_count;
|
||
let mut q = p.clone();
|
||
q.subpaths[si].insert_anchor(seg, t);
|
||
prop_assert_eq!(q.anchor_count(), p.anchor_count() + 1);
|
||
prop_assert_eq!(q.subpaths[si].closed, p.subpaths[si].closed);
|
||
prop_assert!(hausdorff(&p.to_bezpath(), &q.to_bezpath(), 16) < 1e-6);
|
||
prop_assert!((q.length() - p.length()).abs() < 1e-3 * p.length().max(1.0));
|
||
}
|
||
|
||
/// Cutting at anchors keeps every segment as it was, in order (a closed subpath's from its
|
||
/// first cut), in open pieces of two or more anchors. A smoothed corner gets handles on a line
|
||
/// through it.
|
||
#[test]
|
||
fn cut_at_keeps_the_segments(p in arb_path_data(), cuts in proptest::collection::btree_set(0usize..12, 0..4), pick in 0usize..100) {
|
||
let sp = &p.subpaths[pick % p.subpaths.len()];
|
||
let n = sp.anchors.len();
|
||
let pieces = sp.cut_at(&cuts);
|
||
let segs = |s: &vectorcraft_geom::SubPath| (0..s.segment_count()).map(|i| s.segment(i)).collect::<Vec<_>>();
|
||
let mut want = segs(sp);
|
||
if pieces.len() > 1 || pieces.first().is_some_and(|q| q.closed != sp.closed) {
|
||
prop_assert!(pieces.iter().all(|q| !q.closed && q.anchors.len() >= 2));
|
||
if sp.closed {
|
||
want.rotate_left(cuts.iter().copied().find(|&c| c < n).unwrap_or(0));
|
||
}
|
||
} else {
|
||
prop_assert_eq!(&pieces, &vec![sp.clone()]);
|
||
}
|
||
prop_assert_eq!(pieces.iter().flat_map(segs).collect::<Vec<_>>(), want);
|
||
let mut s = sp.clone();
|
||
let i = pick % n.max(1);
|
||
let corner = s.anchors.get(i).is_some_and(|a| a.kind == vectorcraft_geom::AnchorKind::Corner);
|
||
if s.smooth_anchor(i) && corner {
|
||
let a = s.anchors[i];
|
||
let (u, v) = (a.h_in - a.p, a.h_out - a.p);
|
||
prop_assert!(u.cross(v).abs() <= 1e-6 * (1.0 + u.hypot() * v.hypot()) && u.dot(v) <= 0.0, "{a:?}");
|
||
}
|
||
}
|
||
|
||
/// Polygon area via the shoelace formula equals the path's signed area magnitude and
|
||
/// SubPath::area.
|
||
#[test]
|
||
fn polygon_area_agrees(pts in arb_star_polygon()) {
|
||
let p = polygon_path(&pts);
|
||
let a = polygon_area(&pts);
|
||
prop_assert!((p.to_bezpath().area().abs() - a).abs() < 1e-6 * a.max(1.0));
|
||
prop_assert!((p.subpaths[0].area().abs() - a).abs() < 1e-6 * a.max(1.0));
|
||
}
|
||
|
||
/// Serde round trip of PathData is lossless in-memory (JSON Value).
|
||
#[test]
|
||
fn pathdata_serde_roundtrip(p in arb_path_data()) {
|
||
let v = serde_json::to_value(&p).unwrap();
|
||
let back: PathData = serde_json::from_value(v).unwrap();
|
||
prop_assert_eq!(back, p);
|
||
}
|
||
|
||
/// Nearest point really is on the path and no sampled point is closer.
|
||
#[test]
|
||
fn nearest_is_nearest(p in arb_path_data(), q in arb_point(-20.0, 220.0)) {
|
||
let (_, _, _, pt, d) = p.nearest(q).unwrap();
|
||
prop_assert!((pt.distance(q) - d).abs() < 1e-6);
|
||
let best_sample = sample_path(&p, 32).into_iter().map(|s| s.distance(q)).fold(f64::INFINITY, f64::min);
|
||
prop_assert!(d <= best_sample + 1e-6, "nearest {d} > sampled {best_sample}");
|
||
}
|
||
|
||
/// Regular polygons and stars: anchor counts, all anchors on their radius, closed.
|
||
#[test]
|
||
fn polygon_and_star_shapes(c in arb_point(0.0, 100.0), r in 1.0..100.0f64, n in 3u32..20, rot in -360.0..360.0f64, k in 0.1..0.9f64) {
|
||
let p = shapes::polygon(c, r, n, rot);
|
||
prop_assert!(p.is_closed());
|
||
prop_assert_eq!(p.anchor_count(), n as usize);
|
||
for (_, _, a) in p.anchors() {
|
||
prop_assert!((a.p.distance(c) - r).abs() < 1e-9 * r.max(1.0));
|
||
}
|
||
let s = shapes::star(c, r, r * k, n, rot);
|
||
prop_assert_eq!(s.anchor_count(), 2 * n as usize);
|
||
let ds: Vec<f64> = s.anchors().map(|(_, _, a)| a.p.distance(c)).collect();
|
||
prop_assert!(ds.iter().all(|d| (d - r).abs() < 1e-9 * r || (d - r * k).abs() < 1e-9 * r));
|
||
}
|
||
|
||
/// Rectangles and ellipses: bounds equal the defining rect; area matches.
|
||
#[test]
|
||
fn rect_and_ellipse_shapes(r in arb_rect(), rad in 0.0..30.0f64) {
|
||
let p = shapes::rectangle(r);
|
||
prop_assert_eq!(p.bounds().unwrap(), r);
|
||
prop_assert!((p.to_bezpath().area().abs() - r.area()).abs() < 1e-9 * r.area());
|
||
let e = shapes::ellipse(r);
|
||
let eb = e.bounds().unwrap();
|
||
prop_assert!(rect_contains(eb, r, 1e-9) && rect_contains(r, eb, 1e-9));
|
||
let ea = std::f64::consts::PI * r.width() * r.height() / 4.0;
|
||
prop_assert!((e.to_bezpath().area().abs() - ea).abs() < 1e-3 * ea, "{} vs {ea}", e.to_bezpath().area());
|
||
let rr = shapes::rounded_rectangle(r, rad);
|
||
prop_assert!(rect_contains(r, rr.bounds().unwrap(), 1e-9));
|
||
prop_assert!(rr.to_bezpath().area().abs() <= r.area() + 1e-9);
|
||
}
|
||
|
||
/// Length of a rectangle is its perimeter; of a line, its distance.
|
||
#[test]
|
||
fn lengths(r in arb_rect(), a in arb_point(0.0, 100.0), b in arb_point(0.0, 100.0)) {
|
||
prop_assert!((shapes::rectangle(r).length() - 2.0 * (r.width() + r.height())).abs() < 1e-6);
|
||
prop_assert!((shapes::line(a, b).length() - a.distance(b)).abs() < 1e-6);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn from_bezpath_merges_duplicate_closing_point() {
|
||
let mut bp = BezPath::new();
|
||
bp.move_to((0.0, 0.0));
|
||
bp.line_to((10.0, 0.0));
|
||
bp.line_to((10.0, 10.0));
|
||
bp.line_to((0.0, 0.0));
|
||
bp.close_path();
|
||
let p = PathData::from_bezpath(&bp);
|
||
assert_eq!(p.anchor_count(), 3);
|
||
assert!(p.is_closed());
|
||
}
|
||
|
||
#[test]
|
||
fn empty_and_degenerate_paths() {
|
||
let e = PathData::default();
|
||
assert!(e.is_empty());
|
||
assert!(e.bounds().is_none());
|
||
assert_eq!(e.length(), 0.0);
|
||
assert!(e.nearest(Point::ZERO).is_none());
|
||
let one = PathData::from_bezpath(&{
|
||
let mut b = BezPath::new();
|
||
b.move_to((5.0, 5.0));
|
||
b
|
||
});
|
||
assert_eq!(one.anchor_count(), 1);
|
||
assert_eq!(one.bounds(), Some(Rect::new(5.0, 5.0, 5.0, 5.0)));
|
||
assert!(!fill_contains(&one.to_bezpath(), FillRule::NonZero, Point::new(5.0, 5.0)));
|
||
let _ = grid(Rect::new(0.0, 0.0, 1.0, 1.0), 2);
|
||
}
|