Skip to main content

Command Palette

Search for a command to run...

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

Updated
•15 min read•View as Markdown
Validate AI-Generated GLB Files in CI Before They Reach Unity, Godot or three.js
M
Writing about software, automation, and creative tools.

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 .glb packs 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-validator npm package and as a drag-and-drop web page.
  • Accessor: a typed view into a binary buffer, such as vertex positions with declared min/max bounds.
  • 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 position max written 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