vectorcraft/crates/pathops/tests/ops.rs

487 lines
18 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)]
use std::f64::consts::PI;
use kurbo::{ParamCurve, Point, Rect, Shape as _};
use vectorcraft_geom::{FillRule, PathData, SubPath};
use vectorcraft_pathops::*;
const NZ: FillRule = FillRule::NonZero;
fn rect(x: f64, y: f64, w: f64, h: f64) -> PathData {
PathData::from_bezpath(&Rect::new(x, y, x + w, y + h).to_path(1e-6))
}
fn circle(cx: f64, cy: f64, r: f64) -> PathData {
PathData::from_bezpath(&kurbo::Circle::new((cx, cy), r).to_path(1e-4))
}
fn ar(p: &PathData) -> f64 {
area(p, NZ)
}
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * a.abs().max(b.abs()).max(1.0)
}
fn shape(p: PathData, key: u64) -> Shape {
Shape::new(p, NZ, key)
}
fn has_curves(p: &PathData) -> bool {
p.anchors().any(|(_, _, a)| a.has_in() || a.has_out())
}
#[test]
fn union_of_rects() {
let u = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Union);
assert!(close(ar(&u), 175.0, 1e-9));
assert_eq!(u.subpaths.len(), 1);
assert_eq!(u.anchor_count(), 8);
}
#[test]
fn intersect_of_rects() {
let i = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Intersect);
assert!(close(ar(&i), 25.0, 1e-9));
assert_eq!(i.anchor_count(), 4);
}
#[test]
fn difference_of_rects() {
let d = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Difference);
assert!(close(ar(&d), 75.0, 1e-9));
assert_eq!(d.anchor_count(), 6);
}
#[test]
fn xor_of_rects() {
let x = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(5.0, 5.0, 10.0, 10.0), NZ, BoolOp::Xor);
assert!(close(ar(&x), 150.0, 1e-9));
}
#[test]
fn disjoint_union_keeps_both() {
let u = boolean(&rect(0.0, 0.0, 1.0, 1.0), NZ, &rect(5.0, 5.0, 1.0, 1.0), NZ, BoolOp::Union);
assert_eq!(u.subpaths.len(), 2);
assert!(close(ar(&u), 2.0, 1e-9));
}
#[test]
fn a_minus_a_is_empty() {
let c = circle(3.0, 4.0, 7.0);
assert!(boolean(&c, NZ, &c, NZ, BoolOp::Difference).is_empty());
}
#[test]
fn union_idempotent_rect() {
let r = rect(1.0, 2.0, 3.0, 4.0);
let u = boolean(&r, NZ, &r, NZ, BoolOp::Union);
assert_eq!(u.anchor_count(), 4);
assert!(close(ar(&u), 12.0, 1e-9));
}
#[test]
fn circle_union_is_curve_preserving_and_compact() {
let u = boolean(&circle(0.0, 0.0, 10.0), NZ, &circle(10.0, 0.0, 10.0), NZ, BoolOp::Union);
assert!(has_curves(&u));
assert!(u.anchor_count() <= 12, "anchors: {}", u.anchor_count());
// Lens area: 2 r² acos(d/2r) − (d/2)√(4r² − d²)
let (r, d): (f64, f64) = (10.0, 10.0);
let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt();
assert!(close(ar(&u), 2.0 * PI * r * r - lens, 1e-3));
}
#[test]
fn circle_intersect_rect_keeps_curve() {
let i = boolean(&circle(0.0, 0.0, 10.0), NZ, &rect(0.0, -20.0, 20.0, 40.0), NZ, BoolOp::Intersect);
assert!(has_curves(&i));
assert!(close(ar(&i), PI * 50.0, 1e-3));
assert!(i.anchor_count() <= 5, "anchors: {}", i.anchor_count());
}
#[test]
fn hole_from_difference() {
let d = boolean(&rect(0.0, 0.0, 10.0, 10.0), NZ, &rect(3.0, 3.0, 4.0, 4.0), NZ, BoolOp::Difference);
assert_eq!(d.subpaths.len(), 2);
assert!(close(ar(&d), 84.0, 1e-9));
// Consistently oriented: works with both fill rules.
assert!(close(area(&d, FillRule::EvenOdd), 84.0, 1e-9));
}
#[test]
fn fill_rules_differ_for_nested_same_direction() {
let mut p = rect(0.0, 0.0, 10.0, 10.0);
p.subpaths.extend(rect(3.0, 3.0, 4.0, 4.0).subpaths);
assert!(close(area(&p, FillRule::NonZero), 100.0, 1e-9));
assert!(close(area(&p, FillRule::EvenOdd), 84.0, 1e-9));
let u = boolean(&p, FillRule::EvenOdd, &rect(20.0, 0.0, 1.0, 1.0), NZ, BoolOp::Union);
assert!(close(ar(&u), 85.0, 1e-9));
}
#[test]
fn open_paths_are_closed_for_fill() {
let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], false));
assert!(close(ar(&tri), 50.0, 1e-9));
}
#[test]
fn non_finite_input_errors() {
let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 0.0), Point::new(0.0, 1.0)], true));
let r = try_boolean(&bad, NZ, &rect(0.0, 0.0, 1.0, 1.0), NZ, BoolOp::Union, DEFAULT_PRECISION);
assert_eq!(r, Err(PathOpsError::NonFinite));
}
#[test]
fn normalize_figure_eight() {
let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true));
let n = normalize(&p, NZ);
assert_eq!(n.subpaths.len(), 2);
assert!(close(ar(&n), 50.0, 1e-9));
}
fn stack() -> Vec<Shape> {
vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(5.0, 5.0, 10.0, 10.0), 2)]
}
#[test]
fn pf_unite_takes_front_key() {
let r = pathfinder(PathfinderOp::Unite, &stack());
assert_eq!(r.len(), 1);
assert_eq!(r[0].key, 2);
assert!(close(ar(&r[0].path), 175.0, 1e-9));
}
#[test]
fn pf_minus_front_and_back() {
let r = pathfinder(PathfinderOp::MinusFront, &stack());
assert_eq!(r[0].key, 1);
assert!(close(ar(&r[0].path), 75.0, 1e-9));
let r = pathfinder(PathfinderOp::MinusBack, &stack());
assert_eq!(r[0].key, 2);
assert!(close(ar(&r[0].path), 75.0, 1e-9));
}
#[test]
fn pf_intersect_and_exclude_three() {
let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(5.0, 0.0, 10.0, 10.0), 2), shape(rect(8.0, 0.0, 10.0, 10.0), 3)];
let i = pathfinder(PathfinderOp::Intersect, &s);
assert!(close(ar(&i[0].path), 20.0, 1e-9));
// Odd coverage: [0,5)=1, [5,8)=2, [8,10)=3, [10,15)=2, [15,18)=1 → 5+2+3 = 10 wide.
let x = pathfinder(PathfinderOp::Exclude, &s);
assert!(close(ar(&x[0].path), 100.0, 1e-9));
}
#[test]
fn pf_divide_faces() {
let r = pathfinder(PathfinderOp::Divide, &stack());
assert_eq!(r.len(), 3);
let total: f64 = r.iter().map(|s| ar(&s.path)).sum();
assert!(close(total, 175.0, 1e-9));
let overlap = r.iter().find(|s| close(ar(&s.path), 25.0, 1e-9)).unwrap();
assert_eq!(overlap.key, 2);
}
#[test]
fn pf_trim_removes_hidden() {
let r = pathfinder(PathfinderOp::Trim, &stack());
assert_eq!(r.len(), 2);
assert!(close(ar(&r[0].path), 75.0, 1e-9));
assert!(close(ar(&r[1].path), 100.0, 1e-9));
}
#[test]
fn pf_merge_same_key() {
let mut s = stack();
s[1].key = 1;
let r = pathfinder(PathfinderOp::Merge, &s);
assert_eq!(r.len(), 1);
assert!(close(ar(&r[0].path), 175.0, 1e-9));
let r = pathfinder(PathfinderOp::Merge, &stack());
assert_eq!(r.len(), 2);
}
#[test]
fn pf_crop() {
let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(10.0, 0.0, 10.0, 10.0), 2), shape(rect(5.0, 0.0, 10.0, 5.0), 3)];
let r = pathfinder(PathfinderOp::Crop, &s);
assert_eq!(r.len(), 2);
assert!(r.iter().all(|x| close(ar(&x.path), 25.0, 1e-9)));
assert_eq!(r[0].key, 1);
}
#[test]
fn pf_outline_edges() {
let r = pathfinder(PathfinderOp::Outline, &stack());
assert_eq!(r.len(), 4, "{r:?}");
assert!(r.iter().all(|s| s.path.subpaths.iter().all(|sp| !sp.closed)));
let len: f64 = r.iter().map(|s| s.path.length()).sum();
assert!(close(len, 80.0, 1e-9));
}
#[test]
fn pf_outline_dedups_shared_edges() {
// Two squares sharing an edge exactly.
let s = vec![shape(rect(0.0, 0.0, 10.0, 10.0), 1), shape(rect(10.0, 0.0, 10.0, 10.0), 2)];
let r = pathfinder(PathfinderOp::Outline, &s);
let len: f64 = r.iter().map(|s| s.path.length()).sum();
assert!(close(len, 70.0, 1e-9), "len {len}");
}
#[test]
fn regions_and_region_at() {
let s = stack();
let rs = regions(&s);
assert_eq!(rs.len(), 3);
let hit = region_at(&s, Point::new(7.0, 7.0)).unwrap();
assert_eq!(hit.sources, vec![0, 1]);
assert!(close(ar(&hit.path), 25.0, 1e-9));
assert!(region_at(&s, Point::new(100.0, 100.0)).is_none());
let refs: Vec<&Region> = rs.iter().collect();
assert!(close(ar(&merge_regions(&refs)), 175.0, 1e-9));
}
#[test]
fn offset_circle_grows() {
let o = offset_path(&circle(0.0, 0.0, 10.0), 3.0, Join::Round, 4.0);
assert!(close(ar(&o), PI * 169.0, 2e-3), "{}", ar(&o));
assert!(o.anchor_count() <= 16, "anchors {}", o.anchor_count());
}
#[test]
fn offset_circle_insets() {
let o = offset_path(&circle(0.0, 0.0, 10.0), -4.0, Join::Round, 4.0);
assert!(close(ar(&o), PI * 36.0, 2e-3), "{}", ar(&o));
}
#[test]
fn offset_rect_joins() {
let r = rect(0.0, 0.0, 20.0, 10.0);
assert!(close(ar(&offset_path(&r, 2.0, Join::Miter, 4.0)), 24.0 * 14.0, 1e-6));
assert_eq!(offset_path(&r, 2.0, Join::Miter, 4.0).anchor_count(), 4);
assert!(close(ar(&offset_path(&r, 2.0, Join::Bevel, 4.0)), 24.0 * 14.0 - 4.0 * 2.0, 1e-6));
assert!(close(ar(&offset_path(&r, 2.0, Join::Round, 4.0)), 200.0 + 2.0 * 2.0 * 30.0 + PI * 4.0, 1e-3));
assert!(close(ar(&offset_path(&r, -2.0, Join::Miter, 4.0)), 16.0 * 6.0, 1e-6));
assert!(offset_path(&r, -6.0, Join::Miter, 4.0).is_empty());
}
#[test]
fn outline_stroke_of_line() {
let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false));
let o = outline_stroke(&l, 4.0, Cap::Butt, Join::Miter, 4.0);
assert!(close(ar(&o), 200.0, 1e-6));
let o = outline_stroke(&l, 4.0, Cap::Square, Join::Miter, 4.0);
assert!(close(ar(&o), 216.0, 1e-6));
}
#[test]
fn outline_stroke_of_square_is_ring() {
let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0);
assert_eq!(o.subpaths.len(), 2);
assert!(close(ar(&o), 144.0 - 64.0, 1e-6));
}
#[test]
fn outline_stroke_self_overlap_is_cleaned() {
// A zig-zag whose stroke overlaps itself at the joins.
let z = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 1.0), Point::new(10.0, 2.0)], false));
let o = outline_stroke(&z, 3.0, Cap::Round, Join::Round, 4.0);
assert!(!o.is_empty());
// Normalised output: its signed area equals its fill area.
let signed: f64 = o.subpaths.iter().map(|s| s.area()).sum::<f64>().abs();
assert!(close(signed, ar(&o), 1e-6));
}
fn dense_circle(r: f64, n: usize) -> PathData {
let pts: Vec<Point> =
(0..n).map(|i| Point::new(r * (i as f64 / n as f64 * 2.0 * PI).cos(), r * (i as f64 / n as f64 * 2.0 * PI).sin())).collect();
PathData::single(SubPath::polyline(&pts, true))
}
fn max_deviation(orig: &PathData, simp: &PathData) -> f64 {
let bp = orig.to_bezpath();
let mut worst: f64 = 0.0;
for seg in bp.segments() {
for i in 0..=8 {
let p = seg.eval(i as f64 / 8.0);
worst = worst.max(simp.nearest(p).unwrap().4);
}
}
worst
}
#[test]
fn simplify_dense_circle() {
let c = dense_circle(50.0, 200);
let s = simplify(&c, 0.5);
assert!(s.anchor_count() <= 12, "anchors {}", s.anchor_count());
assert!(max_deviation(&c, &s) <= 0.5 + 1e-6);
assert!(s.subpaths[0].closed);
}
#[test]
fn simplify_keeps_square_corners() {
let mut pts = Vec::new();
for (a, b) in [((0.0, 0.0), (10.0, 0.0)), ((10.0, 0.0), (10.0, 10.0)), ((10.0, 10.0), (0.0, 10.0)), ((0.0, 10.0), (0.0, 0.0))] {
for i in 0..5 {
let t = i as f64 / 5.0;
pts.push(Point::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t));
}
}
let sq = PathData::single(SubPath::polyline(&pts, true));
let s = simplify(&sq, 0.1);
assert_eq!(s.anchor_count(), 4);
assert!(!has_curves(&s));
}
#[test]
fn simplify_straight_lines_option() {
let c = dense_circle(50.0, 200);
let s = simplify_with(&c, &SimplifyOptions { tolerance: 1.0, straight_lines: true, ..Default::default() });
assert!(!has_curves(&s));
assert!(s.anchor_count() < 40);
assert!(max_deviation(&c, &s) <= 1.0 + 1e-6);
}
#[test]
fn smooth_rounds_corners() {
let s = smooth(&rect(0.0, 0.0, 10.0, 10.0), 1.0);
assert!(s.anchors().all(|(_, _, a)| a.has_in() && a.has_out()));
let same = smooth(&rect(0.0, 0.0, 10.0, 10.0), 0.0);
assert_eq!(same, rect(0.0, 0.0, 10.0, 10.0));
}
#[test]
fn remove_redundant_collinear_and_curve() {
let p = PathData::single(SubPath::polyline(
&[Point::new(0.0, 0.0), Point::new(5.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)],
true,
));
assert_eq!(remove_redundant_points(&p, 1e-6).anchor_count(), 4);
let c = add_anchor_points(&circle(0.0, 0.0, 10.0));
let n0 = c.anchor_count();
let r = remove_redundant_points(&c, 1e-3);
assert!(r.anchor_count() < n0, "{} vs {}", r.anchor_count(), n0);
assert!(close(ar(&r), ar(&c), 1e-4));
}
#[test]
fn add_anchor_points_doubles() {
let c = circle(0.0, 0.0, 10.0);
let a = add_anchor_points(&c);
assert_eq!(a.anchor_count(), 2 * c.anchor_count());
assert!(close(ar(&a), ar(&c), 1e-9));
let r = add_anchor_points(&rect(0.0, 0.0, 10.0, 10.0));
assert!(r.anchors().any(|(_, _, a)| a.p == Point::new(5.0, 0.0)));
}
#[test]
fn average_axes() {
let p = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 4.0), Point::new(5.0, 8.0)], false));
let h = average(&p, &[(0, 0), (0, 1)], AverageAxis::Horizontal);
assert_eq!(h.subpaths[0].anchors[0].p, Point::new(0.0, 2.0));
assert_eq!(h.subpaths[0].anchors[1].p, Point::new(10.0, 2.0));
let b = average(&p, &[(0, 0), (0, 1), (0, 2)], AverageAxis::Both);
assert!(b.subpaths[0].anchors.iter().all(|a| a.p.distance(Point::new(5.0, 4.0)) < 1e-12));
let v = average(&p, &[(0, 0), (0, 1)], AverageAxis::Vertical);
assert_eq!(v.subpaths[0].anchors[0].p.x, 5.0);
}
#[test]
fn join_two_open_paths() {
let a = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false));
let b = PathData::single(SubPath::polyline(&[Point::new(20.0, 5.0), Point::new(10.0, 0.0)], false));
let j = join(&[a.clone(), b], 1e-6);
assert_eq!(j.subpaths.len(), 1);
assert_eq!(j.anchor_count(), 3);
assert!(!j.subpaths[0].closed);
let c = PathData::single(SubPath::polyline(&[Point::new(12.0, 0.0), Point::new(20.0, 0.0)], false));
let j = join(&[a, c], 1e-6);
assert_eq!(j.anchor_count(), 4);
}
#[test]
fn join_single_path_closes() {
let a = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 5.0)], false));
let j = join(&[a], 1e-6);
assert!(j.subpaths[0].closed);
assert_eq!(j.anchor_count(), 3);
let b = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 5.0), Point::new(0.0, 0.0)], false));
assert_eq!(join(&[b], 1e-6).anchor_count(), 3);
}
#[test]
fn grid_split() {
let g = split_into_grid(Rect::new(0.0, 0.0, 100.0, 50.0), 2, 3, (7.0, 5.0));
assert_eq!(g.len(), 6);
let w = (100.0 - 10.0) / 3.0;
let h = (50.0 - 7.0) / 2.0;
assert!(g.iter().all(|c| close(ar(c), w * h, 1e-9)));
assert_eq!(g[5].bounds().unwrap().x1, 100.0);
assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, 10.0), 0, 3, (1.0, 1.0)).is_empty());
assert!(split_into_grid(Rect::new(0.0, 0.0, 10.0, f64::NAN), 2, 3, (1.0, 1.0)).is_empty());
}
#[test]
#[ignore]
fn timing_unite_1000_circles() {
let mut seed = 0x1234_5678_u64;
let mut rnd = || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed % 1_000_000) as f64 / 1_000_000.0
};
let circles: Vec<Shape> = (0..1000).map(|i| shape(circle(rnd() * 1000.0, rnd() * 1000.0, 5.0 + rnd() * 30.0), i)).collect();
let t = std::time::Instant::now();
let r = pathfinder(PathfinderOp::Unite, &circles);
println!("unite 1000 circles: {} ms, {} anchors", t.elapsed().as_millis(), r[0].path.anchor_count());
assert!(!r.is_empty());
}
#[test]
fn remove_anchor_refits_the_curve_round_it() {
use vectorcraft_geom::Anchor;
let (p0, p3) = (Point::new(0.0, 0.0), Point::new(100.0, 0.0));
let original =
SubPath::new(vec![Anchor::with_handles(p0, p0, Point::new(30.0, 80.0)), Anchor::with_handles(p3, Point::new(70.0, 80.0), p3)], false);
let mut sp = original.clone();
let i = sp.insert_anchor(0, 0.4);
assert!(remove_anchor(&mut sp, i));
assert_eq!(sp.anchors.len(), 2);
assert!(sp.anchors[0].h_out.distance(original.anchors[0].h_out) < 0.5, "{:?}", sp.anchors[0].h_out);
assert!(sp.anchors[1].h_in.distance(original.anchors[1].h_in) < 0.5, "{:?}", sp.anchors[1].h_in);
// A circle stays closed and round with one anchor fewer.
let mut c = circle(0.0, 0.0, 50.0).subpaths.remove(0);
let n = c.anchors.len();
assert!(remove_anchor(&mut c, 1));
assert!(c.closed && c.anchors.len() == n - 1);
let a = ar(&PathData::single(c));
assert!(close(a, PI * 2500.0, 0.03), "{a}");
}
#[test]
fn remove_anchor_between_straight_sides_draws_a_straight_one() {
let mut sp = rect(0.0, 0.0, 100.0, 80.0).subpaths.remove(0);
let n = sp.anchors.len();
assert!(remove_anchor(&mut sp, 0));
assert!(sp.closed && sp.anchors.len() == n - 1);
assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "{sp:?}");
let mut line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(40.0, 30.0), Point::new(100.0, 0.0)], false);
assert!(remove_anchor(&mut line, 1));
assert!(line.anchors.len() == 2 && line.segment_is_line(0));
}
#[test]
fn remove_anchor_at_an_end_drops_its_segment_and_rejects_bad_input() {
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(20.0, 5.0)], false);
assert!(!remove_anchor(&mut sp, 3));
assert!(remove_anchor(&mut sp, 0));
assert_eq!(sp.anchors[0].p, Point::new(10.0, 0.0));
// Non-finite points never panic.
let mut bad = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(f64::NAN, 1.0), Point::new(f64::INFINITY, 0.0)], false);
bad.anchors[0].h_out = Point::new(5.0, f64::NAN);
assert!(remove_anchor(&mut bad, 1));
assert_eq!(bad.anchors.len(), 2);
}