Files
vectorcraft/crates/geom/tests/prop_geom.rs
T
Brandon ThomasandClaude Opus 5.5 3e323bfa2a Robustness: VectorCraft never crashes — no panics in shipped code
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>
2026-10-05 06:28:59 +07:00

278 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}