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>
117 lines
8.1 KiB
JavaScript
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)`);
|