A building mesh is not watertight and its volume is wrong

Problem statement

mesh.volume returns a number. It always returns a number โ€” the calculation is a sum over faces, and it does not require the faces to enclose anything. On an open mesh the result is plausible, wrong, and accompanied by no warning at all.

On a real LoD2 tile, 1,985 of 1,990 solids triangulated to watertight meshes and five did not. Those five reported volumes like everything else. The total across the tile was 683,115 mยณ with a median of 118.4 mยณ, and it would have been very slightly wrong if the five had been included without checking.

This guide diagnoses why a mesh leaks and fixes the causes that are fixable.

Quick answer

Assert, then diagnose:

import trimesh

assert mesh.is_watertight, (
    f"not watertight: {len(mesh.faces)} faces, "
    f"{len(trimesh.repair.broken_faces(mesh))} broken, "
    f"Euler number {mesh.euler_number}")

print(f"volume {abs(mesh.volume):,.1f} mยณ, area {mesh.area:,.1f} mยฒ")

euler_number is the quickest diagnostic. A closed surface of genus 0 โ€” a building without a hole through it โ€” has Euler number 2. Anything else means a hole, a duplicated shell or a non-manifold edge.

Triage of five reasons a building mesh is not watertight and the fix for each.
Five causes; two are repairable automatically and three need the geometry fixed.

Step-by-step solution

1. Find out what kind of broken it is

import trimesh

def diagnose(mesh):
    return {
        "watertight": bool(mesh.is_watertight),
        "winding_consistent": bool(mesh.is_winding_consistent),
        "volume_sign": float(mesh.volume),
        "euler_number": int(mesh.euler_number),
        "faces": len(mesh.faces),
        "broken_faces": len(trimesh.repair.broken_faces(mesh)),
        "duplicate_faces": len(mesh.faces) - len(trimesh.grouping.unique_rows(
            np.sort(mesh.faces, axis=1))[0]),
        "unreferenced_vertices": len(mesh.vertices) - len(np.unique(mesh.faces)),
    }

2. Try the automatic repairs first

Two problems fix themselves: inconsistent winding and duplicated or degenerate faces.

import trimesh
mesh.remove_duplicate_faces()
mesh.remove_degenerate_faces()
mesh.remove_unreferenced_vertices()
trimesh.repair.fix_normals(mesh)
trimesh.repair.fix_winding(mesh)
trimesh.repair.fill_holes(mesh)

fill_holes only closes simple boundary loops. It will not invent a roof.

3. Check the triangulation if the mesh came from a city model

Fan triangulation โ€” splitting a ring from its first vertex โ€” is correct for convex rings and wrong for concave ones, where some triangles fall outside the polygon. That is exactly what produced the five failures in the reference tile. Triangulating each surface in its own plane with an ear-clipping algorithm fixes it.

import numpy as np, mapbox_earcut, shapely

def triangulate_planar(points):
    """Ear-clip a planar 3D ring after projecting to its own plane."""
    n = np.zeros(3)
    for i in range(len(points)):
        n += np.cross(points[i], points[(i + 1) % len(points)])
    n /= np.linalg.norm(n)
    u = np.cross(n, [0, 0, 1] if abs(n[2]) < 0.9 else [1, 0, 0])
    u /= np.linalg.norm(u)
    v = np.cross(n, u)
    flat = np.c_[points @ u, points @ v]
    idx = mapbox_earcut.triangulate_float64(flat, np.array([len(flat)]))
    return np.asarray(idx).reshape(-1, 3)

4. Check for a missing cap

An extrusion built by hand from wall quads has no top or bottom. mesh.euler_number will be 0 rather than 2, and fill_holes usually closes it if the boundary is a simple loop.

5. Check for coincident but unmerged vertices

Two faces that share an edge geometrically but use different vertex indices leave a crack. mesh.merge_vertices() closes it; process=True on construction does the same.

6. Check for a non-manifold edge

An edge used by more than two faces โ€” where two building parts share a wall, or a mesh has an internal partition โ€” is not repairable by welding. It needs the duplicate geometry removed.

7. If it cannot be repaired, report it rather than measuring it

A volume from an open mesh is not a conservative estimate or an approximation. Exclude the mesh, count the exclusions, and fall back to a prism volume where you have a footprint and a height.

Scene showing a closed prism with Euler number two, an open-topped one, and a mesh with an unmerged crack.
The Euler number distinguishes the cases before you look at the geometry.

Code examples

Example 1 โ€” audit a whole model

import numpy as np, trimesh, pandas as pd

