feat(models): 骨骼重绑定——修复 MMD 模型 humanoid 恒为 0/22(契约 skeleton-topology 0.2.0)

根因:normalizeBoneName() 只保留 a-z0-9,**中日文字符被整个丢掉**:
'上半身'/'左ひじ'/'首' 归一化后都是空串,MMD 模型的骨名必然失配,
humanoid 覆盖率恒为 0/22 -> 不可驱动 -> 只能回退交付 GLB 并标记不可用。

修复:
1. 归一化保留 CJK/平假名/片假名/长音符/々,并把全角 ASCII(足IK)折成半角;
2. 候选表补齐四类命名(22 根 x 8-13 个候选):VRoid / Mixamo /
   MMD 标准骨架(下半身/上半身/上半身2/首/頭/左肩/左腕/左ひじ/左手首/
   左足/左ひざ/左足首/左つま先)/ 常见中文名(臀部/头/脖子/左大臂/左大腿…);
   精确匹配保证「左足IK」不会抢占「左足」;
3. Windows 客户端 humanoid_map.gd 的 CANDIDATES 由服务端表**生成写入**,
   normalize() 同步改造,parity 测试断言键/候选顺序完全一致;
4. 契约 skeleton-topology -> 0.2.0:新增 §6「内部动作标准与模型骨骼重绑定」
   (三层标准、22/16 根骨、归一化与匹配规则、实测记录);
5. 新增 4 项测试;规模 17 文件 / 229 项。

实测:奥黛塔 PMX 0/22 -> 21/22、初音未来 PMX 0/22 -> 19/22,均 drivable=true 且产出 VRM。
版本 0.4.0。
This commit is contained in:
opendance
2026-09-17 00:10:07 +08:00
parent 39bfd390e3
commit 9eae6f3e43
14 changed files with 243 additions and 51 deletions
+56
View File
@@ -406,6 +406,62 @@ describe('morphTargets 统计', () => {
});
});
/* --------------------- 5.5 MMD / 中文骨名映射(T-WEB-013 骨骼重绑定) */
describe('MMD 与中文骨名映射(T-WEB-013)', () => {
async function statsFor(names: string[], file: string): Promise<ModelStats> {
const doc = buildHumanoidDocument(names);
return inspectModelFile(write(file, Buffer.from(JSON.stringify(doc))));
}
it('归一化必须保留中日文,并把全角 ASCII 折成半角', () => {
expect(normalizeBoneName('上半身')).toBe('上半身');
expect(normalizeBoneName('左ひじ')).toBe('左ひじ');
expect(normalizeBoneName('首')).toBe('首');
// MMD 的「足IK」是全角字母:折成半角后是 足ik,与「左足」精确匹配不上(安全)
expect(normalizeBoneName('左足IK')).toBe('左足ik');
expect(normalizeBoneName('J_Bip_C_Hips')).toBe('jbipchips');
});
it('MMD 标准骨架名 → 16 根必需骨齐全、drivable=true(0/22 的修复点)', async () => {
const stats = await statsFor(
[
'全ての親', 'センター', '下半身', '上半身', '上半身2', '首', '頭',
'左肩', '左腕', '左ひじ', '左手首', '右肩', '右腕', '右ひじ', '右手首',
'左足', '左ひざ', '左足首', '右足', '右ひざ', '右足首',
],
'mmd-standard.gltf',
);
expect(stats.missingRequiredBones).toEqual([]);
expect(stats.drivable).toBe(true);
expect(stats.humanoidMapped).toBeGreaterThanOrEqual(16);
});
it('MMD 的 IK 骨(足IK / つま先IK)不得抢占 humanoid 骨', async () => {
const stats = await statsFor(
['下半身', '上半身', '首', '頭', '左足IK', '左つま先IK'],
'mmd-ik.gltf',
);
expect(stats.missingRequiredBones).toContain('leftUpperLeg');
expect(stats.missingRequiredBones).toContain('leftFoot');
expect(stats.drivable).toBe(false);
});
it('中文骨名也能映射(臀部/头/脖子/左大腿/左脚)', async () => {
const stats = await statsFor(
[
'臀部', '脊椎', '胸部', '脖子', '头',
'左大臂', '左小臂', '左手', '右大臂', '右小臂', '右手',
'左大腿', '左小腿', '左脚', '右大腿', '右小腿', '右脚',
],
'chinese-names.gltf',
);
expect(stats.missingRequiredBones).toEqual([]);
expect(stats.drivable).toBe(true);
});
});
/* ------------------------------- 6. 与客户端 humanoid_map.gd 的一致性 */
/** gd 文件在客户端仓库里;按「从 web-server 运行」为主,同时兼容从仓库根运行 */