Mapping IFC Storeys to Indoor Levels

The part of IFC & BIM model ingestion that decides what floor everything is on. IFC hands you storeys with names and elevations, which is most of the answer — and the remaining part, choosing which storey is level zero, is the one that puts a map out of step with the lift buttons if it goes wrong.

Why This Is Not Just Reading an Attribute

IfcBuildingStorey carries a Name and an Elevation, and an indoor map needs a signed integer level index where 0 is the ground floor. Getting from one to the other looks like a cast and is three decisions.

Why storeys must be ordered by elevation rather than by name A table of six storeys with their elevations and the position each takes under two sort orders. Sorted alphabetically by name, Basement comes first, Level 1 second, Level 10 third — between Level 1 and Level 2 — Level 2 fourth, Ground fourth and Roof Plant fifth, which is nonsense. Sorted by elevation the order is Basement, Ground, Level 1, Level 2, Level 10, Roof Plant, which yields the correct level indices of minus one, zero, one, two, ten and eleven. Elevation orders storeys; names order them wrongly Storey name Elevation (m) Sorted by name Sorted by elevation Level Basement -3.60 △ 1st 1st -1 Ground 0.00 △ 4th 2nd ● 0 Level 1 4.20 △ 2nd 3rd ● 1 Level 2 8.10 △ 3rd 4th ● 2 Level 10 39.60 △ 3rd 5th ● 10 Roof Plant 43.20 △ 5th 6th 11 Alphabetical sorting puts Level 10 between Level 1 and Level 2. It always has.

Storey names are free text. They are written for humans, they vary between buildings in the same portfolio, and they sort into nonsense — elevation is the only attribute with a defined order.

Ordering. Storey names sort alphabetically into nonsense — “Level 10” lands between “Level 1” and “Level 2” in every language and every collation. Elevation is a float with a defined order and is the only attribute that can be sorted safely.

Origin. Sorting gives an ordering, not indices. Which storey is 0 is a separate decision, and getting it wrong shifts every other level by one, so the map says “level 3” where the lift says “level 2”. No geometric check catches this, because the geometry is perfectly correct — it is labelled wrongly.

Granularity. Not everything modelled as a storey is a storey. Mezzanines, plant decks and intermediate landings are all sometimes IfcBuildingStorey entities, and treating them as full levels renumbers the building above them.

Choosing the Ground Storey

Choosing which IFC storey becomes level zero One decision with four outcomes. The storey nearest zero elevation is normally level zero because it matches the site datum. A storey named Ground should agree with that choice; if it does not, the discrepancy is worth investigating rather than overriding. On a sloping site where two storeys have entrances, the level people arrive on is chosen. If no storey sits near zero elevation, the site datum in the model is wrong and the model should be corrected before ingest. One choice, and every other level index follows from it Which storey is level 0? the choice shifts every other index nearest 0.00 m Use it matches the site datum the usual answer named 'Ground' Cross-check agree with elevation or investigate sloping site Entrance storey the level people arrive on no storey near 0 Reject site datum is wrong fix before ingest

Getting this wrong shifts every level in the building by one. The map then disagrees with the lift buttons, which is the single most reported indoor-map defect and is invisible in any geometric check.

The default rule — the storey whose elevation is nearest zero — works because IFC’s site datum is conventionally set at ground level, and it is right in the large majority of models. The value of stating it as a rule is that it is checkable: the storey chosen by elevation and the storey named “Ground”, “GF”, “00” or “Erdgeschoss” should be the same storey, and when they disagree that is a signal rather than a tie to be broken silently.

Two cases genuinely need a different answer. A sloping site may have two storeys with external entrances, and the one to call level 0 is the one the main entrance is on, because that is what signage and lift buttons will agree with. A podium building may set its site datum at the top of a plinth rather than at street level. Both are configuration, not inference: they belong in the building’s ingest record where a human has recorded the decision, not in a heuristic.

The case that should stop the ingest is no storey within a couple of metres of zero at all. That means the model’s site placement was never set — a common state for a model exported before site coordination — and every elevation in it is relative to something unknown. Levels derived from it will be internally consistent and wrong.

Minimal Working Example

