architecture
The migration regressed one renderer at a time
A coordinate-space migration carried one assumption — "absolute geometry needs no per-copy shift" — that was true for the globe and false for flat maps. Applied per-renderer, it silently dropped world-copy fan-out path by path. The fix is one ten-line router.
By the X-GIS team 3 min read
This loop runs once. It is still written as a loop, and it sits under a comment explaining why running once is correct:
// PR 2d.1D: graticule vertices are absolute ECEF — no per-copy camera// shift needed. Draw once per frame (ECEF world-copy = same geometry).// Previously iterated worldCopiesFor(projType) for Mercator cam_h shift.for (let wi = 0; wi < 1; wi++) {The comment was true on the globe and false on a flat map. At low zoom, a flat Mercator view shows the world several times side by side; each repeat is realized by a per-copy metre offset at draw time. When the engine migrated to absolute-ECEF vertices, the migration’s core assumption — “absolute geometry needs no per-copy shift” — held for the globe (one world, by definition) and not for flat display. The graticule kept the shape of the old fan-out loop, capped at one iteration, while the polygon-fill renderer next door kept its real loop. Result: at low zoom, fills repeated across world copies and the grid lines drawn over them appeared only in the primary world.
The interesting part is not the bug; it’s the distribution of the bug. The
same migration left fills, inline points, and tile point-labels correct, and
broke the graticule, the tile point renderer (const COPIES = [0]), and tile
line-labels (a single unlooped projection call) — each in its own file, each
with its own author-moment of “ECEF is absolute, so one pass is fine.”
A cross-cutting decision — which world copies are visible this frame — was
being re-derived locally by every renderer, so a migration that changed the
answer could only regress it renderer by renderer, wherever the local
re-derivation happened to be rewritten.
The wrong first move
The tempting fix is to write the world-copy formula into a shared helper —
compute visible copies from the camera frustum, longitude span, projection
periodicity. That would have been a fourth implementation. Three correct
copy computations already existed: the Mercator frustum-based enumeration,
the periodic-projection table, and the globe’s trivial [0]. The bug was
never that a formula was missing; it was that choosing between them was
distributed. Centralizing by re-deriving would have added a new divergence
candidate with its own edge cases.
The fix
The authority is a router, not a formula — ten lines that only choose:
export function visibleWorldCopiesFor( projType: number, host: VisibleWorldCopiesHost, canvasWidth: number, canvasHeight: number, dpr: number,): readonly number[] { if (host.globeMode || isGlobeProj(projType)) return SINGLE_COPY if (isMercatorProj(projType)) return host.getVisibleWorldCopies(canvasWidth, canvasHeight, dpr) return enumerateWorldCopies(projType, host.zoom) ? worldCopiesFor(projType) : SINGLE_COPY}The graticule now iterates what the router returns, applying the same
per-copy offset the fill path applies, and keeps the single-pass ECEF fast
path only where the router says one copy exists. The migration’s assumption
finally lives in exactly one if.
How we know it holds
The consumer-wiring gate drives the renderer with a flat-Mercator camera
where the router reports four visible copies and asserts one draw per copy
with the per-copy offset in the uniforms. Reverting only the renderer change
fails it with expected 1 to be 4 — the pre-fix loop draws once regardless —
while the globe test (single ECEF pass, flat MVP never consulted) stays
green, so the gate discriminates the two regimes rather than counting draws.
What a unit test cannot vouch for is the final pixels — the repository’s own
render bar demands a real-GPU pass before the issue closes, and that is
recorded as pending, not claimed.
What generalizes
A migration is an assumption applied everywhere; if the assumption is
re-stated locally in each consumer, “everywhere” arrives one file at a time
and so do the regressions. The two tells were both in that one deleted
snippet: a loop whose bound is a constant 1 (shape preserved, semantics
capped — someone hedged), and a comment asserting a cross-cutting invariant
from inside a single consumer. A comment is where an invariant goes to rot;
a router the consumers must call is where it stays true — and when you do
centralize, route to the existing per-case computations rather than
re-deriving them, or the authority becomes the newest fork.
Read next
verification
5 min read
Verified only where the bug showed: a hot-path fix that shipped a zoom-in regression
A WebGL2 flicker fix (#1139) was render-parity-verified on exactly one scenario — the zoom-OUT hold-tiles case that reproduced the original flicker, retained-content 0.19 → 0.79 matching WebGPU — and never on zoom-in or per-frame cost. It shipped an unmemoized per-frame classifyTile run twice per tile plus synchronous uploads, and stuttered with progressively-black fills on a Seoul zoom-in. Reverted 36 minutes later (#1140). A hot-path fix must be verified in its worst regime, not the one that showed the original bug.
webgl2
8 min read
Two renderers, one truth: all four globe bugs lived in the hand-maintained twin
A displaced vector layer, a red/blue checkerboard ocean, a 16-gon planet, and intermittent flicker — four user-visible globe defects on WebGL2, and not one root cause in shared code. Every camera rendered correctly on WebGPU. On why a hand-maintained backend twin is scaffolding that must die, and the 20.7 km test witness that keeps its seam from drifting while it lives.
architecture
8 min read
The issue said to move the code back
An issue prescribed relocating a context type to kill an adapter-to-adapter dependency edge. By pickup, that mechanism meant reverting a merged refactor. We cut the edge with composition-root injection instead, and locked it with a shrink-only ratchet.