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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "opendance-admin",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"description": "OpenDance Studio 管理端(React + TypeScript + Vite)",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opendance-web-server",
"version": "0.3.0",
"version": "0.4.0",
"description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -12,7 +12,7 @@ export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze
'rest-api': '0.4.0',
'websocket-keypoints': '0.1.0',
'data-models': '0.5.0',
'skeleton-topology': '0.1.0',
'skeleton-topology': '0.2.0',
'model-formats': '0.5.0',
database: '0.3.0',
});
+83 -23
View File
@@ -143,21 +143,51 @@ export const STRIPPABLE_PREFIXES: readonly string[] = ['jbip', 'mixamorig', 'bip
* 与客户端 `humanoid_map.gd` 的 CANDIDATES 逐字一致(测试会逐键比对)。
*/
export const HUMANOID_CANDIDATES: Record<string, string[]> = {
hips: ['chips', 'hips', 'hip', 'pelvis', 'root'],
spine: ['cspine', 'spine', 'spine1', 'spinemid'],
chest: ['cchest', 'chest', 'spine2', 'spineupper'],
upperChest: ['cupperchest', 'upperchest', 'chest2', 'spine3'],
neck: ['cneck', 'neck', 'neck1'],
head: ['chead', 'head'],
leftShoulder: ['lshoulder', 'leftshoulder', 'shoulderl', 'shoulderleft', 'claviclel', 'lclavicle'],
leftUpperArm: ['lupperarm', 'leftupperarm', 'larm', 'leftarm', 'luparm'],
leftLowerArm: ['llowerarm', 'leftlowerarm', 'lforearm', 'leftforearm', 'lelbow'],
leftHand: ['lhand', 'lefthand', 'lwrist', 'leftwrist'],
rightShoulder: ['rshoulder', 'rightshoulder', 'shoulderr', 'shoulderright', 'clavicler', 'rclavicle'],
rightUpperArm: ['rupperarm', 'rightupperarm', 'rarm', 'rightarm', 'ruparm'],
rightLowerArm: ['rlowerarm', 'rightlowerarm', 'rforearm', 'rightforearm', 'relbow'],
rightHand: ['rhand', 'righthand', 'rwrist', 'rightwrist'],
leftUpperLeg: ['lupperleg', 'leftupperleg', 'lthigh', 'leftthigh', 'lupleg', 'lhip', 'lefthip'],
hips: ['chips', 'hips', 'hip', 'pelvis', 'root',
// MMD / 中文常见名
'下半身', '腰', 'センター', 'center', '臀部', '髋', '骨盆',],
spine: ['cspine', 'spine', 'spine1', 'spinemid',
// MMD / 中文常见名
'上半身', '脊椎', '脊柱',],
chest: ['cchest', 'chest', 'spine2', 'spineupper',
// MMD / 中文常见名
'上半身2', '胸部', '胸',],
upperChest: ['cupperchest', 'upperchest', 'chest2', 'spine3',
// MMD / 中文常见名
'上半身3',],
neck: ['cneck', 'neck', 'neck1',
// MMD / 中文常见名
'首', '颈', '脖子',],
head: ['chead', 'head',
// MMD / 中文常见名
'頭', '头', '头部',],
leftShoulder: ['lshoulder', 'leftshoulder', 'shoulderl', 'shoulderleft', 'claviclel', 'lclavicle',
// MMD / 中文常见名
'左肩', '左锁骨',],
leftUpperArm: ['lupperarm', 'leftupperarm', 'larm', 'leftarm', 'luparm',
// MMD / 中文常见名
'左腕', '左大臂', '左上臂', '左臂',],
leftLowerArm: ['llowerarm', 'leftlowerarm', 'lforearm', 'leftforearm', 'lelbow',
// MMD / 中文常见名
'左ひじ', '左肘', '左小臂', '左前臂',],
leftHand: ['lhand', 'lefthand', 'lwrist', 'leftwrist',
// MMD / 中文常见名
'左手首', '左手', '左手腕',],
rightShoulder: ['rshoulder', 'rightshoulder', 'shoulderr', 'shoulderright', 'clavicler', 'rclavicle',
// MMD / 中文常见名
'右肩', '右锁骨',],
rightUpperArm: ['rupperarm', 'rightupperarm', 'rarm', 'rightarm', 'ruparm',
// MMD / 中文常见名
'右腕', '右大臂', '右上臂', '右臂',],
rightLowerArm: ['rlowerarm', 'rightlowerarm', 'rforearm', 'rightforearm', 'relbow',
// MMD / 中文常见名
'右ひじ', '右肘', '右小臂', '右前臂',],
rightHand: ['rhand', 'righthand', 'rwrist', 'rightwrist',
// MMD / 中文常见名
'右手首', '右手', '右手腕',],
leftUpperLeg: ['lupperleg', 'leftupperleg', 'lthigh', 'leftthigh', 'lupleg', 'lhip', 'lefthip',
// MMD / 中文常见名
'左足', '左大腿',],
leftLowerLeg: [
'llowerleg',
'leftlowerleg',
@@ -167,10 +197,17 @@ export const HUMANOID_CANDIDATES: Record<string, string[]> = {
'leftshin',
'lknee',
'leftknee',
],
leftFoot: ['lfoot', 'leftfoot', 'lankle', 'leftankle'],
leftToes: ['ltoes', 'lefttoes', 'ltoe', 'lefttoe', 'ltoebase', 'lefttoebase'],
rightUpperLeg: ['rupperleg', 'rightupperleg', 'rthigh', 'rightthigh', 'rupleg', 'rhip', 'righthip'],
// MMD / 中文常见名
'左ひざ', '左膝', '左小腿', '左膝盖',],
leftFoot: ['lfoot', 'leftfoot', 'lankle', 'leftankle',
// MMD / 中文常见名
'左足首', '左脚', '左脚踝',],
leftToes: ['ltoes', 'lefttoes', 'ltoe', 'lefttoe', 'ltoebase', 'lefttoebase',
// MMD / 中文常见名
'左つま先', '左脚趾',],
rightUpperLeg: ['rupperleg', 'rightupperleg', 'rthigh', 'rightthigh', 'rupleg', 'rhip', 'righthip',
// MMD / 中文常见名
'右足', '右大腿',],
rightLowerLeg: [
'rlowerleg',
'rightlowerleg',
@@ -180,9 +217,14 @@ export const HUMANOID_CANDIDATES: Record<string, string[]> = {
'rightshin',
'rknee',
'rightknee',
],
rightFoot: ['rfoot', 'rightfoot', 'rankle', 'rightankle'],
rightToes: ['rtoes', 'righttoes', 'rtoe', 'righttoe', 'rtoebase', 'righttoebase'],
// MMD / 中文常见名
'右ひざ', '右膝', '右小腿', '右膝盖',],
rightFoot: ['rfoot', 'rightfoot', 'rankle', 'rightankle',
// MMD / 中文常见名
'右足首', '右脚', '右脚踝',],
rightToes: ['rtoes', 'righttoes', 'rtoe', 'righttoe', 'rtoebase', 'righttoebase',
// MMD / 中文常见名
'右つま先', '右脚趾',],
};
/* -------------------------------------------------------------- 客户端逻辑移植 */
@@ -196,7 +238,25 @@ export function normalizeBoneName(nodeName: string): string {
const lower = nodeName.toLowerCase();
let out = '';
for (const ch of lower) {
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) out += ch;
const code = ch.codePointAt(0) ?? 0;
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) {
out += ch;
continue;
}
// 全角 ASCII(A-Z a-z 0-9)折成半角:MMD 常写「足IK」「つま先IK」
if (code >= 0xff01 && code <= 0xff5e) {
out += String.fromCharCode(code - 0xfee0);
continue;
}
// **必须保留中文与日文**:MMD 骨名(上半身 / 左ひじ / 首 / 下半身)全是这些字符。
// v0.3.0 之前只保留 a-z0-9,导致 MMD 模型所有骨名归一化成空串、humanoid 覆盖率恒为 0/22。
if (
(code >= 0x3040 && code <= 0x30ff) || // 平假名 / 片假名 / 长音符
(code >= 0x4e00 && code <= 0x9fff) || // CJK 统一表意文字
code === 0x3005 // 々
) {
out += ch;
}
}
return out;
}
+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 运行」为主,同时兼容从仓库根运行 */