How to calculate building volumes and floor area in Python

On this page

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 119.5 mΒ³ and a total of 683,065 mΒ³, with 99.7% of the meshes watertight. The five that were not returned numbers that look fine and mean nothing.

A prism to the eaves, a prism to the ridge and the true solid. prism to eaveslower boundprism to ridgeupper boundthe solidmedian 119.5 mΒ³683,065 mΒ³ across 1,990 solids in the reference tile spatialworkflow.io
Three defensible answers for one building; the difference is the roof void.

What you get β€” from The data used on this page

1990 solids

Check watertightness, then solid or prism volume

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.2 m against the eaves (1.15 m on the Hague sample in the heights guide). 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.

Choosing a volume method from what the geometry supports. which volume?Do you have a watertight solid for thisbuilding?yesabs(mesh.volume)exact for the modelno, but a heightprismsay which heightmesh leaksrepair, then retrymerge vertices firstno heightdo not guessrecord it as unknownAn open mesh returns a number; it is not a bound spatialworkflow.io
The check is the branch: no watertight solid means no solid volume, not a wrong one.

Solid volumes against prism estimates

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), saved as model.city.json. d is the parsed file, V its vertices with the transform applied, and b one row per solid: its ground-surface footprint and the eaves and ridge heights above ground that the city recorded for it.

import json
import os

import geopandas as gpd
import numpy as np
import requests
from shapely.geometry import Polygon
from shapely.ops import unary_union

if not os.path.exists("model.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("model.city.json", "wb").write(reply.content)
d = json.load(open("model.city.json"))
V = np.array(d["vertices"]) * d["transform"]["scale"] + d["transform"]["translate"]

rows_b = []
for oid, o in d["CityObjects"].items():
    a = o.get("attributes", {})
    for g in o.get("geometry", []):
        if g["type"] != "Solid" or "RelativeEavesHeight" not in a:
            continue
        sem = g["semantics"]
        ground = [Polygon(V[surface[0]][:, :2])
                  for shell, values in zip(g["boundaries"], sem["values"])
                  for surface, v in zip(shell, values)
                  if v is not None and sem["surfaces"][v]["type"] == "GroundSurface"]
        rows_b.append({"id": oid, "roof_type": a.get("roofType"), "height_eaves_m": a["RelativeEavesHeight"],
                       "height_ridge_m": a["RelativeRidgeHeight"], "geometry": unary_union(ground)})
b = gpd.GeoDataFrame(rows_b, crs=28992)
print(len(b), "solids")
1990 solids

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 119.5, max 17,098.9, total 683,065 mΒ³

Example 2 β€” prism volumes, and how far they are from the solid

import geopandas as gpd, numpy as np, pandas as pd

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}")
prism_eaves_m3   median ratio to solid 1.000, p10 0.765, p90 1.000
prism_ridge_m3   median ratio to solid 1.000, p10 1.000, p90 1.252

On the Hague tile both medians are 1.000, because most solids are flat-roofed, but the spread is in the tails: a tenth of the eaves prisms are at least 23.5% short of the solid, and a tenth of the ridge prisms at least 25.2% over. Run it on your own model before choosing a height. If the median ratio for the eaves prism is close to 1, much of your stock is flat-roofed; if it is 0.85, the choice matters for every building.

Example 3 β€” floor area with the conventions made explicit

import numpy as np, pandas as pd

def gross_floor_area(b, storey_height=3.20, external=True, count_basements=False,
                     wall_thickness_m=0.3):
    """GFA with every convention as an argument, and none of them hidden.
    storey_height=3.20 is the eaves calibration for this tile from the heights guide."""
    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))
print(f"roof type 1000 (flat): {(b.roof_type == '1000').sum():,} of {len(b):,}")
       storeys  unit_area_m2  gfa_m2
count   1990.0        1990.0  1990.0
mean       1.8          41.2    99.3
std        0.9          68.2   251.7
min        1.0           0.0     0.0
25%        1.0           6.8     8.3
50%        2.0          24.4    34.5
75%        2.0          48.6    95.5
max        8.0        1175.3  5541.5
roof type 1000 (flat): 1,238 of 1,990

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.

Open meshes, eaves and ridge bounds

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.

The conventions a gross floor area calculation has to state. floor area is a conventionstorey height used2.74 m measured, not 3.0 m assumedinternal or external footprintwall thicknessbasements counted or notdouble-height spacesonce or twicecourtyards excludedinterior ringsdefaults left implicitthe usual failureEvery one of these is an argument, not a constant spatialworkflow.io
GFA is defined by a code of measurement, so every choice has to be explicit.

Signs, parts, courtyards and double counting

  • 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.

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.