Converting GeoJSON Envelopes to IndoorGML

Part of Indoor Map Data Standards. Unlike an IMDF conversion, which is a geometry and vocabulary exercise, an IndoorGML conversion publishes the routing graph as well — so it consumes two internal artefacts rather than one, and runs later in the pipeline.

What the Conversion Has to Produce

Mapping an internal envelope and its routing graph onto IndoorGML A table of seven mappings. A room polygon becomes a CellSpace with a Geometry2D, sourced from an envelope feature. The room's graph node becomes a State whose duality references that cell, sourced from the routing graph's space node. A doorway becomes a CellSpaceBoundary from the envelope's opening. The doorway's edge becomes a Transition connecting two States, from the graph's door edge. A level becomes a SpaceLayer from the level index. A vertical link becomes a Transition across layers, from the graph's vertical edge. The building as a whole becomes a MultiLayeredGraph. Seven mappings, four of them from the routing graph What you have IndoorGML element Source Room polygon CellSpace + Geometry2D envelope feature Room's graph node State (duality -> cell) ● graph space node Doorway CellSpaceBoundary envelope opening Doorway's edge Transition (connects 2 States) ● graph door edge Level SpaceLayer level index Vertical link Transition across layers ● graph vertical edge The building MultiLayeredGraph the venue itself Four of seven rows come from the routing graph, which is why this converter runs after it.

Half the document comes from the graph, not the geometry. That is the whole point of IndoorGML and the reason this converter cannot run before graph construction.

IndoorGML represents a building as primal space — the cells people occupy and the boundaries between them — and dual space, a graph whose nodes correspond one-to-one with cells and whose edges correspond with boundaries that can be crossed. Both have to be emitted, and the second is the reason the conversion is not simply a reformatting of the envelope.

The IndoorGML containment hierarchy an emitter has to build Four stacked bands. A MultiLayeredGraph holds the venue, one per building. Inside it, SpaceLayers exist one per level and per travel mode — for example level 0 default and level 0 step-free as separate layers. Each layer contains States and Transitions, which are the graph itself: a State per cell and a Transition per link. Beneath them sit the CellSpaces carrying the actual room polygons and boundaries, shared by every layer. One cell set, one layer per level and mode MultiLayeredGraph the venue one per building SpaceLayer one per level, per mode level 0 default level 0 step-free level 1 default State / Transition the graph itself State: a cell Transition: a link CellSpace the geometry room polygons boundaries one cell set, several graph layers over it

The geometry is shared and the graphs are not. One set of cells carries a default layer and a step-free layer, which is IndoorGML's answer to the same problem profile weights solve internally.

The SpaceLayer construct is where IndoorGML earns its complexity. A layer is a graph over the cell set, and there can be several — one per level, and within a level, one per travel mode. A default layer and a step-free layer share every CellSpace and differ only in which Transition elements they contain. That is a direct expression of the idea accessible routing profiles implements as weight vectors, and emitting it means the consumer receives the accessibility model rather than having to infer it.

Minimal Working Example

The emitter builds the document in dependency order so no reference points forward:

import logging
from xml.etree import ElementTree as ET

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

NS = {
    "gml": "http://www.opengis.net/gml/3.2",
    "indoorgml": "http://www.opengis.net/indoorgml/1.0/core",
}
for prefix, uri in NS.items():
    ET.register_namespace(prefix, uri)


def q(prefix: str, tag: str) -> str:
    # ElementTree wants "{namespace}tag"; built by concatenation to keep it readable.
    return "{" + NS[prefix] + "}" + tag


