Geometry, LoD & spatial metadata
WKB geometry encoding, surface semantics, the per-LoD column layout, CRS, and the bbox column.
CityParquet stores geometry as ISO WKB blobs, carries the CityGML CM information WKB
cannot express — chiefly per-surface semantics — in a parallel STRUCT column, gives
every level of detail its own column pair, and puts a 3D bounding box on every row for
spatial pruning. This page defines all four.
WKB encoding
The geometry column stores ISO WKB as BLOB, in the file CRS. This is the same WKB
serialisation GeoParquet uses, but not the same vocabulary: GeoParquet admits only the
seven basic OGC Simple Feature types (Point to GeometryCollection), whereas
CityParquet also stores the solid-family geometry (PolyhedralSurfaceZ) a 3D city model
needs. Simple Features 1.2 defines PolyhedralSurface, but GeoParquet does not permit it
— so the two are only partially interchangeable (see the note on GeoParquet-legal types
below).
Specifically:
- ISO WKB with 1000-series Z type codes (
PointZ= 1001,LineStringZ= 1002,PolygonZ= 1003,MultiPointZ= 1004,MultiLineStringZ= 1005,MultiPolygonZ= 1006,GeometryCollectionZ= 1007,PolyhedralSurfaceZ= 1015) — not EWKB. No SRID is embedded in the geometry; the CRS is carried once per file. - little-endian byte order (WKB byte-order marker
0x01). - Coordinates are world-coordinate doubles — the real coordinate values in the file CRS. Unlike CityJSON, CityParquet does not store quantised integer vertices against a dataset-level scale and translation: a consumer needs nothing but the file to read a coordinate, and no client has to understand a quantisation scheme to feed the WKB to GIS tooling. Quantisation is treated as a decoding concern — a consumer wanting compact integer output picks parameters suited to its own precision and extent (see the encoding decision).
A Solid is encoded as a single PolyhedralSurfaceZ whose faces are all the
solid’s surfaces, flattened across shells in document order (outer shell first, then
inner shells). The exterior/interior shell distinction is not represented in the
WKB — it is preserved in geometry_properties_lod* (see below). Within a single
surface, interior rings (holes) are preserved: ring 0 is the exterior ring,
subsequent rings are interior rings of the same PolygonZ.
Ring closure and geometric validity. Every WKB polygon ring MUST be explicitly closed: its last point repeats its first, and it carries at least four points including that closing vertex. This is a structural requirement of the encoding, not a validity judgement. CityParquet does not constrain geometric validity: real-world city models contain zero-area rings, non-planar faces, and other geometry a validator would reject, and the format represents them as the source provides them. A reader MUST NOT reject structurally well-formed geometry on the grounds that it is geometrically invalid; validity assessment is the business of consuming tools, not of the format. A writer MAY normalise geometry that cannot form a structurally well-formed WKB ring at all — fewer than three distinct positions in sequence — and a writer that does so SHOULD report what it removed rather than removing it silently.
Surface winding. Because the geometry is 3D, ring order is defined by the
right-hand rule, not the 2D clockwise/counter-clockwise convention. The default is
right-handed: each surface’s exterior ring is wound so that the right-hand rule
yields the surface’s outward-facing normal, and interior rings are wound
oppositely — matching the CityGML convention. Absent any declaration to the contrary,
a reader MUST assume this default. A writer MAY declare the winding per column
in the city.columns[].orientation_3d field
("right-handed" or "left-handed"). Because city.columns registers every
geometry column — solids included — this field has a home even for the columns absent
from geo.
Empty or absent geometry, and implicit geometries
(whose relative geometry lives in implicit_geometries.parquet), produce a null
geometry cell.
Example (real record). The geometry_lod2_2 blob of the BuildingPart from the
object-table schema (via DuckDB on the 3DBAG
subset):
| Column | Value |
|---|---|
geometry_lod2_2 |
2 425-byte BLOB. WKB header 01 F7030000 10000000 … decodes as: 01 little-endian; F7030000 = type 1015 (PolyhedralSurfaceZ); 10000000 = 16 faces; first face 01 EB030000 … = type 1003 (PolygonZ). |
Geometry-type mapping
| CityGML CM geometry | CityJSON name | CityParquet WKB encoding |
|---|---|---|
MultiPoint |
MultiPoint |
MultiPointZ |
MultiCurve |
MultiLineString |
MultiLineStringZ |
MultiSurface |
MultiSurface |
MultiPolygonZ |
CompositeSurface |
CompositeSurface |
MultiPolygonZ |
Solid |
Solid |
PolyhedralSurfaceZ |
MultiSolid |
MultiSolid |
GeometryCollectionZ (one PolyhedralSurfaceZ per solid) |
CompositeSolid |
CompositeSolid |
GeometryCollectionZ (one PolyhedralSurfaceZ per solid) |
ImplicitGeometry |
GeometryInstance |
implicit_geometry struct + referenced relative geometry in implicit_geometries.parquet; geometry cell is null |
MultiSurface and CompositeSurface both encode to MultiPolygonZ; the distinction
between them is recorded in geometry_properties_lod*.type, as is the distinction
between MultiSolid and CompositeSolid.
The WKB is the analysis geometry. It does not encode semantic surfaces, material/texture mappings, or implicit-geometry references; those live in separate columns or sidecar files.
Geometry properties and semantics
The geometry_properties_lod* column carries the CityGML CM geometry information that
WKB has no slot for — chiefly the semantic surfaces (RoofSurface, WallSurface,
GroundSurface, …) and their attributes. It is a STRUCT, so the parts whose
shape is fixed are typed columns a query engine can read without parsing JSON:
STRUCT<
type VARCHAR, -- non-null
surfaces JSON, -- nullable
face_semantics LIST<INT>, -- nullable; items nullable
shells LIST<LIST<INT>> -- nullable; where non-null, both nesting
-- levels (inner LIST<INT> and each INT)
-- are themselves non-null
>
| Field | Type | Description |
|---|---|---|
type |
VARCHAR |
The CM geometry type (Solid, MultiSurface, …), disambiguating the encodings that share a WKB type. Non-null; SHOULD be dictionary-encoded. |
surfaces |
JSON |
The semantic surface definitions — each an object with a type plus any surface attributes (slope, azimuth, extension attributes, …). Kept as JSON because surfaces do not share a fixed shape (each may carry different attributes). A writer MUST preserve the array’s order and member content, and MUST NOT deduplicate entries or drop unreferenced ones: a semantic surface’s parent / children members are indices into this array, so any reordering or removal dangles them. Null when the geometry carries no semantics. |
face_semantics |
LIST<INT> |
One entry per WKB face, in WKB face order; each entry is the index of that face’s surface in surfaces, or null for a face with no semantics. Null when the geometry carries no semantics. |
shells |
LIST<LIST<INT>> |
Per-solid, then per-shell, face counts, so the shell nesting the WKB flattens can be reconstructed — one inner list per solid (a Solid has exactly one; MultiSolid / CompositeSolid one per member, in WKB order), each inner list the face count of each shell, outer (exterior) shell first and any interior shells after it: [[12]], [[12, 4]], [[12], [8, 4]]. Null for the non-solid types. |
There is no lod field: the level of detail is carried by the column name
(geometry_properties_lod2_2), so storing it again would be redundant.
Example (constructed). A cube-shaped house with a cubic cavity, as CityGML 2.0 and CityJSON permit it. Faces 0–5 form the exterior shell and face outwards; faces 6–11 form the interior shell around the cavity and face into it. The WKB is shown as WKT.
-- geometry_lod2_2: stored as WKB, shown here as WKT
POLYHEDRALSURFACE Z (
((0 0 0, 0 3 0, 3 3 0, 3 0 0, 0 0 0)), -- face 0: ground
((0 0 0, 3 0 0, 3 0 3, 0 0 3, 0 0 0)), -- face 1: wall
... -- faces 2-4: walls
((0 0 3, 3 0 3, 3 3 3, 0 3 3, 0 0 3)), -- face 5: roof
((1 1 1, 2 1 1, 2 2 1, 1 2 1, 1 1 1)), -- face 6: cavity
... -- faces 7-11: cavity
)
-- geometry_properties_lod2_2
{
"type": "Solid",
"surfaces": [
{ "type": "GroundSurface" },
{ "type": "WallSurface" },
{ "type": "RoofSurface" }
],
"face_semantics": [0, 1, 1, 1, 1, 2,
null, null, null, null, null, null],
"shells": [[6, 6]]
}
face_semantics maps each WKB face to an entry of surfaces, or to null for the
cavity faces, which carry no semantics; shells records one solid with two shells of
six faces each.
The key property: face_semantics[i] is the semantic surface of WKB face i. So
“the roof faces of this solid” is a positional filter over face_semantics (now a
native LIST<INT> a columnar engine can UNNEST), and each face’s attributes are one
dereference into surfaces — a flat lookup, not a nested-tree traversal. This is the
primary affordance CityParquet adds for surface-level analysis. Consumers that need
per-surface semantics MUST read face_semantics + surfaces rather than
re-deriving surface identity from raw WKB.
Invariants. len(face_semantics) MUST equal the WKB face count, and every
non-null entry MUST index an existing surfaces element. Where shells is
non-null it MUST have one inner list per solid and its total MUST equal that
same face count. surfaces and face_semantics are null together when the geometry
carries no semantics; shells is non-null for solid-family geometry regardless.
shells, where present, is non-null all the way down. The column itself is nullable (absent for non-solid types), but a populatedshellsvalue has no null entries at either nesting level: each inner list (one per solid) is itself non-null, and everyINTface count inside an inner list is non-null. There is no partially-known shell structure — a solid’s shell partition is either fully recorded or the wholeshellscell is null.face_semanticsitems are independently nullable — this is not the same case assurfaces/face_semanticsbeing null together. A geometry can carry a non-empty, non-nullsurfacesarray (real semantic surface definitions exist) while every entry of a non-nullface_semanticsis itselfnull— no WKB face happens to reference any of them. This is legitimate, not an error: a writer that encounters it MUST keepsurfacesverbatim (the MUST-preserve rule above) rather than dropping the now-apparently-unreferenced entries, and emitsface_semanticsas a same-length, all-null list — never collapses it to a nullface_semanticscell, which is reserved for “this geometry carries no semantics at all” (surfacesalso null).
Why flat, and how it relates to CityJSON
Two facts explain the shape, and neither is arbitrary:
shells exists because of WKB, not because of semantics. A WKB
PolyhedralSurface is a flat list of faces; it has no concept of a shell. So the
shell partition has to be recorded somewhere or a Solid cannot be reconstructed at
all — this is true even for a solid carrying no semantics whatsoever.
Given shells, a nested face_semantics would store that partition twice.
CityJSON nests its semantics.values by shell, mirroring its nested boundaries.
CityParquet’s boundaries are flattened into the WKB, so keeping a nesting that mirrors
a structure the format no longer has would encode the shell partition a second time —
in a form that can disagree with shells, and that loses the counts entirely under
CityJSON’s shorthand where a single null stands for a whole shell.
The practical size of the difference is smaller than it looks. For MultiSurface and
CompositeSurface, CityJSON’s values is already flat, one entry per surface — so
face_semantics is CityJSON’s values unchanged, and shells is absent. The two
forms diverge only for Solid, MultiSolid and CompositeSolid, which is exactly
where the WKB flattened the shells:
// Solid, one shell, 12 faces
// CityJSON: "semantics": { "surfaces": [...], "values": [[0,1,1,1,1,1,1,1,1,1,1,3]] }
// CityParquet: face_semantics = [0,1,1,1,1,1,1,1,1,1,1,3], shells = [[12]]
// MultiSurface, 3 faces — identical in both
// CityJSON: "values": [0,1,1]
// CityParquet: face_semantics = [0,1,1] (shells = null)
CityJSON’s null shorthand — a single null standing for a whole shell or solid with
no semantics — is expanded to one null per face, so face_semantics always has
exactly one entry per face. Reconstruction therefore yields the expanded per-face form
rather than the source’s shorthand; the two are semantically equal but not byte-equal,
and round-trip losslessness is defined up to this
canonicalisation.
Extended semantic surfaces carry their extension’s namespace as a prefix in the
surface object’s type, exactly as extension attributes do in column names: the
+PartyWallSurface of the Energy ADE (namespace energy) is stored as
{ "type": "energy_PartyWallSurface" } — see extensions.
Example. The geometry_properties_lod2_2 struct of a Solid whose 12 faces carry
ground, wall and roof semantics, with reconstruction attributes on the roof surface
(only surfaces is JSON; the rest are typed struct fields):
{
"type": "Solid",
"surfaces": [
{ "type": "GroundSurface" },
{ "type": "WallSurface", "on_footprint_edge": true },
{ "type": "WallSurface", "on_footprint_edge": false },
{ "type": "RoofSurface", "b3_azimut": 297.93, "b3_h_dak_50p": 96.35 }
],
"face_semantics": [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3],
"shells": [[12]]
}
The 12-entry face_semantics maps one-to-one onto the 12 WKB faces; extended surface
attributes (on_footprint_edge, the b3_* reconstruction values) stay inline in
surfaces, since WKB has no slot for them. There is no lod field — the column name
geometry_properties_lod2_2 already carries it.
Levels of detail
Level of detail is represented by the column layout: one geometry column per LoD, each with a matching properties column, so a query for one LoD reads only that column (projection pushdown).
Suffix grammar. A geometry column is named geometry_lod<major>_<minor>, with a
matching geometry_properties_lod<major>_<minor>, material_lod<major>_<minor> and
texture_lod<major>_<minor>. The LoD string’s dot becomes an underscore: LoD 2.2 →
geometry_lod2_2.
- A suffix always carries a minor. LoD
1yieldsgeometry_lod1_0, nevergeometry_lod1. Column names are therefore uniform, and a reader can parse an LoD out of a column name without special cases. - Every geometry column is suffixed. There is no un-suffixed
geometry,geometry_properties,materialortexturecolumn. - The LoD lives only in the column name. The suffix is the single source of truth
for a geometry’s LoD; it is not stored again in a value. A consumer recovers the LoD
by parsing the suffix (
lod2_2→2.2). The exact source spelling is not separately retained, so a source"1"and a source"1.0"both map togeometry_lod1_0and export to the same canonical"1.0"— a canonicalisation of the LoD string, not its value.
The primary geometry column is declared, not named by convention. GeoParquet does
not require a primary geometry column to be called geometry; it names one in
geo.primary_column. CityParquet does the same in the
city.primary_column metadata key. A writer will commonly
choose the finest analysis geometry — often the highest LoD, which may be a Solid;
city.primary_column may name such a column, whereas geo.primary_column (GeoParquet’s
own) must be a legal one, so the two can differ. A consumer MUST read the
declaration rather than assume a column name.
Every geometry column carries an LoD. The invariant is on the file: every
non-null geometry_lod* cell MUST sit in a column with a valid LoD suffix, and
that suffix is the sole carrier of its LoD — the paired geometry_properties_lod*
stores no lod value of its own. There is no default or
sentinel LoD — a geometry whose detail level is unknown has no representable column,
so it cannot appear. (A source geometry with no LoD is therefore not encodable without
an LoD being supplied for it; how a tool obtains one is out of scope.)
Geometry columns are optional. A table whose objects have no analysis geometry —
only implicit geometries, or none at all — simply carries no geometry column, and
declares no city.primary_column.
CRS
The CRS is defined once per file, as PROJJSON, in the
city.crs metadata key (and mirrored into geo for
GeoParquet tools). All geometry columns and the bbox column in a file MUST share
that one CRS; there is no per-row or per-column CRS escape hatch (see
the CRS decision). The key itself follows
GeoParquet’s convention — a PROJJSON object when known, an explicit null when the CRS
is unknown, absent (read as OGC) only in files with no CRS-bearing coordinates —
per the CRS rules.
Spatial metadata
Every object row SHOULD carry a bbox in the same CRS as the geometry — a
3D struct [xmin, ymin, zmin, xmax, ymax, zmax].
bboxis the union over the object’s whole subtree — its own geometry and every descendant’s, not merely the nearest geometry-bearing generation. ABuildingcarrying only an LoD0 footprint over solidBuildingParts therefore still spans its parts’ full z-range. The subtree stays within the object’s family: a group’s member that is a family of its own (see the feature_id rule) is not in the group’s box.- Where the source declares a per-object extent of its own, that extent is
unioned into
bbox. It may only widen the box: a source extent is not guaranteed to contain the geometry it describes, and a box narrower than the geometry would prune the row out of queries that intersect it. bboxis null only when nothing in the object’s subtree has geometry. Null bboxes do not affect Parquet min/max statistics, so row-group pruning stays sound.- When a row stores multiple LoDs,
bboxis the union over all of the row’s stored geometry, not the highest LoD alone.bboxis a pruning aid, and pruning is only sound if the box is a superset of everything the row stores: a superset can cause a false-positive read, but a box drawn from the highest LoD alone could exclude a lower-LoD geometry that extends beyond it, and that geometry would then be wrongly pruned away. A producer MAY union geometry it did not ultimately store (the object still occupies that extent).
Rows MAY be written in Hilbert-curve order
on the bbox centroid to improve spatial locality for object-storage range reads and
row-group pruning. The order is over feature families, not single rows: the writer derives
one key per family and every row of the family takes it, so the order keeps each family’s
rows contiguous. Which point of the family the key
is computed from is the writer’s choice; the reference writer uses the centre of the
feature’s vertices. The cost falls on the writer: no row can
be written until every key is known, so it sorts the whole dataset, in memory or spilling
sorted runs to temporary storage and merging them, whereas rows in source order stream one
at a time. Either order is conformant. Row-group sizing interacts with pruning selectivity; see the
dataset metadata.