Files
Aquarium/scripts/measure-models.mjs
T
kamilkrzywda df4fb24822 fix: replace Box3 auto-scale with measured modelLength per species
- Remove unreliable Box3.setFromObject scaling (broke for skinned meshes,
  control rigs, and scene-containing GLBs)
- Use measured vertex extents (scripts/measure-models.mjs) as modelLength
- Clownfish: hide Surface/TopLight/Bubbles/Mirrored nodes (GLB is a full
  scene, not just a fish) — eliminates the white box + giant fish bug
- All 10 species now have accurate scale factors
2026-10-07 18:17:43 +00:00

585 lines
23 KiB
JavaScript

// 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)}`
);
}