// Measure the actual vertex extents of the fish GLB models. // // Part A parses the raw GLB binary directly (JSON + BIN chunks) and reads // each mesh primitive's POSITION attribute, so the numbers are independent // of three.js/GLTFLoader quirks (skinned-mesh bind-pose bounds, stale // cached bboxes, degenerate control rigs, scene extras like water planes). // // Part B loads each GLB through three.js and evaluates the actual skinned // vertex positions (rest pose + swim animation frames), so we can compare // "measured raw" vs "actually rendered" per species and calibrate // FishSpec.modelLength (see src/scene/GLTFish.ts). The summary at the end // prints the hardcoded `modelLength` value per species. // // GLB format: [magic "glTF"][version][length] then chunks of // [chunkLength: u32 LE][chunkType: u32 LE][chunkData]: // JSON chunk type = 0x4e4f534a ("JSON"), BIN chunk type = 0x004e4942 ("BIN\0") // // Usage: node scripts/measure-models.mjs import fs from "node:fs"; import * as THREE from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; // ---- Node polyfills so three's GLTFLoader can decode embedded textures. // Part B only needs geometry/skinning math; image sources are stubbed as // 1x1 bitmaps. (three r182's GLTFParser.loadImageSource references the bare // global `self` and URL.createObjectURL, which Node does not provide.) if (typeof globalThis.self === "undefined") globalThis.self = globalThis; if (typeof globalThis.createImageBitmap !== "function") { globalThis.createImageBitmap = () => Promise.resolve({ width: 1, height: 1, close() {} }); } if (typeof URL.createObjectURL !== "function") { const RealBlob = globalThis.Blob; class DataUrlBlob extends RealBlob { constructor(parts, options) { super(parts, options); const chunk = parts && parts[0]; if (chunk && chunk.byteLength !== undefined) this._bytes = new Uint8Array(chunk); } } globalThis.Blob = DataUrlBlob; URL.createObjectURL = (b) => b && b._bytes ? "data:" + (b.type || "application/octet-stream") + ";base64," + Buffer.from(b._bytes).toString("base64") : "data:;"; URL.revokeObjectURL = () => {}; } const ROOT = new URL("..", import.meta.url).pathname; const MODELS = { angelfish: "public/models/blendswap/blueface_angelfish.glb", goldfish: "public/models/goldfish.glb", guppy: "public/models/guppy.glb", betta: "public/models/poly-pizza/betta.glb", blue_tang: "public/models/poly-pizza/blue_tang.glb", flower_horn: "public/models/poly-pizza/flower_horn.glb", neon_tetra: "public/models/blendswap/neon_tetra.glb", oscar: "public/models/blendswap/oscar_cichlid.glb", knifefish: "public/models/blendswap/ghost_knifefish.glb", clownfish: "public/models/blendswap/clownfish.glb", }; // Node names excluded from the *rendered* measurement: scene extras shipped // inside the GLB (clownfish water/mirror/light/bubbles), not the fish. const HIDDEN_NODES = { clownfish: ["Surface", "Original-Mirrored", "TopLight", "Bubbles"], }; const JSON_CHUNK = 0x4e4f534a; // "JSON" as u32 LE const BIN_CHUNK = 0x004e4942; // "BIN\0" as u32 LE // ---------------- mat4 helpers (column-major, glTF layout) ---------------- function m4identity() { return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; } function m4mul(a, b) { const out = new Array(16); for (let c = 0; c < 4; c++) { for (let r = 0; r < 4; r++) { let s = 0; for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k]; out[c * 4 + r] = s; } } return out; } function m4trs(t, r, s) { const x = r[0], y = r[1], z = r[2], w = r[3]; const xx = x * x, yy = y * y, zz = z * z; const xy = x * y, xz = x * z, yz = y * z; const wx = w * x, wy = w * y, wz = w * z; return [ (1 - 2 * (yy + zz)) * s[0], 2 * (xy + wz) * s[0], 2 * (xz - wy) * s[0], 0, 2 * (xy - wz) * s[1], (1 - 2 * (xx + zz)) * s[1], 2 * (yz + wx) * s[1], 0, 2 * (xz + wy) * s[2], 2 * (yz - wx) * s[2], (1 - 2 * (xx + yy)) * s[2], 0, t[0], t[1], t[2], 1, ]; } function m4transform(m, p) { return [ m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], ]; } // ---------------- GLB binary parsing ---------------- function readGLB(file) { const bytes = new Uint8Array(fs.readFileSync(file)); const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const magic = dv.getUint32(0, true); if (magic !== 0x46546c67) throw new Error("not a GLB (bad magic): " + file); const version = dv.getUint32(4, true); const totalLen = dv.getUint32(8, true); if (version !== 2) throw new Error("unsupported glTF version " + version + ": " + file); let jsonBytes = null; let binBytes = null; let off = 12; while (off + 8 <= totalLen) { const chunkLen = dv.getUint32(off, true); const chunkType = dv.getUint32(off + 4, true); const data = bytes.slice(off + 8, off + 8 + chunkLen); if (chunkType === JSON_CHUNK) jsonBytes = data; else if (chunkType === BIN_CHUNK) binBytes = data; off += 8 + chunkLen; if (jsonBytes && binBytes) break; } if (!jsonBytes) throw new Error("no JSON chunk: " + file); const json = JSON.parse(new TextDecoder().decode(jsonBytes)); const binBufferIdx = (json.buffers || []).findIndex((b) => !b.uri); if (binBufferIdx >= 0 && !binBytes) throw new Error("buffer declares BIN but no BIN chunk found: " + file); return { json, binBytes, binBufferIdx }; } // ---------------- POSITION attribute reading ---------------- function readPositionBounds(json, dv, binBufferIdx, accIdx) { if (accIdx === undefined || accIdx === null) return null; const acc = json.accessors[accIdx]; if (!acc) return null; if (acc.componentType !== 5126 || acc.type !== "VEC3") return null; // float32 VEC3 only const bv = json.bufferViews[acc.bufferView]; if (!bv || bv.buffer !== binBufferIdx) return null; // external buffer: not readable inline // NOTE: bufferView.byteOffset is relative to the buffer (BIN chunk), and // `dv` is positioned at the start of that chunk, so it can be used directly. const base = (bv.byteOffset || 0) + (acc.byteOffset || 0); const stride = bv.byteStride || 12; let minX = Infinity, minY = Infinity, minZ = Infinity; let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; for (let i = 0; i < acc.count; i++) { const o = base + i * stride; const x = dv.getFloat32(o, true); const y = dv.getFloat32(o + 4, true); const z = dv.getFloat32(o + 8, true); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; } return { count: acc.count, min: [minX, minY, minZ], max: [maxX, maxY, maxZ] }; } // ---------------- node world matrices (from JSON hierarchy) ---------------- function buildWorldMats(json) { const mats = new Map(); const scene = json.scenes[json.scene || 0]; if (!scene) return mats; const queue = scene.nodes.map((i) => [i, m4identity()]); while (queue.length) { const [idx, parentWorld] = queue.pop(); const n = json.nodes[idx]; if (!n) continue; const local = n.matrix ? n.matrix.slice(0, 16) : m4trs( n.translation || [0, 0, 0], n.rotation || [0, 0, 0, 1], n.scale || [1, 1, 1] ); const world = m4mul(parentWorld, local); mats.set(idx, world); for (const c of n.children || []) queue.push([c, world]); } return mats; } // ---------------- bbox helpers ---------------- function boxUnion(a, b) { return { min: [Math.min(a.min[0], b.min[0]), Math.min(a.min[1], b.min[1]), Math.min(a.min[2], b.min[2])], max: [Math.max(a.max[0], b.max[0]), Math.max(a.max[1], b.max[1]), Math.max(a.max[2], b.max[2])], }; } function boxSize(b) { return [b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2]]; } function boxLongest(b) { return b ? Math.max(...boxSize(b)) : null; } function worldBox(rawBox, world) { const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; for (let i = 0; i < 8; i++) { const corner = [ (i & 1) ? rawBox.max[0] : rawBox.min[0], (i & 2) ? rawBox.max[1] : rawBox.min[1], (i & 4) ? rawBox.max[2] : rawBox.min[2], ]; const p = m4transform(world, corner); for (let a = 0; a < 3; a++) { if (p[a] < min[a]) min[a] = p[a]; if (p[a] > max[a]) max[a] = p[a]; } } return { min, max }; } function fmtBox(b) { if (!b) return "(no POSITION data)"; const s = boxSize(b); return [ `min(${b.min[0].toFixed(3)}, ${b.min[1].toFixed(3)}, ${b.min[2].toFixed(3)})`, `max(${b.max[0].toFixed(3)}, ${b.max[1].toFixed(3)}, ${b.max[2].toFixed(3)})`, `size(${s[0].toFixed(3)}, ${s[1].toFixed(3)}, ${s[2].toFixed(3)})`, `longest=${boxLongest(b).toFixed(4)}`, ].join(" "); } // ---------------- Part A: raw GLB measurement ---------------- function measureRaw(name, rel) { const file = ROOT + rel; const { json, binBytes, binBufferIdx } = readGLB(file); if (!binBytes) throw new Error("no BIN chunk but bufferViews reference buffer 0: " + rel); // dv positioned at the start of the BIN chunk: bufferView offsets are relative to it. const dv = new DataView(binBytes.buffer, binBytes.byteOffset, binBytes.byteLength); const worldMats = buildWorldMats(json); const meshes = []; let overallRaw = null; let overallWorld = null; for (let mi = 0; mi < (json.meshes || []).length; mi++) { const mesh = json.meshes[mi]; let raw = null; let verts = 0; const primDetails = []; for (const prim of mesh.primitives || []) { const pb = readPositionBounds(json, dv, binBufferIdx, prim.attributes && prim.attributes.POSITION); if (!pb) { primDetails.push({ material: prim.material !== undefined ? prim.material : null, pos: false }); continue; } verts += pb.count; primDetails.push({ material: prim.material !== undefined ? prim.material : null, count: pb.count, min: pb.min, max: pb.max, }); raw = raw ? boxUnion(raw, { min: pb.min.slice(), max: pb.max.slice() }) : { min: pb.min.slice(), max: pb.max.slice() }; } const ownerNodes = (json.nodes || []) .map((n, i) => ({ n, i })) .filter((x) => x.n.mesh === mi); let world = null; const nodeInfo = ownerNodes.map(({ n, i }) => { const w = worldMats.get(i); if (raw && w) { const wb = worldBox(raw, w); world = world ? boxUnion(world, wb) : wb; } return { name: n.name || `(node#${i})`, scale: n.scale ? n.scale.map((v) => +v.toFixed(5)) : [1, 1, 1], }; }); meshes.push({ name: mesh.name || `(mesh#${mi})`, verts, raw, world, nodes: nodeInfo, prims: primDetails, }); if (raw) overallRaw = overallRaw ? boxUnion(overallRaw, raw) : raw; if (world) overallWorld = overallWorld ? boxUnion(overallWorld, world) : world; } return { json, meshes, overallRaw, overallWorld }; } function dumpMaterials(json) { const ext = { used: json.extensionsUsed || [], required: json.extensionsRequired || [], }; const images = (json.images || []).map((im) => { const bv = im.bufferView !== undefined ? json.bufferViews[im.bufferView] : null; return { name: im.name || null, mime: im.mimeType || null, bytes: bv ? bv.byteLength : (im.uri ? "uri:" + im.uri : "?"), }; }); const mats = (json.materials || []).map((m, i) => { const pbr = m.pbrMetallicRoughness || {}; const tex = pbr.baseColorTexture; const img = tex !== undefined ? images[tex.index] : null; return { i, name: m.name || null, baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1], baseColorTexture: tex !== undefined ? `img${tex.index}(${img ? img.name || img.mime : "?"})` : null, metallic: pbr.metallicFactor !== undefined ? pbr.metallicFactor : 1, roughness: pbr.roughnessFactor !== undefined ? pbr.roughnessFactor : 1, alphaMode: m.alphaMode || "OPAQUE", emissive: m.emissiveFactor || null, doubleSided: m.doubleSided || false, extraKeys: Object.keys(m).filter( (k) => !["name", "pbrMetallicRoughness", "alphaMode", "emissiveFactor", "normalTexture", "occlusionTexture", "image", "extensions", "extras", "doubleSided", "alphaCutoff", "ior", "specularColorFactor"].includes(k) ), }; }); return { ext, mats, images }; } // ---------------- Part B: three.js rendered measurement ---------------- // IMPORTANT: attributes in these GLBs may be INTERLEAVED (byteStride > 0). // attribute.array[4*i+k] is WRONG for interleaved attributes; the // getX/getY/getZ/getW getters (and .getX etc. on InterleavedBufferAttribute) // apply the stride+offset. All per-component reads below go through getters. function pickSwimClip(clips) { if (clips.length === 0) return null; for (const c of clips) { const last = (c.name.split("|").pop() || c.name).trim().toLowerCase(); if (last === "swim") return c; } for (const c of clips) { if (c.name.toLowerCase().includes("swim")) return c; } return clips[0]; } function isUnderHidden(obj, hideSet) { let cur = obj; while (cur) { if (hideSet.has(cur.name)) return true; cur = cur.parent; } return false; } function threeBoxUnion(a, b) { a.min.min(b.min); a.max.max(b.max); return a; } // Describe an attribute's layout (to document interleaving per model). function attrDesc(a) { if (!a) return "missing"; if (a.isInterleavedBufferAttribute) { return `interleaved(stride=${a.data.stride}B, off=${a.offset}B, itemSize=${a.itemSize}, ${a.data.array.constructor.name})`; } return `packed(itemSize=${a.itemSize}, ${a.array.constructor.name})`; } async function measureRendered(name, rel) { const file = ROOT + rel; const bytes = new Uint8Array(fs.readFileSync(file)); const gltf = await new Promise((resolve, reject) => { new GLTFLoader().parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), "", resolve, reject); }); const model = gltf.scene || gltf.scenes[0]; model.updateMatrixWorld(true); const hideSet = new Set(HIDDEN_NODES[name] || []); const plain = []; const skinned = []; const layoutNotes = []; model.traverse((o) => { if (!o.isMesh) return; if (o.isSkinnedMesh && o.geometry.attributes.skinIndex) { layoutNotes.push( ` layout "${o.name}": pos=${attrDesc(o.geometry.attributes.position)} ` + `joints=${attrDesc(o.geometry.attributes.skinIndex)} weights=${attrDesc(o.geometry.attributes.skinWeight)}` ); } if (hideSet.size && isUnderHidden(o, hideSet)) return; (o.isSkinnedMesh ? skinned : plain).push(o); }); // The value the current app code (Box3.setFromObject on the whole scene) // computes — includes any scene extras (e.g. clownfish water surface). const box3 = new THREE.Box3().setFromObject(model); const s3 = box3.getSize(new THREE.Vector3()); // Fish-only box3 (scene extras excluded) — reference for the new hardcoded scheme. let box3Fish = null; if (hideSet.size) { box3Fish = new THREE.Box3(); model.traverse((o) => { if (!o.isMesh) return; if (isUnderHidden(o, hideSet)) return; if (o.geometry.boundingBox === null) o.geometry.computeBoundingBox(); if (o.geometry.boundingBox) { const b = o.geometry.boundingBox.clone().applyMatrix4(o.matrixWorld); box3Fish.union(b); } }); if (box3Fish.isEmpty()) box3Fish = null; } const tmp = new THREE.Vector3(); function evalBox() { const box = new THREE.Box3(); for (const m of plain) { if (m.geometry.boundingBox === null) m.geometry.computeBoundingBox(); const bb = m.geometry.boundingBox; if (!bb) continue; for (let i = 0; i < 8; i++) { tmp.set( (i & 1) ? bb.max.x : bb.min.x, (i & 2) ? bb.max.y : bb.min.y, (i & 4) ? bb.max.z : bb.min.z ); tmp.applyMatrix4(m.matrixWorld); box.expandByPoint(tmp); } } for (const sm of skinned) { const pos = sm.geometry.attributes.position; const joints = sm.geometry.attributes.skinIndex; const weights = sm.geometry.attributes.skinWeight; const bones = sm.skeleton ? sm.skeleton.bones : null; const inverses = sm.skeleton ? sm.skeleton.boneInverses : null; if (!pos || !joints || !weights || !bones || !inverses) continue; const bm = bones.map((b, bi) => new THREE.Matrix4().multiplyMatrices(b.matrixWorld, inverses[bi])); for (let i = 0; i < pos.count; i++) { let sx = 0, sy = 0, sz = 0; for (let k = 0; k < 4; k++) { // STRIDE-SAFE component reads (work for packed AND interleaved attrs) const w = k === 0 ? weights.getX(i) : k === 1 ? weights.getY(i) : k === 2 ? weights.getZ(i) : weights.getW(i); if (!w) continue; const j = k === 0 ? joints.getX(i) : k === 1 ? joints.getY(i) : k === 2 ? joints.getZ(i) : joints.getW(i); if (j < 0 || j >= bm.length) continue; tmp.set(pos.getX(i), pos.getY(i), pos.getZ(i)).applyMatrix4(bm[j]); sx += tmp.x * w; sy += tmp.y * w; sz += tmp.z * w; } tmp.set(sx, sy, sz); if (sm.bindMatrix) tmp.applyMatrix4(sm.bindMatrix); if (sm.matrixWorld) tmp.applyMatrix4(sm.matrixWorld); box.expandByPoint(tmp); } } return box.isEmpty() ? null : box; } const rest = evalBox(); const restSize = rest ? rest.getSize(new THREE.Vector3()) : null; // Swim animation: track max extent over 60 frames (at 30fps = 2s). let swimMax = null; const clip = pickSwimClip(gltf.animations || []); if (clip) { const mixer = new THREE.AnimationMixer(model); mixer.clipAction(clip).setLoop(THREE.LoopRepeat, Infinity).play(); for (let f = 0; f < 60; f++) { mixer.update(1 / 30); model.updateMatrixWorld(true); const b = evalBox(); if (!b) continue; swimMax = swimMax ? threeBoxUnion(swimMax, b) : b.clone(); } } const swimSize = swimMax ? swimMax.getSize(new THREE.Vector3()) : null; return { box3Longest: box3.isEmpty() ? null : Math.max(s3.x, s3.y, s3.z), box3Size: { x: +s3.x.toFixed(4), y: +s3.y.toFixed(4), z: +s3.z.toFixed(4) }, box3FishLongest: box3Fish ? +Math.max(...box3Fish.getSize(new THREE.Vector3()).toArray()).toFixed(4) : null, restLongest: restSize ? Math.max(restSize.x, restSize.y, restSize.z) : null, restSize: restSize ? { x: +restSize.x.toFixed(4), y: +restSize.y.toFixed(4), z: +restSize.z.toFixed(4) } : null, swimLongest: swimSize ? Math.max(swimSize.x, swimSize.y, swimSize.z) : null, swimSize: swimSize ? { x: +swimSize.x.toFixed(4), y: +swimSize.y.toFixed(4), z: +swimSize.z.toFixed(4) } : null, clipCount: (gltf.animations || []).length, layoutNotes, }; } const fmtNum = (n, d = 4) => (n === null || n === undefined ? "-" : n.toFixed(d)); // ---------------- main ---------------- const summary = []; const summaryRendered = {}; for (const [name, rel] of Object.entries(MODELS)) { const file = ROOT + rel; console.log(`\n=== ${name} (${rel}) ===`); let measured = null; let rest = null; let box3 = null; let swim = null; try { const raw = measureRaw(name, rel); const { json } = raw; console.log(` file size: ${(fs.statSync(file).size / 1024).toFixed(1)} kB`); console.log(` nodes: ${json.nodes ? json.nodes.length : 0}, meshes: ${json.meshes ? json.meshes.length : 0}, animations: ${(json.animations || []).length}`); const mat = dumpMaterials(json); console.log(` extensions used: [${mat.ext.used.join(", ")}] required: [${mat.ext.required.join(", ")}]`); console.log(` images: ${mat.images.length ? mat.images.map((im) => `${im.name}(${im.mime}, ${im.bytes}B)`).join(", ") : "(none)"}`); for (const m of mat.mats) { const parts = [ `[${m.i}] "${m.name}"`, `baseColor=${JSON.stringify(m.baseColorFactor)}`, m.baseColorTexture ? `tex=${m.baseColorTexture}` : "noTex", `metal=${typeof m.metallic === "number" ? +m.metallic.toFixed(3) : m.metallic}`, `rough=${typeof m.roughness === "number" ? +m.roughness.toFixed(3) : m.roughness}`, m.alphaMode !== "OPAQUE" ? `alpha=${m.alphaMode}` : "", m.emissive ? `emissive=${JSON.stringify(m.emissive)}` : "", m.doubleSided ? "doubleSided" : "", ]; if (m.extraKeys.length) parts.push(`extra=[${m.extraKeys.join(",")}]`); console.log(` material ${parts.filter(Boolean).join(" ")}`); } for (const m of raw.meshes) { const nodeDesc = m.nodes.length ? m.nodes.map((n) => `${n.name}(scale ${n.scale.join(",")})`).join(" ") : "(no node)"; const matIdx = m.prims.map((p) => (p.material === null || p.material === undefined ? "?" : p.material)).join(","); console.log( ` mesh "${m.name}" verts=${m.verts} mat=[${matIdx}] node=${nodeDesc}\n` + ` raw ${fmtBox(m.raw)}\n` + ` world ${fmtBox(m.world)}` ); } console.log(` OVERALL raw ${fmtBox(raw.overallRaw)}`); console.log(` OVERALL world ${fmtBox(raw.overallWorld)}`); // Part B: what does three.js actually render? try { const r = await measureRendered(name, rel); for (const note of r.layoutNotes) console.log(note); console.log( ` [three] box3(whole scene, app's value) longest=${fmtNum(r.box3Longest)} ${JSON.stringify(r.box3Size)}` + (r.box3FishLongest !== null ? ` | box3(fish only) longest=${fmtNum(r.box3FishLongest)}` : "") + ` | ` + `rest longest=${fmtNum(r.restLongest)} ${JSON.stringify(r.restSize)} | ` + `swimMax longest=${fmtNum(r.swimLongest)} ${JSON.stringify(r.swimSize)} | clips=${r.clipCount}` ); const rendered = Math.max(r.restLongest || 0, r.swimLongest || 0) || null; measured = boxLongest(raw.overallWorld); rest = r.restLongest; box3 = r.box3Longest; swim = r.swimLongest; summaryRendered[name] = rendered; if (measured && rendered) { console.log(` [three] rendered/measured(raw world) = ${(rendered / measured).toFixed(4)}`); } } catch (e) { console.log(` [three] measurement failed: ${e.message}`); } } catch (e) { console.log(` ERROR: ${e.message}`); } summary.push({ name, measured, box3, rest, swim }); } console.log("\n================ SUMMARY (candidates for src/main.ts) ================"); console.log(" hardcoded `longest` should be the model's TRUE rendered extent (max of rest & swimMax),"); console.log(" measured stride-safe. For clownfish this is the fish body only (scene extras excluded)."); for (const s of summary) { const rendered = summaryRendered[s.name]; console.log( ` ${s.name.padEnd(12)} rawWorld=${fmtNum(s.measured)} box3(app)=${fmtNum(s.box3)} ` + `restRendered=${fmtNum(s.rest)} swimMax=${fmtNum(s.swim)} ` + `-> hardcodedLongest=${rendered === undefined ? "?" : fmtNum(rendered)}` ); }