Vector Tile Serving for Indoor Maps

Shipping a whole floor’s geometry to the client as raw GeoJSON works for a demo and collapses in production: a mid-rise office floor is easily several megabytes of polygons, the client re-parses all of it on every pan, and there is no clean way to show only the level the user is on. This technique sits inside the Production-Ready Indoor Map Deployment lifecycle, downstream of validation and immediately upstream of the client SDK: once the pipeline emits a validated GeoJSON envelope, it gets cut into Mapbox Vector Tiles (MVT), separated per level, cached against the same topology_hash that identifies the artifact, and served so the client fetches only the tiles for the current viewport and zoom. Get the tiling contract right and pan/zoom stays smooth, level filtering is a single expression, and re-styling never touches the backend.

The Problem: Whole-Floor GeoJSON Does Not Scale to the Client

A validated floor is a GeoJSON FeatureCollection of rooms, corridors, doors, and vertical connectors — correct, but not a delivery format. Three things break when you hand it to a renderer unchanged.

First, payload size. Every vertex of every polygon is shipped as decimal-string JSON, at full precision, whether the user is zoomed to a single room or looking at the whole building. A floor with a few thousand rooms and their furniture footprints runs to megabytes, and the client blocks re-triangulating the lot on each frame.

Second, no level filtering primitive. When all levels live in one flat feature list, the client has to iterate the whole collection and test properties.level in JavaScript on every render. That is O(features) per frame, and it defeats any GPU-side filtering the renderer offers.

Third, no zoom-appropriateness. At building overview zoom the user cannot see a door swing or a desk, but raw GeoJSON has no notion of zoom — it hands the renderer the same ten-thousand-vertex detail whether the feature covers the screen or a single pixel.

Vector tiles solve all three by construction. Geometry is quantised to a tile grid and delivered in a compact binary protobuf; features are bucketed into named layers so a per-level layer (or a level attribute plus a filter) is a first-class primitive; and each zoom level carries only the geometry that renders meaningfully at that scale. The job of this reference is to turn a validated envelope into level-separated, zoom-tiered tiles and serve them with a cache keyed on topology, so styling stays entirely client-side.

Prerequisites & Dependencies

Before cutting a single tile, the following must be in place:

  • A validated GeoJSON envelope. Tiling assumes the geometry already passed the contract in JSON Schema Design for Indoor Maps and the topology gates in CI Gating for Map Updates. Tiling is a rendering transform; it must never be the place a self-intersecting polygon or an orphaned door is first noticed.
  • Tippecanoe. Mapbox/Felt’s tippecanoe is the tiler of record for GeoJSON → MVT. It owns feature dropping, zoom tiering, and layer assignment. The concrete flag set for indoor data is covered in the companion guide, Serving Indoor Vector Tiles with Tippecanoe.
  • A tile store and server. Either an MBTiles SQLite file behind a tile server, or a PMTiles single-file archive served over HTTP range requests with no server process at all. Both are covered below; PMTiles is the lower-operations option for read-mostly indoor maps.
  • The topology_hash. The content-addressed hash the pipeline already computes is reused as the tile cache key and the tileset’s ETag, so the tile layer and the Cache Invalidation Strategies agree on identity for free.
  • A consistent coordinate frame. Tiles are cut in Web Mercator (EPSG:3857) tile space, so the envelope’s local indoor coordinates must be projected to lon/lat (EPSG:4326) before tiling. A floor tiled in raw millimetre CAD coordinates produces empty or globe-spanning tiles.
  • Libraries. geopandas/shapely and pyproj for the per-level split and projection, subprocess to drive tippecanoe, and redis (or any KV store) for the hash-keyed tile cache.

How It Works: From Validated Envelope to Level-Filtered Tiles

The path has five stages, and each one honours a single contract at its boundary. The validated envelope is split into one GeoJSON layer per level; tippecanoe cuts those layers into a zoom-tiered MVT pyramid written to an MBTiles or PMTiles archive; a tile server (or a range-request reader) serves individual tiles, fronted by a cache keyed on topology_hash; and the client applies a level filter and its own style at render time. Nothing downstream re-derives topology — the tiles are a pure projection of an already-trusted graph.

