mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Shipped code no longer unwraps, expects or panics: every one of the 124 unwrap()/expect()/panic!/unreachable! sites outside tests now handles the failure (Result/Option, ?, let-else, defaults) and reports errors to the user. - Lints: workspace clippy denies unwrap_used, expect_used, panic, todo, unimplemented and unreachable; clippy.toml allows them in tests, and integration tests, examples and testkit opt out explicitly. - Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls the document back and returns EngineError::Internal; a panicking tool is reset; the MCP server answers with an internal error and keeps serving; the UI frame and each control request are guarded. - APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp return Result (JPEG/WebP errors used to write empty files), FontDb::face returns Option, Region::top returns Option. - Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG and PDF through import → render → export (30k cases locally, no panics). - Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section, README pointer and a PR template checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
12 KiB
Rust
278 lines
12 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));
|
||
}
|
||
|
||
/// 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);
|
||
}
|