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.

Four causes of a tile seam, distinguished by two measurements A decision node taking the decoded coordinate range and the styled width as input, with four outcomes. If no coordinate lies outside the extent, the generator clipped at the boundary and must be re-run with a buffer. If geometry extends outside but by less than half the styled width, the buffer is too narrow for the current style. If the overhang exceeds the requirement and a seam still appears, the renderer is clamping out-of-extent geometry. If the same gap exists in the source geometry, there is no tile problem at all. Two measurements separate all four causes Overhang versus styled width? Decode first, then compare Do not raise the buffer yet No overhang at all Generator clipped at the edge; regenerate with a buffer Overhang too small Buffer is narrower than half the current styled width Overhang sufficient Renderer is clamping; the tile is fine Gap in the source Not a tile problem: the geometry is genuinely broken Raising the buffer fixes exactly one of these four, and makes every tile larger in the three cases where it was never the cause.
The last branch is worth ruling out early: a real gap in the source data looks identical to a clipping artefact.

Runnable solution Jump to heading

python
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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
The order to check things in when a seam appears Four checks in order. First confirm the tile being examined is the one actually being rendered, by fetching from the archive rather than a browser cache. Second decode the tile and measure how far geometry extends past the extent in each direction. Third convert that overhang into pixels and compare it against half the styled line width at the affected zoom. Fourth, if the tile is adequate, inspect the source geometry for a genuine gap before concluding the renderer is at fault. Four checks, cheapest first fetch fresh bypass the cache rules out a stale tile measure overhang per layer, per side read the real extent convert and compare grid units to pixels against half the width check the source before blaming the client a real gap looks the same The first check costs seconds and resolves a surprising share of reported seams, because tile caches are long-lived by design.
Each step is cheaper than the one after it, and each can end the investigation on its own.
How each of the four seam causes behaves under three diagnostic probes Three panels describing what changes and what does not for each cause. The zoom probe asks whether the seam moves with zoom: tile-caused seams follow the tile boundaries and move, while a real gap in the source stays at one geographic position. The decode probe asks whether the tile contains geometry past the extent: absent for a generator problem, present but short for a buffer problem, and ample when the client is at fault. The regenerate probe asks whether raising the buffer helps: it helps only in the buffer case and costs bytes in every other. Three probes, and each cause answers them differently Zoom probe Move the zoom, watch the gap Tile causes: it moves Source gap: it stays put Cheapest possible test Do this one first Decode probe Look past the extent No overhang: generator Short overhang: buffer Ample overhang: client One decode answers it Regenerate probe Raise the buffer, rebuild Helps: it was the buffer No change: it was not Costs bytes either way Do this one last The third probe is the one people reach for first, and it is both the slowest and the only one that makes things worse when wrong.
Running the probes in this order means most reported seams are explained before anything is regenerated.

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.

Up one level: The Mapbox Vector Tile Spec & Tile Geometry.