import logging
from dataclasses import dataclass

import ifcopenshell
import ifcopenshell.util.unit

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

GROUND_NAMES = {"ground", "gf", "g", "00", "0", "level 0", "erdgeschoss", "rez-de-chaussee"}


@dataclass(frozen=True)
class Level:
    storey_id: int
    name: str
    elevation_m: float
    index: float           # float, so a mezzanine can be 0.5


def map_storeys(model: ifcopenshell.file, *, near_zero: float = 2.0) -> list[Level]:
    """Order storeys by elevation and assign signed level indices, ground floor = 0."""
    scale = ifcopenshell.util.unit.calculate_unit_scale(model)
    storeys = model.by_type("IfcBuildingStorey")
    if not storeys:
        raise ValueError("model contains no IfcBuildingStorey")

    ordered = sorted(storeys, key=lambda s: (s.Elevation or 0.0) * scale)
    elevations = [(s.Elevation or 0.0) * scale for s in ordered]

    ground_pos = min(range(len(ordered)), key=lambda i: abs(elevations[i]))
    if abs(elevations[ground_pos]) > near_zero:
        raise ValueError(
            f"no storey within {near_zero} m of the site datum "
            f"(nearest is {elevations[ground_pos]:+.2f} m) — model placement is unset")

    named = {i for i, s in enumerate(ordered) if (s.Name or "").strip().lower() in GROUND_NAMES}
    if named and ground_pos not in named:
        logger.warning("storey named as ground (%s) is not the storey nearest the datum (%s)",
                       ordered[min(named)].Name, ordered[ground_pos].Name)

    levels: list[Level] = []
    for i, s in enumerate(ordered):
        levels.append(Level(s.id(), s.Name or f"storey-{s.id()}", elevations[i], i - ground_pos))
    return _demote_mezzanines(levels)

Detecting mezzanines. A storey is a mezzanine candidate when its elevation gap to the storey below is markedly smaller than the building’s typical storey pitch:

import statistics


def _demote_mezzanines(levels: list[Level], ratio: float = 0.65) -> list[Level]:
    """Re-index storeys spaced well under the modal pitch as fractional levels."""
    if len(levels) < 3:
        return levels
    gaps = [b.elevation_m - a.elevation_m for a, b in zip(levels, levels[1:])]
    pitch = statistics.median(gaps)
    out, shift = [], 0.0
    for i, lv in enumerate(levels):
        if 0 < i < len(levels) - 1 and gaps[i - 1] < ratio * pitch and gaps[i] < ratio * pitch:
            out.append(Level(lv.storey_id, lv.name, lv.elevation_m, out[-1].index + 0.5))
            shift -= 1.0                       # everything above keeps its integer index
            logger.info("%s at %+.2f m re-indexed as a mezzanine (%.1f)",
                        lv.name, lv.elevation_m, out[-1].index)
        else:
            out.append(Level(lv.storey_id, lv.name, lv.elevation_m, lv.index + shift))
    return out

The shift is what keeps the storeys above a mezzanine on their original integers, which is the whole point: inserting a mezzanine must not renumber the building.

Mezzanines and Partial Storeys

A mezzanine modelled as a storey, and the index it should receive A section through four storey levels drawn as horizontal lines with their elevations labelled on the left and level indices on the right. Ground sits at 0.00 metres and is level 0. A shorter line at 2.10 metres spans only part of the width and is the mezzanine, which receives level 0.5 because its spacing is half a storey and its depth is partial. Level 1 sits at 4.20 metres and level 2 at 8.40 metres, both full width. Half the spacing, part of the plate, half an index 0.00 level 0 2.10 level 0.5 (half spacing, part depth) 4.20 level 1 8.40 level 2

Two signals agree here: spacing and extent. The mezzanine sits at half the storey pitch and covers part of the floor plate — either alone is suggestive, and together they are conclusive.

Two signals identify a mezzanine, and using both is more robust than either alone.

Spacing. The gap between a mezzanine and the storey below it is roughly half the building’s storey pitch. Comparing against the median gap rather than a fixed floor-to-floor constant makes this work across buildings with different pitches, and the median is robust to the one or two unusual gaps (a double-height atrium, a tall plant level) that a mean would be dragged by.

