Hotfix: stop iterativeShorten() from hanging on a contractible closed loop#254
Open
designbynumbers wants to merge 1 commit into
Open
Hotfix: stop iterativeShorten() from hanging on a contractible closed loop#254designbynumbers wants to merge 1 commit into
designbynumbers wants to merge 1 commit into
Conversation
…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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 closedloop. 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 ano-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
< π, byre-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
NonContractibleLoopThroughSelfEdgeReachesGeodesicis awitness: 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; theno-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
doubleof per-loop state: the shortest length the loop has reached whilecollapsed to a single self-edge.
processSingleEdgeLoop()only unfolds when thecurrent self-edge is strictly shorter than that.
→ it stops, leaving the loop as its minimal single-edge representative.
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 generatedin code, no new assets):
ContractibleClosedLoopTerminates(cone disk)NonContractibleClosedLoopReachesGeodesic(torus)NonContractibleLoopThroughSelfEdgeReachesGeodesic(cone torus)* bounded by a generous
maxIterationscap in the test so it fails rather thanhangs CI.
Full suite: 226/226 pass.