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.

Flow from a footprint through base height, top height, triangulation and capping to a watertight prism.
The two caps are what make it a solid; the walls alone are a tube.

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 height column 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.

Two scenes contrasting buildings extruded from zero against buildings extruded from sampled terrain.
On sloping ground, extruding from zero puts half the block underground.

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.

Comparison of writing extruded buildings as a height column, as CityJSON or as glTF, across payload size, attributes, solidity and best use.
The browser extrudes for free; sending prisms sends ten times the vertices.

Edge cases or notes

  • Zero or negative heights. Clip to a minimum, or drop with a count.
  • Invalid footprints. make_valid before extruding; a bowtie polygon triangulates badly.
  • Self-touching rings produce degenerate triangles.
  • Very small footprints can extrude to slivers; set a minimum area.
  • explode before 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.

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.