Medial Axis vs. Visibility Graph for Indoor Routing

Part of Indoor Routing Graph Construction: the choice between two ways of turning a space into a network, why it is made per space rather than per building, and the quadratic term that decides where the boundary sits.

What the Two Structures Claim

A medial axis and a visibility graph over the same two spaces Two identical rectangular spaces side by side. In the left space a single teal centreline runs down the middle — the medial axis — giving six nodes and paths that hug the centre. In the right space six rose nodes sit at the corners and midpoints, joined by every mutually visible pair, producing fifteen edges and paths that run directly between any two points. A caption notes that the first models where people walk and the second where they could walk. Same space, two structures, two different claims about walking medial axis: one centreline 6 nodes; paths hug the middle visibility graph: every mutually visible pair 15 edges for 6 nodes; paths go direct same space, two structures: one models where people walk, the other where they could corridors favour the first; open concourses favour the second

Neither is more correct — they answer different questions. In a corridor, people walk down the middle and the medial axis is right. In an open concourse, people cut diagonally and the visibility graph is right.

A medial axis is the set of points equidistant from two or more boundary edges — the skeleton of the space. Routing on it produces paths that stay in the middle of whatever they traverse, which in a corridor is exactly where people walk.

A visibility graph connects every pair of vertices that can see each other without crossing an obstacle. Routing on it produces the shortest unobstructed path, which in an open space is the diagonal a person actually takes and which a medial axis cannot express.

The two make different claims. The medial axis says this is where the walkable space is; the visibility graph says these are the moves available. In a 1.8 m corridor the two answers coincide to within a few centimetres. In a 40 m concourse they differ by tens of metres, and the medial axis is the one that is wrong — it will route a user around the perimeter of a hall they would walk straight across.

Minimal Working Example

Both structures from the same polygon, so they can be compared directly:

import itertools
import logging

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

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


def visibility_graph(space: Polygon, extra: list[Point] | None = None,
                     clearance: float = 0.05) -> nx.Graph:
    """Connect every mutually visible vertex pair. O(V^2) edges — apply per space only."""
    if not space.is_valid:
        raise ValueError("visibility_graph needs a valid polygon")

    verts = [Point(c) for c in space.exterior.coords[:-1]]
    for ring in space.interiors:                       # columns, service cores
        verts += [Point(c) for c in ring.coords[:-1]]
    verts += list(extra or [])

    inner = space.buffer(-clearance)                   # keep sight lines off the wall face
    g = nx.Graph()
    for i, p in enumerate(verts):
        g.add_node(i, x=p.x, y=p.y)

    checked = blocked = 0
    for (i, a), (j, b) in itertools.combinations(enumerate(verts), 2):
        checked += 1
        sight = LineString([a, b])
        if inner.contains(sight):
            g.add_edge(i, j, length=a.distance(b), cls="open")
        else:
            blocked += 1
    logger.info("visibility: %d node(s), %d/%d sight line(s) clear",
                len(verts), checked - blocked, checked)
    return g


def choose_structure(space: Polygon, *, narrow: float = 4.0, open_max: float = 800.0) -> str:
    """Pick the structure this space should use, by shape and size."""
    width = 4.0 * space.area / space.length           # equivalent-rectangle width
    aspect = (space.length ** 2) / (4.0 * space.area) # crude elongation measure
    if width < narrow or aspect > 3.0:
        return "medial_axis"
    if space.area <= open_max:
        return "visibility"
    return "navmesh"

choose_structure is deliberately crude, and that is fine: the two measurements it uses — equivalent-rectangle width and elongation — separate corridors from halls reliably, and the consequence of a wrong call on a borderline space is a slightly odd path rather than a broken one.

Scaling and the Crossover

How each structure grows with the size of the space Two curves against space area. Medial-axis node count grows linearly, from 120 nodes for a 50 square metre space to 4,500 for 2,000 square metres. Visibility-graph edge count grows quadratically, from 1,100 edges at 50 square metres to 104,000 at 500 and 1.64 million at 2,000 square metres, because every pair of mutually visible vertices produces an edge. Linear against quadratic, and the crossover comes fast 0 2000 4000 500 1000 1500 2000 space area (m²) nodes / edges (thousands) medial axis: nodes visibility graph: edges (thousands) visibility edges grow quadratically

The quadratic term is what limits the visibility graph. It is excellent in a 600 m² concourse and unusable across a whole floor plate — which is why it is applied per space rather than per level.

The medial axis grows linearly with the size of the space, because it is a skeleton: doubling the area roughly doubles the length of centreline and therefore the node count.

The visibility graph grows quadratically, because it considers every vertex pair. A 500 m² space with a few hundred boundary vertices produces around 100,000 edges; a 2,000 m² space produces 1.6 million. Both the construction (every pair needs a line-of-sight test) and the solve (more edges to relax) become impractical well before a whole floor plate.

