Converting IFC Buildings to CityGML LoD1 with Python

To convert an IFC building to CityGML LoD1, union the projected footprints of its envelope elements into one polygon, resolve a single representative height, and extrude the footprint into a prismatic solid carrying the IFC identifier. LoD1 is a deliberate generalisation: everything the design model knows about assemblies, materials and systems is discarded, and what remains is a block with a height and an identity. This page is part of the CityGML and GML Interchange reference.

How the Generalisation Works

LoD1 declares that the geometry is a prism: a footprint extruded to one height, with a flat top. That declaration is a claim about the data, so the conversion’s job is to produce geometry the claim is true of, not to preserve as much of the source as possible.

Three decisions turn a model into a level-one solid Four stages. Envelope elements are compiled and projected into a single footprint; one representative height is chosen and recorded; the footprint is extruded into a prism; the identifier is carried across so the city object can be reconciled with the model. Everything the design model knew about assemblies, materials and systems is discarded deliberately. Envelope elements walls, roofs, slabs One footprint principal mass Prism flat top by definition City object reconcilable project + union choose a height carry GlobalId

Three decisions do all the work. The footprint comes from projecting the envelope elements and unioning the result — the same operation described in Extracting IFC Wall Geometries to Shapely, applied to the building rather than to one element. The height is a choice between eaves and ridge, and the two differ by metres on any pitched roof, so it must be recorded rather than assumed. The identity is carried from the IFC GlobalId, which is what allows a city object to be reconciled with the model it came from.

Production-Ready Script

# ifcopenshell>=0.7.0, shapely>=2.0, numpy>=1.24, lxml>=4.9, Python 3.9+
from __future__ import annotations

from dataclasses import dataclass
import numpy as np
import ifcopenshell
import ifcopenshell.geom
from shapely.geometry import Polygon
from shapely.ops import unary_union
from shapely.geometry.polygon import orient

ENVELOPE = ("IfcWall", "IfcWallStandardCase", "IfcRoof", "IfcSlab", "IfcCurtainWall")


@dataclass(frozen=True)
class Lod1Building:
    global_id: str
    footprint: Polygon
    base_z: float
    height: float
    height_definition: str          # "eaves" | "ridge" — recorded, never implied


def _settings():
    s = ifcopenshell.geom.settings()
    s.set(s.USE_WORLD_COORDS, True)     # compose the placement chain in the kernel
    return s


def envelope_footprint(model, building, settings) -> tuple[Polygon, float, float]:
    """Union the projected envelope elements; return footprint, base and top Z."""
    polys, zmin, zmax = [], np.inf, -np.inf
    for cls in ENVELOPE:
        for el in model.by_type(cls):
            if not el.Representation:
                continue
            try:
                shape = ifcopenshell.geom.create_shape(settings, el)
            except RuntimeError:
                continue                      # unsupported representation — counted upstream
            v = np.array(shape.geometry.verts).reshape(-1, 3)
            f = np.array(shape.geometry.faces).reshape(-1, 3)
            zmin, zmax = min(zmin, v[:, 2].min()), max(zmax, v[:, 2].max())
            for tri in v[f][:, :, :2]:        # project each triangle to plan
                p = Polygon(tri)
                if p.is_valid and p.area > 1e-9:
                    polys.append(p)
    if not polys:
        raise ValueError("no envelope geometry produced a projectable face")
    merged = unary_union(polys)
    if merged.geom_type == "MultiPolygon":
        merged = max(merged.geoms, key=lambda g: g.area)   # the principal mass
    return orient(merged, sign=1.0), float(zmin), float(zmax)


def to_lod1(ifc_path: str, height_definition: str = "ridge") -> list[Lod1Building]:
    model = ifcopenshell.open(ifc_path)
    settings = _settings()
    out = []
    for building in model.by_type("IfcBuilding"):
        footprint, zmin, zmax = envelope_footprint(model, building, settings)
        out.append(Lod1Building(
            global_id=building.GlobalId,
            footprint=footprint,
            base_z=zmin,
            height=zmax - zmin,
            height_definition=height_definition,
        ))
    return out


def prism_surfaces(b: Lod1Building) -> list[list[tuple[float, float, float]]]:
    """Bottom, top and side faces of the LoD1 solid, consistently oriented."""
    ring = list(b.footprint.exterior.coords)
    z0, z1 = b.base_z, b.base_z + b.height
    bottom = [(x, y, z0) for x, y in ring][::-1]      # downward-facing
    top = [(x, y, z1) for x, y in ring]
    sides = []
    for (x0, y0), (x1, y1) in zip(ring, ring[1:]):
        sides.append([(x0, y0, z0), (x1, y1, z0), (x1, y1, z1), (x0, y0, z1), (x0, y0, z0)])
    return [bottom, top, *sides]


