vectorcraft/crates/pathops/tests/props.rs

133 lines
5.7 KiB
Rust
Raw Permalink 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.

// 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)]
mod common;
use std::f64::consts::PI;
use kurbo::{ParamCurve, Point, Rect, Shape as _};
use proptest::prelude::*;
use vectorcraft_geom::{FillRule, PathData, SubPath};
use vectorcraft_pathops::*;
const NZ: FillRule = FillRule::NonZero;
fn arb_shape() -> impl Strategy<Value = PathData> {
prop_oneof![
(0.0..50.0f64, 0.0..50.0f64, 1.0..40.0f64, 1.0..40.0f64)
.prop_map(|(x, y, w, h)| PathData::from_bezpath(&Rect::new(x, y, x + w, y + h).to_path(1e-6))),
(0.0..50.0f64, 0.0..50.0f64, 1.0..25.0f64).prop_map(|(x, y, r)| PathData::from_bezpath(&kurbo::Circle::new((x, y), r).to_path(1e-4))),
]
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0)
}
proptest! {
#![proptest_config(common::config(48))]
#[test]
fn inclusion_exclusion(a in arb_shape(), b in arb_shape()) {
let u = area(&boolean(&a, NZ, &b, NZ, BoolOp::Union), NZ);
let i = area(&boolean(&a, NZ, &b, NZ, BoolOp::Intersect), NZ);
let (aa, ab) = (area(&a, NZ), area(&b, NZ));
prop_assert!(close(u + i, aa + ab, 2e-3), "u={u} i={i} a={aa} b={ab}");
}
#[test]
fn grid_rects_with_shared_edges(v in proptest::collection::vec((0u8..6, 0u8..6, 1u8..5, 1u8..5), 2..6)) {
// Integer rectangles: lots of coincident edges and touching corners.
let shapes: Vec<Shape> = v.iter().enumerate().map(|(i, &(x, y, w, h))| {
let r = Rect::new(x as f64, y as f64, (x + w) as f64, (y + h) as f64);
Shape::new(PathData::from_bezpath(&r.to_path(1e-6)), 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);
let ua = u.first().map(|s| area(&s.path, NZ)).unwrap_or(0.0);
// Exact cell count on the integer grid.
let mut cells = 0;
for cx in 0..12u8 { for cy in 0..12u8 {
if v.iter().any(|&(x, y, w, h)| cx >= x && cx < x + w && cy >= y && cy < y + h) { cells += 1; }
}}
prop_assert!(close(ua, cells as f64, 1e-9), "union {ua} cells {cells}");
prop_assert!(close(sum, cells as f64, 1e-9), "divide {sum} cells {cells}");
let trim: f64 = pathfinder(PathfinderOp::Trim, &shapes).iter().map(|f| area(&f.path, NZ)).sum();
prop_assert!(close(trim, cells as f64, 1e-9));
}
#[test]
fn difference_and_xor_consistent(a in arb_shape(), b in arb_shape()) {
let d1 = area(&boolean(&a, NZ, &b, NZ, BoolOp::Difference), NZ);
let d2 = area(&boolean(&b, NZ, &a, NZ, BoolOp::Difference), NZ);
let x = area(&boolean(&a, NZ, &b, NZ, BoolOp::Xor), NZ);
prop_assert!(close(d1 + d2, x, 2e-3));
}
#[test]
fn self_difference_empty(a in arb_shape()) {
prop_assert!(boolean(&a, NZ, &a, NZ, BoolOp::Difference).is_empty());
}
#[test]
fn unite_idempotent(a in arb_shape()) {
let u = boolean(&a, NZ, &a, NZ, BoolOp::Union);
prop_assert!(close(area(&u, NZ), area(&a, NZ), 1e-3));
prop_assert!(u.anchor_count() <= a.anchor_count() + 4, "{} vs {}", u.anchor_count(), a.anchor_count());
let uu = boolean(&u, NZ, &u, NZ, BoolOp::Union);
prop_assert!(close(area(&uu, NZ), area(&u, NZ), 1e-6));
}
#[test]
fn divide_sums_to_union(a in arb_shape(), b in arb_shape(), c in arb_shape()) {
let shapes = vec![Shape::new(a, NZ, 1), Shape::new(b, NZ, 2), Shape::new(c, NZ, 3)];
let faces = pathfinder(PathfinderOp::Divide, &shapes);
let sum: f64 = faces.iter().map(|f| area(&f.path, NZ)).sum();
let u = pathfinder(PathfinderOp::Unite, &shapes);
let ua = u.first().map(|s| area(&s.path, NZ)).unwrap_or(0.0);
prop_assert!(close(sum, ua, 2e-3), "sum={sum} union={ua}");
}
#[test]
fn offset_circle_area(r in 5.0..60.0f64, d in -4.0..10.0f64) {
let c = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), r).to_path(1e-5));
let o = offset_path(&c, d, Join::Round, 4.0);
let expect = PI * (r + d) * (r + d);
prop_assert!(close(area(&o, NZ), expect, 3e-3), "got {} expect {}", area(&o, NZ), expect);
}
#[test]
fn outline_stroke_line_area(x in -50.0..50.0f64, y in -50.0..50.0f64, len in 1.0..100.0f64, ang in 0.0..6.0f64, w in 0.5..10.0f64) {
let a = Point::new(x, y);
let b = Point::new(x + len * ang.cos(), y + len * ang.sin());
let l = PathData::single(SubPath::polyline(&[a, b], false));
let o = outline_stroke(&l, w, Cap::Butt, Join::Miter, 4.0);
prop_assert!(close(area(&o, NZ), len * w, 1e-6));
}
#[test]
fn simplify_within_tolerance(
amps in proptest::collection::vec(0.0..8.0f64, 3),
tol in 0.05..2.0f64,
) {
// A wobbly closed curve sampled as a dense polyline.
let n = 240;
let pts: Vec<Point> = (0..n).map(|i| {
let t = i as f64 / n as f64 * 2.0 * PI;
let r = 60.0 + amps[0] * (2.0 * t).sin() + amps[1] * (3.0 * t).cos() + amps[2] * (5.0 * t).sin();
Point::new(r * t.cos(), r * t.sin())
}).collect();
let p = PathData::single(SubPath::polyline(&pts, true));
let s = simplify(&p, tol);
prop_assert!(s.anchor_count() < n);
let bp = p.to_bezpath();
for seg in bp.segments() {
for k in 0..=4 {
let q = seg.eval(k as f64 / 4.0);
let d = s.nearest(q).unwrap().4;
prop_assert!(d <= tol * 1.01 + 1e-6, "deviation {d} > {tol}");
}
}
}
}