Indoor vector tile pipeline: validated GeoJSON to level-filtered client tiles A validated GeoJSON FeatureCollection envelope enters on the left. Stage one splits it into per-level GeoJSON layers, one file per floor level, each tagged with a level attribute. Stage two runs tippecanoe, which cuts the per-level layers into a zoom-tiered pyramid of Mapbox Vector Tiles and writes them into an MBTiles SQLite archive or a PMTiles single-file archive. Stage three is the tile server or PMTiles range-request reader that serves individual z-x-y tiles; it is fronted by a cache keyed on the topology_hash so an unchanged floor is served from cache and a new hash invalidates the entry. Stage four is the wayfinding client, which fetches only the tiles covering the current map viewport at the current zoom, applies a level filter expression so only the active floor is drawn, and styles the geometry entirely on the client GPU. Topology is never re-derived downstream; the tiles are a projection of an already-validated routing graph. Validated envelope in, level-filtered tiles out — topology never re-derived downstream Validated envelope GeoJSON FeatureCollection rooms · corridors · doors topology_hash present EPSG:4326 lon/lat Per-level split one layer per level level-2 · level-1 · L0 · L1 level attribute retained tippecanoe tiling zoom-tiered MVT pyramid -Z min · -z max · drop binary protobuf per tile MBTiles / PMTiles SQLite or single-file z / x / y addressed tagged with topology_hash Wayfinding client viewport + zoom fetch level filter expression styling on the GPU Tile cache · key = topology_hash + z/x/y hit → serve bytes · miss → read archive, then store new hash = new namespace → old tiles age out, never stale the archive is the source of truth; the cache is the hot path in front of it Only visible tiles current z, x/y range active floor only

Step-by-Step Implementation

The build has three ordered steps: split the validated envelope into per-level GeoJSON layers, tile them with tippecanoe, and serve tiles behind a hash-keyed cache. The first and third are Python; the middle wraps the tippecanoe binary in a checked subprocess call.

Step 1: Prepare per-level GeoJSON layers

Tiling wants one input file per level so each becomes a named MVT layer the client can toggle without scanning the whole collection. Read the validated envelope, project from the local frame to lon/lat, group features by their level property, and write one newline-delimited GeoJSON file per level. Keep the level attribute on every feature anyway — a client that filters on an attribute is still cheaper than one shipping every level in the visible layer.

import json
import logging
from pathlib import Path
from typing import Any

import geopandas as gpd
from pyproj import CRS

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


def split_envelope_by_level(
    envelope_path: Path,
    out_dir: Path,
    local_crs: str,
) -> dict[int, Path]:
    """Split a validated GeoJSON envelope into one lon/lat GeoJSON file per level."""
    out_dir.mkdir(parents=True, exist_ok=True)
    raw: dict[str, Any] = json.loads(envelope_path.read_text())
    topo = raw.get("metadata", {}).get("topology_hash", "unknown")

    gdf = gpd.GeoDataFrame.from_features(raw["features"], crs=CRS.from_user_input(local_crs))
    gdf = gdf.to_crs("EPSG:4326")  # tippecanoe tiles in Web Mercator; feed it lon/lat

    if "level" not in gdf.columns:
        raise KeyError("envelope features carry no 'level' property; cannot separate floors")

    written: dict[int, Path] = {}
    for level, group in gdf.groupby("level"):
        dest = out_dir / f"level_{int(level):+d}.geojson"
        group.to_file(dest, driver="GeoJSON")
        written[int(level)] = dest
        logger.info("level %+d: %d features -> %s (hash=%s)", int(level), len(group), dest.name, topo[:12])

    if not written:
        raise ValueError("no levels produced; check the 'level' property values")
    return written

Step 2: Tile the layers with tippecanoe

tippecanoe does the zoom tiering and the level-to-layer assignment. The invocation below tiles one level into a named layer with a bounded zoom range and lets the tiler drop the densest features only where a tile would otherwise blow its size budget — the right default for indoor floors that stay dense right up to building-detail zoom.

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

Wrap that in a checked subprocess call so a non-zero exit or a missing binary fails loudly inside the pipeline instead of producing a silently empty tileset. The full flag reference and the failure modes of each flag are in Serving Indoor Vector Tiles with Tippecanoe.

import logging
import shutil
import subprocess
from pathlib import Path

logger = logging.getLogger(__name__)


