diff --git a/iOverlay/src/bind/segment.rs b/iOverlay/src/bind/segment.rs index b1a95de7..8578e001 100644 --- a/iOverlay/src/bind/segment.rs +++ b/iOverlay/src/bind/segment.rs @@ -12,6 +12,15 @@ pub(crate) struct ContourIndex { impl ContourIndex { pub(crate) const EMPTY: ContourIndex = ContourIndex { data: usize::MAX }; + /// True when this is the sentinel returned by `ScanHoleStore::first_less` + /// when no segment to the left of the query was found. Must be checked + /// before calling `is_hole()` / `index()`, whose outputs are bogus for + /// the sentinel. + #[inline] + pub(crate) fn is_empty(&self) -> bool { + self.data == usize::MAX + } + #[inline] pub(crate) fn is_hole(&self) -> bool { self.data & 1 == 1 diff --git a/iOverlay/src/bind/solver.rs b/iOverlay/src/bind/solver.rs index c10fbdf5..911563a0 100644 --- a/iOverlay/src/bind/solver.rs +++ b/iOverlay/src/bind/solver.rs @@ -53,6 +53,17 @@ impl ShapeBinder { segments: Vec, ) -> BindSolution { let children_count = anchors.len(); + + // With no parent shapes there is nothing to bind children to. Return + // a trivially empty solution rather than indexing `children_count_for_parent` + // below (which would be zero-length and OOB on every child). + if shape_count == 0 { + return BindSolution { + parent_for_child: vec![0; children_count], + children_count_for_parent: Vec::new(), + }; + } + let mut parent_for_child = { #[cfg(debug_assertions)] { @@ -84,6 +95,24 @@ impl ShapeBinder { } let target_id = scan_list.first_less(anchor.v_segment.a.x, ContourIndex::EMPTY, anchor.v_segment); + let child_index = anchor.contour_index.index(); + + // `first_less` returns `ContourIndex::EMPTY` (data == usize::MAX) when + // nothing lies to the left of the anchor. Its low bit is 1, so + // `is_hole()` would return true and `index()` would return + // usize::MAX >> 1 — both meaningless. Without this guard the + // `is_hole` branch below would index `parent_for_child[usize::MAX >> 1]`, + // which panics on large, topologically-tangled inputs. When the + // anchor has no enclosing parent (malformed / ambiguous input), + // fall back to attaching it to shape 0 rather than crashing. + // `shape_count > 0` is guaranteed by the early return at function + // entry, so `children_count_for_parent[0]` is in-bounds. + if target_id.is_empty() { + parent_for_child[child_index] = 0; + children_count_for_parent[0] += 1; + continue; + } + let parent_index = if target_id.is_hole() { // index is a hole index // at this moment this hole parent is known @@ -92,8 +121,6 @@ impl ShapeBinder { target_id.index() }; - let child_index = anchor.contour_index.index(); - parent_for_child[child_index] = parent_index; children_count_for_parent[parent_index] += 1; } @@ -268,12 +295,71 @@ impl SortByAngle for [IdSegment] { #[cfg(test)] mod tests { - use crate::bind::solver::JoinHoles; + use crate::bind::segment::{ContourIndex, IdSegment}; + use crate::bind::solver::{JoinHoles, ShapeBinder}; use crate::geom::v_segment::VSegment; use alloc::vec; use core::cmp::Ordering; use i_float::int::point::IntPoint; + #[test] + fn test_bind_zero_shapes_no_children() { + // shape_count == 0, no anchors: early-return path must produce an empty solution. + let solution = ShapeBinder::bind(0, vec![], vec![]); + assert!(solution.children_count_for_parent.is_empty()); + assert!(solution.parent_for_child.is_empty()); + } + + #[test] + fn test_bind_zero_shapes_with_children() { + // shape_count == 0 with one hole anchor: previously panicked with an OOB index + // into the zero-length children_count_for_parent vec. + let anchor = IdSegment { + contour_index: ContourIndex::new_hole(0), + v_segment: VSegment { + a: IntPoint::new(0, 0), + b: IntPoint::new(5, 0), + }, + }; + let solution = ShapeBinder::bind(0, vec![anchor], vec![]); + assert!(solution.children_count_for_parent.is_empty()); + assert_eq!(solution.parent_for_child, vec![0]); + } + + #[test] + fn test_hole_left_of_all_parents_does_not_panic() { + // Both parent shapes start at x >= 10, so when the sweep processes + // the hole anchor at x == 1 the scan list is empty and first_less + // returns ContourIndex::EMPTY. Before the fix, the is_hole() branch + // would index parent_for_child[usize::MAX >> 1] and panic. + let mut shapes = vec![ + vec![vec![ + IntPoint::new(10, 0), + IntPoint::new(20, 0), + IntPoint::new(20, 10), + IntPoint::new(10, 10), + ]], + vec![vec![ + IntPoint::new(30, 0), + IntPoint::new(40, 0), + IntPoint::new(40, 10), + IntPoint::new(30, 10), + ]], + ]; + let holes = vec![vec![ + IntPoint::new(1, 2), + IntPoint::new(5, 2), + IntPoint::new(5, 8), + IntPoint::new(1, 8), + ]]; + + // Must not panic — the stray hole falls back to shape 0. + shapes.join_unsorted_holes(holes, false); + + assert_eq!(shapes[0].len(), 2); // outer contour + fallback hole + assert_eq!(shapes[1].len(), 1); + } + #[test] fn test_0() { let mut shapes = vec![