Skip to content

Prevent refine() from looping forever on near-cocircular points#205

Open
spokodev wants to merge 1 commit into
mapbox:mainfrom
spokodev:fix/refine-flip-budget
Open

Prevent refine() from looping forever on near-cocircular points#205
spokodev wants to merge 1 commit into
mapbox:mainfrom
spokodev:fix/refine-flip-budget

Conversation

@spokodev

@spokodev spokodev commented Jul 2, 2026

Copy link
Copy Markdown

refine() can loop forever on a class of valid, simple polygons, hanging the calling thread.

const vertices = [
  127.65906365022843, 9.336137742499535, 124.21725103117963, 30.888097161477972,
  91.35514946628345, 89.65621376119454, 40.10446780041529, 121.5550560957686,
  -110.83205604043928, 64.03323632184248, -127.20394987965459, -14.253249980770189,
  61.074962259031416, -112.48932831632469, 127.37846573978545, -12.598669206638515,
  127.77010311801033, -7.668164657400608];
const triangles = earcut(vertices);
refine(triangles, vertices); // never returns

Cause

refine() runs a Lawson edge-flip cascade whose flip decision uses a an inexact floating-point inCircle. On near-cocircular points the test is not transitive: an edge and its flipped counterpart can both test as illegal, so a legalized edge gets re-queued and flipped back, and the cascade cycles forever. The loop has no bound. Measured incidence is about 0.5 percent of random convex float polygons (roughly 1 in 200), and exact-tie cocircular inputs do not trigger it, so it has gone unnoticed. refine is a documented public API whose docstring says the worst case is a not-quite-Delaunay edge, so a hang is the one outcome it should never produce.

Fix

Bound the cascade with a flip budget of 25 * n (n is the half-edge count, 3 times the triangle count). A converging Delaunay cascade needs O(triangles) flips in practice, so this never truncates legitimate work, while it always breaks a float-induced cycle. On hitting the cap the output is exactly what the docstring already allows: a valid manifold mesh with at worst a few not-quite-Delaunay edges (the flip itself is unchanged and convexity-guarded).

Testing

Added a test with the reproducer polygon above. On the current code the test never returns (a synchronous infinite loop freezes the whole test runner). With the fix it passes: the mesh is unchanged in size and its deviation is 0. Full suite stays green (247 tests), eslint and tsc clean. The existing refine quality tests, including the mvt-fixture perimeter-reduction check, are unaffected.

@spokodev spokodev requested a review from a team as a code owner July 2, 2026 03:47
@spokodev spokodev requested review from mourner and removed request for a team July 2, 2026 03:47
refine()'s Lawson edge-flip cascade uses an inexact inCircle test, so on
near-cocircular vertices roundoff can make an edge and its flip both test
as illegal and the cascade cycles forever, hanging the caller. Bound the
number of flips to a generous multiple of the half-edge count; a
converging cascade needs far fewer, and on hitting the cap the output is
still a valid mesh with at worst a few not-quite-Delaunay edges, as the
docstring already allows.
@spokodev spokodev force-pushed the fix/refine-flip-budget branch from a55eb0d to 24a61c5 Compare July 2, 2026 03:48
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.

1 participant