Skip to content

fix(mesh): pave the arc a road curving through a junction actually describes (#356) - #559

Merged
JArmandoAnaya merged 3 commits into
mainfrom
fix/356-junction-corner-solver
Aug 2, 2026
Merged

fix(mesh): pave the arc a road curving through a junction actually describes (#356)#559
JArmandoAnaya merged 3 commits into
mainfrom
fix/356-junction-corner-solver

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #356.

Diagnosis first — all four suspected causes were wrong

The issue's standing note predicted the fillet was never emitted and that
solve_corner returned early through one of four guards. None of them
trips.
Every corner in the failing fixture solves valid = true:

pair valid ta tb phi max_radius radius corner
0→1 (the bad one) true 23.476 23.476 111.99° 34.800 15.000 (cap) (0.000, −11.978)
1→2 true 11.210 14.964 55.996° 5.960 5.960 (4.800, 2.840)
2→0 true 14.964 11.210 55.996° 5.960 5.960 (−4.800, 2.840)

ta/tb are positive and large, phi is mid-range, max_radius has 34.8 m
of headroom, arm ordering is correct. Bound relaxation is not just unnecessary
— relaxing any of these could only make the artifact bigger.

What is actually wrong

Pair 0→1 is the two halves of the same road, split by the junction. Their
facing edges are one continuous pavement edge of radius 34.80 m centred
(0, 30). Extrapolated as straight rays 112° apart, they meet at (0, −11.978)
— while the true edge crosses x = 0 at (0, −4.800). That is 7.18 m of
fabricated pavement
, and the 15 m fillet built on it dips to (0, −8.877).

The straight case works only because two collinear edges never meet:
parallel_edges catches it and paves a corridor instead. The curved case is
that case's exact analogue and was never handled.

Why every gate missed it for so long

The failing boundary marches out along the arm's edge line to (−7.382, −4.827),
turns 88.210° back to the fillet tangency, then follows the 15 m arc. Those
two features are 1.800 m apart — exactly the sidewalk width. The floor mesh is
the carriageway, inset 1.8 m by the #402 bands; the bands wrap the corridor
strips but not the fillet wedge, so the wedge pokes proud and the boundary
doubles back.

Without sidewalks the spike is still there — r30_inside carries the apex at
(0, −12.699) in its boundary, with concave = 0.000 — and passes every
gate, because a spike is a convex corner and the fillet gate exempts convex
corners by design. The sidewalk band is the only reason the matrix could ever
see this. That is why the new gates are geometric rather than quality metrics.

The fix

Detect the curved case the way the straight one is detected — by the two
facing edges being the same curve — and pave the arc corridor instead of a
corner. Two details that are not incidental:

  • The arc is anchored through both face corners, not to either arm's
    osculating circle. The arms are clothoid-fitted, so their osculating circles
    differ by centimetres; anchoring to one left a 5.7 cm miss at the other,
    which is exactly the sliver class this removes.
  • The inward offset is signed. "Inward" is toward the arc centre only for a
    convex through-arm; on the concave side the pavement is on the far side.

junction_corners() already drops solves it cannot fillet, so a through pair
now reports no corner — as a straight through-road always did — and the Corner
tool needs no change. Consequence worth flagging: an authored radius on a
through pair is now ignored, exactly as it always was for a straight one.

Straight approaches are bit-identical

Verified by dumping every junction floor for all 12 fixtures before and after:

before after
perp, deg45, deg135, asymmetric, start_contact, graded, multilane bit-identical
r100_outside, r100_inside, r30_outside, r30_inside, r30_sidewalk_outside changed (the fix)

A straight arm has zero edge curvature, so the solve provably stays the
line-line one. Pinned from here on by straight_floor_golden.txt, generated
from the pre-fix solver
so it proves the fix reproduces 9e9bce2 rather than
agreeing with itself. Positions compare to 1e-6 m (one file, three platforms,
and clothoid/Clipper2/CDT need not agree to the last ULP — a micron is three
orders of magnitude below kBoundarySimplify); indices compare exactly.
.gitattributes pins it eol=lf, the third instance of that byte-gate class.

Verification

  • 3298/3298 ctest green locally, core and editor.
  • Sanitizer (ASan + UBSan), mandatory for this area: 2031 core + 1267
    editor tests, zero findings.
  • The floor's southernmost point now lands on the true pavement edge to the
    millimetre (−4.8000 against a true −4.800; was −8.877).
  • Sabotage-verified, each with a checked build exit code:
    • detection disabled → 8 tests fail; the straight golden correctly stays
      green, confirming it pins only straight output
    • corridor radius inflated 0.5 m → 3 tests fail
    • corridor offset sign flipped → 3 tests fail
    • kFootprintWeld 0.01 → 0.012 → golden fails on the first vertex

One sabotage (kEdgeStripWidth 1.0 → 1.01) caught nothing. That is a vacuous
sabotage rather than a weak test — the strips are interior to the union for
straight junctions, so the constant genuinely cannot move that output; the
kFootprintWeld sabotage above is the one that proves the golden bites.

A first draft also carried a concave-orientation gate. Sabotage showed it could
not fail with any available fixture — the concave corridor's overhang stays
inside the mouth — so it was dropped rather than shipped as coverage that isn't
there, and the detection's concave path is covered by running
NoCornerIsReportedForTheRoadRunningThrough over both orientations instead.

…scribes (#356)

A junction splits its through road into two arms, and the corner solver
extrapolated each arm's facing edge as an infinite straight ray from a single
end station. Two collinear edges never meet, so `parallel_edges` caught the
straight case and paved a corridor; on a curve the rays met at a fabricated
apex metres outside the pavement and the fillet built there paved out to it.
Measured at r = 30 m: the apex sat 7.18 m past the true edge, and the 15 m
fillet dipped 4.08 m past it.

The curved case is now detected the way the straight one always was. When two
arms' facing edges share an osculating circle they are one pavement edge
running through the junction, so there is no corner to fillet and the mesher
paves the arc corridor the edge describes. The arc is anchored through BOTH
face corners rather than to either arm's osculating circle: the arms are
clothoid-fitted, so those circles differ by centimetres and anchoring to one
leaves a sliver at the other. The corridor's inward offset is signed, because
"inward" is toward the arc centre only for a convex through-arm.

A straight arm has zero edge curvature, so the solve stays exactly the
line-line one it has always been. All seven straight-approach fixtures mesh
bit-identically, verified by a before/after differential of every fixture and
pinned from here on by a golden generated from the pre-fix solver.

`junction_corners()` already drops solves it cannot fillet, so a through pair
simply reports no corner — as a straight through-road always did — and the
Corner tool needs no change. An authored radius on such a pair is ignored,
exactly as it always was for a straight one.

The reproduction is no longer disabled: `r30_sidewalk_inside` joins the
quality matrix, and new gates assert the floor lands on the pavement edge the
curve describes and that no corner is reported between a road and itself.

Worth recording: without sidewalks the spike was there too and every gate
passed it, because a spike is a convex corner and the fillet gate exempts
those. Only the sidewalk band wrapped it into a concave needle the matrix
could see — which is why the new gates are geometric rather than quality
metrics.

Closes #356
…e by line

The gate failed on macOS arm64 while passing on x86_64 Linux, Windows and
macOS — with the SAME vertices in a different sequence. Mesh vertex order is
not stable across architectures and never was a kernel guarantee: the
determinism tests pin it only within a single run. A golden that compares the
dump line by line is therefore asserting something the mesher does not promise.

Resolve each index triple to coordinates, sort the corners within a triangle
and the triangles within a case, and compare that. Geometry and connectivity —
what actually must not move — stay pinned; vertex numbering no longer is. The
committed golden is unchanged, since it already encodes the same triangles.

Re-verified by sabotage: kFootprintWeld 0.01 -> 0.012 still fails the gate.
…g them

Sorting canonical triangles and comparing the lists pairwise is not robust:
floor coordinates carry float32-precision noise (~2e-6 m at these magnitudes,
one float ULP), and a single ULP is enough to swap two neighbours in the sort.
Every later triangle then reports as a metres-wide mismatch, which is exactly
how this read on the Windows runner -- one true 1.9e-6 difference followed by
a cascade of bogus 8 m ones.

Match the two triangle multisets with a tolerance instead. 1e-4 m is 20x above
the float noise and 50x below kBoundarySimplify, so it cannot hide a real
change. Sorting still canonicalises WITHIN a triangle, where the corners are
metres apart and no rounding can reorder them.

Re-verified by sabotage: kFootprintWeld 0.01 -> 0.012 leaves 25 of 68 golden
triangles in `asymmetric` with no counterpart.
@JArmandoAnaya
JArmandoAnaya merged commit d41052f into main Aug 2, 2026
16 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the fix/356-junction-corner-solver branch August 2, 2026 12:20
@JArmandoAnaya JArmandoAnaya mentioned this pull request Aug 2, 2026
17 tasks
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.

junction corners: curved approaches produce colliding corner geometry (straight-ray solver)

1 participant