Skip to content

Hotfix: stop iterativeShorten() from hanging on a contractible closed loop#254

Open
designbynumbers wants to merge 1 commit into
nmwsharp:masterfrom
designbynumbers:fix/contractible-loop-hang
Open

Hotfix: stop iterativeShorten() from hanging on a contractible closed loop#254
designbynumbers wants to merge 1 commit into
nmwsharp:masterfrom
designbynumbers:fix/contractible-loop-hang

Conversation

@designbynumbers

Copy link
Copy Markdown
Contributor

Fixes #253.

This is a deliberately minimal hotfix for the non-termination reported in the
linked issue — it converts the hang into a return without changing any run that
already terminates. It is not meant as a complete redesign of how contractible /
trivial loops are handled; you may prefer something more thorough, but this is
safe to drop in in the meantime.

Problem (recap)

FlipEdgeNetwork::iterativeShorten() never returns on a contractible closed
loop. The loop collapses to a single self-edge, and processSingleEdgeLoop()
re-routes that self-edge onto the two opposite edges of its triangle — always
longer (triangle inequality), with no progress guard — so it forms a period-2
cycle with the generic shortening step (unfold → reshorten → same self-edge → …).
See the issue for a self-contained reproducer.

Why not just guard the unfold against lengthening?

The obvious one-line fix is to skip the unfold whenever it would lengthen the
loop. But the unfold always lengthens (triangle inequality), so "skip if it
lengthens" is really "skip always" — it turns processSingleEdgeLoop() into a
no-op. And that's clearly wrong, as you already know: that function exists
precisely to handle a single self-edge loop whose corner is still < π, by
re-exposing it as the two opposite edges so the generic flipper can keep
straightening. Disabling it (which is what the naive guard amounts to) re-breaks
exactly the case it was written for.

A non-contractible loop can also collapse to a single self-edge on its way to
its geodesic and needs that (lengthening) unfold to straighten its last corner.
The regression test NonContractibleLoopThroughSelfEdgeReachesGeodesic is a
witness: a 4×4 torus with a sharp radial cone spike on one vertex and a pinched
tube, with a non-contractible loop around the tube. Its geodesic
(minAngle ≈ 3.50) is only reachable by unfolding the single self-edge; the
no-op guard freezes it at a single self-edge with minAngle ≈ 2.45 (~140°
corner) — terminated, but at a non-geodesic.

So the unfold has to stay. The only thing the current code is missing is a guard
for the one case where the unfold can never make progress — a contractible
loop, which has no geodesic to converge to. That's all the change below adds; it
doesn't otherwise touch processSingleEdgeLoop().

The fix

One double of per-loop state: the shortest length the loop has reached while
collapsed to a single self-edge. processSingleEdgeLoop() only unfolds when the
current self-edge is strictly shorter than that.

  // flip_geodesics.h — FlipEdgePath
  FlipEdgeNetwork& network;
  bool isClosed;
+ // Shortest length this loop has reached while collapsed to a single self-edge.
+ // Used to guarantee termination on contractible loops (no geodesic representative).
+ double lastSingleEdgeLoopLen = std::numeric_limits<double>::infinity();
  // flip_geodesics.cpp — processSingleEdgeLoop(), right after fetching `he`
+ double curLoopLen = tri->edgeLengths[he.edge()];
+ if (curLoopLen >= edgePath.lastSingleEdgeLoopLen * (1.0 - 1e-12)) {
+   return; // no net progress since the last unfold of this loop; stop to guarantee termination
+ }
+ edgePath.lastSingleEdgeLoopLen = curLoopLen;
  • A contractible loop returns to the same self-edge each round (no progress)
    → it stops, leaving the loop as its minimal single-edge representative.
  • A non-contractible loop strictly shortens toward its positive-length
    geodesic each round → it always passes the guard and is unaffected (this is the
    case the naive fix broke).

Why it's safe to drop in

It is exactly as powerful as the current code on any input that already
terminates.
The guard fires only when a loop revisits a single-self-edge state
without having strictly shortened since the previous one — and under the current
deterministic shortening, that is already a non-terminating cycle (if it cycles
once with no reduction, it never reduces). So the change can only turn a hang into
a return; it never shortens, lengthens, or otherwise alters a terminating run.

What it does not do

It's a hang→return hotfix, not a full treatment of trivial loops. A contractible
loop is left as a (tiny) single-edge loop rather than reported as trivial /
removed, so a caller still has to recognize that case. If you'd rather handle
contractibility explicitly, this guard is easy to drop later.

Tests

Three deterministic tests in test/src/flip_geodesic_test.cpp (meshes generated
in code, no new assets):

Test stock naive "never unfold" this PR
ContractibleClosedLoopTerminates (cone disk) hang* pass pass
NonContractibleClosedLoopReachesGeodesic (torus) pass pass pass
NonContractibleLoopThroughSelfEdgeReachesGeodesic (cone torus) pass fail pass

* bounded by a generous maxIterations cap in the test so it fails rather than
hangs CI.

Full suite: 226/226 pass.

…oops

A contractible closed loop has no geodesic representative, so iterativeShorten() would collapse it to a single self-edge and spin forever. processSingleEdgeLoop() re-routes the self-edge onto the two opposite edges of its triangle -- always longer by the triangle inequality -- with no progress guard, forming a period-2 limit cycle with the generic shortening step (unfold -> reshorten -> same self-edge -> ...).

Add a per-loop guard: FlipEdgePath::lastSingleEdgeLoopLen records the shortest length a loop reaches while collapsed to a single self-edge, and processSingleEdgeLoop() only unfolds when strictly shorter than last time. A contractible loop returns to the same self-edge each round (no progress) and stops at its minimal single-edge form; a non-contractible loop strictly shortens toward its positive-length geodesic each round and is unaffected.

Note: a blanket "never unfold if it lengthens" guard is NOT sufficient -- non-contractible loops can also pass through a single self-edge en route to their geodesic and need the (always-lengthening) unfold to straighten the final corner; blanket guarding strands them at a kink (verified: minAngle 2.45 vs geodesic 3.50 on cone-concentrated tori).

Adds three deterministic regression tests (contractible terminates; non-contractible reaches geodesic; non-contractible-through-self-edge reaches geodesic). Full suite passes (226/226).

Prepared with Claude Code (Opus 4.8)
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.

FlipEdgeNetwork::iterativeShorten() hangs on a contractible closed loop

1 participant