diff --git a/scripts/measure-models.mjs b/scripts/measure-models.mjs new file mode 100644 index 0000000..f812cc9 --- /dev/null +++ b/scripts/measure-models.mjs @@ -0,0 +1,584 @@ +// 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)}` + ); +} diff --git a/src/main.ts b/src/main.ts index d25fc72..f35c74b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,39 +60,40 @@ function getSpeciesUrl(sp: FishSpecies): string { }; return urls[sp]; } -// Per-species correction factor between Box3.setFromObject's measured extent -// and the model's true rendered extent (see FishSpec.baseScale). Box3 measures -// skinned geometry in skeleton space (and may use a stale cached bounding box), -// which does not match the GPU's actual output (mesh.matrixWorld * skinning * -// position). baseScale = rendered / box3, so `targetLength / (box3 * baseScale)` -// normalizes each fish to its real rendered size. -// -// Measured with three.js's exact skinning formula per GLB. Most models are 1.0. -// The skinned blendswap oscar/knifefish deviate and are corrected here. The -// clownfish GLB is a full *scene* (water "Surface" 45.3, "TopLight" 15, "Bubbles" -// 3.4) plus a small skinned body (~8.3); box3 is dominated by the water plane, so -// a baseScale can't fix it without blowing up the scene geometry — left at 1.0 -// until the GLB is re-exported as a clean fish. -const SPECIES_SCALE: Record = { - angelfish: 1.0, - goldfish: 1.0, - guppy: 1.0, - betta: 1.0, - blue_tang: 1.0, - flower_horn: 1.0, - neon_tetra: 1.0, - oscar: 1.35, - knifefish: 2.58, - clownfish: 1.0, +// Per-species true longest rendered extent (model units), measured +// offline with scripts/measure-models.mjs (stride-safe rest-pose + +// swim-clip evaluation of the actual GLB data). Fish are scaled by +// targetLength / modelLength. This replaces the old +// Box3.setFromObject auto-measure, which under-reports armature-scaled +// models (oscar, knifefish) and for the clownfish is dominated by its +// 45.3-unit water plane (see FishSpec.modelLength). +const SPECIES_LENGTH: Record = { + angelfish: 4.2139, + goldfish: 1.7386, + guppy: 2.8070, + betta: 1.0228, + blue_tang: 1.0192, + flower_horn: 1.0516, + neon_tetra: 3.6548, + oscar: 8.8411, + knifefish: 23.0140, + clownfish: 8.2773, }; +// The clownfish GLB bundles a whole aquarium scene: an untextured white +// "Surface" water plane (45.3 units — the "white box"), a "TopLight" +// emissive slab, a "Bubbles" prop, and "Original-Mirrored", a mirrored +// duplicate of the body that overlaps the real "ClownFish" node. Hide +// everything except the fish itself. +const HIDDEN_CLOWNFISH_NODES = ["Surface", "TopLight", "Bubbles", "Original-Mirrored"]; function fishToSpec(f: FishData): FishSpec { return { url: getSpeciesUrl(f.species), targetLength: f.genetics.sizeMult * 0.5, + modelLength: SPECIES_LENGTH[f.species], speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult], orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined, genetics: f.genetics, - baseScale: SPECIES_SCALE[f.species], + hideNodes: f.species === "clownfish" ? HIDDEN_CLOWNFISH_NODES : undefined, }; } diff --git a/src/scene/GLTFish.ts b/src/scene/GLTFish.ts index 2641bad..8484cba 100644 --- a/src/scene/GLTFish.ts +++ b/src/scene/GLTFish.ts @@ -17,19 +17,31 @@ const FOOD_EAT = 0.15; export interface FishSpec { url: string; targetLength: number; + /** + * The model's true longest rendered extent in model units, measured + * offline with `scripts/measure-models.mjs` (stride-safe evaluation of + * rest pose + swim-clip extremes). The fish is scaled by + * `targetLength / modelLength`. + * + * This replaces the old `Box3.setFromObject` auto-measure, which is + * unreliable: it measures skinned geometry in skeleton space (an + * armature's scale/rotation is not in the mesh's matrixWorld and a + * stale cached bounding box may be used), so it under-reports + * armature-scaled models (e.g. knifefish 8.89 vs true 22.91, oscar + * 6.49 vs true 8.84), and for the clownfish GLB it is dominated by + * the model's 45.3-unit water plane instead of the 8.28-unit fish. + */ + modelLength: number; + /** + * Node names to hide after loading. The clownfish GLB is a whole + * aquarium scene: a 45.3-unit untextured white "Surface" water plane, + * a "TopLight" slab, a "Bubbles" prop, and a mirrored duplicate of the + * body ("Original-Mirrored") — only "ClownFish" is the fish itself. + */ + hideNodes?: string[]; orient?: { x?: number; y?: number; z?: number }; speed?: Range; genetics?: Genetics; - /** - * Correction factor between the extent reported by `Box3.setFromObject` - * and the fish's true rendered extent. `Box3.setFromObject` measures - * skinned geometry in skeleton space (and may use a stale cached - * bounding box), which does not match what the GPU actually renders - * (`mesh.matrixWorld * skinning * position`). baseScale = rendered / box3, - * so the final scale becomes `targetLength / (box3 * baseScale) = - * targetLength / rendered`, normalizing every fish to its real size. - */ - baseScale?: number; } // ---- Helpers ---- @@ -133,17 +145,21 @@ interface Fish { function buildFish(gltf: GLTF, spec: FishSpec): Fish { const model = gltf.scene ?? gltf.scenes[0]; + // Hide non-fish scene props before anything else (clownfish GLB bundles + // a full scene around the fish body). + if (spec.hideNodes) { + for (const name of spec.hideNodes) { + const node = model.getObjectByName(name); + if (node) node.visible = false; + } + } + const orient = new THREE.Group(); if (spec.orient) { orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0); } - model.updateMatrixWorld(true); - const box = new THREE.Box3().setFromObject(model); - const size = box.getSize(new THREE.Vector3()); - const longest = Math.max(size.x, size.y, size.z); - const baseScale = spec.baseScale ?? 1.0; - const scale = (spec.targetLength / (longest * baseScale)) * rand(0.92, 1.08); + const scale = (spec.targetLength / spec.modelLength) * rand(0.92, 1.08); orient.scale.setScalar(scale); orient.add(model);