Extracting IfcSpace Geometry with IfcOpenShell

The geometry half of IFC & BIM model ingestion: turning an IfcSpace solid into the closed 2D ring an indoor map calls a room, with the settings and the chaining logic that make the result trustworthy.

What Sectioning Actually Does

IfcOpenShell hands you a triangle mesh. Sectioning is the step that turns that mesh into a closed polygon at a chosen height, and it is a chaining problem rather than a geometry one.

How a triangle mesh becomes a closed ring at the cut plane On the left, a tessellated solid drawn as a quadrilateral crossed by its triangulation diagonals, with a dashed horizontal cut plane through it. An arrow leads right to four points joined into a closed ring. Each triangle that straddles the plane contributes exactly one line segment where the plane crosses it; those segments are then chained end to end into a closed ring, which is the room's footprint at that height. Triangles in, segments out, rings if you chain them correctly tessellated solid (triangles) z = cut segments, then a ring each triangle straddling the plane contributes one segment; segments chain into rings

Sectioning is a chaining problem, not a geometry problem. Each straddling triangle gives one segment; the work is joining them in order and detecting when they fail to close, which is what a modelling error looks like.

Every triangle in the mesh either sits entirely above the cut plane, entirely below it, or straddles it. A straddling triangle intersects the plane in exactly one line segment, whose endpoints are found by linear interpolation along the two edges that cross. Collect one segment per straddling triangle and you have an unordered soup of segments that, if the solid is closed, form one or more closed rings.

The chaining is where the failure modes live. A mesh from a well-formed solid produces segments whose endpoints match exactly, and chaining is a dictionary lookup. A mesh from a solid with a modelling defect — an unclosed shell, a self-intersecting boundary — produces segments that nearly match, and naive chaining either stops early (producing an open chain) or loops (producing a ring that revisits a vertex). Both need detecting, because both mean the space’s footprint is not what the model claims.

Geometry Settings That Matter

IfcOpenShell geometry settings for indoor map extraction A table of six IfcOpenShell geometry settings. USE_WORLD_COORDS set to true composes the placement chain automatically. WELD_VERTICES set to false keeps triangle vertices separable, which sectioning needs. INCLUDE_CURVES set to false excludes curve representations, which are not solids and break sectioning. DISABLE_OPENING_SUBTRACTIONS left false keeps doors and windows cut out of walls. APPLY_DEFAULT_MATERIALS set to false avoids irrelevant memory cost. Restricting the element filter to IfcSpace and IfcDoor skips about ninety percent of a coordination model. Six settings, and the defaults are wrong for this job geom setting Value Effect USE_WORLD_COORDS True ● composes the placement chain for you WELD_VERTICES False keeps triangle vertices separable for sectioning INCLUDE_CURVES False △ curves are not solids; they break sectioning DISABLE_OPENING_SUBTRACTIONS False ● doors and windows stay cut out of walls APPLY_DEFAULT_MATERIALS False materials are irrelevant and cost memory elements filter IfcSpace, IfcDoor ● skips 90% of a coordination model The element filter is the single largest performance lever in the whole ingest.

The first setting is the one that silently ruins output. With world coordinates off, every space is positioned relative to its own placement, so a whole building stacks on top of itself at the origin.

Composing the IFC object placement chain A left-to-right chain of four placements. IfcSite carries placement A. IfcBuilding carries placement B, applied relative to A. IfcBuildingStorey carries placement C, relative to A times B. IfcSpace carries placement D, relative to A times B times C. A space's world coordinates are the product of all four matrices in order, so reading the space's own coordinates without composing the chain gives a position relative to its storey rather than to the project. Four matrices, and only their product means anything A A·B A·B·C 1 IfcSite placement A 2 IfcBuilding placement B 3 IfcStorey placement C 4 IfcSpace placement D world coordinates = A·B·C·D

