3D spatial data models explained: 2.5D, meshes and solids
On this page
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.
Start from the question, then 2.5D, mesh or solid
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, read in the CityJSON guide, 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.
One Hague building as 2.5D, as a solid and compared
The data used on this page
The LoD2 tile of The Hague's 3D city model published as a CityJSON sample dataset (DenHaag_01.city.json, 1,990 solids). The setup parses it (d, and V with the transform applied), fan-triangulates every solid into a trimesh mesh (meshes, with their CityJSON ids in ids), builds footprints from each solid's ground surface with its recorded eaves height, and sets geometry to the first solid's geometry. buildings.gpkg is the OpenStreetMap buildings inside the model that carry building:levels, fetched through the maps.mail.ru Overpass mirror and read as it answered on 9 October 2026.
import io
import json
import os
import zipfile
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
import trimesh
from shapely.geometry import Polygon
from shapely.ops import unary_union
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-three-d-data-models-explained-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
if not os.path.exists("DenHaag_01.city.json"):
reply = requests.get("https://3d.bk.tudelft.nl/opendata/cityjson/3dcities/v2.0/DenHaag_01.city.json", timeout=300)
reply.raise_for_status()
open("DenHaag_01.city.json", "wb").write(reply.content)
d = json.load(open("DenHaag_01.city.json"))
V = np.array(d["vertices"]) * d["transform"]["scale"] + d["transform"]["translate"]
vertices = V
meshes, ids, rows, geometry = [], [], [], None
for oid, o in d["CityObjects"].items():
for g in o.get("geometry", []):
if g["type"] != "Solid":
continue
geometry = geometry or g
tris = [[ring[0], ring[i], ring[i + 1]]
for shell in g["boundaries"] for surface in shell
for ring in surface[:1] for i in range(1, len(ring) - 1)]
m = trimesh.Trimesh(vertices=V, faces=np.array(tris), process=True)
m.remove_unreferenced_vertices()
meshes.append(m)
ids.append(oid)
sem = g["semantics"]
ground = [Polygon(V[s[0]][:, :2]) for shell, vals in zip(g["boundaries"], sem["values"])
for s, v in zip(shell, vals) if v is not None and sem["surfaces"][v]["type"] == "GroundSurface"]
rows.append({"id": oid, "height_eaves_m": o["attributes"]["RelativeEavesHeight"],
"geometry": unary_union(ground)})
footprints = gpd.GeoDataFrame(rows, crs=28992).set_index("id")
mesh = next(m for m in meshes if not m.is_watertight).copy()
import osmnx as ox
if not os.path.exists("buildings.gpkg"): # OSM buildings with a level count, inside the model
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
cover = footprints.union_all()
osm = ox.features_from_polygon(gpd.GeoSeries([cover.convex_hull], crs=28992).to_crs(4326).iloc[0],
{"building": True})
osm = osm[osm.geom_type.isin(["Polygon", "MultiPolygon"])].to_crs(28992)
osm = osm[osm.representative_point().within(cover)]
osm = osm.assign(levels=pd.to_numeric(osm.get("building:levels"), errors="coerce")).dropna(subset=["levels"])
osm[["levels", "geometry"]].reset_index(drop=True).to_file("buildings.gpkg")
print(f"{len(meshes):,} meshes, {sum(m.is_watertight for m in meshes):,} watertight")
1,990 meshes, 1,985 watertight
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))
footprint_m2 height_m prism_volume_m3
count 217.0 217.0 217.0
mean 81.6 6.3 644.3
std 97.1 2.1 1938.6
min 39.2 3.0 120.9
25% 48.2 6.0 265.3
50% 62.2 6.0 321.6
75% 78.4 6.0 594.8
max 1342.5 21.0 28191.8
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))
all_vols = np.array([abs(m.volume) for m in meshes])
print(f"all {len(meshes):,} solids: median {np.median(all_vols):.1f} mΒ³, total {all_vols.sum():,.0f} mΒ³")
True 4.4 23.2
all 1,990 solids: median 118.4 mΒ³, total 683,115 mΒ³
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
# every watertight solid whose ridge is at least 1 m above its eaves
ridge = {oid: o["attributes"]["RelativeRidgeHeight"] for oid, o in d["CityObjects"].items()
if "RelativeRidgeHeight" in o.get("attributes", {})}
pitched = [(m, i) for m, i in zip(meshes, ids)
if m.is_watertight and ridge[i] - footprints.loc[i, "height_eaves_m"] >= 1]
footprint_area = np.array([footprints.loc[i].geometry.area for _, i in pitched])
height_to_ridge = np.array([ridge[i] for _, i in pitched])
prism = (footprint_area * height_to_ridge).sum()
real = sum(abs(m.volume) for m, _ in pitched)
roof_void = real - (footprint_area * footprints.loc[[i for _, i in pitched], "height_eaves_m"].values).sum()
print(f"{len(pitched):,} pitched solids: ", end="")
print(f"prism {prism:,.0f} mΒ³, solid {real:,.0f} mΒ³, "
f"prism overstates by {prism/real - 1:.1%}")
print(f"the ridge prism's excess is {(prism - real) / roof_void:.2f} times the roof volume above the eaves")
657 pitched solids: prism 345,339 mΒ³, solid 285,432 mΒ³, prism overstates by 21.0%
the ridge prism's excess is 1.07 times the roof volume above the eaves
For a flat-roofed building the two agree. For a pitched roof, extruding to the ridge fills the space beside the roof slopes, which is about as large as the roof itself: across the tile's 657 pitched solids the excess was 1.07 times the roof volume above the eaves, and the ridge prisms overstated the solids by 21.0%. That is why the height you extrude to matters more than the model you choose.
Why 2.5D is the default, and semantics the real value
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.
Overhangs, building parts, axes and point clouds
- 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.