// 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 { 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::().abs(); assert!(close(signed, ar(&o), 1e-6)); } fn dense_circle(r: f64, n: usize) -> PathData { let pts: Vec = (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 = (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); }