SCENEGOD/scenegod/web/room3d_test.mjs
type-two 7fea880286 [m7-A] non-mixamo rig support + world-integrated backdrops (+4 sync7 render fixes)
Lane A: bone-alias layer so CC_Base_*/Unreal-HumanIK/Rigify/Rokoko/Biped rigs
fold onto the canonical mixamo names — lady.glb pairs 52/102 bones and
retargets (53 tracks) where she previously threw 'missing hips'; mixamo→mixamo
pairing asserted bit-identical. Backdrops gain frustum auto-fit, floor tint
sampled from the plate, and FogExp2 in the plate's colour via a keyable
param. Also caught: _buildLight ran _refreshDefaults before registration, so
scene lights never dimmed the built-in hemi (every preset was washed out).

Orchestrator SYNC-7 fixes (found only by rendering actual frames):
- editor chrome (gizmo/grid/helpers/cam proxies) was baked into output mp4s
- timeline's per-tick rest transform dragged fitted plates out of frame
- fit used the viewport aspect, output renders at its own → letterboxed plates
- plate offset put its top edge on the lens axis → void above centre-frame;
  now params.fitAnchor (default 0.75) picks the row that sits at eye level

Verified: 5 suites green; 1280x720 render shows a CC character dancing a mixamo
clip against an edge-to-edge Flow plate with a horizon and matched ground.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 22:21:37 +10:00

117 lines
8.1 KiB
JavaScript

