Files
vectorcraft/crates/pathops/tests/it/prop_pathops.rs
T
aiongg 8cc69ed45c CI: one test binary per crate, nextest, line tables only (#3855)
* CI: line tables only for workspace crates, no debug info for dependencies

* CI: one integration-test binary per crate

Each crate's tests/*.rs files become modules of tests/it/main.rs. Files that change
process-wide state (Color Settings, the font database, installed plug-ins, the working
directory), the fuzz sweeps and the test targets the Affinity corpus workflow names stay
their own binaries.

* Docs: name the integration tests' new paths

* CI: xtask test with nextest and doctests, one wasm check, clippy --keep-going

cargo xtask ci runs the tests with cargo nextest (cargo test when it is not installed),
builds them with --lib --bins --tests, runs the doctests as their own step, and checks
the wasm crates in one cargo check --keep-going call.
2026-10-23 21:36:10 -04:00

258 lines
13 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.
//! Path-operation properties on concave polygons, ellipses and random smooth curves (complements
//! `props.rs`, which uses rectangles and circles).
use crate::common;
use proptest::prelude::*;
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use vectorcraft_pathops::*;
use vectorcraft_testkit::geom::{directed_hausdorff, grid, hausdorff, polygon_area, rel_close};
use vectorcraft_testkit::strategies::{
arb_closed_shape, arb_convex_polygon, arb_ellipse, arb_rect, arb_smooth_curve, arb_star_polygon, polygon_path,
};
const NZ: FillRule = FillRule::NonZero;
fn inside(p: &PathData, q: Point) -> bool {
vectorcraft_geom::hit::fill_contains(&p.to_bezpath(), NZ, q)
}
fn near_edge(p: &PathData, q: Point, eps: f64) -> bool {
vectorcraft_geom::hit::distance_to_outline(&p.to_bezpath(), q) < eps
}
proptest! {
#![proptest_config(common::config(128))]
/// Area of a simple polygon equals its shoelace area; normalize keeps it.
#[test]
fn polygon_area_matches_shoelace(pts in arb_star_polygon()) {
let p = polygon_path(&pts);
let a = polygon_area(&pts);
prop_assert!(rel_close(area(&p, NZ), a, 1e-6), "{} vs {a}", area(&p, NZ));
prop_assert!(rel_close(area(&normalize(&p, NZ), NZ), a, 1e-4));
}
/// A = (A − B) + (A ∩ B); union/intersection commute; results never exceed their bounds.
#[test]
fn area_identities(a in arb_closed_shape(), b in arb_closed_shape()) {
let (aa, ab) = (area(&a, NZ), area(&b, NZ));
let d = area(&boolean(&a, NZ, &b, NZ, BoolOp::Difference), NZ);
let i = area(&boolean(&a, NZ, &b, NZ, BoolOp::Intersect), NZ);
let i2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Intersect), NZ);
let u = area(&boolean(&a, NZ, &b, NZ, BoolOp::Union), NZ);
let u2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Union), NZ);
let x = area(&boolean(&a, NZ, &b, NZ, BoolOp::Xor), NZ);
let tol = 2e-3;
prop_assert!(rel_close(d + i, aa, tol), "d {d} + i {i} != a {aa}");
prop_assert!(rel_close(i, i2, tol) && rel_close(u, u2, tol));
prop_assert!(rel_close(x, u - i, tol), "xor {x} != u-i {}", u - i);
prop_assert!(i <= aa.min(ab) * (1.0 + tol) + 1e-6);
prop_assert!(u + 1e-6 >= aa.max(ab) * (1.0 - tol));
prop_assert!(u <= (aa + ab) * (1.0 + tol) + 1e-6);
}
/// A ∪ A = A ∩ A = A, A − A = ∅.
#[test]
fn self_ops(a in arb_closed_shape()) {
let aa = area(&a, NZ);
prop_assert!(rel_close(area(&boolean(&a, NZ, &a, NZ, BoolOp::Union), NZ), aa, 1e-3));
prop_assert!(rel_close(area(&boolean(&a, NZ, &a, NZ, BoolOp::Intersect), NZ), aa, 1e-3));
prop_assert!(area(&boolean(&a, NZ, &a, NZ, BoolOp::Difference), NZ) < 1e-3 * aa.max(1.0));
}
/// Point membership of boolean results agrees with the set operation on probe points away
/// from any edge.
#[test]
fn boolean_membership(a in arb_closed_shape(), b in arb_closed_shape()) {
let ops = [BoolOp::Union, BoolOp::Intersect, BoolOp::Difference, BoolOp::Xor];
let results: Vec<PathData> = ops.iter().map(|op| boolean(&a, NZ, &b, NZ, *op)).collect();
for q in grid(Rect::new(0.0, 0.0, 230.0, 230.0), 23) {
if near_edge(&a, q, 0.5) || near_edge(&b, q, 0.5) {
continue;
}
let (ia, ib) = (inside(&a, q), inside(&b, q));
let want = [ia || ib, ia && ib, ia && !ib, ia != ib];
for (k, r) in results.iter().enumerate() {
prop_assert_eq!(inside(r, q), want[k], "{:?} at {:?}", ops[k], q);
}
}
}
/// Divide faces partition the union: areas sum to the union area, and every probe point in
/// the union lies in exactly one face; each face's sources are exactly the shapes covering it.
#[test]
fn divide_partitions_union(v in prop::collection::vec(arb_closed_shape(), 2..5)) {
let shapes: Vec<Shape> = v.iter().enumerate().map(|(i, p)| Shape::new(p.clone(), NZ, i as u64)).collect();
let faces = pathfinder(PathfinderOp::Divide, &shapes);
let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum();
let u = pathfinder(PathfinderOp::Unite, &shapes).first().map(|s| area(&s.path, NZ)).unwrap_or(0.0);
prop_assert!(rel_close(sum, u, 3e-3), "faces {sum} vs union {u}");
let regs = regions(&shapes);
for q in grid(Rect::new(0.0, 0.0, 230.0, 230.0), 17) {
if v.iter().any(|p| near_edge(p, q, 0.5)) {
continue;
}
let covering: Vec<usize> = v.iter().enumerate().filter(|(_, p)| inside(p, q)).map(|(i, _)| i).collect();
let hits: Vec<&Region> = regs.iter().filter(|r| r.contains(q)).collect();
if covering.is_empty() {
prop_assert!(hits.is_empty(), "uncovered point {q:?} in a region");
} else {
prop_assert_eq!(hits.len(), 1, "point {:?} in {} regions", q, hits.len());
prop_assert_eq!(&hits[0].sources, &covering);
}
}
}
/// Offset monotonicity: a larger offset contains a smaller one (area of smaller − larger ≈ 0)
/// and areas are ordered.
#[test]
fn offset_monotone(p in prop_oneof![arb_ellipse(), arb_convex_polygon().prop_map(|v| polygon_path(&v)), arb_star_polygon().prop_map(|v| polygon_path(&v))],
d1 in -3.0..8.0f64, dd in 0.5..6.0f64) {
let d2 = d1 + dd;
// Large offsets of small curved shapes are a known bug (bug_offset_large_delta_loses_area).
let b = p.bounds().unwrap();
prop_assume!(b.width().min(b.height()) >= 2.0 * d2.abs());
for join in [Join::Round, Join::Miter, Join::Bevel] {
let small = offset_path(&p, d1, join, 4.0);
let big = offset_path(&p, d2, join, 4.0);
let (sa, ba) = (area(&small, NZ), area(&big, NZ));
prop_assert!(ba + 1e-6 >= sa, "{join:?}: area({d2}) {ba} < area({d1}) {sa}");
let leak = area(&boolean(&small, NZ, &big, NZ, BoolOp::Difference), NZ);
prop_assert!(leak <= 2e-3 * sa.max(1.0), "{join:?}: offset {d1} leaks {leak} outside offset {d2}");
}
// A positive offset contains the original.
if d2 > 0.0 {
let big = offset_path(&p, d2, Join::Round, 4.0);
let leak = area(&boolean(&p, NZ, &big, NZ, BoolOp::Difference), NZ);
prop_assert!(leak <= 2e-3 * area(&p, NZ).max(1.0));
}
}
/// Round-join offset of a convex polygon follows Steiner's formula A + P·d + π·d².
#[test]
fn offset_convex_steiner(pts in arb_convex_polygon(), d in 0.5..10.0f64) {
let p = polygon_path(&pts);
let perim: f64 = (0..pts.len()).map(|i| pts[i].distance(pts[(i + 1) % pts.len()])).sum();
let want = polygon_area(&pts) + perim * d + std::f64::consts::PI * d * d;
let got = area(&offset_path(&p, d, Join::Round, 4.0), NZ);
prop_assert!(rel_close(got, want, 5e-3), "{got} vs {want}");
}
/// Offsetting by 0 keeps the shape.
#[test]
fn offset_zero_identity(p in arb_closed_shape()) {
let o = offset_path(&p, 0.0, Join::Miter, 4.0);
prop_assert!(rel_close(area(&o, NZ), area(&p, NZ), 2e-3));
}
/// Simplify stays within tolerance (plus a small fitting slack). (It should also never add
/// anchors — see `bug_simplify_adds_anchors`.)
#[test]
fn simplify_error_within_tolerance(c in arb_smooth_curve(), tol in 0.2..5.0f64) {
let s = simplify(&c, tol);
let (a, b) = (c.to_bezpath(), s.to_bezpath());
let err = hausdorff(&a, &b, 24);
prop_assert!(err <= tol * 1.1 + 1e-6, "simplify error {err} > tol {tol}");
// Endpoints are preserved on open paths.
let (c0, s0) = (&c.subpaths[0], &s.subpaths[0]);
prop_assert!(c0.anchors[0].p.distance(s0.anchors[0].p) < 1e-9);
prop_assert!(c0.anchors.last().unwrap().p.distance(s0.anchors.last().unwrap().p) < 1e-9);
}
/// Straight-line simplify produces only line segments within tolerance.
#[test]
fn simplify_straight_lines(c in arb_smooth_curve(), tol in 0.5..5.0f64) {
let s = simplify_with(&c, &SimplifyOptions { tolerance: tol, straight_lines: true, ..Default::default() });
for sp in &s.subpaths {
for i in 0..sp.segment_count() {
prop_assert!(sp.segment_is_line(i));
}
}
prop_assert!(directed_hausdorff(&s.to_bezpath(), &c.to_bezpath(), 16) <= tol * 1.1 + 1e-6);
}
/// add_anchor_points doubles segment count without changing geometry.
#[test]
fn add_anchor_points_preserves(p in arb_closed_shape()) {
let q = add_anchor_points(&p);
let segs = |p: &PathData| p.subpaths.iter().map(|s| s.segment_count()).sum::<usize>();
prop_assert_eq!(segs(&q), 2 * segs(&p));
prop_assert!(hausdorff(&p.to_bezpath(), &q.to_bezpath(), 8) < 1e-6);
}
/// Split into grid: rows×cols cells whose total area is the rect minus gutters.
#[test]
fn split_into_grid_areas(r in arb_rect(), rows in 1usize..6, cols in 1usize..6, g in 0.0..2.0f64) {
let gutter_w = g * (cols - 1) as f64;
let gutter_h = g * (rows - 1) as f64;
prop_assume!(r.width() > gutter_w + 1.0 && r.height() > gutter_h + 1.0);
let cells = split_into_grid(r, rows, cols, (g, g));
prop_assert_eq!(cells.len(), rows * cols);
let total: f64 = cells.iter().map(|c| area(c, NZ)).sum();
let want = (r.width() - gutter_w) * (r.height() - gutter_h);
prop_assert!(rel_close(total, want, 1e-6), "{total} vs {want}");
}
/// Outline stroke of a closed convex polygon (round joins) has area ≈ perimeter × width + π(w/2)²·0
/// (ring area = offset(+w/2) − offset(−w/2)).
#[test]
fn outline_stroke_ring_area(pts in arb_convex_polygon(), w in 0.5..4.0f64) {
let p = polygon_path(&pts);
let ring = area(&outline_stroke(&p, w, Cap::Butt, Join::Round, 4.0), NZ);
let outer = area(&offset_path(&p, w / 2.0, Join::Round, 4.0), NZ);
let inner = area(&offset_path(&p, -w / 2.0, Join::Round, 4.0), NZ);
prop_assert!(rel_close(ring, outer - inner, 1e-2), "ring {ring} vs {}", outer - inner);
}
}
#[test]
fn non_finite_input_is_an_error() {
let mut p = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
p.subpaths[0].anchors[1].p.x = f64::NAN;
let q = vectorcraft_geom::shapes::rectangle(Rect::new(5.0, 5.0, 15.0, 15.0));
assert_eq!(try_boolean(&p, NZ, &q, NZ, BoolOp::Union, DEFAULT_PRECISION), Err(PathOpsError::NonFinite));
assert!(boolean(&p, NZ, &q, NZ, BoolOp::Union).is_empty());
assert!(offset_path(&q, f64::NAN, Join::Round, 4.0).is_empty());
assert!(outline_stroke(&q, f64::INFINITY, Cap::Butt, Join::Round, 4.0).is_empty());
}
#[test]
fn empty_inputs() {
let e = PathData::default();
let q = vectorcraft_geom::shapes::rectangle(Rect::new(5.0, 5.0, 15.0, 15.0));
assert!(rel_close(area(&boolean(&e, NZ, &q, NZ, BoolOp::Union), NZ), 100.0, 1e-9));
assert!(boolean(&e, NZ, &q, NZ, BoolOp::Intersect).is_empty());
assert!(pathfinder(PathfinderOp::Divide, &[]).is_empty());
assert!(regions(&[]).is_empty());
assert!(simplify(&e, 1.0).is_empty());
}
#[test]
fn bug_simplify_adds_anchors() {
use vectorcraft_geom::{Anchor, SubPath};
let a = |p: (f64, f64), i: (f64, f64), o: (f64, f64)| Anchor::with_handles(p.into(), i.into(), o.into());
let c = PathData::single(SubPath::new(
vec![
a((0.0, 100.0), (-4.964818950742777, 102.85147018094767), (4.964818950742777, 97.14852981905233)),
a((29.788913704456665, 82.89117891431397), (19.859275802971112, 82.89117891431397), (39.71855160594222, 82.89117891431397)),
a((59.57782740891333, 100.0), (49.64818950742777, 94.34401856927909), (69.50746531039889, 105.65598143072091)),
a((89.36674111337, 116.82706749863948), (79.43710321188445, 116.82706749863948), (99.29637901485555, 116.82706749863948)),
a((119.15565481782666, 100.0), (109.22601691634111, 102.80451124977324), (129.0852927193122, 97.19548875022676)),
a((148.94456852228333, 100.0), (143.97974957154057, 100.0), (153.9093874730261, 100.0)),
],
false,
));
let s = simplify(&c, 0.2);
assert!(s.anchor_count() <= c.anchor_count(), "{} anchors in, {} out", c.anchor_count(), s.anchor_count());
}
#[test]
fn offset_large_delta_keeps_area() {
for (r, d) in [(1.0, 5.0), (1.0, 3.0), (10.0, 30.0)] {
let p = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 2.0 * r, 2.0 * r));
let got = area(&offset_path(&p, d, Join::Round, 4.0), NZ);
let want = std::f64::consts::PI * (r + d) * (r + d);
assert!(rel_close(got, want, 1e-2), "r={r} d={d}: {got} vs {want}");
}
}