Validating CityJSON with cjval
This page builds a validation gate for CityJSON city models from two tools that check different things — cjval for the schema and the file’s internal structure, and val3dity for geometric validity of the solids and surfaces — with a severity policy, sensible tolerances for LOD2 data in EPSG:25832, and a per-building report a modeller can act on.
Why you hit this
A city model can be perfectly valid JSON, load in every viewer, and still be unusable: a solid that is not closed has no volume, a non-planar “planar” surface triangulates differently in every tool, a semantics array one element short silently mislabels every surface after it, and a parents reference to a building that is not in the file breaks any join to a register. None of that is visible on screen. Validating is how a pipeline stops a bad delivery at the door rather than discovering it three stages later in a mesh that will not close — the problem chased from the other end in making meshes watertight for volume calculations.
Prerequisites
cjval(the CityJSON validator;cargo install cjvalor a release binary) andval3dity2.x.- Python 3.10+ with
numpy>=1.24for the reporting step. - A CityJSON 1.1 or 2.0 file in a metric CRS — tolerances below are in metres and assume EPSG:25832 with DHHN2016 heights.
Step-by-Step
1. Run cjval and read what it checks
import subprocess
from pathlib import Path
src = Path("district_lod2.city.json")
res = subprocess.run(["cjval", str(src)], capture_output=True, text=True)
print(res.stdout[-1800:])
print("exit code:", res.returncode)
cjval works through a fixed list: the file parses as JSON; it validates against the CityJSON schema for the version it declares; any extensions it references resolve and their schemas validate; every parents and children reference points at an object that exists; semantics values arrays have the same shape as the boundaries they label; the geographicalExtent matches the vertices; and it reports duplicate and orphan vertices. The first four are errors — the file is not CityJSON. The last three are warnings: the file is valid but something is inconsistent or wasteful.
2. Run val3dity with tolerances that match the data
report = Path("build/val3dity_report.json")
report.parent.mkdir(exist_ok=True)
subprocess.run([
"val3dity", str(src),
"--report", str(report),
"--planarity_d2p_tol", "0.05", # 5 cm from the best-fit plane
"--planarity_n_tol", "20", # 20° between triangle normals
"--snap_tol", "0.001", # vertices within 1 mm are the same vertex
"--overlap_tol", "0.05", # tolerate 5 cm of solid interpenetration
], check=False)
print(report.stat().st_size, "bytes of report")
Every one of those tolerances is a statement about the data, not a way to silence the tool. LOD2 models produced from aerial photogrammetry have roof planes fitted to noisy points, so 5 cm from the plane and 20° between adjacent triangle normals is realistic; a CAD-derived model should be held to millimetres. The snap tolerance has to exceed the quantisation grid of the file’s transform — with a millimetre grid, coincident vertices can differ by a millimetre — and overlap_tol matters because neighbouring terraced houses in a city model routinely share a party wall that is modelled twice, a few centimetres apart.
3. Summarise the report by error code
import json
from collections import Counter
rep = json.loads(report.read_text())
codes = Counter()
per_object = {}
for feat in rep.get("features", []):
errs = [e["code"] for e in feat.get("errors", [])]
for prim in feat.get("primitives", []):
errs += [e["code"] for e in prim.get("errors", [])]
if errs:
per_object[feat.get("id")] = errs
codes.update(errs)
print(f"{rep.get('features_valid')} of {rep.get('features_total')} features valid")
for code, n in codes.most_common():
print(f" {code}: {n}")
The report’s exact shape varies between val3dity versions, so print one feature before relying on field names. What is stable is the error-code vocabulary, and it is worth knowing by heart because the codes map to distinct causes: 1xx are ring problems, 2xx polygon problems, 3xx shell problems, 4xx solid problems.
4. Turn it into a gate with an explicit policy
import sys
FAIL_CODES = {
101, 102, 103, 104, # rings — always a defect
201, 202, # intersecting rings
301, 302, 303, # shells: not 2-manifold, not closed, bad orientation
305, # self-intersecting shell
}
TOLERATED = {
203: "non-planar beyond 5 cm — photogrammetric roof planes, accepted",
204: "normal deviation — same cause as 203",
401: "intersecting solids — shared party walls in terraced blocks",
}
violations = {oid: [c for c in errs if c in FAIL_CODES] for oid, errs in per_object.items()}
violations = {k: v for k, v in violations.items() if v}
tolerated = Counter(c for errs in per_object.values() for c in errs if c in TOLERATED)
print(f"{len(violations)} objects violate the policy; tolerated: {dict(tolerated)}")
for oid, errs in list(violations.items())[:10]:
print(f" FAIL {oid}: {sorted(set(errs))}")
unknown = {c for errs in per_object.values() for c in errs} - FAIL_CODES - set(TOLERATED)
if unknown:
print("codes with no policy decision:", sorted(unknown))
sys.exit(1 if violations or unknown else 0)
Two properties make this a policy rather than a filter. Every tolerated code carries a written reason, so the next person knows it was a decision. And any code that appears without a decision fails the build — which is what stops a new class of defect from slipping through a gate that was tuned for the defects of a year ago.
5. Report per building so it can be fixed
out = Path("build/validation_report.md")
lines = ["# CityJSON validation", "",
f"- source: `{src.name}`",
f"- features: {rep.get('features_total')}, valid: {rep.get('features_valid')}",
f"- policy violations: {len(violations)}", "",
"| building | codes | plan |", "|---|---|---|"]
for oid, errs in sorted(violations.items()):
codes_s = ", ".join(str(c) for c in sorted(set(errs)))
plan = "re-export" if any(c < 200 for c in errs) else "repair shell"
lines.append(f"| `{oid}` | {codes_s} | {plan} |")
out.write_text("\n".join(lines))
print(out.read_text()[:400])
A report keyed by the building identifier is what makes validation actionable: the identifier is the same one in the register and in the source CityGML, so a modeller can open exactly those buildings. A report that says “1,204 errors” produces nothing.
Expected Output & Verification
district_lod2.city.json is valid
warning: 341 duplicate vertices
warning: 12 orphan vertices
exit code: 0
4731 of 4812 features valid
203: 2884
401: 512
302: 44
104: 3
44 objects violate the policy; tolerated: {203: 2884, 401: 512}
FAIL DEBY_LOD2_4959323: [302]
FAIL DEBY_LOD2_4959871: [104, 302]
Verify the gate the way any test is verified — with known-bad input. Keep three small fixtures in the repository: a file with an unclosed ring, one with a solid missing its ground surface, and one with a semantics array one element short. Assert that cjval rejects the third and val3dity reports 103 and 302 for the first two. A validation step nobody has watched fail is not known to work.
FIXTURES = {"unclosed_ring.city.json": 103, "open_shell.city.json": 302}
for name, expected in FIXTURES.items():
r = subprocess.run(["val3dity", f"tests/fixtures/{name}", "--report", "/tmp/fx.json"], check=False)
got = {e["code"] for f in json.loads(Path("/tmp/fx.json").read_text()).get("features", [])
for e in f.get("errors", [])}
assert expected in got, f"{name}: expected code {expected}, got {sorted(got)}"
print("gate detects every fixture defect")
Performance Notes
cjvalis fast — a second or two for a 40 MB tile, because it is schema and index work. Run it on every file, every time.val3dityis geometric and much slower, especially the solid-intersection checks, which are quadratic in the number of solids in a neighbourhood. Expect minutes for a district and hours for a city; disable the 4xx checks for routine runs and enable them on release.- Validate per tile in parallel processes. Both tools are single-threaded per file.
- Keep reports as build artefacts with the tile name and source checksum, so the same delivery is never validated twice and a regression can be traced to a specific delivery.
Common Errors
cjval cannot resolve an extension schema. The file references an extension by URL and the build machine has no network access. Vendor the extension schema and point the validator at the local copy, or validate extensions in a separate networked step.
Thousands of 203 errors on a model that looks fine. The planarity tolerance is tighter than the data’s noise. Measure the actual deviation on a sample of roofs before choosing; do not set tolerances to whatever silences the tool.
Every building reports 302, shell not closed. LOD2 models often omit the ground surface, so their shells are open by construction. Either treat 302 as expected for that dataset and document it, or close the shells by adding the footprint as a GroundSurface before validating.
val3dity reports nothing at all. It validated no primitives, usually because the geometries are MultiSurface rather than Solid and the run asked only for solid checks. Surfaces get ring and planarity checks; only solids get shell and volume checks.
Frequently Asked Questions
Which errors should block a delivery being accepted?
Ring-level errors and unclosed or non-manifold shells, because no downstream tool can compensate for them. Planarity and solid-intersection findings are usually inherent to city models and belong in a report rather than a rejection.
Can I repair the geometry automatically?
Partly. Snapping vertices within a tolerance fixes many ring problems, and adding a ground surface closes most shells. Anything beyond that — a self-intersecting roof, a wall that misses its neighbour by a metre — needs the model corrected at source, because a repair invents geometry nobody surveyed.
Does a valid CityJSON guarantee a valid mesh after export?
No. Triangulation, decimation and quantisation all introduce their own defects, which is why the mesh gate exists separately in validating glTF assets with the Khronos validator.
Related Guides
- Converting CityGML to CityJSON with citygml-tools — the step whose output this validates
- Reprojecting and Upgrading CityJSON Files — the other step that needs validating afterwards
- Schema Validation Gates for Spatial Data — the same pattern across formats