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 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})")
Key implementation notes:
USE_WORLD_COORDScomposes 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_definitiontravels 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.
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.
Related Pages
- CityGML and GML Interchange — parent reference on the level-of-detail model and GML geometry
- Parsing CityGML with lxml and Shapely — the reading counterpart to this writing guide
- Extracting IFC Wall Geometries to Shapely — the footprint extraction this conversion builds on