Exporting Compliance Results to GeoJSON and CityGML

Permitting portals, municipal GIS servers, and 3D city models each expect compliance findings in a machine-readable spatial format rather than a printed page. This guide serializes a frozen result set to GeoJSON for lightweight web exchange and to CityGML or CityJSON for building-level urban models, while preserving the coordinate reference system metadata that makes the export legally meaningful. It is a task within the audit-ready report generation module, and it produces the same verdicts your PDF compliance reports print.

Prerequisites

Step-by-step

Step 1: Confirm CRS and normalize the output schema

Start from the evaluation output and lock the CRS before anything else. GeoJSON per the specification expects WGS84, so record the measurement CRS as an attribute before reprojecting, or the export loses provenance.

import geopandas as gpd

MEASURE_CRS = "EPSG:25832"  # ETRS89 / UTM 32N (meters), common for German municipal data

def prepare_export(path: str) -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path)
    if gdf.crs is None:
        raise ValueError("Result set has no CRS; cannot export defensibly")
    # Persist the measurement CRS as data so reprojection can't erase provenance.
    gdf["measure_epsg"] = gdf.crs.to_epsg()
    gdf["area_sqm"] = gdf.geometry.area  # computed in the metric CRS, before any reprojection
    keep = ["parcel_id", "status", "rule_id", "rule_version",
            "measured_m", "required_m", "area_sqm", "measure_epsg", "geometry"]
    return gdf[keep]

Step 2: Write GeoJSON in WGS84 with preserved metadata

Reproject a copy to EPSG:4326 for the GeoJSON body, but keep the measure_epsg column so a consumer can see which CRS the distances were measured in. GeoPandas to_file with the GeoJSON driver handles the write.

def export_geojson(gdf: gpd.GeoDataFrame, out_path: str) -> None:
    # RFC 7946 GeoJSON is defined in WGS84 lon/lat; reproject geometry only.
    wgs84 = gdf.to_crs("EPSG:4326")
    # Round coordinates modestly to keep the file reproducible and compact.
    wgs84.to_file(out_path, driver="GeoJSON", COORDINATE_PRECISION=7)

Because the RFC 7946 GeoJSON standard fixes the coordinate system, the measure_epsg attribute is the only record of where the metrics came from — never drop it. See the GeoPandas I/O documentation for driver options and the RFC 7946 specification for the WGS84 requirement.

Step 3: Export CityGML or CityJSON for 3D models

For building-level urban models, write GML through GDAL or produce CityJSON with cjio. CityGML, unlike GeoJSON, retains an explicit CRS, so keep the projected system rather than flattening to WGS84.

def export_citygml(gdf: gpd.GeoDataFrame, out_path: str) -> None:
    # Keep the projected CRS: CityGML records srsName explicitly, so 3D
    # measurements stay in meters rather than degrees.
    projected = gdf.to_crs(MEASURE_CRS)
    # GDAL's GML driver writes an OGC-compliant document with srsName populated.
    projected.to_file(out_path, driver="GML")

If your consumer expects CityJSON specifically, convert the GML with cjio or map attributes onto CityJSON CityObjects, preserving the referenceSystem field with the full EPSG URN.

Step 4: Verify CRS metadata survived the round trip

Re-read each export and assert the CRS is what you intended and that the provenance attribute is intact.

def verify_exports(geojson_path: str, gml_path: str) -> None:
    gj = gpd.read_file(geojson_path)
    assert gj.crs.to_epsg() == 4326, "GeoJSON must be WGS84"
    assert "measure_epsg" in gj.columns, "Lost measurement-CRS provenance"
    gml = gpd.read_file(gml_path)
    assert gml.crs.to_epsg() == 25832, "CityGML lost its projected CRS"
    print(f"GeoJSON features: {len(gj)}, GML features: {len(gml)}")

Verification

Beyond CRS checks, confirm no records were dropped and that a known failing parcel serialized with its non-compliant status in both formats.

def cross_check(source: gpd.GeoDataFrame, geojson_path: str) -> None:
    out = gpd.read_file(geojson_path)
    assert len(out) == len(source), "Feature count drift during GeoJSON write"
    src_fail = set(source.loc[source["status"] != "COMPLIANT", "parcel_id"])
    out_fail = set(out.loc[out["status"] != "COMPLIANT", "parcel_id"])
    assert src_fail == out_fail, "Status mismatch after export"
    print("Export cross-check passed")

Common Pitfalls

  • Measuring area after reprojecting to WGS84. geometry.area on degrees returns meaningless square-degree values. Compute area_sqm in the projected CRS first, then reproject geometry for GeoJSON.
  • Assuming GeoJSON remembers your CRS. RFC 7946 mandates WGS84, so the original EPSG code is gone unless you store it as an attribute. Always carry measure_epsg.
  • Silent geometry drops on GML write. The OGC-strict GML driver rejects invalid rings that GeoJSON tolerated. Run make_valid and a zero-width buffer before exporting, and assert equal feature counts across formats.

Which Geometry Goes in the Export?

An export is more useful when it carries the geometry that explains the verdict rather than only the parcel it concerns, and there are usually three candidates worth including.

