Validate AI-Generated GLB Files in CI Before They Reach Unity, Godot or three.js
Spec checks with the Khronos glTF Validator, a trimesh topology gate, and geometry probes, with thresholds that don’t cry wolf

To validate AI-generated GLB files in CI, chain three checks on every .glb in a pull request: the Khronos glTF Validator for spec errors, a small trimesh gate that hard-fails holes, non-manifold edges and floaters, and a geometry-probe report for self-intersection, hidden surface and thin walls. The validator alone is not enough: in my test, four GLBs with very different geometry all passed it with zero errors. And the probe checks should warn, not block, at zero, because many clean real-world scans already trip them.
I work on modelfy.art, an image-to-3D service, and I wrote the preprint cited below. Everything here runs on open-source tools, and every number is either from that paper or from commands I ran while writing this post.
TL;DR
- Layer 1, spec: the Khronos glTF Validator. Fail the PR on errors (bad accessors, broken buffers). Log warnings.
- Layer 2, topology: a trimesh script. Weld seams with UVs and normals merged, then hard-fail on boundary edges (holes), non-manifold edges and small floating parts.
- Layer 3, probes: geometric-probes-3d for self-intersection, hidden surface and thin walls. Report the numbers and warn. Don't fail at zero.
- Thresholds: holes and floaters are safe to fail at zero. The other probes need a project-specific cutoff.
- Humans stay in the loop: probe scores barely track how good a mesh looks, so someone still orbits the model before it ships.
Terms used in this post
- glTF 2.0 / GLB: the Khronos runtime 3D format. A
.glbpacks the JSON scene description and binary buffers into one file. three.js and Godot load it directly; Unity usually goes through a package such as glTFast. - glTF Validator: the Khronos reference validator. It checks a file against the spec. It ships as the
gltf-validatornpm package and as a drag-and-drop web page. - Accessor: a typed view into a binary buffer, such as vertex positions with declared
min/maxbounds. - Boundary edge: an edge used by exactly one triangle. Boundary edges form the rim of a hole.
- Non-manifold edge: an edge shared by three or more triangles.
- Watertight: every edge is shared by exactly two triangles.
- Floater (small component): a connected piece under 1% of the total surface area. That's the default in both the repo and my gate.
- Self-intersection: triangles that pass through other triangles of the same mesh.
- Hidden surface: surface that can't be seen from any outside viewpoint, such as a shell trapped inside another shell.
- Thin wall: local thickness below a fraction of the bounding-box diagonal (0.005 and 0.01 in the repo).
- AUROC / specificity: AUROC measures how well a score separates defective meshes from clean ones (1.000 is perfect separation). Specificity is the share of clean meshes a threshold lets through.
Why one green check is not enough
Generated meshes fail in three independent ways. The file can break the spec, the geometry can be invalid, or the model can just look wrong. Each layer of the gate answers only one of those questions. To see that concretely, I built five tiny fixtures:
clean.glb: an icosphere with 5,120 faces.floater.glb: the same sphere plus a small detached sphere.hole.glb: the sphere with one triangle deleted.crossing.glb: two overlapping spheres.bad_bounds.glb: the clean file with a wrong positionmaxwritten into its JSON.
I ran each file through all three layers:
| fixture | glTF Validator | trimesh gate | geometry probes |
|---|---|---|---|
| clean | 0 errors | PASS | all zero |
| floater | 0 errors | FAIL (1 small part) | 1 small component |
| hole | 0 errors | FAIL (3 boundary edges) | not watertight |
| crossing | 0 errors | PASS | 3.0% of faces self-intersect, 25% hidden surface |
| bad_bounds | 6 errors | PASS (same triangles as clean) | same as clean |
The validator caught only the spec fault. The topology gate caught the hole and the floater but passed the crossing file, because each sphere is closed and both are large. Only the probes noticed that half the geometry sits inside the other half.
The third question, whether the model looks right, is outside all three layers. In the preprint Geometric validity is not perceptual quality (DOI 10.20944/preprints202609.2626.v1), within-generator correlations between probe features and MATE-3D human geometry ratings had absolute values of at most 0.2. Across 2,797 Hi3DBench meshes, correlations with an MLLM geometry-plausibility score were at most 0.111.
Step-by-step: build the gate
Setup used for every output below: Python 3.13 with trimesh 5.1.0, scipy, networkx, embreex and pymeshlab, plus Node with gltf-validator 2.0.0-dev.3.10. Each script was run in a fresh virtual environment.
Step 1: Commit fixtures with known defects
# make_fixtures.py: four small GLB fixtures with known defects (run after: mkdir fixtures)
import numpy as np, trimesh
clean = trimesh.creation.icosphere(subdivisions=4, radius=1.0)
clean.export("fixtures/clean.glb")
floater = trimesh.util.concatenate([clean, trimesh.creation.icosphere(2, radius=0.05).apply_translation([1.6, 0, 0])])
floater.export("fixtures/floater.glb")
holed = clean.copy()
holed.update_faces(np.arange(len(holed.faces)) != 0) # delete one triangle -> open boundary
holed.remove_unreferenced_vertices()
holed.export("fixtures/hole.glb")
crossing = trimesh.util.concatenate([clean, clean.copy().apply_translation([1.0, 0, 0])])
crossing.export("fixtures/crossing.glb")
print("ok")
Fixtures act as the gate's own unit tests. If a dependency upgrade suddenly lets hole.glb pass, you find out before a real asset slips through.
Step 2: Layer 1, the glTF Validator in Node
// validate.mjs. Usage: node validate.mjs model.glb -> exit 1 on validator errors
import { readFile } from "node:fs/promises";
import validator from "gltf-validator";
const path = process.argv[2];
const report = await validator.validateBytes(new Uint8Array(await readFile(path)), {
maxIssues: 50,
});
const { numErrors, numWarnings, numInfos } = report.issues;
console.log(`${path}: ${numErrors} errors, ${numWarnings} warnings, ${numInfos} infos`);
for (const m of report.issues.messages.filter((m) => m.severity <= 1)) {
console.log(` [${m.severity === 0 ? "ERROR" : "WARN"}] ${m.code} ${m.pointer ?? ""} ${m.message}`);
}
process.exit(numErrors > 0 ? 1 : 0);
On bad_bounds.glb it exits 1 and prints six errors, ACCESSOR_MAX_MISMATCH and ACCESSOR_ELEMENT_OUT_OF_MAX_BOUND, on /accessors/1/max/0 through /2. A loader that trusts those declared bounds would compute the wrong bounding box, so frustum culling and camera framing would break even though the triangles are fine. The four geometry fixtures all print 0 errors, 0 warnings, 0 infos.
Step 3: Layer 2, a trimesh topology gate
"""mesh_gate.py. Usage: python mesh_gate.py assets/*.glb (exit 1 if any file fails)."""
import sys
import numpy as np
import trimesh
MAX_SMALL_PARTS = 0 # parts under 1% of total surface area
MAX_BOUNDARY_EDGES = 0 # edges used by only one face (holes)
MAX_NONMANIFOLD_EDGES = 0 # edges shared by three or more faces
def check(path):
mesh = trimesh.load(path, force="mesh")
mesh.merge_vertices(merge_tex=True, merge_norm=True) # weld UV/normal seams first
edges = np.sort(mesh.edges, axis=1)
_, counts = np.unique(edges, axis=0, return_counts=True)
parts = mesh.split(only_watertight=False)
total = sum(p.area for p in parts)
small = sum(1 for p in parts if p.area < 0.01 * total)
report = {
"faces": len(mesh.faces),
"watertight": mesh.is_watertight,
"boundary_edges": int((counts == 1).sum()),
"nonmanifold_edges": int((counts > 2).sum()),
"parts": len(parts),
"small_parts": small,
}
ok = (report["boundary_edges"] <= MAX_BOUNDARY_EDGES
and report["nonmanifold_edges"] <= MAX_NONMANIFOLD_EDGES
and small <= MAX_SMALL_PARTS)
return ok, report
if __name__ == "__main__":
failed = 0
for path in sys.argv[1:]:
try:
ok, report = check(path)
except Exception as exc: # e.g. a meshopt-compressed GLB that trimesh cannot decode
ok, report = False, {"error": repr(exc)}
failed += not ok
print(("PASS" if ok else "FAIL"), path, report)
sys.exit(1 if failed else 0)
Output on the fixtures:
PASS fixtures/clean.glb {'faces': 5120, 'watertight': True, 'boundary_edges': 0, 'nonmanifold_edges': 0, 'parts': 1, 'small_parts': 0}
PASS fixtures/crossing.glb {'faces': 10240, 'watertight': True, 'boundary_edges': 0, 'nonmanifold_edges': 0, 'parts': 2, 'small_parts': 0}
FAIL fixtures/floater.glb {'faces': 5440, 'watertight': True, 'boundary_edges': 0, 'nonmanifold_edges': 0, 'parts': 2, 'small_parts': 1}
FAIL fixtures/hole.glb {'faces': 5119, 'watertight': False, 'boundary_edges': 3, 'nonmanifold_edges': 0, 'parts': 1, 'small_parts': 0}
The merge_tex=True, merge_norm=True arguments matter. My first version called plain merge_vertices(). On a real textured GLB (a public web-optimised showcase model from modelfy.art, about 150,000 faces), that version reported 57,374 boundary edges and 2,443 parts, because trimesh won't weld vertices that carry different UVs by default, so every UV seam looked like a cut. With both flags set, the same file was watertight, in one part, with zero boundary edges. The fixtures can't catch this mistake because they have no UVs. Always run a gate on one real textured asset before you trust it.
The scipy and networkx installs matter too. mesh.split() needs a graph backend, and on a bare trimesh numpy install it raised ImportError: no graph engines available!.
Step 4: Layer 3, a probe report with geometric-probes-3d
# probe_one.py: run the geometric-probes-3d feature set and print a few columns
import os, sys
sys.path.insert(0, os.environ.get("PROBES_REPO", "geometric-probes-3d")) # path to your clone
from geomcheck.probes import compute_all
KEYS = ["watertight", "n_small_components", "self_intersect_face_frac", "hidden_surface_frac", "thin_frac_0.005"]
for path in sys.argv[1:]:
r = compute_all(path)
print(path, {k: r.get(k) for k in KEYS})
The repo computes 30 deterministic features in six families: open boundaries, components/floaters, self-intersection, hidden surface, thin walls and roughness. It runs on the CPU in pure Python (Trimesh, PyMeshLab, Embree). For the crossing fixture, it printed self_intersect_face_frac 0.030078125, hidden_surface_frac 0.2504 and thin_frac_0.005 0.0045. Those are the overlap signals layer 2 can't see. All four fixtures together took about 1.2 seconds. For larger meshes, the paper reports a median of 0.67 s per mesh on 3D-DefectBench (median 8,119 faces) and 1.61 s on MATE-3D meshes decimated to 10,000 faces, with four parallel workers on an 8-core CPU.
Step 5: Wire it into GitHub Actions
This workflow is a starting point. I ran each of its shell commands locally against a cloned copy of the repo, but I didn't run the workflow itself on GitHub's runners.
name: glb-gate
on:
pull_request:
paths: ["assets/**/*.glb"]
jobs:
glb-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- uses: actions/setup-python@v5
with: { python-version: "3.13" }
- name: Install
run: |
npm install gltf-validator@2.0.0-dev.3.10
pip install "trimesh==5.1.0" numpy scipy networkx embreex pymeshlab
git clone --depth 1 https://github.com/Stark-Will/geometric-probes-3d.git
- name: Layer 1 - glTF Validator (blocking)
run: |
shopt -s globstar nullglob
fail=0
for f in assets/**/*.glb; do node validate.mjs "$f" || fail=1; done
exit "$fail"
- name: Layer 2 - topology gate (blocking)
run: shopt -s globstar nullglob && python mesh_gate.py assets/**/*.glb
- name: Layer 3 - probe report (non-blocking)
continue-on-error: true
run: shopt -s globstar nullglob && python probe_one.py assets/**/*.glb
Layer 3 uses continue-on-error, so its numbers show up in the job log without blocking the merge. Once you've settled on cutoffs (step 6), you can turn the report into a PR comment.
Step 6: Pick thresholds with two operating points
I take this part straight from the paper's injection study, where known defects were added to real Google Scanned Objects scans:
- Holes and floaters: fail at zero. Injected holes and floaters were separated from 120 clean scans with AUROC 1.000 at every severity, and with sensitivity and specificity of 1.00 at a threshold of zero. Clean scans simply don't contain those signals.
- Self-intersection, hidden surface and thin walls: warn, then calibrate. Of the clean decimated scans, 21% already self-intersected, 42% had some hidden surface and 73% had some thin-wall area. At a zero threshold, those checks have a specificity of only 0.79, 0.58 and 0.27. As the paper puts it, "A zero threshold is a property of the decimated scan, not a certificate."
The opposite extreme fails too. The paper also took the 95th percentile of each feature over 200 generated assets that crowd workers had labelled defect-free: 4.4% of faces self-intersecting, a hidden-surface fraction of 0.128, 3.05 small components and a thin-wall fraction of 0.050. Used as a cutoff, that level caught at most 10% of the injected self-intersection defects and none of the one- or three-floater cases. A human "looks fine" label is not geometric cleanliness. In practice, I log the probe values for assets the team has already accepted and set warning lines from that distribution.
| check | policy | basis |
|---|---|---|
| Validator errors | block | spec violation; loaders may misbehave |
| Boundary edges (holes) | block at 0 | AUROC 1.000, specificity 1.00 at zero |
| Small components (floaters) | block at 0 | AUROC 1.000, specificity 1.00 at zero |
| Non-manifold edges | block or warn (your call) | not part of the injection study; I block because many tools choke on them |
| Watertight flag alone | don't rely on it | 98.4% of the 678 3D-DefectBench GLBs are already watertight |
| Self-intersection | warn | specificity 0.79 at zero; signal also rises with noise |
| Hidden surface | warn | specificity 0.58 at zero; responds to several defect types |
| Thin walls | warn | specificity 0.27 at zero |
Keep in mind that outside holes and floaters, the probes are sensitive but not type-specific. At the highest injected severity, the hidden-surface score rose for interpenetration, crossing sheets, noise and thin fins, not just for hidden shells. A warning tells you that a mesh is geometrically busy. It doesn't tell you which defect to file.
Step 7: Keep a human visual pass
A green pipeline means the file can go to the importer. It doesn't mean the asset is any good. The probes can't see a missing handle, a wrong pose or a texture that reads badly. In the paper, those prompt-dependent defects were outside the probes' scope, while vision-language judges did pick them up. For a quick orbit before import, I use an in-browser GLB viewer that renders locally without uploading the file. If you need a print copy, the same site has a GLB to STL converter. If the engine and the viewer disagree, trust the engine.
Troubleshooting
| symptom | likely cause | fix |
|---|---|---|
IndexError('list index out of range') from trimesh.load |
GLB uses EXT_meshopt_compression; trimesh can't decode it |
Confirm the extension list with npx @gltf-transform/cli inspect model.glb; run the Python layers on a decompressed copy (script below) |
| Validator passes, trimesh fails on the same file | The validator understands meshopt; trimesh doesn't | Same as above. Both tools are right about different things |
| Thousands of boundary edges on a textured model | UV/normal seams were not welded | merge_vertices(merge_tex=True, merge_norm=True) |
ImportError: no graph engines available! |
trimesh has no graph backend | pip install scipy networkx |
| Self-intersection warnings on assets you already ship | Normal for decimated real-world geometry | Calibrate on accepted assets; don't block at zero |
| Probes slow in CI | High face counts | Decimate a copy for probing; limit workers to runner cores |
| Everything green, but it looks wrong in engine | Geometry checks don't measure appearance | Visual pass; check materials and pose by eye |
Meshopt-compressed GLBs are common on the web, so here is the decompress step I tested. It uses the glTF-Transform libraries (npm i @gltf-transform/core @gltf-transform/extensions @gltf-transform/functions meshoptimizer):
// decompress.mjs. Usage: node decompress.mjs in.glb out.glb
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dequantize } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const doc = await io.read(process.argv[2]);
for (const ext of doc.getRoot().listExtensionsUsed()) {
if (ext.extensionName === "EXT_meshopt_compression") ext.dispose();
}
await doc.transform(dequantize());
await io.write(process.argv[3], doc);
On the 150,000-face showcase file, this produced a plain GLB that still validated with 0 errors and loaded in trimesh. That is the file behind the seam numbers in step 3. See the glTF-Transform docs for other options.
What this gate cannot tell you
These checks answer whether a file is valid enough to export, rig or print. They don't measure quality. On the expert-labelled 3D-DefectBench split, a classifier built on all 30 probe features reached a macro Matthews correlation of 0.272 across three primary geometry defects. The best vision-language judges scored 0.335 to 0.341, and no paired McNemar test against the four strongest judges was significant. Use the probes as cheap, deterministic gates and confound checks, and leave the "is it good?" call to a person or a vision model.
FAQ
Does the glTF Validator check mesh geometry?
No. It checks that the document follows the spec: accessors, buffers, bounds and extensions. A GLB with a missing triangle is a perfectly legal document. In my test, the hole fixture got 0 errors from the validator, while the topology gate found 3 boundary edges.
Should self-intersection fail the build?
Not at zero. On clean real scans, a zero threshold has a specificity of 0.79, because 21% of those scans already self-intersect. Log the fraction, collect values for assets you've accepted, and set a warning line from that distribution.
Why does my AI-generated GLB load in a browser but crash trimesh?
Most likely it uses EXT_meshopt_compression, which web viewers decode but trimesh 5.1.0 didn't in my test. Decompress a copy with glTF-Transform before running the Python layers.
Is watertight a good enough gate?
Only as a weak signal. 98.4% of the 678 3D-DefectBench GLBs were already watertight, and my crossing fixture is watertight while half of it is buried inside the other half. Count boundary edges, non-manifold edges and floaters separately, and add the probes for overlaps.
How long does layer 3 take?
My four small fixtures took about 1.2 seconds in total. In the paper, the median was 0.67 seconds per 3D-DefectBench mesh and 1.61 seconds per 10,000-face MATE-3D mesh, with four workers on an 8-core CPU.
Sources and tools
- Miles Carter (2026), Geometric validity is not perceptual quality: what deterministic probes can and cannot measure in generated 3D assets. Preprint DOI: 10.20944/preprints202609.2626.v1. Code, configs and per-asset results: Stark-Will/geometric-probes-3d (MIT). The study did not evaluate any modelfy.art system, model or data.
- Khronos glTF Validator and the
gltf-validatornpm package - trimesh
- glTF-Transform
- Fixture scripts and outputs: written and run for this post (trimesh 5.1.0, gltf-validator 2.0.0-dev.3.10, glTF-Transform 4.5.1)

