Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions iOverlay/src/bind/segment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 89 additions & 3 deletions iOverlay/src/bind/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ impl ShapeBinder {
segments: Vec<IdSegment>,
) -> 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)]
{
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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![
Expand Down