Auditing Meshes with Euler Characteristic and Genus
This page audits reconstructed and converted meshes with two integers — the Euler characteristic and the genus — computed per connected component with trimesh, and shows what each value implies about holes, tunnels, duplicated shells and non-manifold joins in building and terrain geometry.
Why you hit this
Most mesh quality checks are thresholds: a distance, an area, an angle. The Euler characteristic is different — it is an integer that cannot drift, so it either matches the shape you expect or the mesh is not the shape you think it is. A closed building shell has χ = 2; a shell with a tunnel through it has χ = 0; a surface with three holes has χ = −1. That makes it the cheapest possible audit for a pipeline that produces thousands of meshes, and it catches the defects that survive every other check: a roof that closed into a torus, two coincident shells counted as one solid, a terrain patch with holes nobody noticed. The topology rules it rests on are in mesh topology basics for digital twins.
Prerequisites
- Python 3.10+ with
trimesh>=4.0,numpy>=1.24,networkx>=3.0(trimesh uses it for graph operations). - Meshes in a metric CRS — EPSG:32633 in the examples — from reconstruction, CityJSON conversion or BIM export.
- An expectation of what each mesh should be: a closed solid, an open height field, or a set of disconnected parts. Without that, the numbers have nothing to be compared against.
The Two Numbers
The Euler characteristic of a mesh is χ = V − E + F: vertices minus edges plus faces. For a closed, connected, orientable surface it depends only on the shape’s topology, not on how finely it is triangulated — decimating a cube from a million triangles to twelve leaves χ unchanged at 2.
The genus follows from it for a closed surface: g = (2 − χ) / 2, the number of “handles” or tunnels. A sphere or a cube has genus 0. A torus, or a building with a through-passage, has genus 1. A block of flats with three archways through it has genus 3.
For a surface with boundary — an open terrain patch, a facade without a base — the relation includes the number of boundary loops: χ = 2 − 2g − b, where b counts the loops. That is what makes the number diagnostic: a terrain patch that should be a disc has χ = 1, and χ = −2 means three unexpected holes.
Step-by-Step
1. Compute the numbers per component
import numpy as np
import trimesh
def topology_report(path, min_faces=8):
mesh = trimesh.load(path, force="mesh", process=False)
mesh.merge_vertices()
mesh.update_faces(mesh.nondegenerate_faces())
mesh.remove_unreferenced_vertices()
rows = []
for i, part in enumerate(mesh.split(only_watertight=False)):
if len(part.faces) < min_faces:
continue
boundary = trimesh.grouping.group_rows(part.edges_sorted, require_count=1)
loops = len(part.outline().entities) if len(boundary) else 0
chi = part.euler_number
closed = part.is_watertight
genus = (2 - chi) // 2 if closed else (2 - chi - loops) // 2
rows.append({
"component": i,
"faces": len(part.faces),
"vertices": len(part.vertices),
"euler": int(chi),
"watertight": bool(closed),
"boundary_loops": loops,
"genus": int(genus),
"volume_m3": round(float(part.volume), 2) if part.is_volume else None,
})
return mesh, rows
mesh, rows = topology_report("meshes/block_07.ply")
for r in rows[:8]:
print(r)
print(f"{len(rows)} components, {len(mesh.faces):,} faces total")
Splitting into components first is essential. The Euler characteristic of a mesh with several disconnected parts is the sum of the parts’ values, so a file containing twelve closed buildings reports χ = 24, which is meaningless as a shape statement and easy to misread as a defect. Per component, each number describes one object.
merge_vertices runs before anything else for the reason given in the parent guide: an unmerged mesh has every edge as a boundary edge, and its Euler characteristic counts a face soup rather than a surface.
2. Compare against expectations
EXPECTED = {
"building": {"watertight": True, "genus": 0, "euler": 2},
"building_with_passage": {"watertight": True, "genus": 1, "euler": 0},
"terrain_patch": {"watertight": False, "genus": 0, "boundary_loops": 1, "euler": 1},
}
def audit(rows, kind, min_volume=20.0):
spec = EXPECTED[kind]
findings = []
for r in rows:
for key, want in spec.items():
got = r.get(key)
if got != want:
findings.append((r["component"], key, want, got))
if spec["watertight"] and r["volume_m3"] is not None and r["volume_m3"] < min_volume:
findings.append((r["component"], "volume_m3", f">{min_volume}", r["volume_m3"]))
return findings
for comp, key, want, got in audit(rows, "building")[:10]:
print(f"component {comp}: {key} expected {want}, got {got}")
The audit is a table lookup. That is the appeal: adding a new mesh kind means adding a row, and the check has no thresholds to tune, no sampling and no tolerance drift.
3. Read what each mismatch means
| Observed | Expected building |
Likely cause |
|---|---|---|
| χ = 2, watertight | — | correct |
| χ = 0, watertight | χ = 2 | a tunnel: a courtyard closed over, or two walls fused |
| χ = −2, watertight | χ = 2 | two tunnels, often an arcade |
| χ = 1, one loop | χ = 2 | open at the base: no ground surface |
| χ = 4, watertight | χ = 2 | two coincident shells counted as one component |
| χ large and odd | χ = 2 | non-manifold edges; the surface is not orientable |
| genus > 0 on terrain | genus 0 | a hole bridged into a tunnel by reconstruction |
The two-shells case is worth dwelling on, because it is the one that silently doubles volumes. A model exported with both an outer and an inner wall surface, coincident or a few centimetres apart, has χ = 4 and passes every watertightness test; its volume is the sum of two enclosed regions and its surface area is double. Only the topology number gives it away cheaply.
4. Locate the defect the number found
An integer says something is wrong; finding where takes one more step.
def locate_handles(part, max_report=5):
"""Candidate tunnels: pairs of nearby faces whose normals oppose across a thin gap."""
if part.euler_number >= 2:
return []
centres = part.triangles_center
normals = part.face_normals
tree = trimesh.proximity.ProximityQuery(part)
hits = []
for i in np.random.default_rng(5).choice(len(centres), min(4000, len(centres)), replace=False):
p = centres[i] - normals[i] * 0.02 # step just inside the surface
d = tree.signed_distance([p])[0]
if d > 0 and d < 0.4: # thin solid: a wall between two voids
hits.append((float(d), centres[i].tolist()))
hits.sort()
return hits[:max_report]
def locate_boundaries(part, max_report=5):
loops = part.outline().entities
out = []
for e in loops[:max_report]:
pts = part.outline().vertices[e.points]
out.append({"length_m": round(float(np.linalg.norm(np.diff(pts, axis=0), axis=1).sum()), 2),
"centre": [round(float(v), 2) for v in pts.mean(axis=0)]})
return sorted(out, key=lambda d: -d["length_m"])
part = mesh.split(only_watertight=False)[0]
print("thin-wall candidates:", locate_handles(part))
print("boundary loops:", locate_boundaries(part))
Reporting the coordinates is what makes the audit actionable. A modeller given “component 7 has genus 1” shrugs; given “component 7 has genus 1, and there is a 12 cm thin wall at 412688, 5335904, 18.4” they can open that spot and see the courtyard that closed over.
5. Gate a pipeline on it
import sys
from pathlib import Path
def gate_meshes(folder, kind="building"):
failures = {}
for p in sorted(Path(folder).glob("*.ply")):
_, rows = topology_report(p)
bad = audit(rows, kind)
if bad:
failures[p.name] = bad
for name, bad in failures.items():
print(f"FAIL {name}: " + "; ".join(f"c{c} {k} {w}≠{g}" for c, k, w, g in bad[:4]))
print(f"{len(failures)} of {len(list(Path(folder).glob('*.ply')))} meshes failed the topology audit")
return 1 if failures else 0
sys.exit(gate_meshes("meshes/block_07_parts"))
Expected Output & Verification
{'component': 0, 'faces': 18402, 'vertices': 9204, 'euler': 2, 'watertight': True, 'boundary_loops': 0, 'genus': 0, 'volume_m3': 14208.42}
{'component': 1, 'faces': 12044, 'vertices': 6026, 'euler': 0, 'watertight': True, 'boundary_loops': 0, 'genus': 1, 'volume_m3': 8104.19}
{'component': 2, 'faces': 9120, 'vertices': 4568, 'euler': 1, 'watertight': False, 'boundary_loops': 1, 'genus': 0, 'volume_m3': None}
12 components, 184,204 faces total
component 1: euler expected 2, got 0
component 1: genus expected 0, got 1
component 2: watertight expected True, got False
component 2: euler expected 2, got 1
thin-wall candidates: [(0.118, [412688.4, 5335904.1, 18.42])]
boundary loops: [{'length_m': 84.6, 'centre': [412701.2, 5335888.4, 8.1]}]
Verify the computation against shapes whose answers are known, which takes four lines and protects the audit from a library change:
import trimesh
CASES = {
"box": (trimesh.creation.box(extents=(4, 6, 3)), 2, 0),
"torus": (trimesh.creation.torus(major_radius=3.0, minor_radius=1.0), 0, 1),
"sphere": (trimesh.creation.icosphere(subdivisions=3), 2, 0),
}
for name, (m, chi, g) in CASES.items():
m.merge_vertices()
got_chi = int(m.euler_number)
got_g = (2 - got_chi) // 2
assert (got_chi, got_g) == (chi, g), f"{name}: expected χ={chi} g={g}, got χ={got_chi} g={got_g}"
print("Euler computation verified on box, torus and sphere")
Then verify invariance, which is the property that makes the number trustworthy: decimate a mesh by 90% and confirm the characteristic is unchanged. If it moves, the decimator has changed the topology — collapsed a tunnel, or opened a hole — which is itself a finding worth having.
Performance Notes
- The computation is linear and trivially cheap: vertices, edges and faces are already known to trimesh, so a city of thousands of meshes audits in seconds.
- Component splitting dominates.
splitbuilds an adjacency graph; on a mesh of millions of faces it takes a few seconds and can be skipped when the file is known to hold one object. - Run the audit before decimation and after, and compare. Two integers per mesh is a cheap regression test for the whole simplification stage.
- Cache per file hash. Topology is deterministic, so a mesh whose bytes have not changed does not need re-auditing.
- Locating defects is the expensive part — proximity queries on a sampled subset — so run it only for components that failed.
Common Errors
Every component reports a huge Euler characteristic. Vertices were not merged, so the “mesh” is a face soup: V and F are both three times the face count and E has no shared edges.
A correct terrain patch reports χ = 1 and the audit expects 2. The expectation is wrong, not the mesh. Open surfaces have χ = 2 − 2g − b; a disc is 1.
Genus comes out negative. The formula was applied to a non-orientable or non-manifold mesh, where the relation does not hold. Repair non-manifold edges first, as in fixing non-manifold edges in 3D meshes.
The number is right and the mesh is still wrong. Topology says nothing about geometry: a building with its roof at ground level and its floor in the air has χ = 2. The audit is one check among several, not a substitute for the geometric ones.
Frequently Asked Questions
Is a genus above zero ever correct?
Yes. Buildings with archways, bridges, and terrain with natural arches all have genuine handles. That is exactly why the audit compares against an expectation per mesh kind rather than demanding genus 0 everywhere.
Does this catch self-intersections?
No. A self-intersecting surface can be perfectly closed and have χ = 2. Self-intersections need the separate check in detecting and repairing self-intersecting geometry.
Should an audit fail the build, or warn?
Fail for meshes with a declared expectation — building shells that must be solids for volume work. Warn for reconstructed surfaces whose topology is inherently uncertain, and record the distribution so a change in it is visible.
Related Guides
- Fixing Non-Manifold Edges in 3D Meshes — the defect that invalidates the formula
- Making Meshes Watertight for Volume Calculations — what a valid χ = 2 lets you compute
- Welding Vertices and Removing Duplicate Faces — the cleanup that has to come first