1use crate::predicates::winding_number;
42use crate::vec::Point2;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BooleanOp {
47 Union,
49 Intersection,
51 Difference,
53}
54
55#[derive(Debug, Clone, Default, PartialEq)]
67pub struct PolygonBooleanResult {
68 pub outer: Vec<Vec<Point2>>,
70 pub holes: Vec<Vec<Point2>>,
72}
73
74impl PolygonBooleanResult {
75 #[must_use]
77 pub fn is_empty(&self) -> bool {
78 self.outer.is_empty() && self.holes.is_empty()
79 }
80
81 #[must_use]
86 pub fn area(&self) -> f64 {
87 let mut total = 0.0;
88 for loop_pts in &self.outer {
89 total += signed_area(loop_pts);
90 }
91 for loop_pts in &self.holes {
92 total += signed_area(loop_pts);
93 }
94 total
95 }
96}
97
98#[must_use]
108pub fn polygon_union(a: &[Point2], b: &[Point2], tol: f64) -> Vec<Vec<Point2>> {
109 polygon_boolean(a, b, BooleanOp::Union, tol).outer
110}
111
112#[must_use]
121#[allow(clippy::too_many_lines)]
122pub fn polygon_boolean(
123 a: &[Point2],
124 b: &[Point2],
125 op: BooleanOp,
126 tol: f64,
127) -> PolygonBooleanResult {
128 let tol = if tol > 0.0 && tol.is_finite() {
129 tol
130 } else {
131 return PolygonBooleanResult::default();
132 };
133
134 let poly_a = match Polygon::normalized(a, tol) {
135 Some(p) => p,
136 None => return degenerate_fallback(a, b, op, tol),
137 };
138 let poly_b = match Polygon::normalized(b, tol) {
139 Some(p) => p,
140 None => return degenerate_fallback(a, b, op, tol),
141 };
142
143 let snapper = Snapper::new(tol);
146 let edges_a = split_polygon(&poly_a, &poly_b, &snapper, tol);
147 let edges_b = split_polygon(&poly_b, &poly_a, &snapper, tol);
148
149 let mut selected: Vec<DirectedEdge> = Vec::new();
151 select_edges(
152 &edges_a,
153 &poly_b,
154 op,
155 EdgeSource::A,
156 &snapper,
157 tol,
158 &mut selected,
159 );
160 select_edges(
161 &edges_b,
162 &poly_a,
163 op,
164 EdgeSource::B,
165 &snapper,
166 tol,
167 &mut selected,
168 );
169
170 if selected.is_empty() {
171 return PolygonBooleanResult::default();
172 }
173
174 let loops = trace_loops(selected, &snapper, tol);
175 classify_loops(loops, tol)
176}
177
178#[must_use]
184pub fn signed_area(polygon: &[Point2]) -> f64 {
185 let n = polygon.len();
186 if n < 3 {
187 return 0.0;
188 }
189 let mut sum = 0.0;
190 for i in 0..n {
191 let p = polygon[i];
192 let q = polygon[(i + 1) % n];
193 sum += p.x().mul_add(q.y(), -(q.x() * p.y()));
194 }
195 sum * 0.5
196}
197
198fn dist_sq(a: Point2, b: Point2) -> f64 {
199 let dx = a.x() - b.x();
200 let dy = a.y() - b.y();
201 dx.mul_add(dx, dy * dy)
202}
203
204fn point_segment_dist_sq(p: Point2, a: Point2, b: Point2) -> f64 {
207 let abx = b.x() - a.x();
208 let aby = b.y() - a.y();
209 let len_sq = abx.mul_add(abx, aby * aby);
210 if len_sq < f64::MIN_POSITIVE {
211 return dist_sq(p, a);
212 }
213 let t = (((p.x() - a.x()) * abx) + ((p.y() - a.y()) * aby)) / len_sq;
214 let t = t.clamp(0.0, 1.0);
215 let proj = Point2::new(a.x() + t * abx, a.y() + t * aby);
216 dist_sq(p, proj)
217}
218
219fn project_param(p: Point2, a: Point2, b: Point2) -> f64 {
222 let abx = b.x() - a.x();
223 let aby = b.y() - a.y();
224 let len_sq = abx.mul_add(abx, aby * aby);
225 if len_sq < f64::MIN_POSITIVE {
226 return 0.0;
227 }
228 (((p.x() - a.x()) * abx) + ((p.y() - a.y()) * aby)) / len_sq
229}
230
231fn lerp(a: Point2, b: Point2, t: f64) -> Point2 {
232 Point2::new(a.x() + t * (b.x() - a.x()), a.y() + t * (b.y() - a.y()))
233}
234
235#[derive(Clone, Copy)]
244struct Snapper {
245 inv: f64,
246 cell: f64,
247}
248
249impl Snapper {
250 fn new(tol: f64) -> Self {
251 let cell = tol.max(f64::MIN_POSITIVE);
254 Self {
255 inv: 1.0 / cell,
256 cell,
257 }
258 }
259
260 fn key(&self, p: Point2) -> (i64, i64) {
262 let kx = (p.x() * self.inv).round();
264 let ky = (p.y() * self.inv).round();
265 (kx as i64, ky as i64)
266 }
267
268 fn snap(&self, p: Point2) -> Point2 {
270 let (kx, ky) = self.key(p);
271 Point2::new(kx as f64 * self.cell, ky as f64 * self.cell)
272 }
273}
274
275struct Polygon {
281 verts: Vec<Point2>,
283}
284
285impl Polygon {
286 fn normalized(input: &[Point2], tol: f64) -> Option<Self> {
289 if input.len() < 3 {
290 return None;
291 }
292 let mut verts: Vec<Point2> = Vec::with_capacity(input.len());
294 for &p in input {
295 if let Some(&last) = verts.last()
296 && dist_sq(p, last) <= tol * tol
297 {
298 continue;
299 }
300 verts.push(p);
301 }
302 while verts.len() >= 2 {
303 let first = verts[0];
304 let last = verts[verts.len() - 1];
305 if dist_sq(first, last) <= tol * tol {
306 verts.pop();
307 } else {
308 break;
309 }
310 }
311 if verts.len() < 3 {
312 return None;
313 }
314
315 let area = signed_area(&verts);
316 if area.abs() <= tol * tol {
317 return None;
318 }
319 if area < 0.0 {
320 verts.reverse();
321 }
322 Some(Self { verts })
323 }
324
325 fn len(&self) -> usize {
326 self.verts.len()
327 }
328
329 fn vert(&self, i: usize) -> Point2 {
330 self.verts[i % self.verts.len()]
331 }
332
333 fn as_slice(&self) -> &[Point2] {
334 &self.verts
335 }
336}
337
338struct SubEdge {
344 start: Point2,
345 end: Point2,
346}
347
348fn split_polygon(subject: &Polygon, other: &Polygon, snapper: &Snapper, tol: f64) -> Vec<SubEdge> {
352 let mut out = Vec::new();
353 let n = subject.len();
354 for i in 0..n {
355 let a1 = subject.vert(i);
356 let a2 = subject.vert(i + 1);
357
358 let mut params: Vec<f64> = Vec::new();
360 let m = other.len();
361 for j in 0..m {
362 let b1 = other.vert(j);
363 let b2 = other.vert(j + 1);
364 collect_edge_split_params(a1, a2, b1, b2, tol, &mut params);
365 }
366
367 params.retain(|&t| t > 0.0 && t < 1.0);
369 params.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
370 dedup_params(&mut params, a1, a2, tol);
371
372 let mut prev = snapper.snap(a1);
374 let mut cuts: Vec<Point2> = Vec::with_capacity(params.len());
375 for &t in ¶ms {
376 cuts.push(snapper.snap(lerp(a1, a2, t)));
377 }
378 cuts.push(snapper.snap(a2));
379 for pt in cuts {
380 if dist_sq(prev, pt) > tol * tol {
381 out.push(SubEdge {
382 start: prev,
383 end: pt,
384 });
385 }
386 prev = pt;
387 }
388 }
389 out
390}
391
392fn collect_edge_split_params(
397 a1: Point2,
398 a2: Point2,
399 b1: Point2,
400 b2: Point2,
401 tol: f64,
402 params: &mut Vec<f64>,
403) {
404 let tol_sq = tol * tol;
405
406 for &bp in &[b1, b2] {
408 if point_segment_dist_sq(bp, a1, a2) <= tol_sq {
409 let t = project_param(bp, a1, a2);
410 if t > 0.0 && t < 1.0 {
411 params.push(t);
412 }
413 }
414 }
415
416 let d_b1 = point_line_dist_sq(b1, a1, a2);
421 let d_b2 = point_line_dist_sq(b2, a1, a2);
422 if d_b1 <= tol_sq && d_b2 <= tol_sq {
423 let tb1 = project_param(b1, a1, a2);
425 let tb2 = project_param(b2, a1, a2);
426 for t in [tb1, tb2] {
427 if t > 0.0 && t < 1.0 {
428 params.push(t);
429 }
430 }
431 return;
432 }
433
434 if let Some((ta, _tb)) = segment_intersection_params(a1, a2, b1, b2)
436 && ta > 0.0
437 && ta < 1.0
438 {
439 params.push(ta);
440 }
441}
442
443fn point_line_dist_sq(p: Point2, a: Point2, b: Point2) -> f64 {
445 let dx = b.x() - a.x();
446 let dy = b.y() - a.y();
447 let len_sq = dx.mul_add(dx, dy * dy);
448 if len_sq < f64::MIN_POSITIVE {
449 return dist_sq(p, a);
450 }
451 let cross = (p.x() - a.x()).mul_add(dy, -((p.y() - a.y()) * dx));
452 (cross * cross) / len_sq
453}
454
455fn segment_intersection_params(
458 a1: Point2,
459 a2: Point2,
460 b1: Point2,
461 b2: Point2,
462) -> Option<(f64, f64)> {
463 let dax = a2.x() - a1.x();
464 let day = a2.y() - a1.y();
465 let dbx = b2.x() - b1.x();
466 let dby = b2.y() - b1.y();
467 let denom = dax.mul_add(dby, -(day * dbx));
468 if denom.abs() < f64::MIN_POSITIVE {
469 return None;
470 }
471 let rx = b1.x() - a1.x();
472 let ry = b1.y() - a1.y();
473 let ta = rx.mul_add(dby, -(ry * dbx)) / denom;
474 let tb = rx.mul_add(day, -(ry * dax)) / denom;
475 if (0.0..=1.0).contains(&tb) {
476 Some((ta, tb))
477 } else {
478 None
479 }
480}
481
482fn dedup_params(params: &mut Vec<f64>, a1: Point2, a2: Point2, tol: f64) {
484 if params.is_empty() {
485 return;
486 }
487 let tol_sq = tol * tol;
488 let mut kept: Vec<f64> = Vec::with_capacity(params.len());
489 for &t in params.iter() {
490 let pt = lerp(a1, a2, t);
491 let is_dup = kept
492 .last()
493 .is_some_and(|&pt_t| dist_sq(lerp(a1, a2, pt_t), pt) <= tol_sq);
494 if !is_dup {
495 kept.push(t);
496 }
497 }
498 *params = kept;
499}
500
501#[derive(Clone, Copy, PartialEq, Eq)]
506enum EdgeSource {
507 A,
508 B,
509}
510
511struct DirectedEdge {
513 start: Point2,
514 end: Point2,
515}
516
517enum MidClass {
519 Inside,
520 Outside,
521 OnBoundary {
524 same_dir: bool,
525 },
526}
527
528#[allow(clippy::too_many_arguments)]
529fn select_edges(
530 edges: &[SubEdge],
531 other: &Polygon,
532 op: BooleanOp,
533 source: EdgeSource,
534 snapper: &Snapper,
535 tol: f64,
536 out: &mut Vec<DirectedEdge>,
537) {
538 for e in edges {
539 let class = classify_midpoint(e, other, snapper, tol);
540 let keep = match (op, source, &class) {
541 (BooleanOp::Union, _, MidClass::Outside) => Keep::Forward,
544 (BooleanOp::Union, EdgeSource::A, MidClass::OnBoundary { same_dir: true }) => {
545 Keep::Forward
546 }
547 (BooleanOp::Union, _, _) => Keep::Drop,
548
549 (BooleanOp::Intersection, _, MidClass::Inside) => Keep::Forward,
552 (BooleanOp::Intersection, EdgeSource::A, MidClass::OnBoundary { same_dir: true }) => {
553 Keep::Forward
554 }
555 (BooleanOp::Intersection, _, _) => Keep::Drop,
556
557 (BooleanOp::Difference, EdgeSource::A, MidClass::Outside) => Keep::Forward,
561 (BooleanOp::Difference, EdgeSource::B, MidClass::Inside) => Keep::Reverse,
562 (BooleanOp::Difference, EdgeSource::A, MidClass::OnBoundary { same_dir: false }) => {
563 Keep::Forward
564 }
565 (BooleanOp::Difference, _, _) => Keep::Drop,
566 };
567
568 match keep {
569 Keep::Forward => out.push(DirectedEdge {
570 start: e.start,
571 end: e.end,
572 }),
573 Keep::Reverse => out.push(DirectedEdge {
574 start: e.end,
575 end: e.start,
576 }),
577 Keep::Drop => {}
578 }
579 }
580}
581
582enum Keep {
583 Forward,
584 Reverse,
585 Drop,
586}
587
588fn classify_midpoint(e: &SubEdge, other: &Polygon, snapper: &Snapper, tol: f64) -> MidClass {
590 let mid = Point2::new(
591 f64::midpoint(e.start.x(), e.end.x()),
592 f64::midpoint(e.start.y(), e.end.y()),
593 );
594
595 let tol_sq = tol * tol;
599 let edir = e.end - e.start;
600 let mut on_boundary: Option<bool> = None;
601 let m = other.len();
602 for j in 0..m {
603 let b1 = other.vert(j);
604 let b2 = other.vert(j + 1);
605 if point_segment_dist_sq(mid, b1, b2) <= tol_sq {
606 let bdir = b2 - b1;
608 let dot = edir.x().mul_add(bdir.x(), edir.y() * bdir.y());
609 on_boundary = Some(dot >= 0.0);
610 break;
611 }
612 }
613 if let Some(same_dir) = on_boundary {
614 return MidClass::OnBoundary { same_dir };
615 }
616
617 let snapped: Vec<Point2> = other.as_slice().iter().map(|&p| snapper.snap(p)).collect();
619 if winding_number(snapper.snap(mid), &snapped) != 0 {
620 MidClass::Inside
621 } else {
622 MidClass::Outside
623 }
624}
625
626fn trace_loops(edges: Vec<DirectedEdge>, snapper: &Snapper, tol: f64) -> Vec<Vec<Point2>> {
637 use std::collections::HashMap;
638
639 let mut adjacency: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
641 for (idx, e) in edges.iter().enumerate() {
642 adjacency.entry(snapper.key(e.start)).or_default().push(idx);
643 }
644
645 let mut used = vec![false; edges.len()];
646 let mut loops: Vec<Vec<Point2>> = Vec::new();
647
648 for start_idx in 0..edges.len() {
649 if used[start_idx] {
650 continue;
651 }
652 let mut loop_pts: Vec<Point2> = Vec::new();
653 let mut current = start_idx;
654 let mut guard = 0usize;
655 let max_steps = edges.len() + 1;
656
657 loop {
658 if used[current] {
659 break;
660 }
661 used[current] = true;
662 let e = &edges[current];
663 loop_pts.push(e.start);
664 let end_key = snapper.key(e.end);
665
666 let Some(candidates) = adjacency.get(&end_key) else {
668 break;
669 };
670 let incoming_dir = e.end - e.start;
671 let mut best: Option<usize> = None;
672 let mut best_score = f64::NEG_INFINITY;
673 for &cand in candidates {
674 if used[cand] {
675 continue;
676 }
677 let ce = &edges[cand];
678 let out_dir = ce.end - ce.start;
679 let score = turn_score(incoming_dir, out_dir);
680 if score > best_score {
681 best_score = score;
682 best = Some(cand);
683 }
684 }
685
686 match best {
687 Some(next) => current = next,
688 None => break,
689 }
690
691 guard += 1;
692 if guard > max_steps {
693 break;
694 }
695
696 if current == start_idx {
698 break;
699 }
700 }
701
702 if loop_pts.len() >= 3 {
704 let area = signed_area(&loop_pts);
705 if area.abs() > tol * tol {
706 loops.push(loop_pts);
707 }
708 }
709 }
710
711 loops
712}
713
714fn turn_score(incoming: crate::vec::Vec2, outgoing: crate::vec::Vec2) -> f64 {
718 let inx = incoming.x();
719 let iny = incoming.y();
720 let outx = outgoing.x();
721 let outy = outgoing.y();
722 let dot = inx.mul_add(outx, iny * outy);
723 let cross = inx.mul_add(outy, -(iny * outx));
724 cross.atan2(dot)
725}
726
727fn classify_loops(loops: Vec<Vec<Point2>>, tol: f64) -> PolygonBooleanResult {
733 let mut result = PolygonBooleanResult::default();
734 for loop_pts in loops {
735 let area = signed_area(&loop_pts);
736 if area.abs() <= tol * tol {
737 continue;
738 }
739 if area > 0.0 {
740 result.outer.push(loop_pts);
741 } else {
742 result.holes.push(loop_pts);
743 }
744 }
745 result
746}
747
748fn degenerate_fallback(
755 a: &[Point2],
756 b: &[Point2],
757 op: BooleanOp,
758 tol: f64,
759) -> PolygonBooleanResult {
760 let pa = Polygon::normalized(a, tol);
761 let pb = Polygon::normalized(b, tol);
762 match (pa, pb) {
763 (None, None) => PolygonBooleanResult::default(),
764 (Some(p), None) => {
765 match op {
767 BooleanOp::Union | BooleanOp::Difference => single_outer(p),
768 BooleanOp::Intersection => PolygonBooleanResult::default(),
769 }
770 }
771 (None, Some(p)) => {
772 match op {
774 BooleanOp::Union => single_outer(p),
775 BooleanOp::Intersection | BooleanOp::Difference => PolygonBooleanResult::default(),
776 }
777 }
778 (Some(_), Some(_)) => PolygonBooleanResult::default(),
780 }
781}
782
783fn single_outer(p: Polygon) -> PolygonBooleanResult {
784 PolygonBooleanResult {
785 outer: vec![p.verts],
786 holes: Vec::new(),
787 }
788}
789
790#[cfg(test)]
791#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
792mod tests {
793 use super::*;
794
795 fn sq(x0: f64, y0: f64, s: f64) -> Vec<Point2> {
796 vec![
797 Point2::new(x0, y0),
798 Point2::new(x0 + s, y0),
799 Point2::new(x0 + s, y0 + s),
800 Point2::new(x0, y0 + s),
801 ]
802 }
803
804 fn rect(x0: f64, y0: f64, w: f64, h: f64) -> Vec<Point2> {
805 vec![
806 Point2::new(x0, y0),
807 Point2::new(x0 + w, y0),
808 Point2::new(x0 + w, y0 + h),
809 Point2::new(x0, y0 + h),
810 ]
811 }
812
813 const TOL: f64 = 1e-9;
814
815 fn assert_area_close(got: f64, expected: f64, eps: f64) {
816 assert!(
817 (got - expected).abs() <= eps,
818 "area mismatch: got {got}, expected {expected}"
819 );
820 }
821
822 #[test]
823 fn overlapping_squares_union_area() {
824 let a = sq(0.0, 0.0, 2.0);
826 let b = sq(1.0, 1.0, 2.0);
827 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
828 assert_eq!(res.outer.len(), 1, "expected one merged outer loop");
829 assert!(res.holes.is_empty(), "no holes expected");
830 assert_area_close(res.area(), 4.0 + 4.0 - 1.0, 1e-7);
831 }
832
833 #[test]
834 fn overlapping_squares_intersection_area() {
835 let a = sq(0.0, 0.0, 2.0);
836 let b = sq(1.0, 1.0, 2.0);
837 let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
838 assert_eq!(res.outer.len(), 1);
839 assert_area_close(res.area(), 1.0, 1e-7);
840 }
841
842 #[test]
843 fn overlapping_squares_difference_area() {
844 let a = sq(0.0, 0.0, 2.0);
845 let b = sq(1.0, 1.0, 2.0);
846 let res = polygon_boolean(&a, &b, BooleanOp::Difference, TOL);
847 assert!(res.holes.is_empty());
849 assert_area_close(res.area(), 3.0, 1e-7);
850 }
851
852 #[test]
853 fn disjoint_squares_union_two_loops() {
854 let a = sq(0.0, 0.0, 1.0);
855 let b = sq(5.0, 5.0, 1.0);
856 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
857 assert_eq!(res.outer.len(), 2, "disjoint union → two outer loops");
858 assert!(res.holes.is_empty());
859 assert_area_close(res.area(), 2.0, 1e-7);
860 }
861
862 #[test]
863 fn disjoint_squares_intersection_empty() {
864 let a = sq(0.0, 0.0, 1.0);
865 let b = sq(5.0, 5.0, 1.0);
866 let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
867 assert!(res.is_empty(), "disjoint intersection is empty");
868 }
869
870 #[test]
871 fn nested_union_is_outer() {
872 let a = sq(0.0, 0.0, 10.0);
874 let b = sq(3.0, 3.0, 2.0);
875 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
876 assert_eq!(res.outer.len(), 1);
877 assert!(res.holes.is_empty());
878 assert_area_close(res.area(), 100.0, 1e-6);
879 }
880
881 #[test]
882 fn nested_intersection_is_inner() {
883 let a = sq(0.0, 0.0, 10.0);
884 let b = sq(3.0, 3.0, 2.0);
885 let res = polygon_boolean(&a, &b, BooleanOp::Intersection, TOL);
886 assert_eq!(res.outer.len(), 1);
887 assert_area_close(res.area(), 4.0, 1e-7);
888 }
889
890 #[test]
891 fn nested_difference_makes_hole() {
892 let a = sq(0.0, 0.0, 10.0);
894 let b = sq(3.0, 3.0, 2.0);
895 let res = polygon_boolean(&a, &b, BooleanOp::Difference, TOL);
896 assert_eq!(res.outer.len(), 1, "outer boundary preserved");
897 assert_eq!(res.holes.len(), 1, "punched void is a hole");
898 assert_area_close(res.area(), 96.0, 1e-6);
899 }
900
901 #[test]
902 fn shared_partial_edge_sliver_no_artifacts() {
903 let a = rect(0.0, 0.0, 10.0, 5.0);
908 let b = rect(0.0, 4.99, 10.0, 3.01); let res = polygon_boolean(&a, &b, BooleanOp::Union, 0.02);
910 assert_eq!(res.outer.len(), 1, "sliver overlap → one merged rectangle");
911 assert!(res.holes.is_empty(), "no sliver holes");
912 assert_area_close(res.area(), 80.0, 0.2);
914 for loop_pts in &res.outer {
916 for i in 0..loop_pts.len() {
917 let p = loop_pts[i];
918 let q = loop_pts[(i + 1) % loop_pts.len()];
919 assert!(
920 dist_sq(p, q) > (0.02 * 0.02),
921 "found a sliver micro-edge of length {}",
922 dist_sq(p, q).sqrt()
923 );
924 }
925 }
926 }
927
928 #[test]
929 fn shared_full_edge_union() {
930 let a = sq(0.0, 0.0, 1.0);
932 let b = sq(1.0, 0.0, 1.0);
933 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
934 assert_eq!(res.outer.len(), 1, "shared-edge union is one rectangle");
935 assert!(res.holes.is_empty());
936 assert_area_close(res.area(), 2.0, 1e-7);
937 }
938
939 #[test]
940 fn t_junction_vertex_on_edge() {
941 let a = rect(0.0, 0.0, 4.0, 2.0);
944 let b = rect(1.0, 2.0, 2.0, 2.0);
945 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
946 assert_eq!(res.outer.len(), 1, "T-junction union is one polygon");
947 assert!(res.holes.is_empty());
948 assert_area_close(res.area(), 8.0 + 4.0, 1e-7);
949 }
950
951 #[test]
952 fn touching_at_corner_union() {
953 let a = sq(0.0, 0.0, 2.0);
957 let b = sq(2.0, 2.0, 2.0);
958 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
959 assert_area_close(res.area(), 8.0, 1e-7);
960 assert!(
961 res.holes.is_empty(),
962 "corner pinch must not fabricate a hole"
963 );
964 for loop_pts in &res.outer {
967 assert!(
968 signed_area(loop_pts) > 1e-7,
969 "degenerate outer loop emitted"
970 );
971 }
972 }
973
974 #[test]
975 fn identical_polygons_union_is_same() {
976 let a = sq(0.0, 0.0, 3.0);
977 let res = polygon_boolean(&a, &a, BooleanOp::Union, TOL);
978 assert_eq!(res.outer.len(), 1, "self-union is the polygon");
979 assert!(res.holes.is_empty());
980 assert_area_close(res.area(), 9.0, 1e-7);
981 }
982
983 #[test]
984 fn identical_polygons_intersection_is_same() {
985 let a = sq(0.0, 0.0, 3.0);
986 let res = polygon_boolean(&a, &a, BooleanOp::Intersection, TOL);
987 assert_eq!(res.outer.len(), 1);
988 assert_area_close(res.area(), 9.0, 1e-7);
989 }
990
991 #[test]
992 fn identical_polygons_difference_is_empty() {
993 let a = sq(0.0, 0.0, 3.0);
994 let res = polygon_boolean(&a, &a, BooleanOp::Difference, TOL);
995 assert!(res.is_empty(), "A \\ A is empty");
996 }
997
998 #[test]
999 fn degenerate_input_too_few_points() {
1000 let a = vec![Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)];
1001 let b = sq(0.0, 0.0, 1.0);
1002 let res = polygon_boolean(&a, &b, BooleanOp::Union, TOL);
1003 assert_eq!(res.outer.len(), 1);
1005 assert_area_close(res.area(), 1.0, 1e-9);
1006 }
1007
1008 #[test]
1009 fn degenerate_zero_tolerance_rejected() {
1010 let a = sq(0.0, 0.0, 1.0);
1011 let b = sq(0.5, 0.5, 1.0);
1012 let res = polygon_boolean(&a, &b, BooleanOp::Union, 0.0);
1013 assert!(res.is_empty(), "non-positive tolerance returns empty");
1014 }
1015
1016 #[test]
1017 fn union_wrapper_returns_outer_only() {
1018 let a = sq(0.0, 0.0, 2.0);
1019 let b = sq(1.0, 1.0, 2.0);
1020 let loops = polygon_union(&a, &b, TOL);
1021 assert_eq!(loops.len(), 1);
1022 assert_area_close(signed_area(&loops[0]), 7.0, 1e-7);
1023 }
1024
1025 #[test]
1026 fn cw_input_is_normalized() {
1027 let a_cw = vec![
1029 Point2::new(0.0, 0.0),
1030 Point2::new(0.0, 2.0),
1031 Point2::new(2.0, 2.0),
1032 Point2::new(2.0, 0.0),
1033 ];
1034 let b = sq(1.0, 1.0, 2.0);
1035 let res = polygon_boolean(&a_cw, &b, BooleanOp::Union, TOL);
1036 assert_eq!(res.outer.len(), 1);
1037 assert_area_close(res.area(), 7.0, 1e-7);
1038 }
1039
1040 use proptest::prelude::*;
1041
1042 proptest! {
1043 #![proptest_config(ProptestConfig::with_cases(200))]
1044
1045 #[test]
1048 fn prop_union_area_bounded(
1049 ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1050 bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1051 ) {
1052 let a = sq(ax, ay, asz);
1053 let b = sq(bx, by, bsz);
1054 let area_a = asz * asz;
1055 let area_b = bsz * bsz;
1056 let res = polygon_boolean(&a, &b, BooleanOp::Union, 1e-9);
1057 if !res.is_empty() {
1058 let u = res.area();
1059 prop_assert!(
1060 u <= area_a + area_b + 1e-6,
1061 "union {u} exceeds sum {}", area_a + area_b
1062 );
1063 prop_assert!(
1064 u >= area_a.max(area_b) - 1e-6,
1065 "union {u} smaller than larger part {}", area_a.max(area_b)
1066 );
1067 }
1068 }
1069
1070 #[test]
1072 fn prop_intersection_area_bounded(
1073 ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1074 bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1075 ) {
1076 let a = sq(ax, ay, asz);
1077 let b = sq(bx, by, bsz);
1078 let area_a = asz * asz;
1079 let area_b = bsz * bsz;
1080 let res = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
1081 let i = res.area();
1082 prop_assert!(
1083 i <= area_a.min(area_b) + 1e-6,
1084 "intersection {i} exceeds smaller part {}", area_a.min(area_b)
1085 );
1086 }
1087
1088 #[test]
1091 fn prop_inclusion_exclusion(
1092 ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1093 bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1094 ) {
1095 let a = sq(ax, ay, asz);
1096 let b = sq(bx, by, bsz);
1097 let area_a = asz * asz;
1098 let area_b = bsz * bsz;
1099 let u = polygon_boolean(&a, &b, BooleanOp::Union, 1e-9);
1100 let i = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
1101 if !u.is_empty() {
1102 let lhs = u.area() + i.area();
1103 prop_assert!(
1104 (lhs - (area_a + area_b)).abs() <= 1e-4,
1105 "inclusion-exclusion off: {lhs} vs {}", area_a + area_b
1106 );
1107 }
1108 }
1109
1110 #[test]
1112 fn prop_difference_partitions_a(
1113 ax in -3.0f64..3.0, ay in -3.0f64..3.0, asz in 0.5f64..4.0,
1114 bx in -3.0f64..3.0, by in -3.0f64..3.0, bsz in 0.5f64..4.0,
1115 ) {
1116 let a = sq(ax, ay, asz);
1117 let b = sq(bx, by, bsz);
1118 let area_a = asz * asz;
1119 let d = polygon_boolean(&a, &b, BooleanOp::Difference, 1e-9);
1120 let i = polygon_boolean(&a, &b, BooleanOp::Intersection, 1e-9);
1121 let lhs = d.area() + i.area();
1122 prop_assert!(
1123 (lhs - area_a).abs() <= 1e-4,
1124 "difference partition off: {lhs} vs {area_a}"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn diagonal_triangle_square_intersection() {
1131 let square = sq(0.0, 0.0, 4.0);
1136 let tri = vec![
1137 Point2::new(2.0, -1.0),
1138 Point2::new(6.0, 3.0),
1139 Point2::new(2.0, 7.0),
1140 ];
1141 let inter = polygon_boolean(&square, &tri, BooleanOp::Intersection, TOL);
1142 assert!(!inter.is_empty(), "diagonal overlap must intersect");
1143 let clipped = crate::polygon2d::sutherland_hodgman_clip(&square, &tri);
1145 let expected = signed_area(&clipped).abs();
1146 assert!(expected > 0.0, "sanity: clip area positive");
1147 assert_area_close(inter.area(), expected, 1e-6);
1148 }
1149
1150 #[test]
1151 fn rotated_square_overlap_union_intersection() {
1152 let square = sq(0.0, 0.0, 4.0);
1156 let diamond = vec![
1157 Point2::new(2.0, -1.0),
1158 Point2::new(5.0, 2.0),
1159 Point2::new(2.0, 5.0),
1160 Point2::new(-1.0, 2.0),
1161 ];
1162 let area_sq = 16.0;
1163 let area_di = signed_area(&diamond).abs();
1164 let u = polygon_boolean(&square, &diamond, BooleanOp::Union, TOL);
1165 let i = polygon_boolean(&square, &diamond, BooleanOp::Intersection, TOL);
1166 assert!(!u.is_empty() && !i.is_empty());
1167 assert_area_close(u.area() + i.area(), area_sq + area_di, 1e-6);
1168 }
1169}