Files
opendance-web/web-server/test/model-inspector.spec.ts
T
opendance 9eae6f3e43 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。
2026-09-17 00:10:07 +08:00

562 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});
});