#37: raise the MVT buffer from 64 to 256 to fix the z19 line-layer artifact #59

Merged
art merged 1 commit from night/issue-37-mvt-buffer into main 2026-08-05 01:20:23 +00:00
Owner

Closes #37


Issue #37 — rendering issue: MVT buffer too small for stroked line layers at z19

What the issue reported

At z17–18 a cover polygon near 52.23002/20.91506 (Warsaw) rendered badly; the
reporter suspected the MVT buffer was too small. The maintainer later overruled
the fill-only conclusion with screenshots on the issue: line layers at z19
show the classic buffer-cut artifact — road casing missing on one side, and a
road segment ending mid-road with a rounded line-cap — at Powstańców Śląskich
× Czulhowska (Warsaw, PL) and at Hägerstensvägen (Stockholm, SE).

What was wrong

The buffer was 64 units on a 4096-extent tile (= 1/64 of a tile = 4 px at
z19). A road crossing a tile edge gets its tile copy clipped by
ST_AsMVTGeom at the buffer line (x/y = 4160 = 4096+64); MapLibre draws
line-cap: round at the cut. The road casing is wider than the 4px bleed, so
the cut is visible: casing missing on one side, rounded cap mid-road. At lower
zooms the road width ramp makes the cut less noticeable — which is why the
reporter saw it worst at z19 and the earlier cover-fill A/B (opaque fills hide
the clip by construction) measured 0-pixel diffs.

The fix

Raise the MVT buffer and the matching margin envelope from 64 to 256
(= 1/16 of the tile = 16 px at z19) in:

  • importer/shared.go — shared layers (margin => (256.0/4096),
    buffer => 256)
  • importer/layer.go — per-country layers, both the mvtFunc and the
    mvtFuncUnionAll benchmark variant
  • docs/geo-primer.md — the buffer/margin explanation now documents the 256
    value and why 64 was wrong

