JSON Schema Design for Indoor Maps

A JSON Schema is the contract that decides whether a floor plan is allowed to become a deployable artifact, and it sits at the structural-gate stage of the Production-Ready Indoor Map Deployment lifecycle — after geometry is vectorized but before anything is hashed, versioned, or served. Every payload that crosses this boundary is a GeoJSON FeatureCollection carrying a metadata block and a directed routing graph, and the schema’s only job is to reject malformed structure deterministically so that a broken polygon or a dangling edge fails in continuous integration rather than in a visitor’s navigation session.

The Problem: Structural Drift Becomes Routing Failure

Indoor wayfinding has almost no tolerance for silent structural error. Outdoors, a missing attribute degrades gracefully — a basemap tile renders without a label. Indoors, the same omission changes navigational truth: an edge with a null routing_weight makes a corridor look impassable, a node ID that no other feature references strands a point of interest, and a mismatched coordinate_system shifts an entire floor by tens of metres on the first zoom.

The failure is rarely loud at the point of authoring. A facilities technician exports a revised floor, a script appends a new wing, and the payload still parses as valid JSON. It is only three stages downstream — once the routing engine tries to compute a path or a client SDK tries to paint a room — that the defect surfaces, by which time it has already been cached at the edge and shipped to devices. The symptoms read like distributed-systems bugs (routing loops, coordinate drift, client out-of-memory crashes) but the root cause is almost always a structural invariant that no gate enforced.

A strict schema converts those latent defects into a single, early, reproducible verdict: this payload conforms, or it does not. Crucially, the schema validates structure — required fields, types, identifier patterns, coordinate frames — while a companion graph check validates topology. Both must pass before an artifact is eligible for CI gating for map updates.

Prerequisites & Dependencies

Before authoring the schema, pin the assumptions every payload is expected to honour. Mismatches here are the most common source of “valid JSON that still breaks routing”:

  • JSON Schema dialect: Draft 2020-12. It is the only widely supported draft with $dynamicRef, prefix items, and stable unevaluatedProperties, all of which matter once schemas are modularized for the API surface described in Designing JSON schemas for indoor map APIs.
  • Serialization envelope: GeoJSON FeatureCollection (IETF RFC 7946) with a non-standard top-level metadata member. RFC 7946 permits foreign members, so the envelope stays spec-compatible with off-the-shelf GeoJSON tooling.
  • Coordinate frame: a building-local Cartesian frame in metres, declared explicitly. Indoor coordinates rarely align with WGS84; the rules for choosing and pinning a frame live in Indoor Coordinate Reference Systems, and floor_level is a discrete navigable index, not a raw CAD elevation — see Level Mapping & Z-Axis Logic.
  • Python libraries: jsonschema>=4.21 for Draft 2020-12 validation and networkx>=3.2 for the topology pass. Geometry repair (shapely) is assumed to have already run during vectorization.
  • Upstream guarantee: edges only exist where Wall & Door Detection Algorithms identified a passable opening; the schema enforces that every LineString edge feature is traversal-typed, but it cannot invent edges the parser never produced.

How the Validation Contract Works

The contract splits a single payload into two orthogonal checks so each can fail with a precise reason. Structural validation walks the FeatureCollection against the JSON Schema and asserts that field names, types, enums, and identifier patterns hold. Topological validation rebuilds the directed routing graph from the validated features and asserts that the graph is referentially sound and connected. A payload is deployable only when both pass; either failure is FATAL and blocks the merge.

Two parallel gates: structural schema and graph topology, reconverging on one deploy-or-block verdict A single GeoJSON FeatureCollection on the left, carrying a metadata block and a features array of LineString edges and Polygon spaces, splits along two arrows. The upper path enters a JSON Schema structural gate (Draft 2020-12) that checks required fields and types, enums such as edge_type and coordinate_system, node id patterns like caret-N-digits, and additionalProperties false. The lower path enters a NetworkX topology gate that rebuilds the directed routing graph, rejects dangling source or target references, and requires weak connectivity. Both paths converge on a single "both pass?" decision. A PASS edge leads to promoting the artifact with a verified topology_hash into the CI gate for map updates; a FAIL edge leads to a FATAL outcome that blocks the merge. Split → gate → reconverge: one payload, two orthogonal checks, one verdict GeoJSON FeatureCollection metadata: coordinate_system, topology_hash, floor_level features[ ]: edges + spaces JSON Schema — structural gate Draft 2020-12 · jsonschema.iter_errors • required fields & types • enums: edge_type, coordinate_system • node id pattern ^N[0-9]+$ • additionalProperties: false NetworkX — topology gate directed routing graph rebuilt from edges • nodes derived from edge endpoints • reject dangling source / target • require weak connectivity both pass? PASS promote artifact topology_hash verified → CI gate for map updates FAIL FATAL blocks the merge

