constrained delaunayRefine inserts two circumcenters at the same point → zero-area triangle
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 68/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- cpp
- Domain
- computer-graphics
Research direction
Build and run refine_degeneracy.cpp with gc-refine-repro.txt in both marked and unmarked modes to confirm the constrained failure and clean control. Read IntrinsicTriangulation::insertCircumcenter in src/surface/intrinsic_triangulation.cpp and follow its caller in delaunayRefine. Done means constrained refinement no longer creates coincident vertices or zero-area triangles while the unmarked result remains unchanged.
Written by the indexing model from the issue text.
Description
Runnable reproducer (gist): https://gist.github.com/designbynumbers/8347ae053074a4c1387350977483d3c9
(refine_degeneracy.cpp, the minimized gc-refine-repro.txt, and suggested-fix-proximity-guard.patch.)
Version: master @ 1e8e43d2d50b18c98ea0b4a53ffaf5848dbaaa26 (2026-05-04).
Summary
On a freshly built IntegerCoordinatesIntrinsicTriangulation (all edges
shared) whose metric is valid (every triangle satisfies the triangle
inequality; pre-mollified; min interior angle 1.69°), calling
delaunayRefine(25°) traces a Steiner circumcenter onto an existing vertex
— the new point lands on a triangle corner, coincident with a previously-inserted
Steiner vertex (the two occupy the bit-identical location on the input surface),
immediately creating a ~zero-length edge → a degenerate needle triangle. This
happens iff a subset of interior edges is fixed via setMarkedEdges
(constrained / Chew's-2nd-with-segments).
Controlled A/B on the exact same mesh + metric (attached gc-refine-repro.txt,
419 V / 547 F), delaunayRefine(25°):
| interior marked edges | result |
|---|---|
| 41 (+417 boundary, auto-fixed) | degenerate: V 419→1182, F→2013, min angle 0°, worst triangle edge lengths 0.249122, 1.51626e-09, 0.249122 |
| 0 (417 boundary only) | clean: V 419→693, F→1019, min angle 25.1244° |
Both arms run the identical mesh + metric; they differ only in the 41
interior marked edges, which isolates them as the trigger. The unconstrained
refiner reaches the angle bound cleanly; only the marked-edge (segment-
constrained) path manufactures the needle.
The failure is fully deterministic and environment-independent — the
output above (down to the 1.51626e-09 edge length and V=1182 F=2013
counts) is bit-identical across:
- stock gc,
-O3 -DNDEBUG, no local patches; - a Debug
-O0 -fsanitize=addressbuild (gc assertions enabled).
The ASan/Debug build reports no memory error and trips no
GC_SAFETY_ASSERT — refinement completes "successfully" and simply returns a
triangulation containing a zero-area triangle (so a downstream cotan-Laplacian
/ BFF solve then blows up). This is an algorithmic correctness bug in the
constrained refinement, not memory unsafety.
Root cause (confirmed by instrumenting IntrinsicTriangulation::insertCircumcenter + delaunayRefine)
delaunayRefine inserts circumcenters via the base
IntrinsicTriangulation::insertCircumcenter (src/surface/intrinsic_triangulation.cpp:248).
Instrumenting just after its insertVertex call shows the degenerate edge is
created immediately at insertion (not by a later flip):
insert produced tiny incident edge IMMEDIATELY: minInc=2.82087e-15 maxInc=4.45961 endType=Face
i.e. the traced circumcenter ends as a Face-type point sitting on a corner
of the intrinsic triangle it landed in — coincident with an existing vertex. In
the final mesh the resulting needle's short edge connects two distinct inserted
Steiner vertices at the bit-identical input-surface location:
worst edge (len 1.51626e-09) endpoints:
tail: deg 5, input face f_114, bary (0.593902, 0.166484, 0.239615)
tip : deg 7, input face f_114, bary (0.593902, 0.166484, 0.239615) <- identical
both Face-type circumcenters
There is no proximity/coincidence guard before/after insertVertex (the
only guards are: trace blocked by a marked edge → split that edge's midpoint;
trace failed → bail). Why the constrained path hits this and the unconstrained
path doesn't: the input has a 1.69° angle at a constrained corner; marked
edges can't be flipped or refined away, so Chew's-with-segments repeatedly
targets the skinny faces around that corner and their circumcenters cluster
onto essentially one point. (Relatedly, delaunayRefine's deleteNearbyVertices
cleanup, which removes previously-inserted vertices near a new one, is registered
as an edge-split callback only — it never fires on face-interior circumcenter
insertions, so clustered coincident circumcenters are never cleaned up either.)
A standard fix is the usual Delaunay-refinement proximity guard: if the traced
circumcenter falls within ε of an existing vertex, reject the insertion (accept
the triangle) rather than inserting a coincident point — see Suggested fix.
Reproducer
Self-contained, gc-only (no other deps). Files:
refine_degeneracy.cpp(~140 lines). Args:<state.txt> [mollifyEPS=1e-5] [markEdges=1].gc-refine-repro.txt— the input state:nV nF nE, thennFtriangles
(vertex-index triples), thennEedges (a b length marked). A compressed
manifold triangle mesh with boundary; lengths/marks keyed by vertex-pair so
they're independent of gc's edge indexing. Marked edges are reconstructed on
the IT'sintrinsicMeshbeforesetMarkedEdges(mirroring correct gc usage).
(It is an automatically delta-debug-minimized reduction of a larger real
pipeline state — the many small holes / large boundary are a reduction
artifact, not essential to the bug.)
Build (against a stock gc build tree):
c++ -std=c++17 -O3 -DNDEBUG \
-I<gc>/include -I<gc>/deps/happly -I<gc>/deps/nanoflann/include \
-isystem <eigen> \
refine_degeneracy.cpp <gc-build>/lib/libgeometry-central.a \
-o refine_degeneracy
Run (constrained = degenerate; unconstrained = clean control):
./refine_degeneracy gc-refine-repro.txt 1e-5 1 # marked: min angle 0° (REPRODUCED)
./refine_degeneracy gc-refine-repro.txt 1e-5 0 # unmarked: min angle 25.1244°
Expected
delaunayRefine on a valid constrained triangulation should terminate with
all non-constrained angles ≥ the bound (or at worst leave bounded-but-small
angles only where two constraint edges meet at a genuinely small input
angle) — never insert a zero-area / coincident-vertex triangle.
Notes
- The attached input is an automatically minimized (delta-debugged) reduction
of a real pipeline state; happy to share the larger original or minimize
further if useful. - The bug is independent of intrinsic mollification: the input metric is
already mollified and the IT ctor'smollifyEPS(1e-5 here) does not change
the outcome. - Our day-to-day build carries an unrelated 2-line local patch to
SurfacePoint::inEdge; the bug reproduces identically on stock gc with
no patches (shown above), so the patch is not involved.
Suggested fix (tested)
A minimal proximity guard in IntrinsicTriangulation::insertCircumcenter
(src/surface/intrinsic_triangulation.cpp): after insertVertex, if the new
vertex has a near-zero incident edge (it landed on an existing vertex), undo the
insertion and return Vertex(). delaunayRefine already treats Vertex() as
"insertion failed" and continues, leaving the unrefinable constrained corner as
a healthy (non-degenerate) triangle.
// === Phase 3: Add the new vertex
Vertex newV = insertVertex(newPositionOnIntrinsic);
// Proximity guard: a traced circumcenter can land coincident with an existing
// vertex (ends as a Face/Edge point on a triangle corner), creating a
// zero-area needle. Undo such an insertion rather than poisoning the mesh.
if (newV != Vertex()) {
double minInc = std::numeric_limits<double>::infinity(), maxInc = 0.;
for (Edge e : newV.adjacentEdges()) {
minInc = std::min(minInc, edgeLengths[e]);
maxInc = std::max(maxInc, edgeLengths[e]);
}
if (minInc <= 1e-9 * maxInc) {
removeInsertedVertex(newV);
return Vertex();
}
}
return newV;
Results on the attached repro (stock gc, otherwise unchanged):
| case | before | after patch |
|---|---|---|
| 41 interior marked (constrained) | min angle 0°, edge 1.5e-9 | min angle 4.21°, minEdge 0.446, no degeneracy (deterministic) |
| 0 interior marked (control) | min angle 25.1244° | min angle 25.1244° (unchanged) |
Full diff: suggested-fix-proximity-guard.patch. Notes: (a) the same guard
likely belongs in IntegerCoordinatesIntrinsicTriangulation::insertCircumcenterOrSplitSegment
for its callers (delaunayRefine itself uses the base insertCircumcenter).
(b) A heavier alternative — extending delaunayRefine's deleteNearbyVertices
cleanup (currently an edge-split callback only) to fire on face insertions via
faceInsertionCallbackList — would also remove the coincidence, but runs a
Dijkstra ball-search + vertex deletions on every circumcenter and risks
insert/delete oscillation; the local guard is cheaper and side-effect-free.
- Dominant language
- C++
- Stars
- 1.3k
- Forks
- 183
- PR merge metrics
- No merged PRs in 30d
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from nmwsharp/geometry-central
-
Difficulty 1/5 Under an hour Newbie friendliness 88/100
nmwsharp/geometry-central#256 ·
-
Difficulty 4/5 3-5 days Newbie friendliness 48/100
nmwsharp/geometry-central#255 · 1 comment ·
-
Difficulty 4/5 3-5 days Newbie friendliness 30/100
nmwsharp/geometry-central#253 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 57/100
nmwsharp/geometry-central#247 ·
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
nmwsharp/geometry-central#234 ·
All issues in nmwsharp/geometry-central
Similar issues
-
enhancement
Difficulty 1/5 Under an hour Newbie friendliness 88/100
QuantStack/git2cpp#187 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
mlcommons/mobile_app_open#1182 ·
-
Needs-Triage
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
microsoft/winget-cli#6547 ·
-
Difficulty 1/5 Under an hour Newbie friendliness 90/100
AXERA-TECH/ax-llm#77 ·