def emit_cellspace(parent: ET.Element, feature: dict, srs: str) -> str:
    """Write one CellSpace and return its gml:id for the State to reference."""
    props = feature["properties"]
    cell_id = f"c-{props['feature_id']}"
    member = ET.SubElement(parent, q("indoorgml", "cellSpaceMember"))
    cell = ET.SubElement(member, q("indoorgml", "CellSpace"), {q("gml", "id"): cell_id})
    geom = ET.SubElement(cell, q("indoorgml", "cellSpaceGeometry"))
    poly = ET.SubElement(geom, q("gml", "Polygon"), {"srsName": srs})
    ring = ET.SubElement(ET.SubElement(poly, q("gml", "exterior")),
                         q("gml", "LinearRing"))
    coords = feature["geometry"]["coordinates"][0]
    ET.SubElement(ring, q("gml", "posList")).text = " ".join(
        f"{x:.6f} {y:.6f}" for x, y in coords)
    return cell_id


def emit_state(parent: ET.Element, node_id: str, cell_id: str, xy: tuple[float, float]) -> str:
    """Write one dual-space State whose duality points at an already-written cell."""
    state_id = f"s-{node_id}"
    member = ET.SubElement(parent, q("indoorgml", "stateMember"))
    state = ET.SubElement(member, q("indoorgml", "State"), {q("gml", "id"): state_id})
    ET.SubElement(state, q("indoorgml", "duality"),
                  {q("gml", "href"): f"#{cell_id}"})
    pos = ET.SubElement(ET.SubElement(state, q("indoorgml", "geometry")), q("gml", "Point"))
    ET.SubElement(pos, q("gml", "pos")).text = f"{xy[0]:.6f} {xy[1]:.6f}"
    return state_id


def emit_transition(parent: ET.Element, edge_id: str, a: str, b: str, weight: float) -> None:
    """Write one dual-space Transition connecting two States already written."""
    member = ET.SubElement(parent, q("indoorgml", "transitionMember"))
    tr = ET.SubElement(member, q("indoorgml", "Transition"), {q("gml", "id"): f"t-{edge_id}"})
    for state in (a, b):
        ET.SubElement(tr, q("indoorgml", "connects"), {q("gml", "href"): f"#{state}"})
    ET.SubElement(tr, q("indoorgml", "weight")).text = f"{weight:.3f}"

Assembly. The three emitters are called in order — every CellSpace first, then every State, then every Transition — so each href refers to an element already present in the document:

def convert(envelope: dict, graph, srs: str, profiles: tuple[str, ...] = ("default",)) -> bytes:
    if not srs:
        raise ValueError("IndoorGML requires an explicit srsName; refusing to guess")
    root = ET.Element(q("indoorgml", "IndoorFeatures"), {q("gml", "id"): "venue"})
    primal = ET.SubElement(root, q("indoorgml", "primalSpaceFeatures"))

    cells = {f["properties"]["feature_id"]: emit_cellspace(primal, f, srs)
             for f in envelope["features"] if f["properties"].get("is_routable")}

    mlg = ET.SubElement(root, q("indoorgml", "multiLayeredGraph"))
    for profile in profiles:
        for level in sorted({n["level"] for _, n in graph.nodes(data=True)}):
            layer = ET.SubElement(mlg, q("indoorgml", "SpaceLayer"),
                                  {q("gml", "id"): f"layer-{profile}-{level:g}"})
            states = {}
            for node, attrs in graph.nodes(data=True):
                if attrs.get("level") != level or attrs.get("kind") != "space":
                    continue
                cell = cells.get(node.split(":", 1)[-1])
                if cell is None:
                    logger.warning("state %s has no cell; skipping", node)
                    continue
                states[node] = emit_state(layer, node, cell, (attrs["x"], attrs["y"]))
            for a, b, data in graph.edges(data=True):
                if a in states and b in states and _allowed(data, profile):
                    emit_transition(layer, f"{a}|{b}", states[a], states[b],
                                    data.get("length", 0.0))
    logger.info("emitted %d cell(s) across %d layer(s)", len(cells), len(profiles))
    return ET.tostring(root, encoding="utf-8", xml_declaration=True)

Emission Order and Reference Integrity

