Skip to content
CityParquet is v0.1.0-draft — the specification is still changing.
Esc
↑↓navigate↵open⌘Jpreview
On this page

B · Geometry encoding

WKB geometry, parallel property columns, one geometry column per LoD, and encode-time transform.

How CityParquet stores geometry: as WKB blobs for interoperability today, with everything WKB cannot carry pushed into parallel property columns, one geometry column per level of detail, and CityJSON’s quantised vertices expanded to world-coordinate doubles at encode time. Prior art weighed here: GeoParquet, Apache Iceberg, PostGIS EWKB, GeoArrow, and 3DCityDB.

WKB geometry (including 3D), not native geometry or EWKB

open

Decision. Geometry is stored as WKB BLOB in the dataset CRS. 3D solids → PolyhedralSurfaceZ; multi/composite solids → GeometryCollectionZ; surfaces → MultiPolygonZ. See the specification → Geometry encoding.

Alternatives considered.

  • (a) WKB blob — the OGC Simple Features encoding; interoperable but opaque to the columnar engine. Used by GeoParquet and Apache Iceberg for their geometry columns.
  • (b) PostGIS EWKB — WKB plus a per-record SRID (see the CRS decision for the CRS angle).
  • (c) native Parquet GEOMETRY — the GeoParquet 2.0 direction (vectorisable, coordinate-level pushdown).
  • (d) custom Arrow / GeoArrow packed-coordinate extension type (typed, zero-copy).

Why this. Interoperability today. WKB opens unmodified in DuckDB-spatial, GDAL, QGIS, and PostGIS; it is the common denominator that GeoParquet and Iceberg both settled on. The native GEOMETRY logical type is the more “correct” columnar direction and is adopted where it applies (see Status below), but it inherits the same limit: its 3D vocabulary reaches GeometryCollection and no further, so the solid geometry that is CityParquet’s whole point falls outside it. We follow 3DCityDB’s precedent of using WKB even for 3D.

Trade-off. WKB is opaque to the columnar engine — parsed on every access, poorer compression, no coordinate-level predicate pushdown. Concrete tooling limit found in practice: DuckDB-spatial cannot decode our multi-surface/solid WKB. That limit is itself a reportable finding (columnar-3D tooling is immature). Many libraries advertise WKB support but only for 2D simple features.

Status. WKB is decided, and option (c) is adopted alongside it: the native Parquet GEOMETRY logical type annotates the same WKB bytes rather than replacing them, so a column is simultaneously a plain BYTE_ARRAY and a GeoParquet 2.0 geometry column.

The annotation is not applied to every geometry column. It is applied exactly where geo declares the column — the declaration rule — because a solid column’s PolyhedralSurface Z is outside the geometry model of the engines that consume the annotation. DuckDB promotes any annotated column and converts it eagerly, and its GEOMETRY type has no PolyhedralSurface, so annotating one costs the ability to read it at all rather than buying anything. What CityParquet’s most distinctive columns gain from GeoParquet 2.0 is therefore nothing; what its footprint columns gain is native typing and a per-chunk bounding box with zmin/zmax. That asymmetry is a fact about the ecosystem’s 3D support, not a choice this format makes, and it is the same asymmetry the WKB-versus-native decision above already turns on.

Why not (d). The main goal behind a native Arrow geometry type is native statistics and not needing a separate bbox column — and the Parquet GEOMETRY logical type with GeospatialStatistics delivers exactly that, over WKB bytes every existing reader already understands. That leaves a packed or indexed Arrow encoding offering only a modest size advantage over WKB, which does not justify a second encoding to specify, write, read and test across three implementations. GeoParquet 2.0 reaches the same conclusion, permitting "WKB" alone and dropping the GeoArrow native encodings 1.1.0 allowed.

An index-based layout would also have to go a level deeper than GeoArrow’s own native geometry types support, to carry CityParquet’s shell/solid nesting — so the interoperability argument for reusing a community encoding does not hold here either.

Non-WKB geometry information lives in parallel columns

decided

Decision. A geometry_properties_lod* STRUCT column (per LoD) holds what WKB cannot: the geometry type, the semantic-surface definitions (surfaces), the surface→face mapping, and the solid shell structure. The fixed-shape parts are typed struct fields (type, face_semantics LIST<INT>, shells LIST<LIST<INT>>); only surfaces — heterogeneous per surface — stays JSON. Appearance goes in separate material_lod* / texture_lod* columns — typed MAPs keyed by theme, aligned to the same WKB face order (see appearance and shared resources). Crucially, the surface→face mapping is stored flattened and aligned to WKB face order — a flat face_semantics array where face_semantics[i] is the surface index of WKB face i — rather than as a nested per-shell tree (the shape CityJSON’s values uses). The shell structure includes interior shells, which CityJSON and CityGML 2.0 permit but CityGML 3.0 restricts away, as a stated departure from the 3.0 conceptual model. See the specification → Geometry properties and semantics.

