How to Define an Indoor CRS for Multi-Building Campuses

This page covers the one technique that keeps a campus routable end to end — collapsing every building’s private drawing frame onto a single surveyed plane — and it sits under Indoor Coordinate Reference Systems within the broader Indoor Mapping Architecture & Standards reference. When that plane is shared, a corridor in Building A and a skybridge into Building B agree on what “one metre north” means; when it is not, the routing graph silently fragments at every building boundary.

Concept Definition

A campus-scale indoor CRS is a single facility-local Cartesian frame, metric, orthogonal, and anchored to one surveyed origin, into which every building’s geometry is transformed before it reaches a spatial database or routing graph. The origin is fixed to a permanent reference — a surveyed geodetic control marker, a structural column-grid intersection, or a high-precision GNSS point — and never moves once published.

Each building arrives in its own local frame, carrying its own drawing unit, (0,0) anchor, and User Coordinate System rotation. Unifying them is a per-building 2D similarity (Helmert) transform — translation, rotation, and a single uniform scale — solved from surveyed control points, plus a shared vertical datum so floor levels stack consistently. The transform is valid only when its residual error is small; an affine transform is the fallback when a building’s drawing carries non-uniform scale (different X and Y units), at the cost of one extra degree of freedom. Three invariants must hold across the whole campus:

  1. Consistent horizontal datum — all CAD/BIM exports project to a common local tangent plane or state-plane system before the per-building offset is applied.
  2. Deterministic vertical datum — floor elevations resolve to one vertical reference (NAVD88, EGM2008, or a surveyed site benchmark) with Z = 0 at the ground-floor finished floor level.
  3. Scale preservation — legacy export artifacts (1:100 vs 1:50 mismatches) are absorbed by explicitly solving for uniform scale, never by eyeballing.
From three private drawing frames to one campus plane and a connected routing graph On the left, three buildings each arrive in their own local frame, drawn as small squares rotated at different angles with their own origin and drawing scale. Each is fed into a per-building 2D Helmert solve — translation, rotation and one uniform scale — driven by surveyed control points and gated so any building whose residual RMS exceeds 0.15 metres is rejected. The three solved buildings converge onto one shared surveyed campus Cartesian plane with a single fixed origin and a common metric grid. That plane produces a single routing graph: building footprints A, B and C carry internal nodes, and adjacent buildings are joined by dedicated portal nodes — a skybridge between A and B and a tunnel between A and C — whose coordinates are byte-identical in both datasets so the graph stays one connected component. Many private frames → one surveyed campus plane → one connected routing graph LOCAL FRAMES PER-BUILDING SOLVE CAMPUS PLANE ROUTING GRAPH Building A own origin 1:100 · CAD north Building B own origin 1:50 · mm units Building C own origin 1:100 · ft units Helmert solve translate · rotate 1 uniform scale surveyed control points RMS ≤ 0.15 m gate one fixed origin A B C shared metric grid A B C skybridge portal tunnel portal portal nodes: exact coordinate parity

Minimal Working Example

The core technique is solving the per-building similarity transform from at least three non-collinear control points and reporting the residual RMS in metres. This self-contained routine returns the 3×3 affine matrix and the RMS, the single number that gates whether the building may be published.

import logging
from typing import Tuple
import numpy as np

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def solve_campus_helmert(
    source: np.ndarray, target: np.ndarray
) -> Tuple[np.ndarray, float]:
    """Solve a 2D similarity (Helmert) transform mapping a building's local
    frame onto the surveyed campus plane; return (3x3 affine, RMS in metres)."""
    if source.shape[0] < 3:
        raise ValueError("Need >=3 non-collinear control points for scale+rotation.")
    src_c = source - source.mean(axis=0)
    tgt_c = target - target.mean(axis=0)
    a = np.sum(src_c[:, 0] * tgt_c[:, 0] + src_c[:, 1] * tgt_c[:, 1])
    b = np.sum(src_c[:, 0] * tgt_c[:, 1] - src_c[:, 1] * tgt_c[:, 0])
    denom = np.sum(src_c ** 2)
    if denom == 0:
        raise ValueError("Degenerate control points: source points coincide.")
    scale = np.hypot(a, b) / denom
    theta = np.arctan2(b, a)
    rot = scale * np.array([[np.cos(theta), -np.sin(theta)],
                            [np.sin(theta),  np.cos(theta)]])
    trans = target.mean(axis=0) - rot @ source.mean(axis=0)
    fitted = (rot @ source.T).T + trans
    rms = float(np.sqrt(np.mean(np.sum((fitted - target) ** 2, axis=1))))
    logging.info("scale=%.6f theta=%.4f rad RMS=%.4f m", scale, theta, rms)
    return np.vstack([np.column_stack([rot, trans]), [0, 0, 1]]), rms