The emission order that keeps every gml:id reference resolvable A sequence across four participants. The envelope supplies cells and boundaries to the emitter, which writes CellSpaces to the document first. The routing graph then supplies nodes and edges per level; the emitter resolves each State's duality reference to a cell it has already written, then writes States followed by Transitions. The document confirms that every gml id reference resolves. Write in dependency order and references cannot dangle Envelope Graph Emitter Document cells + boundaries write CellSpaces first nodes + edges per level resolve duality references write States, then Transitions gml:ids all resolve Cells before states, states before transitions: every reference points backwards.

Forward references are legal in GML and a liability in practice. Emitting in dependency order means a broken reference is impossible rather than merely detectable.

GML permits forward references — an xlink:href may point at an element defined later in the document — and relying on that is a reliable way to ship a document with a dangling reference that only a strict consumer notices.

Emitting in dependency order removes the possibility. Cells are written before the states that reference them; states before the transitions that connect them; layers before anything inside them. A reference that cannot be resolved at emission time is therefore a bug in the input, not a document that might resolve later, and it can be raised immediately with the id that failed.

The one place this needs care is vertical transitions, which connect states in two different SpaceLayer elements. Both layers must be written before the transition, which means the vertical edges are emitted in a final pass after every level’s layer is complete.

Common Errors & Fixes

Consumer reports “unresolved duality reference”. A State points at a CellSpace id that was never written, usually because the cell was filtered out as non-routable while its graph node was not. Filter both from the same predicate — if a space is not routable it should have neither a cell nor a state.

Geometry is in the wrong place. srsName was omitted, so the consumer assumed a default. Every geometry container needs it, and the emitter above refuses to run without one rather than emitting a document whose coordinates are ambiguous.

Ring is not closed. GML LinearRing requires the first and last positions to be identical. GeoJSON also requires it, but some producers omit it, so the safe move is to close it explicitly during emission rather than trusting the input:

if coords[0] != coords[-1]:
    coords = list(coords) + [coords[0]]

The document is enormous. A SpaceLayer per level per profile multiplies quickly — 8 levels and 4 profiles is 32 layers over the same cells. Emit only the profiles a consumer asked for, and remember that the cells are shared, so the marginal cost of a profile is its transitions rather than the geometry.

Integration Point

This converter is the only publishing step that consumes the routing graph, which places it downstream of routing graph construction as well as of the envelope. That ordering is worth making explicit in the build, because an IndoorGML publication attempted before the graph exists will emit primal space with an empty dual — a document that validates and carries none of the information the standard exists to carry.

Everything else follows the boundary discipline described in Indoor Map Data Standards: the internal model stays in local metres with its own taxonomy, the conversion happens at the edge, and nothing reads the emitted document back.

Frequently Asked Questions

Do I have to emit every accessibility profile as a layer?

No — emit the ones a consumer will use, which is usually one or two. The MultiLayeredGraph construct makes multiple layers possible, not mandatory, and each additional layer costs a full set of Transition elements over the same cells. A transit information system typically wants a default layer and a step-free layer; emitting a low-vision and a service layer as well produces a document twice the size that nothing reads. The cells are shared, so adding a profile later is cheap if a consumer asks.

Can IndoorGML carry the same accessibility attributes as IMDF?

Yes, through the navigation module’s extensions, and it expresses them differently in a way that suits routing better. Where IMDF puts an accessibility attribute on a unit or opening and leaves the consumer to interpret it, IndoorGML lets you omit the Transition entirely from a step-free layer — the stair simply is not traversable in that graph. That is a stronger statement and a less ambiguous one, though it does mean a consumer cannot tell from the step-free layer alone why a connection is missing, which is an argument for emitting both layers rather than only the accessible one.

Is there a Python library for IndoorGML?

Nothing mature enough to depend on, which is why the example builds the XML directly. The schema is public and the element set an indoor map needs is small — CellSpace, State, Transition, SpaceLayer and their containers — so a hand-written emitter of a few hundred lines is both practical and easier to reason about than adapting a general GML library. Validate the output against the published XSD with lxml, which does have mature schema support, rather than trusting the emitter.

This page is a companion to Indoor Map Data Standards, part of the Indoor Mapping Architecture & Standards section.