- 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
585 lines
23 KiB
JavaScript
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)}`
|
|
);
|
|
}
|