labels
Three rounds to keep a label on the planet
A row of country labels floated in the sky above the globe's horizon. Round 1 — an angular margin, provably safe at every zoom — died because floaters and healthy labels interleave in angular depth (Nigeria 0.332 floats, Kenya 0.329 doesn't). Round 2's screen-space limb fixed Chad and missed two-line Burkina Faso by exactly one line of text. The gate only held once it moved to where the quad height actually exists.
By the X-GIS team 7 min read
At globe zoom 2, latitude 20, pitch 85°, a row of country labels renders in the sky — glyph quads floating cleanly past the planet’s silhouette, on both backends, pixel-identical. The issue (#1042) guessed the cause: “missing far-side containment.” That premise died first: the label projector already culls with the exact tangent-cone test, the same formula the tile selector uses. The cull was correct. It was also binary: an anchor one pixel inside the limb passes, and its quad — tens of pixels of text centred on that anchor — draws the rest of itself into space.
So the fix is “require some headroom inside the horizon.” Three rounds of that, and the instructive part is that every round’s unit tests were green while a screenshot refuted the round. Twice.
Round 1: the margin that was provably safe — for the wrong variable
The obvious margin is angular: don’t just require the anchor inside the
tangent circle (cos ≥ cosH), require it a band inside. The trap I did
manage to dodge is the additive margin. The globe path runs at city zoom
too, where the visibility headroom 1 − cosH collapses — measured 0.0027 at
z12 and 4·10⁻⁵ at z18 — so any fixed cosine offset eventually exceeds the
entire headroom and blanks every label on screen, including one at dead
centre. The margin therefore had to be a fraction of the headroom:
const cosC = cosH + LABEL_HORIZON_MARGIN * (1 - cosH) // 0.15 of the headroomA fraction strictly below 1 can never cull the sub-eye point, at any zoom — we pinned that with a z12 regression test, matched the 0.15 to an existing flat-path precedent, and measured the calibration at the worst camera (z0, pitch 85: trims a 7.9° angular rim for under a pixel of radial screen cost). Mathematically safe for every zoom. Shipped.
The probe screenshot refuted it in one frame. At the probe camera the gate’s own metric refuses to order the labels: Nigeria sits at headroom fraction f = 0.332 and floats; Kenya sits at f = 0.329 and is perfectly healthy. In angular terms, Chad floats 7.5° inside the horizon while Kenya is fine at 14.8° inside. Floaters and healthy labels interleave in angular depth, because screen compression at the limb varies with azimuth — the same angular band is sub-pixel at one bearing and half a text line at another. There is no threshold to tune. The proof of safety quantified over zoom; the bug lived on the azimuth axis.
Round 2: measure in the space where the failure is
The failure is “pixels above a screen-space curve,” so round 2 gates in
screen space: project the horizon circle itself — centre e·R·cosH, radius
R·sinH, plane perpendicular to the eye axis, the same sphere model the
anchor cull uses — into a 64-sample screen polygon, and draw an anchor only
if its already-computed screen point sits at least 7 px inside that limb
polygon. The one design decision that mattered: the circle is projected
through the identical rtc-matrix+focus path the anchors themselves take.
No second projection theory, no frame-consistency bug waiting; if the
anchors move, the limb moves with them, by construction.
Why 7 px: the quad is centred on its anchor, so only the top half rises toward the sky, and half of our ~14 px line height is 7. The probe’s own witnesses bracket the constant into (2.9, 11.8]: floating Chad projects 2.9 px inside the limb (must be culled), healthy Kenya 11.8 px inside (must survive). Chad’s label left the sky. Green tests, better screenshot.
Then the screenshot pushed back twice more.
First, it accused the wrong country. Reviewing the after-frame downscaled to fit, I flagged Nigeria as still floating. Its measured inset said 11.7 px — statistically indistinguishable from healthy Kenya’s 11.8 — and the ×5 native-resolution crop showed the label sitting flat on the disc. At review scale, a healthy label and a floater are the same three grey pixels; the downscaled read was noise. (Our own verification rules ban exactly this judgment, and I made it anyway, in the session that was busy proving why the rule exists.)
Second, the same ×5 crop pass found the real survivor: Burkina Faso, a two-line label, top line fully in the sky. Its anchor sits 7.92 px inside the limb — past the 7 px gate, so round 2 keeps it — but a two-line quad’s half-height is ~12 px. The constant encoded “labels are one line tall.” The fix missed by exactly one line of text.
Round 3: move the gate to where the height exists
The honest conclusion: the label pass cannot know the quad height at its
dispatch site, because the text hasn’t been shaped yet. Any constant there
is a guess about typography. The quad height has exactly one authority — the
collision box built in text-stage.prepare(), the same bbox the greedy
placement collides and the renderer consumes — so that is where the gate
went. The projector now exposes the measurement, not the decision:
/** px the screen point sits inside the projected limb polygon; * +Infinity off-globe / flat / degenerate cameras. */limbInsetPx: (x: number, y: number) => numberand the collision-box build makes the call: a point label whose box centre
sits closer to the limb than its half-height (+2 px) emits no collision
candidates at all. Greedy placement never sees it, so it stays unplaced —
which holds under allowOverlap too, since empty bboxes never enter the
loop — and its paired icon drops in lockstep instead of orphaning.
The witnesses, pinned at the probe camera: Burkina Faso (−1.6, 12.4), inset 7.92 px — skipped at any line count. Kenya (37.9, 0.2), inset 11.57 px — kept as a single line, and correctly skipped if you set it in three lines (half-height ~21 px > 11.57). The gate finally scales with the thing that was actually poking into the sky.
How we know it holds
Each round left behind the test that would have caught it. Round 1’s
additive-margin trap is pinned by the z12 test (headroom 0.0027 < 0.15, yet
the dead-centre anchor survives). Round 2’s limb cull is fail-before
verified — neutering the limb test fails exactly the Chad-cull assertion —
and map.project() is pinned byte-identical, because the public “where does
this coordinate land” API must never inherit a label-policy cull. Round 3
pins the BF/Kenya insets to within half a pixel and the skip arithmetic on
both sides.
But keep the score honest: the unit suite was green at the end of round 1 and round 2. Tests encode the round’s own theory of the bug; they are how a fix stays fixed, not how it gets refuted. All three refutations came from a rendered frame — one full-resolution 16-split and a ×5 crop each time.
What generalizes
A gate can be provably safe across its entire domain and still wrong, when the proof quantifies over one axis (zoom) and the failure varies along another (azimuth). The Nigeria/Kenya pair — 0.332 floats, 0.329 doesn’t — is what that looks like in data: when your gate’s own metric interleaves positives and negatives, stop tuning the threshold; you are measuring in the wrong space.
And when the right space still fails, ask whose number the gate is guessing. Round 2’s 7 px was the label pass re-deriving a quantity — quad height — that another stage owns outright. Moving the check to the authority didn’t just fix two-line labels; it deleted the assumption class. The constant knew about fonts. The collision box just knows the height.
References
- “The label that lands in the ocean” — the previous label-anchor bug, and the same lesson about points that are usually inside.
- “Three render bugs reduced to one unsynced field” — an earlier #1042-adjacent report (“labels off the globe”) that turned out to be the harness, not the engine.
- “Pixels don’t lie” — why the rendered frame outranks a green suite in this codebase’s verification order.
Read next
rendering
7 min read
Draped at the wrong tile: a deferred write turned a shared uniform pool into a data race
A draped landcover polygon rendered hundreds of kilometres offshore — but only on WebGPU. Every vector slice of one source shares one Material uniform pool, and each slice resets the pool cursor to 0, so a later slice's tile i overwrote an earlier slice's tile-i buffer. WebGPU's queue.writeBuffer defers to submit (last-writer-wins), so every draw read the final bytes; WebGL2's immediate bufferSubData never showed it — and the differential test was structurally blind, because both frames aliased identically.
rendering
7 min read
The hemisphere that was never selected: a fast-path bbox that collapsed to a point
Half the globe rendered as background again — same symptom as the drape-suppression bug, opposite root cause. This time the trans-antimeridian tiles were never selected: the overzoom fast-path unprojects the viewport corners to a lon/lat box, but on a small globe disc the corners miss the sphere, the box collapses to the sub-camera point, and it emits the single tile column under the camera — which a contiguous longitude range can never wrap across the dateline. Told apart from the draw bug by seam location and persistence.
rendering
6 min read
The hemisphere that wasn't there: computed, cached, uploaded, drawn nowhere
Half the globe rendered as pure background, cut exactly at the 0-meridian z1 tile boundary. The fallback ancestors for the missing half were computed, CPU-cached, and force-uploaded to the GPU — then drawn nowhere, because a drape suppression flag was scoped to the renderer instance instead of one dispatch. Verified by making the network slow on purpose, after the first pixel readback lied.