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_semanticsdoes — onematId(ornull) 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
PolygonZring order), and each ring is a struct of the textureidand itsuvlist — 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, solen(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 facei’s ring count; whenuvis non-null,len(texture[theme][i][r].uv)MUST equal ringr’s WKB point count minus one (the closing repeat carries no pair) and every entry MUST hold exactly two values.- Every non-null
idMUST match anidin the corresponding sidecar; references areBIGINTbecause the sidecaridcolumns 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 structidanduvare 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-nullface_semanticsis kept. Thenullcell 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
idvalues — dataset-global and stable — not the feature-local indices CityJSONSeq uses. A reference resolves by matching the sidecar’sidcolumn; 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’suvlist, 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.