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.
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
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.
Related
- IFC & BIM Model Ingestion — the surrounding pipeline, and the checks a BIM source still needs.
- Mapping IFC Storeys to Indoor Levels — where the elevation for the cut plane comes from.
- Fixing Invalid Polygons with Shapely — what to do with a sectioned ring that does not validate.
This page is a companion to IFC & BIM Model Ingestion, part of the Automated Floor Plan Parsing & Vectorization section.