Building a Routing Graph from Room Polygons

The end-to-end implementation behind Indoor Routing Graph Construction: one function that takes a validated envelope and returns either a graph a router can use or a named reason it cannot.

What the Function Has to Guarantee

Graph construction is easy to write and easy to write badly, and the difference is entirely in what the function promises. Three guarantees are worth building around:

  1. It never returns an unusable graph. A disconnected graph is a failure, not a result with a warning attached. Anything that logs and continues will eventually publish a building with a stranded wing.
  2. Paths stay inside the building. Every edge between a space and the circulation network goes through an opening, which means a door node and a spur — never a direct join from a room’s interior point to the nearest skeleton vertex.
  3. It is deterministic. The same envelope produces the same graph, node ids included, because the graph’s hash feeds the content-addressed version that decides whether caches are purged.
The call sequence that turns one level's envelope into a verified graph A sequence across five participants. The envelope supplies polygons and openings for one level to the skeletoniser, which produces centrelines per space and passes them to the node placer. The node placer first merges door openings less than 0.4 metres apart into one node, then adds nodes and edges to the graph. The graph is passed to the verifier, which counts connected components and returns either the graph or a named failure to the caller. Five participants, and only one of them can say no Envelope Skeletoniser Node placer Graph Verifier polygons + openings, one level centrelines per space merge doors < 0.4 m apart add nodes + edges connected components? graph, or a named failure The verifier answers to the caller, not to the graph: a bad graph is never returned.

The verifier is in the call path, not beside it. A build that returns a disconnected graph and logs a warning will ship one; a build that returns a failure cannot.

Minimal Working Example

The whole build, for one building, with the guarantees above enforced:

import hashlib
import json
import logging
from dataclasses import dataclass

import networkx as nx
from shapely.geometry import Point, Polygon, shape

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


class GraphNotRoutable(RuntimeError):
    """Raised when the assembled graph cannot serve routes; never downgraded to a warning."""


@dataclass(frozen=True)
class BuildResult:
    graph: nx.Graph
    graph_hash: str
    stats: dict


def build_graph(envelope: dict, transitions: list[dict], *, step: float = 0.35,
                spur_len: float = 0.6, merge_doors: float = 0.4) -> BuildResult:
    """Assemble and verify a building's routing graph from a validated envelope."""
    features = envelope.get("features", [])
    if not features:
        raise ValueError("empty envelope: nothing to build a graph from")

    spaces: dict[float, dict[str, Polygon]] = {}
    openings: dict[float, list[dict]] = {}
    for f in features:
        props, level = f["properties"], float(f["properties"]["level"])
        if props.get("space_class") == "door":
            pt = shape(f["geometry"]).representative_point()
            openings.setdefault(level, []).append(
                {"id": props["feature_id"], "x": pt.x, "y": pt.y,
                 "width": props.get("width_m", 0.9),
                 "space_a": props.get("space_a"), "space_b": props.get("space_b")})
        elif props.get("is_routable"):
            spaces.setdefault(level, {})[props["feature_id"]] = shape(f["geometry"])

    g = nx.Graph()
    for level, polys in sorted(spaces.items()):
        doors = _merge_close(openings.get(level, []), merge_doors)
        g = nx.compose(g, build_level_graph(polys, doors, level, step=step, spur=spur_len))
    g = add_vertical_edges(g, transitions)

    stats = verify(g)
    if not stats["ok"]:
        raise GraphNotRoutable(
            f"{stats['components']} component(s), "
            f"{stats['unreachable_spaces']} unreachable space(s)")

    payload = json.dumps(
        {"nodes": sorted(g.nodes()), "edges": sorted(map(sorted, g.edges()))},
        separators=(",", ":"))
    graph_hash = hashlib.sha256(payload.encode()).hexdigest()[:16]
    logger.info("graph ok: %d nodes, %d edges, hash %s",
                g.number_of_nodes(), g.number_of_edges(), graph_hash)
    return BuildResult(g, graph_hash, stats)


def _merge_close(openings: list[dict], tol: float) -> list[dict]:
    """Collapse double doors into one node so instructions do not say 'turn' twice."""
    out: list[dict] = []
    for op in sorted(openings, key=lambda o: (o["x"], o["y"])):
        if out and Point(op["x"], op["y"]).distance(Point(out[-1]["x"], out[-1]["y"])) < tol:
            out[-1]["width"] += op["width"]
            continue
        out.append(dict(op))
    if len(out) != len(openings):
        logger.info("merged %d double-door opening(s)", len(openings) - len(out))
    return out

Two details in that function do more work than their line count suggests. The hash is computed over sorted node and edge ids, not over the object, so it is stable across dictionary ordering and Python versions. And the door merge runs before node placement, so a pair of double doors becomes one node with a combined width rather than two nodes 0.3 m apart that generate a spurious turn instruction between them.

Node Placement and the Spur

Why a door node needs a spur to the skeleton A room opening onto a corridor whose skeleton runs along its length. On the left, the correct arrangement: a rose space node inside the room, an amber door node placed on the opening itself, and a short 0.6 metre spur joining the door node perpendicular to the corridor skeleton. On the right, the same connection made without a spur: a dashed red line runs diagonally from inside the room straight to the skeleton, crossing the wall face on the way, marked with a red cross. One extra node per door, and paths stop crossing walls room corridor skeleton door node, on the opening spur, 0.6 m space node (the search result) without the spur: straight to the skeleton the line crosses the wall face door node + spur no spur (wrong)

