// 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)`);