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).

The tippecanoe flags an indoor tileset depends on A reference table of seven tippecanoe flags. Minimum and maximum zoom are set to 14 and 18 because indoor detail is only legible above zoom 16. Feature limits and tile size limits must be disabled, because a dropped room is an unroutable room and dense floors legitimately exceed the default 500 kilobyte tile budget. A layer per building level lets the client filter without parsing feature properties. The drop-densest-as-needed flag must never be used indoors, since it silently deletes features. Detail of 12 to 14 gives sub-centimetre precision. Generating ids provides stable feature identifiers for feature state. Two flags decide whether the tileset is complete or merely pretty Flag Value Why indoors -Z / -z 14 / 18 buildings are only legible above z16 --no-feature-limit set △ a dropped room is an unroutable room --no-tile-size-limit set △ dense floors exceed 500 KB legitimately -l / --layer per level ● lets the client filter without parsing --drop-densest-as-needed △ never △ silently deletes features --detail 12 - 14 sub-centimetre precision indoors --generate-ids set stable feature ids for feature state The two dropping flags are the difference between a pretty map and a correct one.

Tippecanoe's defaults optimise for cartography. Dropping the densest features keeps a world map legible; indoors it removes exactly the cluttered lobby a wayfinder needs, and nothing in the output says a feature is missing.

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
Feature retention by zoom level, with and without density-based dropping Two series across zoom levels 14 to 18 for a dense ground-floor lobby. With limits disabled, one hundred percent of features are present at every zoom. With drop-densest-as-needed enabled, only 61 percent survive at zoom 14, 74 percent at 15, 88 percent at 16 and 96 percent at 17, reaching full coverage only at zoom 18. The features removed are concentrated in exactly the busiest part of the floor. Density dropping thins the lobby first 60 70 80 90 100 14 15 16 17 18 zoom level features present in the tile (%) features retained (%) features retained with --drop-densest-as-needed (%) 39% of the lobby gone at z14

The loss is worst where it matters most. Density-based dropping removes features from the most crowded areas — lobbies, retail concourses — which are precisely the places people need wayfinding.

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:

Tracing a feature that is missing from a served tile One diagnostic question with four outcomes. The feature may have been absent from the envelope and never reached the tiler at all. It may have been reprojected outside the tileset's bounding box, which happens when local metres are treated as degrees. It may fall below the tileset's minimum zoom for the viewport being displayed. Or tippecanoe may have dropped it under a density or size limit, which produces no error and is only visible by comparing feature counts before and after tiling. Four places a feature can vanish, three of them silently A feature is missing from the served tile. Where did it go? four stages can drop it, and only one of them logs anything absent upstream Envelope never reached the tiler outside the bbox Reprojection metres treated as degrees below min zoom Zoom range -Z set too high for this viewport dropped by tippecanoe Density limits silent — check the feature count diff

Only the first branch is visible without instrumentation. That is why the build compares the envelope's feature count against the tileset's and fails on any discrepancy — otherwise silent dropping is indistinguishable from a source gap.

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.