// room3d.js — loaders + Mixamo retarget, ported from MESHGOD rigroom.html. // Pure module: no DOM, no globals, no toast — throw Errors, callers handle UI. // The bake algorithm is byte-for-byte rigroom's; only the target is now a // parameter instead of a closed-over global. import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; import { BVHLoader } from 'three/addons/loaders/BVHLoader.js'; export async function parseAny(buf, name){ name = (name||'').toLowerCase(); // extensionless routes (gallery//glb) + magic-byte fallback: gltf binary starts "glTF" if(!/\.(glb|gltf|fbx|obj|bvh)$/.test(name)){ const head = new TextDecoder().decode(new Uint8Array(buf, 0, Math.min(16, buf.byteLength))); if(head.startsWith('glTF')) name += '.glb'; else if(head.includes('Kaydara')) name += '.fbx'; else if(head.includes('HIERARCHY')) name += '.bvh'; else name += '.glb'; // worst case the loader throws a clear error } if(name.endsWith('.glb') || name.endsWith('.gltf')){ const g = await new GLTFLoader().parseAsync(buf, ''); return {root:g.scene, anims:g.animations||[]}; } if(name.endsWith('.fbx')){ const r = new FBXLoader().parse(buf, ''); return {root:r, anims:r.animations||[]}; } if(name.endsWith('.obj')){ const r = new OBJLoader().parse(new TextDecoder().decode(buf)); r.traverse(o=>{ if(o.isMesh && !o.material.map) o.material = new THREE.MeshStandardMaterial({color:0x9aa4af, roughness:.7}); }); return {root:r, anims:[]}; } if(name.endsWith('.bvh')){ const r = new BVHLoader().parse(new TextDecoder().decode(buf)); const root = new THREE.Object3D(); root.add(r.skeleton.bones[0]); return {root, anims:[r.clip]}; } throw new Error('unsupported type: '+name); } export function stats(root){ let tris=0, meshes=0, bones=0; root.traverse(o=>{ if(o.isMesh){ meshes++; const g=o.geometry; tris += (g.index ? g.index.count : g.attributes.position.count)/3; } if(o.isBone) bones++; }); return {tris:Math.round(tris), meshes, bones}; } export function disposeRoot(root){ root.traverse(o=>{ if(o.geometry) o.geometry.dispose(); const m=o.material; if(m)(Array.isArray(m)?m:[m]).forEach(mt=>{ for(const k in mt){ const v=mt[k]; if(v && v.isTexture) v.dispose(); } mt.dispose(); }); }); } // canonical bone key: "mixamorig:LeftUpLeg" / "mixamorig_LeftUpLeg" / "mixamorig4LeftUpLeg" → "leftupleg" // (mixamo numbers the namespace per-download — strip digits too). Encodes real Mixamo quirks; keep as-is. export const canon = n => n.replace(/^.*?mixamorig\d*[:_]?/i,'').replace(/[^a-z0-9]/gi,'').toLowerCase(); export function boneMap(root){ const m={}; root.traverse(o=>{ if(o.isBone && !(canon(o.name) in m)) m[canon(o.name)] = o; }); return m; } const hipsOf = map => map['hips'] || null; // bind-pose snapshot — retargets are world-space deltas against THIS. Capture BEFORE any clip poses it. export function captureRest(root){ root.updateMatrixWorld(true); const rest = new Map(), order = []; root.traverse(o=>{ if(o.isBone){ order.push(o); rest.set(o, { wq:o.getWorldQuaternion(new THREE.Quaternion()), lq:o.quaternion.clone(), lp:o.position.clone() }); } }); return {rest, order}; } // WORLD-SPACE rotation-delta transfer, baked at 30fps. Raw quaternion copying only works when both // rests share bone axes (fbx→fbx). Blender-exported GLB rigs orient bones differently, so we sample // the SOURCE through the clip and move each target bone by the source bone's world-space delta. export function bakeRetarget(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts={}){ const tgt = boneMap(tgtRoot), src = boneMap(srcRoot); const tHips = hipsOf(tgt), sHips = hipsOf(src); if(!tHips || !sHips) throw new Error('retarget: missing hips (mixamorig skeleton?)'); const sRest = srcRest.rest, tRest = tgtRest.rest, tOrder = tgtRest.order; const pairs = new Map(); // target bone → source bone. Dedupe by canon key: fbx files often const used = new Set(); // carry a DUPLICATE hierarchy — bind only the first (what boneMap picks) for(const tb of tOrder){ const k=canon(tb.name), sb=src[k]; if(sb && sRest.has(sb) && !used.has(k)){ pairs.set(tb, sb); used.add(k); } } if(!pairs.size) throw new Error('retarget: no matching bones'); let ratio=1; // hips travel: local hip-height ratio (cm fbx vs m glb sorts itself out) if(sHips.position.y > 1e-6) ratio = tHips.position.y / sHips.position.y; const fps=30, n=Math.max(2, Math.ceil(clip.duration*fps)+1); const times = new Float32Array(n); const qData = new Map([...pairs.keys()].map(tb=>[tb, new Float32Array(n*4)])); const pData = new Float32Array(n*3); const mixerS = new THREE.AnimationMixer(srcRoot); mixerS.clipAction(clip).play(); const q1=new THREE.Quaternion(), q2=new THREE.Quaternion(), q3=new THREE.Quaternion(); for(let i=0;i new THREE.QuaternionKeyframeTrack(`${tb.name}.quaternion`, times, qData.get(tb))); tracks.push(new THREE.VectorKeyframeTrack(`${tHips.name}.position`, times, pData)); return new THREE.AnimationClip(clip.name, clip.duration, tracks); } // ---- self-check: two 3-bone skeletons with DIFFERENT rests, bake a 1s clip across, assert the // world-space rotation DELTA the target reproduces matches the source's, within 1e-3. -------------- function _mkSkel(prefix, restEuler){ // hips -> spine -> head, each 1 unit up its parent. restEuler tilts each bone's rest differently. const names = ['Hips','Spine','Head']; let parent=null, root=null; const bones=[]; names.forEach((nm,i)=>{ const b = new THREE.Bone(); b.name = prefix+nm; b.position.set(0, i===0 ? 1.0 : 1.0, 0); // hips at y=1 so ratio is well-defined b.rotation.set(restEuler[i][0], restEuler[i][1], restEuler[i][2]); if(parent) parent.add(b); else root=b; parent=b; bones.push(b); }); const wrap = new THREE.Object3D(); wrap.add(root); wrap.updateMatrixWorld(true); return {wrap, bones}; } export function _selftest(){ // underscore namespaces (not colon) so three's PropertyBinding binds the clips; canon() still // strips "mixamorig1_" / "mixamorig4_" to the same key, which is what we're actually testing. const src = _mkSkel('mixamorig1_', [[0,0,0],[0.1,0,0.2],[0,0.15,0]]); const tgt = _mkSkel('mixamorig4_', [[0,0.3,0],[-0.2,0,0.1],[0.05,0,-0.1]]); // different namespace + rests const srcRest = captureRest(src.wrap), tgtRest = captureRest(tgt.wrap); // source clip: rotate the spine over 1s const times = new Float32Array([0, 0.5, 1.0]); const q0=new THREE.Quaternion(), q1=new THREE.Quaternion().setFromEuler(new THREE.Euler(0.6,0,0.3)); const qm=q0.clone().slerp(q1,0.5); const vals=new Float32Array([q0.x,q0.y,q0.z,q0.w, qm.x,qm.y,qm.z,qm.w, q1.x,q1.y,q1.z,q1.w]); const clip = new THREE.AnimationClip('t', 1.0, [new THREE.QuaternionKeyframeTrack(src.bones[1].name+'.quaternion', times, vals)]); const baked = bakeRetarget(clip, src.wrap, srcRest, tgt.wrap, tgtRest); const srcMix = new THREE.AnimationMixer(src.wrap); srcMix.clipAction(clip).play(); const tgtMix = new THREE.AnimationMixer(tgt.wrap); tgtMix.clipAction(baked).play(); const delta=(bone,rest)=>bone.getWorldQuaternion(new THREE.Quaternion()) .multiply(rest.wq.clone().invert()); let maxErr=0, srcMoved=0; for(const t of [0, 0.5, 1.0]){ srcMix.setTime(t); src.wrap.updateMatrixWorld(true); tgtMix.setTime(t); tgt.wrap.updateMatrixWorld(true); for(let i=0;i<3;i++){ const ds=delta(src.bones[i], srcRest.rest.get(src.bones[i])); const dt=delta(tgt.bones[i], tgtRest.rest.get(tgt.bones[i])); let d = Math.abs(ds.x*dt.x + ds.y*dt.y + ds.z*dt.z + ds.w*dt.w); // |dot|→1 when equal (mod sign) maxErr = Math.max(maxErr, 1 - d); srcMoved = Math.max(srcMoved, 2*Math.acos(Math.min(1, Math.abs(ds.w)))); // world-delta angle (rad) } } const ok = maxErr < 1e-3 && srcMoved > 0.05; // guard: a non-posing source (e.g. unbound clip) would pass trivially return {ok, maxErr, srcMoved}; }