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

Appearance & implicit geometries

Materials, textures, and implicit geometries — the optional sidecar files.

Appearance — material and texture — is separated from geometry and carried in per-LoD typed MAP columns aligned to the geometry’s WKB faces, with the shared definitions deduplicated into sidecar files. The shared relative geometries of implicit geometries use the same geometry strategy as the object table. All three sidecars are optional: a source with no appearance and no implicit geometry produces none of them.

material / texture columns

Appearance is separated from geometry, following the OBJ / COLLADA / glTF principle. The object table has stable nullable material_lod* and texture_lod* columns; the shared definitions live in sidecar files.

Appearance is per-surface data attached to a geometry, and every geometry has exactly one LoD — so appearance is inherently per-LoD, and CityParquet gives it one column per LoD, named by the suffix grammar and paired to the geometry it decorates: material_lod2_2 / texture_lod2_2 describe the surfaces of the same row’s geometry_lod2_2 cell, exactly as geometry_properties_lod2_2 carries that geometry’s semantics. So a query for one LoD’s appearance reads only that column, and the LoD-to-appearance pairing is carried by the column name rather than by a key a consumer must match.

Column Type
material_lod* MAP<VARCHAR, LIST<BIGINT>>
texture_lod* MAP<VARCHAR, LIST<LIST<STRUCT<id BIGINT, uv LIST<LIST<DOUBLE>>>>>>

These columns carry no lod value — the column name already fixes the LoD. The outer dimension is a theme: a dynamic string key whose set is not known in advance (a geometry may carry any number of named appearance themes), which is why the column is a MAP rather than a STRUCT — a fixed struct cannot express an open key set. What a theme holds has a fixed shape, so the map’s value is typed. The key is the theme name; CityJSON’s unnamed theme is the empty string "".

-- material_lod2_2 — theme → one material id (or null) per WKB face
{ "<theme>": [ <matId|null>, … ] }

-- texture_lod2_2 — theme → per WKB face → per ring of that face → { id, uv }
{ "<theme>": [ [ { id: <texId>, uv: [ [u,v], [u,v], … ] }, … ], … ] }

A material_lod<suffix> / texture_lod<suffix> cell is non-null only where the same row’s geometry_lod<suffix> is non-null. A cell is null when the geometry carries no material (or no texture) in any theme; a theme absent from the map carries nothing for that geometry.

  • Material is per face: a theme’s list aligns to the geometry’s WKB faces exactly as face_semantics does — one matId (or null) per face — so its length equals the WKB face count (see geometry & semantics). A source that assigns one material to the whole geometry is expanded to one entry per face; there is no whole-geometry shorthand in the column.
  • Texture is per ring, because texture coordinates attach to ring vertices: a theme’s list is one entry per WKB face, each of which is a list over that face’s rings (exterior ring first, then interior rings, matching the PolygonZ ring order), and each ring is a struct of the texture id and its uv list — one [u, v] pair per distinct vertex of that ring, in ring vertex order, each inner list holding exactly two values. A WKB ring closes by repeating its first point; that closing repeat has no pair, so len(uv) is the ring’s WKB point count minus one — the CityJSON ring’s vertex count. A ring with no texture is { id: null, uv: null }. Flattening a face to one { id, uv } would lose the appearance of any interior ring, so the ring level is retained even when a face has only its exterior ring.

Invariants. For every theme in a non-null cell:

  • len(material[theme]) MUST equal the WKB face count.
  • len(texture[theme]) MUST equal the WKB face count; len(texture[theme][i]) MUST equal face i’s ring count; when uv is non-null, len(texture[theme][i][r].uv) MUST equal ring r’s WKB point count minus one (the closing repeat carries no pair) and every entry MUST hold exactly two values.
  • Every non-null id MUST match an id in the corresponding sidecar; references are BIGINT because the sidecar id columns are.
  • Map values MUST be non-null and a map MUST NOT be empty: a theme is either present with a full-length list or absent. In texture_lod* every face entry and every ring struct MUST be non-null; within a ring struct id and uv are null together; every [u, v] entry and both of its values MUST be non-null.
  • A theme the source carries stays present even when every face entry is null (a same-length all-null list), exactly as an all-null face_semantics is kept. The null cell is reserved for a geometry with no material (or no texture) at all.

The key property is the same one face_semantics has: material[theme][i] is the material of WKB face i, with no geometry-type-specific nesting to walk. A Solid’s per-shell partition is already recorded once, in geometry_properties_lod*.shells.

Example (constructed). A MultiSurface at LoD 2.2 with two faces, a roof and a wall, each a single ring of four distinct vertices. The roof has material 3 in the unnamed theme; the wall has texture 0, with one [u, v] pair per vertex of its ring, in a theme named photo:

// geometry_properties_lod2_2
{ "type": "MultiSurface",
  "surfaces": [ { "type": "RoofSurface" }, { "type": "WallSurface" } ],
  "face_semantics": [0, 1], "shells": null }

// material_lod2_2 — one entry per WKB face, aligned like face_semantics
{ "": [3, null] }

// texture_lod2_2 — per WKB face, then per ring of that face
{ "photo": [ [ { "id": null, "uv": null } ],
             [ { "id": 0, "uv": [[0, 0], [1, 0], [1, 1], [0, 1]] } ] ] }

The 3 and the 0 are the ids of rows in the sidecars, not row positions:

materials.parquet id name diffuseColor
3 roofmat [0.9, 0.1, 0.1]
textures.parquet id image_uri image_type textureType
0 appearance/facade.jpg JPG specific

