Attaching EXT_structural_metadata to Building Tiles
This page attaches per-building attributes to a glTF tile using EXT_structural_metadata and EXT_mesh_features — declaring a schema with real types, packing a property table, assigning feature IDs to vertices, and verifying that a pick in the viewer returns the attributes of the building that was clicked. It replaces the 1.0 batch table, and the replacement is worth making mostly because the schema turns an implied convention into something a validator and a future reader can check.
Why you hit this
A tileset without metadata is a picture. The moment a planner clicks a building and expects its construction year, or a query has to select every industrial building above 20 m, the geometry needs attributes attached to it in a form the runtime can index. In 1.0 that was the batch table: a JSON object per tile, positionally joined to features, with types inferred from whatever happened to be in the values. It worked and it carried no contract, so a property that was an integer in one tile and a string in another was perfectly legal and broke consumers at runtime.
The schema-level decisions and the wider 1.1 picture live in tileset metadata and 3D Tiles Next.
Prerequisites
- Node 18+ with
@gltf-transform/cli(npm i -g @gltf-transform/cli) and3d-tiles-validator. - Python 3.10+ with
pygltflib>=1.16andnumpy>=1.24for inspection. - A
.glbper tile whose primitives are one building each, or a merged primitive where you can compute a per-vertex building index. - The attribute table itself — typically a GeoPackage or Parquet keyed by the same building id the geometry carries.
Step-by-Step
1. Declare the schema, with types and enums
The schema is the contract. Write it once for the tileset and reference it from every tile.
import json
schema = {
"id": "city_v3",
"name": "Municipal building register",
"version": "3.0.0",
"classes": {
"building": {
"name": "Building",
"properties": {
"gml_id": {"type": "STRING", "required": True},
"year_built": {"type": "SCALAR", "componentType": "UINT16"},
"height_m": {"type": "SCALAR", "componentType": "FLOAT32"},
"storeys": {"type": "SCALAR", "componentType": "UINT8"},
"function": {"type": "ENUM", "enumType": "buildingFunction", "required": True},
"heritage": {"type": "BOOLEAN"},
},
}
},
"enums": {
"buildingFunction": {
"valueType": "UINT8",
"values": [
{"name": "residential", "value": 0},
{"name": "commercial", "value": 1},
{"name": "industrial", "value": 2},
{"name": "civic", "value": 3},
{"name": "mixed", "value": 4},
],
}
},
}
json.dump(schema, open("city_v3.schema.json", "w"), indent=2)
Three choices in that block are worth defending. UINT16 for a year is exact and half the size of a float. FLOAT32 for height is right because the value is a measurement with real uncertainty, not an identifier. And function as an enum means the wire format is one byte per building while the reader still sees "commercial" — over 400,000 buildings that is 400 KB instead of about 4 MB.
2. Build the property table
A property table is columnar: one array per property, indexed by feature id. Variable-length properties such as strings need an offsets array alongside the data.
import numpy as np
buildings = [
{"gml_id": "BLDG_0041", "year_built": 1974, "height_m": 18.4, "storeys": 6,
"function": 0, "heritage": False},
{"gml_id": "BLDG_0042", "year_built": 1908, "height_m": 22.1, "storeys": 7,
"function": 1, "heritage": True},
{"gml_id": "BLDG_0043", "year_built": 2011, "height_m": 11.9, "storeys": 3,
"function": 4, "heritage": False},
]
year = np.array([b["year_built"] for b in buildings], dtype=np.uint16)
height = np.array([b["height_m"] for b in buildings], dtype=np.float32)
storeys = np.array([b["storeys"] for b in buildings], dtype=np.uint8)
function = np.array([b["function"] for b in buildings], dtype=np.uint8)
heritage = np.packbits(
np.array([b["heritage"] for b in buildings], dtype=np.uint8), bitorder="little")
ids = "".join(b["gml_id"] for b in buildings).encode()
id_offsets = np.cumsum([0] + [len(b["gml_id"]) for b in buildings]).astype(np.uint32)
print("year bytes:", year.nbytes, "| ids bytes:", len(ids),
"| offsets bytes:", id_offsets.nbytes)
The string offsets array has one more entry than there are features — it stores the start of every string plus the end of the last. An off-by-one here truncates the final building’s id, which shows up as one unpickable building at the end of every tile.
3. Assign feature IDs to the geometry
EXT_mesh_features connects vertices to rows in the table. For per-building tiles the simplest form is a per-vertex attribute holding the building index.
import numpy as np
from pygltflib import GLTF2
g = GLTF2().load("block_0_0.glb")
prim = g.meshes[0].primitives[0]
# One value per vertex, naming which building that vertex belongs to.
n_vertices = g.accessors[prim.attributes.POSITION].count
feature_ids = np.zeros(n_vertices, dtype=np.float32)
feature_ids[1200:2400] = 1
feature_ids[2400:] = 2
prim.extensions = prim.extensions or {}
prim.extensions["EXT_mesh_features"] = {
"featureIds": [{"featureCount": 3, "attribute": 0, "propertyTable": 0}]
}
print("feature ids:", np.unique(feature_ids).astype(int).tolist())
attribute: 0 refers to the _FEATURE_ID_0 vertex attribute, and propertyTable: 0 names which table in the tile’s EXT_structural_metadata those ids index. Both are indices, not names, so inserting a table at the front of the list silently rewires every primitive that referenced index 0.
4. Wire the extension into the glTF
gltf-transform writes both extensions and keeps the buffer layout valid, which is worth more than hand-editing the JSON.
npx @gltf-transform/cli meta \
--schema city_v3.schema.json \
--class building \
--table block_0_0_table.json \
block_0_0.glb block_0_0_meta.glb
npx @gltf-transform/cli inspect block_0_0_meta.glb | head -30
5. Verify a pick returns the right building
The check that matters is end to end: pick a vertex, resolve its feature id, read the row, and compare against the source register.
import numpy as np
from pygltflib import GLTF2
g = GLTF2().load("block_0_0_meta.glb")
ext = g.extensions["EXT_structural_metadata"]
table = ext["propertyTables"][0]
print("schema:", ext["schema"]["id"], "| class:", table["class"],
"| features:", table["count"])
# Resolve one feature the way a runtime does.
def read_scalar(prop_name, feature_id, dtype):
view_idx = table["properties"][prop_name]["values"]
view = g.bufferViews[view_idx]
blob = g.binary_blob()[view.byteOffset : view.byteOffset + view.byteLength]
return np.frombuffer(blob, dtype=dtype)[feature_id]
fid = 1
print("year_built:", int(read_scalar("year_built", fid, np.uint16)))
print("height_m:", float(read_scalar("height_m", fid, np.float32)))
Expected Output & Verification
A correct tile inspects as:
schema: city_v3 | class: building | features: 3
year_built: 1908
height_m: 22.100000381469727
year bytes: 6 | ids bytes: 27 | offsets bytes: 16
Then assert the two counts agree before the tile ships — the feature count declared in EXT_mesh_features must equal the property table’s count, and both must equal the number of buildings the geometry step wrote.
prim_ext = g.meshes[0].primitives[0].extensions["EXT_mesh_features"]
declared = prim_ext["featureIds"][0]["featureCount"]
table_count = g.extensions["EXT_structural_metadata"]["propertyTables"][0]["count"]
assert declared == table_count, f"{declared} feature ids vs {table_count} table rows"
print("feature ids and table rows agree:", declared)
Finally validate and pick in a real client. 3d-tiles-validator will catch a malformed extension; only a pick catches a table whose rows are in a different order from the geometry, which is valid and wrong.
Common Errors
Every building returns the same attributes. The _FEATURE_ID_0 attribute is all zeros, usually because it was declared but never populated. Check np.unique on the attribute before writing the tile.
The last building in each tile cannot be picked. The string offsets array has N entries instead of N+1, so the final id is truncated to zero length. Build offsets with np.cumsum([0] + lengths).
A pick returns a neighbour’s attributes. The geometry and the property table were built in different orders. Build them in one pass, and if a sort is unavoidable, sort both with the same permutation and assert on a known id afterwards.
Unknown extension EXT_structural_metadata in an older viewer. The client predates 1.1. Either pin a newer CesiumJS or keep publishing a 1.0 tileset with batch tables alongside during the transition.
Frequently Asked Questions
Can I keep using the batch table?
Yes — 1.0 tilesets remain valid and CesiumJS still reads them. The reasons to move are the declared types, the enum compression, and having one schema instead of an implied convention repeated per tile.
How large should a property table be?
One per tile, covering the features in that tile. A single table for the whole city forces every tile to carry it, which defeats the point of tiling the metadata alongside the geometry.
Can properties be per-vertex or per-texel instead of per-feature?
Yes. EXT_structural_metadata supports property attributes (per-vertex) and property textures (per-texel), which suit continuous quantities like temperature or material index. Per-feature tables are the right form for register attributes.
One further practice is worth adopting early, because retrofitting it is painful. Keep the schema in its own file, under version control, and generate both the tileset’s copy and any consumer’s decoding code from it. The schema is small, it changes rarely, and it is the only artifact in the whole chain that carries names rather than positions — so it is the natural place for the contract between whoever builds tiles and whoever reads them to live. A schema that exists only inside the tiles is a contract that can only be discovered by decoding an artifact.
Finally, decide deliberately what happens to a building with a missing attribute. required: true makes the validator reject the tile, which is right for an identifier and wrong for an optional survey field. For optional properties, the choice is between omitting the property from the table entirely and encoding a sentinel — and a sentinel is almost always the wrong answer, because every consumer then has to know which value means “absent” and one of them will not.
Related Guides
- Tileset Metadata and 3D Tiles Next — the wider 1.1 picture
- Implicit Tiling with Subtree Files — the other half of the change
- CityGML vs 3D Tiles for Municipal Twin Delivery — where these attributes come from
Back to Tileset Metadata and 3D Tiles Next.