A broken GLB from an AI 3D generator is either a container the loader cannot decode, or a triangle mesh that opens and still fails a watertight check. Inspect the file, decompress meshopt if it is there, weld coincident vertices, and repair the fault you measured: non-manifold edges, floaters, normals, or self-intersection.
Below is that triage: a script, then a worked example that reads the same checks on 18 polished showcase GLBs. A textured render does not show the geometry, so even a finished-looking model is worth checking before it becomes a collider, a boolean, or a print.
When a meshopt compressed GLB will not import
A GLB is binary glTF: buffers, accessors, one material, images. Web copies of these assets often store the mesh with EXT_meshopt_compression and KHR_mesh_quantization, and the textures with EXT_texture_webp. A viewer with a meshopt decoder will orbit the model. Blender, Unity, or trimesh without that decoder reports a corrupt file, an empty scene, or a mesh with no maps. That happens before any vertex is wrong.
Inspect the container first:
npx @gltf-transform/cli inspect model.glb
Read the extensions. If EXT_meshopt_compression or KHR_mesh_quantization is present, write a plain GLB with glTF-Transform (its CLI and scripting API read meshopt and quantized files and can write them back without those extensions; check the docs of the version you have installed) and hand the next tool that file. If EXT_texture_webp is present and the destination drops the base color, normal, or metallic-roughness image, repack those images to a format the importer accepts.
Then pick the consumer format. glTF/GLB goes back to the web and to Unity. OBJ is the interchange mesh. STL goes to PrusaSlicer, Cura, or Bambu Studio. USDZ is what an Apple AR Quick Look path expects. Changing the container leaves a non-manifold edge where it was.
Repair a GLB with trimesh: load, weld, branch
Run this only after the loader can see triangles. force="mesh" bakes node transforms into one Trimesh for the check. That copy is the measurement. It is not the textured asset you ship. out.stl is the slicer file. Unity still needs a GLB exported from Blender so the material survives.
import sys
import numpy as np
import trimesh
from trimesh import repair
def edge_counts(mesh):
# one face = boundary edge; three or more = non-manifold edge
_, counts = np.unique(mesh.edges_sorted, axis=0, return_counts=True)
return int((counts == 1).sum()), int((counts > 2).sum())
def triage(path):
mesh = trimesh.load(path, force="mesh")
if not isinstance(mesh, trimesh.Trimesh) or mesh.is_empty:
raise SystemExit("No triangles. Decompress the GLB, then retry.")
mesh.merge_vertices()
repair.fix_normals(mesh)
boundary, nonmanifold = edge_counts(mesh)
# fill_holes closes boundary edges. It leaves non-manifold edges alone.
if boundary > 0 and nonmanifold == 0:
mesh.fill_holes()
parts = mesh.split(only_watertight=False)
areas = np.array([p.area for p in parts]) if parts else np.array([])
total = float(areas.sum()) if len(areas) else 0.0
small = int((areas < 0.01 * total).sum()) if total else 0
print(
f"faces={len(mesh.faces)} watertight={mesh.is_watertight} "
f"winding_ok={mesh.is_winding_consistent}"
)
print(
f"boundary_edges={boundary} nonmanifold_edges={nonmanifold} "
f"parts={len(parts)} small_parts={small}"
)
if len(parts) > 1 and total:
keep = [p for p, a in zip(parts, areas) if a >= 0.01 * total]
if keep:
mesh = trimesh.util.concatenate(keep)
mesh.export("out.stl")
return mesh
if __name__ == "__main__":
triage(sys.argv[1])
mesh.is_watertight means every edge is shared by exactly two faces. mesh.is_winding_consistent is separate: neighbors agree. A volume you can trust needs both, which is the manifold question plus an outward orientation. The 1% test matches the floater rule in the measurement section: a small part is a component under 1% of total area.
On the trimesh builds I use, mesh.fill_holes() closes single-triangle holes and does nothing to an edge that already has two or more faces. trimesh.repair.fix_normals(mesh) makes winding consistent. It does not by itself point normals outward. That is Recalculate Outside in Blender. PyMeshLab and MeshLab can weld, close holes, and filter non-manifold edges if you would rather not drive Blender for the delete step.
There is a bug in the script as written: boundary and nonmanifold are captured before fill_holes(), and the print still shows those earlier counts. The small-part drop uses areas from the split taken before concatenate, which is what you want. Re-run edge_counts on the returned mesh if you need the post-fix numbers. Also, split before the drop still includes every component in the printed parts count, which is the useful one.
Reading the checks on 18 polished showcase GLBs
I measured 18 public homepage showcase models on 2026-09-27 with geometric-probes-3d, CPU only, deterministic. Vertices were welded after the bounding-box diagonal was normalised to 1. I work on modelfy.art.
These are curated showcase models, web-optimised display versions, not a benchmark. Keep that selection bias in mind. Each file is about 1.6–2.5 MB, around 150K triangles, one material, and three 1024×1024 maps (base color, normal, metallic-roughness). Probe time was 2.0–2.6 s per mesh.
A thin-wall face has local wall thickness under 0.5% of the bounding-box diagonal. Hidden surface was sampled and never seen from 64 outside directions. 10 of 18 are closed (watertight) as downloaded. The other 8 have 0 open boundary loops and 1–4 non-manifold edges to merge. That reading is local: a few edges with the wrong face count, which needs a different fix from a hole.
| model | faces | non-manifold edges | parts (small) | self-intersecting | hidden | thin-wall | degenerate |
|---|---|---|---|---|---|---|---|
| adventurer | 150,000 | 2 | 1 (0) | 0.010% | 1.05% | 1.24% | 0 |
| backpack | 149,906 | 1 | 1 (0) | 0.003% | 2.32% | 0.77% | 0 |
| bust | 149,852 | 2 | 2 (1) | 0.041% | 2.72% | 1.62% | 0 |
| cat | 150,000 | 1 | 1 (0) | 0.010% | 0.06% | 0.24% | 0 |
| dwarf | 149,948 | 4 | 2 (0) | 0.014% | 3.64% | 3.29% | 0 |
| fox | 150,000 | 1 | 1 (0) | 0.003% | 0.04% | 0.10% | 0 |
| radio | 149,990 | 1 | 1 (0) | 0.011% | 0.00% | 0.22% | 3 |
| robot | 149,988 | 2 | 1 (0) | 0.031% | 1.11% | 0.43% | 0 |
The dwarf has the highest non-manifold count in the set, 4, and still has zero open boundary loops. Its two parts are both above the 1% line, so neither counts as a small separate part. The bust is the one of these eight that also has a small part. The radio adds 3 degenerate faces beside a single non-manifold edge.
Dwarf. modelfy.art public showcase; checks: geometric-probes-3d. 149,948 faces, closed (watertight): no, 0 open boundary loops, 4 non-manifold edges to merge, 2 parts (0 small), 0.014% self-intersecting faces, 3.64% hidden surface, 3.29% thin-wall faces, 2.2 s on CPU. Blue marks thin wall along the axe blades, the shield rim, and the trim. The legend also uses lavender for inner (hidden) surface, amber for a small separate part, teal for self-intersection and sage for an open edge, each with the action to take. Overlays sit on a reduced display copy; the counts are from the full mesh.
Watertight is one bit. Two closed meshes where the other readings tell you what to do next:
| model | faces | parts (small) | self-intersecting | hidden | thin-wall | degenerate |
|---|---|---|---|---|---|---|
| blacksmith | 149,984 | 7 (5) | 0.005% | 11.67% | 30.63% | 1 |
| hammer | 150,088 | 2 (1) | 0.235% | 0.24% | 0.12% | 27 |
Both have 0 open boundary loops and 0 non-manifold edges. The blacksmith is 7 parts, 5 of them small, with thin roof and awning plates (30.63% thin-wall faces, so thicken them before printing) and 11.67% inner surface. The hammer adds one small component, 27 degenerate faces that Merge by Distance clears, and 0.235% self-intersecting faces. Chair, chest, and dragon read closest to print-ready. A log line that only prints is_watertight will accept the hammer and reject the dwarf.
Hammer. modelfy.art public showcase; checks: geometric-probes-3d. 150,088 faces, watertight true, 0 open boundary loops, 0 non-manifold edges, 2 parts (1 small), 0.235% self-intersecting faces, 0.24% hidden surface, 0.12% thin-wall faces, 2.1 s on CPU. The full-mesh pass also counted 27 degenerate faces. The grey render does not show a second hammer, which fits a small part under 1% of area. Legend: blue thin wall, lavender inner surface, amber small part, teal self-intersection, sage open edge.
Eyeball the decompressed GLB before and after the repair in the GLB page of the online 3D viewer. It runs locally in the browser and does not upload the file. For a slicer, the GLB to STL converter does the container change the same way. Use both on a file your loader can already open.
Symptom, cause, and fix
| Symptom | Cause | Fix |
|---|---|---|
| GLB will not import in Blender or Unity | Meshopt compression or quantized accessors, and the importer has no decoder |
npx @gltf-transform/cli inspect model.glb, then decompress with glTF-Transform |
| Textures missing, mesh present | WebP images inside the GLB | Repack those images, then re-export glTF/GLB |
| Not watertight, 0 boundary edges, 1–4 non-manifold edges | An edge has three or more faces. All 8 showcase meshes that are not closed read like this | Select > Select All by Trait > Non Manifold, then Mesh > Clean Up > Merge by Distance. Skip fill_holes()
|
| Open boundary loops | Edges that belong to one face |
mesh.fill_holes() once the non-manifold count is 0, then recheck mesh.is_watertight
|
| Floaters | Extra components. Small means under 1% of area. Blacksmith: 7 parts, 5 small |
mesh.split(only_watertight=False), drop specks you did not mean to ship |
| Inconsistent normals | Neighboring faces disagree |
trimesh.repair.fix_normals(mesh), then Recalculate Outside (Shift+N). Confirm mesh.is_winding_consistent
|
| Degenerate faces | Zero-area triangles. Hammer: 27. Radio: 3 | Merge by Distance, with a small distance so thin blades survive |
| Self-intersection | Faces cross. Hammer: 0.235% of faces, and the mesh is watertight | 3D Print Toolbox, check Intersections. Delete or relax the crossing faces |
| Thin wall | Thickness under 0.5% of the bounding-box diagonal. Dwarf: 3.29% of faces, blue on the axe and shield | Keep intentional detail on a real-time prop. Run Thickness before a boolean, a collider, or a print |
| Hidden surface | Surface never visible from 64 outside directions. Dwarf: 3.64%. Blacksmith: 11.67% | Look for nested shells before you build a collider |
Blender pass when the script should stop
Enable the 3D Print Toolbox add-on once. Use this path when non-manifold edges, self-intersection, or wall thickness are still non-zero after the script.
- Import the decompressed GLB. Confirm the material and the three textures arrived. If they did not, fix WebP before you edit a vertex.
- Edit Mode, select all, Mesh > Clean Up > Merge by Distance. Keep the distance small so you weld duplicates and leave the axe blade intact.
- Select > Select All by Trait > Non Manifold. What stays selected is the local fault. Merge again or delete those faces.
- Select all, Recalculate Outside (Shift+N), so normals point outward after winding is consistent.
- In 3D Print Toolbox run Checks: Solid, Intersections, Thickness. Solid is watertight and manifold. Intersections covers the hammer's crossing faces. Thickness is wall thickness in scene units.
- Export GLB for Unity or the web, STL for PrusaSlicer, Cura, or Bambu Studio, OBJ for another DCC tool, USDZ for an Apple AR path. Run the triage script again on whatever you exported.
Checklist before the GLB leaves the branch
-
inspecthas been read, and a meshopt or quantized buffer is decompressed for any loader without a decoder. - Blender, Unity, or both open the file.
- Coincident vertices are welded, and Merge by Distance left the thin parts you meant to keep.
- The non-manifold edge count is 0, or the leftover edges are written down as an accepted local fault.
- Open boundary loops are 0 if this mesh has to enclose a volume.
-
mesh.is_winding_consistentis true, and Recalculate Outside has been run. - Split parts are reviewed. Parts under 1% of area are dropped or kept on purpose. The dwarf's second part is not small.
- Intersections have been checked if the mesh feeds a boolean, a collider, or a slicer.
- Degenerate faces are gone.
- Thin-wall and hidden-surface readings are reviewed on their own. They do not move the watertight bit.
- The shipped GLB still has its material.
out.stlstayed on the print branch. - The extension matches the consumer: GLB, OBJ, STL, or USDZ.
FAQ
Why does a GLB play in the web viewer and fail to import in Blender or Unity?
The viewer was built with a meshopt decoder and a WebP image path. The importer was not. EXT_meshopt_compression, KHR_mesh_quantization, and EXT_texture_webp are container features. Inspect, decompress with glTF-Transform, and import that file. Topology repair starts once triangles and textures are visible.
Why is the mesh not watertight when there are zero open boundary loops?
Watertight means every edge has exactly two faces. An open boundary loop is the hole case: edges with one face. A non-manifold edge has three or more. On the 8 non-watertight showcase meshes the loop count was 0 and the non-manifold count was 1–4. fill_holes() addresses holes. Select Non Manifold addresses the other edges.
Will fill_holes repair a non-manifold GLB?
No. mesh.fill_holes() operates on boundary edges. On the showcase files that are not closed after a weld, the reason is 1–4 edges with three or more faces, and zero edges form a hole. Count both, and call fill_holes() only when the boundary count is the one above zero.
How do I repair a GLB with trimesh and keep the texture?
Use trimesh on a decompressed file as the measuring pass: weld, normals, single-triangle holes, drop tiny floaters, mesh.export("out.stl") for the slicer. force="mesh" concatenates primitives and drops the glTF material structure. Do face deletes in Blender on the imported GLB, then export a new GLB so Unity still receives base color, normal, and metallic-roughness.
What is a floater, and when should I delete it?
A floater here is a connected component under 1% of total surface area. The blacksmith reports 7 parts, 5 small, with 0 non-manifold edges, and it is watertight. The hammer reports 2 parts, 1 small, plus 27 degenerate faces. Split, look, and delete stray specks. Both of the dwarf's parts are above 1% of area, so keeping only the largest component would throw away real geometry.
Are these 18 files a benchmark of AI 3D generators?
No. They are the public homepage showcase, chosen as display models and run through a web optimiser. 10 of 18 are closed as downloaded. The other 8 need 1–4 non-manifold edges merged, with zero holes. Chair, chest, and dragon read closest to print-ready. That is this set on 2026-09-27, after a weld, with selection bias. Use it to choose which check to automate. These probes answer whether the mesh is closed, manifold, and free of extra parts you can count.
Sources and tools
- Showcase pass, 2026-09-27, geometric-probes-3d on the public homepage GLBs. Code: geometric-probes-3d.
- glTF-Transform:
npx @gltf-transform/cli inspect model.glb, then decompress before a loader without meshopt support. - trimesh:
load(..., force="mesh"),merge_vertices,is_watertight,is_winding_consistent,split(only_watertight=False),fill_holes,repair.fix_normals,export. - Blender 3D Print Toolbox (Solid, Intersections, Thickness), Merge by Distance, Select Non Manifold, Recalculate Outside.
- MeshLab and PyMeshLab for the same weld and manifold cleanup.
- PrusaSlicer, Cura, or Bambu Studio on the STL. Unity on the repaired GLB.
- Browser checks, local, no upload: the GLB viewer and the GLB to STL converter linked above.


Top comments (1)
The ordering around merge_vertices deserves emphasis: if you weld before splitting off the floaters, a fragment sharing a few coincident vertices with the main body gets fused to it, and split() can no longer separate them. Splitting by connected component first, then welding, then filling is the more reliable order here. Also worth knowing that fill_holes in trimesh only closes certain simple boundary loops, so passing the guard condition is not a guarantee - count boundary edges again after the call, the same re-read as the script bug in the post, to see whether anything actually got filled or whether that loop needs manual attention.