if __name__ == "__main__":
    for b in to_lod1("model.ifc"):
        print(f"{b.global_id}: area {b.footprint.area:.1f} m2, "
              f"height {b.height:.2f} m ({b.height_definition})")
Eaves height against ridge height Two defensible answers to the question a level-one solid has to settle, and what each is right for. The eaves height understates volume and is what a facade or daylight study wants; the ridge height overstates footprint volume and is what a visibility or obstruction study wants. On a pitched roof they differ by metres, so the number is meaningless without the definition attached. Eaves height — top of the wall plane — understates volume — suits daylight and facade work — closer to the occupied envelope Ridge height — highest point of the roof — overstates volume — suits visibility and obstruction — what a planner usually means Record which definition was applied — a height without it is not a measurement.

Key implementation notes:

  • USE_WORLD_COORDS composes the placement chain in the kernel. Without it every element mesh arrives in its own local frame and the union merges walls that are nowhere near each other.
  • Degenerate projections are dropped before the union. Vertical faces project to zero-area slivers, and unioning those is both slow and a source of invalid results.
  • Selecting the largest polygon from a multi-part union takes the principal mass. Where outbuildings should be separate city objects, split rather than select — but do it deliberately.
  • orient(..., sign=1.0) normalises the exterior ring to counter-clockwise so the side faces come out consistently. Skipping this is the usual cause of a solid that fails validation.
  • height_definition travels with the object. A height without its definition is not a measurement.

Compatibility Matrix

Component Supported range Notes
ifcopenshell >=0.7.0 USE_WORLD_COORDS and create_shape stable
IFC schema IFC2X3, IFC4, IFC4X3 envelope class names differ slightly across releases
shapely >=2.0 unary_union, orient
CityGML target 1.0, 2.0, 3.0 lod1Solid present in all three
Coordinate system projected LoD1 extrusion assumes a metric vertical axis

Fallback Strategies

1. The union produces several disjoint parts. A site with detached structures modelled as one building. Decide whether they are separate city objects — usually they are — and emit one per part rather than silently taking the largest.

The projected footprint that becomes the prism base The outline produced by projecting the envelope elements of an L-shaped building and unioning the result. It is the base of the extruded prism and the only horizontal information a level-one solid carries. Where the union produces several disjoint parts, each is normally a separate city object rather than a fragment to discard. Easting (m, local) Northing (m) footprint Ring winding is normalised before extrusion, or the side faces come out inconsistent.

2. Height comes out absurd. A model containing a site or terrain element inside the envelope class list drags zmin down to ground level several metres below the building base. Restrict the class list, or compute the base from the lowest slab rather than from all geometry.

3. Elements with no geometry. Counted and skipped in the loop above, but a building where most elements skip produces a footprint that is a fragment. Assert a minimum yield ratio before accepting the result, as described in the parent section on Python Parsing & Geometry Extraction.

4. The model is not georeferenced. The resulting city object sits at model coordinates near the origin. Resolve the georeferencing first; a CityGML file with no meaningful coordinates is not usable as a city model.

5. Curved facades. The projection produces a many-vertex ring that is faithful and unwieldy. Simplify to a tolerance appropriate to LoD1 — decimetres, not millimetres — before extruding, and record the tolerance applied.

FAQ

Which height should LoD1 use?

Whichever your consumer expects, stated explicitly. The common choices are the eaves height and the maximum roof height, and they differ by metres on a pitched roof. CityGML has an attribute for measured height, so record which definition was applied rather than leaving the number unqualified — a shadow study and a volume calculation want different answers.

Do I need the whole IFC model to produce LoD1?

No, and evaluating all of it is the slow way. The envelope elements — external walls, roofs, slabs — determine the footprint and height. Filtering to those classes before compiling geometry typically cuts the work by an order of magnitude on a detailed model.

Why does my extruded solid fail validation?

Usually ring orientation. A prismatic solid needs consistently outward-facing surfaces, and a footprint ring taken straight from a union may be clockwise or counter-clockwise depending on the geometry it came from. Normalise the ring winding before building the side faces, and orient the top and bottom caps to match.