// room3d_test.mjs — Lane A M7-A1 check: bone aliasing + the retarget guardrail.
// Runs under plain `node`, no deps and no three.js: it loads the REAL room3d.js with the three
// imports stripped (nothing at module top level touches THREE), so the tables under test are the
// shipped ones — not a copy that can drift.
// node scenegod/web/room3d_test.mjs
import assert from 'node:assert/strict';
import fs from 'node:fs';
const src = fs.readFileSync(new URL('./room3d.js', import.meta.url), 'utf8')
.replace(/^import .*from ['"]three(\/[^'"]*)?['"];$/gm, '');
const { canon, pairBones, rigStyle } = await import(
'data:text/javascript;base64,' + Buffer.from(src).toString('base64'));
// ---- 1. BACKWARD COMPAT: every mixamo bone canons exactly as the pre-M7 one-liner did ----------
const legacy = n => n.replace(/^.*?mixamorig\d*[:_]?/i, '').replace(/[^a-z0-9]/gi, '').toLowerCase();
const MIXAMO = ['Hips','Spine','Spine1','Spine2','Neck','Head','HeadTop_End',
...['Left','Right'].flatMap(s => [s+'Shoulder', s+'Arm', s+'ForeArm', s+'Hand', s+'UpLeg', s+'Leg',
s+'Foot', s+'ToeBase', s+'Toe_End',
...['Thumb','Index','Middle','Ring','Pinky'].flatMap(f => [1,2,3,4].map(i => `${s}Hand${f}${i}`))])];
assert.equal(MIXAMO.length, 65, 'the man.fbx bone list');
for(const n of MIXAMO) for(const ns of ['mixamorig:', 'mixamorig_', 'mixamorig4:', 'mixamorig1_', ''])
assert.equal(canon(ns + n), legacy(ns + n), `mixamo canon changed: ${ns + n}`);
assert.equal(canon('mixamorig4:LeftHand'), 'lefthand', 'per-download namespace still collapses');
assert.equal(canon('mixamorig:Hips'), 'hips');
assert.equal(canon('mixamorig:LeftToe_End'), 'lefttoeend', 'end effectors stay legacy (unmapped)');
// ---- 2. Character Creator (the real lady.glb names, verified out of the GLB's JSON chunk) -------
const CC = ['CC_Base_BoneRoot_01','CC_Base_Hip_02','CC_Base_Pelvis_03','CC_Base_L_Thigh_04',
'CC_Base_L_Calf_05','CC_Base_L_Foot_06','CC_Base_L_ToeBaseShareBone_07','CC_Base_L_ToeBase_08',
'CC_Base_L_CalfTwist01_014','CC_Base_R_Thigh_018','CC_Base_R_Calf_019','CC_Base_R_Foot_021',
'CC_Base_R_ToeBase_022','CC_Base_Waist_033','CC_Base_Spine01_034','CC_Base_Spine02_035',
'CC_Base_NeckTwist01_036','CC_Base_NeckTwist02_037','CC_Base_Head_038','CC_Base_L_Eye_046',
'CC_Base_L_Clavicle_049','CC_Base_L_Upperarm_050','CC_Base_L_Forearm_051',
'CC_Base_L_ForearmTwist01_052','CC_Base_L_Hand_055','CC_Base_L_Pinky1_056','CC_Base_L_Mid1_062',
'CC_Base_L_Thumb1_068','CC_Base_L_Breast_074','CC_Base_R_Clavicle_077','CC_Base_R_Upperarm_078',
'CC_Base_R_Forearm_079','CC_Base_R_Hand_083','CC_Base_R_Index3_095',
'CC_Base_R_UpperarmTwist02_0100'];
for(const [name, want] of [
['CC_Base_Hip_02','hips'], ['CC_Base_L_Thigh_04','leftupleg'], ['CC_Base_L_Calf_05','leftleg'],
['CC_Base_L_Foot_06','leftfoot'], ['CC_Base_L_ToeBase_08','lefttoebase'],
['CC_Base_R_Thigh_018','rightupleg'], ['CC_Base_Waist_033','spine'],
['CC_Base_Spine01_034','spine1'], ['CC_Base_Spine02_035','spine2'],
['CC_Base_NeckTwist01_036','neck'], ['CC_Base_Head_038','head'],
['CC_Base_L_Clavicle_049','leftshoulder'], ['CC_Base_L_Upperarm_050','leftarm'],
['CC_Base_L_Forearm_051','leftforearm'], ['CC_Base_L_Hand_055','lefthand'],
['CC_Base_R_Hand_083','righthand'], ['CC_Base_L_Thumb1_068','lefthandthumb1'],
['CC_Base_L_Mid1_062','lefthandmiddle1'], ['CC_Base_R_Index3_095','righthandindex3'],
['CC_Base_L_Pinky1_056','lefthandpinky1']])
assert.equal(canon(name), want, `CC ${name}${canon(name)}, want ${want}`);
// twist / share / cosmetic bones have no mixamo counterpart: they must stay UNMAPPED, not guess
for(const n of ['CC_Base_L_CalfTwist01_014','CC_Base_L_ToeBaseShareBone_07','CC_Base_L_Eye_046',
'CC_Base_L_Breast_074','CC_Base_L_ForearmTwist01_052','CC_Base_R_UpperarmTwist02_0100',
'CC_Base_BoneRoot_01','CC_Base_NeckTwist02_037'])
assert.ok(!canon(n).match(/^(hips|spine|neck|head|left|right)/), `${n} must stay unmapped, got ${canon(n)}`);
// ---- 3. Unreal / HumanIK, Rigify, Unity-VRM / Rokoko, plain lowercase --------------------------
for(const [name, want] of [
// Unreal mannequin / HumanIK
['pelvis','hips'], ['spine_01','spine'], ['spine_02','spine1'], ['spine_03','spine2'],
['neck_01','neck'], ['head','head'], ['clavicle_l','leftshoulder'], ['upperarm_l','leftarm'],
['lowerarm_l','leftforearm'], ['hand_r','righthand'], ['thigh_r','rightupleg'],
['calf_l','leftleg'], ['foot_r','rightfoot'], ['ball_l','lefttoebase'],
['thumb_01_l','lefthandthumb1'], ['index_03_r','righthandindex3'],
// Blender Rigify (deform bones are what gets skinned)
['DEF-spine','spine'], ['DEF-spine.001','spine1'], ['DEF-spine.002','spine2'],
['DEF-shoulder.L','leftshoulder'], ['DEF-upper_arm.L','leftarm'], ['DEF-forearm.R','rightforearm'],
['DEF-hand.L','lefthand'], ['DEF-thigh.R','rightupleg'], ['DEF-shin.L','leftleg'],
['DEF-foot.L','leftfoot'], ['DEF-toe.R','righttoebase'], ['DEF-thumb.01.L','lefthandthumb1'],
['DEF-f_index.02.R','righthandindex2'], ['ORG-upper_arm.R','rightarm'],
// Unity humanoid / VRM / Rokoko-style long names + plain lowercase
['LeftUpperArm','leftarm'], ['RightLowerArm','rightforearm'], ['LeftUpperLeg','leftupleg'],
['RightLowerLeg','rightleg'], ['Chest','spine1'], ['UpperChest','spine2'], ['Hips','hips'],
['leftshoulder','leftshoulder'], ['l_hand','lefthand'], ['R_Foot','rightfoot'],
['Bip01 Pelvis','hips'], ['Bip01 L Clavicle','leftshoulder']])
assert.equal(canon(name), want, `${name}${canon(name)}, want ${want}`);
// side-less limbs stay unmapped (guessing a side would silently mirror a character)
for(const n of ['thigh','upperarm','hand','shoulder','lowerarm','toe'])
assert.ok(!canon(n).match(/^(left|right)/), `side-less "${n}" must stay unmapped, got ${canon(n)}`);
// ---- 4. pairing: a CC target against a mixamo source ------------------------------------------
const boneList = names => names.map(name => ({ name }));
const mapOf = names => { const m = {};
for(const b of boneList(names)) if(!(canon(b.name) in m)) m[canon(b.name)] = b; return m; };
const mixamoSrc = mapOf(MIXAMO.map(n => 'mixamorig:' + n));
const ccPairs = pairBones(boneList(CC), mixamoSrc);
assert.ok(ccPairs.size >= 8, `CC target paired only ${ccPairs.size} bones`);
assert.equal(ccPairs.size, 26, 'every mappable bone in the CC sample pairs exactly once');
// (the full 102-bone lady.glb skeleton pairs 52: core + both arms/legs + all 30 finger bones —
// every twist/share/toe-detail/facial bone correctly stays unmapped)
// CC_Base_Hip wins 'hips'; CC_Base_Pelvis folds onto the same key and is left unbaked (not doubled)
const paired = [...ccPairs.keys()].map(b => b.name);
assert.ok(paired.includes('CC_Base_Hip_02') && !paired.includes('CC_Base_Pelvis_03'), 'hips dedupe');
// mixamo→mixamo is unaffected
assert.equal(pairBones(boneList(MIXAMO.map(n => 'mixamorig4:' + n)), mixamoSrc).size, 65);
// ---- 5. the guardrail: an unmappable rig fails loudly instead of baking soup -------------------
const junk = boneList(['CC_Base_BoneRoot_01', ...Array.from({length: 53}, (_, i) => `joint_x${i}`)]);
assert.throws(() => pairBones(junk, mixamoSrc), err => {
assert.match(err.message, /only \d+\/54 bones matched/, err.message);
assert.match(err.message, /CC_Base/, 'names the detected skeleton style');
assert.match(err.message, /first unmatched bone 'joint_x0'|first unmatched bone 'CC_Base_BoneRoot_01'/);
assert.match(err.message, /unsupported skeleton/);
return true;
}, 'a 0-8 match rig must throw');
// tiny synthetic skeletons (room3d _selftest builds a 3-bone pair) must still bake
assert.equal(pairBones(boneList(['mixamorig4_Hips','mixamorig4_Spine','mixamorig4_Head']),
mapOf(['mixamorig1_Hips','mixamorig1_Spine','mixamorig1_Head'])).size, 3, '_selftest skeletons');
// ---- 6. style detection -----------------------------------------------------------------------
assert.equal(rigStyle(CC), 'CC_Base');
assert.equal(rigStyle(MIXAMO.map(n => 'mixamorig:' + n)), 'mixamo');
assert.equal(rigStyle(['pelvis','spine_01','upperarm_l','thigh_r']), 'Unreal/HumanIK');
assert.equal(rigStyle(['DEF-spine','DEF-upper_arm.L']), 'Rigify');
assert.equal(rigStyle(['bone_a','bone_b']), 'unknown');
console.log(`room3d_test: ALL PASS (${ccPairs.size} CC bones paired against mixamo)`);