How to extrude building footprints into 3D in Python
Problem statement
Extrusion is the cheapest 3D there is: a polygon, a base height, a top height, and a closed prism. It is what turns a footprint layer into something you can compute shadows, volumes and visibility from, and it is the step every 2.5D workflow starts with.
The arithmetic is trivial. What is not trivial is everything around it โ which height to extrude to, what the base is when the ground slopes, what to do with holes and multipart geometries, and whether the result is a watertight solid or a bag of triangles that looks right and measures nothing.
This guide extrudes a real footprint layer correctly, checks the result, and writes it out in the formats the next step needs.
Quick answer
import numpy as np, trimesh
from shapely.geometry import Polygon
def extrude(polygon: Polygon, base: float, top: float) -> trimesh.Trimesh:
"""A watertight prism from a 2D polygon, with holes handled."""
mesh = trimesh.creation.extrude_polygon(polygon, height=top - base)
mesh.apply_translation([0, 0, base])
return mesh
meshes = [extrude(geom, base, base + h)
for geom, base, h in zip(b.geometry, b.ground_z, b.height_m)]
scene = trimesh.util.concatenate(meshes)
print(scene.is_watertight, f"{sum(abs(m.volume) for m in meshes):,.0f} mยณ")
trimesh.creation.extrude_polygon triangulates the polygon including its interior rings, caps both ends and welds the walls, so the result is watertight by construction. Hand-rolled extrusions usually are not.
Step-by-step solution
1. Work in a projected CRS
Extrusion mixes horizontal and vertical units. In degrees, a 12 m extrusion of a polygon whose coordinates are around 4.27 and 52.10 produces a building twelve degrees tall. Reproject first.
2. Get the base height from the terrain, not from zero
A building extruded from z = 0 sits at sea level. Sample the DTM under each footprint and use a robust statistic โ the median of the DTM cells inside the polygon, or the minimum if you want the building to sit on its lowest ground contact.
import rasterio
from rasterio.mask import mask
def ground_height(dtm_path, geom, stat="median"):
with rasterio.open(dtm_path) as src:
arr, _ = mask(src, [geom], crop=True, filled=False)
values = arr.compressed()
return float(np.median(values) if stat == "median" else values.min())
3. Decide which top height you are extruding to
Eaves gives a flat-roofed block that under-represents volume; ridge over-represents it. On a real LoD2 dataset the median difference was 1.07 m on a median 6.68 m building. If the choice matters, say which you used.
4. Handle multipart footprints and holes
A MultiPolygon is several buildings or several parts of one; extrude each part and keep the relationship. Interior rings are courtyards and must stay holes โ extrude_polygon handles them, a naive wall-building loop does not.
5. Check the result is watertight
bad = [i for i, m in enumerate(meshes) if not m.is_watertight]
print(f"{len(bad)} of {len(meshes)} meshes are not watertight")
Anything not watertight has no volume and will fail a boolean operation later.
6. Decide how to write it out
- GeoPackage with PolygonZ โ keeps it in the GIS world, loses the solid.
- CityJSON โ a real solid with semantics, readable by 3D tools.
- glTF / OBJ / PLY โ for viewers and renderers.
- A
heightcolumn on the footprints โ for web maps that extrude client-side.
The last is usually the right answer for delivery: the browser extrudes, and the payload stays 2D.
7. Keep the attributes
The mesh loses everything except geometry. Carry an id array alongside so the extruded model can be joined back to the footprint table.
Code examples
Example 1 โ the full pipeline, with terrain and checks
import geopandas as gpd, numpy as np, rasterio, trimesh
from rasterio.mask import mask
def extrude_layer(buildings, dtm_path, height_col="height_m", crs="EPSG:28992"):
b = buildings.to_crs(crs).explode(index_parts=False).reset_index(drop=True)
with rasterio.open(dtm_path) as src:
bases = []
for geom in b.geometry:
try:
arr, _ = mask(src, [geom], crop=True, filled=False)
vals = arr.compressed()
bases.append(float(np.median(vals)) if vals.size else np.nan)
except ValueError:
bases.append(np.nan)
b["ground_z"] = bases
meshes, ids = [], []
for row in b.itertuples():
if not np.isfinite(row.ground_z) or not np.isfinite(getattr(row, height_col)):
continue
m = trimesh.creation.extrude_polygon(row.geometry, getattr(row, height_col))
m.apply_translation([0, 0, row.ground_z])
meshes.append(m)
ids.append(row.Index)
b["extruded"] = b.index.isin(ids)
return meshes, ids, b
meshes, ids, b = extrude_layer(buildings, "dtm.tif")
print(f"extruded {len(meshes):,} of {len(b):,}; "
f"not watertight: {sum(not m.is_watertight for m in meshes)}")
print(f"total volume {sum(abs(m.volume) for m in meshes):,.0f} mยณ")
Example 2 โ extrude to a PolygonZ layer instead of a mesh
from shapely.geometry import Polygon, MultiPolygon
import geopandas as gpd
def prism_surfaces(poly: Polygon, base: float, top: float):
"""Ground, roof and wall faces as 3D polygons โ a GIS-friendly 'solid'."""
def ring3(coords, z):
return [(x, y, z) for x, y in coords]
faces = [Polygon(ring3(poly.exterior.coords, base)),
Polygon(ring3(poly.exterior.coords, top))]
for ring in [poly.exterior, *poly.interiors]:
pts = list(ring.coords)
for a, c in zip(pts, pts[1:]):
faces.append(Polygon([(a[0], a[1], base), (c[0], c[1], base),
(c[0], c[1], top), (a[0], a[1], top)]))
return faces
rows = []
for row in b.itertuples():
for f in prism_surfaces(row.geometry, row.ground_z, row.ground_z + row.height_m):
rows.append({"id": row.Index, "geometry": f})
surfaces = gpd.GeoDataFrame(rows, crs=b.crs)
surfaces.to_file("buildings_3d.gpkg", layer="surfaces", driver="GPKG")
Useful when the consumer is a GIS rather than a 3D engine. Note it is a collection of faces, not a solid: nothing enforces that they close.
Example 3 โ the web-map answer: do not extrude at all
web = b[["id", "height_m", "ground_z", "geometry"]].to_crs(4326)
web["min_height"] = 0
web.to_file("buildings.geojson", driver="GeoJSON", COORDINATE_PRECISION=6)
MapLibre's fill-extrusion layer reads height and base from properties and extrudes on the GPU. The payload stays 2D, the client does the work, and the file is a fraction of the size of any mesh format.
Explanation
Why extrude_polygon rather than a wall loop
Building the walls by hand is easy; getting the caps right is not. A polygon with interior rings needs a constrained triangulation to cap it, and the triangulation has to be consistent between the top and bottom faces or the solid leaks. trimesh.creation.extrude_polygon uses a proper triangulator and produces a watertight result including holes.
Why the base height dominates the error on sloping ground
A flat base under a building on a 5% slope across a 20 m footprint is a metre out at one end, which is more than most height estimates are wrong by. Using the median DTM value inside the footprint splits the difference; using the minimum guarantees the building does not float but buries the uphill side.
Why extruding to the ridge overstates volume
A prism to the ridge fills the whole roof void. For a symmetric pitched roof, the prism overstates the building's volume by about half the roof volume โ on a 6 m building with a 1 m roof rise, roughly 8%. Extruding to the eaves understates it by the same amount. Neither is wrong; the choice must be recorded.
Why the browser is often the right place to extrude
A city block as a mesh is megabytes; the same block as footprints with a height column is tens of kilobytes. The GPU extrudes it in a frame. Server-side extrusion is for when you need the geometry itself โ volumes, shadows, intersections โ not for when you need a picture.
Edge cases or notes
- Zero or negative heights. Clip to a minimum, or drop with a count.
- Invalid footprints.
make_validbefore extruding; a bowtie polygon triangulates badly. - Self-touching rings produce degenerate triangles.
- Very small footprints can extrude to slivers; set a minimum area.
explodebefore extruding. A MultiPolygon extrudes as separate prisms.- Keep an id array. Meshes carry no attributes.
- Watch memory. A hundred thousand prisms is a large scene.
- Terrain and buildings need the same vertical datum.
Internal links
- Where building heights come from, and how wrong they are โ the height you extrude to
- Extruded buildings float above or sink into the ground โ the base height in practice
- 3D spatial data models explained: 2.5D, meshes and solids โ what a prism can and cannot represent
- How to calculate building volumes and floor area in Python โ measuring the result
- A building mesh is not watertight and its volume is wrong โ when the check fails
- How to export a 3D city model to glTF in Python โ writing it out
- How to publish a 3D building layer to a web map โ the client-side route
- How to fix invalid geometries in GeoPandas โ before extruding
FAQ
How do I extrude a polygon to 3D in Python?
trimesh.creation.extrude_polygon(polygon, height) produces a watertight prism including any interior rings; translate it in z to set the base.
What should the base height be?
The terrain under the footprint, sampled from a DTM โ the median of the cells inside the polygon is a reasonable default. Extruding from zero puts buildings at sea level.
Should I extrude to the eaves or the ridge?
Whichever your analysis needs, and record it. On a real dataset the two differed by a median of 1.07 m on a median 6.68 m building, and a prism to the ridge overstates volume by roughly half the roof void.
Why is my extruded mesh not watertight?
Usually a hand-built wall loop with no caps, or an invalid footprint. Use a proper triangulating extruder and validate the polygon first.
Do I need to extrude for a web map?
No. Ship footprints with a height property and let MapLibre's fill-extrusion do it on the GPU โ the payload is a fraction of the size.
How do I keep the attributes on the mesh?
You cannot; meshes carry geometry only. Keep a parallel array of ids so the model can be joined back to the footprint table.