That is the entire reason the choice is made per space. A building with 40 corridor-shaped spaces and one 600 m² lobby gets a medial axis in 40 places and a visibility graph in one, and the resulting graph is a few thousand nodes rather than a few million.

Choosing a graph structure for each individual space One decision applied per space with four outcomes. A corridor-shaped space — narrower than four metres or with an aspect ratio above three to one — uses the medial axis. An open space under 800 square metres uses a visibility graph, whose quadratic edge count is still small at that size. A larger open space uses a navmesh, triangulating the area and routing on triangle adjacency. An ordinary room gets a single node, because nobody needs directions within a room. Per space, by shape and size — not one choice per building Which structure for this space? decided per space, not per building corridor-shaped Medial axis width < 4 m or aspect > 3:1 open, < 800 m² Visibility graph direct paths quadratic but small open, large Navmesh triangulate, route on adjacency a room Single node nobody needs in-room directions

Mixing structures is normal and cheap. They meet at door nodes, which every approach produces identically — so a lobby on a navmesh connects to a corridor on a medial axis with no special handling.

Comparison Reference

Dimension Medial axis Visibility graph
Path shape Centre of the traversable space Straight lines between corners
Realism in a corridor Excellent Poor — hugs the wall
Realism in a hall Poor — routes the perimeter Excellent
Node growth Linear in area Linear in boundary vertices
Edge growth Linear Quadratic in vertices
Handles interior obstacles Naturally (skeleton flows around) Naturally (sight lines blocked)
Turn instructions Good — junctions are explicit Poor — every edge is a turn
Build cost, 600 m² space ~0.4 s ~2.1 s
Typical use Corridors, wards, cellular offices Concourses, lobbies, atria

The turn-instruction row is the underrated one. A medial axis has junction nodes where the skeleton branches, which map directly onto “turn left at the junction”. A visibility graph has no such structure — every vertex is a potential turn and the path is a sequence of bearing changes — so generating usable instructions from it requires a separate simplification pass.

Common Errors & Fixes

A hall routes around its perimeter. The medial axis was applied to an open space. The skeleton of a large convex area is a short segment near its centre, so every entrance connects to that stub and the path from one door to another runs in and back out again along the walls. Switch that space to a visibility graph or a navmesh; the door nodes stay identical, so nothing else changes.

Visibility construction takes minutes on one space. The boundary is over-densified. A visibility graph should use the polygon’s actual corners, not an interpolated point every 0.35 m — densification is a medial-axis requirement and is actively harmful here, since it multiplies the vertex count that the edge growth is quadratic in. Simplify the boundary first.

Sight lines cut through a wall. The containment test used the raw polygon rather than a negatively buffered one, so a line running exactly along a wall face counts as inside. Buffer inward by a few centimetres before testing, as in the example above.

Paths inside a hall clip its columns. Interior rings were not included in the vertex set. Columns and service cores appear as interiors of the space polygon, and both their vertices and their blocking effect are needed — omitting them produces sight lines straight through structure.

Integration Point

Structure choice sits inside the skeletonisation stage of routing graph construction, applied per space before node placement. Whatever structure a space uses, it exposes the same interface to the next stage: a set of interior nodes and edges, onto which door nodes attach via spurs. That uniform interface is what makes mixing structures free — the composition step does not know or care which space used which.

The choice is also recorded per space in the build report, because it affects how routes through that space read to a user, and a complaint about an odd path is much faster to diagnose when the structure is known.

Frequently Asked Questions

Can I use a visibility graph for a whole floor?

Not at a realistic floor size. The edge count is quadratic in the number of boundary vertices, and a floor plate with fifty rooms has thousands of them once every wall corner is included — that is millions of sight-line tests to build and millions of edges to hold and relax. It also produces poor results in corridors, where the shortest unobstructed path hugs the inside of every corner rather than following the middle. Applied per open space, where the vertex count is small and the diagonal really is the path people take, it is exactly the right tool.

What about a uniform grid instead of either?

A grid is a reasonable third option for open spaces and a poor one for corridors. Its attraction is simplicity: mask out obstacles, connect neighbouring cells, run A star. Its costs are that resolution and memory trade off directly — a 0.5 m grid over a 2,000 m² hall is 8,000 cells — and that grid paths have a characteristic staircase appearance that needs post-smoothing before anyone will accept them on a map. Where a navmesh is available it dominates the grid on both counts, giving fewer nodes and naturally straight paths.

How do the two structures connect at a doorway?

Through the door node, which is structure-agnostic by construction. Every approach places a node at the centre of each opening and joins it to whatever interior network that space uses — a skeleton vertex for a medial axis, the nearest visible vertex for a visibility graph, the containing triangle for a navmesh. Because the door node is shared between the two spaces it joins, a lobby on a navmesh and a corridor on a medial axis connect with no adapter and no special case in the composition step.

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