From e97b9a8b027070610338c05a6fa8a8f3f581e807 Mon Sep 17 00:00:00 2001 From: Filyus Date: Wed, 17 Jun 2026 11:20:22 +0500 Subject: [PATCH 1/2] fix(bind): don't panic on an orphan hole with no containing shell ShapeBinder::private_solve resolves each hole's parent with `scan_list.first_less(..., ContourIndex::EMPTY, ...)`. When a hole's left-bottom anchor has no shell segment strictly to its left -- e.g. a degenerate hole whose leftmost edge is coincident with a shell edge, so the strict `first_less` cannot see the equal-x segment -- it returns the EMPTY sentinel (`data: usize::MAX`). The code then indexes `parent_for_child[target_id.index()]` and `children_count_for_parent[parent_index]` with `index() == usize::MAX >> 1`, panicking with "index out of bounds: the index is 9223372036854775807". Detect the EMPTY sentinel and treat the hole as orphaned: skip it during binding (and in the join push loop) instead of indexing with the sentinel. Such a hole has no valid parent shell, so it is dropped from the output. Hit with f64 boolean ops over geometry with many near-coincident edges at high coordinate magnitudes (a screen-space visibility resolver). --- iOverlay/src/bind/segment.rs | 5 +++++ iOverlay/src/bind/solver.rs | 25 ++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) 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..f7e8c86a 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); } } From 5c5ccc17a10e16e981ed0a3c149b171b8060dabc Mon Sep 17 00:00:00 2001 From: Filyus Date: Wed, 17 Jun 2026 11:46:06 +0500 Subject: [PATCH 2/2] test(bind): regression for orphan-hole EMPTY-parent panic --- iOverlay/src/bind/solver.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/iOverlay/src/bind/solver.rs b/iOverlay/src/bind/solver.rs index f7e8c86a..f5dfc952 100644 --- a/iOverlay/src/bind/solver.rs +++ b/iOverlay/src/bind/solver.rs @@ -297,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; @@ -359,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); + } }