How it was verified (A/B, the maintainer's exact request)

Rendered the same cameras at z19 with b64 and b256 via
scripts/cdp-map-check.mjs (real Chrome over CDP, no virtual time), 2 renders
per buffer, and pixel-diffed:

camera b64 vs b64 control b256 vs b256 control b64 vs b256
Warsaw z19 (Powstańców Śląskich × Człuchowska) 0 px 0 px 6644 px
Stockholm z19 (Hägerstensvägen) 0 px 0 px 4551 px
Warsaw z18 (same spot) 3499 px

The b64-vs-b256 diff is concentrated at tile seams (57% of pixels within 30 px
of a tile edge), and at b64 the diff pixels show the background cover colour
where b256 draws road — i.e. the road copy was missing past the seam. Decoded
tiles confirm the geometry clip moved from 4160 to 4352. Visually: b64 shows
the casing break + rounded cap; b256 is clean. Screenshots are attached to the
verification comment on #37 (comment id 422).

⚠️ One methodology trap hit during the run: the first A/B renders were
byte-identical between b64 and b256 because a stale headless-Chrome
instance from an earlier session was still holding CDP port 9333 and serving
cached b64 tiles. Kill stray Chrome before re-rendering.

Tile-size cost at z19 (b64 → b256)

Hex-encoded zxy_map bytes, five tiles around the Warsaw intersection:

tile b64 b256 Δ
292604/172637 596 677 +14%
292604/172636 576 623 +8%
292605/172637 430 494 +15%
292604/172635 378 451 +19%
292603/172637 504 561 +11%

The roads layer alone: 296→357 (+21%), 307→335 (+9%), 136→141 (+4%),
146→147 (+0.7%), 309→365 (+18%). The cost is modest and per-layer; opaque
fill layers render identically at either buffer, so only stroked layers pay.

Deployment without a data reimport

ensureParentSQL() (importer/main.go) regenerates all shared-layer zxy_map_*
function bodies via CREATE OR REPLACE FUNCTION on every importer invocation, so
one importer run redeploys the new buffer live — no reimport, no cascade, no
data touched. The live DB is currently running b256.

Left undone / notes

  • The importer also regenerates per-country zxy_* functions on layer import;
    a full reimport is not needed for the buffer change.
  • The 2026-08-04 "buffer is not the cause" investigation note in
    .hermes/skills/.../mvt-buffer-investigation.md is superseded by this A/B
    (that run only tested opaque cover fills).
Closes #37 --- # Issue #37 — rendering issue: MVT buffer too small for stroked line layers at z19 ## What the issue reported At z17–18 a `cover` polygon near 52.23002/20.91506 (Warsaw) rendered badly; the reporter suspected the MVT buffer was too small. The maintainer later overruled the fill-only conclusion with screenshots on the issue: **line layers at z19** show the classic buffer-cut artifact — road casing missing on one side, and a road segment ending mid-road with a rounded line-cap — at Powstańców Śląskich × Czulhowska (Warsaw, PL) and at Hägerstensvägen (Stockholm, SE). ## What was wrong The buffer was **64 units** on a 4096-extent tile (= 1/64 of a tile = 4 px at z19). A road crossing a tile edge gets its tile copy clipped by `ST_AsMVTGeom` at the buffer line (x/y = 4160 = 4096+64); MapLibre draws `line-cap: round` at the cut. The road casing is wider than the 4px bleed, so the cut is visible: casing missing on one side, rounded cap mid-road. At lower zooms the road width ramp makes the cut less noticeable — which is why the reporter saw it worst at z19 and the earlier cover-fill A/B (opaque fills hide the clip by construction) measured 0-pixel diffs. ## The fix Raise the MVT buffer **and** the matching `margin` envelope from 64 to 256 (= 1/16 of the tile = 16 px at z19) in: - `importer/shared.go` — shared layers (`margin => (256.0/4096)`, `buffer => 256`) - `importer/layer.go` — per-country layers, both the `mvtFunc` and the `mvtFuncUnionAll` benchmark variant - `docs/geo-primer.md` — the buffer/margin explanation now documents the 256 value and why 64 was wrong ## How it was verified (A/B, the maintainer's exact request) Rendered the **same cameras at z19 with b64 and b256** via `scripts/cdp-map-check.mjs` (real Chrome over CDP, no virtual time), 2 renders per buffer, and pixel-diffed: | camera | b64 vs b64 control | b256 vs b256 control | b64 vs b256 | |---|---:|---:|---:| | Warsaw z19 (Powstańców Śląskich × Człuchowska) | 0 px | 0 px | **6644 px** | | Stockholm z19 (Hägerstensvägen) | 0 px | 0 px | **4551 px** | | Warsaw z18 (same spot) | — | — | 3499 px | The b64-vs-b256 diff is concentrated at tile seams (57% of pixels within 30 px of a tile edge), and at b64 the diff pixels show the background cover colour where b256 draws road — i.e. the road copy was missing past the seam. Decoded tiles confirm the geometry clip moved from 4160 to 4352. Visually: b64 shows the casing break + rounded cap; b256 is clean. Screenshots are attached to the verification comment on #37 (comment id 422). ⚠️ One methodology trap hit during the run: the first A/B renders were **byte-identical between b64 and b256** because a stale headless-Chrome instance from an earlier session was still holding CDP port 9333 and serving cached b64 tiles. Kill stray Chrome before re-rendering. ## Tile-size cost at z19 (b64 → b256) Hex-encoded `zxy_map` bytes, five tiles around the Warsaw intersection: | tile | b64 | b256 | Δ | |---|---:|---:|---:| | 292604/172637 | 596 | 677 | +14% | | 292604/172636 | 576 | 623 | +8% | | 292605/172637 | 430 | 494 | +15% | | 292604/172635 | 378 | 451 | +19% | | 292603/172637 | 504 | 561 | +11% | The `roads` layer alone: 296→357 (+21%), 307→335 (+9%), 136→141 (+4%), 146→147 (+0.7%), 309→365 (+18%). The cost is modest and per-layer; opaque fill layers render identically at either buffer, so only stroked layers pay. ## Deployment without a data reimport `ensureParentSQL()` (importer/main.go) regenerates all shared-layer `zxy_map_*` function bodies via CREATE OR REPLACE FUNCTION on every importer invocation, so one importer run redeploys the new buffer live — no reimport, no cascade, no data touched. The live DB is currently running b256. ## Left undone / notes - The importer also regenerates per-country `zxy_*` functions on layer import; a full reimport is not needed for the buffer change. - The 2026-08-04 "buffer is not the cause" investigation note in `.hermes/skills/.../mvt-buffer-investigation.md` is superseded by this A/B (that run only tested opaque cover fills).
The z19 rendering artifact reported on #37 — road casing missing on one side,
and road segments ending mid-road with a rounded cap — is the MVT buffer cut,
confirmed by a controlled A/B at the exact reported intersection.

The tile's copy of a road crossing a tile edge is clipped by ST_AsMVTGeom at
the buffer line (4096+64 = 4160 with the old 64-unit buffer), and MapLibre
draws its `line-cap: round` at the cut. The casing is wider than the 4px
(z19) bleed, so the cut shows. At 256 the bleed is 16px, the road copy
extends past the seam, and the artifact disappears.

A/B at Powstańców Śląskich × Człuchowska (Warsaw) and Hägerstensvägen
(Stockholm), both z19, real Chrome over CDP, 2 renders per buffer:
same-buffer controls are 0-pixel diffs; b64 vs b256 differs by 6644 px
(Warsaw) and 4551 px (Stockholm), concentrated at tile seams. Decoded tiles
confirm the clip moved from 4160 to 4352. Screenshots are on the issue.

Tile-size cost at z19 (b64 → b256, `zxy_map` hex bytes, five tiles around the
Warsaw intersection): +8% to +19% per tile; the roads layer alone +4% to
+21%. Opaque fill layers (cover) render identically at either buffer — the
earlier 0-diff fill-only finding was real but irrelevant to stroked lines.

Change: buffer and margin constants in importer/shared.go and
importer/layer.go (both the mvtFunc and the union-all benchmark variant),
and the buffer documentation in docs/geo-primer.md. Deployed live by one
importer run (ensureParentSQL regenerates the zxy functions) — no reimport,
no cascade.

Co-Authored-By: Hermes Agent <noreply@nousresearch.com>
art merged commit 474e8caba9 into main 2026-08-05 01:20:23 +00:00
art deleted branch night/issue-37-mvt-buffer 2026-08-05 01:20:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
art/ismap!59
No description provided.