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:
@@ -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 运行」为主,同时兼容从仓库根运行 */
|
||||
|
||||
Reference in New Issue
Block a user