/** * Headless audit of the four farm GLBs in assets/meshes/. Run: * node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts * * This is the closest thing to browser verification the asset runtime has: it * parses each real GLB, runs the same paintability check the game runs, and — * for the blob body — normalises the geometry and fires the same * outside-in raycast PaintSkin uses to turn a world point into a UV. If that * raycast returns a UV, a splat lands; if it returns null, splats silently do * nothing, which is the failure mode this file exists to catch. * * `THREE.GLTFLoader: Couldn't load texture` warnings are expected here — node * has no ImageBitmap. Geometry, UVs and materials all still parse. */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import * as THREE from 'three' import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' import { paintableInfo, normalizeToRadius } from '../src/assets/registry' // GLTFLoader reaches for `self` when wiring its texture loaders. ;(globalThis as unknown as { self: unknown }).self = globalThis const MESH_DIR = fileURLToPath(new URL('../assets/meshes/', import.meta.url)) const FILES = ['blobbo-base', 'prop-spring-boot', 'prop-paint-bucket', 'prop-toaster-launcher'] const loader = new GLTFLoader() let failures = 0 function parse(file: string): Promise { const buf = readFileSync(MESH_DIR + file + '.glb') const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer return new Promise((res) => loader.parse(ab, '', res, () => res(null))) } for (const file of FILES) { const gltf = await parse(file) if (!gltf) { console.log(`${file}: PARSE FAILED`) failures++ continue } const info = paintableInfo(gltf.scene) console.log( `${file.padEnd(22)} meshes=${info.meshCount} materials=${info.materialCount} ` + `tris=${info.triCount} uvOk=${info.uvOk} skinned=${info.skinned} paintSafe=${info.ok}` + (gltf.animations.length ? ` clips=${gltf.animations.map((a) => a.name).join(',')}` : ''), ) for (const p of info.problems) console.log(` ! ${p}`) } // ---- the blob body, end to end --------------------------------------------- const gltf = await parse('blobbo-base') if (!gltf) { failures++ } else { let mesh: THREE.Mesh | null = null gltf.scene.traverse((o) => { if (!mesh && (o as THREE.Mesh).isMesh) mesh = o as THREE.Mesh }) const body = mesh as unknown as THREE.Mesh const geo = body.geometry.clone() normalizeToRadius(geo, 0.5) const fitted = new THREE.Mesh(geo, new THREE.MeshStandardMaterial()) fitted.updateWorldMatrix(true, false) const r = geo.boundingSphere!.radius const c = geo.boundingSphere!.center console.log(`\nblob.body fitted: boundingRadius=${r.toFixed(6)} centre=(${c.x.toFixed(6)}, ${c.y.toFixed(6)}, ${c.z.toFixed(6)})`) if (Math.abs(r - 0.5) > 1e-5) { console.log(' ! radius is not 0.5 — puddle belly contact would drift'); failures++ } if (c.length() > 1e-5) { console.log(' ! not centred on the origin — PaintSkin raycasts would miss'); failures++ } // PaintSkin.uvAtWorldPoint, reproduced: cast from outside, inward through the // centre, and take the UV of the first hit. const raycaster = new THREE.Raycaster() let hits = 0 const dirs = [ new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0), new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1), new THREE.Vector3(1, 1, 1).normalize(), ] for (const dir of dirs) { const origin = dir.clone().multiplyScalar(r * 2.2) raycaster.set(origin, dir.clone().negate()) raycaster.near = 0 raycaster.far = r * 4.4 const hit = raycaster.intersectObject(fitted, false).find((h) => h.uv) if (hit?.uv) hits++ else console.log(` ! no UV hit casting along ${dir.toArray().map((n) => n.toFixed(2)).join(',')} — a splat there would vanish`) } console.log(`splat raycast: ${hits}/${dirs.length} directions returned a UV`) if (hits !== dirs.length) failures++ } console.log(failures === 0 ? '\nfarm-assets.check: OK' : `\nfarm-assets.check: ${failures} FAILURE(S)`) if (failures > 0) process.exitCode = 1