The structural schema below validates the same envelope used everywhere else in the pipeline. It pins the coordinate frame in metadata, requires a topology_hash so the downstream gate can verify byte-level integrity, and constrains each Feature so that routing edges (LineString) and spatial primitives (Polygon) carry exactly the properties the routing engine and renderer depend on.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://indoor-mapping-wayfinding.com/schemas/indoor_map.json",
  "title": "IndoorMapFeatureCollection",
  "type": "object",
  "required": ["type", "metadata", "features"],
  "properties": {
    "type": { "const": "FeatureCollection" },
    "metadata": {
      "type": "object",
      "required": ["map_version", "schema_revision", "building_id",
                   "floor_level", "topology_hash", "coordinate_system"],
      "properties": {
        "map_version":     { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
        "schema_revision": { "type": "string", "pattern": "^v\\d+\\.\\d+\\.\\d+$" },
        "building_id":     { "type": "string", "minLength": 1 },
        "floor_level":     { "type": "integer" },
        "topology_hash":   { "type": "string", "pattern": "^sha256:[0-9a-f]{12,64}$" },
        "coordinate_system": {
          "type": "string",
          "enum": ["EPSG:0-local-cartesian", "EPSG:3857-transformed", "EPSG:4326"]
        },
        "snap_tolerance_m": { "type": "number", "exclusiveMinimum": 0, "default": 0.01 }
      },
      "additionalProperties": false
    },
    "features": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["type", "id", "geometry", "properties"],
        "properties": {
          "type": { "const": "Feature" },
          "id":   { "type": "string", "minLength": 1 },
          "geometry": {
            "type": "object",
            "required": ["type", "coordinates"],
            "properties": {
              "type": { "enum": ["Point", "LineString", "Polygon"] },
              "coordinates": {}
            }
          },
          "properties": { "type": "object" }
        },
        "allOf": [
          {
            "if": { "properties": { "geometry": { "properties": { "type": { "const": "LineString" } } } } },
            "then": {
              "properties": {
                "properties": {
                  "required": ["edge_type", "source", "target", "routing_weight", "traversable"],
                  "properties": {
                    "edge_type":    { "enum": ["corridor", "door", "elevator", "stair", "ramp"] },
                    "source":       { "type": "string", "pattern": "^N[0-9]+$" },
                    "target":       { "type": "string", "pattern": "^N[0-9]+$" },
                    "routing_weight": { "type": "number", "minimum": 0 },
                    "traversable":  { "type": "boolean" },
                    "accessibility_rating": { "enum": ["full", "partial", "none"] }
                  }
                }
              }
            }
          },
          {
            "if": { "properties": { "geometry": { "properties": { "type": { "const": "Polygon" } } } } },
            "then": {
              "properties": {
                "properties": {
                  "required": ["category", "floor_level"],
                  "properties": {
                    "category":    { "type": "string", "minLength": 1 },
                    "floor_level": { "type": "integer" }
                  }
                }
              }
            }
          }
        ],
        "additionalProperties": false
      }
    }
  },
  "additionalProperties": false
}

The conditional allOf blocks are the part that makes this an indoor schema rather than a generic GeoJSON validator: a LineString is treated as a routing edge and must declare its edge_type and a non-negative routing_weight, while a Polygon is treated as a navigable space and must declare a category drawn from the project’s POI taxonomy. additionalProperties: false at every level stops schema drift — an unrecognised field is a hard failure, not a silently ignored payload bloat.

Step-by-Step Implementation

Each step below is a self-contained, typed function with a logging call and explicit handling of the failure that actually occurs in the field. Compose them into a single CLI runner that returns a non-zero exit code on any failure so CI can gate on it.

Step 1 — Load the schema and the payload

Load both documents and fail fast on the two errors that dominate this step: a missing file and malformed JSON. Surfacing the path and the line number here saves hours of debugging an opaque validator stack trace later.

import json
import logging
from pathlib import Path
from typing import Any

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


def load_json(path: Path) -> dict[str, Any]:
    """Load a JSON document, raising a clear error for missing or malformed files."""
    try:
        with path.open("r", encoding="utf-8") as fh:
            return json.load(fh)
    except FileNotFoundError:
        logger.error("File not found: %s", path)
        raise
    except json.JSONDecodeError as exc:
        logger.error("Malformed JSON in %s at line %d col %d", path, exc.lineno, exc.colno)
        raise

