Extracting IFC Properties into Tile Metadata
This page carries the properties of an IFC model into the metadata of the tiles built from it — choosing a small schema out of the hundreds of available property sets, normalising units and enumerations, assembling the property-table arrays that EXT_structural_metadata expects, and styling the resulting tileset by fire rating, storey or construction type.
Why you hit this
The geometry is the easy half of ingesting BIM. What makes a twin useful is that clicking a wall returns its fire rating, its storey and its asset identifier, and that a view can colour every element by construction type. All of that exists in the IFC as property sets — and an export that only triangulates geometry discards it, leaving a twin that looks like a building and answers nothing. The georeferencing half of the same ingestion is in BIM and IFC georeferencing for digital twins.
Prerequisites
- Python 3.10+ with
ifcopenshell>=0.8,numpy>=1.24. - An IFC4 model with populated property sets — check one element before planning a schema, because models vary enormously in what they carry.
- A tiler that accepts a metadata schema and per-feature values, or the ability to write glTF with
EXT_structural_metadatayourself.
Step-by-Step
1. Survey what the model actually carries
from collections import Counter
import ifcopenshell
import ifcopenshell.util.element
model = ifcopenshell.open("clinic_block_c.ifc")
elements = model.by_type("IfcBuildingElement")
print(f"{len(elements):,} building elements, schema {model.schema}")
pset_names, prop_names = Counter(), Counter()
for el in elements[:5000]: # a sample is enough to survey
for pset, props in ifcopenshell.util.element.get_psets(el).items():
pset_names[pset] += 1
for p in props:
if p != "id":
prop_names[f"{pset}.{p}"] += 1
for name, n in pset_names.most_common(8):
print(f"{n:>6} elements {name}")
print()
for name, n in prop_names.most_common(12):
print(f"{n:>6} {name}")
Surveying first prevents the two usual mistakes: designing a schema around properties the model does not populate, and exporting everything. A large IFC model carries tens of thousands of distinct property names across standard Pset_* sets, vendor-specific sets and project-specific ones, and almost all of them are irrelevant to a twin. The list of property names sorted by how many elements have them is the shortlist.
2. Choose a small schema and declare it
SCHEMA = {
"id": "clinic_bim_v1",
"name": "Clinic BIM element metadata",
"classes": {
"element": {
"name": "Building element",
"properties": {
"guid": {"componentType": "STRING", "required": True},
"ifc_type": {"componentType": "STRING", "required": True},
"storey": {"componentType": "STRING"},
"name": {"componentType": "STRING"},
"fire_rating": {"componentType": "STRING"},
"is_external": {"type": "BOOLEAN"},
"load_bearing": {"type": "BOOLEAN"},
"area_m2": {"componentType": "FLOAT32"},
"volume_m3": {"componentType": "FLOAT32"},
"thickness_m": {"componentType": "FLOAT32"},
},
}
},
}
Ten properties is a good target for a first pass: an identifier, a type, a location in the building, and the handful of values a user will filter or colour by. Everything else stays in the IFC, which remains the system of record — the twin’s metadata is a query surface, not a copy of the model. Keeping it small also matters for tile size, since property tables ship inside the tile content.
3. Extract and normalise the values
import ifcopenshell.util.unit
UNIT_SCALE = ifcopenshell.util.unit.calculate_unit_scale(model) # project length → metres
def storey_of(el):
for rel in getattr(el, "ContainedInStructure", ()) or ():
s = rel.RelatingStructure
if s.is_a("IfcBuildingStorey"):
return s.Name
return None
def prop(psets, *paths, default=None):
for path in paths:
pset, key = path.split(".")
val = (psets.get(pset) or {}).get(key)
if val is not None:
return val
return default
def as_bool(v):
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().upper() in ("TRUE", "T", "YES", "1")
return None
def extract(el):
psets = ifcopenshell.util.element.get_psets(el)
qto = {k: v for k, v in psets.items() if k.startswith("Qto_")}
area = prop(qto, *[f"{k}.NetSideArea" for k in qto], *[f"{k}.GrossSideArea" for k in qto])
volume = prop(qto, *[f"{k}.NetVolume" for k in qto], *[f"{k}.GrossVolume" for k in qto])
thickness = prop(psets, "Pset_WallCommon.Thickness", "Qto_WallBaseQuantities.Width")
return {
"guid": el.GlobalId,
"ifc_type": el.is_a(),
"storey": storey_of(el) or "",
"name": el.Name or "",
"fire_rating": str(prop(psets, "Pset_WallCommon.FireRating",
"Pset_SlabCommon.FireRating", default="") or ""),
"is_external": as_bool(prop(psets, "Pset_WallCommon.IsExternal",
"Pset_SlabCommon.IsExternal")),
"load_bearing": as_bool(prop(psets, "Pset_WallCommon.LoadBearing",
"Pset_SlabCommon.LoadBearing")),
"area_m2": float(area) * UNIT_SCALE ** 2 if area else None,
"volume_m3": float(volume) * UNIT_SCALE ** 3 if volume else None,
"thickness_m": float(thickness) * UNIT_SCALE if thickness else None,
}
rows = [extract(el) for el in elements]
print(f"{len(rows):,} rows; storeys {sorted({r['storey'] for r in rows})[:6]}")
Three normalisations happen there and all three matter. Lengths are multiplied by the project unit scale, and areas and volumes by its square and cube — a millimetre model reports areas in square millimetres, so a wall comes out as 12,400,000 until it is converted. Booleans in IFC property sets are frequently strings, because exporters write .T. or "TRUE". And quantity sets are named per element type (Qto_WallBaseQuantities, Qto_SlabBaseQuantities), so a lookup has to search across them rather than hard-coding one.
4. Assemble the property-table arrays
EXT_structural_metadata stores each property as a binary array parallel to the feature IDs. Numeric properties are a plain array; strings need an offsets array because they vary in length.
import numpy as np
def build_property_table(rows, schema_class):
n = len(rows)
table = {"class": "element", "count": n, "properties": {}}
buffers = {}
for name, spec in schema_class["properties"].items():
values = [r.get(name) for r in rows]
ctype = spec.get("componentType") or spec.get("type")
if ctype == "STRING":
encoded = [(v or "").encode("utf-8") for v in values]
offsets = np.zeros(n + 1, dtype=np.uint32)
offsets[1:] = np.cumsum([len(b) for b in encoded])
buffers[f"{name}_values"] = b"".join(encoded)
buffers[f"{name}_offsets"] = offsets.tobytes()
table["properties"][name] = {"values": f"{name}_values",
"stringOffsets": f"{name}_offsets",
"stringOffsetType": "UINT32"}
elif ctype == "BOOLEAN":
bits = np.zeros((n + 7) // 8, dtype=np.uint8)
for i, v in enumerate(values):
if v:
bits[i // 8] |= 1 << (i % 8)
buffers[f"{name}_values"] = bits.tobytes()
table["properties"][name] = {"values": f"{name}_values"}
else:
arr = np.array([np.nan if v is None else v for v in values], dtype=np.float32)
buffers[f"{name}_values"] = arr.tobytes()
table["properties"][name] = {"values": f"{name}_values"}
return table, buffers
table, buffers = build_property_table(rows, SCHEMA["classes"]["element"])
print(f"{table['count']:,} features; buffers: "
f"{sum(len(b) for b in buffers.values()) / 1e6:.2f} MB across {len(buffers)} arrays")
Two details of the encoding cause most of the trouble. String offsets are cumulative byte positions with n + 1 entries, so the last entry is the total length — an off-by-one here shifts every string by one feature, which produces metadata that looks populated and is wrong. Booleans are bit-packed, one bit per feature, least significant bit first within each byte.
5. Style the tileset by the metadata
const clinic = await Cesium.Cesium3DTileset.fromUrl("/tiles/clinic/tileset.json");
viewer.scene.primitives.add(clinic);
clinic.style = new Cesium.Cesium3DTileStyle({
color: {
conditions: [
["${fire_rating} === 'EI90'", "color('#b0413e')"],
["${fire_rating} === 'EI60'", "color('#c46a3d')"],
["${load_bearing} === true", "color('#1f6b8a')"],
["${is_external} === true", "color('#4f7a4d')"],
["true", "color('#e6e0d4')"],
],
},
show: "${storey} !== 'LevelB1'",
});
const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction((m) => {
const f = viewer.scene.pick(m.position);
if (f instanceof Cesium.Cesium3DTileFeature) {
console.log(Object.fromEntries(f.getPropertyIds().map((k) => [k, f.getProperty(k)])));
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
The styling expressions are the return on the whole exercise: colouring by fire rating, hiding a basement level, or filtering to load-bearing elements happens in the client with no rebuild. getPropertyIds on a picked feature is also the quickest way to confirm the metadata arrived — if it returns an empty list, the property table did not make it into the tile.
Expected Output & Verification
41,882 building elements, schema IFC4
38104 elements Pset_WallCommon
22841 elements Qto_WallBaseQuantities
18402 elements Pset_SlabCommon
9120 elements Pset_DoorCommon
41,882 rows; storeys ['LevelB1', 'Level00', 'Level01', 'Level01Tech', 'Level02']
41,882 features; buffers: 3.84 MB across 16 arrays
Verify the table round-trips before it goes into tiles, by decoding it exactly as a client will:
def decode_string_property(buffers, table, name, index):
offs = np.frombuffer(buffers[table["properties"][name]["stringOffsets"]], dtype=np.uint32)
data = buffers[table["properties"][name]["values"]]
return data[offs[index]:offs[index + 1]].decode("utf-8")
def decode_bool_property(buffers, table, name, index):
bits = np.frombuffer(buffers[table["properties"][name]["values"]], dtype=np.uint8)
return bool(bits[index // 8] >> (index % 8) & 1)
for i in (0, 1, len(rows) // 2, len(rows) - 1):
assert decode_string_property(buffers, table, "guid", i) == rows[i]["guid"]
assert decode_string_property(buffers, table, "storey", i) == rows[i]["storey"]
assert decode_bool_property(buffers, table, "is_external", i) == bool(rows[i]["is_external"])
print("property table decodes back to the source rows")
areas = np.frombuffer(buffers["area_m2_values"], dtype=np.float32)
valid = np.isfinite(areas)
print(f"area_m2: {valid.sum():,} populated, median {np.median(areas[valid]):.1f} m², "
f"max {areas[valid].max():.1f} m²")
assert np.nanmax(areas) < 10_000, "implausible area: unit scaling missed"
Decoding the first, second, middle and last feature catches the off-by-one in the offsets array, which is invisible if only the first feature is checked. The area sanity bound catches the unit-scale error: a wall with an area of 12 million is a millimetre model that was not converted.
Common Errors
Every string property is shifted by one feature. The offsets array has n entries instead of n + 1, or starts at the first string’s length rather than at zero. The multi-index assertion above is the guard.
Areas and volumes are absurdly large or small. The unit scale was applied linearly to an area, or not at all. Areas need the square of the scale and volumes the cube.
Booleans are all true. The IFC values were strings such as "F" or .F., and a truthiness test on a non-empty string returns true. Parse them explicitly, as as_bool does.
getPropertyIds() returns nothing in the viewer. The property table was written but not referenced from the mesh’s feature IDs, or the tiler dropped the extension. Check that the glTF declares EXT_structural_metadata and that the primitive has a feature-ID set.
Storey is empty for most elements. Containment is expressed through IfcRelContainedInSpatialStructure on the element, but some elements are nested in an aggregate — a curtain wall’s panels, for instance — and inherit their parent’s storey. Walk up through Decomposes when the direct lookup fails.
Frequently Asked Questions
How many properties is too many?
Tile size is the limit: property tables ship with the geometry, so a hundred properties on a hundred thousand features is tens of megabytes spread across the tiles. Ten to twenty properties covers filtering and colouring; anything else belongs behind an API keyed by the GUID.
Should the property values be strings or enumerations?
Declared enumerations are better where the value set is known and small — fire ratings, element types — because they encode as integers and the schema documents the allowed values. Strings are the pragmatic choice when the model’s values are inconsistent, which they often are.
Can properties be updated without re-tiling?
Not if they are in the tiles. That is the argument for keeping only stable properties in the metadata and serving volatile ones from an API, as described in streaming live sensor updates onto tilesets.
Related Guides
- Attaching EXT_structural_metadata to Building Tiles — the extension in depth
- Simplifying IFC Geometry for Web Tiles — the geometry half of the same export
- Styling Tiles by Metadata with Cesium3DTileStyle — expressions over these properties