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.

What an IFC source gives you that a CAD drawing does not, and what it costs A two-column comparison. From DWG or DXF, levels are inferred from layer names, units come from an INSUNITS code that is often unset, rooms must be polygonised from wall runs, room names resolved from text anchors by proximity, and doors inferred from gaps in walls; the geometry is 2D and cheap. From IFC, levels are explicit IfcBuildingStorey entities, units are always declared, rooms are IfcSpace entities that are already bounded, names are a LongName attribute, and doors are IfcDoor entities with a host wall; but the geometry is a 3D boundary representation that must be sectioned, and models only exist for new-build and refurbishment projects. Better semantics, harder geometry, far less coverage What you get DWG / DXF source IFC source Levels △ inferred from layer names ● IfcBuildingStorey, explicit Units △ $INSUNITS code, often 0 ● IfcUnitAssignment, always present Rooms △ polygonised from wall runs ● IfcSpace, already bounded Room names △ text anchors, resolved by proximity ● LongName on the space Doors △ inferred from wall gaps ● IfcDoor with a host wall Geometry cost ● 2D, cheap △ 3D B-rep, must be sectioned Model availability ● near universal △ new-build and refurb only The last two rows are why DWG pipelines still exist: IFC is better and less available.

Five rows favour IFC and two favour CAD — and the two decide most portfolios. An estate built before BIM mandates has no models to ingest, so the IFC path serves the newest buildings and the DWG path serves the rest.

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 IfcSpace entities at all, and the correct response is to fail loudly rather than to produce an empty building.
  • Spaces must be modelled. IfcSpace is 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.PredefinedType is IFC4 only, and relationships are traversed differently. Read model.schema and branch explicitly.

Architecture: Hierarchy, Placement, Section

The IFC spatial hierarchy an indoor map is extracted from Four stacked bands of the IFC spatial containment hierarchy. IfcProject carries the length unit, true north and the geometric context. IfcSite and IfcBuilding carry object placements and the site elevation. IfcBuildingStorey is the level, already named and carrying an elevation. IfcSpace is the room, already typed with a LongName, a PredefinedType and explicit space boundaries. Each level of the hierarchy inherits its parent's placement, so coordinates read from a space alone are meaningless. The hierarchy is the level index and the taxonomy, already built IfcProject units + context length unit true north geometric context IfcSite / IfcBuilding placement ObjectPlacement site elevation IfcBuildingStorey the level, already named Elevation Name storey ordering IfcSpace the room, already typed LongName PredefinedType boundaries each level inherits its parent's placement — never read coordinates in isolation

IFC hands you three stages for free. Level assignment, room typing and unit declaration are all explicit in the model — the work that dominates a DWG pipeline is already done, which changes what ingestion is for.

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

Sectioning an IfcSpace solid into a 2D room footprint On the left, an IfcSpace drawn in elevation as a rectangle representing the solid volume of a room, with a dashed horizontal cut plane through it at finished floor level plus 1.2 metres. An arrow leads to the right, where the resulting two-dimensional footprint is drawn as a closed polygon — one polygon per space per level. A note explains that the cut must sit above sills and below soffits: too low and it catches skirtings and floor finishes, too high and it catches ducts and bulkheads. A room is a solid until you decide where to cut it IfcSpace solid (elevation view) cut plane: FFL + 1.2 m resulting 2D footprint one Polygon per space, per level cut above sills and below soffits: too low catches skirtings, too high catches ducts

The cut height is the one parameter with no right answer in the model. IFC gives a solid; a floor plan is a section through it, and 1.0-1.4 m above finished floor level is the convention that matches how architects draw plans.

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

This page is part of the Automated Floor Plan Parsing & Vectorization section.