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.
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
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
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.
Related
- IFC & BIM Model Ingestion — the pipeline this stage sits in and what it hands downstream.
- Extracting IfcSpace Geometry with IfcOpenShell — the consumer of the storey elevation this stage resolves.
- Converting CAD Elevations to Indoor Z-Levels — the same problem when the source has no storey entities at all.
This page is a companion to IFC & BIM Model Ingestion, part of the Automated Floor Plan Parsing & Vectorization section.