diff --git a/iOverlay/Cargo.toml b/iOverlay/Cargo.toml index 513428f..9256547 100644 --- a/iOverlay/Cargo.toml +++ b/iOverlay/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "i_overlay" -version = "8.1.1" +version = "8.1.2" authors = ["Nail Sharipov "] edition = "2024" rust-version = "1.88" diff --git a/iOverlay/src/bind/segment.rs b/iOverlay/src/bind/segment.rs index afbcc14..140b889 100644 --- a/iOverlay/src/bind/segment.rs +++ b/iOverlay/src/bind/segment.rs @@ -1,5 +1,5 @@ use crate::geom::v_segment::VSegment; -use crate::vector::edge::{DataVectorEdge, DataVectorPath}; +use crate::vector::edge::DataVectorPath; use alloc::vec::Vec; use i_float::int::number::int::IntNumber; use i_float::int::point::IntPoint; @@ -125,24 +125,37 @@ impl IdSegments for DataVectorPath { x_max: I, clockwise: bool, ) { - fn inner<'a, D: 'a, I: IntNumber + 'a, It: Iterator>>( + fn inner, IntPoint)>>( iter: It, buffer: &mut Vec>, id_data: ContourIndex, x_min: I, x_max: I, ) { - for vec in iter { - if vec.a.x < vec.b.x && x_min < vec.b.x && vec.a.x <= x_max { - buffer.push(IdSegment::::new(id_data, vec.a, vec.b)); + for (a, b) in iter { + if a.x < b.x && x_min < b.x && a.x <= x_max { + buffer.push(IdSegment::::new(id_data, a, b)); } } } if clockwise { - inner(self.iter(), buffer, id_data, x_min, x_max); + // Reversing edge order does not reverse their endpoints. + inner( + self.iter().map(|edge| (edge.b, edge.a)), + buffer, + id_data, + x_min, + x_max, + ); } else { - inner(self.iter().rev(), buffer, id_data, x_min, x_max); + inner( + self.iter().map(|edge| (edge.a, edge.b)), + buffer, + id_data, + x_min, + x_max, + ); } } } diff --git a/iOverlay/src/core/extract.rs b/iOverlay/src/core/extract.rs index 408874b..1989acf 100644 --- a/iOverlay/src/core/extract.rs +++ b/iOverlay/src/core/extract.rs @@ -66,6 +66,19 @@ where &self, overlay_rule: OverlayRule, buffer: &mut BooleanExtractionBuffer, + ) -> IntShapes { + let mut shapes = self.extract_shapes_with_collinear(overlay_rule, buffer); + if !self.options.preserve_output_collinear { + shapes.simplify_contour(); + } + shapes + } + + // Keep shared vertices until every binding step has finished. + fn extract_shapes_with_collinear( + &self, + overlay_rule: OverlayRule, + buffer: &mut BooleanExtractionBuffer, ) -> IntShapes { self.links .filter_by_overlay_into(overlay_rule, &mut buffer.visited); @@ -87,8 +100,8 @@ where buffer: &mut BooleanExtractionBuffer, ) -> FlatShapeHierarchy { let clockwise = self.options.output_direction == ContourDirection::Clockwise; - let shapes = self.extract_shapes(overlay_rule, buffer); - FlatShapeHierarchy::from_shapes(shapes, clockwise) + let shapes = self.extract_shapes_with_collinear(overlay_rule, buffer); + FlatShapeHierarchy::from_shapes(shapes, clockwise, self.options.preserve_output_collinear) } /// Extracts the flat contours from the overlay graph based on the specified overlay rule. @@ -130,7 +143,6 @@ where .reserve(buffer.visited.len().saturating_sub(buffer.points.len())); let mut link_index = 0; - let mut anchors_already_sorted = true; while link_index < buffer.visited.len() { if buffer.visited.is_visited(link_index) { link_index += 1; @@ -160,10 +172,7 @@ where &mut buffer.visited, &mut buffer.points, ); - let (is_valid, is_modified) = buffer.points.validate( - self.options.min_output_area, - self.options.preserve_output_collinear, - ); + let is_valid = buffer.points.validate(self.options.min_output_area); if !is_valid { link_index += 1; @@ -174,15 +183,7 @@ where if is_hole { let left_bottom = if clockwise { contour[1] } else { contour[0] }; - let mut v_segment = contour.left_bottom_segment_from(left_bottom); - - if is_modified { - let most_left = contour.left_bottom_segment(); - if most_left != v_segment { - v_segment = most_left; - anchors_already_sorted = false; - } - }; + let v_segment = contour.left_bottom_segment_from(left_bottom); debug_assert!(v_segment == contour.left_bottom_segment()); let id_data = ContourIndex::new_hole(holes.len()); @@ -193,10 +194,6 @@ where } } - if !anchors_already_sorted { - anchors.sort_unstable_by_key(|s0| s0.v_segment.a); - } - shapes.join_sorted_holes(holes, anchors, clockwise); shapes @@ -277,16 +274,21 @@ where &mut buffer.visited, &mut buffer.points, ); - let (is_valid, _) = buffer.points.validate( - self.options.min_output_area, - self.options.preserve_output_collinear, - ); + let is_valid = buffer.points.validate(self.options.min_output_area); if !is_valid { link_index += 1; continue; } + // Flat output has no binding step. Simplify only when exporting + // the contour, and discard contours that collapse during cleanup. + if !self.options.preserve_output_collinear { + buffer.points.simplify_contour(); + if buffer.points.len() < 3 { + continue; + } + } output.add_contour(buffer.points.as_slice()); } } @@ -321,31 +323,24 @@ impl StartPathData { } pub(crate) trait GraphContour { - fn validate(&mut self, min_output_area: I::WideUInt, preserve_output_collinear: bool) -> (bool, bool); + fn validate(&mut self, min_output_area: I::WideUInt) -> bool; fn push_node_and_get_other(&mut self, link: &OverlayLink, node_id: usize) -> usize; } impl GraphContour for IntContour { #[inline] - fn validate(&mut self, min_output_area: I::WideUInt, preserve_output_collinear: bool) -> (bool, bool) { - let is_modified = if !preserve_output_collinear { - self.simplify_contour() - } else { - false - }; - + fn validate(&mut self, min_output_area: I::WideUInt) -> bool { if self.len() < 3 { - return (false, is_modified); + return false; } if min_output_area == I::WideUInt::ZERO { - return (true, is_modified); + return true; } let area = self.unsafe_area(); let abs_area = area.unsigned_abs() >> 1; - let is_valid = abs_area >= min_output_area; - (is_valid, is_modified) + abs_area >= min_output_area } #[inline] diff --git a/iOverlay/src/core/extract_ogc.rs b/iOverlay/src/core/extract_ogc.rs index e9f6b73..2a433f4 100644 --- a/iOverlay/src/core/extract_ogc.rs +++ b/iOverlay/src/core/extract_ogc.rs @@ -102,7 +102,6 @@ where let mut holes = Vec::with_capacity(hole_count_hint); let mut anchors = Vec::with_capacity(hole_count_hint); - let mut anchors_already_sorted = true; link_index = 0; while link_index < buffer.visited.len() { @@ -134,10 +133,7 @@ where &mut buffer.points, ); - let (is_valid, is_modified) = buffer.points.validate( - self.options.min_output_area, - self.options.preserve_output_collinear, - ); + let is_valid = buffer.points.validate(self.options.min_output_area); if !is_valid { link_index += 1; @@ -146,15 +142,7 @@ where let contour = buffer.points.as_slice().to_vec(); let left_bottom = if is_main_dir_cw { contour[1] } else { contour[0] }; - let mut v_segment = contour.left_bottom_segment_from(left_bottom); - - if is_modified { - let most_left = contour.left_bottom_segment(); - if most_left != v_segment { - v_segment = most_left; - anchors_already_sorted = false; - } - }; + let v_segment = contour.left_bottom_segment_from(left_bottom); debug_assert!(v_segment == contour.left_bottom_segment()); let id_data = ContourIndex::new_hole(holes.len()); @@ -162,10 +150,6 @@ where holes.push(contour); } - if !anchors_already_sorted { - anchors.sort_unstable_by_key(|s0| s0.v_segment.a); - } - shapes.join_sorted_holes(holes, anchors, is_main_dir_cw); } @@ -224,6 +208,7 @@ where // First, mark all edges that belong to the contour. + let mut start_link_id = start_data.link_id; let mut end_link_id = start_data.link_id; global_visited.visit_edge(link_id, VisitState::HullVisited); @@ -263,6 +248,7 @@ where link.a.id }; end_link_id = end_link_id.max(link_id); + start_link_id = start_link_id.min(link_id); contour_visited.visit_edge(link_id, VisitState::Unvisited); global_visited.visit_edge(link_id, VisitState::HullVisited); original_contour_len += 1; @@ -280,10 +266,7 @@ where points, ); - let (is_valid, _) = points.validate( - self.options.min_output_area, - self.options.preserve_output_collinear, - ); + let is_valid = points.validate(self.options.min_output_area); let contour_len = points.len(); @@ -298,7 +281,8 @@ where if contour_len < original_contour_len { // contour has self touches - let mut link_index = start_data.link_id; + let mut link_index = start_link_id; + while link_index <= end_link_id { if contour_visited.is_visited(link_index) { link_index += 1; @@ -318,10 +302,10 @@ where // Self-touch splits can only produce holes inside this contour. - let hole_start_data = StartPathData::new(clockwise, link, left_top_link); + let hole_start_data = StartPathData::new(!clockwise, link, left_top_link); self.find_contour( &hole_start_data, - clockwise, + !clockwise, VisitState::HoleVisited, contour_visited, points, @@ -329,10 +313,7 @@ where // Hole have to belong to this shape. if let Some(shape) = shape.as_mut() { - let (is_valid, _) = points.validate( - self.options.min_output_area, - self.options.preserve_output_collinear, - ); + let is_valid = points.validate(self.options.min_output_area); if !is_valid { link_index += 1; diff --git a/iOverlay/src/core/hierarchy.rs b/iOverlay/src/core/hierarchy.rs index e3b73fd..52bcbf7 100644 --- a/iOverlay/src/core/hierarchy.rs +++ b/iOverlay/src/core/hierarchy.rs @@ -7,6 +7,7 @@ use i_key_sort::sort::two_keys_cmp::TwoKeysAndCmpSort; use i_shape::flat::buffer::FlatShapesBuffer; use i_shape::int::count::PointsCount; use i_shape::int::shape::IntShapes; +use i_shape::int::simple::Simplify; use i_tree::Expiration; /// A direct relationship between a hole contour and a shape nested inside it. @@ -44,8 +45,26 @@ impl FlatShapeHierarchy where I: IntNumber + Expiration + SortKey, { - pub(crate) fn from_shapes(shapes: IntShapes, clockwise: bool) -> Self { + pub(crate) fn from_shapes( + mut shapes: IntShapes, + clockwise: bool, + preserve_output_collinear: bool, + ) -> Self { let links = Self::bind_links(&shapes, clockwise); + if !preserve_output_collinear { + // Extracted contours have non-zero area. Removing collinear + // vertices preserves that area, so no contour or shape disappears + // and all indices in links remain valid. + for shape in &mut shapes { + for contour in shape { + contour.simplify_contour(); + debug_assert!( + contour.len() >= 3, + "non-zero-area contour collapsed during simplification" + ); + } + } + } let shapes = Self::flatten(shapes); Self { shapes, links } diff --git a/iOverlay/src/float/overlay.rs b/iOverlay/src/float/overlay.rs index a02996b..3bb3141 100644 --- a/iOverlay/src/float/overlay.rs +++ b/iOverlay/src/float/overlay.rs @@ -265,6 +265,18 @@ where } } + #[inline] + fn update_adapter(&mut self, adapter: FloatPointAdapter) { + if self.overlay.options.min_output_area != I::WideUInt::ZERO { + let inv_scale = self.adapter.inv_scale().to_f64(); + let area = self.overlay.options.min_output_area.to_f64() * inv_scale * inv_scale; + self.overlay.options.min_output_area = adapter + .round_sqr_len_to_int(P::Scalar::from_float(area)) + .to_uint(); + } + self.adapter = adapter; + } + /// Reinit `FloatOverlay` instance and initializes it with subject and clip shapes. /// - `subj`: A `ShapeResource` that define the subject. /// - `clip`: A `ShapeResource` that define the clip. @@ -272,6 +284,13 @@ where /// - `Contour`: A contour representing a closed path. This path is interpreted as closed, so it doesn’t require the start and endpoint to be the same for processing. /// - `Contours`: A collection of contours, each representing a closed path. /// - `Shapes`: A collection of shapes, where each shape may consist of multiple contours. + /// + /// The current integer `min_output_area` is converted back to float area using + /// the previous adapter, then rounded to the new adapter's integer scale. + /// This approximately preserves the threshold in float units; repeated reinitialization + /// can lose precision. A threshold rounded to zero stays zero on subsequent calls. + /// An overlay created with `new_empty` starts with a zero threshold, so the + /// `min_output_area` originally passed to `new_empty` is not restored. pub fn reinit_with_subj_and_clip(&mut self, subj: &R0, clip: &R1) where R0: ShapeResource

+ ?Sized, @@ -280,7 +299,7 @@ where self.clear(); let iter = subj.iter_paths().chain(clip.iter_paths()).flatten(); - self.adapter = FloatPointAdapter::with_iter(iter); + self.update_adapter(FloatPointAdapter::with_iter(iter)); self.add_source(subj, ShapeType::Subject); self.add_source(clip, ShapeType::Clip); } @@ -291,14 +310,20 @@ where /// - `Contour`: A contour representing a closed path. This path is interpreted as closed, so it doesn’t require the start and endpoint to be the same for processing. /// - `Contours`: A collection of contours, each representing a closed path. /// - `Shapes`: A collection of shapes, where each shape may consist of multiple contours. + /// + /// The current integer `min_output_area` is converted back to float area using + /// the previous adapter, then rounded to the new adapter's integer scale. + /// This approximately preserves the threshold in float units; repeated reinitialization + /// can lose precision. A threshold rounded to zero stays zero on subsequent calls. + /// An overlay created with `new_empty` starts with a zero threshold, so the + /// `min_output_area` originally passed to `new_empty` is not restored. pub fn reinit_with_subj(&mut self, subj: &R) where R: ShapeResource

