需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
- model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
**骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
- model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
- vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
- gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
(服务端判定的"可驱动" = 客户端实际能否驱动)
需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
→(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf
需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
**骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程
其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
顶层开发书更新为 v1.4
验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
506 lines
19 KiB
TypeScript
506 lines
19 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);
|
||
});
|
||
});
|
||
|
||
/* ------------------------------- 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');
|
||
});
|
||
});
|