Extracting IFC Wall Geometries to Shapely
The direct answer: open the IFC file with ifcopenshell, configure the geometry kernel to return world-coordinate meshes, project each triangulated face to the XY plane, build Polygon objects from the resulting coordinate pairs, merge adjacent triangles with shapely.ops.unary_union, and repair topology with shapely.make_valid(). The full pipeline runs in pure Python with no external CAD software. For the broader context of working with IFC geometry at scale, see the IfcOpenShell Workflow guide.
How IfcOpenShell Handles Wall Geometry
IfcOpenShell delegates geometry resolution to the OpenCASCADE (OCCT) kernel, which is bundled with the ifcopenshell-python wheel. When you call ifcopenshell.geom.create_shape(), OCCT reads the wall’s native representation — IfcExtrudedAreaSolid, IfcFacetedBrep, or a B-Rep body — evaluates any nested boolean operations (openings for doors and windows), and tessellates the result into a triangulated mesh. The ifcopenshell.util.shape helpers then expose that mesh as flat numpy-compatible arrays.
What the API does not do automatically:
- It does not project 3D geometry to 2D. That step belongs entirely to your code.
- It does not guarantee topologically valid 2D polygons after projection. Vertical or near-vertical triangles collapse to degenerate lines when you drop the Z coordinate.
- It does not distinguish between IFC2x3
IfcWallStandardCaseand IFC4IfcWallat the shape-creation level — both go through the same OCCT pipeline, but unit and precision defaults differ between schema versions.
The diagram below shows the data-flow from IFC file to validated Shapely geometry.
The extraction process follows five deterministic steps:
- Model loading and query — parse the IFC file and filter
IfcWall(and optionallyIfcWallStandardCase) entities. Avoid recursive type traversal unless you explicitly needIfcProxyor legacy classifications. - Geometry settings configuration — enable world coordinates, disable curve inclusion, and disable material application so the kernel returns pure mesh data.
- Mesh extraction — retrieve flattened vertex arrays and face index lists. OCCT tessellates complex solids into triangles, so each face contains exactly three vertex indices.
- Axis projection — drop the target axis (Z for floor-plan views) and map the remaining coordinates to
(x, y)tuples, maintaining consistent winding order. - Topology assembly — convert projected triangles to Shapely polygons, merge with
unary_union, and repair withmake_valid.
Production-Ready Script
The script below handles projection, validation, and merging in a single pass. It is structured for batch processing and includes explicit per-element error routing so a single corrupt wall does not abort the run.
# ifcopenshell>=0.8.0, shapely>=2.0.0, numpy>=1.24.0
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.util.shape
from shapely.geometry import Polygon, MultiPolygon
import shapely
import shapely.ops
import numpy as np
from typing import Literal
def extract_walls_to_shapely(
ifc_path: str,
projection_axis: Literal["X", "Y", "Z"] = "Z",
min_area: float = 0.001,
include_standard_case: bool = True,
) -> list[dict]:
"""
Extract IfcWall (and optionally IfcWallStandardCase) geometries and
convert them to validated Shapely Polygon / MultiPolygon objects.
Returns a list of dicts with keys:
id (str) GlobalId of the wall element
name (str) Name attribute, may be None
geometry (Polygon | MultiPolygon | None)
status (str) "success" | "no_faces" | "error"
message (str) present only when status == "error"
"""
model = ifcopenshell.open(ifc_path)
# Collect wall types — IfcWallStandardCase is a subtype of IfcWall in
# IFC2x3; IFC4 merges them, but both strings remain valid to query.
entity_types = ["IfcWall"]
if include_standard_case:
entity_types.append("IfcWallStandardCase")
walls = []
seen_ids: set[str] = set()
for etype in entity_types:
for w in model.by_type(etype):
if w.GlobalId not in seen_ids:
walls.append(w)
seen_ids.add(w.GlobalId)
# Configure geometry kernel: world coordinates, mesh-only output.
settings = ifcopenshell.geom.settings()
settings.set(settings.USE_WORLD_COORDS, True)
settings.set(settings.EXCLUDE_SOLIDS_AND_SURFACES, False)
settings.set(settings.INCLUDE_CURVES, False)
settings.set(settings.APPLY_DEFAULT_MATERIALS, False)
axis_map = {"X": 0, "Y": 1, "Z": 2}
proj_idx = axis_map[projection_axis.upper()]
other_axes = [i for i in range(3) if i != proj_idx]
results: list[dict] = []
for wall in walls:
try:
shape = ifcopenshell.geom.create_shape(settings, wall)
# get_vertices returns a flat list; reshape to (N, 3)
verts = np.array(
ifcopenshell.util.shape.get_vertices(shape)
).reshape(-1, 3)
faces = ifcopenshell.util.shape.get_faces(shape)
polygons: list[Polygon] = []
for face in faces:
# Project each triangle vertex to the 2D plane
coords = [
(float(verts[i][other_axes[0]]),
float(verts[i][other_axes[1]]))
for i in face
]
try:
poly = Polygon(coords)
if poly.is_valid and poly.area > min_area:
polygons.append(poly)
except Exception:
# Degenerate triangle (e.g. all vertices collinear)
continue
if not polygons:
results.append({
"id": wall.GlobalId,
"name": getattr(wall, "Name", None),
"geometry": None,
"status": "no_faces",
})
continue
# Merge adjacent triangles into a coherent wall footprint
merged = shapely.ops.unary_union(polygons)
# Repair self-intersections produced by projection
valid_geom = shapely.make_valid(merged)
results.append({
"id": wall.GlobalId,
"name": getattr(wall, "Name", None),
"geometry": valid_geom,
"status": "success",
})
except Exception as exc:
results.append({
"id": wall.GlobalId,
"name": getattr(wall, "Name", None),
"geometry": None,
"status": "error",
"message": str(exc),
})
return results
# ── Usage ──────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import json
walls = extract_walls_to_shapely("model.ifc", projection_axis="Z")
success = [w for w in walls if w["status"] == "success"]
errors = [w for w in walls if w["status"] == "error"]
print(f"Extracted {len(success)} walls, {len(errors)} errors")
# Export to GeoJSON for PostGIS / QGIS ingestion
features = []
for w in success:
features.append({
"type": "Feature",
"properties": {"globalId": w["id"], "name": w["name"]},
"geometry": shapely.to_geojson(w["geometry"])
if hasattr(shapely, "to_geojson")
else w["geometry"].__geo_interface__,
})
with open("walls.geojson", "w") as f:
json.dump({"type": "FeatureCollection", "features": features}, f)
Key implementation notes:
USE_WORLD_COORDSapplies the element’s placement transform before tessellation, so all vertices arrive in the model’s global coordinate system. Omitting this flag returns geometry in the element’s local frame, breaking multi-wall spatial queries.- The
min_areathreshold (default0.001m²) discards near-degenerate triangles that arise when vertical wall faces are projected flat. Adjust for your model’s unit convention (IFC defaults to metres). shapely.make_valid()was added in Shapely 1.8 and stabilised in 2.0. It repairs the most common projection artefacts: self-touching rings, inward spikes, and zero-width slivers thatunary_uniondoes not fully eliminate.- Store
GlobalIdas the foreign key in any downstream spatial database. It is the only stable cross-tool identifier that survives round-trips through Revit, ArchiCAD, and IFC exporters.
For handling the mesh arrays themselves in more complex scenarios — including normal-direction filtering to isolate horizontal versus vertical faces — see Geometry & Mesh Conversion.
Compatibility Matrix
| Component | Supported Range | Notes |
|---|---|---|
| Python | 3.9 – 3.12 | Python 3.8 reached end-of-life October 2024 |
| ifcopenshell | ≥ 0.8.0 | Earlier releases lack ifcopenshell.util.shape; get_vertices / get_faces API stabilised in 0.7.0 |
| Shapely | ≥ 2.0.0 | shapely.make_valid() as a top-level function requires ≥ 2.0; use shapely.validation.make_valid() on 1.8.x |
| NumPy | ≥ 1.24.0 | Used only for vertex reshape; any NumPy 1.x ≥ 1.20 works in practice |
| IFC schema | IFC2x3, IFC4, IFC4x3 | IFC2x3 IfcWallStandardCase is handled identically by OCCT; IFC4x3 IfcWall with IfcAdvancedBrepWithVoids requires ≥ 0.8.0 |
| OS | Linux, macOS, Windows | OCCT kernel is bundled; no external CAD dependencies |
| Memory | ~200 MB per 1,000 walls | OpenCASCADE caches tessellation state; process in batches of 500 – 1,000 for large models |
Fallback Strategies / Troubleshooting
1. create_shape raises RuntimeError: No geometry for element
The wall entity has no body representation — common in early-stage Revit exports where walls exist as schedule objects without 3D geometry. Filter before processing:
from ifcopenshell.util.element import get_psets
def has_body_representation(wall) -> bool:
if not wall.Representation:
return False
return any(
rep.RepresentationIdentifier == "Body"
for rep in wall.Representation.Representations
)
walls = [w for w in model.by_type("IfcWall") if has_body_representation(w)]
2. Projected polygons produce only LineString or Point geometries
All tessellation triangles lie in a vertical plane — the wall is perfectly perpendicular to your projection axis. Switch the projection axis or pre-filter by face normal:
def face_normal(verts, face):
"""Compute the unit normal of a triangular face."""
v0, v1, v2 = [verts[i] for i in face]
edge1 = v1 - v0
edge2 = v2 - v0
n = np.cross(edge1, edge2)
length = np.linalg.norm(n)
return n / length if length > 1e-10 else np.zeros(3)
# Keep only faces with a significant Z component (near-horizontal)
horizontal_faces = [
f for f in faces
if abs(face_normal(verts, f)[2]) > 0.5
]
3. Unit mismatch — wall footprints are 1,000× too large or too small
IFC files default to metres, but older Revit and ArchiCAD exports embed millimetres without a proper IfcSIUnit declaration. Check the project unit and apply a scale factor:
from ifcopenshell.util.unit import calculate_unit_assignment
unit_scale = calculate_unit_assignment(model, "LENGTHUNIT")
# unit_scale == 0.001 when the file uses millimetres
verts = verts * unit_scale
4. shapely.make_valid returns a GeometryCollection instead of a Polygon
This happens when the merged geometry contains disconnected components (e.g. wall openings split a wall into multiple segments). Extract only polygon-type members:
from shapely.geometry import GeometryCollection
def extract_polygons(geom):
if geom.is_empty:
return None
if isinstance(geom, (Polygon, MultiPolygon)):
return geom
if isinstance(geom, GeometryCollection):
polys = [g for g in geom.geoms if isinstance(g, (Polygon, MultiPolygon))]
return shapely.ops.unary_union(polys) if polys else None
return None
5. Memory exhaustion on models with thousands of walls
Process in batches and release the OCCT shape cache after each batch:
BATCH_SIZE = 500
for i in range(0, len(walls), BATCH_SIZE):
batch = walls[i : i + BATCH_SIZE]
batch_results = [process_wall(w, settings, other_axes, min_area) for w in batch]
# write batch_results to database here, then let the batch go out of scope
For coordinate-system alignment after extraction — projecting the resulting Shapely geometries into a real-world CRS — refer to Converting CAD Local Coordinates to EPSG:4326.
Related Pages
- IfcOpenShell Workflow — parent guide covering the full ifcopenshell API surface for IFC parsing and geometry access
- Geometry & Mesh Conversion — techniques for working with tessellated mesh arrays, face normals, and polygon topology
- Converting CAD Polylines to GeoJSON — sibling workflow for vector geometry export from DXF/DWG sources
- Python Parsing & Geometry Extraction — overview of the full geometry-extraction pipeline across IFC, DXF, and DWG formats