// Fix for the "giant fish" bug in the Poly Pizza GLB models (betta, blue_tang, flower_horn). // // ROOT CAUSE // Each poly-pizza GLB has two 100x-scaled node hierarchies under RootNode: // - Fish_Armature (scale=100) -> parent of the skeleton bones // - (scale=100) -> the node that owns the mesh + skin // three.js binds skinned meshes with an identity bindMatrix and uses the GLB's // inverseBindMatrices verbatim (Skeleton.init() only recomputes them if absent). // Those inverses were authored for a UNIT-scale bind, but the 100x node scales // inflate every bone's matrixWorld by 100x, so the shader product // (boneMatrixWorld * inverseBindMatrix) inflates skinned vertices ~100x relative // to the rest geometry. Box3.setFromObject (raw geometry) does NOT include that, // so GLTFish's `scale = targetLength / Box3` ends up rendering ~100x too big. // // FIX (pure JSON-chunk edit; BIN chunk + all 6 animations preserved untouched) // - Fish_Armature scale 100 -> S (bone parent: scales BOTH Box3 and rendered) // - scale 100 -> 1 (mesh node: only affects rendered; keep 1 so Box3==rendered) // // Measured relationship (two 100x nodes: A=Fish_Armature, B=): // M (Box3.setFromObject) = base * A // R (true rendered) = base * A * B // So setting B=1 and A=S=1/base gives M = R = 1.0, i.e. R/M = 1 and longest dim = 1. // After this, `targetLength` directly controls fish size, matching the working models. // // Re-run-safe note: run once. Backups of the originals are in /tmp/opencode/backup-poly-pizza. import fs from "node:fs"; import * as THREE from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; const MODELS = { 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", }; const root = new URL("..", import.meta.url).pathname; // ---------------- GLB binary helpers ---------------- function readGLB(path) { const bytes = new Uint8Array(fs.readFileSync(path)); const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); if (dv.getUint32(0, true) !== 0x46546c67) throw new Error("not a GLB (bad magic): " + path); const jsonLen = dv.getUint32(12, true); if (String.fromCharCode(...bytes.slice(16, 20)) !== "JSON") throw new Error("bad JSON chunk: " + path); const jsonBytes = bytes.slice(20, 20 + jsonLen); const binStart = 20 + jsonLen; const binLen = dv.getUint32(binStart, true); if (String.fromCharCode(...bytes.slice(binStart + 4, binStart + 8)) !== "BIN\0") throw new Error("bad BIN chunk: " + path); const binBytes = bytes.slice(binStart + 8, binStart + 8 + binLen); return { json: JSON.parse(new TextDecoder().decode(jsonBytes)), binBytes, binLen }; } function buildGLB(jsonObj, binBytes, binLen) { const jsonRaw = new TextEncoder().encode(JSON.stringify(jsonObj)); const paddedJsonLen = (jsonRaw.length + 3) & ~3; const jsonPadded = new Uint8Array(paddedJsonLen).fill(0x20); jsonPadded.set(jsonRaw, 0); const total = 12 + 8 + paddedJsonLen + 8 + binLen; const out = new Uint8Array(total); const dv = new DataView(out.buffer); dv.setUint32(0, 0x46546c67, true); // "glTF" dv.setUint32(4, 2, true); // version 2 dv.setUint32(8, total, true); // total length dv.setUint32(12, paddedJsonLen, true); out.set(new TextEncoder().encode("JSON"), 16); out.set(jsonPadded, 20); const binStart = 20 + paddedJsonLen; dv.setUint32(binStart, binLen, true); out.set(new TextEncoder().encode("BIN"), binStart + 4); // 4th byte stays 0x00 -> "BIN\0" out.set(binBytes, binStart + 8); return out; } // ---------------- measurement (exact three.js skinning formula) ---------------- function toArrayBuffer(buf) { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } function loadGLB(file) { return new Promise((res, rej) => { new GLTFLoader().parse(toArrayBuffer(fs.readFileSync(file)), "", res, (e) => rej(e)); }); } function pickClip(clips) { if (!clips.length) return null; return clips.find((x) => /swim/i.test(x.name)) || clips[0]; } function renderedLongest(model, mixer, frames) { const skinned = []; model.traverse((o) => { if (o.isSkinnedMesh) skinned.push(o); }); const box = new THREE.Box3(); const tmp = new THREE.Vector3(); let maxL = 0; for (let f = 0; f <= frames; f++) { if (mixer) mixer.update(1 / 30); model.updateMatrixWorld(true); box.makeEmpty(); for (const sm of skinned) { const pos = sm.geometry.attributes.position, joints = sm.geometry.attributes.skinIndex, weights = sm.geometry.attributes.skinWeight; const bones = sm.skeleton.bones, inverses = sm.skeleton.boneInverses; if (!bones || !inverses || !pos) continue; const bm = bones.map((b) => new THREE.Matrix4().multiplyMatrices(b.matrixWorld, inverses[bones.indexOf(b)])); for (let i = 0; i < pos.count; i++) { let sx = 0, sy = 0, sz = 0; for (let k = 0; k < 4; k++) { const w = weights.array[4 * i + k]; if (!w) continue; const j = joints.array[4 * i + k]; 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); } } const s = box.getSize(new THREE.Vector3()); maxL = Math.max(maxL, Math.max(s.x, s.y, s.z)); } return maxL; } // ---------------- per-model fix ---------------- async function fixOne(name, rel) { const path = root + rel; // 1) Compute S: load original, set all 100x nodes to 1, measure the natural rest longest dim. const gltf = await loadGLB(path); const model = gltf.scene ?? gltf.scenes[0]; model.traverse((o) => { if (o.scale && o.scale.x === 100 && o.scale.y === 100 && o.scale.z === 100) o.scale.set(1, 1, 1); }); model.updateMatrixWorld(true); const l1 = new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()); const l1l = Math.max(l1.x, l1.y, l1.z); const S = 1 / l1l; // 2) Edit the raw GLB JSON chunk. const { json, binBytes, binLen } = readGLB(path); let meshNode = null, armatureNode = null; for (const n of json.nodes || []) { if (n.scale && n.scale[0] === 100 && n.scale[1] === 100 && n.scale[2] === 100) { if (n.mesh !== undefined) { n.scale = [1, 1, 1]; meshNode = n; } // mesh node -> 1 else { n.scale = [S, S, S]; armatureNode = n; } // bone parent -> S } } if (!meshNode || !armatureNode) throw new Error(`expected one mesh-node + one armature-node at scale 100; got mesh=${!!meshNode} armature=${!!armatureNode}`); // 3) Write the corrected GLB. fs.writeFileSync(path, buildGLB(json, binBytes, binLen)); // 4) Verify: reload and confirm Box3 ~ 1, rendered ~ 1 (R/M ~ 1), animations intact. const g2 = await loadGLB(path); const m2 = g2.scene ?? g2.scenes[0]; const M2 = new THREE.Box3().setFromObject(m2).getSize(new THREE.Vector3()); const M2l = Math.max(M2.x, M2.y, M2.z); const clip2 = pickClip(g2.animations); const mixer2 = new THREE.AnimationMixer(m2); let R2 = 0; if (clip2) { mixer2.clipAction(clip2).play(); R2 = renderedLongest(m2, mixer2, 30); } const clips = (g2.animations || []).length; const ok = M2l > 0.95 && M2l < 1.05 && R2 / M2l > 0.95 && R2 / M2l < 1.1 && clips > 0; console.log( `${name}: rest-longest@1x=${l1l.toFixed(5)} -> S=${S.toFixed(4)} | ` + `${armatureNode.name}(bones)=${S.toFixed(3)}, ${meshNode.name}(mesh)=1 | ` + `Box3=${M2l.toFixed(4)} R(swim)=${R2.toFixed(4)} R/M=${(R2 / M2l).toFixed(3)} clips=${clips} ${ok ? "OK" : "** CHECK **"} | ` + `targetLength 0.5 renders ${((0.5 / M2l) * R2).toFixed(3)}` ); return ok; } const results = []; for (const [name, rel] of Object.entries(MODELS)) { try { results.push(await fixOne(name, rel)); } catch (e) { console.log(`${name}: ERROR ${e.message}`); results.push(false); } } console.log(results.every(Boolean) ? "\nALL 3 FIXED & VERIFIED" : "\nSOME FAILED"); process.exit(results.every(Boolean) ? 0 : 1);