根因: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。
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, resolve } from 'node:path';
|
||
import { afterAll, describe, expect, it } from 'vitest';
|
||
import {
|
||
HUMANOID_BONES,
|
||
HUMANOID_CANDIDATES,
|
||
HUMANOID_REQUIRED,
|
||
STRIPPABLE_PREFIXES,
|
||
boneNameVariants,
|
||
inspectModelFile,
|
||
normalizeBoneName,
|
||
} from '../src/models/model-inspector';
|
||
import type { ModelStats } from '../src/models/model-inspector';
|
||
|
||
/**
|
||
* 模型检查器测试。
|
||
*
|
||
* 全部夹具都在代码里构造(GLB 二进制 / glTF JSON),不依赖外部文件、不联网。
|
||
* 另有一条「与客户端一致」用例直接读 `opendance-windows/src/model/humanoid_map.gd`
|
||
* 的源码做逐键比对 —— 客户端改了名字映射,这里必须立刻失败。
|
||
*/
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'opendance-inspector-'));
|
||
|
||
afterAll(() => {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
function write(name: string, content: Buffer): string {
|
||
const path = join(dir, name);
|
||
writeFileSync(path, content);
|
||
return path;
|
||
}
|
||
|
||
/** 构造合法 GLB:12 字节头 + JSON chunk(+ 可选 BIN chunk),chunk 按 4 字节对齐 */
|
||
function buildGlb(json: unknown, bin?: Buffer): Buffer {
|
||
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
||
const jsonChunk = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]);
|
||
|
||
const chunks: Buffer[] = [];
|
||
const header = Buffer.alloc(12);
|
||
header.write('glTF', 0, 'ascii');
|
||
header.writeUInt32LE(2, 4);
|
||
chunks.push(header);
|
||
|
||
const jsonHeader = Buffer.alloc(8);
|
||
jsonHeader.writeUInt32LE(jsonChunk.length, 0);
|
||
jsonHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON'
|
||
chunks.push(jsonHeader, jsonChunk);
|
||
|
||
if (bin && bin.length > 0) {
|
||
const binChunk = Buffer.concat([bin, Buffer.alloc((4 - (bin.length % 4)) % 4)]);
|
||
const binHeader = Buffer.alloc(8);
|
||
binHeader.writeUInt32LE(binChunk.length, 0);
|
||
binHeader.writeUInt32LE(0x004e4942, 4); // 'BIN\0'
|
||
chunks.push(binHeader, binChunk);
|
||
}
|
||
|
||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||
const out = Buffer.concat(chunks, total);
|
||
out.writeUInt32LE(total, 8);
|
||
return out;
|
||
}
|
||
|
||
/** 所有统计数字必须是有限整数(不允许 NaN / Infinity / 小数) */
|
||
function expectIntegerStats(stats: ModelStats): void {
|
||
const numericKeys: Array<keyof ModelStats> = [
|
||
'fileSize',
|
||
'meshes',
|
||
'primitives',
|
||
'vertices',
|
||
'triangles',
|
||
'materials',
|
||
'textures',
|
||
'images',
|
||
'animations',
|
||
'morphTargets',
|
||
'nodes',
|
||
'bones',
|
||
'skins',
|
||
'humanoidMapped',
|
||
'humanoidTotal',
|
||
'maxBoneDepth',
|
||
];
|
||
for (const key of numericKeys) {
|
||
const value = stats[key];
|
||
expect(Number.isInteger(value), `${String(key)} 必须是有限整数,实际为 ${String(value)}`).toBe(true);
|
||
}
|
||
for (const bone of stats.boneTree) {
|
||
expect(Number.isInteger(bone.index)).toBe(true);
|
||
expect(Number.isInteger(bone.depth)).toBe(true);
|
||
expect(Number.isInteger(bone.childCount)).toBe(true);
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------- 1. GLB 基础统计 + 骨骼树 */
|
||
|
||
/** VRoid 风格的最小夹具:1 mesh(POSITION=3 / indices=3)、1 material、1 image、1 skin */
|
||
function basicGlb(): Buffer {
|
||
return buildGlb(
|
||
{
|
||
asset: { version: '2.0' },
|
||
scene: 0,
|
||
scenes: [{ nodes: [0] }],
|
||
nodes: [
|
||
{ name: 'J_Bip_C_Hips', children: [1] },
|
||
{ name: 'J_Bip_L_UpperArm', children: [2] },
|
||
{ name: 'J_Bip_L_LowerArm' },
|
||
],
|
||
skins: [{ joints: [0, 1, 2] }],
|
||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
|
||
accessors: [{ count: 3 }, { count: 3 }],
|
||
materials: [{}],
|
||
images: [{}],
|
||
},
|
||
// 顺带覆盖 BIN chunk:解析器应跳过它而不受影响
|
||
Buffer.from([1, 2, 3, 4, 5, 6]),
|
||
);
|
||
}
|
||
|
||
describe('GLB 基础统计与骨骼树', () => {
|
||
it('顶点/面数/材质/skin 与 depth、parentName、childCount 正确', async () => {
|
||
const glb = basicGlb();
|
||
const stats = await inspectModelFile(write('basic.glb', glb));
|
||
|
||
expect(stats.container).toBe('glb');
|
||
expect(stats.vrmVersion).toBeNull();
|
||
expect(stats.fileSize).toBe(glb.length);
|
||
expect(stats.vertices).toBe(3);
|
||
expect(stats.triangles).toBe(1);
|
||
expect(stats.materials).toBe(1);
|
||
expect(stats.images).toBe(1);
|
||
expect(stats.textures).toBe(0);
|
||
expect(stats.meshes).toBe(1);
|
||
expect(stats.primitives).toBe(1);
|
||
expect(stats.skins).toBe(1);
|
||
expect(stats.nodes).toBe(3);
|
||
expect(stats.bones).toBe(3);
|
||
expect(stats.warnings).toEqual([]);
|
||
|
||
expect(stats.boneTree).toHaveLength(3);
|
||
expect(
|
||
stats.boneTree.map((bone) => ({
|
||
name: bone.name,
|
||
depth: bone.depth,
|
||
parentName: bone.parentName,
|
||
childCount: bone.childCount,
|
||
inSkin: bone.inSkin,
|
||
})),
|
||
).toEqual([
|
||
{ name: 'J_Bip_C_Hips', depth: 0, parentName: null, childCount: 1, inSkin: true },
|
||
{ name: 'J_Bip_L_UpperArm', depth: 1, parentName: 'J_Bip_C_Hips', childCount: 1, inSkin: true },
|
||
{ name: 'J_Bip_L_LowerArm', depth: 2, parentName: 'J_Bip_L_UpperArm', childCount: 0, inSkin: true },
|
||
]);
|
||
expect(stats.maxBoneDepth).toBe(2);
|
||
|
||
// 三根节点都应按名字映射上 humanoid 规范骨
|
||
expect(stats.boneTree.map((bone) => bone.humanoid)).toEqual(['hips', 'leftUpperArm', 'leftLowerArm']);
|
||
expect(stats.humanoidMapped).toBeGreaterThanOrEqual(3);
|
||
expect(stats.humanoidTotal).toBe(22);
|
||
expectIntegerStats(stats);
|
||
});
|
||
|
||
it('传入 fileSize 时优先采用传入值', async () => {
|
||
const stats = await inspectModelFile(write('sized.glb', basicGlb()), 123456);
|
||
expect(stats.fileSize).toBe(123456);
|
||
});
|
||
|
||
it('没有 scenes 时以「无父节点」为根,多根各自 depth=0', async () => {
|
||
const doc = {
|
||
asset: { version: '2.0' },
|
||
nodes: [
|
||
{ name: 'Hips', children: [1] },
|
||
{ name: 'Spine', children: [2] },
|
||
{ name: 'Chest' },
|
||
{ name: 'Prop' },
|
||
],
|
||
};
|
||
const stats = await inspectModelFile(write('multi-root.gltf', Buffer.from(JSON.stringify(doc))));
|
||
|
||
expect(stats.container).toBe('gltf');
|
||
expect(stats.boneTree.map((bone) => [bone.name, bone.depth])).toEqual([
|
||
['Hips', 0],
|
||
['Spine', 1],
|
||
['Chest', 2],
|
||
['Prop', 0],
|
||
]);
|
||
expect(stats.maxBoneDepth).toBe(2);
|
||
});
|
||
|
||
it('同名冲突取先出现的节点(父在前顺序)', async () => {
|
||
const doc = {
|
||
asset: { version: '2.0' },
|
||
scene: 0,
|
||
scenes: [{ nodes: [0] }],
|
||
nodes: [{ name: 'Hips', children: [1] }, { name: 'Spine', children: [2] }, { name: 'Hips' }],
|
||
};
|
||
const stats = await inspectModelFile(write('duplicate.gltf', Buffer.from(JSON.stringify(doc))));
|
||
const hips = stats.boneTree.find((bone) => bone.humanoid === 'hips');
|
||
expect(hips?.index).toBe(0);
|
||
expect(stats.boneTree[2].humanoid).toBeNull();
|
||
});
|
||
});
|
||
|
||
/* ------------------------------------------------------------- 2. VRM 识别 */
|
||
|
||
describe('VRM 识别', () => {
|
||
it('VRMC_vrm 扩展 → container=vrm / vrmVersion=1.0', async () => {
|
||
const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } });
|
||
const stats = await inspectModelFile(write('vrm1.vrm', glb));
|
||
expect(stats.container).toBe('vrm');
|
||
expect(stats.vrmVersion).toBe('1.0');
|
||
});
|
||
|
||
it('VRM 0.x 扩展(无 specVersion)→ vrmVersion=0.x', async () => {
|
||
const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRM: { meta: { title: 't' } } } });
|
||
const stats = await inspectModelFile(write('vrm0.vrm', glb));
|
||
expect(stats.container).toBe('vrm');
|
||
expect(stats.vrmVersion).toBe('0.x');
|
||
});
|
||
|
||
it('缺 meta 的 VRM 会给出提示', async () => {
|
||
const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } });
|
||
const stats = await inspectModelFile(write('vrm1-nometa.vrm', glb));
|
||
expect(stats.warnings.join(';')).toContain('meta');
|
||
});
|
||
});
|
||
|
||
/* --------------------------------------------- 3. 16 根必需 humanoid 骨 */
|
||
|
||
/** 用 VRoid 命名覆盖驱动客户端所需的 16 根 */
|
||
const REQUIRED_NODE_NAMES = [
|
||
'J_Bip_C_Hips',
|
||
'J_Bip_C_Spine',
|
||
'J_Bip_C_Neck',
|
||
'J_Bip_C_Head',
|
||
'J_Bip_L_UpperArm',
|
||
'J_Bip_L_LowerArm',
|
||
'J_Bip_L_Hand',
|
||
'J_Bip_R_UpperArm',
|
||
'J_Bip_R_LowerArm',
|
||
'J_Bip_R_Hand',
|
||
'J_Bip_L_UpperLeg',
|
||
'J_Bip_L_LowerLeg',
|
||
'J_Bip_L_Foot',
|
||
'J_Bip_R_UpperLeg',
|
||
'J_Bip_R_LowerLeg',
|
||
'J_Bip_R_Foot',
|
||
];
|
||
|
||
/** 串成一条链:所有骨骼都进 skin.joints,父子关系保证 depth 递增 */
|
||
function buildHumanoidDocument(names: string[]): unknown {
|
||
return {
|
||
asset: { version: '2.0' },
|
||
scene: 0,
|
||
scenes: [{ nodes: [0] }],
|
||
nodes: names.map((name, index) => (index + 1 < names.length ? { name, children: [index + 1] } : { name })),
|
||
skins: [{ joints: names.map((_, index) => index) }],
|
||
};
|
||
}
|
||
|
||
describe('必需 16 根 humanoid 骨', () => {
|
||
it('16 根齐全 → drivable=true、missingRequiredBones=[]', async () => {
|
||
const doc = buildHumanoidDocument(REQUIRED_NODE_NAMES);
|
||
const path = write('humanoid-complete.gltf', Buffer.from(JSON.stringify(doc)));
|
||
const stats = await inspectModelFile(path);
|
||
|
||
expect(stats.container).toBe('gltf');
|
||
expect(stats.nodes).toBe(16);
|
||
expect(stats.bones).toBe(16);
|
||
expect(stats.humanoidMapped).toBe(16);
|
||
expect(stats.missingRequiredBones).toEqual([]);
|
||
expect(stats.drivable).toBe(true);
|
||
expect(stats.boneTree.every((bone) => bone.humanoid !== null)).toBe(true);
|
||
expect(stats.maxBoneDepth).toBe(15);
|
||
expectIntegerStats(stats);
|
||
});
|
||
|
||
it('缺 leftFoot → drivable=false、missingRequiredBones 只含 leftFoot', async () => {
|
||
const names = REQUIRED_NODE_NAMES.filter((name) => name !== 'J_Bip_L_Foot');
|
||
const doc = buildHumanoidDocument(names);
|
||
const path = write('humanoid-no-left-foot.gltf', Buffer.from(JSON.stringify(doc)));
|
||
const stats = await inspectModelFile(path);
|
||
|
||
expect(stats.drivable).toBe(false);
|
||
expect(stats.missingRequiredBones).toEqual(['leftFoot']);
|
||
expect(stats.humanoidMapped).toBe(15);
|
||
});
|
||
});
|
||
|
||
/* --------------------------------------------------- 4. 非 glTF 容器降级 */
|
||
|
||
describe('非 glTF 容器', () => {
|
||
it('假 fbx 字节 → container=unknown、有 warnings、不抛错', async () => {
|
||
const fbx = Buffer.concat([
|
||
Buffer.from('Kaydara FBX Binary \x00', 'latin1'),
|
||
Buffer.from([0x1a, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||
]);
|
||
const stats = await inspectModelFile(write('fake.fbx', fbx));
|
||
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings.length).toBeGreaterThan(0);
|
||
expect(stats.humanoidMapped).toBe(0);
|
||
expect(stats.drivable).toBe(false);
|
||
expect(stats.missingRequiredBones).toEqual([...HUMANOID_REQUIRED]);
|
||
expectIntegerStats(stats);
|
||
});
|
||
|
||
it('假 pmx 字节 → container=unknown、有 warnings', async () => {
|
||
const pmx = Buffer.concat([Buffer.from('PMX ', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x50])]);
|
||
const stats = await inspectModelFile(write('fake.pmx', pmx));
|
||
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('合法 JSON 但不是 glTF → container=unknown', async () => {
|
||
const path = write('package.json', Buffer.from(JSON.stringify({ name: 'x', version: '1.0.0' })));
|
||
const stats = await inspectModelFile(path);
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings.join(';')).toContain('glTF');
|
||
});
|
||
});
|
||
|
||
/* ------------------------------------------------------ 5. 截断与健壮性 */
|
||
|
||
describe('截断与健壮性', () => {
|
||
it('截断一半的 GLB → 不抛错、container=unknown、warnings 非空', async () => {
|
||
const full = basicGlb();
|
||
const truncated = full.subarray(0, Math.floor(full.length / 2));
|
||
const stats = await inspectModelFile(write('truncated.glb', Buffer.from(truncated)));
|
||
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings.length).toBeGreaterThan(0);
|
||
expectIntegerStats(stats);
|
||
});
|
||
|
||
it('不是 GLB 且 JSON 截断 → 不抛错', async () => {
|
||
const path = write('broken.gltf', Buffer.from('{"asset":{"version":"2.0"'));
|
||
const stats = await inspectModelFile(path);
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('文件不存在 → 不抛错,记 warning', async () => {
|
||
const stats = await inspectModelFile(join(dir, 'no-such-model.glb'));
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings[0]).toContain('读取文件失败');
|
||
expect(stats.fileSize).toBe(0);
|
||
expect(stats.bones).toBe(0);
|
||
});
|
||
|
||
it('文件不存在但传了 fileSize → 仍返回该大小', async () => {
|
||
const stats = await inspectModelFile(join(dir, 'no-such-model-2.glb'), 2048);
|
||
expect(stats.fileSize).toBe(2048);
|
||
expect(stats.warnings.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('缺 accessor / 空文档也不产生 NaN', async () => {
|
||
const doc = {
|
||
asset: { version: '2.0' },
|
||
meshes: [{ primitives: [{ attributes: { POSITION: 99 }, indices: 42 }, {}] }],
|
||
accessors: [{ count: 'not-a-number' }],
|
||
};
|
||
const stats = await inspectModelFile(write('bad-accessor.gltf', Buffer.from(JSON.stringify(doc))));
|
||
expect(stats.vertices).toBe(0);
|
||
expect(stats.triangles).toBe(0);
|
||
expect(stats.primitives).toBe(2);
|
||
expect(stats.warnings.length).toBeGreaterThan(0);
|
||
expectIntegerStats(stats);
|
||
});
|
||
|
||
it('GLB magic 正确但版本不是 2 → 降级 unknown', async () => {
|
||
const glb = basicGlb();
|
||
glb.writeUInt32LE(1, 4);
|
||
const stats = await inspectModelFile(write('glb-v1.glb', glb));
|
||
expect(stats.container).toBe('unknown');
|
||
expect(stats.warnings.join(';')).toContain('版本');
|
||
});
|
||
});
|
||
|
||
/* -------------------------------------------------------- 7. morphTargets */
|
||
|
||
describe('morphTargets 统计', () => {
|
||
it('所有 primitive 的 targets 数量之和', async () => {
|
||
const glb = buildGlb({
|
||
asset: { version: '2.0' },
|
||
meshes: [
|
||
{
|
||
primitives: [
|
||
{ attributes: { POSITION: 0 }, targets: [{ POSITION: 1 }, { POSITION: 2 }] },
|
||
{ attributes: { POSITION: 0 } },
|
||
],
|
||
},
|
||
],
|
||
accessors: [{ count: 6 }, { count: 6 }, { count: 6 }],
|
||
});
|
||
const stats = await inspectModelFile(write('morph.glb', glb));
|
||
|
||
expect(stats.morphTargets).toBe(2);
|
||
expect(stats.primitives).toBe(2);
|
||
expect(stats.vertices).toBe(12);
|
||
expect(stats.triangles).toBe(4);
|
||
expectIntegerStats(stats);
|
||
});
|
||
});
|
||
|
||
/* --------------------- 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 运行」为主,同时兼容从仓库根运行 */
|
||
function findHumanoidMap(): string {
|
||
const candidates = [
|
||
join(__dirname, '..', '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
|
||
resolve(process.cwd(), '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
|
||
resolve(process.cwd(), '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
|
||
resolve(process.cwd(), 'opendance-windows', 'src', 'model', 'humanoid_map.gd'),
|
||
];
|
||
for (const candidate of candidates) {
|
||
if (existsSync(candidate)) return candidate;
|
||
}
|
||
throw new Error(`找不到 opendance-windows/src/model/humanoid_map.gd,候选路径:\n${candidates.join('\n')}`);
|
||
}
|
||
|
||
const HUMANOID_MAP_PATH = findHumanoidMap();
|
||
const GD_SOURCE = readFileSync(HUMANOID_MAP_PATH, 'utf8');
|
||
|
||
function parseGdStringList(raw: string): string[] {
|
||
return raw
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter((item) => item.length > 0)
|
||
.map((item) => item.replace(/^"|"$/g, ''));
|
||
}
|
||
|
||
/** 抽 `const NAME ... = [ "a", "b", ... ]` 形式的字符串数组 */
|
||
function extractGdStringList(source: string, constName: string): string[] {
|
||
const match = new RegExp(`const\\s+${constName}\\s*:[^=]*=\\s*\\[([\\s\\S]*?)\\]`).exec(source);
|
||
if (!match) throw new Error(`humanoid_map.gd 里找不到 ${constName}`);
|
||
return parseGdStringList(match[1]);
|
||
}
|
||
|
||
/** 抽 `const CANDIDATES := { "canonical": ["a", "b"], ... }` */
|
||
function extractGdCandidates(source: string): Record<string, string[]> {
|
||
const match = /const\s+CANDIDATES\s*:=\s*\{([\s\S]*?)\n\}/.exec(source);
|
||
if (!match) throw new Error('humanoid_map.gd 里找不到 CANDIDATES');
|
||
const out: Record<string, string[]> = {};
|
||
const entry = /"([A-Za-z0-9_]+)"\s*:\s*\[([^\]]*)\]/g;
|
||
let hit: RegExpExecArray | null;
|
||
while ((hit = entry.exec(match[1])) !== null) {
|
||
out[hit[1]] = parseGdStringList(hit[2]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
describe(`与客户端 humanoid_map.gd 一致(${HUMANOID_MAP_PATH})`, () => {
|
||
it('CANDIDATES 逐键一致(键顺序与候选顺序都要一致)', () => {
|
||
const gd = extractGdCandidates(GD_SOURCE);
|
||
expect(Object.keys(gd).length).toBeGreaterThan(0);
|
||
// 键顺序也要一致:服务端按 HUMANOID_BONES 顺序做映射与提示
|
||
expect(Object.keys(HUMANOID_CANDIDATES)).toEqual(Object.keys(gd));
|
||
for (const canonical of Object.keys(gd)) {
|
||
expect(HUMANOID_CANDIDATES[canonical], `候选表 ${canonical} 与客户端不一致`).toEqual(gd[canonical]);
|
||
}
|
||
});
|
||
|
||
it('HUMANOID_BONES 与客户端一致,humanoidTotal 就是它的长度', () => {
|
||
expect([...HUMANOID_BONES]).toEqual(extractGdStringList(GD_SOURCE, 'HUMANOID_BONES'));
|
||
expect(HUMANOID_BONES.length).toBe(22);
|
||
});
|
||
|
||
it('STRIPPABLE_PREFIXES 与客户端一致', () => {
|
||
expect([...STRIPPABLE_PREFIXES]).toEqual(extractGdStringList(GD_SOURCE, 'STRIPPABLE_PREFIXES'));
|
||
});
|
||
|
||
it('必需 16 根都能在客户端候选表里找到,且互不重复', () => {
|
||
const gd = extractGdCandidates(GD_SOURCE);
|
||
expect(HUMANOID_REQUIRED).toHaveLength(16);
|
||
expect(new Set(HUMANOID_REQUIRED).size).toBe(16);
|
||
for (const bone of HUMANOID_REQUIRED) {
|
||
expect(Object.keys(gd)).toContain(bone);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('normalize / variants 行为与客户端一致', () => {
|
||
it('VRoid / Mixamo / Bip01 三种前缀的展开与缩写', () => {
|
||
expect(normalizeBoneName('J_Bip_L_UpperArm')).toBe('jbiplupperarm');
|
||
expect(boneNameVariants('J_Bip_L_UpperArm')).toEqual(['lupperarm', 'lupperarm', 'leftupperarm']);
|
||
expect(normalizeBoneName('mixamorig:LeftArm')).toBe('mixamorigleftarm');
|
||
expect(boneNameVariants('mixamorig:LeftArm')).toEqual(['leftarm', 'larm', 'leftarm']);
|
||
expect(boneNameVariants('Bip01 L Thigh')).toEqual(['lthigh', 'lthigh', 'leftthigh']);
|
||
expect(boneNameVariants('J_Bip_C_Hips')).toEqual(['chips', 'chips', 'chips']);
|
||
});
|
||
|
||
it('不剥离 J_Aim_ 等扭转辅助骨前缀(否则会撞上真正的骨骼)', () => {
|
||
expect(boneNameVariants('J_Aim_L_UpperLeg')).toEqual([
|
||
'jaimlupperleg',
|
||
'jaimlupperleg',
|
||
'jaimlupperleg',
|
||
]);
|
||
const allCandidates = Object.values(HUMANOID_CANDIDATES).flat();
|
||
expect(allCandidates).not.toContain('jaimlupperleg');
|
||
});
|
||
});
|