IFC & BIM Model Ingestion for Indoor Maps
An IFC model is the best indoor map source that exists, and ingesting one is a different job from parsing a drawing rather than an easier version of the same job. The semantics that dominate a DWG pipeline — which storey is this, is this space a room, what is it called — arrive already answered. What replaces them is geometry: IFC describes rooms as 3D solids in a placement hierarchy, and a floor plan is a horizontal section through them. This topic sits inside Automated Floor Plan Parsing & Vectorization and covers reading the model, sectioning it, and the validation a BIM source still needs despite everything it gets right.
The Problem: A Model That Is Right About Everything Except the Plan
The instinct on first opening an IFC file is that the work is done. IfcBuildingStorey gives you
levels with names and elevations. IfcSpace gives you rooms with LongName values that match the
signage. IfcDoor gives you openings with host walls. Compared with recovering all of that from
layer names and text anchors, it feels like the pipeline collapses to a serialisation exercise.
It does not, for one structural reason: IFC has no concept of a floor plan. A plan is a projection convention — a horizontal section at roughly 1.2 m above finished floor level, looking down — and IFC stores the building, not the drawing of it. Every room is a solid; every wall is a solid; the outline you need is the intersection of those solids with a plane you have to choose.
That single difference produces the whole IFC-specific failure set. Cut too low and the footprint picks up skirting and floor-finish geometry. Cut too high and it picks up bulkheads, ducts and the underside of stairs. Cut a space with a sloped soffit — under a stair, in a plant room — and the footprint changes with the cut height in ways that matter. And because the sectioning happens in your code rather than in the model, none of it is validated by the BIM authoring tool.
The second structural difference is placement. Coordinates in IFC are relative: a space’s geometry is expressed in its own object placement, which is relative to the storey’s, which is relative to the building’s, which is relative to the site’s. Reading a space’s coordinates without composing that chain gives you numbers that look plausible and are wrong by however far the storey is from the project origin — which is exactly the failure the campus coordinate reference system work exists to prevent.
Prerequisites & Dependencies
| Dependency | Version | Used for |
|---|---|---|
ifcopenshell |
≥ 0.7 | parsing, geometry iteration, placement composition |
ifcopenshell.geom |
bundled | the OpenCASCADE-backed tessellation and sectioning |
shapely |
≥ 2.0 | 2D footprint assembly and validity |
numpy |
≥ 1.24 | transform composition |
Three assumptions about the model itself matter more than the library versions:
- The model must be a coordination model, not a discipline model. An architectural model alone
usually has the spaces; a structural or MEP model alone does not. If the ingest is pointed at
whichever IFC happened to be delivered, roughly half of them will contain no
IfcSpaceentities at all, and the correct response is to fail loudly rather than to produce an empty building. - Spaces must be modelled.
IfcSpaceis optional in IFC and plenty of models omit it, carrying rooms only as the void between walls. Those models need the wall-based pipeline, not this one. - The schema version must be read, not assumed. IFC2X3 and IFC4 differ in ways that reach this
code —
IfcSpace.PredefinedTypeis IFC4 only, and relationships are traversed differently. Readmodel.schemaand branch explicitly.
Architecture: Hierarchy, Placement, Section
Ingestion is three passes over the model, and they map onto the hierarchy above.
Pass 1 — context. Read IfcProject for the length unit (never assume metres; plenty of models
are in millimetres) and the geometric context, and read true north from the context’s
TrueNorth direction if the map will later be reprojected. This pass produces a scale factor and
a rotation, both of which apply to everything else.
Pass 2 — spatial structure. Walk IfcBuildingStorey entities, ordering them by Elevation
rather than by name — storey names are free text and sort alphabetically into nonsense. Each storey
becomes a level index. This is where the whole
level mapping
problem is solved for free, with one caveat covered below: mezzanines are sometimes modelled as
storeys and sometimes as spaces within a storey, and the two need different handling.
Pass 3 — geometry. For each IfcSpace contained in a storey, compose the placement chain,
tessellate the solid, and section it at the storey elevation plus the cut height. The result is one
or more 2D rings; the largest becomes the room footprint and any others are reported (a space that
sections into two rings is usually an L-shaped room modelled as two volumes, or a modelling error).
The output is the same GeoJSON envelope every other source produces, which is the point: downstream stages cannot tell an IFC-sourced building from a DWG-sourced one, and nothing in routing-graph construction or deployment needs to know.
Step-by-Step Implementation
Step 1 — open the model and establish the unit scale.
import logging
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.unit
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def open_model(path: str) -> tuple[ifcopenshell.file, float]:
"""Open an IFC file and return it with its length-unit scale factor to metres."""
try:
model = ifcopenshell.open(path)
except Exception as exc: # ifcopenshell raises bare exceptions
logger.error("cannot open %s: %s", path, exc)
raise
scale = ifcopenshell.util.unit.calculate_unit_scale(model)
if not model.by_type("IfcSpace"):
raise ValueError(f"{path} contains no IfcSpace entities — not a coordination model")
logger.info("opened %s (%s), unit scale %.4f m", path, model.schema, scale)
return model, scale
Step 2 — order storeys into level indices.
def level_index(model: ifcopenshell.file, scale: float) -> dict[int, int]:
"""Map each IfcBuildingStorey id to an ordinal level index, ground floor = 0."""
storeys = model.by_type("IfcBuildingStorey")
if not storeys:
raise ValueError("model has no IfcBuildingStorey — cannot assign levels")
# Order by elevation, never by name: "Level 10" sorts before "Level 2".
ordered = sorted(storeys, key=lambda s: (s.Elevation or 0.0) * scale)
ground = min(ordered, key=lambda s: abs((s.Elevation or 0.0) * scale))
base = ordered.index(ground)
mapping = {s.id(): i - base for i, s in enumerate(ordered)}
for s in ordered:
logger.info("storey %-14s elev %+7.2f m -> level %+d",
(s.Name or "?")[:14], (s.Elevation or 0.0) * scale, mapping[s.id()])
return mapping
Ground floor is chosen as the storey nearest zero elevation rather than the first in the list, because basements are common and a model whose lowest storey is a car park should not make that level 0.
Step 3 — section each space into a footprint.
from shapely.geometry import Polygon
from shapely.ops import unary_union
def space_footprint(space, settings, cut_height: float = 1.2) -> Polygon | None:
"""Section one IfcSpace solid at cut_height above its storey and return the footprint."""
try:
shape = ifcopenshell.geom.create_shape(settings, space)
except RuntimeError as exc: # unrepresentable or empty geometry
logger.warning("no geometry for space %s: %s", space.GlobalId, exc)
return None
verts = shape.geometry.verts
faces = shape.geometry.faces
rings = section_triangles(verts, faces, z=cut_height) # see the guide linked below
if not rings:
logger.warning("space %s does not intersect the cut plane at %.2f m",
space.GlobalId, cut_height)
return None
merged = unary_union([Polygon(r) for r in rings if len(r) >= 3])
if merged.geom_type == "MultiPolygon":
parts = sorted(merged.geoms, key=lambda p: p.area, reverse=True)
logger.info("space %s sectioned into %d rings; keeping the largest",
space.GlobalId, len(parts))
merged = parts[0]
return merged if merged.area > 0.5 else None
Step 4 — emit the envelope. Each surviving footprint becomes a Feature carrying the
feature_id derived from the space’s GlobalId, the level from the storey mapping, the
space_class mapped from PredefinedType, and the LongName as the room name — the same
properties every other source produces.
Edge Cases & Gotchas
| Pattern | Symptom | Handling |
|---|---|---|
| Model in millimetres | Building is 40,000 m across | Always apply calculate_unit_scale; assert the resulting span is plausible |
| Mezzanine as a storey | An extra integer level | Detect half-spacing and re-index as a fractional level |
| Space with a sloped soffit | Footprint changes with cut height | Section at two heights and report if the areas differ by > 5% |
IfcSpace covering a whole floor |
One 2,000 m² room | Filter on PredefinedType; zone-level spaces are not rooms |
Missing LongName |
Unnamed rooms | Fall back to Name, then to GlobalId; never invent |
| Federated model, duplicate spaces | Rooms appear twice | De-duplicate on GlobalId before sectioning |
| IFC2X3 model | PredefinedType missing |
Branch on model.schema; map from ObjectType instead |
The zone-space case is worth expanding because it is common and quiet. Many models carry
IfcSpace entities at more than one granularity: individual rooms, and larger zones that contain
them (a department, a fire compartment, a lettable area). Both are valid, both section into
plausible polygons, and ingesting both gives you a floor where every room is also inside a
2,000 m² “room”. The discriminator in IFC4 is PredefinedType — SPACE for rooms, ZONE or
GFA for the aggregates — and in IFC2X3 it is usually visible in ObjectType. Where neither is
set, an area filter combined with a containment test (a space that fully contains other spaces is
a zone) resolves it.
Validation Output
The ingest report for an IFC source has a different shape from a CAD one, because different things can go wrong:
{
"source": "HQ-ARCH-COORD-r14.ifc",
"schema": "IFC4",
"trace_id": "9a3f0c71",
"unit_scale_to_m": 0.001,
"storeys": 8,
"levels": [-1, 0, 1, 2, 3, 4, 5, 6],
"spaces_total": 512,
"spaces_sectioned": 486,
"no_geometry": 4,
"missed_cut_plane": 2,
"zone_spaces_filtered": 18,
"multi_ring_spaces": 6,
"unnamed": 0
}
The two numbers to watch are missed_cut_plane and multi_ring_spaces. A space that does not
intersect the cut plane is either a void modelled with zero height or a mezzanine space whose
storey elevation is wrong; either way it is a real modelling issue worth reporting back to the BIM
author. Multi-ring spaces are usually L-shaped rooms modelled as two volumes, which is fine, but a
sharp rise in the count means the cut height is catching something structural.
The assertion worth automating is a total-area cross-check against the model’s own quantities:
def test_footprint_area_matches_model(space, footprint):
qto = get_quantity(space, "Qto_SpaceBaseQuantities", "NetFloorArea")
if qto is None:
return # not every model carries quantities
drift = abs(footprint.area - qto) / max(qto, 1e-9)
assert drift < 0.05, f"{space.GlobalId}: sectioned area is {drift:.0%} off the model's own"
That check catches wrong cut heights, wrong unit scales and bad placement composition in one assertion, because all three change the area and the model’s own quantity does not.
Performance & Scale Notes
IFC ingestion is dominated by tessellation, which is the OpenCASCADE work ifcopenshell.geom does
to turn B-rep solids into triangle meshes. It is expensive and it parallelises well.
| Model | Spaces | Parse | Tessellate + section | Total |
|---|---|---|---|---|
| Small office, IFC4 | 96 | 1.4 s | 11 s | 12.4 s |
| 8-storey HQ, IFC4 | 512 | 6.1 s | 78 s | 84 s |
| Hospital, federated | 2,940 | 31 s | 611 s | 642 s |
Three things bring that down substantially. First, ifcopenshell.geom.iterator with a worker
count runs tessellation across processes and gives close to linear speedup — the hospital above
drops to about 70 seconds on twelve cores. Second, restricting the geometry settings to the entity
types you need (IfcSpace, and IfcDoor if you are extracting openings) avoids tessellating
every pipe and luminaire in a coordination model, which is frequently 90% of the work. Third,
sectioning only needs triangles that straddle the cut plane, so a bounding-box pre-filter per
space discards most of the mesh before any intersection maths runs.
Memory is the other constraint: a federated hospital model held open with full geometry can exceed 8 GB. Processing storey by storey, opening the model once and iterating spaces per storey, keeps the working set bounded — and fits the per-level parallelism the async batch pipeline already provides.
Frequently Asked Questions
Do I still need geometry cleanup if the source is IFC?
The snapping and noding passes, usually not; the classification and gating passes, always. IFC solids are topologically sound, so the endpoint gaps and unnoded crossings that dominate CAD cleanup simply do not occur — you can set the snapping tolerance to zero. What remains is the plausibility work: filtering zone-level spaces that are not rooms, catching footprints whose area disagrees with the model’s own quantities, and gating the level’s face count against the previous publish. Those are exactly the checks in geometry cleanup and topology repair, and they earn their place on any source.
What cut height should I use?
Between 1.0 and 1.4 metres above the storey’s finished floor level, with 1.2 m as the default, because that is the convention architectural plans are drawn to and therefore the height at which room outlines look the way people expect. Below about 0.9 m the section starts catching skirtings, floor finishes and furniture bases; above about 1.6 m it starts catching bulkheads, ducts and the undersides of stairs. Where a portfolio has many sloped-soffit spaces — plant rooms, spaces under stairs — sectioning at two heights and comparing the areas is worth the extra tessellation, because it turns a silent ambiguity into a reported one.
Can I get doors and walls from IFC as well as rooms?
Yes, and doors are worth taking. IfcDoor entities carry their host wall through an IfcRelFillsElement relationship and an opening through IfcRelVoidsElement, which gives you door positions and widths directly rather than by inferring them from gaps in wall runs — removing the single largest source of error in the CAD pipeline. Walls are less clearly worth it: you need them for rendering but not for routing, since the rooms and doors already define the topology, and IfcWall geometry is expensive to tessellate. A common arrangement is to take spaces and doors from IFC and derive wall lines from the space boundaries where a rendered wall is needed.
What if the model has no IfcSpace entities?
Fail the ingest and route the building to the drawing-based pipeline. A model without spaces is usually a discipline model — structural or MEP — that was delivered instead of the architectural coordination model, and there is no reliable way to recover rooms from it: the void between walls can be polygonised, but at that point you are running the CAD pipeline against geometry that is more expensive to read and no more informative. Detecting this at the front of the ingest and saying so is far better than producing a building with eight storeys and no rooms, which validates, publishes and cannot be routed.
Related
- Extracting IfcSpace Geometry with IfcOpenShell — the placement composition and triangle sectioning this topic summarises.
- Mapping IFC Storeys to Indoor Levels — storey ordering, ground-floor selection and the mezzanine cases.
- IFC vs. DWG as an Indoor Map Source — which pipeline to point a given building at, and why.
- Geometry Cleanup & Topology Repair — the classification and gating passes an IFC source still needs.
- Level Mapping & Z-Axis Logic — the problem IFC solves for you, and the conventions its output must still meet.
This page is part of the Automated Floor Plan Parsing & Vectorization section.