diff --git a/public/models/poly-pizza/betta.glb b/public/models/poly-pizza/betta.glb index fc8aa78..bf0136e 100644 Binary files a/public/models/poly-pizza/betta.glb and b/public/models/poly-pizza/betta.glb differ diff --git a/public/models/poly-pizza/blue_tang.glb b/public/models/poly-pizza/blue_tang.glb index 2a29e8d..1530dc7 100644 Binary files a/public/models/poly-pizza/blue_tang.glb and b/public/models/poly-pizza/blue_tang.glb differ diff --git a/public/models/poly-pizza/flower_horn.glb b/public/models/poly-pizza/flower_horn.glb index 8a6d6c0..5cb8870 100644 Binary files a/public/models/poly-pizza/flower_horn.glb and b/public/models/poly-pizza/flower_horn.glb differ diff --git a/scripts/fix-model-scales.mjs b/scripts/fix-model-scales.mjs new file mode 100644 index 0000000..8bef7b8 --- /dev/null +++ b/scripts/fix-model-scales.mjs @@ -0,0 +1,160 @@ +// 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);