From fc5ec69d3f7438fa56be3f9f72a18512d380ae59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 12:08:44 +0000 Subject: [PATCH 1/3] Add test for contours sharing a point at (2,2) after union Four non-overlapping boxes are unioned to form a shape with a hole. The exterior contour and hole contour should share a single point at (2,2), but the library currently merges them into one figure-8 contour that visits (2,2) twice. Tests EvenOdd, NonZero, Positive, and Negative fill rules. https://claude.ai/code/session_01UWsUQwhyBYT5ABqevBFmkR --- iOverlay/tests/shared_point_tests.rs | 144 +++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 iOverlay/tests/shared_point_tests.rs diff --git a/iOverlay/tests/shared_point_tests.rs b/iOverlay/tests/shared_point_tests.rs new file mode 100644 index 0000000..83f4a15 --- /dev/null +++ b/iOverlay/tests/shared_point_tests.rs @@ -0,0 +1,144 @@ +#[cfg(test)] +mod tests { + use i_float::int::point::IntPoint; + use i_overlay::core::fill_rule::FillRule; + use i_overlay::core::overlay::{Overlay, ShapeType}; + use i_overlay::core::overlay_rule::OverlayRule; + + // Four boxes that form a shape with a hole, where the exterior + // contour and hole contour should share a point at (2,2): + // + // 0 1 2 3 + // y=3 +--+--+ + // | | | + // y=2 +--+--*--+ * = shared point (2,2) + // | |##| | ## = hole + // y=1 +--+--+--+ + // | | | | + // y=0 +--+--+--+ + // + // Box 1: (0,0)-(3,1) bottom strip + // Box 2: (0,1)-(1,2) middle-left + // Box 3: (2,1)-(3,2) middle-right + // Box 4: (0,2)-(2,3) top-left + // + // The hole is at (1,1)-(2,2). The point (2,2) lies on both the + // exterior contour and the hole contour. + // + // Expected exterior contour (6 points): + // (0,0) → (3,0) → (3,2) → (2,2) → (2,3) → (0,3) + // + // Expected hole contour (4 points): + // (1,1) → (2,1) → (2,2) → (1,2) + // + // BUG: The library currently produces a single merged contour that + // visits (2,2) twice in a figure-8 instead of two separate contours: + // (0,0)→(3,0)→(3,2)→(2,2)→(2,1)→(1,1)→(1,2)→(2,2)→(2,3)→(0,3) + + fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Vec { + vec![ + IntPoint::new(x1, y1), + IntPoint::new(x2, y1), + IntPoint::new(x2, y2), + IntPoint::new(x1, y2), + ] + } + + fn overlay() -> Overlay { + let mut overlay = Overlay::new(16); + + overlay.add_contour(&rect(0, 0, 3, 1), ShapeType::Subject); + overlay.add_contour(&rect(0, 1, 1, 2), ShapeType::Subject); + overlay.add_contour(&rect(2, 1, 3, 2), ShapeType::Subject); + overlay.add_contour(&rect(0, 2, 2, 3), ShapeType::Subject); + + overlay + } + + fn contour_contains(contour: &[IntPoint], point: IntPoint) -> bool { + contour.iter().any(|p| *p == point) + } + + #[test] + fn test_shared_point_even_odd() { + let mut buffer = Default::default(); + + let result = overlay() + .build_graph_view(FillRule::EvenOdd) + .unwrap() + .extract_shapes(OverlayRule::Subject, &mut buffer); + + assert_eq!(result.len(), 1, "expected 1 shape"); + assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); + + let shared = IntPoint::new(2, 2); + assert!( + contour_contains(&result[0][0], shared), + "exterior contour must contain (2,2)" + ); + assert!( + contour_contains(&result[0][1], shared), + "hole contour must contain (2,2)" + ); + } + + #[test] + fn test_shared_point_non_zero() { + let mut buffer = Default::default(); + + let result = overlay() + .build_graph_view(FillRule::NonZero) + .unwrap() + .extract_shapes(OverlayRule::Subject, &mut buffer); + + assert_eq!(result.len(), 1, "expected 1 shape"); + assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); + + let shared = IntPoint::new(2, 2); + assert!( + contour_contains(&result[0][0], shared), + "exterior contour must contain (2,2)" + ); + assert!( + contour_contains(&result[0][1], shared), + "hole contour must contain (2,2)" + ); + } + + #[test] + fn test_shared_point_positive() { + let mut buffer = Default::default(); + + let result = overlay() + .build_graph_view(FillRule::Positive) + .unwrap() + .extract_shapes(OverlayRule::Subject, &mut buffer); + + assert_eq!(result.len(), 1, "expected 1 shape"); + assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); + + let shared = IntPoint::new(2, 2); + assert!( + contour_contains(&result[0][0], shared), + "exterior contour must contain (2,2)" + ); + assert!( + contour_contains(&result[0][1], shared), + "hole contour must contain (2,2)" + ); + } + + #[test] + fn test_shared_point_negative() { + let mut buffer = Default::default(); + + let result = overlay() + .build_graph_view(FillRule::Negative) + .unwrap() + .extract_shapes(OverlayRule::Subject, &mut buffer); + + // All contours are CCW (positive winding), so Negative fill rule + // produces no filled regions. + assert_eq!(result.len(), 0, "expected 0 shapes for Negative fill rule"); + } +} From 5df4813b5ca6b055211548cea0a4f280de17e673 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Feb 2026 12:22:24 +0000 Subject: [PATCH 2/3] Use OGC options and verify input winding order in shared point test - Switch to Overlay::with_contours_custom with IntOverlayOptions::ogc() - Add test_input_winding_order asserting all rectangles are CCW (negative area_two) - Simplify overlay helper to accept fill_rule and return results directly https://claude.ai/code/session_01UWsUQwhyBYT5ABqevBFmkR --- iOverlay/tests/shared_point_tests.rs | 76 ++++++++++++++-------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/iOverlay/tests/shared_point_tests.rs b/iOverlay/tests/shared_point_tests.rs index 83f4a15..6856543 100644 --- a/iOverlay/tests/shared_point_tests.rs +++ b/iOverlay/tests/shared_point_tests.rs @@ -2,8 +2,9 @@ mod tests { use i_float::int::point::IntPoint; use i_overlay::core::fill_rule::FillRule; - use i_overlay::core::overlay::{Overlay, ShapeType}; + use i_overlay::core::overlay::{IntOverlayOptions, Overlay}; use i_overlay::core::overlay_rule::OverlayRule; + use i_shape::int::area::Area; // Four boxes that form a shape with a hole, where the exterior // contour and hole contour should share a point at (2,2): @@ -25,16 +26,17 @@ mod tests { // The hole is at (1,1)-(2,2). The point (2,2) lies on both the // exterior contour and the hole contour. // - // Expected exterior contour (6 points): + // Expected exterior contour (6 points, CCW): // (0,0) → (3,0) → (3,2) → (2,2) → (2,3) → (0,3) // - // Expected hole contour (4 points): + // Expected hole contour (4 points, CW): // (1,1) → (2,1) → (2,2) → (1,2) // // BUG: The library currently produces a single merged contour that // visits (2,2) twice in a figure-8 instead of two separate contours: // (0,0)→(3,0)→(3,2)→(2,2)→(2,1)→(1,1)→(1,2)→(2,2)→(2,3)→(0,3) + /// Create a CCW rectangle contour from (x1,y1) to (x2,y2). fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Vec { vec![ IntPoint::new(x1, y1), @@ -44,15 +46,24 @@ mod tests { ] } - fn overlay() -> Overlay { - let mut overlay = Overlay::new(16); - - overlay.add_contour(&rect(0, 0, 3, 1), ShapeType::Subject); - overlay.add_contour(&rect(0, 1, 1, 2), ShapeType::Subject); - overlay.add_contour(&rect(2, 1, 3, 2), ShapeType::Subject); - overlay.add_contour(&rect(0, 2, 2, 3), ShapeType::Subject); + fn subject_contours() -> Vec> { + vec![ + rect(0, 0, 3, 1), + rect(0, 1, 1, 2), + rect(2, 1, 3, 2), + rect(0, 2, 2, 3), + ] + } - overlay + fn overlay(fill_rule: FillRule) -> Vec>> { + let contours = subject_contours(); + let mut overlay = Overlay::with_contours_custom( + &contours, + &[], + IntOverlayOptions::ogc(), + Default::default(), + ); + overlay.overlay(OverlayRule::Subject, fill_rule) } fn contour_contains(contour: &[IntPoint], point: IntPoint) -> bool { @@ -60,13 +71,19 @@ mod tests { } #[test] - fn test_shared_point_even_odd() { - let mut buffer = Default::default(); + fn test_input_winding_order() { + // All input rectangles must be CCW (negative area_two). + for contour in &subject_contours() { + assert!( + contour.area_two() < 0, + "input contour must be counter-clockwise, got area_two={}", contour.area_two() + ); + } + } - let result = overlay() - .build_graph_view(FillRule::EvenOdd) - .unwrap() - .extract_shapes(OverlayRule::Subject, &mut buffer); + #[test] + fn test_shared_point_even_odd() { + let result = overlay(FillRule::EvenOdd); assert_eq!(result.len(), 1, "expected 1 shape"); assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); @@ -84,12 +101,7 @@ mod tests { #[test] fn test_shared_point_non_zero() { - let mut buffer = Default::default(); - - let result = overlay() - .build_graph_view(FillRule::NonZero) - .unwrap() - .extract_shapes(OverlayRule::Subject, &mut buffer); + let result = overlay(FillRule::NonZero); assert_eq!(result.len(), 1, "expected 1 shape"); assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); @@ -107,12 +119,7 @@ mod tests { #[test] fn test_shared_point_positive() { - let mut buffer = Default::default(); - - let result = overlay() - .build_graph_view(FillRule::Positive) - .unwrap() - .extract_shapes(OverlayRule::Subject, &mut buffer); + let result = overlay(FillRule::Positive); assert_eq!(result.len(), 1, "expected 1 shape"); assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); @@ -130,15 +137,10 @@ mod tests { #[test] fn test_shared_point_negative() { - let mut buffer = Default::default(); - - let result = overlay() - .build_graph_view(FillRule::Negative) - .unwrap() - .extract_shapes(OverlayRule::Subject, &mut buffer); + let result = overlay(FillRule::Negative); - // All contours are CCW (positive winding), so Negative fill rule - // produces no filled regions. + // All input contours are CCW (positive winding), so Negative + // fill rule produces no filled regions. assert_eq!(result.len(), 0, "expected 0 shapes for Negative fill rule"); } } From 21314e3de474690f78660c0edc93de6e8ba02fc9 Mon Sep 17 00:00:00 2001 From: Brett Tully Date: Fri, 6 Feb 2026 12:31:42 +1000 Subject: [PATCH 3/3] Fix OGC extraction producing figure-8 contours at shared vertices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When hull contours self-touch at pinch points (e.g. exterior and hole sharing a vertex), extract_ogc now detects and splits them inline during pass 1. Split sub-contours are classified by winding area as either new shapes or pending holes, which are joined after pass 2. - Add find_pinch_point using i_key_sort's sort_by_two_keys for O(n log n) duplicate detection on contour points - Add split_all_pinch_points to recursively decompose figure-8 contours - Add comprehensive tests (16 cases) covering 4 geometries × 4 fill rules, validated against Shapely reference output --- iOverlay/src/core/extract_ogc.rs | 63 +++- iOverlay/tests/shared_point_tests.rs | 440 +++++++++++++++++++++------ 2 files changed, 412 insertions(+), 91 deletions(-) diff --git a/iOverlay/src/core/extract_ogc.rs b/iOverlay/src/core/extract_ogc.rs index 3b3b26f..23a4129 100644 --- a/iOverlay/src/core/extract_ogc.rs +++ b/iOverlay/src/core/extract_ogc.rs @@ -9,6 +9,9 @@ use crate::core::overlay_rule::OverlayRule; use crate::geom::v_segment::VSegment; use alloc::vec; use alloc::vec::Vec; +use i_float::int::point::IntPoint; +use i_key_sort::sort::two_keys::TwoKeysSort; +use i_shape::int::path::ContourExtension; use i_shape::int::shape::IntShapes; use i_shape::util::reserve::Reserve; @@ -24,6 +27,8 @@ impl OverlayGraph<'_> { buffer.points.reserve_capacity(buffer.visited.len()); let mut avg_holes_count = 0; + let mut pending_holes: Vec> = Vec::new(); + let mut point_buf = Vec::new(); let mut link_index = 0; while link_index < buffer.visited.len() { @@ -74,7 +79,19 @@ impl OverlayGraph<'_> { } let contour = buffer.points.as_slice().to_vec(); - shapes.push(vec![contour]); + if find_pinch_point(&contour, &mut point_buf).is_some() { + for part in split_all_pinch_points(contour, &mut point_buf) { + let area = part.unsafe_area(); + let is_hole = if is_main_dir_cw { area < 0 } else { area > 0 }; + if is_hole { + pending_holes.push(part); + } else { + shapes.push(vec![part]); + } + } + } else { + shapes.push(vec![contour]); + } } if avg_holes_count > 0 { @@ -157,6 +174,10 @@ impl OverlayGraph<'_> { shapes.join_sorted_holes(holes, anchors, is_main_dir_cw); } + if !pending_holes.is_empty() { + shapes.join_unsorted_holes(pending_holes, is_main_dir_cw); + } + shapes } @@ -193,3 +214,43 @@ impl OverlayGraph<'_> { } } } + +fn find_pinch_point(contour: &[IntPoint], point_buf: &mut Vec) -> Option<(usize, usize)> { + let n = contour.len(); + if n < 2 { + return None; + } + point_buf.clear(); + point_buf.extend_from_slice(contour); + point_buf.sort_by_two_keys(false, |p| p.x, |p| p.y); + for w in point_buf.windows(2) { + if w[0] == w[1] { + let target = w[0]; + let i = contour.iter().position(|p| *p == target).unwrap(); + let j = contour[i + 1..].iter().position(|p| *p == target).unwrap() + i + 1; + return Some((i, j)); + } + } + None +} + +fn split_all_pinch_points(contour: Vec, point_buf: &mut Vec) -> Vec> { + if let Some((i, j)) = find_pinch_point(&contour, point_buf) { + let inner: Vec = contour[i..j].to_vec(); + let mut outer: Vec = contour[..=i].to_vec(); + outer.extend_from_slice(&contour[j + 1..]); + + let mut result = Vec::new(); + if inner.len() >= 3 { + result.extend(split_all_pinch_points(inner, point_buf)); + } + if outer.len() >= 3 { + result.extend(split_all_pinch_points(outer, point_buf)); + } + result + } else if contour.len() >= 3 { + vec![contour] + } else { + Vec::new() + } +} diff --git a/iOverlay/tests/shared_point_tests.rs b/iOverlay/tests/shared_point_tests.rs index 6856543..deb20ec 100644 --- a/iOverlay/tests/shared_point_tests.rs +++ b/iOverlay/tests/shared_point_tests.rs @@ -6,47 +6,68 @@ mod tests { use i_overlay::core::overlay_rule::OverlayRule; use i_shape::int::area::Area; - // Four boxes that form a shape with a hole, where the exterior - // contour and hole contour should share a point at (2,2): - // - // 0 1 2 3 - // y=3 +--+--+ - // | | | - // y=2 +--+--*--+ * = shared point (2,2) - // | |##| | ## = hole - // y=1 +--+--+--+ - // | | | | - // y=0 +--+--+--+ - // - // Box 1: (0,0)-(3,1) bottom strip - // Box 2: (0,1)-(1,2) middle-left - // Box 3: (2,1)-(3,2) middle-right - // Box 4: (0,2)-(2,3) top-left - // - // The hole is at (1,1)-(2,2). The point (2,2) lies on both the - // exterior contour and the hole contour. - // - // Expected exterior contour (6 points, CCW): - // (0,0) → (3,0) → (3,2) → (2,2) → (2,3) → (0,3) - // - // Expected hole contour (4 points, CW): - // (1,1) → (2,1) → (2,2) → (1,2) - // - // BUG: The library currently produces a single merged contour that - // visits (2,2) twice in a figure-8 instead of two separate contours: - // (0,0)→(3,0)→(3,2)→(2,2)→(2,1)→(1,1)→(1,2)→(2,2)→(2,3)→(0,3) - - /// Create a CCW rectangle contour from (x1,y1) to (x2,y2). fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Vec { - vec![ + let contour = vec![ IntPoint::new(x1, y1), IntPoint::new(x2, y1), IntPoint::new(x2, y2), IntPoint::new(x1, y2), - ] + ]; + assert!( + contour.area_two() < 0, + "input contour must be counter-clockwise, got area_two={}", + contour.area_two() + ); + contour + } + + fn run_overlay( + shapes: &[Vec], + overlay_rule: OverlayRule, + fill_rule: FillRule, + ) -> Vec>> { + match overlay_rule { + OverlayRule::Subject => { + let mut ov = + Overlay::with_contours_custom(shapes, &[], IntOverlayOptions::ogc(), Default::default()); + ov.overlay(overlay_rule, fill_rule) + } + _ => { + let mut ov = Overlay::with_contours_custom( + &shapes[0..1], + &shapes[1..], + IntOverlayOptions::ogc(), + Default::default(), + ); + ov.overlay(overlay_rule, fill_rule) + } + } + } + + fn run_test(shapes: &[Vec], fill_rule: FillRule, assert_fn: impl Fn(&[Vec>])) { + for &overlay_rule in &[OverlayRule::Subject, OverlayRule::Union] { + let result = run_overlay(shapes, overlay_rule, fill_rule); + assert_fn(&result); + } + } + + fn assert_empty(result: &[Vec>]) { + assert_eq!(result.len(), 0, "expected 0 shapes, got {result:?}"); } - fn subject_contours() -> Vec> { + // --------------------------------------------------------------- + // Tests: shared_point + // --------------------------------------------------------------- + + // 0 1 2 3 + // y=3 +--+--+ + // | | + // y=2 +--+--*--+ * = shared point (2,2) + // | |##| | ## = hole + // y=1 +--+--+--+ + // | | + // y=0 +--+--+--+ + fn shared_point_shapes() -> Vec> { vec![ rect(0, 0, 3, 1), rect(0, 1, 1, 2), @@ -55,92 +76,331 @@ mod tests { ] } - fn overlay(fill_rule: FillRule) -> Vec>> { - let contours = subject_contours(); - let mut overlay = Overlay::with_contours_custom( - &contours, - &[], - IntOverlayOptions::ogc(), - Default::default(), + // 1 shape, 2 contours + // exterior (CCW, 6 pts): (0,0)→(3,0)→(3,2)→(2,2)→(2,3)→(0,3) + // hole (CW, 4 pts): (2,2)→(2,1)→(1,1)→(1,2) + fn assert_shared_point(result: &[Vec>]) { + assert_eq!(result.len(), 1, "expected 1 shape, got {result:?}"); + assert_eq!(result[0].len(), 2, "expected 2 contours, got {:?}", result[0]); + assert_eq!( + result[0][0], + vec![ + IntPoint::new(0, 0), + IntPoint::new(3, 0), + IntPoint::new(3, 2), + IntPoint::new(2, 2), + IntPoint::new(2, 3), + IntPoint::new(0, 3), + ], + "exterior mismatch" + ); + assert_eq!( + result[0][1], + vec![ + IntPoint::new(2, 2), + IntPoint::new(2, 1), + IntPoint::new(1, 1), + IntPoint::new(1, 2), + ], + "hole mismatch" ); - overlay.overlay(OverlayRule::Subject, fill_rule) } - fn contour_contains(contour: &[IntPoint], point: IntPoint) -> bool { - contour.iter().any(|p| *p == point) + #[test] + fn test_shared_point_even_odd() { + run_test(&shared_point_shapes(), FillRule::EvenOdd, assert_shared_point); } #[test] - fn test_input_winding_order() { - // All input rectangles must be CCW (negative area_two). - for contour in &subject_contours() { - assert!( - contour.area_two() < 0, - "input contour must be counter-clockwise, got area_two={}", contour.area_two() - ); - } + fn test_shared_point_non_zero() { + run_test(&shared_point_shapes(), FillRule::NonZero, assert_shared_point); } #[test] - fn test_shared_point_even_odd() { - let result = overlay(FillRule::EvenOdd); + fn test_shared_point_positive() { + run_test(&shared_point_shapes(), FillRule::Positive, assert_shared_point); + } + + #[test] + fn test_shared_point_negative() { + run_test(&shared_point_shapes(), FillRule::Negative, assert_empty); + } - assert_eq!(result.len(), 1, "expected 1 shape"); - assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); + // --------------------------------------------------------------- + // Tests: two_shapes_touching + // --------------------------------------------------------------- - let shared = IntPoint::new(2, 2); - assert!( - contour_contains(&result[0][0], shared), - "exterior contour must contain (2,2)" + // 0 1 2 + // y=2 +--+ + // | | + // y=1 +--*--+ * = touching point (1,1) + // | | + // y=0 +--+ + fn two_shapes_touching_shapes() -> Vec> { + vec![rect(0, 1, 1, 2), rect(1, 0, 2, 1)] + } + + // 2 shapes, each a simple rectangle (4 pts, no holes) + // shape 0: (0,2)→(0,1)→(1,1)→(1,2) + // shape 1: (1,1)→(1,0)→(2,0)→(2,1) + fn assert_two_shapes_touching(result: &[Vec>]) { + assert_eq!(result.len(), 2, "expected 2 shapes, got {result:?}"); + assert_eq!(result[0].len(), 1, "shape 0 should have 1 contour"); + assert_eq!(result[1].len(), 1, "shape 1 should have 1 contour"); + assert_eq!( + result[0][0], + vec![ + IntPoint::new(0, 2), + IntPoint::new(0, 1), + IntPoint::new(1, 1), + IntPoint::new(1, 2), + ], + "shape 0 mismatch" ); - assert!( - contour_contains(&result[0][1], shared), - "hole contour must contain (2,2)" + assert_eq!( + result[1][0], + vec![ + IntPoint::new(1, 1), + IntPoint::new(1, 0), + IntPoint::new(2, 0), + IntPoint::new(2, 1), + ], + "shape 1 mismatch" ); } #[test] - fn test_shared_point_non_zero() { - let result = overlay(FillRule::NonZero); + fn test_two_shapes_touching_even_odd() { + run_test( + &two_shapes_touching_shapes(), + FillRule::EvenOdd, + assert_two_shapes_touching, + ); + } - assert_eq!(result.len(), 1, "expected 1 shape"); - assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); + #[test] + fn test_two_shapes_touching_non_zero() { + run_test( + &two_shapes_touching_shapes(), + FillRule::NonZero, + assert_two_shapes_touching, + ); + } - let shared = IntPoint::new(2, 2); - assert!( - contour_contains(&result[0][0], shared), - "exterior contour must contain (2,2)" + #[test] + fn test_two_shapes_touching_positive() { + run_test( + &two_shapes_touching_shapes(), + FillRule::Positive, + assert_two_shapes_touching, ); - assert!( - contour_contains(&result[0][1], shared), - "hole contour must contain (2,2)" + } + + #[test] + fn test_two_shapes_touching_negative() { + run_test(&two_shapes_touching_shapes(), FillRule::Negative, assert_empty); + } + + // --------------------------------------------------------------- + // Tests: two_holes_sharing_vertices + // --------------------------------------------------------------- + + // 0 1 2 3 4 5 6 7 + // y=3 +--+--+ +--+--+ + // | | | | + // y=2 +--+--*--+--+--*--+--+ * = pinch points (2,2) and (5,2) + // | |##| |##| | ## = holes + // y=1 +--+--+--+--+--+--+--+ + // | | + // y=0 +--+--+--+--+--+--+--+ + fn two_holes_sharing_shapes() -> Vec> { + vec![ + rect(0, 0, 7, 1), + rect(0, 1, 1, 2), + rect(2, 1, 5, 2), + rect(6, 1, 7, 2), + rect(0, 2, 2, 3), + rect(5, 2, 7, 3), + ] + } + + // 1 shape with 3 contours (exterior + 2 holes) + // exterior (CCW, 8 pts): (0,0)→(7,0)→(7,3)→(5,3)→(5,2)→(2,2)→(2,3)→(0,3) + // hole 1 (CW, 4 pts): (2,2)→(2,1)→(1,1)→(1,2) + // hole 2 (CW, 4 pts): (5,2)→(6,2)→(6,1)→(5,1) + fn assert_two_holes_sharing(result: &[Vec>]) { + assert_eq!(result.len(), 1, "expected 1 shape, got {result:?}"); + assert_eq!(result[0].len(), 3, "expected 3 contours, got {:?}", result[0]); + assert_eq!( + result[0][0], + vec![ + IntPoint::new(0, 0), + IntPoint::new(7, 0), + IntPoint::new(7, 3), + IntPoint::new(5, 3), + IntPoint::new(5, 2), + IntPoint::new(2, 2), + IntPoint::new(2, 3), + IntPoint::new(0, 3), + ], + "exterior mismatch" + ); + assert_eq!( + result[0][1], + vec![ + IntPoint::new(2, 2), + IntPoint::new(2, 1), + IntPoint::new(1, 1), + IntPoint::new(1, 2), + ], + "hole 1 mismatch" + ); + assert_eq!( + result[0][2], + vec![ + IntPoint::new(5, 2), + IntPoint::new(6, 2), + IntPoint::new(6, 1), + IntPoint::new(5, 1), + ], + "hole 2 mismatch" ); } #[test] - fn test_shared_point_positive() { - let result = overlay(FillRule::Positive); + fn test_two_holes_sharing_even_odd() { + run_test( + &two_holes_sharing_shapes(), + FillRule::EvenOdd, + assert_two_holes_sharing, + ); + } - assert_eq!(result.len(), 1, "expected 1 shape"); - assert_eq!(result[0].len(), 2, "expected 2 contours (exterior + hole)"); + #[test] + fn test_two_holes_sharing_non_zero() { + run_test( + &two_holes_sharing_shapes(), + FillRule::NonZero, + assert_two_holes_sharing, + ); + } - let shared = IntPoint::new(2, 2); - assert!( - contour_contains(&result[0][0], shared), - "exterior contour must contain (2,2)" + #[test] + fn test_two_holes_sharing_positive() { + run_test( + &two_holes_sharing_shapes(), + FillRule::Positive, + assert_two_holes_sharing, ); - assert!( - contour_contains(&result[0][1], shared), - "hole contour must contain (2,2)" + } + + #[test] + fn test_two_holes_sharing_negative() { + run_test(&two_holes_sharing_shapes(), FillRule::Negative, assert_empty); + } + + // --------------------------------------------------------------- + // Tests: three_holes_sharing_vertices + // --------------------------------------------------------------- + + // 0 1 2 3 4 5 6 7 + // y=4 +--+--+ +--+--+ + // | | | | + // y=3 +--+--*--+--+--*--+--+ * = (2,3) and (5,3) + // | |##| |##| | ## = holes 1 and 2 + // y=2 +--+--*--+--+--*--+--+ * = (2,2) and (5,2) + // | |########| | ######## = hole 3 + // y=1 +--+--+--+--+--+--+--+ + // | | + // y=0 +--+--+--+--+--+--+--+ + fn three_holes_sharing_shapes() -> Vec> { + vec![ + rect(0, 0, 7, 1), + rect(0, 1, 2, 2), + rect(5, 1, 7, 2), + rect(0, 2, 1, 3), + rect(2, 2, 5, 3), + rect(6, 2, 7, 3), + rect(0, 3, 2, 4), + rect(5, 3, 7, 4), + ] + } + + // 2 shapes, both with no holes (center block splits off at pinch points) + // main shape (CCW, 16 pts): complex exterior with notches + // center block (CCW, 4 pts): (2,3)→(2,2)→(5,2)→(5,3) + fn assert_three_holes_sharing(result: &[Vec>]) { + assert_eq!(result.len(), 2, "expected 2 shapes, got {result:?}"); + let (main, center) = if result[0][0].len() > result[1][0].len() { + (&result[0], &result[1]) + } else { + (&result[1], &result[0]) + }; + assert_eq!(main.len(), 1, "main shape should have 1 contour"); + assert_eq!( + main[0], + vec![ + IntPoint::new(0, 0), + IntPoint::new(7, 0), + IntPoint::new(7, 4), + IntPoint::new(5, 4), + IntPoint::new(5, 3), + IntPoint::new(6, 3), + IntPoint::new(6, 2), + IntPoint::new(5, 2), + IntPoint::new(5, 1), + IntPoint::new(2, 1), + IntPoint::new(2, 2), + IntPoint::new(1, 2), + IntPoint::new(1, 3), + IntPoint::new(2, 3), + IntPoint::new(2, 4), + IntPoint::new(0, 4), + ], + "main shape mismatch" + ); + assert_eq!(center.len(), 1, "center block should have 1 contour"); + assert_eq!( + center[0], + vec![ + IntPoint::new(2, 3), + IntPoint::new(2, 2), + IntPoint::new(5, 2), + IntPoint::new(5, 3), + ], + "center block mismatch" ); } #[test] - fn test_shared_point_negative() { - let result = overlay(FillRule::Negative); + fn test_three_holes_sharing_even_odd() { + run_test( + &three_holes_sharing_shapes(), + FillRule::EvenOdd, + assert_three_holes_sharing, + ); + } + + #[test] + fn test_three_holes_sharing_non_zero() { + run_test( + &three_holes_sharing_shapes(), + FillRule::NonZero, + assert_three_holes_sharing, + ); + } + + #[test] + fn test_three_holes_sharing_positive() { + run_test( + &three_holes_sharing_shapes(), + FillRule::Positive, + assert_three_holes_sharing, + ); + } - // All input contours are CCW (positive winding), so Negative - // fill rule produces no filled regions. - assert_eq!(result.len(), 0, "expected 0 shapes for Negative fill rule"); + #[test] + fn test_three_holes_sharing_negative() { + run_test(&three_holes_sharing_shapes(), FillRule::Negative, assert_empty); } }