Serving Indoor Vector Tiles with Tippecanoe

This page is the concrete tiling recipe for indoor floors — the exact tippecanoe flags, the per-level layer convention, and the MBTiles output — and it sits one level down inside Vector Tile Serving for Indoor Maps, which frames why indoor maps ship as tiles at all.

What Tiling Indoor Floors with Tippecanoe Means

tippecanoe is a command-line tiler that turns GeoJSON into a Mapbox Vector Tile (MVT) pyramid: a set of binary protobuf tiles addressed by z/x/y, one per zoom level and grid cell, packed into an MBTiles SQLite file or a PMTiles archive. For indoor maps the two things it does that matter most are layer assignment — each floor becomes a named layer the client can toggle — and zoom tiering with feature dropping, so a building-overview tile is not asked to carry every desk footprint.

The critical constraint that trips up first-time indoor tiling: tippecanoe tiles in Web Mercator and expects lon/lat (EPSG:4326) input. Geometry still in a building’s local Cartesian or raw CAD frame produces empty or globe-spanning tiles. Project to lon/lat first — the per-level split in the parent guide does exactly that. Everything below assumes one lon/lat GeoJSON file per level, named so the level is recoverable (level_+2.geojson, level_-1.geojson).

Minimal Working Example

Two pieces: the tippecanoe invocation for one floor, and a typed Python wrapper that runs it across every level with real error handling so a tiler failure stops the pipeline instead of silently emitting nothing.

tippecanoe \
  -o level_2.mbtiles \
  -l level_2 \
  --minimum-zoom=16 --maximum-zoom=22 \
  --drop-densest-as-needed \
  --extend-zooms-if-still-dropping \
  --no-tile-size-limit \
  --force \
  level_+2.geojson
import logging
import shutil
import subprocess
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def tile_indoor_levels(level_files: dict[int, Path], out_dir: Path, min_zoom: int = 16, max_zoom: int = 22) -> dict[int, Path]:
    """Tile one MBTiles per floor level, naming each layer so the client filter can match it."""
    if shutil.which("tippecanoe") is None:
        raise FileNotFoundError("tippecanoe not on PATH; install it before tiling")
    out_dir.mkdir(parents=True, exist_ok=True)

    outputs: dict[int, Path] = {}
    for level, src in sorted(level_files.items()):
        layer = f"level_{level:+d}".replace("+", "p").replace("-", "m")  # stable, filter-safe layer id
        dest = out_dir / f"{layer}.mbtiles"
        cmd = [
            "tippecanoe", "-o", str(dest), "-l", layer,
            f"--minimum-zoom={min_zoom}", f"--maximum-zoom={max_zoom}",
            "--drop-densest-as-needed", "--extend-zooms-if-still-dropping",
            "--no-tile-size-limit", "--force", str(src),
        ]
        try:
            subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=1800)
        except subprocess.CalledProcessError as exc:
            logger.error("tippecanoe failed (%d) for level %+d: %s", exc.returncode, level, exc.stderr.strip())
            raise
        except subprocess.TimeoutExpired:
            logger.error("tippecanoe timed out on level %+d", level)
            raise
        logger.info("tiled level %+d -> %s (layer=%s)", level, dest.name, layer)
        outputs[level] = dest
    return outputs

The layer id is derived deterministically from the level and stripped of +/- so the client’s style filter references a stable string. Signed levels matter for indoor data — basements are negative — and a +2/-1 naming scheme keeps them unambiguous through the tiler and into the client.

Tippecanoe Flag Reference