Call it once per building with that building’s surveyed control points (source = local drawing coordinates, target = campus-plane coordinates), then refuse to publish any building whose RMS exceeds the threshold below.

Parameter & Threshold Reference

Parameter Type Default / Threshold Notes
source, target np.ndarray shape (N, 2) Paired control points in metres; N ≥ 3, non-collinear.
Control points per building int 3 minimum, 5+ recommended Extra points let RMS detect a bad pick; collinear sets are rejected.
max_rms_threshold float 0.15 m Publish gate. Above this, the transform is unfit for routing.
Horizontal datum EPSG / string local tangent plane or state plane Common across all buildings before per-building offset.
Vertical datum string NAVD88 / EGM2008 / site benchmark One reference campus-wide; mixing them stacks floors wrong.
Ground-floor Z float 0.0 m at FFL Logical level indices (B1, L1, L2) map to physical Z-offsets.
Portal coordinate tolerance float 0.0 m (exact parity) Skybridge/tunnel endpoints must be byte-identical in both graphs.
Z-plane bbox overlap float 0.3 m horizontal max Larger overlap between adjacent floor levels signals datum misalignment.
Transform model enum helmert | affine Use affine only when X and Y scales genuinely differ.

Common Errors & Fixes

RuntimeError: RMS 0.83m exceeds threshold 0.15m — almost always a scale artifact: one building’s drawing was exported at 1:50 while the campus grid assumes 1:100, so its geometry is uniformly stretched. Because the Helmert solver returns scale explicitly, inspect it — a value near 2.0 or 0.5 confirms a unit/scale mismatch rather than a survey error. Re-export the offending DWG at the correct scale (or pre-multiply its coordinates) and re-solve; do not raise the threshold to force a pass.

Stairwell routing failures / “elevator shaft clipping” — the horizontal transform is clean but the vertical datum is not, so two floor levels occupy overlapping Z ranges. Flag the geometry whose Z falls outside its building envelope before ingestion:

import logging

def flag_z_outliers(features: list[dict], z_min: float, z_max: float) -> list[str]:
    """Return ids of features whose elevation escapes the building envelope."""
    bad = [f["id"] for f in features
           if not (z_min <= f["properties"]["z"] <= z_max)]
    if bad:
        logging.warning("Z-datum misaligned: %d feature(s) outside envelope", len(bad))
    return bad

If this returns more than ~5% of a building’s features, the vertical datum is offset from the horizontal CRS — cross-reference laser-scanned point clouds against the CAD baseline to find the systematic shift, and resolve floor indices the way Converting CAD Elevations to Indoor Z-Levels describes.

Graph disconnection at a skybridge or tunnel — both buildings solve cleanly in isolation, yet the routing graph splits into two components. The cause is a portal node whose coordinates differ by a few centimetres between the two building datasets. Cross-building transitions need dedicated portal nodes whose coordinates are exactly shared, expressed in the campus GeoJSON envelope so both graphs snap to one vertex:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": "portal-skybridge-A3-B2",
      "geometry": { "type": "Point", "coordinates": [1042.50, 2080.75] },
      "properties": { "kind": "skybridge", "z": 7.20, "level_id": "L2",
                      "connects": ["bldg-A", "bldg-B"] }
    }
  ]
}

Integration Point

This transform is the contract every downstream stage assumes. Geometry only reaches it after vector ingestion — the SVG/DWG parsing workflows deliver a unit-aligned FeatureCollection, and the campus CRS then anchors it to the surveyed datum. Vertical resolution is handed to Level Mapping & Z-Axis Logic, which turns clustered Z-planes into routing-ready level IDs. Once geometry is on the shared plane, Z-axis routing weights and the exact-parity portal nodes above feed the wayfinding engine, while Fallback Routing Architectures rely on consistent grid alignment so dead-reckoning corridor vectors stay valid when RTLS or BLE positioning drops out. Publish the result through the envelope defined in JSON Schema Design for Indoor Maps so the campus plane and its POI placements stay interoperable across SDKs.

This page sits under the Indoor Coordinate Reference Systems collection, part of the Indoor Mapping Architecture & Standards reference.