diff --git a/iOverlay/src/bind/segment.rs b/iOverlay/src/bind/segment.rs index 1ee7ed88..afbcc143 100644 --- a/iOverlay/src/bind/segment.rs +++ b/iOverlay/src/bind/segment.rs @@ -13,6 +13,11 @@ pub(crate) struct ContourIndex { impl ContourIndex { pub(crate) const EMPTY: ContourIndex = ContourIndex { data: usize::MAX }; + #[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 90e793ce..f5dfc952 100644 --- a/iOverlay/src/bind/solver.rs +++ b/iOverlay/src/bind/solver.rs @@ -94,6 +94,20 @@ 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(); + + if target_id.is_empty() { + // No containing contour found to the left of this hole's anchor. + // This happens for a degenerate hole whose leftmost edge is + // coincident with a shell edge (the strict `first_less` cannot + // see an equal-x segment), so it has no real parent shell. Mark + // it orphaned and skip it during binding instead of indexing + // `parent_for_child`/`children_count_for_parent` with the EMPTY + // sentinel (`usize::MAX`), which panicked. + parent_for_child[child_index] = usize::MAX; + continue; + } + let parent_index = if target_id.is_hole() { // index is a hole index // at this moment this hole parent is known @@ -102,10 +116,10 @@ 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; + if parent_index != usize::MAX { + children_count_for_parent[parent_index] += 1; + } } BindSolution { @@ -191,6 +205,11 @@ impl JoinHoles for Vec> { for (hole_index, hole) in holes.into_iter().enumerate() { let shape_index = solution.parent_for_child[hole_index]; + if shape_index == usize::MAX { + // Orphan hole with no containing shell (see ShapeBinder::bind); + // dropping it keeps a degenerate sliver from corrupting output. + continue; + } self[shape_index].push(hole); } } @@ -278,7 +297,8 @@ 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; @@ -340,4 +360,34 @@ mod tests { assert_eq!(short_result, long_result); assert_eq!(Ordering::Less, long_result); } + + #[test] + fn orphan_hole_without_parent_shell_is_dropped_not_panicking() { + // Regression: a hole whose left-bottom anchor has no shell segment + // strictly to its left makes `first_less` return `ContourIndex::EMPTY`. + // Before the fix, binding indexed `parent_for_child[EMPTY.index()]` + // (== usize::MAX >> 1) and panicked with "index out of bounds". + let anchor = IdSegment::with_segment( + ContourIndex::new_hole(0), + VSegment { + a: IntPoint::new(0, 5), + b: IntPoint::new(1, 6), + }, + ); + // The only shell edge lies entirely to the right of the hole, so the + // sweep never inserts it before the anchor: the hole has no parent shell. + let shell_edge = IdSegment::with_segment( + ContourIndex::new_shape(0), + VSegment { + a: IntPoint::new(10, 0), + b: IntPoint::new(20, 0), + }, + ); + + let solution = ShapeBinder::bind::(2, vec![anchor], vec![shell_edge]); + + // The orphan hole is marked (usize::MAX) and skipped by the join loop, + // instead of crashing. + assert_eq!(solution.parent_for_child[0], usize::MAX); + } }