def tile_level(src: Path, mbtiles_out: Path, layer: str, min_zoom: int = 16, max_zoom: int = 22) -> Path:
    """Tile one per-level GeoJSON into an MBTiles layer, failing loudly on tiler error."""
    if shutil.which("tippecanoe") is None:
        raise FileNotFoundError("tippecanoe not on PATH; install it before tiling")

    cmd = [
        "tippecanoe", "-o", str(mbtiles_out), "-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:
        proc = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=1800)
    except subprocess.CalledProcessError as exc:
        logger.error("tippecanoe failed (%d) for %s: %s", exc.returncode, layer, exc.stderr.strip())
        raise
    except subprocess.TimeoutExpired:
        logger.error("tippecanoe timed out tiling %s", layer)
        raise

    logger.info("tiled %s -> %s (%s)", layer, mbtiles_out.name, proc.stderr.strip().splitlines()[-1] if proc.stderr else "ok")
    return mbtiles_out

Step 3: Serve tiles behind a topology-hash cache

Serving is a lookup: a request for z/x/y becomes a read from the MBTiles SQLite (or a PMTiles range read), fronted by a cache whose key embeds the topology_hash. Embedding the hash in the key means a new artifact writes into a fresh namespace and old tiles simply age out — there is no explicit purge, and a client can never be handed a tile from a stale topology. The ETag returned is the same hash, so the client’s conditional GET short-circuits an unchanged floor.

import logging
import sqlite3
from dataclasses import dataclass
from typing import Optional

import redis

logger = logging.getLogger(__name__)


@dataclass
class Tile:
    data: bytes
    etag: str          # the topology_hash of the serving artifact
    from_cache: bool


def serve_tile(
    cache: redis.Redis,
    mbtiles: sqlite3.Connection,
    topology_hash: str,
    z: int, x: int, y: int,
    ttl_seconds: int = 86_400,
) -> Optional[Tile]:
    """Return one MVT tile, keyed on topology_hash so a new artifact never serves stale tiles."""
    key = f"tile:{topology_hash}:{z}:{x}:{y}"
    cached = cache.get(key)
    if cached is not None:
        return Tile(cached, topology_hash, from_cache=True)

    # MBTiles stores rows in TMS (y flipped) relative to XYZ.
    tms_y = (1 << z) - 1 - y
    row = mbtiles.execute(
        "SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
        (z, x, tms_y),
    ).fetchone()
    if row is None:
        logger.info("tile miss (empty) z=%d x=%d y=%d hash=%s", z, x, y, topology_hash[:12])
        return None  # a legitimately empty tile — the client draws nothing here

    data: bytes = row[0]
    try:
        cache.set(key, data, ex=ttl_seconds)
    except redis.RedisError as exc:
        logger.warning("tile cache write failed, serving from archive anyway: %s", exc)

    return Tile(data, topology_hash, from_cache=False)

Edge Cases & Gotchas

Symptom Root cause Diagnostic Remediation
Small rooms vanish at low zoom tippecanoe dropped features to hold the tile size budget Compare feature counts at -Z vs -z in the tileset Raise the minimum zoom for detail layers, or use --drop-densest-as-needed so only the densest tiles thin, not whole floors
Client shows every floor stacked Level not separated into a layer, and no filter applied Inspect the tile’s layer names in a tile debugger Emit one layer per level, or keep the level attribute and apply a client filter expression
Tiles render off the coast of Africa or nowhere Geometry tiled in raw local/CAD coordinates, not lon/lat Check the input GeoJSON’s coordinate magnitudes Project the envelope from its local frame to EPSG:4326 before tiling
Giant floor produces multi-megabyte tiles One enormous atrium or site polygon dominates a tile Look for a single feature far larger than the rest Split or simplify the outsized feature, or lower --maximum-zoom for its layer
Client sees stale geometry after a republish Cache key omitted the topology_hash Diff the served ETag against the artifact hash Embed topology_hash in the cache key so a new artifact writes a fresh namespace

The unifying rule: tiling is a lossy, zoom-aware projection of a trusted graph — every gotcha above is either a dropped feature you did not budget for, a level you did not separate, or a coordinate frame you did not project.

Validation Output

Do not eyeball the map. Assert that a specific tile contains the layer and the level features you expect, so a silent feature drop is caught in the pipeline rather than by a user standing on the third floor:

