Repairing Non-Manifold Meshes with trimesh
A CAD-derived mesh is usually broken in one of three specific ways — duplicate vertices leaving hairline gaps, inconsistent face winding, or genuinely missing faces — and the repair differs for each. Diagnose first, merge at a tolerance you can justify, fix winding before checking volume, and fill holes last and sparingly. This page belongs to Choosing a Geometry Engine for Python Pipelines.
Three Different Faults With One Symptom
is_watertight returning False is the symptom, and it has distinct causes that call for different repairs.
Duplicate vertices. Every face carries its own copy of each corner, and copies that differ in the last few decimal places do not merge. The mesh has no gaps in any visual sense, and every edge is a boundary edge because no two faces share a vertex index. This is the dominant fault in anything derived from a face-soup format such as 3DFACE, and merging fixes it completely.
Inconsistent winding. Faces share vertices correctly but disagree about which side is out. The mesh may be topologically closed and still report a negative or nonsensical volume, and any operation depending on inside-versus-outside is unreliable. This is a normals fix, not a topology fix.
Missing faces. The source genuinely does not contain part of the surface. No amount of merging closes it, and a hole filler will close it by inventing geometry — which is sometimes right and always worth knowing about.
Doing them in the wrong order wastes effort: filling holes before merging fills gaps that were never really there, adding faces that a merge would have made unnecessary.
Production-Ready Script
# trimesh>=4.0, numpy>=1.24, Python 3.9+
from __future__ import annotations
from dataclasses import dataclass, asdict
import numpy as np
import trimesh
@dataclass(frozen=True)
class MeshDiagnosis:
vertices: int
faces: int
watertight: bool
winding_consistent: bool
boundary_edges: int
volume: float
@property
def duplicate_vertex_suspected(self) -> bool:
# Every edge a boundary edge is the signature of an unmerged face soup.
return self.boundary_edges > 0 and not self.watertight
def diagnose(mesh: trimesh.Trimesh) -> MeshDiagnosis:
edges = mesh.edges_sorted
_, counts = np.unique(edges, axis=0, return_counts=True)
return MeshDiagnosis(
vertices=len(mesh.vertices),
faces=len(mesh.faces),
watertight=bool(mesh.is_watertight),
winding_consistent=bool(mesh.is_winding_consistent),
boundary_edges=int((counts == 1).sum()),
volume=float(mesh.volume) if mesh.is_watertight else float("nan"),
)
def repair(
mesh: trimesh.Trimesh,
*,
merge_tol: float = 1e-6,
max_filled_area_ratio: float = 0.02,
) -> tuple[trimesh.Trimesh, dict]:
"""Merge, orient, then fill — in that order — and report what each stage did."""
before = diagnose(mesh)
work = mesh.copy()
work.merge_vertices(merge_tex=False, merge_norm=False, digits_vertex=None)
trimesh.constants.tol.merge = merge_tol
work.remove_duplicate_faces()
work.remove_degenerate_faces()
after_merge = diagnose(work)
work.fix_normals() # consistent winding, outward orientation
after_orient = diagnose(work)
area_before = float(work.area)
if not work.is_watertight:
trimesh.repair.fill_holes(work)
filled_ratio = (float(work.area) - area_before) / area_before if area_before else 0.0
if filled_ratio > max_filled_area_ratio:
raise ValueError(
f"hole filling added {filled_ratio:.1%} of the surface area — "
"the source is missing faces, not merely unmerged"
)
final = diagnose(work)
if not final.watertight:
raise ValueError("mesh is still not watertight after merge, orient and fill")
if final.volume <= 0:
raise ValueError(f"volume is {final.volume:.6g} — normals are still inverted")
return work, {
"before": asdict(before),
"after_merge": asdict(after_merge),
"after_orient": asdict(after_orient),
"final": asdict(final),
"merge_tol": merge_tol,
"filled_area_ratio": filled_ratio,
}
if __name__ == "__main__":
mesh = trimesh.load("model.obj", process=False) # process=False: diagnose the ORIGINAL
fixed, report = repair(mesh)
print(report["before"]["boundary_edges"], "->", report["final"]["boundary_edges"])
Key implementation notes:
process=Falseon load is important when diagnosing. The default processing merges vertices on import, which repairs the fault before you can measure it.- The stages run in order — merge, orient, fill — and each is diagnosed, so the report says which stage actually fixed the mesh.
- The filled-area ratio is a guard against confident fabrication. A mesh that needs 20% of its area invented was not broken, it was incomplete.
- Volume is only meaningful once the mesh is watertight and oriented, which is why it is checked last.
- The merge tolerance is a parameter and it is recorded in the report. A repair whose tolerance is unknown is not reproducible.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
trimesh |
>=4.0 |
repair.fill_holes, fix_normals |
numpy |
>=1.24 |
edge counting |
| Input formats | OBJ, STL, PLY, glTF | load with process=False to diagnose faithfully |
| Boolean backend | optional | not required by this repair |
| Coordinate range | shifted to a local origin | large coordinates degrade the merge |
Fallback Strategies
1. Still not watertight after merging. Either the tolerance is too tight for the source noise, or faces are genuinely missing. Raise the tolerance by an order of magnitude once; if the boundary edge count barely moves, it is missing faces.
2. Merging collapsed real edges. The tolerance exceeded the shortest genuine edge, producing degenerate faces. Reduce it and re-run from the original mesh rather than from the damaged one.
3. Volume is negative after fix_normals. The mesh has separate components with opposing orientation — common when several solids were exported into one file. Split into connected components, repair each, and recombine.
4. Fill added far too much area. Reject, as the script does. Go back to the source: a solid exported without its underside is an export configuration problem, not a mesh problem.
5. Repair succeeds but downstream still complains. Tolerance is per engine and the engines do not agree. Re-validate on the receiving side rather than trusting this verdict, as the parent page describes.
FAQ
What merge tolerance should I use?
Larger than the numerical noise in the source and smaller than the shortest edge you need to keep. A CAD export in metres typically has noise around 1e-9 and edges no shorter than a millimetre, which leaves a wide safe range — 1e-6 to 1e-5 metres is usual. Too tight leaves duplicate vertices and the mesh never closes; too loose collapses short edges into degenerate faces.
Why is my volume negative?
Inverted normals. Volume is computed from the signed contributions of the faces, so a consistently inward-facing mesh reports the negative of its true volume. Fix the winding and orient the normals outward first; a negative volume is a normals diagnosis rather than a geometry one.
When should I give up on a repair?
When filling holes would invent geometry rather than close gaps. A mesh missing whole faces — a solid exported without its underside, say — can be made watertight by a hole filler, and the result is a confident fabrication. Set a limit on the total area filled relative to the surface area, and reject beyond it.
Related Pages
- Choosing a Geometry Engine for Python Pipelines — parent reference on which engine answers which question
- Converting 3DFACE Entities to OBJ Meshes — the welding problem this repair continues
- Geometry Mesh Conversion — the stage-boundary assertions a repaired mesh must satisfy