+ ?Sized, { self.clear(); - let iter = subj.iter_paths().flatten(); - self.adapter = FloatPointAdapter::with_iter(iter); + self.update_adapter(FloatPointAdapter::with_iter(iter)); self.add_source(subj, ShapeType::Subject); } diff --git a/iOverlay/src/string/split.rs b/iOverlay/src/string/split.rs index 90d62fe..a1dcee3 100644 --- a/iOverlay/src/string/split.rs +++ b/iOverlay/src/string/split.rs @@ -55,7 +55,7 @@ impl Split for IntContour { } } - if contour_buffer.len() > 2 { + if contour_buffer.len() > 2 && contour_buffer.validate_area(min_area) { result.push(contour_buffer.as_slice().to_vec()); } @@ -171,7 +171,7 @@ impl ValidateArea for IntContour { return true; } let abs_area = self.unsafe_area().unsigned_abs() >> 1; - abs_area < min_area + abs_area >= min_area } } diff --git a/iOverlay/src/vector/extract.rs b/iOverlay/src/vector/extract.rs index a133173..3f63217 100644 --- a/iOverlay/src/vector/extract.rs +++ b/iOverlay/src/vector/extract.rs @@ -56,7 +56,6 @@ where let mut anchors = Vec::new(); let mut link_index = 0; - let mut anchors_already_sorted = true; while link_index < buffer.visited.len() { if buffer.visited.is_visited(link_index) { link_index += 1; @@ -82,10 +81,7 @@ where let mut contour = self.find_vector_contour(start_data, direction, visited_state, &mut buffer.visited, store); - let (is_valid, is_modified) = contour.validate( - self.options.min_output_area, - self.options.preserve_output_collinear, - ); + let is_valid = contour.validate(self.options.min_output_area); if !is_valid { link_index += 1; @@ -94,16 +90,7 @@ where if is_hole { let left_bottom = if clockwise { contour[1].a } else { contour[0].a }; - let mut v_segment = most_left_bottom_from(&contour, left_bottom); - - if is_modified { - let most_left = most_left_bottom(&contour); - if most_left != v_segment { - v_segment = most_left; - anchors_already_sorted = false; - } - }; - + let v_segment = most_left_bottom_from(&contour, left_bottom); debug_assert!(v_segment == most_left_bottom(&contour)); let id_data = ContourIndex::new_hole(holes.len()); anchors.push(IdSegment::with_segment(id_data, v_segment)); @@ -113,12 +100,12 @@ where } } - if !anchors_already_sorted { - anchors.sort_by_key(|s0| s0.v_segment.a); - } - shapes.join_sorted_holes(holes, anchors, clockwise); + if !self.options.preserve_output_collinear { + shapes.simplify_contour(); + } + shapes } @@ -340,7 +327,7 @@ fn is_sorted(segments: &[IdSegment]) -> bool { } trait DataGraphContour { - fn validate(&mut self, min_output_area: I::WideUInt, preserve_output_collinear: bool) -> (bool, bool); + fn validate(&mut self, min_output_area: I::WideUInt) -> bool; fn push_node_and_get_other( &mut self, link: &OverlayLink, @@ -351,19 +338,13 @@ trait DataGraphContour { impl DataGraphContour for DataVectorPath { #[inline] - fn validate(&mut self, min_output_area: I::WideUInt, preserve_output_collinear: bool) -> (bool, bool) { - let is_modified = if !preserve_output_collinear { - self.simplify_contour() - } else { - false - }; - + fn validate(&mut self, min_output_area: I::WideUInt) -> bool { if self.len() < 3 { - return (false, is_modified); + return false; } if min_output_area == I::WideUInt::ZERO { - return (true, is_modified); + return true; } // A spiral can overflow a partial shoelace sum even though its final @@ -373,7 +354,7 @@ impl DataGraphContour for DataVectorPath acc.wrapping_add(edge.a.cross_product(edge.b)) }); - ((double_area.unsigned_abs() >> 1) >= min_output_area, is_modified) + (double_area.unsigned_abs() >> 1) >= min_output_area } #[inline] diff --git a/iOverlay/tests/float_reinit_tests.rs b/iOverlay/tests/float_reinit_tests.rs new file mode 100644 index 0000000..e373a42 --- /dev/null +++ b/iOverlay/tests/float_reinit_tests.rs @@ -0,0 +1,58 @@ +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; +use i_overlay::float::overlay::{FloatOverlay, OverlayOptions}; + +fn square(side: f64) -> [[f64; 2]; 4] { + [[0.0, 0.0], [side, 0.0], [side, side], [0.0, side]] +} + +fn area_options() -> OverlayOptions { + let mut options = OverlayOptions::default(); + options.min_output_area = 2.0; + options +} + +#[test] +fn reinit_subject_preserves_minimum_area_in_float_units() { + for (old_side, new_side, expected_count) in [(1.0, 4.0, 1), (4.0, 1.0, 0)] { + let options = area_options(); + let subject = square(new_side); + let mut reused = FloatOverlay::with_subj_custom(&square(old_side), options, Solver::AUTO); + reused.reinit_with_subj(&subject); + + let expected = FloatOverlay::with_subj_custom(&subject, options, Solver::AUTO) + .overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(expected.len(), expected_count); + assert_eq!( + reused.overlay(OverlayRule::Subject, FillRule::NonZero), + expected, + "reinitializing from side {old_side} to {new_side} must retain the area threshold" + ); + } +} + +#[test] +fn reinit_subject_and_clip_preserves_minimum_area_in_float_units() { + for (old_side, new_side, expected_count) in [(1.0, 4.0, 1), (4.0, 1.0, 0)] { + let options = area_options(); + let subject = square(new_side); + let clip = square(new_side / 2.0); + let mut reused = FloatOverlay::with_subj_and_clip_custom( + &square(old_side), + &square(old_side / 2.0), + options, + Solver::AUTO, + ); + reused.reinit_with_subj_and_clip(&subject, &clip); + + let expected = FloatOverlay::with_subj_and_clip_custom(&subject, &clip, options, Solver::AUTO) + .overlay(OverlayRule::Union, FillRule::NonZero); + assert_eq!(expected.len(), expected_count); + assert_eq!( + reused.overlay(OverlayRule::Union, FillRule::NonZero), + expected, + "reinitializing from side {old_side} to {new_side} must retain the area threshold" + ); + } +} diff --git a/iOverlay/tests/hierarchy_touch_tests.rs b/iOverlay/tests/hierarchy_touch_tests.rs new file mode 100644 index 0000000..44aaaef --- /dev/null +++ b/iOverlay/tests/hierarchy_touch_tests.rs @@ -0,0 +1,94 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::hierarchy::ChildLink; +use i_overlay::core::overlay::{ContourDirection, IntOverlayOptions, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn contour(points: &[[i32; 2]]) -> Vec { + points.iter().map(|p| IntPoint::new(p[0], p[1])).collect() +} + +#[test] +fn hierarchy_preserves_links_and_honors_collinear_output_option() { + let subject = vec![ + contour(&[[0, 0], [10, 0], [20, 0], [20, 20], [0, 20]]), + contour(&[[2, 2], [2, 10], [2, 18], [18, 18], [18, 2]]), + contour(&[[4, 4], [6, 4], [8, 4], [8, 8], [4, 8]]), + ]; + for ogc in [false, true] { + for preserve in [false, true] { + for direction in [ContourDirection::CounterClockwise, ContourDirection::Clockwise] { + let options = IntOverlayOptions { + preserve_input_collinear: true, + preserve_output_collinear: preserve, + output_direction: direction, + ogc, + ..Default::default() + }; + let mut overlay = Overlay::with_contours_custom(&subject, &[], options, Default::default()); + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + + assert_eq!(hierarchy.shapes.to_shapes(), shapes); + assert_eq!(hierarchy.shapes.shape_ranges, vec![0..2, 2..3]); + assert!( + hierarchy + .shapes + .contour_ranges + .iter() + .all(|r| r.len() == if preserve { 5 } else { 4 }) + ); + assert_eq!( + hierarchy.links, + vec![ChildLink { + parent_shape_index: 0, + parent_contour_index: 1, + child_shape_index: 1, + }] + ); + } + } + } +} + +#[test] +fn island_touching_hole_boundary_keeps_its_parent() { + for clockwise in [false, true] { + for island in [ + [[2, 10], [6, 8], [8, 10], [6, 12]], + [[10, 2], [12, 6], [10, 8], [8, 6]], + [[18, 10], [14, 12], [12, 10], [14, 8]], + [[10, 18], [8, 14], [10, 12], [12, 14]], + ] { + let subject = vec![ + contour(&[[0, 0], [20, 0], [20, 20], [0, 20]]), + contour(&[[2, 2], [2, 18], [18, 18], [18, 2]]), + contour(&island), + ]; + let mut overlay = Overlay::with_contours(&subject, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let hierarchy = overlay.overlay_hierarchy(OverlayRule::Subject, FillRule::NonZero); + assert_eq!( + hierarchy.shapes.shape_ranges.len(), + 2, + "island={island:?}, clockwise={clockwise}, hierarchy={hierarchy:?}" + ); + assert_eq!( + hierarchy.links.len(), + 1, + "island={island:?}, clockwise={clockwise}, hierarchy={hierarchy:?}" + ); + let link = hierarchy.links[0]; + let parent = &hierarchy.shapes.shape_ranges[link.parent_shape_index]; + let child = &hierarchy.shapes.shape_ranges[link.child_shape_index]; + assert_eq!(parent.len(), 2); + assert_eq!(child.len(), 1); + assert_eq!(link.parent_contour_index, parent.start + 1); + } + } +} diff --git a/iOverlay/tests/issue_91_tests.rs b/iOverlay/tests/issue_91_tests.rs new file mode 100644 index 0000000..fe49e70 --- /dev/null +++ b/iOverlay/tests/issue_91_tests.rs @@ -0,0 +1,251 @@ +//! Regressions from https://github.com/iShape-Rust/iOverlay/issues/91. +//! Collinear output simplification must not change hole ownership or hierarchy. + +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_shape::int::shape::IntShapes; +use i_shape::int::simple::Simplify; + +const PANIC_INPUT: &[&[[i32; 2]]] = &[ + &[[4079, 3454], [4090, 3462], [4083, 3471], [4073, 3464]], + &[[4078, 3464], [4084, 3463], [4081, 3460]], + &[[4072, 3463], [4080, 3471], [4061, 3471]], +]; + +const OWNER_INPUT: &[&[[i32; 2]]] = &[ + &[[-4, 3], [-7, 6], [-4, 6]], + &[[-4, 2], [-1, 5], [-4, 5]], + &[[-3, 2], [-10, 5], [-6, 5]], + &[[-3, 0], [-4, 1], [-1, 0]], + &[[-3, 2], [-3, 5], [-2, 5]], + &[[-4, 2], [-4, 4], [0, 4]], + &[[-2, 1], [-5, 1], [-2, 4]], +]; + +const HIERARCHY_INPUT: &[&[[i32; 2]]] = &[ + &[[-3, -1], [-3, -4], [-7, -4]], + &[[0, -4], [-1, -4], [-1, 0]], + &[[0, -4], [-3, -7], [-3, -4]], + &[[0, -2], [-1, -3], [-1, -1]], + &[[-1, -1], [-2, -2], [-2, -1]], + &[[-3, -4], [-3, -7], [-6, -4]], + &[[0, -2], [-3, -5], [-3, -2]], +]; + +fn contour(points: &[[i32; 2]]) -> Vec { + points.iter().map(|&[x, y]| IntPoint::new(x, y)).collect() +} + +fn overlay( + input: &[&[[i32; 2]]], + squares: usize, + ogc: bool, + preserve_collinear: bool, + clockwise: bool, +) -> Overlay { + let mut contours: Vec<_> = input.iter().map(|p| contour(p)).collect(); + // Disjoint squares force the hole binder across its list/tree threshold. + for i in 0..squares { + let x = 100 + 40 * i as i32; + contours.push(contour(&[[x, 100], [x + 10, 100], [x + 10, 110], [x, 110]])); + } + let mut overlay = Overlay::with_contours(&contours, &[]); + overlay.options.ogc = ogc; + overlay.options.preserve_output_collinear = preserve_collinear; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + overlay +} + +// Compare geometry independently of start vertex, winding, and retained +// collinear vertices. These tests check binding for both output options. +fn canonical(path: &[IntPoint]) -> Vec { + let mut path = path.to_vec(); + path.simplify_contour(); + assert!(path.len() >= 3, "unexpected degenerate contour: {path:?}"); + let start = path.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + path.rotate_left(start); + if path[1] > path[path.len() - 1] { + path[1..].reverse(); + } + path +} + +fn assert_hole_owner(shapes: &IntShapes, hole: &[[i32; 2]], outer: &[[i32; 2]]) { + let hole = canonical(&contour(hole)); + let owners: Vec<_> = shapes + .iter() + .filter(|shape| shape.iter().skip(1).any(|p| canonical(p) == hole)) + .collect(); + assert_eq!(owners.len(), 1, "expected exactly one owner: {shapes:?}"); + assert_eq!( + canonical(&owners[0][0]), + canonical(&contour(outer)), + "hole attached to the wrong shape: {shapes:?}" + ); + assert_eq!(shapes.iter().map(|s| s.len() - 1).sum::(), 1); +} + +fn check_panic_case(ogc: bool, preserve_collinear: bool, vectors: bool) { + for clockwise in [false, true] { + for squares in [29, 30] { + let mut overlay = overlay(PANIC_INPUT, squares, ogc, preserve_collinear, clockwise); + let shapes = if vectors { + overlay + .build_shape_vectors(FillRule::NonZero, OverlayRule::Union) + .into_iter() + .map(|s| { + s.into_iter() + .map(|p| p.into_iter().map(|e| e.a).collect()) + .collect() + }) + .collect() + } else { + overlay.overlay(OverlayRule::Union, FillRule::NonZero) + }; + assert_eq!(shapes.len(), squares + 2); + assert_hole_owner(&shapes, PANIC_INPUT[1], PANIC_INPUT[0]); + } + } +} + +fn check_owner_case(ogc: bool, preserve_collinear: bool, vectors: bool) { + for clockwise in [false, true] { + for squares in [0, 31] { + let mut overlay = overlay(OWNER_INPUT, squares, ogc, preserve_collinear, clockwise); + let shapes = if vectors { + overlay + .build_shape_vectors(FillRule::NonZero, OverlayRule::Union) + .into_iter() + .map(|s| { + s.into_iter() + .map(|p| p.into_iter().map(|e| e.a).collect()) + .collect() + }) + .collect() + } else { + overlay.overlay(OverlayRule::Union, FillRule::NonZero) + }; + assert_eq!(shapes.len(), squares + 2); + assert_hole_owner( + &shapes, + &[[-4, 3], [-4, 4], [-3, 2]], + &[ + [-6, 5], + [-10, 5], + [-4, 2], + [-5, 1], + [-2, 1], + [-2, 3], + [0, 4], + [-2, 4], + [-3, 3], + [-3, 5], + [-4, 5], + [-4, 6], + [-7, 6], + ], + ); + } + } +} + +fn check_hierarchy_case(ogc: bool, preserve_collinear: bool) { + for clockwise in [false, true] { + let hierarchy = overlay(HIERARCHY_INPUT, 0, ogc, preserve_collinear, clockwise) + .overlay_hierarchy(OverlayRule::Union, FillRule::NonZero); + let shapes = hierarchy.shapes.to_shapes(); + assert_eq!(shapes.len(), 2); + let triangle = canonical(&contour(HIERARCHY_INPUT[4])); + let triangle = shapes.iter().find(|s| canonical(&s[0]) == triangle).unwrap(); + assert_eq!(triangle.len(), 1); + assert_eq!(shapes.iter().map(|s| s.len() - 1).sum::(), 1); + assert!( + hierarchy.links.is_empty(), + "the separate triangle must have no parent: {hierarchy:?}" + ); + } +} + +mod ordinary { + #[test] + fn touching_shapes_bind_hole_across_tree_threshold() { + super::check_panic_case(false, false, false); + } + + #[test] + fn hole_stays_with_containing_shape() { + super::check_owner_case(false, false, false); + } + + #[test] + fn separate_triangle_has_no_hierarchy_parent() { + super::check_hierarchy_case(false, false); + } +} + +mod ogc { + #[test] + fn touching_shapes_bind_hole_across_tree_threshold() { + super::check_panic_case(true, false, false); + } + + #[test] + fn hole_stays_with_containing_shape() { + super::check_owner_case(true, false, false); + } + + #[test] + fn separate_triangle_has_no_hierarchy_parent() { + super::check_hierarchy_case(true, false); + } +} + +mod ordinary_preserved { + #[test] + fn touching_shapes_bind_hole_across_tree_threshold() { + super::check_panic_case(false, true, false); + } + + #[test] + fn hole_stays_with_containing_shape() { + super::check_owner_case(false, true, false); + } + + #[test] + fn separate_triangle_has_no_hierarchy_parent() { + super::check_hierarchy_case(false, true); + } +} + +mod ogc_preserved { + #[test] + fn touching_shapes_bind_hole_across_tree_threshold() { + super::check_panic_case(true, true, false); + } + + #[test] + fn hole_stays_with_containing_shape() { + super::check_owner_case(true, true, false); + } + + #[test] + fn separate_triangle_has_no_hierarchy_parent() { + super::check_hierarchy_case(true, true); + } +} + +#[test] +fn vector_touching_shapes_bind_hole_across_tree_threshold() { + check_panic_case(false, false, true); +} + +#[test] +fn vector_hole_stays_with_containing_shape() { + check_owner_case(false, false, true); +} diff --git a/iOverlay/tests/ogc_hole_orientation_tests.rs b/iOverlay/tests/ogc_hole_orientation_tests.rs new file mode 100644 index 0000000..71e3e0a --- /dev/null +++ b/iOverlay/tests/ogc_hole_orientation_tests.rs @@ -0,0 +1,226 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::core::solver::Solver; + +fn touching_hole() -> Vec { + // Outer area 6, hole area 1. The hole touches the leftmost outer vertex. + [[0, 1], [-1, 0], [0, 0], [0, -2], [-1, 0], [0, -3], [2, -1]] + .map(|p| IntPoint::new(p[0], p[1])) + .to_vec() +} + +fn double_area(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +fn check_hole_direction(clockwise: bool) { + for shift in 0..7 { + let mut path = touching_hole(); + path.rotate_left(shift); + let mut overlay = Overlay::with_contour(&path, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 2); + assert_eq!(double_area(&shapes[0][0]) < 0, clockwise); + assert_eq!( + double_area(&shapes[0][1]) > 0, + clockwise, + "shift={shift}, shapes={shapes:?}" + ); + let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!(area, if clockwise { -10 } else { 10 }); + } +} + +#[test] +fn ogc_leftmost_touching_hole_has_opposite_winding() { + check_hole_direction(false); +} + +#[test] +fn clockwise_ogc_leftmost_touching_hole_has_opposite_winding() { + check_hole_direction(true); +} + +#[test] +fn ogc_leftmost_touching_hole_survives_nonzero_roundtrip() { + let mut overlay = Overlay::with_contour(&touching_hole(), &[]); + overlay.options.ogc = true; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + let contours: Vec<_> = shapes.into_iter().flatten().collect(); + let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!( + area, 10, + "area must remain 6 - 1 after reusing OGC output: {result:?}" + ); +} + +#[test] +fn ordinary_extraction_preserves_leftmost_touching_hole_area() { + let shapes = + Overlay::with_contour(&touching_hole(), &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!(area, 10); +} + +fn holes_at_leftmost_vertex(count: usize) -> Vec> { + let mut contours = vec![vec![ + IntPoint::new(0, 0), + IntPoint::new(48, -48), + IntPoint::new(48, 48), + ]]; + for i in 0..count { + let bottom = -20 + 5 * i as i32; + let top = bottom + 1 + (i % 3) as i32; + // Disjoint angular sectors, separated by filled wedges. Every hole + // shares exactly the outer contour's unique leftmost vertex. + contours.push(vec![ + IntPoint::new(0, 0), + IntPoint::new(24, top), + IntPoint::new(24, bottom), + ]); + } + contours +} + +fn canonical_contours(mut contours: Vec>) -> Vec> { + for contour in &mut contours { + let start = contour.iter().enumerate().min_by_key(|(_, p)| **p).unwrap().0; + contour.rotate_left(start); + } + contours.sort(); + contours +} + +fn check_shared_leftmost_holes(input: &[Vec], expected: &[Vec], case: &str) { + let expected_area: i64 = expected.iter().map(|p| double_area(p)).sum(); + for solver in [Solver::LIST, Solver::TREE, Solver::FRAG] { + for fill in [FillRule::EvenOdd, FillRule::NonZero] { + for clockwise in [false, true] { + let mut overlay = Overlay::with_contours(input, &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + overlay.solver = solver; + let shapes = overlay.overlay(OverlayRule::Subject, fill); + let context = format!( + "{case}, solver={:?}, fill={fill:?}, clockwise={clockwise}", + solver.strategy + ); + assert_eq!(shapes.len(), 1, "{context}: {shapes:?}"); + assert_eq!(shapes[0].len(), expected.len(), "{context}: {shapes:?}"); + for (i, contour) in shapes[0].iter().enumerate() { + assert_eq!( + double_area(contour) < 0, + clockwise != (i > 0), + "{context}: {contour:?}" + ); + } + let area: i64 = shapes[0].iter().map(|p| double_area(p)).sum(); + assert_eq!( + area, + if clockwise { -expected_area } else { expected_area }, + "{context}" + ); + let mut expected_direction = expected.to_vec(); + if clockwise { + for contour in &mut expected_direction { + contour.reverse(); + } + } + // Exact boundaries catch missing, duplicated, or incorrectly split holes. + assert_eq!( + canonical_contours(shapes[0].clone()), + canonical_contours(expected_direction), + "{context}" + ); + + let mut reused = Overlay::with_contours(&shapes[0], &[]); + reused.options.ogc = true; + reused.solver = solver; + let roundtrip = reused.overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(roundtrip.len(), 1, "roundtrip: {context}"); + assert_eq!( + canonical_contours(roundtrip[0].clone()), + canonical_contours(expected.to_vec()), + "roundtrip: {context}" + ); + } + } + } +} + +#[test] +fn ogc_multiple_holes_share_the_outer_leftmost_vertex() { + for count in [2, 3, 8] { + let expected = holes_at_leftmost_vertex(count); + for reversed in [false, true] { + for shift in 0..expected.len() { + let mut input = expected.clone(); + input.rotate_left(shift); + if reversed { + input.reverse(); + } + // Change the starting vertex of each separate contour as well. + for (i, contour) in input.iter_mut().enumerate() { + contour.rotate_left((shift + i) % 3); + } + check_shared_leftmost_holes( + &input, + &expected, + &format!("holes={count}, shift={shift}, reversed_order={reversed}"), + ); + } + } + } +} + +#[test] +fn ogc_one_contour_visits_multiple_holes_at_its_leftmost_vertex() { + for count in [2, 3, 8] { + let expected = holes_at_leftmost_vertex(count); + for reversed_hole_order in [false, true] { + let mut holes = expected[1..].to_vec(); + if reversed_hole_order { + holes.reverse(); + } + let mut path = vec![IntPoint::new(0, 0)]; + for hole in &holes { + path.extend_from_slice(&hole[1..]); + path.push(IntPoint::new(0, 0)); + } + path.extend_from_slice(&expected[0][1..]); + for reversed_winding in [false, true] { + let mut input = path.clone(); + if reversed_winding { + input.reverse(); + } + for shift in 0..input.len() { + check_shared_leftmost_holes( + &[input.clone()], + &expected, + &format!( + "holes={count}, shift={shift}, reversed_hole_order={reversed_hole_order}, reversed_winding={reversed_winding}" + ), + ); + input.rotate_left(1); + } + } + } + } +} diff --git a/iOverlay/tests/ogc_traversal_tests.rs b/iOverlay/tests/ogc_traversal_tests.rs new file mode 100644 index 0000000..f082e19 --- /dev/null +++ b/iOverlay/tests/ogc_traversal_tests.rs @@ -0,0 +1,92 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::Overlay; +use i_overlay::core::overlay_rule::OverlayRule; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn ogc_contour_marking_completes_a_closed_tour() { + const CHILD_ENV: &str = "I_OVERLAY_OGC_CLOSED_TOUR_CHILD"; + if std::env::var_os(CHILD_ENV).is_some() { + let paths = [ + [ + [8, -14], + [-12, -13], + [7, -10], + [1, -3], + [-14, 8], + [6, -16], + [8, 20], + [7, -20], + [-7, 6], + [11, -6], + ], + [ + [-17, -2], + [-11, -15], + [-18, 2], + [-6, 19], + [15, 3], + [-2, -19], + [-6, -4], + [11, -14], + [17, 10], + [-19, -17], + ], + [ + [8, -18], + [-8, -11], + [9, 3], + [11, -5], + [-16, 1], + [-1, -1], + [-2, 13], + [0, -11], + [-17, -18], + [-18, 12], + ], + ] + .map(|path| path.map(|p| IntPoint::new(p[0], p[1])).to_vec()); + let mut overlay = Overlay::with_contours(&paths, &[]); + overlay.options.ogc = true; + assert!( + !overlay + .overlay(OverlayRule::Subject, FillRule::EvenOdd) + .is_empty() + ); + return; + } + + // An unreachable final edge can keep the marking loop running forever. + // Run the regression in a child so failure does not hang the test suite. + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "ogc_contour_marking_completes_a_closed_tour", + "--nocapture", + ]) + .env(CHILD_ENV, "1") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if child.try_wait().unwrap().is_some() { + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("OGC extraction of 30 input vertices did not finish within 3 seconds"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} diff --git a/iOverlay/tests/ogc_winding_tests.rs b/iOverlay/tests/ogc_winding_tests.rs new file mode 100644 index 0000000..5f6dc29 --- /dev/null +++ b/iOverlay/tests/ogc_winding_tests.rs @@ -0,0 +1,79 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn touching_hole() -> Vec { + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 5], + [2, 7], + [4, 5], + [2, 3], + [0, 5], + ] + .map(|p| IntPoint::new(p[0], p[1])) + .to_vec() +} + +fn double_area(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +#[test] +fn ogc_touching_hole_has_opposite_winding() { + check_touching_hole(false); +} + +#[test] +fn clockwise_ogc_touching_hole_has_opposite_winding() { + check_touching_hole(true); +} + +fn check_touching_hole(clockwise: bool) { + let mut overlay = Overlay::with_contour(&touching_hole(), &[]); + overlay.options.ogc = true; + overlay.options.output_direction = if clockwise { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 1); + assert_eq!(shapes[0].len(), 2); + assert_eq!(double_area(&shapes[0][0]) > 0, !clockwise); + assert_eq!( + double_area(&shapes[0][1]) > 0, + clockwise, + "clockwise={clockwise}, shapes={shapes:?}" + ); +} + +#[test] +fn ordinary_extraction_preserves_touching_hole_area() { + let shapes = + Overlay::with_contour(&touching_hole(), &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = shapes.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!(area, 184); +} + +#[test] +fn ogc_output_preserves_holes_when_reused_with_nonzero_fill() { + let path = touching_hole(); + let mut overlay = Overlay::with_contour(&path, &[]); + overlay.options.ogc = true; + let shapes = overlay.overlay(OverlayRule::Subject, FillRule::NonZero); + let contours: Vec<_> = shapes.into_iter().flatten().collect(); + let result = Overlay::with_contours(&contours, &[]).overlay(OverlayRule::Subject, FillRule::NonZero); + let area: i64 = result.iter().flatten().map(|p| double_area(p)).sum(); + assert_eq!( + area, 184, + "the area must remain 100 - 8 after reusing OGC output; result={result:?}" + ); +} diff --git a/iOverlay/tests/output_consistency_tests.rs b/iOverlay/tests/output_consistency_tests.rs new file mode 100644 index 0000000..fa27cdd --- /dev/null +++ b/iOverlay/tests/output_consistency_tests.rs @@ -0,0 +1,87 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, Overlay}; +use i_overlay::core::overlay_rule::OverlayRule; +use i_shape::flat::buffer::FlatContoursBuffer; + +fn canonical(mut paths: Vec>) -> Vec> { + for path in &mut paths { + let start = (0..path.len()) + .min_by(|&a, &b| { + (0..path.len()) + .map(|j| path[(a + j) % path.len()]) + .cmp((0..path.len()).map(|j| path[(b + j) % path.len()])) + }) + .unwrap(); + path.rotate_left(start); + } + paths.sort(); + paths +} + +#[test] +fn flat_and_vector_outputs_match_shapes_with_area_filtering() { + let mut seed = 0x61ab_117c_0018_23ad_u64; + let mut next = || { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((seed >> 32) % 41) as i32 - 20 + }; + for case in 0..1000 { + let a: Vec<_> = (0..3 + case % 8).map(|_| IntPoint::new(next(), next())).collect(); + let b: Vec<_> = (0..3 + case % 9).map(|_| IntPoint::new(next(), next())).collect(); + for threshold in [0, 1, 10, 50] { + let mut regular = Overlay::with_contour(&a, &b); + regular.options.min_output_area = threshold; + regular.options.preserve_output_collinear = case % 2 == 0; + regular.options.output_direction = if case % 3 == 0 { + ContourDirection::Clockwise + } else { + ContourDirection::CounterClockwise + }; + for rule in [ + OverlayRule::Union, + OverlayRule::Intersect, + OverlayRule::Difference, + OverlayRule::Xor, + ] { + let fill = [ + FillRule::EvenOdd, + FillRule::NonZero, + FillRule::Positive, + FillRule::Negative, + ][case % 4]; + let shapes = regular.overlay(rule, fill); + let expected = canonical(shapes.into_iter().flatten().collect()); + let mut flat = FlatContoursBuffer::default(); + regular.overlay_into(rule, fill, &mut flat); + let actual = canonical( + flat.ranges + .iter() + .map(|r| flat.points[r.clone()].to_vec()) + .collect(), + ); + assert_eq!( + actual, expected, + "flat: case={case}, threshold={threshold}, rule={rule:?}, a={a:?}, b={b:?}" + ); + let shapes = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + regular.build_shape_vectors(fill, rule) + })) + .unwrap_or_else(|_| { + panic!("vector extraction panicked: case={case}, threshold={threshold}, rule={rule:?}, fill={fill:?}, a={a:?}, b={b:?}") + }); + let actual = canonical( + shapes + .into_iter() + .flatten() + .map(|p| p.into_iter().map(|e| e.a).collect()) + .collect(), + ); + assert_eq!( + actual, expected, + "vectors: case={case}, threshold={threshold}, rule={rule:?}, a={a:?}, b={b:?}" + ); + } + } + } +} diff --git a/iOverlay/tests/slice_area_tests.rs b/iOverlay/tests/slice_area_tests.rs new file mode 100644 index 0000000..f4715fe --- /dev/null +++ b/iOverlay/tests/slice_area_tests.rs @@ -0,0 +1,115 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::IntOverlayOptions; +use i_overlay::float::overlay::OverlayOptions; +use i_overlay::float::string_overlay::FloatStringOverlay; +use i_overlay::string::overlay::StringOverlay; +use i_overlay::string::rule::StringRule; + +fn square() -> [IntPoint; 4] { + [ + IntPoint::new(0, 0), + IntPoint::new(10, 0), + IntPoint::new(10, 10), + IntPoint::new(0, 10), + ] +} + +fn area_two(path: &[IntPoint]) -> i64 { + path.iter() + .zip(path.iter().cycle().skip(1)) + .map(|(a, b)| i64::from(a.x) * i64::from(b.y) - i64::from(a.y) * i64::from(b.x)) + .sum() +} + +#[test] +fn slice_minimum_area_removes_small_piece() { + let mut overlay = StringOverlay::with_shape_contour(&square()); + overlay.add_string_line([IntPoint::new(1, -1), IntPoint::new(1, 11)]); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let unfiltered = graph.extract_shapes(StringRule::Slice); + let mut areas: Vec<_> = unfiltered.iter().map(|s| area_two(&s[0])).collect(); + areas.sort(); + assert_eq!(areas, [20, 180]); + + for (threshold, expected) in [ + (0, vec![20, 180]), + (10, vec![20, 180]), + (11, vec![180]), + (50, vec![180]), + (90, vec![180]), + (91, vec![]), + ] { + let mut options = IntOverlayOptions::default(); + options.min_output_area = threshold; + let filtered = graph.extract_shapes_custom(StringRule::Slice, options); + let mut areas: Vec<_> = filtered.iter().map(|s| area_two(&s[0])).collect(); + areas.sort(); + assert_eq!(areas, expected, "minimum area {threshold} must include equality"); + } +} + +#[test] +fn slice_minimum_area_above_subject_area_returns_empty() { + let mut overlay = StringOverlay::with_shape_contour(&square()); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let mut options = IntOverlayOptions::default(); + options.min_output_area = 1000; + let result = graph.extract_shapes_custom(StringRule::Slice, options); + assert!( + result.is_empty(), + "a square of area 100 must be filtered out at threshold 1000" + ); +} + +#[test] +fn slice_small_area_threshold_preserves_large_loops_and_holes() { + let mut overlay = StringOverlay::with_shape_contour(&square()); + overlay.add_string_path(&[ + IntPoint::new(0, 0), + IntPoint::new(3, 3), + IntPoint::new(7, 3), + IntPoint::new(7, 7), + IntPoint::new(3, 7), + IntPoint::new(3, 3), + ]); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let expected = graph.extract_shapes(StringRule::Slice); + assert_eq!(expected.len(), 2); + let total_area: i64 = expected.iter().flatten().map(|p| area_two(p)).sum(); + assert_eq!(total_area, 200); + for threshold in [1, 16] { + let mut options = IntOverlayOptions::default(); + options.min_output_area = threshold; + let actual = graph.extract_shapes_custom(StringRule::Slice, options); + assert_eq!( + actual, expected, + "areas 16 and 84 must survive minimum area {threshold}" + ); + } +} + +#[test] +fn float_slice_small_area_threshold_preserves_large_loops_and_holes() { + let subject = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + let cut = [ + [0.0, 0.0], + [3.0, 3.0], + [7.0, 3.0], + [7.0, 7.0], + [3.0, 7.0], + [3.0, 3.0], + ]; + let mut overlay = FloatStringOverlay::with_shape_and_string_fixed_scale(&subject, &cut, 100.0).unwrap(); + let graph = overlay.build_graph_view(FillRule::NonZero).unwrap(); + let expected = graph.extract_shapes(StringRule::Slice); + assert_eq!(expected.len(), 2); + assert_eq!(expected.iter().map(|s| s.len() - 1).sum::(), 1); + let mut options = OverlayOptions::default(); + options.min_output_area = 1.0; + let actual = graph.extract_shapes_custom(StringRule::Slice, options); + assert_eq!( + actual, expected, + "a low area threshold must preserve the annulus and its inner piece" + ); +} diff --git a/iOverlay/tests/vector_hole_binding_tests.rs b/iOverlay/tests/vector_hole_binding_tests.rs new file mode 100644 index 0000000..cedbbb4 --- /dev/null +++ b/iOverlay/tests/vector_hole_binding_tests.rs @@ -0,0 +1,43 @@ +use i_float::int::point::IntPoint; +use i_overlay::core::edge_overlay::{EdgeOverlay, InputEdge}; +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay::{ContourDirection, ShapeType}; +use i_overlay::core::overlay_rule::OverlayRule; + +fn check_hole_binding(direction: ContourDirection) { + let contours = [ + [[0, 0], [10, 0], [10, 10], [0, 10]], + [[2, 2], [2, 8], [8, 8], [8, 2]], + [[20, 0], [30, 0], [30, 10], [20, 10]], + ]; + let mut overlay = EdgeOverlay::::new(12); + overlay.options.output_direction = direction; + for contour in contours { + for (a, b) in contour.iter().zip(contour.iter().cycle().skip(1)) { + overlay.add_edge( + InputEdge { + a: IntPoint::new(a[0], a[1]), + b: IntPoint::new(b[0], b[1]), + data: (), + }, + ShapeType::Subject, + ); + } + } + let shapes = overlay.build_vector_shapes(OverlayRule::Subject, FillRule::NonZero); + assert_eq!(shapes.len(), 2); + let left = shapes.iter().find(|s| s[0].iter().any(|e| e.a.x == 0)).unwrap(); + let right = shapes.iter().find(|s| s[0].iter().any(|e| e.a.x == 20)).unwrap(); + assert_eq!(left.len(), 2, "the left square must own the hole"); + assert_eq!(right.len(), 1, "the right square has no hole"); +} + +#[test] +fn counterclockwise_vector_output_binds_hole_to_its_shape() { + check_hole_binding(ContourDirection::CounterClockwise); +} + +#[test] +fn clockwise_vector_output_binds_hole_to_its_shape() { + check_hole_binding(ContourDirection::Clockwise); +}