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_metadata yourself.

Step-by-Step

1. Survey what the model actually carries

python
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

python
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

python
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.

From property sets to a tile metadata schema An IFC element carries several property sets: a common set with fire rating and external flags, a quantity set with areas and volumes in project units, and vendor-specific sets. A selection of ten properties is normalised into metres and booleans and mapped into one metadata class with declared component types, which is what ships inside the tile. Pset_WallCommonFireRating, IsExternal Qto_WallBaseQuantitiesNetSideArea (mm²) storey containmentIfcBuildingStorey.Name vendor psetsnot carried normalise:units, booleans,missing values metadata class guid, ifc_type: STRING is_external: BOOLEAN area_m2: FLOAT32 ten properties, declared
The schema is a deliberate selection, not an export: ten declared properties per element, normalised, with the IFC remaining the system of record.

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.

python
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.

String property encoding with offsets Three storey names are concatenated into one byte buffer without separators. A parallel offsets array of four unsigned integers gives the start of each string and the total length, so the reader slices the buffer. If the offsets array has only three entries, the last string has no end and the reader either truncates or overruns. LevelB1 Level00 Level01Tech 071425 offsets: [0, 7, 14, 25] — four entries for three strings three entries would leave the last string unbounded Byte offsets, not character offsets: a non-ASCII name breaks a character-based implementation.
The offsets array is the whole mechanism for variable-length properties, and its length is one more than the feature count.
One tileset, three views from the same metadata The same building tileset rendered three ways from the metadata that shipped with it: coloured by fire rating, filtered to load-bearing elements only, and with one storey hidden. None of the three requires a rebuild, because the values are already in the tiles and the styling happens in the client. colour by fire_rating show load_bearing only hide storey LevelB1 EI90 · EI60 · none others hidden two storeys remain All three are client-side style expressions over the property table.
The payoff for shipping ten properties is that every one of these views is a style change rather than a tiling run.

5. Style the tileset by the metadata

javascript
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

text
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:

python
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.

Back to BIM and IFC Georeferencing for Digital Twins.