The spur costs one node and fixes the rendering. Joining a room directly to the nearest skeleton point produces a diagonal that leaves the room through its wall, which is the same visual defect a centroid graph has.

The spur is the smallest part of the build and the one that decides whether routes look right.

A door node sits on the opening — the centre of the gap in the wall. A space node sits at the room’s representative point. Joining the two directly is correct. Joining the space node directly to the corridor skeleton is not, because the straight line between them passes through the wall face beside the door, and that line is what gets rendered.

So each door node gets two edges: one to the space it serves, and one to the nearest point on the corridor skeleton, perpendicular where possible. The spur length is short — 0.6 m is typical, being roughly half a corridor width — and it is charged as real distance, because it is.

The other placement rule worth stating explicitly: junction nodes come from the skeleton, not from the rooms. Where the medial axis branches, a node is created, and that node is what a turn-by-turn generator uses to emit “turn left”. A graph without junction nodes can route perfectly and cannot give directions.

Parameter Reference

Graph size and route accuracy against the densification step Two curves against the boundary densification step used before the Voronoi construction. Node count falls steeply from 2,960 at a 0.2 metre step to 1,720 at 0.35 metres and 610 at 1 metre. Route length error against actual walked distance stays under 10 centimetres up to a 0.35 metre step, then rises sharply to 19 centimetres at 0.5 metres, 62 at 0.75 and 141 at one metre, as the skeleton starts cutting corners. One parameter sets both graph size and distance honesty 0 1000 2000 3000 0.2 0.4 0.6 0.8 1 boundary densification step (m) nodes / error (cm) nodes route length error vs. walked distance (cm) 0.35 m: 1,720 nodes, 8 cm error

0.35 m is where both curves are still flat. Coarser than that the skeleton starts cutting corners, so the route under-reports the walk — which users notice as arriving later than the app said.

Parameter Type Default Notes
step float 0.35 m Boundary densification; sets node count and distance honesty
spur_len float 0.6 m Door-to-skeleton connection; ≈ half a corridor width
merge_doors float 0.4 m Openings closer than this become one node
max_edge_len float 15 m Longer skeleton edges are split for snapping resolution
min_space_area float 1.2 m² Below this a space gets no node; it is not a destination
strict bool True Raise on a non-routable graph rather than returning it

strict exists as a parameter only so that diagnostic tooling can inspect a broken graph. It must be True in the build path, and the CI gate should assert that it is — a flag that can be flipped to make a failing build pass will eventually be flipped.

Common Errors & Fixes

GraphNotRoutable: 2 component(s). The commonest cause is an opening whose space_a or space_b reference does not resolve, so a room was never joined to the corridor. Print the component sizes and the space ids in the smaller one; the answer is usually a single door feature whose reference was dropped upstream.

Every route is a few metres longer than the walk. The densification step is too coarse and the skeleton is cutting corners. The chart above quantifies it: at a 1 m step, route length under-reports by 1.4 m per corner, which compounds across a long route into a visible discrepancy between the estimate and the walk.

Instructions say “turn” in the middle of a straight corridor. Junction nodes are being created by skeleton noise rather than by real branches. Simplify each skeleton segment before adding it — seg.simplify(0.05, preserve_topology=True) removes the wobble the Voronoi introduces without moving the centreline perceptibly.

The graph hash changes on every build with identical input. Node ids are being generated from unordered iteration or from object identity. Derive them from coordinates (rounded) or from stable feature_id values, and sort before hashing — the assertion is worth automating:

def test_graph_hash_is_stable(envelope, transitions):
    a = build_graph(envelope, transitions).graph_hash
    b = build_graph(envelope, transitions).graph_hash
    assert a == b, "graph construction is not deterministic; caches will churn"

Integration Point

This build runs after geometry cleanup has produced non-overlapping room polygons and after level mapping has assigned every feature a level. Its output is consumed by accessible routing profiles, which apply weights at query time, and by fallback routing architectures, whose reconciliation tier snaps drifted endpoints onto exactly these nodes.

The graph_hash it returns joins the topology_hash in the published artifact, and the CI gate runs verify again on the candidate before promotion — the same check, run twice, because the build machine and the gate are allowed to disagree and it is much cheaper to find out there than in production.

Frequently Asked Questions

Should the graph be built per level or per building?

Per level for the horizontal structure, then composed into one building graph before verification. Levels are independent for skeletonisation and node placement, which makes them trivially parallel and keeps memory bounded; but connectivity is a building-wide property, so verifying per level would pass a building whose lift was never modelled. Composing first and verifying once is what catches the stranded-floor case, and it costs nothing because composition of disjoint graphs is cheap.

How do I add a temporary corridor closure without rebuilding?

Set an attribute on the affected edges rather than removing them. Marking an edge closed and having the weight function return infinity for closed edges keeps the topology intact, so the closure can be lifted by flipping the attribute back, and every routing profile sees it on its next query with no rebuild. Removing the edges instead would work until the closure ends, at which point restoring them means either rebuilding or reconstructing exactly the edges that were deleted — and the second is a reliable source of subtle graph corruption.

What node id scheme survives a rebuild?

Derive ids from stable inputs, not from iteration order. Space and door nodes should key off the feature_id from the envelope, which is stable across rebuilds because it comes from the source drawing or model. Skeleton nodes have no natural identifier, so key them on rounded coordinates — two decimal places is a centimetre, which is finer than any real geometry change and coarse enough that floating-point noise does not produce a new id. The test is the hash-stability assertion above: if ids are unstable, it fails.

This page is a companion to Indoor Routing Graph Construction, part of the Indoor Mapping Architecture & Standards section.