Extent. A mezzanine covers part of the floor plate, not all of it. Once spaces have been sectioned, comparing the total footprint area of a candidate storey against its neighbours is a strong second signal: a mezzanine is typically 15-40% of the plate below it, while a genuine storey is 85-105%.

Where the two signals disagree, report rather than decide. A storey at half spacing covering the full plate is unusual and worth a human look — it is sometimes a split-level building where the right model is two separate levels, and sometimes a modelling artefact where a suspended-ceiling zone was given its own storey.

Common Errors & Fixes

Every level in the building is off by one. The ground storey was chosen wrongly, usually because the model has a basement at 0.00 and the true ground floor at +3.60 — which happens when the site datum was set to the lowest slab rather than to ground level. The tell is that the storey named “Ground” and the storey nearest zero are different, which is exactly what the warning in the example above surfaces. The fix is a per-building override recorded in the ingest configuration, not a change to the rule.

A mezzanine renumbers the floors above it. The mezzanine was assigned an integer index. Every storey above it then shifted, so the map’s “level 4” is the building’s “level 3” from that point up. The fractional-index approach avoids this by construction, and it is worth asserting:

def test_mezzanine_does_not_shift_upper_levels(levels):
    integers = [lv.index for lv in levels if float(lv.index).is_integer()]
    assert integers == sorted(set(integers)), "an inserted level renumbered the building"
    assert integers == list(range(int(min(integers)), int(max(integers)) + 1))

Two storeys share an elevation. Federated models sometimes contain the same storey from two disciplines. De-duplicate on elevation within a tolerance before indexing, keeping the one that contains spaces — the architectural storey — and log the discard.

Level indices disagree with the lift buttons. The most user-visible failure, and it is verifiable: the storey names in the model usually match the signage, so a cross-check between the derived index and the numeric part of the storey name catches the mismatch before publication. It belongs in the CI gate, because it is the kind of defect no geometric test can see.

Integration Point

Level assignment happens after the model is opened and before any space geometry is read, because the storey elevation is what sets the cut plane for sectioning. Its output — the level property on every feature — is consumed by everything downstream: the level filter in the vector tiles, the vertical edges in the routing graph, and the floor picker in every client SDK.

For a portfolio that mixes sources, the important property is that IFC-derived and CAD-derived levels use the same convention, which is the one level mapping and Z-axis logic defines: signed integers with 0 at the ground floor, fractional values for mezzanines, and no implicit relationship between index and height.

Frequently Asked Questions

Should I trust IfcBuildingStorey.Elevation or the geometry?

Trust the attribute for ordering and the geometry for validation. Elevation is what the authoring tool recorded and is almost always correct relative to the model’s own datum, which is all ordering needs. But it can disagree with where the geometry actually sits — usually because a storey was moved without its elevation being updated, or because the placement chain adds an offset the attribute does not know about. Comparing the attribute against the median z of the spaces contained in that storey catches the disagreement in one line, and a mismatch of more than a few centimetres is worth failing on, because it means one of the two is lying and you cannot tell which.

How do I handle a building with two ground floors?

Pick the entrance people are directed to and record the decision. Split-level and sloping-site buildings genuinely have two storeys at grade, and there is no attribute in the model that resolves it because it is a wayfinding question rather than a geometric one: the answer is whichever storey the building’s own signage calls ground. Put it in the building’s ingest record as an explicit ground_storey_id, so the choice is visible, reviewable and stable across model revisions. Inferring it fresh on every ingest risks the answer changing when the model does.

What about storeys that exist in the model but not in reality?

They are common and must be filtered rather than indexed. Authoring tools accumulate working storeys — a datum for setting out, a ‘Level 00 Structural’ twin of the architectural ground floor, a roof plane with no accessible area. The reliable discriminator is containment: a storey that contains no IfcSpace entities is not a floor anyone walks on, whatever it is called. Filter on that before indexing, and log what was dropped, because a storey that unexpectedly contains no spaces sometimes means the spaces are there but related through a relationship your traversal missed.

This page is a companion to IFC & BIM Model Ingestion, part of the Automated Floor Plan Parsing & Vectorization section.