meshcheck Phase 0: a deterministic glTF report card and the corpus that proves it

PHASE00
DATE2026-07-13

What shipped

At the end of Phase 0, meshcheck works and demos with no server attached.

A two-crate Cargo workspace turns a GLB/glTF file into a report card. Scene::from_bytes parses the container (GLB BIN chunk, data: URIs, external files through an injected resolver), fills the whole stats block, and 28 deterministic checks run over it: SPEC-001..004, GEO-001..009, XFM-001..003, UV-001..006, MAT-001..006, PERF-001..006. Each reads its thresholds from config/*.toml. The report is the full SPEC_02 envelope with a canonical id order, a summary/verdict rollup, and a committed JSON Schema with a drift test. meshcheck-corpus check file.glb --profile pc prints it.

The other half is ground truth. meshcheck-corpus generate fetches six pinned Khronos sample assets and stamps out 21 programmatic mutations, one labelled defect each, writing corpus/manifest.toml as the committed record of what should be found. meshcheck-corpus bench --full-gate reads that manifest and asserts detection over broken/, zero false positives over clean/, byte-identical double runs over every asset, and checks_only p95 latency per size class. Then it rewrites the Phase 0 table in BENCHMARKS.md and exits non-zero on any red row. GitHub Actions runs the whole gate on every push to main.

Decisions

Most of them serve determinism. CheckId is a {group, num} struct whose derived Ord is the canonical report order, so parallel checks sort into stable output. Core never reads the clock; ReportMeta injects ids and timestamps. Every float goes through q6. Parsing uses from_slice_without_validation because the gltf crate’s own validation rejects exactly the assets SPEC-001/002/003 exist to report on. Edge identity welds vertices by exact position bits, so Box.glb’s 24 split vertices collapse to 8 corners and the cube reads as closed.

Two calibrations came from real assets rather than theory. Duck.glb does not pass clean. Its authored UVs really do overlap, so its UV warns were ruled true positives and recorded as manifest expected_warnings instead of tuned away. GEO-002’s hole_area_pct moved from an M1 placeholder of 1.0 to 50.0 so seam-open Khronos assets warn rather than fail, with error reserved for majority-open meshes. Both are logged.

Two bench rules I wanted on the record. Latency is measured out-of-band with Instant while timing_ms stays zeroed, so double-run reports are byte-identical with no normalization step. And corrupt_json, which needs the external Khronos validator to trip SPEC-001, reports as excluded when no validator is installed. Never as a pass.

What broke

The gltf crate refused the unknown-extension fixture until parse-time validation came off. The validator CLI flag was wrong on the first pass (-s -o <dir> instead of the real -o stdout flag); downloading the 2.0.0-dev.3.10 binary and reading --help settled it. parry3d produced 189 compile errors when default-features = false stripped its dim3/std features; the fix was enabling required-features and std alongside enhanced-determinism. A hand-rolled Moller tri-tri test got written as a parry fallback, then deleted once parry proved reliable on flat triangles.

The corpus found two more. The naive unweld mutation tripped nothing, because duplicating a shared vertex produces bit-identical copies that GEO-005 excludes as self-welding. A 1e-5 sub-tolerance jitter fixed it. The 8192² upsize texture first wrote a 268 MB file under NoFilter+Fast; the Up filter turns flat rows into zero deltas and dropped it to 360 KB. And the clean-corpus sweep flagged two error-level GEO-002 fails (Avocado, DamagedHelmet) that went to review as review_todo markers rather than being self-approved. That’s where the hole_area_pct retune came from.

Numbers

All from the dev machine (Windows, cargo 1.96, release). Detection is 100% of the 20 evaluated broken assets, with corrupt_json excluded for lack of a validator. Zero false positives and zero regressions across the six clean assets; their warn sets exactly match the manifest. Zero byte diffs over all 27 assets run twice. checks_only p95 is 44 ms for the ≤50k-tri class and 567 ms for the ≤500k-tri class (a 196k-tri exploded Box dominates the big class), against targets of 500 ms and 3 s. No schema drift, and the wasm32 core build compiles. Per-check budget has room: Box.glb’s geometry kit builds in 3.69 µs and the full check registry runs in 49.61 µs with the kit cached.

Next

Phase 1 lifts the core onto Vercel Functions, meshcheck-core compiled to wasm behind a TypeScript API: /validate, uploads, jobs, an API-key credit ledger in Neon Postgres, and Blob storage with a retention sweep. The gate I care about is wasm-vs-native report parity over the corpus, byte-identical except the documented GEO-009 skip.

Decisions
DecisionWhyAlternatives rejected
CheckId is a { group, num } struct with derived Ord; the string form is only the serde surfaceGroup-then-number ordering is the canonical report order, so deriving Ord gives report determinism for free after a single sort_by_keyCheckId(&'static str) parsed on every compare, an exhaustive enum of all ids
Timestamps and report ids are caller-supplied via ReportMeta; core never reads the clock, and every reported float passes through q6 (round to 6 decimals)Determinism rule: same file + same profile must yield byte-identical bytes; the clock lives only at the CLI layer and q6 kills platform FP noise before serderead SystemTime inside core, report raw f64
Parse with gltf::Gltf::from_slice_without_validation, not from_sliceThe gltf crate's own validation rejects assets with unknown required extensions or unresolved references, exactly the conditions SPEC-001/002/003 must report, not choke onfrom_slice and treat its rejection as a parse error (makes SPEC-002/003 unreachable)
Config-driven severity escalation via CheckOutcome.severity_override, including profile-conditional escalation keyed on CheckContext.profile_nameSPEC_01 has checks whose severity depends on the measurement (XFM-002) or the profile (MAT-003 NPOT on mobile); the effective severity is override.unwrap_or(configured), sourced only from config, never a hardcoded Severitya second severity field on the wire, hardcoding escalated severities or a == "mobile" test in check code
Edge identity uses exact-bitwise position welding, not vertex indicesMeshes split vertices for normals/UVs (Box.glb: 24 vertices for 8 corners); index-keyed edges would make every seam a false boundary and Box would fail every manifold checkindex-keyed edges, tolerance welding in the shared kit (kept only inside GEO-005 where near-coincidence is the signal)
GEO-009 self-intersection uses parry3d 0.29 intersection_test with a hand-rolled deterministic sweep-and-prune broadphaseAn empirical check confirmed parry 0.29's glam dispatcher handles flat triangle/triangle correctly, so the pinned dependency is usable; a hand-rolled Moller fallback was written first, then deleted once parry proved reliableship the hand-rolled Moller tri-tri test, parry Qbvh broadphase
The shared UV rasterizer is an integer edge-function scanline with the top-left fill rule, sampled at texel centres on a ×2 latticeByte-identical output first: no anti-aliasing, no float accumulation, no SIMD; centre sampling avoids the shared-corner double-exclusion that pixel-corner sampling producestiny-skia / lyon (AA + SIMD break determinism), pixel-corner sampling
Per-mutation corpus seed = ChaCha8Rng::seed_from_u64(fnv1a64(base) ^ fnv1a64(defect)); broken binaries regenerated in CI, not committedPortable, version-stable randomness so the corpus regenerates byte-for-byte; per-mutation derivation means adding a defect never perturbs another's streamSmallRng (platform/version-dependent algorithm), one global seed advanced across mutations
Broken assets are built from a serde_json::Value glTF document framed by gltf::binary::Glb, not a typed gltf_json::RootA Value has BTreeMap-sorted keys (byte-deterministic) and avoids the Checked/USize64/Index boilerplate of constructing a typed Root from nothing, while the validator-safe 4-byte chunk padding still comes from gltf::binary::Glbtyped gltf_json::Root construction, hand-rolled GLB framing
Duck.glb's UV-002/004/005 warns are accepted as true positives and recorded as manifest expected_warnings, not tuned awayDuck's authored UVs genuinely overlap (summed UV area 1.197 > UV bbox 0.919; an independent rasterizer agrees at ~94.5%), a real defect class the product exists to catch; Khronos samples are spec-perfect but not game-readytune uv_overlap_pct until Duck passes (hides a defect class), swap Duck for another model
GEO-002 hole_area_pct recalibrated 1.0 → 50.0 against the clean corpus; error reserved for majority-open meshes1.0 was an M1 placeholder (SPEC_01 named the parameter without a number); Avocado's 6.78% seam and DamagedHelmet's 47.7% open bust must warn, not fail; logged in config/CHANGELOG.mdper-asset error exceptions (SPEC_06 only sanctions expected-warning exceptions), leaving it at 1.0
meshcheck-core carries a wasm feature graph from day one: default = parallel + geo-selfx, --no-default-features drops rayon and parry3dThe hosted API path is the wasm core; keeping rayon and parry3d optional (GEO-009 reports skipped, never omitted, when geo-selfx is off) means the wasm build is a feature toggle, not a rewritea wasm-only fork of core, unconditional rayon/parry3d (neither builds on the wasm target here)
The M6 bench measures latency out-of-band with Instant and zeroes timing_ms in every reportThe corpus pipeline assembles reports with Timing::default(), so double-run reports are byte-identical without normalizing timing; perf is a separate wall-clock measurement over parse + checksnormalize/strip timing_ms before diffing (fragile), read latency from the report's own timing_ms
corrupt_json detection is reported as excluded (validator absent), never as a pass, when no Khronos validator is discoverableSPEC-001 is the only check that needs an external validator; counting it as detected without one would be dishonest, so the scoreboard shows n/N with the exclusion namedcount it as a pass, hard-fail the gate when no validator is installed
The BENCHMARKS.md updater rewrites only the current/status cells of Phase 0 rows it measuredTargets are human-owned; the tool reads the target cell to decide pass/fail and preserves metric/target bytes and every other phase's table character-for-character (a golden test pins the output)regenerate the whole table (clobbers human-owned targets)
Benchmarks
✓MetricValueTarget
✓broken/ detection rate100% (20/20; corrupt_json excluded, validator absent)100%
✓clean/ false positives00
✓determinism (double-run diff, full corpus)0 diffs across 27 assets0 diffs
✓checks_only p95, ≤50k tris44 ms< 500 ms
✓checks_only p95, ≤500k tris567 ms< 3 s
✓JSON Schema driftnonenone
✓wasm32 feature buildcompilescompiles
✓Box.glb geometry kit build (release, 100k iters)3.69 µs/iterfeeds the checks_only baseline
✓Box.glb full check registry, kit cached (release, 100k iters)49.61 µs/iterwell under the checks_only budget