Every coordinate in IFC is relative to something. IfcOpenShell composes the chain for you when you ask for a shape through create_shape with world coordinates enabled — reading ObjectPlacement by hand is where people get this wrong.

The placement chain is the setting that matters most and is easiest to get wrong. IFC coordinates are relative all the way up: a space is placed relative to its storey, the storey relative to the building, the building relative to the site. USE_WORLD_COORDS tells IfcOpenShell to compose that chain and hand back world coordinates. With it off — the default in some versions — every space comes back positioned relative to its own placement, which means the whole building collapses onto the origin and every floor sits on top of every other.

The symptom is unmistakable once you know it: total floor area is correct, every room is the right shape, and the building is a few metres across.

Minimal Working Example

import logging
from collections import defaultdict

import ifcopenshell
import ifcopenshell.geom
import numpy as np
from shapely.geometry import Polygon

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


def geom_settings() -> ifcopenshell.geom.settings:
    """Settings tuned for sectioning: world coordinates on, welding off, curves out."""
    s = ifcopenshell.geom.settings()
    s.set(s.USE_WORLD_COORDS, True)      # compose the placement chain
    s.set(s.WELD_VERTICES, False)        # sectioning needs per-triangle vertices
    s.set(s.INCLUDE_CURVES, False)       # curve representations are not solids
    return s


def section_triangles(verts: list[float], faces: list[int], z: float,
                      eps: float = 1e-9) -> list[list[tuple[float, float]]]:
    """Chain the segments where a triangle mesh crosses the plane z into closed rings."""
    v = np.asarray(verts, dtype=float).reshape(-1, 3)
    f = np.asarray(faces, dtype=int).reshape(-1, 3)
    segments: list[tuple[tuple[float, float], tuple[float, float]]] = []

    for tri in f:
        p = v[tri]
        above = p[:, 2] > z
        if above.all() or not above.any():
            continue                                    # wholly on one side
        hits: list[tuple[float, float]] = []
        for i in range(3):
            a, b = p[i], p[(i + 1) % 3]
            if (a[2] > z) == (b[2] > z):
                continue
            t = (z - a[2]) / ((b[2] - a[2]) or eps)      # linear interpolation
            hits.append((round(a[0] + t * (b[0] - a[0]), 6),
                         round(a[1] + t * (b[1] - a[1]), 6)))
        if len(hits) == 2 and hits[0] != hits[1]:
            segments.append((hits[0], hits[1]))

    return _chain(segments)


def _chain(segments) -> list[list[tuple[float, float]]]:
    """Walk segments into closed rings; an unclosed chain is reported, not returned."""
    adj: dict[tuple, list[tuple]] = defaultdict(list)
    for a, b in segments:
        adj[a].append(b)
        adj[b].append(a)

    rings, seen = [], set()
    for start in list(adj):
        if start in seen:
            continue
        ring, cur, prev = [start], start, None
        seen.add(start)
        while True:
            nxt = next((n for n in adj[cur] if n != prev and n not in seen), None)
            if nxt is None:
                nxt = next((n for n in adj[cur] if n == start and len(ring) > 2), None)
                break
            ring.append(nxt)
            seen.add(nxt)
            prev, cur = cur, nxt
        if len(ring) >= 3 and start in adj[ring[-1]]:
            rings.append(ring)
        elif len(ring) >= 3:
            logger.warning("unclosed section chain of %d point(s) — check the source solid",
                           len(ring))
    return rings

The _chain walk deliberately distinguishes two outcomes: a ring that closes back on its start, and a chain that runs out of neighbours. The second is not an error to swallow — it means the solid was not watertight at that height, which is a defect in the model that the BIM author can fix.

Parameter Reference

Parameter Type Default Notes
z float storey elevation + 1.2 The cut plane, in world coordinates
eps float 1e-9 Guards division when a triangle edge is horizontal
rounding int 6 dp Endpoint quantisation; too coarse merges distinct vertices
min_area float 0.5 m² Rings smaller than this are tessellation artefacts
workers int cores − 2 ifcopenshell.geom.iterator parallelism

