Indoor Mapping Architecture & Standards: Engineering Production-Grade Spatial Systems
Indoor mapping operates at a fundamentally different scale and tolerance than outdoor GIS. Sub-meter precision, strict vertical topology, and continuous semantic enrichment are non-negotiable when the same dataset must drive turn-by-turn wayfinding, real-time asset tracking, emergency egress, and space-utilization analytics off a single source of truth. A facility that “looks right” in a CAD viewer routinely fails routing the moment two corridors fail to snap, a floor level is mislabelled, or a door is geometrically present but semantically invisible. This guide is the reference architecture for the whole discipline: the deterministic pipeline, the spatial data model, the Python that builds a routing graph, the failure modes that bite in production, and the operational gates that keep a living indoor map from silently drifting out of correctness.
topology_hash, and all stages share one canonical GeoJSON FeatureCollection.Layered Architecture & Data Pipelines
A resilient indoor mapping platform decouples concerns across five deterministic stages. This separation prevents cascading failures during ingestion, lets routing compute scale independently of authoring, and guarantees that what ships to a client is exactly what passed validation. Each stage consumes a well-defined artefact and emits a stricter one; nothing downstream re-derives geometry it could have inherited.
- Ingestion — Accepts CAD (DWG/DXF), BIM (IFC), Revit exports, and LiDAR point clouds. Normalizes drawing units, strips non-geometric metadata, and aligns every drawing to a consistent origin. Because raw deliverables are heterogeneous and frequently malformed, ingestion is where the upstream Automated Floor Plan Parsing & Vectorization workflows hand off topologically valid GeoJSON — this layer assumes geometry has already been traced, not pixels.
- Spatial Processing — Cleans topology, resolves polygon overlaps, snaps dangling nodes, and projects every floor plane into one consistent framework. The correctness of this stage rests entirely on a well-defined Indoor Coordinate Reference System; without a metric, orthogonal, survey-anchored datum, downstream distance weights and tile coordinates are meaningless.
- Semantic Enrichment — Maps spatial primitives (rooms, corridors, doors, elevators, mechanical zones) to controlled vocabularies and attaches operational metadata (capacity, HVAC zone, security clearance). The classification contract is governed by POI Taxonomy & Classification, which is what turns inert geometry into routable, filterable assets.
- Routing Graph Construction — Converts validated topology into a directed routing graph with weighted edges for pedestrian movement, accessibility, and emergency egress. Vertical movement between floor levels is modelled explicitly via Level Mapping & Z-Axis Logic; horizontal continuity depends on door and threshold edges identified during parsing by Wall & Door Detection Algorithms.
- Delivery & API — Exposes vector tiles, GeoJSON/FlatGeobuf endpoints, and REST/gRPC routing services tuned for mobile SDKs, web clients, and sensor fusion. When the primary routing service degrades, the contract for graceful degradation lives in Fallback Routing Architectures.
Each artefact crossing a stage boundary should be content-addressed with a topology_hash so the system can prove that a deployed map is the exact one that passed validation. Data drift, inconsistent floor numbering, and unvalidated topology remain the three primary failure modes in indoor navigation systems — and all three are caught at stage boundaries, never in the client.
Core Data Model & Spatial Schema
Every stage reads and writes one canonical envelope: a GeoJSON FeatureCollection carrying a top-level metadata block and floor-level features. Keeping a single schema across the pipeline means validation rules, tile builders, and routing all parse the same payload, and it keeps this site’s content consistent with the contract enforced by JSON Schema Design for Indoor Maps.
{
"type": "FeatureCollection",
"metadata": {
"map_version": "2.4.1",
"schema_revision": "v1.3.0",
"generated_at": "2026-06-26T08:30:00Z",
"topology_hash": "sha256:a1b2c3d4e5f6",
"coordinate_system": "EPSG:32610",
"building_id": "HQ-WEST",
"level_index": [-1, 0, 1, 2]
},
"features": [
{
"type": "Feature",
"id": "room-101",
"geometry": { "type": "Polygon", "coordinates": [[[0, 0], [10, 0], [10, 8], [0, 8], [0, 0]]] },
"properties": { "category": "office", "floor_level": 0, "is_routable": false, "capacity": 6 }
},
{
"type": "Feature",
"id": "door-101a",
"geometry": { "type": "Point", "coordinates": [10, 4] },
"properties": { "category": "door", "floor_level": 0, "is_routable": true, "access": "step_free" }
}
]
}
The fields that matter most for correctness are deliberately small in number:
| Field | Type | Scope | Notes |
|---|---|---|---|
map_version |
string (SemVer) | metadata | Drives client cache invalidation and Rollback Triggers & Versioning. |
topology_hash |
string (sha256:…) |
metadata | Content hash of the validated geometry; proves byte-for-byte reproducibility, never call it a checksum. |
coordinate_system |
string (EPSG) | metadata | Must resolve to a metric projected framework, not a geographic one for routing math. |
floor_level |
integer | feature | Signed level index (-1 basement, 0 ground); the join key for vertical edges. |
category |
string (enum) | feature | Controlled vocabulary from the taxonomy; governs routing weight and search. |
is_routable |
boolean | feature | Whether the feature contributes nodes/edges to the routing graph. |
access |
string (enum) | feature | step_free, stairs_only, restricted — feeds accessibility routing. |
Two conventions keep the model deterministic. First, floor_level is an integer index, never a display string: B1, G, M, 1 are presentation labels mapped at render time, so that arithmetic on level indices (e.g. “how many floors does this elevator span”) stays unambiguous. Second, geometry is stored in the building’s projected framework with metres as the unit, so edge weights are travel distances directly, with no per-query unit conversion.
Python Implementation Pattern
The following module ingests floor-level geometry, validates it, builds a directed routing graph with explicit vertical edges, and answers a shortest-path query. It uses geopandas, shapely, networkx, and pyproj, with typed signatures, structured logging, and explicit handling of the two failure modes that actually occur in production: invalid geometry on ingest and the absence of any path at query time.
import logging
from typing import Iterable, Sequence
import geopandas as gpd
import networkx as nx
from shapely.geometry import LineString, Point, Polygon
from shapely.validation import make_valid
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("indoor_routing")
Coord = tuple[float, float]
class IndoorRoutingGraph:
"""Builds and queries a directed indoor routing graph from validated floor geometry."""
def __init__(self, crs: str = "EPSG:32610", level_height_m: float = 4.0) -> None:
self.crs = crs
self.level_height_m = level_height_m # vertical penalty per floor on connectors
self.graph: nx.DiGraph = nx.DiGraph()
def ingest_and_validate(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Normalize the coordinate framework, repair invalid geometry, drop empties."""
try:
gdf = gdf[~gdf.geometry.is_empty].copy()
gdf["geometry"] = gdf.geometry.apply(make_valid)
gdf = gdf.to_crs(self.crs)
except (ValueError, TypeError) as exc:
# Most common: a feature with a missing/garbled CRS or null geometry.
logger.error("Ingest failed during validation/reprojection: %s", exc)
raise
logger.info("Validated %d floor-level primitives in %s", len(gdf), self.crs)
return gdf
def build(self, gdf: gpd.GeoDataFrame) -> None:
"""Add horizontal edges from routable lines/polygons, then stitch vertical connectors."""
for _, row in gdf.iterrows():
if not row.get("is_routable", False):
continue
geom = row.geometry
level = int(row.get("floor_level", 0))
coords = self._edge_coords(geom)
for u, v in zip(coords, coords[1:]):
weight = Point(u).distance(Point(v))
self.graph.add_edge(
(u, level), (v, level), weight=weight, mode="walk", floor_level=level
)
self._add_vertical_edges(gdf)
logger.info(
"Routing graph built: %d nodes, %d edges",
self.graph.number_of_nodes(),
self.graph.number_of_edges(),
)
@staticmethod
def _edge_coords(geom: object) -> Sequence[Coord]:
if isinstance(geom, LineString):
return list(geom.coords)
if isinstance(geom, Polygon):
return list(geom.exterior.coords)
return []
def _add_vertical_edges(self, gdf: gpd.GeoDataFrame) -> None:
"""Connect elevators/stairs that share an (x, y) footprint across adjacent levels."""
connectors = gdf[gdf["category"].isin(("elevator", "stairs", "escalator"))]
for _, row in connectors.iterrows():
node = (row.geometry.centroid.coords[0], int(row["floor_level"]))
below = (node[0], node[1] - 1)
if below in self.graph:
penalty = self.level_height_m * (3.0 if row["category"] == "stairs" else 1.2)
self.graph.add_edge(node, below, weight=penalty, mode=row["category"])
self.graph.add_edge(below, node, weight=penalty, mode=row["category"])
def route(self, start: Coord, start_level: int, end: Coord, end_level: int) -> list:
"""Shortest path with nearest-node snapping; returns [] when no path exists."""
if self.graph.number_of_nodes() == 0:
raise RuntimeError("Graph is empty — call build() before route().")
s = self._nearest((start, start_level))
t = self._nearest((end, end_level))
try:
path = nx.shortest_path(self.graph, s, t, weight="weight")
except nx.NetworkXNoPath:
logger.warning("No route between %s and %s — check vertical connectivity", s, t)
return []
logger.info("Route found: %d segments", len(path))
return path
def _nearest(self, target: tuple[Coord, int]) -> tuple[Coord, int]:
(tx, ty), lvl = target
same_level = [n for n in self.graph.nodes if n[1] == lvl]
pool = same_level or list(self.graph.nodes)
return min(pool, key=lambda n: Point(n[0]).distance(Point((tx, ty))))
Three production notes turn this from a worked example into a deployable component:
- Replace the linear
_nearestscan with anrtreeorscipy.spatial.KDTreeindex for sub-10 ms snapping on large floor sets; the linear scan isO(n)per query and degrades badly past a few thousand nodes. - Make edge weights pluggable: accessibility (
access == "stairs_only"should be infinite for step-free routing), security zones, and live congestion feeds all modify the sameweightattribute rather than forking the graph. - Serialize with
networkx.readwrite.json_graphornx.write_gexf().nx.write_gpickle()was removed in NetworkX 3.0; JSON serialization also keeps the graph diffable in CI Gating for Map Updates.
Validation & Failure Modes
Indoor systems fail quietly. A skipped check at one stage produces output that parses cleanly and routes wrongly. The table below is the concrete contract for “what breaks when this stage is misconfigured,” and each item should map to an automated assertion, not a code review comment.
| Stage skipped/misconfigured | Symptom in production | Deterministic guard |
|---|---|---|
| Geometry validation on ingest | Self-intersecting rooms; make_valid errors deep in graph build |
Reject any feature where geom.is_valid is false before it enters the pipeline |
| Coordinate framework not metric | Edge weights in degrees; routes prefer absurd paths | Assert CRS.is_projected and that a 1-unit step equals 1 metre |
| Floor level stored as string | Vertical edges never join; “no path” between floors | Assert floor_level is an integer and present on every routable feature |
| Door/threshold edges missing | Rooms are isolated islands; routing returns [] |
Cross-check routable openings against parsed doors; assert no orphan corridor components |
| Connector centroids not snapped | Elevator nodes float; _add_vertical_edges silently adds nothing |
Assert every connector has a matching node on at least one adjacent level |
No topology_hash on export |
Deployed map cannot be proven equal to the validated one | Refuse to publish a FeatureCollection lacking a content hash |
The single most valuable test is a connectivity assertion: after build(), the routing graph for one building should be a single weakly-connected component (or a documented, known set). A sudden jump in component count is the earliest signal that a snap tolerance, a missing door, or a renumbered floor has fractured the map.
def assert_connected(graph: nx.DiGraph, max_components: int = 1) -> None:
components = nx.number_weakly_connected_components(graph)
logger.info("Routing graph has %d weakly-connected component(s)", components)
if components > max_components:
raise AssertionError(
f"Topology fractured: {components} components (expected <= {max_components})"
)
Operational Considerations
A correct map on a developer’s laptop is not the deliverable; a continuously correct map served to thousands of clients is. Four operational concerns separate a demo from a deployment.
Versioning. Treat the FeatureCollection as a versioned artefact. SemVer map_version distinguishes a PATCH (coordinate snapping, cache manifest tweak — safe to hot-patch mid-session) from a MAJOR (renumbered floors, schema break — requires client refresh). The mechanics of promotion and reversal are owned by Rollback Triggers & Versioning.
CI gating. No map reaches production without passing topology validation, the connectivity assertion, and spatial regression against golden-path datasets. These gates are formalised in CI Gating for Map Updates, and the broader release discipline lives across Production-Ready Indoor Map Deployment.
Cache invalidation. Vector tiles and routing graphs are cached aggressively at the edge. A new map_version plus topology_hash is the signal clients use to discard stale tiles; the strategy for propagating that signal without a stampede is covered in Cache Invalidation Strategies.
Observability. Track routing latency, cache hit rate, graph rebuild time, and — critically — coordinate transformation residuals. Rising residuals are the fingerprint of survey drift long before any user reports a misplaced room. Use GeoPackage or FlatGeobuf for versioned storage and generate tiles via tippecanoe or ogr2ogr with fixed zoom thresholds so two builds of the same source are bit-identical. The OGC IndoorGML standard provides a formal exchange schema that reduces custom ETL when integrating third-party vendors, and the NetworkX documentation covers the graph serialization and shortest-path internals used above.
Frequently Asked Questions
Why not just reuse an outdoor GIS stack (WGS84 / EPSG:4326) for indoor maps?
Geographic coordinate systems express position in degrees, so straight-line distance is not metric and varies with latitude. Indoor routing needs edge weights that are travel distances in metres, and sub-metre features near walls suffer from floating-point precision loss in degree space. Anchor each building to a projected, metric framework as described in Indoor Coordinate Reference Systems, and only convert to a geographic system at the very edge of delivery if a consumer requires it.
How should floor levels be modelled so vertical routing actually works?
Store floor_level as a signed integer index and join vertical edges on it; keep display labels (B1, G, M) as a separate mapping applied at render time. Connectors — elevators, stairs, escalators — must have centroids that snap to a node on each adjacent level, or the graph will look complete while silently refusing cross-floor routes. The full treatment is in Level Mapping & Z-Axis Logic.
What is a topology hash and why publish one with every map?
It is a content hash (e.g. sha256:…) computed over the validated geometry and embedded in the metadata block. It lets edge nodes and clients prove that the map they received is byte-for-byte the one that passed CI, drives cache invalidation, and makes rollbacks deterministic. It is the integrity anchor for everything downstream — never substitute an ad-hoc field name like “checksum,” because the rest of the pipeline keys off topology_hash.
Which routing algorithm should I use — Dijkstra, A*, or Contraction Hierarchies?
Dijkstra is correct and sufficient for single-building graphs of a few thousand nodes. A* with a Euclidean heuristic helps on large campuses with many edges per query. Contraction Hierarchies pay off only when you precompute once and serve very high query volumes. Whatever you choose, the weight function — not the algorithm — encodes accessibility and security; an infinite weight on stairs_only edges is how step-free routing is expressed.
What happens to routing when the primary service degrades?
Production deployments must degrade gracefully to a cached routing graph or a simplified heuristic path rather than returning errors mid-navigation. The patterns for cached-graph fallback, staleness bounds, and safe heuristic substitution are detailed in Fallback Routing Architectures.
How do parsed CAD drawings become the validated GeoJSON this architecture assumes?
Ingestion here assumes geometry, not pixels or raw DWG entities. The tracing, layer resolution, and threshold detection happen upstream in Automated Floor Plan Parsing & Vectorization and its Wall & Door Detection Algorithms; the output of that work is the topologically valid FeatureCollection this pipeline ingests and validates.
Related
- Indoor Coordinate Reference Systems — metric, orthogonal, survey-anchored frameworks for facility geometry.
- Level Mapping & Z-Axis Logic — modelling vertical connectivity and resolving floor-numbering ambiguity.
- POI Taxonomy & Classification — controlled vocabularies that make geometry routable and searchable.
- Fallback Routing Architectures — graceful degradation when routing services fail.
- JSON Schema Design for Indoor Maps — the validation contract for the
FeatureCollectionenvelope above.
This page anchors the indoor mapping architecture section of the site — return to the home overview to explore the floor-plan parsing and production deployment sections that feed into and consume this architecture.