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 stableunevaluatedProperties, 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-levelmetadatamember. 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_levelis a discrete navigable index, not a raw CAD elevation — see Level Mapping & Z-Axis Logic. - Python libraries:
jsonschema>=4.21for Draft 2020-12 validation andnetworkx>=3.2for 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
LineStringedge 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.
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.
Related Pages
- Designing JSON schemas for indoor map APIs
- CI Gating for Map Updates
- Cache Invalidation Strategies
- Rollback Triggers & Versioning
- SDK Integration Patterns
This page is part of the Production-Ready Indoor Map Deployment reference; the coordinate conventions it assumes are defined in Indoor Coordinate Reference Systems.