The endpoint rounding is subtler than it looks. Chaining matches segment endpoints by equality, and floating-point interpolation produces values that differ in the last bits even when the geometry is exact. Rounding to six decimal places (a micron) makes matching reliable without merging vertices that are genuinely distinct — real building geometry has nothing closer together than a millimetre. Rounding to three decimal places starts merging adjacent vertices on curved walls and produces rings that skip corners.

Common Errors & Fixes

The whole building is a few metres across. USE_WORLD_COORDS is off, so every space is positioned in its own local frame. Turn it on; there is no case in this pipeline for local coordinates.

Every space returns zero rings. The cut plane is in the wrong frame. z must be in the same world coordinates the shapes come back in, which means storey elevation plus the cut height, not the cut height alone. A quick diagnostic is to print the mesh’s own z-range for one space:

v = np.asarray(shape.geometry.verts).reshape(-1, 3)
logger.info("space z range: %.2f to %.2f m; cutting at %.2f", v[:, 2].min(), v[:, 2].max(), z)

If the cut sits outside that range, the frame is wrong.

Rings come back with hundreds of nearly-collinear points. The mesh is finely tessellated — a curved wall becomes many small triangles, each contributing a segment. The ring is correct but heavy, and it will bloat the tile payload. Simplify with shapely’s simplify(0.01, preserve_topology=True) after building the polygon; a centimetre tolerance is invisible in a floor plan and typically removes 80% of the vertices.

RuntimeError: Failed to process shape. IfcOpenShell could not build geometry for that entity, usually because the representation is a curve or an unimplemented parametric type. Catch it per space, count it, and continue — the whole-model failure this would otherwise cause is out of proportion to one unrepresentable space.

Integration Point

This step sits in the middle of IFC & BIM model ingestion: after the storey ordering that supplies the elevation for the cut plane, and before the envelope assembly that turns footprints into features. Its output goes through the same plausibility filters as any other source — the area and width floors from geometry cleanup — because a section can produce a valid ring that is not a room just as readily as a polygonizer can.

The GlobalId on each space becomes the feature_id in the published envelope, which matters more than it sounds: GlobalId is stable across model revisions, so a room keeps its identity when the building is re-issued, and the content hash changes only when the geometry genuinely moved.

Frequently Asked Questions

Why not use IfcOpenShell's own 2D representations?

Because they are not reliably present and not reliably plans. IFC allows a FootPrint or Plan representation on a space, and when an authoring tool writes one it is usually exactly what you want — but most models carry only the Body representation, and the ones that do carry a 2D representation often generated it at a different cut height, or for a different purpose, than an indoor map needs. Sectioning the solid yourself gives one consistent rule across every model in the portfolio, which matters more than saving the tessellation. Where a FootPrint representation does exist, comparing its area with your section is a useful free validation.

How do I handle a space whose section produces two rings?

Look at the areas before deciding. Two rings of comparable size usually mean an L-shaped or U-shaped room modelled as two adjoining volumes, and the right answer is to union them into a single polygon — the room really is one space. One large ring and one tiny one usually means a column or a service riser standing inside the room, which should become an interior ring (a hole) rather than a separate polygon. Two rings far apart means the space entity covers two disconnected areas, which is a modelling error worth reporting. Keeping the count in the ingest report is what lets you tell which case a portfolio is producing.

Is sectioning better than projecting the solid downward?

For rooms, yes. Projection — taking the outline of the whole solid viewed from above — includes everything at every height, so a space with a sloped soffit projects to its widest extent and a room with a recessed alcove at ceiling level gains floor area it does not have. Sectioning answers the question a wayfinder actually asks: what is the shape of the space at the height a person occupies. The one case for projection is computing a building footprint or a gross-area figure, where the widest extent is genuinely what you want.

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