// Generic GLB structural audit (species-agnostic). For each file: mesh/poly counts, // materials with alphaMode + doubleSided (the koi's failure was one BLEND double-sided // mesh), textures, skin/joint count, animation clip names (must carry Idle/Swim in the // GLB, not just the Maya source), bbox proportions, and a crude "two-tail" check — // histogram of the rear-slice vertices along the thin (left/right) axis: a single peak // at centre = one tail; two side peaks with a centre gap = a forked / double tail. import { NodeIO } from '@gltf-transform/core'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); const path = process.argv[2]; const doc = await io.read(path); const root = doc.getRoot(); const base = path.split('/').pop(); const meshes = root.listMeshes(); let prims = 0, tris = 0, verts = 0; const bb = { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] }; const allPos = []; for (const m of meshes) for (const p of m.listPrimitives()) { prims++; const pos = p.getAttribute('POSITION'); const idx = p.getIndices(); tris += idx ? idx.getCount() / 3 : pos.getCount() / 3; verts += pos.getCount(); const v = [0, 0, 0]; for (let i = 0; i < pos.getCount(); i++) { pos.getElement(i, v); allPos.push(v[0], v[1], v[2]); for (let d = 0; d < 3; d++) { bb.min[d] = Math.min(bb.min[d], v[d]); bb.max[d] = Math.max(bb.max[d], v[d]); } } } const size = bb.max.map((m, d) => m - bb.min[d]); const mats = root.listMaterials(); const texs = root.listTextures(); const skin = root.listSkins()[0]; const joints = skin ? skin.listJoints() : []; const anims = root.listAnimations(); // axes: width = thinnest (fish are laterally compressed); length = longest of the other two. const widthAxis = size.indexOf(Math.min(...size)); const other = [0, 1, 2].filter((d) => d !== widthAxis); const lenAxis = other[0] === undefined ? 0 : (size[other[0]] >= size[other[1]] ? other[0] : other[1]); // rear slice = last 22% along the length axis (either end — pick the end with fewer verts = the tail, not the body) const loEnd = bb.min[lenAxis] + 0.22 * size[lenAxis]; const hiEnd = bb.max[lenAxis] - 0.22 * size[lenAxis]; let loN = 0, hiN = 0; for (let i = 0; i < allPos.length; i += 3) { const L = allPos[i + lenAxis]; if (L <= loEnd) loN++; else if (L >= hiEnd) hiN++; } const tailIsLo = loN <= hiN; const wc = (bb.min[widthAxis] + bb.max[widthAxis]) / 2, wHalf = size[widthAxis] / 2 || 1; const bins = new Array(11).fill(0); let tailN = 0; for (let i = 0; i < allPos.length; i += 3) { const L = allPos[i + lenAxis]; if (tailIsLo ? L > loEnd : L < hiEnd) continue; tailN++; const t = (allPos[i + widthAxis] - wc) / wHalf; // -1..1 across width bins[Math.max(0, Math.min(10, Math.round((t + 1) * 5)))]++; } const centreFrac = tailN ? (bins[4] + bins[5] + bins[6]) / tailN : 0; // fraction near centre-plane const AX = ['X', 'Y', 'Z']; console.log(`\n== ${base} ==`); console.log(` meshes ${meshes.length} prims ${prims} tris ${Math.round(tris)} verts ${verts}`); console.log(` materials ${mats.length}: ${mats.map((m) => `${m.getName() || '—'}[${m.getAlphaMode()}${m.getDoubleSided() ? ',2SIDED' : ''}]`).join(' ')}`); console.log(` textures ${texs.length} skin joints ${joints.length}`); console.log(` anims ${anims.length}: ${anims.map((a) => a.getName()).join(', ') || '(none in GLB!)'}`); console.log(` bbox size ${AX.map((a, d) => `${a}=${size[d].toFixed(2)}`).join(' ')} (len axis ${AX[lenAxis]}, width axis ${AX[widthAxis]})`); console.log(` tail slice: ${tailN} verts, ${(centreFrac * 100).toFixed(0)}% near centre-plane → ${centreFrac > 0.5 ? 'SINGLE tail (centred)' : 'possible fork/side-lobes — eyeball it'}`); const tj = joints.filter((j) => /tail|caudal|fork|fin/i.test(j.getName())).map((j) => j.getName()); if (tj.length) console.log(` fin/tail joints: ${tj.join(', ')}`);