import gzip
import sqlite3

import mapbox_vector_tile  # decodes MVT protobuf


def assert_tile_has_level(mbtiles_path: str, z: int, x: int, y: int, layer: str, level: int) -> None:
    conn = sqlite3.connect(mbtiles_path)
    tms_y = (1 << z) - 1 - y
    row = conn.execute(
        "SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
        (z, x, tms_y),
    ).fetchone()
    assert row is not None, f"tile {z}/{x}/{y} is empty"

    raw = row[0]
    decoded = mapbox_vector_tile.decode(gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw)
    assert layer in decoded, f"layer '{layer}' missing from tile {z}/{x}/{y}"

    levels = {f["properties"].get("level") for f in decoded[layer]["features"]}
    assert level in levels, f"level {level} not present in layer '{layer}': saw {levels}"


assert_tile_has_level("level_2.mbtiles", 20, 172089, 351776, "level_2", 2)

If that assertion fails on layer missing, the level was tiled into the wrong layer name and the client filter will never match it. If it fails on level not present, the features for that floor were dropped at the requested zoom — raise the minimum zoom or relax the drop policy.

Performance & Scale Notes

  • Budget the tile, not the floor. Aim for tiles under ~500 KB before compression; a well-tiered pyramid keeps overview zooms sparse and pushes room-level detail to z ≥ 19. The MVT protobuf plus gzip typically lands an order of magnitude below the equivalent GeoJSON.
  • Zoom range is a coverage decision. Indoor maps rarely need anything below z16; set --minimum-zoom where the building first fills the screen and --maximum-zoom where a door swing is legible. Every extra zoom level roughly quadruples the tile count.
  • PMTiles for read-mostly maps. A PMTiles archive is a single file served over HTTP range requests with no tile server process — the client fetches byte ranges directly from object storage or a CDN. For indoor maps that change on a deploy cadence rather than continuously, this removes an entire serving tier.
  • Cache the hot pyramid, not everything. Users concentrate around entrances, lobbies, and their own floor. A hash-keyed cache with a day-long TTL absorbs the repeated fetches, and because the key carries the topology_hash, a republish never requires an explicit purge — the old namespace simply expires.
  • Separate layers keep the client cheap. One layer per level means the renderer’s filter is a layer toggle, not a per-feature attribute test on every frame, and the GPU never uploads geometry for floors the user is not on.

Frequently Asked Questions

Should I separate levels into layers or use a single layer with a level attribute?

Prefer one layer per level for interactive indoor maps. A per-level layer lets the client switch floors with a single layer toggle, and the renderer never uploads geometry for floors that are not visible. A shared layer plus a level filter expression works and is simpler to tile, but the client then carries every floor’s geometry and evaluates the filter per feature. A common middle ground is a layer per level for the interactive floors plus the level attribute retained on every feature for querying and picking.

Why do small rooms disappear when I zoom out?

Because tippecanoe drops features to keep each tile inside its size budget, and at low zoom a small room is a sub-pixel polygon it can safely shed. That is usually correct — nobody reads a desk footprint at building-overview zoom. If specific features must survive, raise the minimum zoom of the detail layer so those features simply are not requested until they are legible, or use --drop-densest-as-needed so only the densest tiles thin rather than every tile at that zoom.

MBTiles or PMTiles for serving?

MBTiles is a SQLite database and needs a small tile server to answer z/x/y reads; it suits maps behind an application server that is already handling auth and the cache. PMTiles is a single archive designed for HTTP range requests, so the client reads tiles straight from a CDN or object store with no server process — the lower-operations choice for indoor maps that update on a deploy cadence. Both hold the same MVT tiles; the difference is purely how they are served.

How does tile caching stay consistent with map updates?

By keying the cache on the topology_hash. Each cache entry is tile:{topology_hash}:{z}:{x}:{y}, so a new artifact — which by definition has a new hash — writes into a fresh namespace and the old tiles age out on their TTL. There is no explicit purge to get wrong, and a client can never be handed a tile from a superseded topology. This is the same content-addressed identity the cache invalidation strategy uses across the delivery path.

This page is part of the Production-Ready Indoor Map Deployment reference; the tiles it produces are validated upstream by CI Gating for Map Updates.