Two normalisations apply relative to a CityJSON source:

  • Material and texture references are sidecar id values — dataset-global and stable — not the feature-local indices CityJSONSeq uses. A reference resolves by matching the sidecar’s id column; it MUST NOT be interpreted as a row position, which is not a stable key and may change when a package is rewritten.
  • Texture UV indices are replaced inline by the actual [u, v] coordinate pair in the ring’s uv list, so the object table is self-contained and no stored UV pool is needed. On export, UV pairs are re-interned into a feature-local pool and indices re-derived.

materials.parquet

Column Type Req. Description
id BIGINT ✔ Material identifier, unique across rows; the value material_lod* references
name VARCHAR Material name
ambientIntensity DOUBLE Ambient intensity
diffuseColor LIST<DOUBLE> Diffuse colour — 3 values in [0,1]
specularColor LIST<DOUBLE> Specular colour — 3 values in [0,1]
emissiveColor LIST<DOUBLE> Emissive colour — 3 values in [0,1]
transparency DOUBLE Transparency
shininess DOUBLE Shininess
isSmooth BOOLEAN Smooth-shading flag
other JSON Unmapped material members

All columns except id are nullable. A colour column MUST hold exactly three values, each in [0,1], when non-null.

textures.parquet

There is no stored UV pool — UV coordinates are inlined in the object table’s texture_lod* maps, as described above.

Column Type Req. Description
id BIGINT ✔ Texture identifier, unique across rows; the value texture_lod* references
image_uri VARCHAR External image URI
image_data BLOB Optional embedded image bytes
image_type VARCHAR Image format token, verbatim from the source ("JPG", "PNG"); not a MIME type
wrapMode VARCHAR One of none, wrap, mirror, clamp, border; SHOULD be dictionary-encoded
textureType VARCHAR One of unknown, specific, typical; SHOULD be dictionary-encoded
borderColor LIST<DOUBLE> Border colour — 4 values in [0,1] (RGBA)
other JSON Unmapped texture members

All columns except id are nullable, but a row MUST carry at least one of image_uri / image_data — a texture with no image is meaningless. When both are present, image_data is the authoritative bytes and image_uri is where they came from. wrapMode and textureType are enumerations: a value outside the listed set is invalid. borderColor MUST hold exactly four values, each in [0,1], when non-null.

implicit_geometries.parquet

An implicit geometry, following the CityGML ImplicitGeometry, places a shared geometry at an object by a reference point and a transformation matrix. The shared geometry — the relative geometry, in its own local coordinates — is stored once in implicit_geometries.parquet, one row per relative geometry, using the same geometry strategy as the object table — including the per-LoD column grammar, so a relative geometry’s LoD is carried by its column name here exactly as it is in an object table.

Column Type Req. Description
id BIGINT ✔ Relative-geometry identifier, unique across rows; the value the object table’s implicit_geometry.id references
name VARCHAR The relative geometry’s identifier in the source document, when it had one. id is an integer so that relative geometries remap like the other sidecars on merge, which leaves nowhere else for a meaningful source name to survive.
geometry_lod* BLOB Relative geometry as WKB in local coordinates (raw doubles — its vertices are not subject to any dataset-level transform); one column per LoD
geometry_properties_lod* STRUCT<type, surfaces, face_semantics, shells> Geometry properties of the relative geometry, the same struct as an object row’s
material_lod* MAP<VARCHAR, LIST<BIGINT>> Material mapping of the relative geometry, per LoD — the same shape and invariants as an object row’s
texture_lod* MAP<VARCHAR, LIST<LIST<STRUCT<id BIGINT, uv LIST<LIST<DOUBLE>>>>>> Texture mapping of the relative geometry, per LoD — the same shape and invariants as an object row’s

A relative geometry is a single geometry at a single LoD, so each row populates exactly the column set matching its own LoD and leaves the others null. The table is sparse by construction; this is the cost of keeping one LoD-naming rule across the whole format rather than making the sidecar the sole place where an LoD rides in a value. There is no other column: a relative geometry is a plain geometry (WKB + properties + appearance) with no members left over to preserve.

Relative geometries are in local coordinates, and are exempt from the file CRS. geometry_lod* in this sidecar holds raw doubles in the relative geometry’s own local frame — an object’s transformationMatrix and reference point place it into the world. So the file-CRS invariant that binds object geometry and bbox to one CRS does not apply to implicit_geometries.parquet: it carries no city.crs key. (The reference point in the object table is in the file CRS, like any other object-table geometry.)

An object with an implicit geometry stores it in the object-table implicit_geometry column. The column is nullable, but a non-null value MUST carry both id and point — an implicit geometry with neither a relative geometry to resolve nor a place to sit is meaningless:

Field Type Description
id BIGINT Referenced relative-geometry id in implicit_geometries.parquet (required when the struct is non-null)
point BLOB WKB PointZ for the reference point, in the file CRS (required when the struct is non-null)
transformationMatrix LIST<DOUBLE> The transformation matrix — a flat, row-major 4×4, so exactly 16 values when non-null (the CityJSON transformationMatrix layout, unchanged); absent ⇒ identity

Placement. The transformationMatrix M and the reference point p place each vertex v of the relative geometry at the world position

M · (vx, vy, vz, 1)T + (px, py, pz, 0)T

— the matrix acts in the relative geometry’s own frame, and the result is moved to the reference point, as in CityGML’s ImplicitGeometry. M is a full 4×4 affine matrix, so its translation column (m03, m13, m23) is applied on top of p. Its last row MUST be 0, 0, 0, 1: a projective matrix has no meaning for an implicit geometry, and a reader MUST treat one as invalid input. An absent matrix is the identity, so the vertex is placed at v + p.

🟡 An object with several implicit geometries cannot currently be represented, since implicit_geometry is a single struct — see the open questions.

Last updated on October 12, 2026

Was this page helpful?