Extrude Buildings in 3D with PyQGIS
Extrusion is what turns a footprint layer into a city. The mechanics are one symbol and one property, and the two things that decide whether the result is any good are where the height comes from and how the building sits relative to the ground. Get the second wrong and half your buildings are buried in a hillside.
This recipe belongs to Temporal & 3D Visualization in PyQGIS. It covers a constant extrusion, a data-defined one, height reference modes, materials and colour by attribute, and the honest options when the layer carries no height at all.
Prerequisites
- QGIS 3.34 LTR or newer with 3D support.
- A polygon layer of footprints, ideally with a height or storey-count attribute.
- A configured scene — see configuring a 3D map view.
A constant extrusion
Start here to confirm the scene works at all before adding data-defined complexity.
from qgis.core import QgsProject
from qgis._3d import QgsVectorLayer3DRenderer, QgsPolygon3DSymbol
buildings = QgsProject.instance().mapLayersByName("buildings")[0]
symbol = QgsPolygon3DSymbol()
symbol.setExtrusionHeight(9.0)
buildings.setRenderer3D(QgsVectorLayer3DRenderer(symbol))
Breakdown: setRenderer3D is entirely separate from setRenderer — a layer's 2D style and 3D style share nothing, and changing one leaves the other alone. The extrusion height is in the layer's map units, so 9.0 on a metric layer is nine metres, roughly three storeys. If nothing appears after this, the problem is upstream in the scene rather than here: no terrain generator, a wrong extent, or the layer missing from the settings' layer list.
Height from an attribute
from qgis.core import QgsProperty, QgsAbstract3DSymbol
symbol = QgsPolygon3DSymbol()
symbol.setExtrusionHeight(9.0) # fallback
properties = symbol.dataDefinedProperties()
properties.setProperty(
QgsAbstract3DSymbol.PropertyExtrusionHeight,
QgsProperty.fromExpression(
'coalesce("height_m", "levels" * 3.0, 9.0)'
),
)
symbol.setDataDefinedProperties(properties)
buildings.setRenderer3D(QgsVectorLayer3DRenderer(symbol))
Breakdown: dataDefinedProperties() returns a copy, so setDataDefinedProperties() afterwards is required — the same read-modify-write pattern that governs most QGIS property collections, and the same silent no-op if you skip it. The coalesce expression is the practical heart of this: use a measured height where one exists, fall back to storeys times an assumed storey height, and fall back again to a constant. Three metres per storey is the usual assumption for housing and too low for commercial, which is one more reason to prefer a real height. Because this is an ordinary QGIS expression, everything in evaluating expressions applies, including aggregates and custom functions.
Sitting the buildings on the ground
from qgis.core import Qgis
symbol.setAltitudeClamping(Qgis.AltitudeClamping.Terrain)
symbol.setAltitudeBinding(Qgis.AltitudeBinding.Centroid)
symbol.setOffset(0.0)
Breakdown: Clamping to terrain places the footprint on the ground; Absolute places it at the height in the data regardless of ground, and Relative adds the data height to the ground. The binding decides which ground height a footprint uses: Centroid takes one value at the centre and keeps the base flat, which is right for a building; Vertex follows the terrain at each vertex and makes the base drape, which is right for a road or a field boundary and wrong for a wall. A building on a slope with vertex binding has a base that visibly tilts. On QGIS 3.28 and earlier these enums live at QgsPoint3DSymbol.AltitudeClamping and similar class-scoped names.
Colour and material
from qgis.core import QgsPhongMaterialSettings
from qgis.PyQt.QtGui import QColor
material = QgsPhongMaterialSettings()
material.setAmbient(QColor(70, 68, 64))
material.setDiffuse(QColor(206, 200, 190))
material.setSpecular(QColor(24, 24, 24))
symbol.setMaterialSettings(material)
Breakdown: Keeping specular near-black is what stops masonry looking like polished plastic — the default in some releases is far too high. Ambient controls how dark the shadowed faces go; too low and one side of every building is a black silhouette, too high and the scene flattens. For colour driven by data, the material's diffuse colour also accepts a data-defined property, so colouring by use class, age or height band works exactly as it does in 2D, and it is usually more informative than the height alone.
Where a layer needs several distinct 3D looks, QgsRuleBased3DRenderer takes rules with filter expressions, each carrying its own symbol — the 3D analogue of a rule-based renderer.
Deriving heights when there are none
If the footprints carry no height and no storey count, the honest options are to state a constant as a placeholder, or to measure the heights from LiDAR. The second is a short chain of algorithms and gives a genuine model.
import processing
processing.run("native:zonalstatisticsfb", {
"INPUT": buildings,
"INPUT_RASTER": "/data/output/nDSM.tif",
"RASTER_BAND": 1,
"COLUMN_PREFIX": "h_",
"STATISTICS": [2, 5], # mean and max
"OUTPUT": "/data/output/buildings_h.gpkg",
})
Breakdown: The input raster is a normalised surface model — the surface model minus the terrain model — so each cell holds height above ground, and its mean inside a footprint is the building's average roof height. Producing that raster is the point-cloud work in creating a DEM from a point cloud. Taking both mean and maximum is worth it: the mean is the sensible extrusion height, and a maximum far above it flags a footprint that overlaps a tree or a taller neighbour, which is exactly the quality check the result needs.
Checking the model before anyone else does
A city model is convincing enough that reviewers rarely question it, so it is worth running two checks yourself.
The first is a height distribution. Extruded buildings hide their outliers — a thirty-storey error in a suburb looks like a tower block rather than like a mistake — so read the numbers rather than the picture.
heights = []
for feature in buildings.getFeatures():
value = feature["height_m"]
if value is not None:
heights.append(float(value))
heights.sort()
count = len(heights)
print(f"n={count} min={heights[0]:.1f} median={heights[count // 2]:.1f} "
f"p99={heights[int(count * 0.99)]:.1f} max={heights[-1]:.1f}")
print("above 60 m:", sum(1 for h in heights if h > 60))
Breakdown: The gap between the ninety-ninth percentile and the maximum is the useful number: on a real city they are close, and a maximum many times the percentile means a handful of bad records. Counting the buildings above a threshold you know to be implausible for the area turns that into a list you can go and look at. Doing this before rendering saves explaining a skyscraper later.
The second check is coverage. Count how many footprints fell through to the constant fallback, because a model where sixty per cent of buildings are the same placeholder height is not a model of that city.
total = buildings.featureCount()
with_height = sum(
1 for f in buildings.getFeatures()
if f["height_m"] is not None or f["levels"] is not None
)
print(f"{with_height}/{total} have a real height ({100 * with_height / total:.0f}%)")
Breakdown: Reporting this alongside the map is the difference between a model and a picture. Where the percentage is low, deriving heights from LiDAR as described below is usually the only way to raise it, and it is worth the effort precisely because the alternative looks the same and means much less.
QGIS version compatibility
QgsPolygon3DSymbol and QgsVectorLayer3DRenderer have been present since QGIS 3.0, with data-defined extrusion arriving in 3.10. The altitude clamping and binding enums moved into the scoped Qgis namespace in 3.30, with the older class-scoped names still available. QgsRuleBased3DRenderer arrived in 3.12. QgsPhongMaterialSettings gained additional material types alongside it in 3.16, and the Phong settings remain the sensible default for buildings.
Troubleshooting
- Buildings are flat. No extrusion height set, or the data-defined property was built and never assigned back with
setDataDefinedProperties. - Buildings float or sink on a slope. Altitude clamping is
Absolutewhere it should beTerrain. - A building's base is tilted. Altitude binding is
Vertexrather thanCentroid. - Every building is the same height despite an attribute. The expression references a field name that does not exist;
coalescethen falls through to the constant silently. - The scene is unusably slow. Too many vertices per footprint; simplify before extruding.
- Buildings look like plastic. Specular is too high in the material settings.
Conclusion
Extrude from the best height available and say which it was, clamp to terrain with centroid binding so buildings sit on the ground rather than through it, and keep the material dull. A city model built from real heights answers questions about shadow, view and massing; one built from a constant answers none of them while looking exactly as convincing.
Frequently Asked Questions
Can I extrude down as well as up? Yes, with a negative extrusion height plus an offset, which is how basements and cuttings are shown. Combining a negative height with terrain clamping needs care, since the base is the terrain.
Does extrusion work on lines and points?
Lines extrude into walls with QgsLine3DSymbol, which is how fences and retaining walls are modelled. Points take QgsPoint3DSymbol, which places a shape or a loaded 3D model rather than extruding.
How do I show roof shapes rather than flat tops? QGIS extrudes to a flat roof. Real roof geometry needs a true 3D model format, imported as a scene model rather than derived from footprints.
Can the extrusion height be animated? Only by changing the expression and re-rendering. There is no interpolation between values across frames.