Skip to content

fix(bind): don't panic on an orphan hole with no containing shell#77

Open
Filyus wants to merge 2 commits into
iShape-Rust:mainfrom
Filyus:fix/hole-bind-empty-parent
Open

fix(bind): don't panic on an orphan hole with no containing shell#77
Filyus wants to merge 2 commits into
iShape-Rust:mainfrom
Filyus:fix/hole-bind-empty-parent

Conversation

@Filyus

@Filyus Filyus commented Jun 17, 2026

Copy link
Copy Markdown

Problem

ShapeBinder::private_solve (src/bind/solver.rs) resolves each hole's parent contour with:

let target_id = scan_list.first_less(anchor.v_segment.a.x, ContourIndex::EMPTY, anchor.v_segment);

When a hole's left-bottom anchor has no shell segment strictly to its left, first_less returns the ContourIndex::EMPTY sentinel (data: usize::MAX). The code then unconditionally does:

let parent_index = if target_id.is_hole() { parent_for_child[target_id.index()] } else { target_id.index() };
...
children_count_for_parent[parent_index] += 1;

Because EMPTY.is_hole() is usize::MAX & 1 == 1 (true) and EMPTY.index() is usize::MAX >> 1, this indexes parent_for_child[9223372036854775807] and panics:

index out of bounds: the len is N but the index is 9223372036854775807

This happens for a degenerate hole whose leftmost edge is coincident with a shell edge — the strict first_less cannot see the equal-x segment, so the hole appears to have no containing shell. It is reliably triggered by f64 boolean ops over geometry with many near-coincident edges at high coordinate magnitudes (here: a screen-space visibility resolver subtracting many occluder polygons whose borders coincide with the subject's borders).

Fix

Detect the EMPTY sentinel and treat the hole as orphaned: mark it (usize::MAX) and skip it during binding and in the join push loop, instead of indexing parent_for_child / children_count_for_parent with the sentinel. An orphan hole has no valid parent shell, so it is dropped from the output (it is a degenerate sub-pixel artifact in practice).

Adds ContourIndex::is_empty() and guards both the producer (private_solve) and the consumer (scan_join's push loop).

Test

Adds bind::solver::tests::orphan_hole_without_parent_shell_is_dropped_not_panicking: it feeds ShapeBinder::bind a hole anchor with the only shell segment entirely to its right (so the sweep finds no parent). With the guard disabled it reproduces the exact panic (index out of bounds: the len is 1 but the index is 9223372036854775807); with the fix it asserts the orphan hole is marked usize::MAX (dropped). Full suite: 348 passed.

Notes

  • The same code path exists unchanged in 6.0.1 and 7.0.0.
  • Open question: an orphan hole has no valid containing shell, so this PR drops it. If you'd rather attach it to the outer/nearest shell, I'm happy to adjust.

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).
@Filyus Filyus marked this pull request as ready for review June 17, 2026 06:33
@Filyus

Filyus commented Jun 17, 2026

Copy link
Copy Markdown
Author

Clarification on the open question (orphan-hole handling): this PR drops the orphan. The correct non-dropping alternative is a point-in-polygon fallback when first_less returns EMPTY — locate the shell that actually contains the hole's anchor and bind to it (O(shells), no data loss). Attaching to the nearest/outer shell without that containment test isn't safe, since the true parent is exactly what the strict lookup missed. Happy to push the point-in-polygon variant if you'd prefer it over dropping.

@NailxSharipov

Copy link
Copy Markdown
Member

Hi Filyus,

Thanks for the PR.

At first glance, this looks like it may be a false positive, possibly found by an LLM, rather than a real issue in valid input data.

Could you provide a concrete example where this panic happens? In a correct topology, this situation should not possible. This solver is designed to work only with already correct topology.

You are not the first person to point out this area. Please also take a look at #70 and #72, where similar concerns were discussed.

Regarding the proposed fix: dropping an orphan hole may hide invalid topology rather than solve the actual issue. Before changing this behavior, I would like to see a real reproducible case and understand whether the input topology is valid.

Thanks.

@Filyus

Filyus commented Jun 17, 2026

Copy link
Copy Markdown
Author

Yes, the fix was made by LLM, but it seems correct. The geometry's topology after projection and booleans isn't very good, but this solution worked. My scene and algorithm are quite big and complex, but I can provide an example if needed.

@NailxSharipov

Copy link
Copy Markdown
Member

Thanks for the clarification.

A reproducible example would be very helpful here. If the problem a real issue, I think it's better to find its root cause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants