Benchmark
The merge tolerance that decides whether an STL reads as watertight
Why do two tools disagree about whether the same STL is watertight?
Result
STL stores every triangle as three independent vertices, so a 2,562 vertex icosphere is written out as 15,360 loose vertices forming 5,120 separate shells. Reading it back means guessing which of those coincide. When the coordinates are bit-identical, exact matching recovers all 2,562 and the mesh is watertight. Add vertex noise of 1e-06 mm and exact matching recovers almost nothing: it takes a merge tolerance of 0.1 mm, about 2.9 percent of the 3.459 mm shortest edge, before the mesh closes again. Two tools reporting differently on one file are usually not disagreeing about the file. They are using different tolerances.
The same sphere before and after an STL round trip
| Property | Original mesh | STL, read with no merging |
|---|---|---|
| Vertices | 2,562 | 15,360 |
| Faces | 5,120 | 5,120 |
| Boundary edges | 0 | 15,360 |
| Non-manifold edges | 0 | 0 |
| Shells | 1 | 5,120 |
| Watertight | Yes | No |
| Volume (cubic mm) | 522,467.37 | undefined |
Nothing was damaged in transit. The face count is identical and every coordinate is intact. What the format dropped is the knowledge that three triangles were sharing one corner, and without it the mesh has no interior and therefore no volume.
Merge tolerance sweep, clean coordinates
| Tolerance (mm) | Vertices | Boundary edges | Shells | Watertight | Matches original |
|---|---|---|---|---|---|
| 0 | 2,562 | 0 | 1 | Yes | Yes |
| 1 x 10^-7 | 2,562 | 0 | 1 | Yes | Yes |
| 1 x 10^-5 | 2,562 | 0 | 1 | Yes | Yes |
| 1 x 10^-4 | 2,562 | 0 | 1 | Yes | Yes |
| 0.001 | 2,562 | 0 | 1 | Yes | Yes |
| 0.01 | 2,562 | 0 | 1 | Yes | Yes |
| 0.1 | 2,562 | 0 | 1 | Yes | Yes |
| 1 | 2,562 | 0 | 1 | Yes | Yes |
With coordinates that survived export unchanged, every tolerance including exact matching gets the original mesh back. This is the case that makes vertex welding look like a solved problem.
Merge tolerance sweep, vertex jitter of 1e-06 mm
| Tolerance (mm) | Vertices | Boundary edges | Shells | Watertight | Matches original |
|---|---|---|---|---|---|
| 0 | 15,360 | 15,360 | 5,120 | No | No |
| 1 x 10^-7 | 15,357 | 15,360 | 5,120 | No | No |
| 1 x 10^-5 | 4,289 | 7,068 | 1,201 | No | No |
| 1 x 10^-4 | 2,822 | 1,362 | 14 | No | No |
| 0.001 | 2,596 | 174 | 3 | No | No |
| 0.01 | 2,582 | 104 | 1 | No | No |
| 0.1 | 2,562 | 0 | 1 | Yes | Yes |
| 1 | 2,562 | 0 | 1 | Yes | Yes |
A millionth of a millimetre is far below anything that could matter physically, and it is enough to break exact matching completely: 15,360 vertices and 5,120 shells at zero tolerance. The mesh does not close until 0.1 mm, four orders of magnitude above the error being corrected.
Merge tolerance sweep, vertex jitter of 0.01 mm
| Tolerance (mm) | Vertices | Boundary edges | Shells | Watertight | Matches original |
|---|---|---|---|---|---|
| 0 | 15,360 | 15,360 | 5,120 | No | No |
| 1 x 10^-7 | 15,360 | 15,360 | 5,120 | No | No |
| 1 x 10^-5 | 15,360 | 15,360 | 5,120 | No | No |
| 1 x 10^-4 | 15,360 | 15,360 | 5,120 | No | No |
| 0.001 | 15,359 | 15,360 | 5,120 | No | No |
| 0.01 | 14,637 | 15,354 | 5,117 | No | No |
| 0.1 | 4,537 | 7,746 | 1,472 | No | No |
| 1 | 2,669 | 592 | 5 | No | No |
At this noise level no tolerance in the sweep recovers the original. Even 1 mm, a third of the shortest edge, leaves 592 boundary edges and 5 shells. Past a certain scatter the answer is not a better tolerance.
The same sphere written to indexed formats
| Format | Vertices | Boundary edges | Shells | Watertight | Volume (cubic mm) |
|---|---|---|---|---|---|
| PLY | 2,562 | 0 | 1 | Yes | 522,467.37 |
| OBJ | 2,562 | 0 | 1 | Yes | 522,467.37 |
| GLB | 2,562 | 0 | 1 | Yes | 522,467.37 |
PLY, OBJ and GLB store a vertex list and index into it, so the sharing is written down rather than inferred. All three come back at 2,562 vertices and one watertight shell with no tolerance involved anywhere.
Why the tolerance has to be read against the model, not in absolute terms
A tolerance of 0.1 mm is 2.9 percent of this model's shortest edge, which is why it closes the mesh without destroying detail. The same 0.1 mm applied to a model whose shortest edge is 0.05 mm would weld genuinely distinct corners together. This is why a tolerance that works well on one file collapses another, and why a tool cannot simply pick a good default and be done.
How this was measured
An icosphere, 4 subdivisions, scaled to 100 mm across: 2,562 vertices, 5,120 faces, watertight, volume 522,467.37 cubic mm, shortest edge 3.459 mm. It was exported to STL and read back at eight merge tolerances from exact to 1 mm, under four levels of vertex jitter, and also written to PLY, OBJ and GLB for comparison.
- Software and versions
- trimesh 5.1.0, numpy 2.5.2
- Date measured
- 2026-09-08
- Script
scripts/measure-weld-tolerance.py- Raw data
- weld-tolerance-2026-09-08.json
This data is published under CC BY 4.0. Reuse the numbers freely, with a link back so a reader can check the method.
What this does not show
Stated so the result is not read wider than it goes.
- what any named repair tool reports, since that needs those tools
- Jitter here is synthetic and uniform. Real coordinate error from an exporter is neither, and tends to cluster where the exporter did arithmetic.
- The sweep stops at 1 mm. Larger tolerances would eventually weld the model into something smaller than itself, which is a different failure worth its own measurement.