Flag Purpose Indoor default Notes
-o <file>.mbtiles Output archive one per level Use .pmtiles to write a range-request archive instead
-l <name> Force a single layer name level_p2 etc. Without it, the layer is named after the input file — keep it explicit so the client filter is stable
-Z / --minimum-zoom Lowest zoom tiled 16 Below the zoom where the building fills the screen there is nothing to show
-z / --maximum-zoom Highest zoom tiled 22 Where a door swing is legible; each extra level roughly quadruples tile count
--drop-densest-as-needed Thin only the densest tiles to fit budget on Preferred over dropping every tile at a zoom; keeps sparse areas complete
--extend-zooms-if-still-dropping Add zoom levels until dropping stops on Prevents permanent loss of dense-area detail at the max zoom
--no-tile-size-limit Do not hard-cap tile bytes on for dense floors Use with care; pair with a zoom range so tiles stay reasonable
-y <attr> / --include Keep only named attributes space_class, level, feature_id, is_routable Drop everything else to shrink tiles
--force Overwrite an existing output on in CI Idempotent re-tiling on every build

Common Errors & Fixes

Features dropped silently at low zoom. You tiled a floor and small rooms vanish when zoomed out, with no error. tippecanoe dropped them to hold the tile size budget — it reports the drop on stderr but exits 0. Capture and inspect that output, and either raise the detail layer’s minimum zoom or switch the drop policy:

proc = subprocess.run(cmd, check=True, capture_output=True, text=True)
if "dropped" in proc.stderr.lower():
    logger.warning("tippecanoe dropped features: %s", proc.stderr.strip().splitlines()[-1])
    # Raise --minimum-zoom for this layer, or add --drop-densest-as-needed so only dense tiles thin.

Wrong layer name breaks the client filter. The map renders nothing for a floor even though the tile is populated, because the client style filters on level_2 but tippecanoe named the layer after the input file (level_+2). Always pass -l with the exact id the client expects, and derive both from one shared function so they cannot drift:

def layer_id(level: int) -> str:
    return f"level_{level:+d}".replace("+", "p").replace("-", "m")  # used by BOTH tiler and client style

Unable to open / tiles land in the ocean. Either the input path is wrong (a missing per-level file) or the geometry is in the wrong coordinate frame. If the tileset builds but renders in the wrong place, the input was not lon/lat — project the envelope to EPSG:4326 before tiling. If tippecanoe cannot open the file at all, check the path the wrapper built and that the per-level split actually wrote it.

Integration Point

The tiles produced here do not stand alone. They are the output of the per-level split described in Vector Tile Serving for Indoor Maps, which also owns the hash-keyed cache and the MBTiles/PMTiles serving tier that puts these tiles in front of clients. On the consuming side, a web renderer loads the tileset and applies a per-level filter — the MapLibre GL integration is detailed in Integrating Indoor Maps with MapLibre GL, where the layer ids this recipe emits become the source-layer references in the client style.

Frequently Asked Questions

One MBTiles per level, or all levels in one file?

Either works; the trade-off is operational. One archive per level keeps each floor independently rebuildable and lets you republish a single floor without retiling the building, which suits maps where floors change on different cadences. A single archive with one layer per level is fewer files to serve and manage, and is fine when the whole building is retiled together on each deploy. The layer-per-level convention is the same in both cases — only the packaging differs.

What zoom range should indoor floors use?

Set --minimum-zoom where the building first fills the viewport — for most sites that is around z16 — and --maximum-zoom where the smallest feature you care about (a door, a desk) is legible, usually z21 or z22. There is no value in tiling below z16 for an indoor map; the building is a dot. Remember each extra maximum zoom level roughly quadruples the tile count and build time, so do not push the max higher than the detail actually requires.

How do I keep tile size down without losing rooms?

Prefer --drop-densest-as-needed over an unconditional drop so only the tiles that actually exceed the budget thin, leaving sparse corridors and lobbies complete. Trim attributes to just what the client renders and routes on with -y space_class -y level -y feature_id -y is_routable, since attribute strings are a real fraction of tile bytes. If a single oversized feature such as a site boundary dominates, simplify or split it rather than raising the whole layer’s budget.

This page sits within the Vector Tile Serving for Indoor Maps guide, part of the Production-Ready Indoor Map Deployment reference.