Step 2 — Validate structure against the schema

Use Draft202012Validator.iter_errors rather than a single validate call so that every violation is reported in one pass with its exact JSON pointer. A facilities team fixing ten broken features at once should not have to re-run the gate ten times.

from jsonschema import Draft202012Validator


def validate_structure(payload: dict[str, Any], schema: dict[str, Any]) -> None:
    """Assert the payload conforms to the schema; log every violation with its path."""
    validator = Draft202012Validator(schema)
    errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path))
    if errors:
        for err in errors:
            pointer = "/".join(str(p) for p in err.absolute_path) or "<root>"
            logger.error("Schema violation at %s: %s", pointer, err.message)
        raise ValueError(f"Structural validation failed with {len(errors)} error(s)")
    logger.info("Structural validation passed (%d features)", len(payload["features"]))

Step 3 — Rebuild and validate the routing graph

Structure can be perfect while topology is broken, so reconstruct the directed routing graph from the validated LineString features and assert two invariants: no edge references a node that no other feature defines, and the traversable graph is weakly connected. The node set is derived from the edge endpoints, so a dangling reference is caught before networkx ever sees it.

import networkx as nx


def validate_topology(payload: dict[str, Any]) -> None:
    """Rebuild the routing graph and assert referential integrity and connectivity."""
    edges = [f for f in payload["features"]
             if f["geometry"]["type"] == "LineString" and f["properties"].get("traversable")]
    declared_nodes: set[str] = set()
    for e in edges:
        declared_nodes.update((e["properties"]["source"], e["properties"]["target"]))

    graph = nx.DiGraph()
    graph.add_nodes_from(declared_nodes)
    for e in edges:
        p = e["properties"]
        graph.add_edge(p["source"], p["target"], weight=p["routing_weight"], id=e["id"])

    isolates = list(nx.isolates(graph))
    if isolates:
        logger.error("Isolated nodes with no traversable edge: %s", isolates)
        raise ValueError("Topology has unreachable nodes")

    if graph.number_of_nodes() > 0:
        components = list(nx.weakly_connected_components(graph))
        if len(components) > 1:
            sizes = sorted((len(c) for c in components), reverse=True)
            logger.error("Routing graph has %d disconnected components: sizes=%s",
                         len(components), sizes)
            raise ValueError("Traversable graph is not weakly connected")
    logger.info("Topology validation passed (%d nodes, %d edges)",
                graph.number_of_nodes(), graph.number_of_edges())

Step 4 — Wire it into a gate-ready runner

The runner composes the three checks and exits non-zero on the first failure, which is exactly the signal a CI step needs. The same topology_hash the schema requires is what downstream stages reuse for cache invalidation and rollback triggers & versioning, so a passing run here is the precondition for promoting the artifact.

import sys