Three geometries worth exporting per verdictThe parcel locates the result, the evaluated envelope shows the standard, and the failing geometry is the specific evidence.CarriesOpened first byParcelLocation and identityAnyone joining to their own dataEnvelope or bufferThe standard being appliedDesignersFailing geometryThe specific evidenceReviewers
Export them as separate features sharing a parcel id and a role attribute; a single multi-part geometry hides which part is which.

The parcel locates the result and is what a recipient joins against their own data. The evaluated envelope or buffer shows the standard being applied, which is what turns a number into something a designer can work against. And the failing geometry — the encroaching sliver, the portion of a footprint inside a constraint zone — is the specific evidence, and it is the layer reviewers open first.

Exporting all three as separate features with a shared parcel identifier and a role attribute keeps the file simple and lets a recipient style them independently. Bundling them into one multi-part geometry is smaller and considerably less useful, because the parts can no longer be told apart.

CRS in an Interchange Format

GeoJSON is specified to be in WGS84, and this is where a careful pipeline can undo itself at the last step.

Reprojecting for interchange, safelyMeasurement happens in the projected working frame; reprojection to WGS84 is a presentation step performed after every value has been computed.Has every measurementbeen computed before theexport reprojection?noDo not reproject yetmeasuring after reprojection changes the answeryesReproject and name the unitsmeasured_in_crs travels with the valuesRecord both the working frame and the export frame
A distance in feet beside coordinates in degrees is fine — provided the unit is named. A bare "distance" is not.

The rule that works is to compute in the projected working frame, export in WGS84, and record both. Reprojecting for export is a presentation step performed after every measurement is complete, so it cannot affect a verdict — but the measured values in the properties are in the working frame’s units and must say so. A distance_ft property beside coordinates in degrees is not a contradiction provided the unit is named; a bare distance beside them is an invitation to misread.

CityGML carries a CRS declaration and does not share GeoJSON’s constraint, which makes it the better choice where the recipient needs the geometry in the frame it was measured in — usually another agency’s GIS. Its cost is complexity: it is a much larger specification, and producing a valid document that a specific recipient’s software will open is a compatibility exercise rather than a serialisation one.

def export_geojson(verdicts, working_crs, out_path):
    """Reproject for interchange at the last moment; keep the measured units named."""
    gdf = to_geodataframe(verdicts, crs=working_crs)
    gdf["distance_ft"] = gdf["measured"]        # named unit, not a bare "distance"
    gdf["measured_in_crs"] = working_crs        # the frame the number came from
    gdf.to_crs("EPSG:4326").to_file(out_path, driver="GeoJSON")

Properties a Recipient Can Actually Use

An export’s attribute schema is an interface, and it should be treated with the same care as an API.

An export schema is an interfaceNamed units, a stable outcome vocabulary, the citation and rule version, the recipient’s own parcel key, and a version on the schema itself.Units named, never implieddistance_ft, or a companion unit fieldStable outcome vocabularyincluding the indeterminate valueCitation and rule versionso the recipient knows the standardThe recipient’s parcel keynot only your internal identifierA schema versionso a change is detected, not discovered
Every field a consumer reads is a commitment. Small and stable beats comprehensive and evolving.

Name the units in the field names or in a companion field, never leave them implied. Use a stable, documented set of outcome values, including the indeterminate one. Carry the citation and the rule version so a recipient can tell what standard was applied. Include the parcel identifier the recipient uses, not only your internal key. And version the schema itself, so a consumer can detect a change rather than discovering it through a broken map.

Keep the property set small and stable in preference to comprehensive and evolving. Every field a consumer reads is a commitment, and removing one later breaks their workflow — which, for an agency exchanging data with another agency, is a considerably bigger problem than omitting it in the first place.

Part of: Audit-ready report generation

Frequently Asked Questions

Why does GeoJSON not preserve my projected coordinate system?

The GeoJSON standard, RFC 7946, defines all coordinates in WGS84 longitude and latitude and removed the older CRS member. Writing GeoJSON therefore reprojects your data to EPSG:4326, and the original projected system is not recorded anywhere in the geometry. The fix is to store the measurement EPSG code as a feature attribute before you reproject, so any consumer can see which CRS the compliance distances were computed in.

When should I choose CityGML or CityJSON over GeoJSON?

Use GeoJSON for lightweight web exchange, portal ingestion, and 2D parcel footprints. Reach for CityGML or CityJSON when the consumer maintains a semantic 3D city model and needs building-level objects, level-of-detail structure, and an explicit reference system. CityJSON is the more compact, developer-friendly JSON encoding of the same CityGML data model, and it retains the projected CRS that 3D measurements depend on.

How do I keep area and distance values correct across both formats?

Compute every metric once, in the projected metric CRS, and store the results as plain numeric attributes such as area in square meters and distance in meters. Those numbers then travel unchanged into whatever format you write, regardless of how the geometry itself is reprojected for display. Never recompute area from GeoJSON coordinates, because those are degrees.

Can I round coordinates to shrink the file without breaking audits?

Yes, within reason. Seven decimal places of longitude and latitude is roughly centimeter precision, which is finer than most parcel surveys, so COORDINATE_PRECISION=7 produces compact, reproducible files without materially affecting compliance geometry. Avoid aggressive rounding that could shift a boundary across a setback line.

For a reviewer-facing view of the same exported layers, continue to building interactive HTML compliance dashboards, and ensure the provenance you serialize here matches what the validation log design module captured upstream.