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.
Related
- Vector Tile Serving for Indoor Maps — the per-level split, hash-keyed cache, and serving tier this recipe feeds.
- Integrating Indoor Maps with MapLibre GL — the web client that loads these tiles and filters them by level.
- Production-Ready Indoor Map Deployment — the delivery lifecycle these tiles are one stage of.
This page sits within the Vector Tile Serving for Indoor Maps guide, part of the Production-Ready Indoor Map Deployment reference.