use crate::predicates::winding_number;
use crate::vec::Point2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BooleanOp {
Union,
Intersection,
Difference,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PolygonBooleanResult {
pub outer: Vec<Vec<Point2>>,
pub holes: Vec<Vec<Point2>>,
}
impl PolygonBooleanResult {
#[must_use]
pub fn is_empty(&self) -> bool {
self.outer.is_empty() && self.holes.is_empty()
}
#[must_use]
pub fn area(&self) -> f64 {
let mut total = 0.0;
for loop_pts in &self.outer {
total += signed_area(loop_pts);
}
for loop_pts in &self.holes {
total += signed_area(loop_pts);
}
total
}
}
#[must_use]
pub fn polygon_union(a: &[Point2], b: &[Point2], tol: f64) -> Vec<Vec<Point2>> {
polygon_boolean(a, b, BooleanOp::Union, tol).outer
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn polygon_boolean(
a: &[Point2],
b: &[Point2],
op: BooleanOp,
tol: f64,
) -> PolygonBooleanResult {
let tol = if tol > 0.0 && tol.is_finite() {
tol
} else {
return PolygonBooleanResult::default();
};
let poly_a = match Polygon::normalized(a, tol) {
Some(p) => p,
None => return degenerate_fallback(a, b, op, tol),
};
let poly_b = match Polygon::normalized(b, tol) {
Some(p) => p,
None => return degenerate_fallback(a, b, op, tol),
};
let snapper = Snapper::new(tol);
let edges_a = split_polygon(&poly_a, &poly_b, &snapper, tol);
let edges_b = split_polygon(&poly_b, &poly_a, &snapper, tol);
let mut selected: Vec<DirectedEdge> = Vec::new();
select_edges(
&edges_a,
&poly_b,
op,
EdgeSource::A,
&snapper,
tol,
&mut selected,
);
select_edges(
&edges_b,
&poly_a,
op,
EdgeSource::B,
&snapper,
tol,
&mut selected,
);
if selected.is_empty() {
return PolygonBooleanResult::default();
}
let loops = trace_loops(selected, &snapper, tol);
classify_loops(loops, tol)
}
#[must_use]
pub fn signed_area(polygon: &[Point2]) -> f64 {
let n = polygon.len();
if n < 3 {
return 0.0;
}
let mut sum = 0.0;
for i in 0..n {
let p = polygon[i];
let q = polygon[(i + 1) % n];
sum += p.x().mul_add(q.y(), -(q.x() * p.y()));
}
sum * 0.5
}
fn dist_sq(a: Point2, b: Point2) -> f64 {
let dx = a.x() - b.x();
let dy = a.y() - b.y();
dx.mul_add(dx, dy * dy)
}
fn point_segment_dist_sq(p: Point2, a: Point2, b: Point2) -> f64 {
let abx = b.x() - a.x();
let aby = b.y() - a.y();
let len_sq = abx.mul_add(abx, aby * aby);
if len_sq < f64::MIN_POSITIVE {
return dist_sq(p, a);
}
let t = (((p.x() - a.x()) * abx) + ((p.y() - a.y()) * aby)) / len_sq;
let t = t.clamp(0.0, 1.0);
let proj = Point2::new(a.x() + t * abx, a.y() + t * aby);
dist_sq(p, proj)
}
fn project_param(p: Point2, a: Point2, b: Point2) -> f64 {
let abx = b.x() - a.x();
let aby = b.y() - a.y();
let len_sq = abx.mul_add(abx, aby * aby);
if len_sq < f64::MIN_POSITIVE {
return 0.0;
}
(((p.x() - a.x()) * abx) + ((p.y() - a.y()) * aby)) / len_sq
}
fn lerp(a: Point2, b: Point2, t: f64) -> Point2 {
Point2::new(a.x() + t * (b.x() - a.x()), a.y() + t * (b.y() - a.y()))
}
#[derive(Clone, Copy)]
struct Snapper {
inv: f64,
cell: f64,
}
impl Snapper {
fn new(tol: f64) -> Self {
let cell = tol.max(f64::MIN_POSITIVE);
Self {
inv: 1.0 / cell,
cell,
}
}
fn key(&self, p: Point2) -> (i64, i64) {
let kx = (p.x() * self.inv).round();
let ky = (p.y() * self.inv).round();
(kx as i64, ky as i64)
}
fn snap(&self, p: Point2) -> Point2 {
let (kx, ky) = self.key(p);
Point2::new(kx as f64 * self.cell, ky as f64 * self.cell)
}
}
struct Polygon {
verts: Vec<Point2>,
}
impl Polygon {
fn normalized(input: &[Point2], tol: f64) -> Option<Self> {
if input.len() < 3 {
return None;
}
let mut verts: Vec<Point2> = Vec::with_capacity(input.len());
for &p in input {
if let Some(&last) = verts.last()
&& dist_sq(p, last) <= tol * tol
{
continue;
}
verts.push(p);
}
while verts.len() >= 2 {
let first = verts[0];
let last = verts[verts.len() - 1];
if dist_sq(first, last) <= tol * tol {
verts.pop();
} else {
break;
}
}
if verts.len() < 3 {
return None;
}
let area = signed_area(&verts);
if area.abs() <= tol * tol {
return None;
}
if area < 0.0 {
verts.reverse();
}
Some(Self { verts })
}
fn len(&self) -> usize {
self.verts.len()
}
fn vert(&self, i: usize) -> Point2 {
self.verts[i % self.verts.len()]
}
fn as_slice(&self) -> &[Point2] {
&self.verts
}
}
struct SubEdge {
start: Point2,
end: Point2,
}
fn split_polygon(subject: &Polygon, other: &Polygon, snapper: &Snapper, tol: f64) -> Vec<SubEdge> {
let mut out = Vec::new();
let n = subject.len();
for i in 0..n {
let a1 = subject.vert(i);
let a2 = subject.vert(i + 1);
let mut params: Vec<f64> = Vec::new();
let m = other.len();
for j in 0..m {
let b1 = other.vert(j);
let b2 = other.vert(j + 1);
collect_edge_split_params(a1, a2, b1, b2, tol, &mut params);
}
params.retain(|&t| t > 0.0 && t < 1.0);
params.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
dedup_params(&mut params, a1, a2, tol);
let mut prev = snapper.snap(a1);
let mut cuts: Vec<Point2> = Vec::with_capacity(params.len());
for &t in ¶ms {
cuts.push(snapper.snap(lerp(a1, a2, t)));
}
cuts.push(snapper.snap(a2));
for pt in cuts {
if dist_sq(prev, pt) > tol * tol {
out.push(SubEdge {
start: prev,
end: pt,
});
}
prev = pt;
}
}
out
}
fn collect_edge_split_params(
a1: Point2,
a2: Point2,
b1: Point2,
b2: Point2,
tol: f64,
params: &mut Vec<f64>,
) {
let tol_sq = tol * tol;
for &bp in &[b1, b2] {
if point_segment_dist_sq(bp, a1, a2) <= tol_sq {
let t = project_param(bp, a1, a2);
if t > 0.0 && t < 1.0 {
params.push(t);
}
}
}
let d_b1 = point_line_dist_sq(b1, a1, a2);
let d_b2 = point_line_dist_sq(b2, a1, a2);
if d_b1 <= tol_sq && d_b2 <= tol_sq {
let tb1 = project_param(b1, a1, a2);
let tb2 = project_param(b2, a1, a2);
for t in [tb1, tb2] {
if t > 0.0 && t < 1.0 {
params.push(t);
}
}
return;
}
if let Some((ta, _tb)) = segment_intersection_params(a1, a2, b1, b2)
&& ta > 0.0
&& ta < 1.0
{
params.push(ta);
}
}
fn point_line_dist_sq(p: Point2, a: Point2, b: Point2) -> f64 {
let dx = b.x() - a.x();
let dy = b.y() - a.y();
let len_sq = dx.mul_add(dx, dy * dy);
if len_sq < f64::MIN_POSITIVE {
return dist_sq(p, a);
}
let cross = (p.x() - a.x()).mul_add(dy, -((p.y() - a.y()) * dx));
(cross * cross) / len_sq
}
fn segment_intersection_params(
a1: Point2,
a2: Point2,
b1: Point2,
b2: Point2,
) -> Option<(f64, f64)> {
let dax = a2.x() - a1.x();
let day = a2.y() - a1.y();
let dbx = b2.x() - b1.x();
let dby = b2.y() - b1.y();
let denom = dax.mul_add(dby, -(day * dbx));
if denom.abs() < f64::MIN_POSITIVE {
return None;
}
let rx = b1.x() - a1.x();
let ry = b1.y() - a1.y();
let ta = rx.mul_add(dby, -(ry * dbx)) / denom;
let tb = rx.mul_add(day, -(ry * dax)) / denom;
if (0.0..=1.0).contains(&tb) {
Some((ta, tb))
} else {
None
}
}
fn dedup_params(params: &mut Vec<f64>, a1: Point2, a2: Point2, tol: f64) {
if params.is_empty() {
return;
}
let tol_sq = tol * tol;
let mut kept: Vec<f64> = Vec::with_capacity(params.len());
for &t in params.iter() {
let pt = lerp(a1, a2, t);
let is_dup = kept
.last()
.is_some_and(|&pt_t| dist_sq(lerp(a1, a2, pt_t), pt) <= tol_sq);
if !is_dup {
kept.push(t);
}
}
*params = kept;
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EdgeSource {
A,
B,
}
struct DirectedEdge {
start: Point2,
end: Point2,
}
enum MidClass {
Inside,
Outside,
OnBoundary {
same_dir: bool,
},
}
#[allow(clippy::too_many_arguments)]
fn select_edges(
edges: &[SubEdge],
other: &Polygon,
op: BooleanOp,
source: EdgeSource,
snapper: &Snapper,
tol: f64,
out: &mut Vec<DirectedEdge>,
) {
for e in edges {
let class = classify_midpoint(e, other, snapper, tol);
let keep = match (op, source, &class) {
(BooleanOp::Union, _, MidClass::Outside) => Keep::Forward,
(BooleanOp::Union, EdgeSource::A, MidClass::OnBoundary { same_dir: true }) => {
Keep::Forward
}
(BooleanOp::Union, _, _) => Keep::Drop,
(BooleanOp::Intersection, _, MidClass::Inside) => Keep::Forward,
(BooleanOp::Intersection, EdgeSource::A, MidClass::OnBoundary { same_dir: true }) => {
Keep::Forward
}
(BooleanOp::Intersection, _, _) => Keep::Drop,
(BooleanOp::Difference, EdgeSource::A, MidClass::Outside) => Keep::Forward,
(BooleanOp::Difference, EdgeSource::B, MidClass::Inside) => Keep::Reverse,
(BooleanOp::Difference, EdgeSource::A, MidClass::OnBoundary { same_dir: false }) => {
Keep::Forward
}
(BooleanOp::Difference, _, _) => Keep::Drop,
};
match keep {
Keep::Forward => out.push(DirectedEdge {
start: e.start,
end: e.end,
}),
Keep::Reverse => out.push(DirectedEdge {
start: e.end,
end: e.start,
}),
Keep::Drop => {}
}
}
}
enum Keep {
Forward,
Reverse,
Drop,
}
fn classify_midpoint(e: &SubEdge, other: &Polygon, snapper: &Snapper, tol: f64) -> MidClass {
let mid = Point2::new(
f64::midpoint(e.start.x(), e.end.x()),
f64::midpoint(e.start.y(), e.end.y()),
);
let tol_sq = tol * tol;
let edir = e.end - e.start;
let mut on_boundary: Option<bool> = None;
let m = other.len();
for j in 0..m {
let b1 = other.vert(j);
let b2 = other.vert(j + 1);
if point_segment_dist_sq(mid, b1, b2) <= tol_sq {
let bdir = b2 - b1;
let dot = edir.x().mul_add(bdir.x(), edir.y() * bdir.y());
on_boundary = Some(dot >= 0.0);
break;
}
}
if let Some(same_dir) = on_boundary {
return MidClass::OnBoundary { same_dir };
}
let snapped: Vec<Point2> = other.as_slice().iter().map(|&p| snapper.snap(p)).collect();
if winding_number(snapper.snap(mid), &snapped) != 0 {
MidClass::Inside
} else {
MidClass::Outside
}
}
fn trace_loops(edges: Vec<DirectedEdge>, snapper: &Snapper, tol: f64) -> Vec<Vec<Point2>> {
use std::collections::HashMap;
let mut adjacency: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (idx, e) in edges.iter().enumerate() {
adjacency.entry(snapper.key(e.start)).or_default().push(idx);
}
let mut used = vec![false; edges.len()];
let mut loops: Vec<Vec<Point2>> = Vec::new();
for start_idx in 0..edges.len() {
if used[start_idx] {
continue;
}
let mut loop_pts: Vec<Point2> = Vec::new();
let mut current = start_idx;
let mut guard = 0usize;
let max_steps = edges.len() + 1;
loop {
if used[current] {
break;
}
used[current] = true;
let e = &edges[current];
loop_pts.push(e.start);
let end_key = snapper.key(e.end);
let Some(candidates) = adjacency.get(&end_key) else {
break;
};
let incoming_dir = e.end - e.start;
let mut best: Option<usize> = None;
let mut best_score = f64::NEG_INFINITY;
for &cand in candidates {
if used[cand] {
continue;
}
let ce = &edges[cand];
let out_dir = ce.end - ce.start;
let score = turn_score(incoming_dir, out_dir);
if score > best_score {
best_score = score;
best = Some(cand);
}
}
match best {
Some(next) => current = next,
None => break,
}
guard += 1;
if guard > max_steps {
break;
}
if current == start_idx {
break;
}
}
if loop_pts.len() >= 3 {
let area = signed_area(&loop_pts);
if area.abs() > tol * tol {
loops.push(loop_pts);
}
}
}
loops
}
fn turn_score(incoming: crate::vec::Vec2, outgoing: crate::vec::Vec2) -> f64 {
let inx = incoming.x();
let iny = incoming.y();
let outx = outgoing.x();
let outy = outgoing.y();
let dot = inx.mul_add(outx, iny * outy);
let cross = inx.mul_add(outy, -(iny * outx));
cross.atan2(dot)
}
fn classify_loops(loops: Vec<Vec<Point2>>, tol: f64) -> PolygonBooleanResult {
let mut result = PolygonBooleanResult::default();
for loop_pts in loops {
let area = signed_area(&loop_pts);
if area.abs() <= tol * tol {
continue;
}
if area > 0.0 {
result.outer.push(loop_pts);
} else {
result.holes.push(loop_pts);
}
}
result
}
fn degenerate_fallback(
a: &[Point2],
b: &[Point2],
op: BooleanOp,
tol: f64,
) -> PolygonBooleanResult {
let pa = Polygon::normalized(a, tol);
let pb = Polygon::normalized(b, tol);
match (pa, pb) {
(None, None) => PolygonBooleanResult::default(),
(Some(p), None) => {
match op {
BooleanOp::Union | BooleanOp::Difference => single_outer(p),
BooleanOp::Intersection => PolygonBooleanResult::default(),
}
}
(None, Some(p)) => {
match op {
BooleanOp::Union => single_outer(p),
BooleanOp::Intersection | BooleanOp::Difference => PolygonBooleanResult::default(),
}
}
(Some(_), Some(_)) => PolygonBooleanResult::default(),
}
}
fn single_outer(p: Polygon) -> PolygonBooleanResult {
PolygonBooleanResult {
outer: vec![p.verts],
holes: Vec::new(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
mod tests {
use super::*;
fn sq(x0: f64, y0: f64, s: f64) -> Vec<Point2> {
vec![
Point2::new(x0, y0),
Point2::new(x0 + s, y0),
Point2::new(x0 + s, y0 + s),
Point2::new(x0, y0 + s),
]
}
fn rect(x0: f64, y0: f64, w: f64, h: f64) -> Vec<Point2> {
vec![
Point2::new(x0, y0),
Point2::new(x0 + w, y0),
Point2::new(x0 + w, y0 + h),
Point2::new(x0, y0 + h),
]
}
const TOL: f64 = 1e-9;
fn assert_area_close(got: f64, expected: f64, eps: f64) {
assert!(
(got - expected).abs() <= eps,
"area mismatch: got {got}, expected {expected}"
);
}
#[test]
fn overlapping_squares_union_area() {
let a = sq(0.0, 0.0, 2.0);
let b = sq(1.0, 1.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1, "expected one merged outer loop");
assert!(res.holes.is_empty(), "no holes expected");
assert_area_close(res.area(), 4.0 + 4.0 - 1.0, 1e-7);
}
#[test]
fn overlapping_squares_intersection_area() {
let a = sq(0.0, 0.0, 2.0);
let b = sq(1.0, 1.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
assert_eq!(res.outer.len(), 1);
assert_area_close(res.area(), 1.0, 1e-7);
}
#[test]
fn overlapping_squares_difference_area() {
let a = sq(0.0, 0.0, 2.0);
let b = sq(1.0, 1.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Difference, TOL);
assert!(res.holes.is_empty());
assert_area_close(res.area(), 3.0, 1e-7);
}
#[test]
fn disjoint_squares_union_two_loops() {
let a = sq(0.0, 0.0, 1.0);
let b = sq(5.0, 5.0, 1.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 2, "disjoint union → two outer loops");
assert!(res.holes.is_empty());
assert_area_close(res.area(), 2.0, 1e-7);
}
#[test]
fn disjoint_squares_intersection_empty() {
let a = sq(0.0, 0.0, 1.0);
let b = sq(5.0, 5.0, 1.0);
let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
assert!(res.is_empty(), "disjoint intersection is empty");
}
#[test]
fn nested_union_is_outer() {
let a = sq(0.0, 0.0, 10.0);
let b = sq(3.0, 3.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1);
assert!(res.holes.is_empty());
assert_area_close(res.area(), 100.0, 1e-6);
}
#[test]
fn nested_intersection_is_inner() {
let a = sq(0.0, 0.0, 10.0);
let b = sq(3.0, 3.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
assert_eq!(res.outer.len(), 1);
assert_area_close(res.area(), 4.0, 1e-7);
}
#[test]
fn nested_difference_makes_hole() {
let a = sq(0.0, 0.0, 10.0);
let b = sq(3.0, 3.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Difference, TOL);
assert_eq!(res.outer.len(), 1, "outer boundary preserved");
assert_eq!(res.holes.len(), 1, "punched void is a hole");
assert_area_close(res.area(), 96.0, 1e-6);
}
#[test]
fn shared_partial_edge_sliver_no_artifacts() {
let a = rect(0.0, 0.0, 10.0, 5.0);
let b = rect(0.0, 4.99, 10.0, 3.01); let res = polygon_boolean(&a, &b, BooleanOp::Union, 0.02);
assert_eq!(res.outer.len(), 1, "sliver overlap → one merged rectangle");
assert!(res.holes.is_empty(), "no sliver holes");
assert_area_close(res.area(), 80.0, 0.2);
for loop_pts in &res.outer {
for i in 0..loop_pts.len() {
let p = loop_pts[i];
let q = loop_pts[(i + 1) % loop_pts.len()];
assert!(
dist_sq(p, q) > (0.02 * 0.02),
"found a sliver micro-edge of length {}",
dist_sq(p, q).sqrt()
);
}
}
}
#[test]
fn shared_full_edge_union() {
let a = sq(0.0, 0.0, 1.0);
let b = sq(1.0, 0.0, 1.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1, "shared-edge union is one rectangle");
assert!(res.holes.is_empty());
assert_area_close(res.area(), 2.0, 1e-7);
}
#[test]
fn t_junction_vertex_on_edge() {
let a = rect(0.0, 0.0, 4.0, 2.0);
let b = rect(1.0, 2.0, 2.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1, "T-junction union is one polygon");
assert!(res.holes.is_empty());
assert_area_close(res.area(), 8.0 + 4.0, 1e-7);
}
#[test]
fn touching_at_corner_union() {
let a = sq(0.0, 0.0, 2.0);
let b = sq(2.0, 2.0, 2.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_area_close(res.area(), 8.0, 1e-7);
assert!(
res.holes.is_empty(),
"corner pinch must not fabricate a hole"
);
for loop_pts in &res.outer {
assert!(
signed_area(loop_pts) > 1e-7,
"degenerate outer loop emitted"
);
}
}
#[test]
fn identical_polygons_union_is_same() {
let a = sq(0.0, 0.0, 3.0);
let res = polygon_boolean(&a, &a, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1, "self-union is the polygon");
assert!(res.holes.is_empty());
assert_area_close(res.area(), 9.0, 1e-7);
}
#[test]
fn identical_polygons_intersection_is_same() {
let a = sq(0.0, 0.0, 3.0);
let res = polygon_boolean(&a, &a, BooleanOp::Intersection, TOL);
assert_eq!(res.outer.len(), 1);
assert_area_close(res.area(), 9.0, 1e-7);
}
#[test]
fn identical_polygons_difference_is_empty() {
let a = sq(0.0, 0.0, 3.0);
let res = polygon_boolean(&a, &a, BooleanOp::Difference, TOL);
assert!(res.is_empty(), "A \\ A is empty");
}
#[test]
fn degenerate_input_too_few_points() {
let a = vec![Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)];
let b = sq(0.0, 0.0, 1.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1);
assert_area_close(res.area(), 1.0, 1e-9);
}
#[test]
fn degenerate_zero_tolerance_rejected() {
let a = sq(0.0, 0.0, 1.0);
let b = sq(0.5, 0.5, 1.0);
let res = polygon_boolean(&a, &b, BooleanOp::Union, 0.0);
assert!(res.is_empty(), "non-positive tolerance returns empty");
}
#[test]
fn union_wrapper_returns_outer_only() {
let a = sq(0.0, 0.0, 2.0);
let b = sq(1.0, 1.0, 2.0);
let loops = polygon_union(&a, &b, TOL);
assert_eq!(loops.len(), 1);
assert_area_close(signed_area(&loops[0]), 7.0, 1e-7);
}
#[test]
fn cw_input_is_normalized() {
let a_cw = vec![
Point2::new(0.0, 0.0),
Point2::new(0.0, 2.0),
Point2::new(2.0, 2.0),
Point2::new(2.0, 0.0),
];
let b = sq(1.0, 1.0, 2.0);
let res = polygon_boolean(&a_cw, &b, BooleanOp::Union, TOL);
assert_eq!(res.outer.len(), 1);
assert_area_close(res.area(), 7.0, 1e-7);
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn prop_union_area_bounded(
ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
) {
let a = sq(ax, ay, asz);
let b = sq(bx, by, bsz);
let area_a = asz * asz;
let area_b = bsz * bsz;
let res = polygon_boolean(&a, &b, BooleanOp::Union, 1e-9);
if !res.is_empty() {
let u = res.area();
prop_assert!(
u <= area_a + area_b + 1e-6,
"union {u} exceeds sum {}", area_a + area_b
);
prop_assert!(
u >= area_a.max(area_b) - 1e-6,
"union {u} smaller than larger part {}", area_a.max(area_b)
);
}
}
#[test]
fn prop_intersection_area_bounded(
ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
) {
let a = sq(ax, ay, asz);
let b = sq(bx, by, bsz);
let area_a = asz * asz;
let area_b = bsz * bsz;
let res = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
let i = res.area();
prop_assert!(
i <= area_a.min(area_b) + 1e-6,
"intersection {i} exceeds smaller part {}", area_a.min(area_b)
);
}
#[test]
fn prop_inclusion_exclusion(
ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
) {
let a = sq(ax, ay, asz);
let b = sq(bx, by, bsz);
let area_a = asz * asz;
let area_b = bsz * bsz;
let u = polygon_boolean(&a, &b, BooleanOp::Union, 1e-9);
let i = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
if !u.is_empty() {
let lhs = u.area() + i.area();
prop_assert!(
(lhs - (area_a + area_b)).abs() <= 1e-4,
"inclusion-exclusion off: {lhs} vs {}", area_a + area_b
);
}
}
#[test]
fn prop_difference_partitions_a(
ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
) {
let a = sq(ax, ay, asz);
let b = sq(bx, by, bsz);
let area_a = asz * asz;
let d = polygon_boolean(&a, &b, BooleanOp::Difference, 1e-9);
let i = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
let lhs = d.area() + i.area();
prop_assert!(
(lhs - area_a).abs() <= 1e-4,
"difference partition off: {lhs} vs {area_a}"
);
}
}
#[test]
fn diagonal_triangle_square_intersection() {
let square = sq(0.0, 0.0, 4.0);
let tri = vec![
Point2::new(2.0, -1.0),
Point2::new(6.0, 3.0),
Point2::new(2.0, 7.0),
];
let inter = polygon_boolean(&square, &tri, BooleanOp::Intersection, TOL);
assert!(!inter.is_empty(), "diagonal overlap must intersect");
let clipped = crate::polygon2d::sutherland_hodgman_clip(&square, &tri);
let expected = signed_area(&clipped).abs();
assert!(expected > 0.0, "sanity: clip area positive");
assert_area_close(inter.area(), expected, 1e-6);
}
#[test]
fn rotated_square_overlap_union_intersection() {
let square = sq(0.0, 0.0, 4.0);
let diamond = vec![
Point2::new(2.0, -1.0),
Point2::new(5.0, 2.0),
Point2::new(2.0, 5.0),
Point2::new(-1.0, 2.0),
];
let area_sq = 16.0;
let area_di = signed_area(&diamond).abs();
let u = polygon_boolean(&square, &diamond, BooleanOp::Union, TOL);
let i = polygon_boolean(&square, &diamond, BooleanOp::Intersection, TOL);
assert!(!u.is_empty() && !i.is_empty());
assert_area_close(u.area() + i.area(), area_sq + area_di, 1e-6);
}
}