Benchmark

Where a watertight model stops being watertight when it changes format

Why does a watertight model become non-watertight after a format conversion?

Measured 2026-09-14

Result

The conversion does not break the model. Every format in this test stored exactly the vertices it was handed, and the file that came out contained no less than the file that went in. What differs is what the program reading it is allowed to do. Handed a sphere whose triangles each carry their own corners, at coordinates that are still bit identical where the triangles meet, STL, OBJ, PLY and 3MF all come back as one closed shell of 642 vertices, because their readers rebuild the topology by merging vertices that sit at the same point. GLB comes back as 3840 vertices in 1280 separate shells, not because anything was lost but because glTF forbids that merge: a glTF vertex is a bundle of position, normal and texture coordinate, so two corners at the same point with different UVs are required to be two vertices. The format that throws away the most information gets the mesh back intact, and the format that preserves it exactly hands on the defect.

A closed sphere whose triangles do not share their corners

FormatStores a vertex indexVertices in the fileVertices after readingSeparate shellsWatertight
STLNo3,8406421Yes
OBJYes3,8406421Yes
PLYYes3,8406421Yes
GLBYes3,8403,8401,280No
3MFYes3,8406421Yes

Read the third and fourth columns against each other. All five files hold the same 3840 vertex records, so no format dropped anything or added anything. Where the fourth column is smaller than the third, the reader rebuilt the topology by merging corners that sit at the same point, and the sphere closed. GLB is the only row where the two columns match, and it is the only row that is not watertight.

The control: the same sphere with its corners shared

FormatVertices in the fileVertices after readingFile size (bytes)Watertight
STL3,84064264,084Yes
OBJ64264242,269Yes
PLY64264224,560Yes
GLB64264223,764Yes
3MF64264213,761Yes

Given a mesh whose corners are already shared, all five formats round trip it closed, GLB included. Nothing here is a defect in any format. The only row that still holds 3840 vertices is STL, which has no way to express sharing and writes every corner out again regardless, which is why its file is the largest of the five while carrying the least.

When corners that should coincide do not quite

Displacement (mm)Below the float32 gapSTLPLYGLBOBJ3MF
0Yes100%100%0%100%100%
1 x 10^-8Yes92.5%92.5%0%5.6%5.6%
1 x 10^-7Yes74.58%74.58%0%0%0%
1 x 10^-6Yes17.76%17.76%0%0%0%
1 x 10^-5No0.03%0.03%0%0%0%
1 x 10^-4No0%0%0%0%0%

The percentage is how many of the duplicate corners the reader managed to resolve back into one. The top row is the bit identical case from the first table. Below it the corners are displaced, and the formats split into two groups that have nothing to do with which is the better format: STL and PLY store positions as single precision and quantise most of the displacement away, while OBJ and 3MF write decimal text and preserve it. Only the first row produces a closed mesh in any format.

The format that keeps the least gets the mesh back

The result is the opposite way round from the usual account of conversion. STL cannot record that two triangles share a corner: the format is a flat list of triangles with three coordinates each and no index at all, so it writes the same corner out as many times as it is used. That total loss of topology is what forces the reader to rebuild it, and rebuilding is what closes the sphere. GLB carries a proper index buffer and reproduces the vertex list exactly as handed over, defect included. Faithfulness is what fails here. A pipeline that converts a generated GLB to STL for printing often finds the printing side reports a closed mesh while the source viewer reported 1280 loose triangles, and nothing was repaired between the two.

glTF is not misbehaving, it is following a rule

The glTF specification describes primitives as corresponding to the data required for GPU draw calls, and requires that all attribute accessors for a given primitive have the same count. That second clause is the binding one. If POSITION, NORMAL and TEXCOORD_0 must all hold the same number of entries, then a vertex is the whole tuple rather than a point in space, and two corners at the same position carrying different texture coordinates or different normals are required to be two separate vertices. Every UV seam and every hard edge in a textured model is a place where that duplication is deliberate. A reader that merged by position would be fusing vertices the format defines as distinct, and would destroy the shading and the texture seams to do it. The exact wording of both sentences is recorded in the source data with the specification it came from.

Partial repair is the worst of the three outcomes

The third table has a row that deserves more attention than the ones around it. At a displacement of one hundredth of a micron, STL and PLY resolve 92.5 per cent of the duplicate corners and OBJ and 3MF resolve 5.6 per cent. None of them produce a closed mesh. A model that arrives with 7.5 per cent of its corners still split looks closed, passes a visual check, renders correctly and reports a plausible volume, and then fails in a slicer for reasons that appear to have nothing to do with the conversion that caused them. The formats did not choose this: it falls out of whether positions are stored as single precision binary or as decimal text, which is not a property anyone selects a format for.

What this means for a model that came out of a generator

Image to 3D generators deliver GLB, because GLB is the format for delivering a textured mesh to a viewer. The measurement above says that a GLB will hand on an unshared vertex list exactly as it received it, and that a watertightness check run on the GLB is therefore a check of the generator's vertex buffer rather than of the shape. The same mesh routed through STL will usually report as closed. Neither reading is wrong and they are not measuring the same thing, which is worth knowing before concluding that a conversion step repaired anything.

How this was measured

An icosphere of 642 vertices and 1280 faces, scaled to 100 mm across, closed and manifold by construction. It is then separated so that every triangle carries its own three corners, with the coordinates left bit identical where triangles meet: geometrically closed and topologically open at the same time. This is the state an exporter produces when it writes each face independently, and it is what a great many generated and converted meshes arrive in. The welded sphere runs as a control so any difference is attributable to the vertex sharing rather than to the shape. Each file was written, then opened twice: once as a raw container to count the vertex records the file itself holds, and once through the mesh library to see what a reader makes of them. The GLB POSITION accessor count was parsed out of the binary JSON chunk, the STL triangle count out of its 80 byte header, the OBJ v lines and the PLY vertex element out of their headers, and the 3MF vertex elements out of the model part inside the ZIP.

Software and versions
trimesh 5.1.0, numpy 2.5.2
Date measured
2026-09-14
Script
scripts/measure-watertight-conversion.py
Raw data
watertight-conversion-2026-09-14.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 application does on import. The reader behaviour recorded here is one library's, and the column that matters is the one before it: what the file stores. Blender, Cura, PrusaSlicer, MeshLab and every engine make their own merge decision, and the disagreement between them is the subject of the vertex welding benchmark rather than this one.
  • Whether merging by position is the right thing to do. For a solid destined for a printer it usually is. For a model carrying UV seams or hard edges it destroys information that cannot be recovered, which is why the rendering format does not do it.
  • Meshes that were never closed in the first place. Every case here starts from a sphere that is watertight by construction, so any open edge in the result was introduced on the way through.

Where this is used

Blender , MeshLab , Assimp