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 IfcWallStandardCase and IFC4 IfcWall at 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.

IFC wall geometry extraction pipeline Five sequential stages: IFC File → OCCT Tessellation → Vertex/Face Arrays → 2D Projection → Shapely Polygon (validated) IFC File IfcWall entities OCCT Kernel tessellation Mesh Arrays verts + faces 2D Projection drop Z axis Shapely Polygon (valid) Step 1 Step 2 Step 3 Step 4 Step 5

The extraction process follows five deterministic steps:

  1. Model loading and query — parse the IFC file and filter IfcWall (and optionally IfcWallStandardCase) entities. Avoid recursive type traversal unless you explicitly need IfcProxy or legacy classifications.
  2. Geometry settings configuration — enable world coordinates, disable curve inclusion, and disable material application so the kernel returns pure mesh data.
  3. Mesh extraction — retrieve flattened vertex arrays and face index lists. OCCT tessellates complex solids into triangles, so each face contains exactly three vertex indices.
  4. Axis projection — drop the target axis (Z for floor-plan views) and map the remaining coordinates to (x, y) tuples, maintaining consistent winding order.
  5. Topology assembly — convert projected triangles to Shapely polygons, merge with unary_union, and repair with make_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_COORDS applies 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_area threshold (default 0.001 m²) 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 that unary_union does not fully eliminate.
  • Store GlobalId as 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.