diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index dd848bb4..f48e6420 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "i_overlay" -version = "7.0.0" +version = "7.0.1" authors = ["Nail Sharipov "] edition = "2024" rust-version = "1.88" diff --git a/iOverlay/src/core/extract.rs b/iOverlay/src/core/extract.rs index 85d0c334..112b3af4 100644 --- a/iOverlay/src/core/extract.rs +++ b/iOverlay/src/core/extract.rs @@ -211,8 +211,11 @@ where points.clear(); points.push(start_data.begin); + let last_link_id = + GraphUtil::next_link(self.links, self.nodes, link_id, last_node_id, !clockwise, visited); + // Find a closed tour - while node_id != last_node_id { + while link_id != last_link_id { link_id = GraphUtil::next_link(self.links, self.nodes, link_id, node_id, clockwise, visited); let link = unsafe { diff --git a/iOverlay/src/core/extract_ogc.rs b/iOverlay/src/core/extract_ogc.rs index 8ff78248..6033861d 100644 --- a/iOverlay/src/core/extract_ogc.rs +++ b/iOverlay/src/core/extract_ogc.rs @@ -198,8 +198,11 @@ where visited.visit_edge(link_id, visited_state); + let last_link_id = + GraphUtil::next_link(self.links, self.nodes, link_id, last_node_id, !clockwise, visited); + // Find a closed tour - while node_id != last_node_id { + while link_id != last_link_id { link_id = GraphUtil::next_link(self.links, self.nodes, link_id, node_id, clockwise, visited); let link = unsafe { @@ -237,10 +240,19 @@ where global_visited.visit_edge(link_id, VisitState::HullVisited); contour_visited.visit_edge(link_id, VisitState::Unvisited); + let last_link_id = GraphUtil::next_link( + self.links, + self.nodes, + link_id, + last_node_id, + !clockwise, + global_visited, + ); + let mut original_contour_len = 1; // Find a closed tour - while node_id != last_node_id { + while link_id != last_link_id { link_id = GraphUtil::next_link( self.links, self.nodes, diff --git a/iOverlay/src/vector/extract.rs b/iOverlay/src/vector/extract.rs index c6553ce0..903dd735 100644 --- a/iOverlay/src/vector/extract.rs +++ b/iOverlay/src/vector/extract.rs @@ -152,8 +152,11 @@ where store, )); + let last_link_id = + GraphUtil::next_link(self.links, self.nodes, link_id, last_node_id, !clockwise, visited); + // Find a closed tour - while node_id != last_node_id { + while link_id != last_link_id { link_id = GraphUtil::next_link(self.links, self.nodes, link_id, node_id, clockwise, visited); let link = unsafe { @@ -532,4 +535,32 @@ mod tests { debug_assert!(shapes.len() == 2); } + + #[test] + fn test_self_touching_contour_closes_by_edge() { + #[rustfmt::skip] + let subj = int_shape![ + [[-5, 0], [0, 0], [0, 5]], + [[-3, 2], [-1, 2], [-1, 1]], + ]; + + let mut buffer = Default::default(); + let mut overlay = Overlay::with_contours(&subj, &[]); + overlay.options = IntOverlayOptions { + preserve_input_collinear: false, + output_direction: ContourDirection::CounterClockwise, + preserve_output_collinear: true, + min_output_area: 0u64, + ogc: false, + }; + + let shapes = overlay + .build_graph_view(FillRule::NonZero) + .unwrap() + .extract_vector_shapes(OverlayRule::Subject, &mut buffer); + + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 1); + assert_eq!(shapes[0][0].len(), 7); + } } diff --git a/iOverlay/tests/boolean/test_138.json b/iOverlay/tests/boolean/test_138.json index 721858be..d53f99c2 100644 --- a/iOverlay/tests/boolean/test_138.json +++ b/iOverlay/tests/boolean/test_138.json @@ -2,11 +2,11 @@ "fillRule": 1, "subjPaths": [[[0, 0], [0, 80], [90, 80], [90, 0]], [[10, 10], [80, 10], [80, 50], [10, 50], [70, 20], [70, 40]], [[50, 60], [60, 60], [60, 70], [50, 70]]], "clipPaths": [], - "subject": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]], + "subject": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]], "clip": [[]], - "union": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]], + "union": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]], "intersect": [[]], - "difference": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]], + "difference": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]], "inverseDifference": [[]], - "xor": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]] -} \ No newline at end of file + "xor": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]] +} diff --git a/iOverlay/tests/boolean/test_139.json b/iOverlay/tests/boolean/test_139.json index 7e3b0414..a8109abb 100644 --- a/iOverlay/tests/boolean/test_139.json +++ b/iOverlay/tests/boolean/test_139.json @@ -2,11 +2,11 @@ "fillRule": 1, "subjPaths": [[[0, 0], [0, 80], [90, 80], [90, 0]], [[10, 10], [80, 10], [80, 50], [10, 50], [70, 20], [70, 40]], [[20, 60], [30, 60], [30, 70], [20, 70]], [[50, 60], [60, 60], [60, 70], [50, 70]]], "clipPaths": [], - "subject": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]], + "subject": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]], "clip": [[]], - "union": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]], + "union": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]], "intersect": [[]], - "difference": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]], + "difference": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]], "inverseDifference": [[]], - "xor": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]], [[[50, 30], [70, 40], [70, 20]]]]] -} \ No newline at end of file + "xor": [[[[[0, 0], [0, 80], [90, 80], [90, 0]], [[50, 30], [10, 10], [80, 10], [80, 50], [10, 50], [50, 30], [70, 40], [70, 20]], [[20, 70], [20, 60], [30, 60], [30, 70]], [[50, 70], [50, 60], [60, 60], [60, 70]]]]] +} diff --git a/iOverlay/tests/boolean/test_150.json b/iOverlay/tests/boolean/test_150.json index 02a3e81f..d12b1d77 100644 --- a/iOverlay/tests/boolean/test_150.json +++ b/iOverlay/tests/boolean/test_150.json @@ -2,11 +2,11 @@ "fillRule": 1, "subjPaths": [[[0, 0], [4, -1], [4, 1]], [[0, 0], [5, 2], [5, -2]], [[0, 0], [6, -3], [6, 3]], [[0, 0], [7, 4], [7, -4]]], "clipPaths": [], - "subject": [[[[[0, 0], [7, 4], [7, -4]], [[6, 3], [0, 0], [5, 2], [5, -2], [0, 0], [6, -3]], [[4, 1], [0, 0], [4, -1]]]]], + "subject": [[[[[0, 0], [7, 4], [7, -4], [0, 0], [6, -3], [6, 3]]], [[[0, 0], [5, 2], [5, -2], [0, 0], [4, -1], [4, 1]]]]], "clip": [[]], - "union": [[[[[0, 0], [7, 4], [7, -4]], [[6, 3], [0, 0], [5, 2], [5, -2], [0, 0], [6, -3]], [[4, 1], [0, 0], [4, -1]]]]], + "union": [[[[[0, 0], [7, 4], [7, -4], [0, 0], [6, -3], [6, 3]]], [[[0, 0], [5, 2], [5, -2], [0, 0], [4, -1], [4, 1]]]]], "intersect": [[]], - "difference": [[[[[0, 0], [7, 4], [7, -4]], [[6, 3], [0, 0], [5, 2], [5, -2], [0, 0], [6, -3]], [[4, 1], [0, 0], [4, -1]]]]], + "difference": [[[[[0, 0], [7, 4], [7, -4], [0, 0], [6, -3], [6, 3]]], [[[0, 0], [5, 2], [5, -2], [0, 0], [4, -1], [4, 1]]]]], "inverseDifference": [[]], - "xor": [[[[[0, 0], [7, 4], [7, -4]], [[6, 3], [0, 0], [5, 2], [5, -2], [0, 0], [6, -3]], [[4, 1], [0, 0], [4, -1]]]]] -} \ No newline at end of file + "xor": [[[[[0, 0], [7, 4], [7, -4], [0, 0], [6, -3], [6, 3]]], [[[0, 0], [5, 2], [5, -2], [0, 0], [4, -1], [4, 1]]]]] +} diff --git a/iOverlay/tests/boolean/test_151.json b/iOverlay/tests/boolean/test_151.json index 83f51edf..c760917c 100644 --- a/iOverlay/tests/boolean/test_151.json +++ b/iOverlay/tests/boolean/test_151.json @@ -1,61 +1,12 @@ { "fillRule": 1, - "subjPaths": [ - [[0, 0], [3, 3], [6, 0]], - [[0, 0], [3,-3], [0,-6]], - [[3,-3], [6, 0], [6,-6]], - [[3,-3], [5,-4], [5,-2]] - ], + "subjPaths": [[[0, 0], [3, 3], [6, 0]], [[0, 0], [3, -3], [0, -6]], [[3, -3], [6, 0], [6, -6]], [[3, -3], [5, -4], [5, -2]]], "clipPaths": [], - "subject": [[ - [ - [[0, -6], [0, 0], [3, -3]] - ], - [ - [[0, 0], [3, 3], [6, 0]] - ], - [ - [[3, -3], [6, 0], [6, -6]], - [[5, -2], [3, -3], [5, -4]] - ] - ]], + "subject": [[[[[0, -6], [0, 0], [3, -3]]], [[[0, 0], [3, 3], [6, 0]]], [[[3, -3], [6, 0], [6, -6], [3, -3], [5, -4], [5, -2]]]]], "clip": [[]], - "union": [[ - [ - [[0, -6], [0, 0], [3, -3]] - ], - [ - [[0, 0], [3, 3], [6, 0]] - ], - [ - [[3, -3], [6, 0], [6, -6]], - [[5, -2], [3, -3], [5, -4]] - ] - ]], + "union": [[[[[0, -6], [0, 0], [3, -3]]], [[[0, 0], [3, 3], [6, 0]]], [[[3, -3], [6, 0], [6, -6], [3, -3], [5, -4], [5, -2]]]]], "intersect": [[]], - "difference": [[ - [ - [[0, -6], [0, 0], [3, -3]] - ], - [ - [[0, 0], [3, 3], [6, 0]] - ], - [ - [[3, -3], [6, 0], [6, -6]], - [[5, -2], [3, -3], [5, -4]] - ] - ]], + "difference": [[[[[0, -6], [0, 0], [3, -3]]], [[[0, 0], [3, 3], [6, 0]]], [[[3, -3], [6, 0], [6, -6], [3, -3], [5, -4], [5, -2]]]]], "inverseDifference": [[]], - "xor": [[ - [ - [[0, -6], [0, 0], [3, -3]] - ], - [ - [[0, 0], [3, 3], [6, 0]] - ], - [ - [[3, -3], [6, 0], [6, -6]], - [[5, -2], [3, -3], [5, -4]] - ] - ]] -} \ No newline at end of file + "xor": [[[[[0, -6], [0, 0], [3, -3]]], [[[0, 0], [3, 3], [6, 0]]], [[[3, -3], [6, 0], [6, -6], [3, -3], [5, -4], [5, -2]]]]] +} diff --git a/iOverlay/tests/boolean/test_152.json b/iOverlay/tests/boolean/test_152.json index 5b26de49..f66fe22a 100644 --- a/iOverlay/tests/boolean/test_152.json +++ b/iOverlay/tests/boolean/test_152.json @@ -2,11 +2,11 @@ "fillRule": 1, "subjPaths": [[[0, 0], [3, 4], [3, 1]], [[0, 0], [2, 1], [2, 2]], [[0, 0], [3, -1], [3, -4]], [[0, 0], [2, -2], [2, -1]]], "clipPaths": [], - "subject": [[[[[0, 0], [3, 4], [3, 1]], [[2, 2], [0, 0], [2, 1]]], [[[0, 0], [3, -1], [3, -4]], [[2, -1], [0, 0], [2, -2]]]]], + "subject": [[[[[0, 0], [3, 4], [3, 1], [0, 0], [2, 1], [2, 2]]], [[[0, 0], [3, -1], [3, -4], [0, 0], [2, -2], [2, -1]]]]], "clip": [[]], - "union": [[[[[0, 0], [3, 4], [3, 1]], [[2, 2], [0, 0], [2, 1]]], [[[0, 0], [3, -1], [3, -4]], [[2, -1], [0, 0], [2, -2]]]]], + "union": [[[[[0, 0], [3, 4], [3, 1], [0, 0], [2, 1], [2, 2]]], [[[0, 0], [3, -1], [3, -4], [0, 0], [2, -2], [2, -1]]]]], "intersect": [[]], - "difference": [[[[[0, 0], [3, 4], [3, 1]], [[2, 2], [0, 0], [2, 1]]], [[[0, 0], [3, -1], [3, -4]], [[2, -1], [0, 0], [2, -2]]]]], + "difference": [[[[[0, 0], [3, 4], [3, 1], [0, 0], [2, 1], [2, 2]]], [[[0, 0], [3, -1], [3, -4], [0, 0], [2, -2], [2, -1]]]]], "inverseDifference": [[]], - "xor": [[[[[0, 0], [3, 4], [3, 1]], [[2, 2], [0, 0], [2, 1]]], [[[0, 0], [3, -1], [3, -4]], [[2, -1], [0, 0], [2, -2]]]]] -} \ No newline at end of file + "xor": [[[[[0, 0], [3, 4], [3, 1], [0, 0], [2, 1], [2, 2]]], [[[0, 0], [3, -1], [3, -4], [0, 0], [2, -2], [2, -1]]]]] +} diff --git a/iOverlay/tests/ocg_tests.rs b/iOverlay/tests/ocg_tests.rs index 97e74738..ccf97443 100644 --- a/iOverlay/tests/ocg_tests.rs +++ b/iOverlay/tests/ocg_tests.rs @@ -2,10 +2,15 @@ mod tests { #![allow(clippy::explicit_counter_loop)] + use i_float::int::point::IntPoint; use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions, Overlay}; use i_overlay::core::overlay_rule::OverlayRule; + use i_shape::int::area::Area; use i_shape::{int_path, int_shape}; + use std::f64::consts::PI; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::time::{Duration, Instant}; #[test] fn test_0() { @@ -436,6 +441,67 @@ mod tests { assert_eq!(result[1][1].len(), 3); } + #[test] + fn test_10() { + let subj_paths = int_shape![ + [[0, 0], [-6, 2], [-2, -6]], + [[-3, 0], [0, 0], [-3, -1]], + [[0, 0], [4, -6], [4, 6]], + ]; + + let mut overlay = + Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + + let shapes = overlay.overlay(OverlayRule::Union, FillRule::NonZero); + assert_eq!(shapes[0].len(), 2); + assert_eq!(shapes[1].len(), 1); + } + + #[test] + fn test_11() { + let subj_paths = int_shape![ + [ + [-5, 5], + [-4, 1], + [0, 0], + [-4, -1], + [-5, -5], + [0, 0], + [5, -5], + [4, -1], + [0, 0], + [4, 1], + [5, 5], + [0, 0] + ], + [ + [-3, -2], + [-3, -1], + [0, 0], + [-3, 1], + [-3, 2], + [0, 0], + [3, 2], + [3, 1], + [0, 0], + [3, -1], + [3, -2], + [0, 0] + ], + ]; + + let mut overlay = + Overlay::with_contours_custom(&subj_paths, &[], IntOverlayOptions::ogc(), Default::default()); + + let shapes = overlay.overlay(OverlayRule::Union, FillRule::NonZero); + assert_eq!(shapes.len(), 4); + for shape in shapes.iter() { + assert_eq!(shape.len(), 2); + assert_eq!(shape[0].len(), 3); + assert_eq!(shape[1].len(), 3); + } + } + #[test] fn test_checkerboard_a() { for n in 4..50 { @@ -577,4 +643,338 @@ mod tests { assert_eq!(main.len(), 6); } + + #[test] + fn test_random_grid_holes() { + for seed in 0..256 { + random_grid_holes(seed, 8, 35); + random_grid_holes(seed ^ 0x9e37_79b9_7f4a_7c15, 10, 45); + random_grid_holes(seed ^ 0xd1b5_4a32_d192_ed03, 12, 55); + } + } + + fn random_grid_holes(seed: u64, n: usize, fill_percent: u32) { + let mut rng = GridRng::new(seed); + let mut clipped = vec![false; n * n]; + let mut clipped_count = 0; + + for y in 0..n { + for x in 0..n { + let is_clipped = rng.percent(fill_percent); + clipped[y * n + x] = is_clipped; + clipped_count += is_clipped as usize; + } + } + + if clipped_count == 0 || clipped_count == n * n { + return; + } + + let expected_count = remaining_components(n, &clipped); + let subj_paths = vec![rect_path(0, 0, n as i32, n as i32)]; + let mut clip_paths = Vec::with_capacity(clipped_count); + + for y in 0..n { + for x in 0..n { + if clipped[y * n + x] { + clip_paths.push(rect_path(x as i32, y as i32, x as i32 + 1, y as i32 + 1)); + } + } + } + + let mut overlay = Overlay::with_contours_custom( + &subj_paths, + &clip_paths, + IntOverlayOptions::ogc(), + Default::default(), + ); + + let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); + + assert_eq!( + result.len(), + expected_count, + "seed={seed} n={n} fill={fill_percent}% grid:\n{}", + grid_debug(n, &clipped) + ); + + for shape in result.iter() { + assert!(!shape.is_empty(), "seed={seed} n={n}"); + for contour in shape.iter() { + assert!(contour.len() >= 3, "seed={seed} n={n} contour={contour:?}"); + } + } + } + + #[test] + fn test_random_self_intersections() { + for seed in 0..128 { + random_self_intersections(seed, 1, 12); + random_self_intersections(seed ^ 0x9e37_79b9_7f4a_7c15, 2, 20); + random_self_intersections(seed ^ 0xd1b5_4a32_d192_ed03, 3, 28); + } + } + + #[test] + #[ignore = "long randomized OGC stress test"] + fn test_random_self_intersections_stress() { + let seconds = env_u64("OGC_STRESS_SECONDS", 600); + let mut seed = env_u64("OGC_STRESS_SEED", 0xa076_1d64_78bd_642f); + let deadline = Instant::now() + Duration::from_secs(seconds); + let mut iteration = 0usize; + + while Instant::now() < deadline { + run_random_self_intersections_case(seed, iteration, 1, 12); + run_random_self_intersections_case(seed ^ 0x9e37_79b9_7f4a_7c15, iteration, 2, 20); + run_random_self_intersections_case(seed ^ 0xd1b5_4a32_d192_ed03, iteration, 3, 28); + + seed = next_stress_seed(seed); + iteration += 1; + } + + eprintln!("OGC stress completed: iterations={iteration} seconds={seconds}"); + } + + fn run_random_self_intersections_case( + seed: u64, + iteration: usize, + contour_count: usize, + hole_count: usize, + ) { + let result = catch_unwind(AssertUnwindSafe(|| { + random_self_intersections(seed, contour_count, hole_count); + })); + + if let Err(payload) = result { + panic!( + "OGC stress failed: iteration={iteration} seed={seed} contour_count={contour_count} hole_count={hole_count} panic={}", + panic_message(payload) + ); + } + } + + fn random_self_intersections(seed: u64, contour_count: usize, hole_count: usize) { + let mut rng = GridRng::new(seed); + let mut subj_paths = Vec::with_capacity(contour_count); + let mut clip_paths = Vec::with_capacity(hole_count); + + for _ in 0..contour_count { + subj_paths.push(random_star_contour(&mut rng, 760, 260, 640)); + } + + for _ in 0..hole_count { + clip_paths.push(random_star_contour(&mut rng, 680, 60, 220)); + } + + let mut overlay = Overlay::with_contours_custom( + &subj_paths, + &clip_paths, + IntOverlayOptions::ogc(), + Default::default(), + ); + + let result = overlay.overlay(OverlayRule::Difference, FillRule::EvenOdd); + + let mut overlay = + Overlay::with_shapes_options(&result, &[], IntOverlayOptions::ogc(), Default::default()); + let normalized = overlay.overlay(OverlayRule::Union, FillRule::EvenOdd); + + let result_area = result.area().abs(); + let normalized_area = normalized.area().abs(); + let area_delta = (result_area - normalized_area).abs(); + let max_area = result_area.max(normalized_area); + let area_tolerance = 20_000.max(max_area / 5); + assert!( + area_delta <= area_tolerance, + "seed={seed} contour_count={contour_count} hole_count={hole_count} area_delta={area_delta} area_tolerance={area_tolerance} result_area={result_area} normalized_area={normalized_area}" + ); + + for shape in result.iter() { + assert!( + !shape.is_empty(), + "seed={seed} contour_count={contour_count} hole_count={hole_count}" + ); + for contour in shape.iter() { + assert!( + contour.len() >= 3, + "seed={seed} contour_count={contour_count} hole_count={hole_count} contour={contour:?}" + ); + } + } + } + + fn random_star_contour( + rng: &mut GridRng, + center_abs: i32, + min_radius: i32, + max_radius: i32, + ) -> Vec> { + let n = 9 + 2 * rng.range_usize(0, 5); + let mut step = rng.range_usize(2, n / 2); + while gcd(n, step) != 1 { + step += 1; + if step >= n / 2 { + step = 2; + } + } + + let center_x = rng.range_i32(-center_abs, center_abs); + let center_y = rng.range_i32(-center_abs, center_abs); + let radius = rng.range_i32(min_radius, max_radius) as f64; + let angle_shift = rng.unit_f64() * 2.0 * PI; + + let mut points = Vec::with_capacity(n); + for i in 0..n { + let angle_jitter = (rng.unit_f64() - 0.5) * 0.18; + let radius_jitter = 0.72 + rng.unit_f64() * 0.56; + let angle = angle_shift + 2.0 * PI * i as f64 / n as f64 + angle_jitter; + let r = radius * radius_jitter; + points.push(IntPoint::new( + center_x + (r * angle.cos()).round() as i32, + center_y + (r * angle.sin()).round() as i32, + )); + } + + let mut contour = Vec::with_capacity(n); + let mut index = 0; + for _ in 0..n { + contour.push(points[index]); + index = (index + step) % n; + } + + contour + } + + fn gcd(mut a: usize, mut b: usize) -> usize { + while b != 0 { + let r = a % b; + a = b; + b = r; + } + a + } + + fn remaining_components(n: usize, clipped: &[bool]) -> usize { + let mut visited = vec![false; clipped.len()]; + let mut components = 0; + let mut stack = Vec::new(); + + for start in 0..clipped.len() { + if clipped[start] || visited[start] { + continue; + } + + components += 1; + visited[start] = true; + stack.push(start); + + while let Some(index) = stack.pop() { + let x = index % n; + let y = index / n; + + if x > 0 { + visit_cell(index - 1, clipped, &mut visited, &mut stack); + } + if x + 1 < n { + visit_cell(index + 1, clipped, &mut visited, &mut stack); + } + if y > 0 { + visit_cell(index - n, clipped, &mut visited, &mut stack); + } + if y + 1 < n { + visit_cell(index + n, clipped, &mut visited, &mut stack); + } + } + } + + components + } + + fn visit_cell(index: usize, clipped: &[bool], visited: &mut [bool], stack: &mut Vec) { + if clipped[index] || visited[index] { + return; + } + visited[index] = true; + stack.push(index); + } + + fn rect_path(x0: i32, y0: i32, x1: i32, y1: i32) -> Vec> { + vec![ + IntPoint::new(x0, y0), + IntPoint::new(x1, y0), + IntPoint::new(x1, y1), + IntPoint::new(x0, y1), + ] + } + + fn grid_debug(n: usize, clipped: &[bool]) -> String { + let mut s = String::new(); + for y in (0..n).rev() { + for x in 0..n { + s.push(if clipped[y * n + x] { '#' } else { '.' }); + } + s.push('\n'); + } + s + } + + struct GridRng { + state: u64, + } + + impl GridRng { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0xa076_1d64_78bd_642f, + } + } + + fn percent(&mut self, value: u32) -> bool { + self.next_u32() % 100 < value + } + + fn range_i32(&mut self, min: i32, max: i32) -> i32 { + let width = (max - min + 1) as u32; + min + (self.next_u32() % width) as i32 + } + + fn range_usize(&mut self, min: usize, max: usize) -> usize { + let width = max - min + 1; + min + self.next_u32() as usize % width + } + + fn unit_f64(&mut self) -> f64 { + self.next_u32() as f64 / u32::MAX as f64 + } + + fn next_u32(&mut self) -> u32 { + self.state = self + .state + .wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3); + (self.state >> 32) as u32 + } + } + + fn next_stress_seed(seed: u64) -> u64 { + seed.wrapping_mul(0xe703_7ed1_a0b4_28db) + .wrapping_add(0x8ebc_6af0_9c88_c6e3) + } + + fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + } + + fn panic_message(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + "non-string panic payload".to_string() + } } diff --git a/iOverlay/tests/simplify_tests.rs b/iOverlay/tests/simplify_tests.rs index 345d60c5..e4387f97 100644 --- a/iOverlay/tests/simplify_tests.rs +++ b/iOverlay/tests/simplify_tests.rs @@ -151,9 +151,8 @@ mod tests { let simple = paths.simplify(FillRule::NonZero, op); assert_eq!(simple.len(), 1); - assert_eq!(simple[0].len(), 2); - assert_eq!(simple[0][0].len(), 4); - assert_eq!(simple[0][1].len(), 3); + assert_eq!(simple[0].len(), 1); + assert_eq!(simple[0][0].len(), 7); } fn square(pos: IntPoint) -> Vec {