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.

One symptom, three faults, three repairs Three reasons a mesh reports as not watertight, the signature that distinguishes each, and the repair it needs. They call for different actions and the order matters: filling holes before merging closes gaps that a merge would have removed, adding geometry the source never lacked. Fault Signature Repair Duplicate vertices every edge is a boundary edge merge at a tolerance Inconsistent winding closed but volume is negative fix normals Missing faces merging changes nothing fill — or reject Merge, orient, then fill. The other orders invent geometry.

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"])
The window a merge tolerance has to sit in Three lengths on the same scale for a typical CAD export in metres. The numerical noise left by the authoring application sets the floor, and the shortest edge that must survive sets the ceiling. Any tolerance between them merges duplicates without collapsing real geometry, which is a wide and comfortable window once the two bounds are known. numerical noise (floor) 0 m typical tolerance 0 m shortest real edge (ceiling) 0.001 m 0 0.001 Below the floor nothing merges; above the ceiling real edges collapse into degenerate faces.

Key implementation notes:

  • process=False on 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.

The guard between a repair and a fabrication A branch on how much surface area hole filling added. A small addition closes the gaps a merge could not, which is repair. A large one means the source was missing whole faces, and closing it produces a confident invention — a watertight solid whose underside was never modelled. The threshold turns that distinction into a rule. Area added by hole filling? Repair accept under 2% Fabrication reject, fix the source over 2%

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.