Alternatives considered.

  • (a) copy CityJSON’s nested semantics.values verbatim (simplest, faithful, but analysis-hostile — a nested tree keyed by shell/surface that must be walked and index-resolved);
  • (b) 3DCityDB’s geometry_properties: a flat hierarchy descriptor keyed by geometryIndex into the polyhedral surface — but it carries only geometric hierarchy, not semantic surface types/attributes (3DCityDB keeps those as separate features);
  • (c) the flattened, face-aligned form — chosen: 3DCityDB’s flat index-into-the-polyhedral-surface idea, but keeping the CityGML CM’s semantic surface definitions inline.

Why this. WKB / OGC Simple Features has no slot for the CityGML CM’s semantic surfaces (WallSurface/RoofSurface…), materials, textures, LoD, or implicit-geometry references — once geometry is a blob these must live somewhere explicit. And for the analytical target workload, the mapping must be cheap to use: with face_semantics aligned to WKB faces, “the roof faces of this solid” is a positional filter and per-surface attributes are one dereference into surfaces, instead of a nested-tree traversal. Verbatim CityJSON values would force procedural JSON walking on every surface-level query.

Trade-off. Semantics live beside geometry rather than inside it (the consumer zips the two columns by face index); and aligning face_semantics to the emitted faces means a strict 1 with the source’s surfaces is not guaranteed where faces are dropped (a future consideration, noted under open questions).

Status. Decided and implemented, face_semantics as a native LIST<INT> included; a strict-1 guarantee remains open.

One geometry column per LoD

decided

Decision. Each LoD gets its own geometry_lod* + geometry_properties_lod* column pair, and every geometry column is LoD-suffixed — there is no un-suffixed geometry column, and a suffix always carries a minor version (geometry_lod1_0, never geometry_lod1). The primary geometry column is declared in metadata rather than identified by name. See the specification → Levels of detail.

Alternatives considered.

  • (a) one column per LoD (geometry_lod1_0, geometry_lod2_2, …) — chosen;
  • (b) one long column, one row per LoD (extra lod column);
  • (c) one file per LoD (cityobjects_lod1.parquet, …).

Why this. A query wanting only LoD2 reads only the LoD2 column (projection pushdown) and never touches the LoD1 bytes — the columnar win. The expected access pattern is “the same LoD across many objects” or “several LoDs of one object”, not “different objects each at a different LoD”, so option (b) would sacrifice pruning for a case we do not expect. Option (b) also breaks attribute encoding: with one row per LoD, either every attribute is duplicated across an object’s per-LoD rows, or only the highest-LoD row carries them — neither works well. Option (c) makes the reader resolve which file holds which LoD.

Trade-off. Wider schema as the LoD count grows; each LoD needs its matching geometry_properties_lod*. Uniform suffixes cost a small canonicalisation of the LoD string — a source LoD of 1 names the column geometry_lod1_0 and exports as 1.0 — but the LoD value is unaffected, and the suffix is the single source of truth (no redundant lod value is stored).

Status. Decided and implemented. Column-per-LoD is the normative layout; the row-per-LoD and file-per-LoD alternatives are rejected.

transform applied at encode time, not a structural key

decided

Decision. CityJSON’s quantised integer vertices and transform (scale + translate) are applied during encoding; coordinates are stored as world-coordinate doubles. The source transform is not a structural metadata key — re-quantisation on decode is the decoder’s choice, with parameters it selects. A producer MAY still carry the source transform (and other source-dependent header fields) informationally in the catch-all other metadata key. See the specification → Geometry encoding.

Alternatives considered. Preserve the quantised integers + transform (better compression, byte-exact round-trip) vs expand to doubles and discard the transform (simpler analysis representation).

Why this. A world-coordinate double representation is the simplest thing for analytical consumers and for feeding WKB to GIS tooling — no client needs to know the quantisation scheme. Quantisation is fundamentally a decoding concern: a consumer that wants compact integer output picks a transform suited to its own precision/extent needs, so reproducing the source’s transform buys nothing that the decoder can’t choose better itself.

Trade-off. We lose the exact original quantisation, so a strict byte-level round-trip of vertices is not offered; model-level round-trip (identity, hierarchy, attributes, semantics, appearance, implicit geometries) is unaffected.

Status. Decided — transform applied at encode; not a structural key, but MAY be carried informationally in other.

Last updated on October 12, 2026

Was this page helpful?