How to calculate building volumes and floor area in Python
Problem statement
Volume and floor area look like one calculation and are three. A prism volume from a footprint and a height is a lower or upper bound depending on which height you used. A solid's volume is exact for the geometry you have and wrong if the mesh leaks. Gross floor area is neither โ it is a count of storeys times a footprint, with a set of conventions about what counts.
The numbers matter because they feed everything downstream: embodied carbon, heating demand, valuation, waste estimates. Being 10% out because you extruded to the ridge rather than the eaves is a real error that nobody sees, because both answers look plausible.
This guide computes all three on a real LoD2 model, and shows how far apart they are.
Quick answer
import numpy as np, trimesh
# solid volume, from the model geometry
mesh = trimesh.Trimesh(vertices=V, faces=faces, process=True)
assert mesh.is_watertight, "volume is meaningless for an open mesh"
volume = abs(mesh.volume)
# prism volume, from a footprint and a height
prism_eaves = footprint_area * height_to_eaves
prism_ridge = footprint_area * height_to_ridge
# gross floor area, from a storey count
gfa = footprint_area * n_storeys
On a real LoD2 tile of central The Hague, 1,990 solids gave a median volume of 118.4 mยณ and a total of 683,115 mยณ, with 99.7% of the meshes watertight. The five that were not returned numbers that look fine and mean nothing.
Step-by-step solution
1. Check watertightness before computing anything
An open mesh has no interior, so mesh.volume returns a signed number computed from the divergence theorem over whatever faces exist. It is not a volume. Assert, do not warn.
2. Compute the solid volume where you have a solid
trimesh does this in one call. Take the absolute value: the sign depends on face winding, which many models get inconsistent.
3. Compute the prism volume where you do not
Footprint area times height. State which height, because the two bracket the truth:
- Prism to the eaves is a lower bound: it omits the roof void.
- Prism to the ridge is an upper bound: it fills the whole roof.
- The solid volume sits between them, near the eaves prism for a shallow roof.
4. Use the roof type to choose
On the Hague tile, 1,238 of 1,990 buildings had roof type 1000 โ flat โ and for those the two prisms and the solid agree. The remaining 752 are where the choice matters.
5. Compute gross floor area separately
GFA is storeys times footprint, and it is a convention, not a measurement. Decide and document whether you count basements, whether you use internal or external footprint area, and what happens to double-height spaces.
6. Propagate the height uncertainty
If the height came from a level count, it carries a mean absolute error of about 1.9 m. Multiplying that by the footprint area gives a volume uncertainty that belongs next to the volume.
7. Sanity-check against something
Total built volume per hectare, mean volume per building, or floor area against a known population โ any external check catches a units error or a missing transform faster than reading the code again.
Code examples
Example 1 โ solid volumes from a CityJSON model
import numpy as np, trimesh
def solid_volumes(city_objects, V):
rows = []
for oid, obj in city_objects.items():
for g in obj.get("geometry", []):
if g["type"] != "Solid":
continue
tris = []
for shell in g["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]])
if not tris:
continue
m = trimesh.Trimesh(vertices=V, faces=np.array(tris), process=True)
m.remove_unreferenced_vertices()
rows.append({"id": oid, "watertight": bool(m.is_watertight),
"volume_m3": abs(m.volume), "surface_area_m2": m.area})
return rows
rows = solid_volumes(d["CityObjects"], V)
vols = np.array([r["volume_m3"] for r in rows if r["watertight"]])
print(f"{len(rows):,} solids, {sum(r['watertight'] for r in rows):,} watertight")
print(f"volume: min {vols.min():.1f}, median {np.median(vols):.1f}, "
f"max {vols.max():,.1f}, total {vols.sum():,.0f} mยณ")
1,990 solids, 1,985 watertight
volume: min 0.1, median 118.4, max 17,098.9, total 683,115 mยณ
Example 2 โ prism volumes, and how far they are from the solid
import geopandas as gpd, numpy as np
b["prism_eaves_m3"] = b.area * b["height_eaves_m"]
b["prism_ridge_m3"] = b.area * b["height_ridge_m"]
cmp = b.merge(pd.DataFrame(rows).set_index("id"), left_on="id", right_index=True)
cmp = cmp[cmp.watertight & (cmp.volume_m3 > 1)]
for col in ("prism_eaves_m3", "prism_ridge_m3"):
ratio = cmp[col] / cmp["volume_m3"]
print(f"{col:16} median ratio to solid {ratio.median():.3f}, "
f"p10 {ratio.quantile(.1):.3f}, p90 {ratio.quantile(.9):.3f}")
Run it on your own model before choosing a height. If the median ratio for the eaves prism is close to 1, your stock is flat-roofed and the choice does not matter; if it is 0.85, it does.
Example 3 โ floor area with the conventions made explicit
import numpy as np, pandas as pd
def gross_floor_area(b, storey_height=2.74, external=True, count_basements=False,
wall_thickness_m=0.3):
"""GFA with every convention as an argument, and none of them hidden."""
storeys = np.maximum(np.round(b["height_eaves_m"] / storey_height), 1)
area = b.area if external else (b.buffer(-wall_thickness_m).area.clip(lower=0))
gfa = area * storeys
if count_basements and "basement_levels" in b:
gfa = gfa + area * b["basement_levels"].fillna(0)
return pd.DataFrame({"storeys": storeys, "unit_area_m2": area, "gfa_m2": gfa})
gfa = gross_floor_area(b)
print(gfa.describe().round(1))
The default storey_height=2.74 is the value implied by a real dataset โ measured eaves height divided by OpenStreetMap level count, median across 509 buildings โ rather than the customary 3.0 m. Using a measured figure makes the storey count and therefore the floor area consistent with the heights you already have.
Explanation
Why mesh.volume on an open mesh is dangerous
Trimesh computes volume by summing signed tetrahedron volumes over the faces. With a closed, consistently oriented surface that sums to the enclosed volume. With a hole, the contributions no longer cancel and you get a number โ plausibly sized, entirely wrong. There is no error, no warning and no way to tell from the value, which is why the assertion has to be explicit.
Why the eaves prism is a lower bound and the ridge prism an upper one
The building is the prism to the eaves plus the roof volume. The prism to the ridge is the prism to the eaves plus the whole box the roof sits in. For a symmetric gable that box is twice the roof volume, so the ridge prism overstates by about the roof volume and the eaves prism understates by the same amount. The solid is in between and closer to the eaves.
Why flat roofs make most of this moot
63.5% of the buildings in the Hague tile had ridge height equal to eaves height โ a flat roof โ and for those the three calculations agree exactly. The choice of height only matters for the remainder, which is why the first thing to compute on a new model is what share of it is flat.
Why gross floor area is not a geometric quantity
GFA is defined by a code of measurement, not by the building. Whether the area is measured to the internal face of the external wall, whether basements count, whether plant rooms count, and how mezzanines are treated are all conventions that differ by jurisdiction and by purpose. The only defensible approach is to make every convention an explicit argument and record the values used.
Edge cases or notes
- Take the absolute value. Winding determines the sign.
- Building parts sum. Group by parent before reporting per building.
- Courtyards are holes. A footprint area that ignores interior rings overstates everything.
- Very small solids are noise. Filter below a threshold and report how many.
- Overlapping parts double-count. Check for intersecting solids before summing.
- Units come from the CRS. A model in feet gives volumes in cubic feet.
- Z exaggeration in viewers is display only. It does not affect the geometry.
- Sanity-check the total. Built volume per hectare is a number you can compare.
Internal links
- A building mesh is not watertight and its volume is wrong โ the failure this guide guards against
- How to extrude building footprints into 3D in Python โ producing the prisms
- How to read a CityJSON city model in Python โ getting the solids
- 3D spatial data models explained: 2.5D, meshes and solids โ which model gives which answer
- Where building heights come from, and how wrong they are โ the uncertainty to propagate
- Level of detail explained for 3D city models โ why LoD2 gives a different number
- How to calculate area and distance in GeoPandas โ the footprint area underneath
- How to estimate rooftop solar potential in Python โ the other roof-geometry calculation
FAQ
How do I calculate a building's volume in Python?
For a solid, build a watertight mesh and take abs(mesh.volume). For a footprint and a height, multiply the footprint area by the height โ and say which height.
Why is my mesh volume wrong?
Almost certainly because the mesh is not watertight. Trimesh returns a number regardless; check is_watertight first.
Should I extrude to the eaves or the ridge for volume?
The eaves prism is a lower bound and the ridge prism an upper one. The true solid sits between them, nearer the eaves.
Does the roof shape matter much?
Only for the buildings that have one. On a real tile, 63.5% of buildings were flat-roofed, and for those all three calculations agree.
What storey height should I use for floor area?
A measured one. Dividing measured eaves heights by level counts on one real dataset gave 2.74 m per storey, not the customary 3.0 m.
Is gross floor area a geometric quantity?
No. It depends on conventions โ internal or external measurement, basements, plant rooms โ so make every convention an explicit argument and record what you used.