3D spatial data models explained: 2.5D, meshes and solids
Problem statement
"3D building data" describes at least four different things, and code written for one of them fails on the others. A footprint with a height attribute, a triangulated mesh, a closed solid with labelled surfaces and a point cloud are all called 3D, and only two of them have a volume you can compute.
Choosing the wrong model is expensive in a specific way: you build a pipeline around polygons with a height column, and then a real city model arrives as CityJSON solids with semantic surfaces and none of your code applies. This guide sets out the four models, what each can answer, and which questions force you up to the next one.
Quick answer
| model | what it is | volume? | roof shape? | typical source |
|---|---|---|---|---|
| 2.5D | a 2D footprint plus one height | yes, as a prism | no | OSM, building footprints |
| surface (2.5D raster) | one height per pixel | no | partly | DSM from LiDAR or photogrammetry |
| mesh | triangles in 3D, not necessarily closed | only if closed | yes | photogrammetry, glTF, 3D Tiles |
| solid | a closed shell, oriented, watertight | yes | yes | CityJSON, CityGML LoD2+ |
Most urban analysis โ shadows, solar, volumes, visibility, sky view factor โ works on 2.5D. The jump to solids is worth making when the roof shape changes the answer.
Step-by-step solution
1. Start from the question, not the data
- Shadows, daylight, visibility โ 2.5D is usually enough.
- Volume, floor area, embodied material โ 2.5D gives a prism volume; solids give the real one.
- Roof-mounted solar, roof drainage โ you need roof geometry, so LoD2 solids or a DSM.
- Interior anything โ LoD4 or a BIM model, which is a different world.
2. Understand what 2.5D cannot represent
A footprint with a height is a vertical prism. It cannot represent an overhang, a bridge, an arcade, a balcony or a building with two heights โ and it cannot represent the same ground location having two surfaces, which is exactly what a road under a building is. If your study area has any of those, decide explicitly whether the error matters.
3. Know that a mesh is not automatically a solid
A mesh is a set of triangles. Whether it encloses a volume is a separate property โ watertightness โ that has to be checked, not assumed. On a real LoD2 dataset, 1,985 of 1,990 solids triangulated cleanly to watertight meshes and five did not; those five return a meaningless volume.
4. Treat the surface model as the cheapest 3D there is
A digital surface model is a raster, so every raster tool applies: shadows are a hillshade problem, sky view factor is a horizon-angle problem, and building height is a DSM minus DTM problem. For city-scale analysis it is often the fastest route, and it comes free with LiDAR.
5. Recognise the solid's extra content: semantics
A CityJSON solid carries labelled surfaces. In a real Hague LoD2 tile, 1,990 buildings had 11,215 wall surfaces, 3,004 roof surfaces and 1,990 ground surfaces, each labelled. Those labels are what let you compute roof area without guessing from the normal direction.
6. Expect to move between models
The common path is footprints + heights โ extruded solids โ mesh โ glTF for the browser. Each step is lossy in one direction and fabricated in the other: extruding invents a flat roof, and meshing discards the semantics.
Code examples
Example 1 โ 2.5D: a footprint and a height
import geopandas as gpd
b = gpd.read_file("buildings.gpkg").to_crs(28992)
b["height_m"] = b["levels"] * 3.0
b["footprint_m2"] = b.area
b["prism_volume_m3"] = b.area * b["height_m"]
b["wall_area_m2"] = b.geometry.length * b["height_m"]
print(b[["footprint_m2", "height_m", "prism_volume_m3"]].describe().round(1))
Three useful quantities from two columns. That is why 2.5D survives: the arithmetic is trivial and it is right to within the roof.
Example 2 โ a solid, triangulated and measured
import numpy as np, trimesh
def solid_to_mesh(boundaries, vertices):
"""CityJSON Solid โ triangles, fan-triangulating each surface's outer ring."""
tris = []
for shell in boundaries:
for surface in shell:
ring = surface[0]
for i in range(1, len(ring) - 1):
tris.append([ring[0], ring[i], ring[i + 1]])
return trimesh.Trimesh(vertices=vertices, faces=np.array(tris), process=True)
mesh = solid_to_mesh(geometry["boundaries"], vertices)
print(mesh.is_watertight, round(abs(mesh.volume), 1), round(mesh.area, 1))
On the Hague tile this gave a median building volume of 118.4 mยณ and a total of 683,115 mยณ across 1,990 solids.
Example 3 โ comparing the prism against the solid
import numpy as np
prism = footprint_area * height_to_ridge
real = abs(mesh.volume)
print(f"prism {prism:,.0f} mยณ, solid {real:,.0f} mยณ, "
f"prism overstates by {prism/real - 1:.1%}")
For a flat-roofed building the two agree. For a pitched roof, extruding to the ridge overstates the volume by roughly half the roof volume โ which is why the height you extrude to matters more than the model you choose.
Explanation
Why 2.5D is the default and should be
Every input you are likely to have โ footprints, DSMs, LiDAR-derived heights โ is naturally 2.5D, and the operations people actually run are insensitive to the roof. A shadow cast by a pitched roof differs from the prism's shadow by a metre or two at the edges; a solar analysis does not. Moving to solids costs data you may not have and code you have to write.
Why watertightness is the property that matters for a mesh
Volume, inside/outside tests and boolean operations all require a closed surface with consistent orientation. A mesh from photogrammetry usually has holes where the scanner could not see, and a mesh from a careless triangulation has duplicated or flipped faces. trimesh reports is_watertight and can often repair the simple cases; a mesh that cannot be repaired cannot be measured.
Why the semantics are the real value of a city model
Without labels, separating roof from wall means classifying by surface normal, which fails on steep roofs and on walls that lean. A CityJSON model states which surface is which, so roof area, wall area and ground area are sums rather than estimates. That is usually a bigger practical difference than the geometry itself.
Why the vertical datum is part of the model
A city model's z values are heights in a vertical datum โ the Hague tile uses EPSG:7415, which is RD New horizontally and NAP vertically. Mixing a model in NAP with a DTM in ellipsoidal height puts every building about 43 m underground in the Netherlands. The horizontal CRS is checked by everybody; the vertical one is checked by almost nobody.
Edge cases or notes
- Overhangs break 2.5D. Arcades, bridges and cantilevers need a solid or a mesh.
- A building can have several parts. LoD2 models split them; footprints usually do not.
- Z-up is not universal. glTF is Y-up, which is a common source of sideways buildings.
- Units can differ between axes. Rare, and catastrophic when it happens.
- Point clouds are not a model. They are a measurement you derive a model from.
- BIM is a different discipline. IFC models carry components, not just surfaces.
- Terrain is part of the scene. A building model without a ground surface floats or sinks.
- LoD is about detail, not accuracy. An LoD2 model can be less accurate than an LoD1 one.
Internal links
- Level of detail explained for 3D city models โ what LoD1 to LoD4 actually mean
- CityJSON and CityGML explained โ the standard solids arrive in
- Where building heights come from, and how wrong they are โ the input to every 2.5D analysis
- How to extrude building footprints into 3D in Python โ the 2.5D to solid step
- How to calculate building volumes and floor area in Python โ measuring each model
- A building mesh is not watertight and its volume is wrong โ the check this guide flags
- LiDAR surfaces explained: DSM, DTM and CHM โ the raster route into 3D
- Vertical datums explained โ why z needs its own CRS
FAQ
What is 2.5D data?
A two-dimensional footprint with a single height attached, extruded into a vertical prism. It cannot represent overhangs or two surfaces above the same ground point, and it is enough for most urban analysis.
What is the difference between a mesh and a solid?
A mesh is a set of triangles; a solid is a closed, consistently oriented shell that encloses a volume. A mesh may or may not be watertight, and only a watertight one has a meaningful volume.
Do I need a full city model for shadow analysis?
No. Footprints with heights are enough for shadows, daylight and visibility. Roof geometry matters for roof-mounted solar and drainage.
How do I know if my mesh is watertight?
trimesh.Trimesh(...).is_watertight. On a real LoD2 tile, 1,985 of 1,990 solids were watertight and five were not.
Why are semantic surfaces useful?
They label each face as wall, roof or ground, so roof area is a sum rather than a guess from surface normals. The Hague tile has 11,215 walls, 3,004 roofs and 1,990 grounds, all labelled.
Does the vertical datum matter?
Yes. A model in a national height datum mixed with ellipsoidal heights is offset by tens of metres โ about 43 m in the Netherlands.