Data Validation & QA Gates for 3D Geospatial Pipelines
Bad geometry and bad metadata do not announce themselves. A tileset with an inverted geometricError, a point cloud silently in EPSG:4326 degrees, or a mesh with a hole where a QEM collapse widened a non-manifold edge will render — badly — and the defect only surfaces when a measurement disagrees with the survey or a client refuses to refine. The remedy is a set of automated gates that run between “tiling finished” and “artifact published,” each one asserting a specific invariant and failing the build with a non-zero exit code when the invariant breaks. This guide defines those gates — JSON Schema on tileset.json and metadata, geometricError monotonicity and bounding-volume containment, CRS and units checks with pyproj, LAS header checks with laspy, and mesh watertightness with trimesh — and shows how to wire them as blocking checks so no defect reaches a CDN. It is the offline counterpart to streaming and runtime diagnostics and belongs under digital twin troubleshooting and reliability; the CI plumbing that runs these gates lives in CI/CD automation for spatial pipelines.
Prerequisites
Every gate is a Python function or CLI call that returns pass/fail deterministically. Pin the toolchain so a gate’s verdict is reproducible across machines — a validator that passes on a developer laptop and fails in CI is worse than no gate at all.
| Component | Pinned version | Gate it powers |
|---|---|---|
| Python | 3.10–3.12 | Orchestration and assertion logic |
jsonschema |
4.21+ | Schema gate on tileset.json and metadata |
numpy |
1.26+ | Monotonicity and containment arithmetic |
pyproj |
3.6+ | CRS and units gate |
laspy |
2.5+ | LAS/LAZ header gate |
trimesh |
4.4+ | Mesh watertightness gate |
3d-tiles-validator |
0.5+ (Node 18+) | Reference schema + geometry validation |
Input formats. Gates run on the tiling output — tileset.json plus .b3dm/.pnts/.glb payloads — and on the source that fed it: classified .laz/.las for point tiles and glTF/.ply meshes for b3dm. State the CRS explicitly and never infer it. Source data should be a projected metric CRS such as EPSG:32618 (UTM 18N) or EPSG:25832 (UTM 32N central Europe); tiled output for CesiumJS lands in EPSG:4978 (geocentric WGS84, metres). The CRS gate exists precisely because “the data is probably in the right system” is the assumption that ships a twin kilometres off its true location.
Concept
A validation gate is a check that has the authority to stop the pipeline. It has three properties: it asserts one named invariant, it is deterministic, and it exits non-zero on failure so the surrounding CI job halts. Gates are ordered cheapest-first so a fast schema check rejects a malformed file before an expensive geometry traversal runs. The full set forms a funnel — raw tiling output enters, each gate either passes it through or rejects it, and only output that clears every gate is published.
The invariants worth gating are the ones that render silently wrong. Schema validity catches structural corruption in tileset.json. geometricError monotonicity (child ≤ parent) catches the inversion that makes a client refine into coarser geometry. Bounding-volume containment (child box inside parent box) catches the culling error that hides visible tiles. CRS and units catch the geographic-versus-projected mistake that makes every distance meaningless. LAS header checks catch scale, offset, and CRS drift in the source cloud. Mesh watertightness catches the holes that decimation turns into gaps. None of these is caught by “it looked fine in the viewer” — they need executable assertions with expected values, which is what the gates provide. Encode the boundary contract as code, not as a wiki page nobody reads at three in the morning.
Step-by-Step Workflow
Order the gates cheapest-first. Schema parsing is milliseconds, geometry traversal is seconds, and a full 3d-tiles-validator run is the most expensive, so run them in that sequence and short-circuit on the first failure.
1. Schema gate on tileset.json and metadata
Validate tileset.json against a JSON Schema before touching geometry. This catches missing asset.version, malformed boundingVolume, or metadata whose type does not match its declared class.
import json
from jsonschema import Draft202012Validator
# Minimal structural schema for a 3D Tiles 1.1 tileset root.
TILESET_SCHEMA = {
"type": "object",
"required": ["asset", "geometricError", "root"],
"properties": {
"asset": {"type": "object", "required": ["version"],
"properties": {"version": {"enum": ["1.0", "1.1"]}}},
"geometricError": {"type": "number", "minimum": 0},
"root": {"type": "object",
"required": ["boundingVolume", "geometricError"]},
},
}
def schema_gate(tileset_path):
tileset = json.loads(open(tileset_path).read())
errors = sorted(Draft202012Validator(TILESET_SCHEMA).iter_errors(tileset),
key=lambda e: e.path)
for e in errors:
print(f"SCHEMA FAIL {list(e.path)}: {e.message}")
return not errors
assert schema_gate("tileset/tileset.json"), "schema gate failed"
2. Geometry gate: monotonic geometricError and bounding-volume containment
Walk the tree and assert that no child’s geometricError exceeds its parent’s and that every child box sits inside its parent box. Both are invariants the client trusts and the schema does not enforce.
import numpy as np
def _box_extent(box):
"""3D Tiles 'box' -> (min_corner, max_corner) in the tile's frame."""
c = np.array(box[:3]); hx, hy, hz = box[3], box[7], box[11]
half = np.array([abs(hx), abs(hy), abs(hz)])
return c - half, c + half
def geometry_gate(tile, parent_error=float("inf"), parent_box=None):
ge = tile["geometricError"]
if ge > parent_error + 1e-6:
raise AssertionError(f"geometricError increased: {ge} > {parent_error}")
box = tile.get("boundingVolume", {}).get("box")
if box and parent_box is not None:
cmin, cmax = _box_extent(box)
pmin, pmax = _box_extent(parent_box)
if not (np.all(cmin >= pmin - 1e-3) and np.all(cmax <= pmax + 1e-3)):
raise AssertionError("child bounding volume escapes parent")
for child in tile.get("children", []):
geometry_gate(child, ge, box)
return True
tileset = json.loads(open("tileset/tileset.json").read())
geometry_gate(tileset["root"])
The monotonicity invariant is the single most common silent failure in tiling; the deep dive on wiring it, plus the transform check, is writing 3d-tiles-validator checks in CI.
3. CRS and units gate with pyproj
Assert the source CRS is projected and metric before anything is tiled, because geometricError is meaningless in degrees. Inspect the CRS with pyproj rather than trusting a filename or a sidecar.
from pyproj import CRS
def crs_gate(epsg_code):
crs = CRS.from_epsg(epsg_code)
if not crs.is_projected:
raise AssertionError(f"EPSG:{epsg_code} is geographic, need a projected CRS")
units = {ax.unit_name for ax in crs.axis_info}
if not units <= {"metre", "meter"}:
raise AssertionError(f"EPSG:{epsg_code} axis units are {units}, need metres")
return True
crs_gate(32618) # UTM 18N, projected metres -> passes
# crs_gate(4326) # WGS84 degrees -> raises, correctly rejected
The full treatment — round-tripping a control point and rejecting EPSG:4326 while accepting EPSG:32618 — is in asserting CRS and units with pyproj.
4. LAS header gate with laspy
For point-cloud sources, read the LAS header and assert scale, offset, and CRS are sane before the cloud is thinned into pnts. A wrong scale factor multiplies every coordinate.
import laspy
def las_header_gate(las_path, expected_epsg=32618):
with laspy.open(las_path) as reader:
header = reader.header
if header.point_count == 0:
raise AssertionError(f"{las_path}: empty point cloud")
if not all(s > 0 for s in header.scales):
raise AssertionError(f"{las_path}: non-positive scale {header.scales}")
crs = header.parse_crs()
if crs is None or crs.to_epsg() != expected_epsg:
got = None if crs is None else crs.to_epsg()
raise AssertionError(f"{las_path}: CRS EPSG:{got}, expected {expected_epsg}")
return True
las_header_gate("survey/block_12.laz", expected_epsg=32618)
5. Mesh watertightness gate with trimesh
For b3dm sources, assert the mesh is watertight and winding-consistent, because a hole that survives into tiling becomes a visible gap at coarse LODs after decimation.
import trimesh
def watertight_gate(mesh_path):
mesh = trimesh.load(mesh_path, force="mesh", process=False)
if not mesh.is_winding_consistent:
raise AssertionError(f"{mesh_path}: inconsistent winding")
if not mesh.is_watertight:
broken = trimesh.repair.broken_faces(mesh)
raise AssertionError(f"{mesh_path}: not watertight, {len(broken)} broken faces")
return True
watertight_gate("blocks/tower_a.ply")
6. Run 3d-tiles-validator and wire every gate as a blocking check
Finish with the reference validator, then compose all gates into one runner that exits non-zero the moment any gate fails, so the CI job stops before the publish step.
import subprocess, sys
def official_validator(tileset_path):
result = subprocess.run(
["npx", "3d-tiles-validator", "--tilesetFile", tileset_path],
capture_output=True, text=True)
if result.returncode != 0:
print(result.stdout, result.stderr)
return result.returncode == 0
def run_gates(tileset_path, las_paths, mesh_paths, epsg=32618):
checks = [
("schema", lambda: schema_gate(tileset_path)),
("geometry", lambda: geometry_gate(json.loads(open(tileset_path).read())["root"])),
("crs", lambda: crs_gate(epsg)),
("las", lambda: all(las_header_gate(p, epsg) for p in las_paths)),
("mesh", lambda: all(watertight_gate(p) for p in mesh_paths)),
("validator", lambda: official_validator(tileset_path)),
]
for name, check in checks:
try:
if not check():
print(f"GATE FAILED: {name}"); sys.exit(1)
except AssertionError as err:
print(f"GATE FAILED: {name}: {err}"); sys.exit(1)
print("all gates passed")
# run_gates("tileset/tileset.json", laz_files, mesh_files, epsg=32618)
Validation & Verification
The gates validate the data; a small self-test validates the gates. Confirm each rejects a known-bad input and accepts a known-good one — a gate that never fails is not a gate.
# The CRS gate must reject geographic degrees and accept projected metres.
try:
crs_gate(4326); raised = False
except AssertionError:
raised = True
assert raised, "CRS gate failed to reject EPSG:4326"
assert crs_gate(32618)
# The geometry gate must reject an inverted geometricError.
bad = {"geometricError": 10.0, "boundingVolume": {"box": [0,0,0, 5,0,0, 0,5,0, 0,0,5]},
"children": [{"geometricError": 40.0,
"boundingVolume": {"box": [0,0,0, 2,0,0, 0,2,0, 0,0,2]},
"children": []}]}
try:
geometry_gate(bad); raised = False
except AssertionError:
raised = True
assert raised, "geometry gate failed to catch inversion"
print("gate self-tests passed")
SCHEMA FAIL []: 'root' is a required property # example rejection message
GATE FAILED: crs: EPSG:4326 is geographic, need a projected CRS
gate self-tests passed
all gates passed
Expected verdicts: EPSG:4326 rejected, EPSG:32618 accepted, an inverted geometricError raised, an empty .laz rejected, and a non-watertight .ply flagged with a broken-face count. The runner exits 0 only when all six clear.
Performance & Scale
The cost ordering matters at city scale. Schema validation and the CRS gate are effectively free and should run on every commit. The geometry traversal is linear in tile count — a few hundred thousand tiles validate in seconds if _box_extent stays vectorized and the walk is iterative rather than deeply recursive. The 3d-tiles-validator and the per-mesh watertightness checks dominate, so scope them: validate the whole tileset structure every run, but only re-run the watertightness gate on meshes whose source hash changed, cached the same way incremental tiling caches its .b3dm outputs. LAS header checks are header-only reads, so they scale to thousands of tiles cheaply — never parse the full point records to check a header. Run the cheap gates as a fast pre-commit hook and the expensive ones in the CI job, so a developer gets the schema and CRS verdict in under a second and the full funnel runs before publish.
Failure Modes & Gotchas
A gate passes locally and fails in CI. The toolchain drifted — a newer 3d-tiles-validator tightened a rule, or pyproj shipped a different PROJ database that resolves an EPSG code differently. Pin every version in the table above and run the gates in the same container locally and in CI so the verdict is identical.
The schema gate passes but the tileset still renders wrong. JSON Schema only checks structure, not semantics — it cannot know that a geometricError inverted between parent and child, or that a box is placed in the wrong frame. That is exactly why the geometry and CRS gates exist alongside it; never treat schema validity as sufficient.
The CRS gate accepts a compound CRS you did not expect. is_projected is true for the horizontal component of some compound systems, so a check that only inspects projection can miss a vertical-datum problem. Assert the specific EPSG code you expect (for example EPSG:32618, or the compound EPSG:32618+5703 when heights matter), not merely that some projection exists.
Watertightness fails on a mesh that looks closed. trimesh reports non-watertight when winding is inconsistent or duplicate vertices leave edges unmerged, even if the surface appears sealed. Run mesh.merge_vertices() and trimesh.repair.fix_winding() in the source pipeline before the gate, and treat a persistent failure as a real topology defect to fix upstream, not a gate to relax.
Frequently Asked Questions
Should these gates block the build or just warn?
Block. A gate that only warns is ignored within a week, and the defects it catches — an inverted geometricError, a geographic CRS, a hole in a mesh — render silently wrong rather than crashing, so nobody notices the warning until a measurement is disputed. Exit non-zero and stop the publish step; make passing the gates the only path to a CDN.
Is 3d-tiles-validator enough on its own?
No. The official validator checks the schema, non-negative and generally decreasing geometricError, and bounding-volume containment, which is a strong baseline. It does not check your source CRS, your LAS headers, or your mesh watertightness, and it cannot know your project’s expected EPSG code. Run it as one gate among several, not as the whole QA story.
How do I gate metadata as well as geometry?
Extend the JSON Schema in step 1 to cover the 3D Metadata schema classes your tileset declares — assert that each property’s type, componentType, and enum values match the class definition. A metadata property whose type drifts from its schema breaks styling and querying downstream just as surely as bad geometry breaks rendering.
Where do these gates run in a real pipeline?
In CI, between the tiling job and the deploy job, as a required check. The cheap gates also belong in a pre-commit hook for fast local feedback. The CI wiring — caching, matrix jobs, and artifact promotion — is covered in the CI/CD automation guide linked below; this page defines what each gate asserts, not the runner it executes in.
Related Guides
- Writing 3D Tiles Validator Checks in CI — the geometry and transform gates as a CI job
- Asserting CRS and Units with pyproj — the CRS gate in full, EPSG:32618 versus EPSG:4326
- CI/CD Automation for Spatial Pipelines — the runner that executes these gates before deploy
- Automated Tile Generation for 3D Geospatial — the tiling step whose output these gates validate