Debugging Features Clipped at Tile Edges Jump to heading
A hairline gap appears along a tile boundary at one zoom and not another. Work out, in four checks, whether the tile, the style or the renderer is responsible — rather than raising the buffer and hoping.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A seam has exactly four possible causes, and they are distinguishable by evidence rather than by guesswork.
The tile has no buffer geometry. Decode it and look at the coordinate range: if no coordinate falls outside 0 to extent, the generator clipped exactly at the boundary, and no style change will fix it.
The buffer is present but too narrow. Geometry extends past the edge, but by fewer grid units than half the styled line width needs. This is arithmetic, not opinion.
The style is wider than the buffer assumed. The same evidence as above, read from the other side: the buffer was correct for the style it was designed against, and the style has since changed.
The client is clamping. The tile contains adequate buffer geometry and the renderer still draws a seam, which means it is discarding out-of-extent coordinates rather than drawing them.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
import mapbox_vector_tile
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.mvt.debug")
TILE_PIXELS = 512
@dataclass(frozen=True)
class Overhang:
layer: str
extent: int
min_x: int
min_y: int
max_x: int
max_y: int
@property
def units(self) -> int:
"""How far geometry reaches beyond the tile, in grid units."""
return max(-self.min_x, -self.min_y,
self.max_x - self.extent, self.max_y - self.extent, 0)
def pixels(self, tile_pixels: int = TILE_PIXELS) -> float:
return self.units * tile_pixels / self.extent
def _walk(geometry) -> list[tuple[int, int]]:
"""Flatten any nesting depth of decoded coordinates into a point list."""
points: list[tuple[int, int]] = []
stack = [geometry]
while stack:
item = stack.pop()
if (isinstance(item, (list, tuple)) and len(item) == 2
and all(isinstance(v, (int, float)) for v in item)):
points.append((int(item[0]), int(item[1])))
elif isinstance(item, (list, tuple)):
stack.extend(item)
return points
def measure(tile_bytes: bytes) -> list[Overhang]:
decoded = mapbox_vector_tile.decode(tile_bytes)
results: list[Overhang] = []
for name, layer in decoded.items():
extent = layer.get("extent", 4096)
xs: list[int] = []
ys: list[int] = []
for feature in layer["features"]:
for x, y in _walk(feature["geometry"]["coordinates"]):
xs.append(x)
ys.append(y)
if not xs:
continue
results.append(Overhang(name, extent, min(xs), min(ys), max(xs), max(ys)))
return results
def diagnose(tile_path: Path, layer: str, styled_width_px: float) -> str:
for over in measure(tile_path.read_bytes()):
if over.layer != layer:
continue
needed_px = styled_width_px / 2.0
actual_px = over.pixels()
logger.info("%s: coords %d..%d (extent %d) = %.1f px of overhang, "
"style needs %.1f px",
layer, min(over.min_x, over.min_y),
max(over.max_x, over.max_y), over.extent,
actual_px, needed_px)
if over.units == 0:
return "GENERATOR: no buffer geometry — regenerate with a buffer"
if actual_px < needed_px:
return (f"BUFFER: {actual_px:.1f} px of overhang, "
f"{needed_px:.1f} px needed for this style")
return "CLIENT: tile has adequate buffer — the renderer is clamping"
return f"LAYER: {layer!r} not present in this tile"
if __name__ == "__main__":
verdict = diagnose(Path("14-9111-5455.mvt"), layer="transportation",
styled_width_px=10.0)
logger.info("verdict: %s", verdict)
Step-by-step walkthrough Jump to heading
- Fetch the tile from the source of truth. A browser cache can hold a tile generated before the last pipeline change, which sends you debugging a problem that no longer exists.
- Read the extent from the layer. It is declared per layer and need not be the conventional value; assuming it turns every measurement into a wrong measurement.
- Flatten geometry generically. Decoded coordinates nest differently for points, lines, polygons and multi-part geometries. A generic walk avoids a type-by-type traversal that will miss a case.
- Measure overhang in all four directions. A tile can have buffer geometry on one side and none on another, typically because the source data simply stops there.
- Convert to pixels before comparing. The style is specified in pixels and the tile in grid units; comparing them without converting is the most common analytical error here.
- Return a verdict, not a number. The function names which component to change, because the point of the exercise is to stop people raising the buffer reflexively.
- Rule out the source separately. If the verdict says the tile is fine and the renderer still shows a gap, check the source geometry before blaming the client — a genuinely disconnected way looks identical to a clipping artefact.
Verification Jump to heading
- The verdict changes when you change the input. Run the diagnosis against a deliberately unbuffered tile and confirm it reports the generator rather than the buffer.
- Overhang is reported per side. A tile at the edge of your data coverage legitimately has no overhang on the outward side.
- Pixel conversion matches the extent. Change the extent in a test tile and confirm the reported pixel figure scales inversely.
- A known-good tile passes. Diagnose a boundary where no seam is visible; it should report adequate buffer, confirming the threshold is calibrated.
- Source geometry is genuinely continuous. Load the affected feature from the extract and confirm it has no gap of its own.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Diagnosis disagrees with the map | Tile fetched from a browser cache | Fetch from the archive or with cache headers disabled |
| Overhang reported as zero everywhere | Extent assumed rather than read | Read the extent from each decoded layer |
| Some geometries not measured | Coordinate nesting handled per type | Flatten generically rather than by geometry type |
| Verdict blames the client wrongly | Comparison made in grid units | Convert grid units to pixels before comparing |
| Seam persists after raising the buffer | Cause was the renderer, not the tile | Re-run the diagnosis; only one cause responds to buffer |
| Only one side shows a seam | Data coverage genuinely ends there | Expected at the edge of an extract; not a tile defect |
Specification reference Jump to heading
Vector tile geometry coordinates may fall outside the range 0 to extent. Such coordinates represent geometry retained beyond the tile boundary so that renderers can draw features continuously across tile edges, and clients are expected to render them rather than discard them. See the Mapbox Vector Tile specification for the coordinate range and the note on geometry extending past the tile.
Frequently Asked Questions Jump to heading
Should I just increase the buffer when I see a seam?
Only if the measurement says the buffer is the cause, which it is in about one case in four. Raising it in the other three makes every tile in the set larger while leaving the seam exactly where it was. Decoding one tile and comparing its overhang against half the styled width takes a minute and tells you which of the four causes you are looking at.
Why does the seam appear at only one zoom level?
Because styled widths usually vary with zoom, while the buffer is fixed in grid units. A road drawn four pixels wide at zoom 12 and twelve pixels wide at zoom 16 needs three times the overhang at the higher zoom from the same tile buffer. Compute the requirement at the zoom where the style is widest, not at whichever zoom you happened to be looking at.
Can the renderer really be at fault?
Yes, though it is the least common cause. A client that clips geometry to the tile boundary rather than to the tile plus its buffer produces seams no amount of buffer will fix. The diagnosis is unambiguous: if the decoded tile contains geometry extending further past the edge than the style needs and a gap still renders, the tile has done its part.
How do I tell a clipping artefact from a real gap in the data?
Load the feature from the source extract and look at it there. A clipping artefact is always exactly on a tile boundary and disappears at a different zoom, because the boundaries move. A genuine gap in the source sits at the same geographic position at every zoom, which is the distinguishing test and also the reason to check the source before concluding anything about the renderer.
Related Jump to heading
- Choosing Tile Extent and Buffer Values — deriving the buffer this diagnosis measures against.
- The Mapbox Vector Tile Spec & Tile Geometry — the parent topic and the out-of-extent coordinate rule.
- Encoding OSM Geometry into MVT with Python — the encoder whose clipping behaviour is under test.
- Finding Disconnected Road Network Components — confirming whether a gap is real in the source.
- Invalidating Tile Caches After an OSM Diff — why a stale tile is a plausible first suspect.
Up one level: The Mapbox Vector Tile Spec & Tile Geometry.