def audit_meshes(meshes, ids):
    rows = []
    for m, i in zip(meshes, ids):
        rows.append({
            "id": i,
            "watertight": bool(m.is_watertight),
            "winding_ok": bool(m.is_winding_consistent),
            "euler": int(m.euler_number),
            "faces": len(m.faces),
            "volume_m3": float(abs(m.volume)),
        })
    df = pd.DataFrame(rows)
    print(f"{len(df):,} meshes, {df.watertight.sum():,} watertight "
          f"({df.watertight.mean():.1%})")
    print(df[~df.watertight][["id", "euler", "faces"]].head(10).to_string(index=False))
    return df

audit = audit_meshes(meshes, ids)
1,990 meshes, 1,985 watertight (99.7%)

Example 2 โ€” repair, then re-check

import trimesh

def repair(mesh):
    m = mesh.copy()
    m.merge_vertices()
    m.update_faces(m.nondegenerate_faces())
    m.update_faces(m.unique_faces())
    m.remove_unreferenced_vertices()
    trimesh.repair.fix_winding(m)
    trimesh.repair.fix_inversion(m)
    if not m.is_watertight:
        trimesh.repair.fill_holes(m)
    return m

fixed = [repair(m) for m in meshes]
before = sum(m.is_watertight for m in meshes)
after = sum(m.is_watertight for m in fixed)
print(f"watertight before {before:,}, after {after:,}")

Report both numbers. A repair that fixes three of five is a result; a repair that silently changes the geometry of the 1,985 that were already fine is a problem.

Example 3 โ€” fall back to a prism, with the fallback recorded

import numpy as np, pandas as pd

def volumes_with_fallback(meshes, ids, footprints):
    rows = []
    for m, i in zip(meshes, ids):
        if m.is_watertight:
            rows.append({"id": i, "volume_m3": abs(m.volume), "method": "solid"})
        else:
            f = footprints.loc[i]
            rows.append({"id": i, "volume_m3": f.geometry.area * f.height_eaves_m,
                         "method": "prism to eaves (mesh not watertight)"})
    df = pd.DataFrame(rows)
    print(df.method.value_counts().to_string())
    return df

The method column is what stops the fallback from being invisible in the totals.

Explanation

Why an open mesh still returns a volume

Trimesh computes volume as a sum of signed tetrahedron volumes formed by each triangle and the origin. For a closed, consistently oriented surface, the contributions outside the solid cancel exactly and the sum is the enclosed volume. With a hole they do not cancel, and the sum is some number related to the faces that happen to be present. It is not an underestimate, an approximation or a bound โ€” it is arithmetic on an incomplete surface.

Why fan triangulation produces the failures

Splitting a polygon into triangles from a single vertex is only valid when every triangle lies inside the polygon, which is guaranteed for a convex ring and false for a concave one. A concave roof face โ€” an L-shaped plane, or one with a notch โ€” produces triangles that stick out, overlapping their neighbours. The mesh looks right in a viewer and fails the manifold test, which is exactly the five-in-1,990 pattern observed.

Why merging vertices is usually the first fix

Meshes assembled from independently triangulated faces often have coincident vertices with different indices, so adjacent faces do not share an edge. Every such pair is a crack. merge_vertices welds them within a tolerance, and it is by far the commonest single-line fix.

Why a non-manifold edge is a modelling problem, not a mesh problem

An edge shared by three or more faces means the geometry describes something that is not a surface โ€” typically two building parts that share a wall, each modelled as a complete solid. The fix is in the model: either merge the parts into one solid, or keep them separate and do not concatenate them before measuring.

Vertical steps from asserting watertightness, through automatic repair and exclusion, to a recorded prism fallback.
The method column is what stops the fallback from vanishing into the totals.

Edge cases or notes

  • euler_number 2 is closed and genus 0. An arcade through a building gives 0.
  • volume sign follows winding. Take the absolute value after checking consistency.
  • process=True merges vertices on construction and is usually what you want.
  • fill_holes only closes simple loops. It cannot rebuild a missing roof.
  • Concatenating meshes creates non-manifold edges where they touch.
  • Tolerances matter. Vertices a micrometre apart are coincident for a building.
  • Report the failures. Five of 1,990 is a quality figure worth publishing.
  • Prism fallback needs a height definition. Record which one.

FAQ

Why does my mesh volume look wrong?

Because the mesh is probably not watertight. Trimesh returns a number regardless; check is_watertight before using it.

What does the Euler number tell me?

A closed surface with no holes through it has Euler number 2. Anything else indicates a boundary, a duplicate shell or a non-manifold edge.

How do I repair a mesh?

Merge vertices, remove degenerate and duplicate faces, fix winding, then fill simple holes. Re-check afterwards and report how many were fixed.

Why do some CityJSON solids fail?

Usually fan triangulation of concave surfaces. Triangulate each face in its own plane with an ear-clipping algorithm instead.

Can I just take the absolute value and carry on?

No. The sign is a winding problem; the magnitude is wrong for an open mesh whatever the sign.

What should I do with a mesh I cannot repair?

Exclude it from solid volumes, fall back to a prism from its footprint and height, and record which method each building used.