def run_gate(map_path: Path, schema_path: Path) -> int:
    """Validate one indoor map artifact; return 0 on success, 1 on any failure."""
    try:
        payload = load_json(map_path)
        schema = load_json(schema_path)
        validate_structure(payload, schema)
        validate_topology(payload)
    except (ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
        logger.error("Gate FAILED for %s: %s", map_path.name, exc)
        return 1
    logger.info("Gate PASSED for %s — safe to promote", map_path.name)
    return 0


if __name__ == "__main__":
    sys.exit(run_gate(Path(sys.argv[1]), Path("schemas/indoor_map.json")))

Edge Cases & Gotchas

Most schema escapes are not exotic — they are predictable structural mistakes that a generic GeoJSON validator waves through. Encode each as an explicit rule:

Failure pattern Why it slips past naive validation Schema / gate defence
Mixed coordinate frames Two floors authored in different tools, one in local metres, one in EPSG:4326 Single root coordinate_system enum; CI assert that node coordinate ranges match the declared frame
Dangling edge reference source/target typo passes the ^N[0-9]+$ pattern but names no real node Topology pass derives nodes from edges; nx.isolates rejects unreachable IDs
Null routing weight routing_weight omitted on a traversable edge, defaulting to None Conditional required on LineString; minimum: 0 forbids negative or null cost
Degenerate polygon Polygon ring with fewer than 4 positions or an unclosed ring Reject in vectorization; schema additionally caps geometry to validated Polygon type
Schema drift / payload bloat A new tool appends an undeclared legacy_id field additionalProperties: false at every level turns the stray field into a hard failure
Y-axis inversion CAD export flips the Y origin; JSON still validates Out of schema scope — assert bounding-box orientation in CI before hashing

Validation Output: Correct vs. Incorrect

A passing run logs a single deployable verdict; a failing run names the exact JSON pointer and the exact graph defect. Wire both into a regression test so the gate’s behaviour itself is asserted, not assumed.

# CORRECT — a conformant artifact
INFO: Structural validation passed (214 features)
INFO: Topology validation passed (118 nodes, 203 edges)
INFO: Gate PASSED for building_a_floor3.json — safe to promote

# INCORRECT — drift + a severed wing in the same payload
ERROR: Schema violation at features/57/properties: 'routing_weight' is a required property
ERROR: Schema violation at metadata: Additional properties are not allowed ('legacy_id' was unexpected)
ERROR: Routing graph has 2 disconnected components: sizes=[101, 17]
ERROR: Gate FAILED for building_a_floor3.json — Traversable graph is not weakly connected

The corresponding assertion pattern in a test suite pins the contract so a future schema edit cannot silently relax it:

import pytest


def test_dangling_edge_is_rejected() -> None:
    """A payload whose edge targets an undefined node must fail the topology gate."""
    payload = {
        "type": "FeatureCollection",
        "metadata": {"map_version": "1.0.0", "schema_revision": "v1.0.0",
                     "building_id": "BLDG-04", "floor_level": 3,
                     "topology_hash": "sha256:9f1c0ab27e4d",
                     "coordinate_system": "EPSG:0-local-cartesian"},
        "features": [{
            "type": "Feature", "id": "E0001",
            "geometry": {"type": "LineString", "coordinates": [[0, 0], [5, 0]]},
            "properties": {"edge_type": "corridor", "source": "N1", "target": "N2",
                           "routing_weight": 5.0, "traversable": True},
        }],
    }
    # N1 -> N2 with no inbound edge to N1 leaves a single weak component, which is
    # valid; flip target to an undefined-but-pattern-valid id to force the failure.
    payload["features"][0]["properties"]["target"] = "N9"
    with pytest.raises(ValueError, match="not weakly connected"):
        validate_topology(payload)

Performance & Scale Notes

Structural validation is linear in the number of features and the per-feature work is dominated by regex evaluation on identifier strings; a 10,000-feature floor validates in well under a second on a CI runner. The cost concern is memory, not time: iter_errors materializes the full error list, so on a badly broken payload it can briefly hold tens of thousands of error objects. Cap the reported errors (slice the first 200) before logging when validating machine-generated batches.

The topology pass is O(V + E) to build the graph and O(V + E) for weak-connectivity, which stays cheap into the hundreds of thousands of edges. The real scaling lever is batch granularity: validate one floor level per process rather than a whole-building payload, because a single floor is the unit that gets versioned, hashed, and cached anyway. Parallelise across floors in CI rather than loading a monolithic building graph into one interpreter, and reuse a single compiled Draft202012Validator instance across the batch — recompiling the schema per file is the most common avoidable cost when validating large floor-plan sets.

Frequently Asked Questions

Why validate a GeoJSON FeatureCollection instead of a custom nodes/edges object?

Because the whole pipeline already serializes to a FeatureCollection, and staying inside that envelope means off-the-shelf GeoJSON tooling — geojson.io, GDAL, GeoPandas — can inspect, render, and diff artifacts without a translation layer. The routing graph is expressed as LineString edge features with source/target properties, which the topology pass reassembles. A custom object would force a bespoke viewer and a second serialization contract for no structural gain.

Should the JSON Schema enforce graph connectivity?

No — JSON Schema validates structure, not topology. There is no portable way to express “every edge endpoint must reference a node that some other feature defines” or “the graph must be weakly connected” in Draft 2020-12. Keep those invariants in the networkx pass so each layer fails with a precise, actionable reason instead of one opaque verdict.

How do I version the schema without breaking deployed clients?

Pin a schema_revision (e.g. v1.3.0) in the payload metadata and treat a mismatch against the registry as a FATAL gate failure. Additive changes (new optional property) bump the patch; a new required property or a tightened enum bumps the minor and ties to an SDK compatibility range. The modular $ref/$dynamicRef approach in Designing JSON schemas for indoor map APIs lets shared definitions evolve without forking every floor schema.

Where does coordinate validation belong — schema or CI?

The schema declares the allowed coordinate_system enum and the numeric type of every coordinate, but it cannot assert that the actual values fall inside the building’s surveyed bounds or that the Y-axis points the right way. Those are CI assertions run after structural validation and before the topology hash, using the bounding box of the floor as ground truth.

This page is part of the Production-Ready Indoor Map Deployment reference; the coordinate conventions it assumes are defined in Indoor Coordinate Reference Systems.