feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 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 变为该模型
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterBoneTree } from '../admin/src/lib/bone-tree';
|
||||
import type { BoneNode } from '../admin/src/api/types';
|
||||
|
||||
/**
|
||||
* 只测 ModelReviewPanel 使用的纯函数 filterBoneTree。
|
||||
* 骨骼树是「扁平数组 + depth 缩进」渲染的,过滤时若丢掉祖先,树就会断裂,
|
||||
* 因此这里重点覆盖「命中 + 祖先链」的保留逻辑。
|
||||
*/
|
||||
|
||||
function bone(input: Partial<BoneNode> & Pick<BoneNode, 'name' | 'depth'>): BoneNode {
|
||||
return {
|
||||
index: 0,
|
||||
parentName: null,
|
||||
childCount: 0,
|
||||
inSkin: false,
|
||||
humanoid: null,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一棵典型的人形骨骼树(先序遍历,父在前):
|
||||
* - Hips → Spine → Chest → {LeftUpperArm→LeftLowerArm→LeftHand→LeftHand_IK, RightUpperArm, J_Bip_C_上半身→辅助骨骼_末端}
|
||||
* - LeftHand_IK / J_Bip_C_上半身 分支 / Root_End 都是不参与蒙皮的辅助节点。
|
||||
*/
|
||||
const TREE: BoneNode[] = [
|
||||
bone({ index: 0, name: 'Hips', depth: 0, parentName: null, childCount: 3, inSkin: true, humanoid: 'hips' }),
|
||||
bone({ index: 1, name: 'Spine', depth: 1, parentName: 'Hips', childCount: 1, inSkin: true, humanoid: 'spine' }),
|
||||
bone({ index: 2, name: 'Chest', depth: 2, parentName: 'Spine', childCount: 3, inSkin: true, humanoid: 'chest' }),
|
||||
bone({ index: 3, name: 'LeftUpperArm', depth: 3, parentName: 'Chest', childCount: 1, inSkin: true, humanoid: 'leftUpperArm' }),
|
||||
bone({ index: 4, name: 'LeftLowerArm', depth: 4, parentName: 'LeftUpperArm', childCount: 1, inSkin: true, humanoid: 'leftLowerArm' }),
|
||||
bone({ index: 5, name: 'LeftHand', depth: 5, parentName: 'LeftLowerArm', childCount: 1, inSkin: true, humanoid: 'leftHand' }),
|
||||
bone({ index: 6, name: 'LeftHand_IK', depth: 6, parentName: 'LeftHand', childCount: 0, inSkin: false }),
|
||||
bone({ index: 7, name: 'RightUpperArm', depth: 3, parentName: 'Chest', childCount: 0, inSkin: true, humanoid: 'rightUpperArm' }),
|
||||
bone({ index: 8, name: 'J_Bip_C_上半身', depth: 3, parentName: 'Chest', childCount: 1, inSkin: false }),
|
||||
bone({ index: 9, name: '辅助骨骼_末端', depth: 4, parentName: 'J_Bip_C_上半身', childCount: 0, inSkin: false }),
|
||||
bone({ index: 10, name: 'Root_End', depth: 1, parentName: 'Hips', childCount: 0, inSkin: false }),
|
||||
];
|
||||
|
||||
function names(nodes: readonly BoneNode[]): string[] {
|
||||
return nodes.map((node) => node.name);
|
||||
}
|
||||
|
||||
describe('filterBoneTree:搜索保留祖先链', () => {
|
||||
it('命中叶子节点时保留完整祖先链(含不参与蒙皮的祖先)', () => {
|
||||
const result = filterBoneTree(TREE, '辅助骨骼_末端');
|
||||
expect(names(result)).toEqual(['Hips', 'Spine', 'Chest', 'J_Bip_C_上半身', '辅助骨骼_末端']);
|
||||
// 祖先链上的节点一个都不能少,也不能混入无关分支
|
||||
expect(names(result)).not.toContain('LeftHand');
|
||||
expect(names(result)).not.toContain('Root_End');
|
||||
});
|
||||
|
||||
it('多个命中时取并集:各自的祖先只出现一次,且保持父在前', () => {
|
||||
const result = filterBoneTree(TREE, 'Arm');
|
||||
expect(names(result)).toEqual([
|
||||
'Hips',
|
||||
'Spine',
|
||||
'Chest',
|
||||
'LeftUpperArm',
|
||||
'LeftLowerArm',
|
||||
'RightUpperArm',
|
||||
]);
|
||||
expect(names(result)).not.toContain('LeftHand');
|
||||
expect(new Set(names(result)).size).toBe(result.length);
|
||||
});
|
||||
|
||||
it('大小写不敏感,且支持中英混排子串', () => {
|
||||
expect(names(filterBoneTree(TREE, 'leftlowerarm'))).toEqual([
|
||||
'Hips',
|
||||
'Spine',
|
||||
'Chest',
|
||||
'LeftUpperArm',
|
||||
'LeftLowerArm',
|
||||
]);
|
||||
expect(names(filterBoneTree(TREE, 'BIP_C_上半身'))).toEqual([
|
||||
'Hips',
|
||||
'Spine',
|
||||
'Chest',
|
||||
'J_Bip_C_上半身',
|
||||
]);
|
||||
expect(names(filterBoneTree(TREE, ' 左 '))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterBoneTree:仅显示蒙皮骨骼', () => {
|
||||
it('空关键词 + onlySkin:只留蒙皮骨骼(其祖先同为蒙皮),辅助节点被排除', () => {
|
||||
const result = filterBoneTree(TREE, '', true);
|
||||
expect(names(result)).toEqual([
|
||||
'Hips',
|
||||
'Spine',
|
||||
'Chest',
|
||||
'LeftUpperArm',
|
||||
'LeftLowerArm',
|
||||
'LeftHand',
|
||||
'RightUpperArm',
|
||||
]);
|
||||
expect(names(result)).not.toContain('LeftHand_IK');
|
||||
expect(names(result)).not.toContain('J_Bip_C_上半身');
|
||||
expect(names(result)).not.toContain('Root_End');
|
||||
});
|
||||
|
||||
it('onlySkin 只筛选「命中节点」,非蒙皮节点命中时不保留', () => {
|
||||
expect(names(filterBoneTree(TREE, 'IK', true))).toEqual([]);
|
||||
// 同一个关键字在关闭开关时能命中
|
||||
expect(names(filterBoneTree(TREE, 'IK', false))).toEqual([
|
||||
'Hips',
|
||||
'Spine',
|
||||
'Chest',
|
||||
'LeftUpperArm',
|
||||
'LeftLowerArm',
|
||||
'LeftHand',
|
||||
'LeftHand_IK',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterBoneTree:空关键词与边界', () => {
|
||||
it('空关键词返回全量(顺序不变,且是新数组)', () => {
|
||||
const result = filterBoneTree(TREE, '');
|
||||
expect(result).toEqual(TREE);
|
||||
expect(result).toHaveLength(TREE.length);
|
||||
expect(result).not.toBe(TREE);
|
||||
expect(filterBoneTree(TREE, ' ')).toEqual(TREE);
|
||||
});
|
||||
|
||||
it('无命中返回空数组', () => {
|
||||
expect(filterBoneTree(TREE, '不存在的骨骼')).toEqual([]);
|
||||
expect(filterBoneTree([], 'anything')).toEqual([]);
|
||||
});
|
||||
|
||||
it('depth 异常时退回 parentName 查找,祖先链依然完整', () => {
|
||||
const flat: BoneNode[] = [
|
||||
bone({ index: 0, name: 'root', depth: 0, parentName: null }),
|
||||
bone({ index: 1, name: 'child', depth: 0, parentName: 'root' }),
|
||||
bone({ index: 2, name: 'leaf', depth: 0, parentName: 'child' }),
|
||||
];
|
||||
expect(names(filterBoneTree(flat, 'leaf'))).toEqual(['root', 'child', 'leaf']);
|
||||
});
|
||||
});
|
||||
@@ -95,24 +95,17 @@ case "$cmd" in
|
||||
file="\${2:-}"
|
||||
if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then
|
||||
case "$file" in
|
||||
*.fbx)
|
||||
echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2
|
||||
exit 1
|
||||
;;
|
||||
*.fbx) echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "\${FAKE_INFO_FAIL:-}" = "all" ]; then
|
||||
echo "ERROR: Failed to load file" >&2; exit 1
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
export)
|
||||
target="\${3:-}"
|
||||
case "$target" in
|
||||
*.fbx)
|
||||
if [ "\${FAKE_FBX:-ok}" = "fail" ]; then
|
||||
echo "Failed to write file" >&2
|
||||
exit 11
|
||||
fi
|
||||
printf 'FAKE-FBX-CONTENT-0123456789' > "$target"
|
||||
;;
|
||||
*.glb)
|
||||
if [ "\${FAKE_GLB:-ok}" = "fail" ]; then
|
||||
echo "Failed to write file" >&2
|
||||
@@ -121,6 +114,16 @@ case "$cmd" in
|
||||
printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null
|
||||
if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi
|
||||
;;
|
||||
*.gltf)
|
||||
if [ "\${FAKE_GLTF:-fail}" = "fail" ]; then
|
||||
echo "Failed to write file" >&2
|
||||
exit 11
|
||||
fi
|
||||
# 写出 glTF JSON 与配套 .bin(打包器要能把它们合成 GLB)
|
||||
bin="\${target%.gltf}.bin"
|
||||
printf '\x01\x02\x03\x04\x05\x06\x07\x08' > "$bin"
|
||||
printf '%s' "\${FAKE_GLTF_JSON:-}" > "$target"
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
exit 0
|
||||
@@ -159,6 +162,38 @@ function buildGlbWithExternalImages(): Buffer {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 构造一个节点名能映射到 16 根 humanoid 必需骨骼的最小 GLB */
|
||||
function buildHumanoidGlb(): Buffer {
|
||||
const names = [
|
||||
'Hips', 'Spine', 'Neck', 'Head',
|
||||
'LeftUpperArm', 'LeftLowerArm', 'LeftHand',
|
||||
'RightUpperArm', 'RightLowerArm', 'RightHand',
|
||||
'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot',
|
||||
'RightUpperLeg', 'RightLowerLeg', 'RightFoot',
|
||||
];
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: names.map((name, index) => ({
|
||||
name,
|
||||
...(index + 1 < names.length ? { children: [index + 1] } : {}),
|
||||
})),
|
||||
};
|
||||
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
|
||||
const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20);
|
||||
jsonBuf.copy(jsonPadded);
|
||||
const total = 12 + 8 + jsonPadded.length;
|
||||
const out = Buffer.alloc(total);
|
||||
out.writeUInt32LE(0x46546c67, 0);
|
||||
out.writeUInt32LE(2, 4);
|
||||
out.writeUInt32LE(total, 8);
|
||||
out.writeUInt32LE(jsonPadded.length, 12);
|
||||
out.writeUInt32LE(0x4e4f534a, 16);
|
||||
jsonPadded.copy(out, 20);
|
||||
return out;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-'));
|
||||
fakeAssimp = join(workRoot, 'fake-assimp');
|
||||
@@ -284,22 +319,25 @@ describe('GLB 贴图内嵌', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => {
|
||||
const archive = () =>
|
||||
describe('压缩包导入与转换(用假 Assimp 做对照)', () => {
|
||||
/** 一个含 PMX 的最小模型包 */
|
||||
const pmxArchive = () =>
|
||||
buildZip([
|
||||
{ path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') },
|
||||
{ path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) },
|
||||
{ path: 'textures/b.png', content: Buffer.from([6, 7, 8]) },
|
||||
]);
|
||||
|
||||
// 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图),
|
||||
// 这样贴图内嵌路径也能被覆盖到
|
||||
const fakeGlbB64 = buildGlbWithExternalImages().toString('base64');
|
||||
|
||||
const runConvert = (env: Record<string, string>, preferFormat: 'fbx' | 'glb' = 'fbx') => {
|
||||
const runConvert = (
|
||||
archive: Buffer,
|
||||
env: Record<string, string> = {},
|
||||
preferFormat: 'glb' | 'vrm' = 'glb',
|
||||
) => {
|
||||
const previous = { ...process.env };
|
||||
Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env });
|
||||
return convertModelArchive(archive(), {
|
||||
return convertModelArchive(archive, {
|
||||
assimpBin: fakeAssimp,
|
||||
preferFormat,
|
||||
preferredPmx: 'Hatsune Miku.pmx',
|
||||
@@ -309,50 +347,88 @@ describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => {
|
||||
});
|
||||
};
|
||||
|
||||
it('FBX 导出成功且可回读 → 用 FBX', async () => {
|
||||
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' });
|
||||
expect(result.format).toBe(ModelFormat.Fbx);
|
||||
it('GLB 直出成功 → 交付 GLB 并内嵌贴图', async () => {
|
||||
const result = await runConvert(pmxArchive(), { FAKE_GLB: 'ok' });
|
||||
expect(result.format).toBe(ModelFormat.Glb);
|
||||
expect(result.direct).toBe(false);
|
||||
expect(result.sourceModelPath).toBe('Hatsune Miku.pmx');
|
||||
expect(result.attempts).toHaveLength(1);
|
||||
expect(result.note).toContain('已内嵌 2 张贴图');
|
||||
expect(result.attempts[0]).toContain('回读校验通过');
|
||||
});
|
||||
|
||||
it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => {
|
||||
const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' });
|
||||
it('GLB 直出失败 → 退回 glTF 导出再自行打包成 GLB', async () => {
|
||||
const gltfJson = JSON.stringify({
|
||||
asset: { version: '2.0' },
|
||||
buffers: [{ byteLength: 8, uri: 'converted.bin' }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 8 }],
|
||||
images: [{ uri: 'textures/a.png' }],
|
||||
meshes: [{ primitives: [{ attributes: {} }] }],
|
||||
});
|
||||
const result = await runConvert(pmxArchive(), {
|
||||
FAKE_GLB: 'fail',
|
||||
FAKE_GLTF: 'ok',
|
||||
FAKE_GLTF_JSON: gltfJson,
|
||||
});
|
||||
expect(result.format).toBe(ModelFormat.Glb);
|
||||
expect(result.attempts).toHaveLength(2);
|
||||
expect(result.attempts[0]).toContain('FBX');
|
||||
expect(result.attempts[0]).toContain('导出失败');
|
||||
expect(result.attempts[1]).toContain('GLB');
|
||||
expect(result.note).toContain('Hatsune Miku.pmx → GLB');
|
||||
// 贴图从压缩包里被内嵌进 GLB
|
||||
expect(result.note).toContain('已内嵌 2 张贴图');
|
||||
expect(result.attempts.some((line) => line.includes('GLB 直出:导出失败'))).toBe(true);
|
||||
expect(result.attempts.some((line) => line.includes('GLB 打包:由 glTF 打包成功'))).toBe(true);
|
||||
// 打包产物是合法 GLB(长度自洽)
|
||||
expect(result.buffer.readUInt32LE(0)).toBe(0x46546c67);
|
||||
expect(result.buffer.readUInt32LE(8)).toBe(result.buffer.length);
|
||||
});
|
||||
|
||||
it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => {
|
||||
// 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来
|
||||
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' });
|
||||
expect(result.attempts[0]).toContain('无法回读');
|
||||
expect(result.format).toBe(ModelFormat.Glb);
|
||||
expect(result.attempts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => {
|
||||
await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow(
|
||||
/模型转换失败[\s\S]*Assimp/,
|
||||
);
|
||||
});
|
||||
|
||||
it('压缩包里没有 PMX → 明确报错', async () => {
|
||||
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
|
||||
it('两种路径都失败 → 抛出可读错误并提示安装 Assimp', async () => {
|
||||
await expect(
|
||||
convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }),
|
||||
).rejects.toThrow(/没有找到 \.pmx/);
|
||||
runConvert(pmxArchive(), { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' }),
|
||||
).rejects.toThrow(/模型转换失败[\s\S]*Assimp/);
|
||||
});
|
||||
|
||||
it('preferFormat=vrm 且 humanoid 齐全 → 交付 VRM;不全 → 回退 GLB 并说明', async () => {
|
||||
// 含 16 根 humanoid 骨骼的 GLB
|
||||
const humanoidB64 = buildHumanoidGlb().toString('base64');
|
||||
const wrapped = await runConvert(pmxArchive(), { FAKE_GLB_B64: humanoidB64 }, 'vrm');
|
||||
expect(wrapped.format).toBe(ModelFormat.Vrm);
|
||||
expect(wrapped.note).toContain('VRM');
|
||||
|
||||
// 骨骼名对不上的 GLB → 回退 GLB
|
||||
const fallback = await runConvert(pmxArchive(), { FAKE_GLB_B64: fakeGlbB64 }, 'vrm');
|
||||
expect(fallback.format).toBe(ModelFormat.Glb);
|
||||
expect(fallback.attempts.some((line) => line.includes('VRM 封装:失败'))).toBe(true);
|
||||
});
|
||||
|
||||
it('包内是 .vrm / .glb 时直接导入,不做重编码', async () => {
|
||||
const vrmArchive = buildZip([
|
||||
{ path: 'sub/model.vrm', content: buildHumanoidGlb() },
|
||||
{ path: 'model.pmx', content: Buffer.from('PMX-BYTES') },
|
||||
]);
|
||||
const result = await runConvert(vrmArchive, { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' });
|
||||
// .vrm 优先级高于 .pmx:即使用 assimp 会失败也应当成功(因为根本不调用它)
|
||||
expect(result.direct).toBe(true);
|
||||
expect(result.format).toBe(ModelFormat.Vrm);
|
||||
expect(result.sourceModelPath).toBe('sub/model.vrm');
|
||||
expect(result.note).toContain('直接导入');
|
||||
});
|
||||
|
||||
it('包内只有 .glb 时直接导入为 GLB', async () => {
|
||||
const glbArchive = buildZip([{ path: 'a.glb', content: buildGlbWithExternalImages() }]);
|
||||
const result = await runConvert(glbArchive);
|
||||
expect(result.direct).toBe(true);
|
||||
expect(result.format).toBe(ModelFormat.Glb);
|
||||
});
|
||||
|
||||
it('压缩包里没有可用模型 → 明确报错', async () => {
|
||||
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
|
||||
await expect(convertModelArchive(zip, { assimpBin: fakeAssimp })).rejects.toThrow(
|
||||
/没有找到可用的模型文件/,
|
||||
);
|
||||
});
|
||||
|
||||
it('assimp 不存在时给出可读错误', async () => {
|
||||
await expect(
|
||||
convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }),
|
||||
convertModelArchive(pmxArchive(), {
|
||||
assimpBin: join(workRoot, 'no-such-assimp'),
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).rejects.toThrow(/模型转换失败/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderModelThumbnail } from '../src/models/model-thumbnail';
|
||||
|
||||
/**
|
||||
* 缩略图生成测试。
|
||||
* 所有夹具都在代码里构造,不联网、不依赖仓库外的文件。
|
||||
*/
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-thumb-'));
|
||||
|
||||
function write(name: string, content: Buffer): string {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 构造 GLB:12 字节头 + JSON chunk(空格补齐)+ 可选 BIN chunk(0 补齐) */
|
||||
function buildGlb(json: unknown, bin?: Buffer): Buffer {
|
||||
const jsonRaw = Buffer.from(JSON.stringify(json), 'utf8');
|
||||
const jsonPad = (4 - (jsonRaw.length % 4)) % 4;
|
||||
const jsonChunk = Buffer.concat([jsonRaw, Buffer.alloc(jsonPad, 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);
|
||||
chunks.push(jsonHeader, jsonChunk);
|
||||
|
||||
if (bin) {
|
||||
const binPad = (4 - (bin.length % 4)) % 4;
|
||||
const binChunk = Buffer.concat([bin, Buffer.alloc(binPad)]);
|
||||
const binHeader = Buffer.alloc(8);
|
||||
binHeader.writeUInt32LE(binChunk.length, 0);
|
||||
binHeader.writeUInt32LE(0x004e4942, 4);
|
||||
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;
|
||||
}
|
||||
|
||||
function float32Buffer(values: number[]): Buffer {
|
||||
const buffer = Buffer.alloc(values.length * 4);
|
||||
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function uint16Buffer(values: number[]): Buffer {
|
||||
const buffer = Buffer.alloc(values.length * 2);
|
||||
values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** 单个三角形、无索引的最小 GLB */
|
||||
function triangleGlb(): Buffer {
|
||||
const bin = float32Buffer([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.9, 0.4, 0.3, 1] } }],
|
||||
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
|
||||
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: bin.length }],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 8 顶点 12 三角形的立方体(每个面朝外 CCW),scale 控制大小 */
|
||||
function cubeGlb(scale: number): Buffer {
|
||||
const s = scale;
|
||||
const positions = [
|
||||
-s, -s, -s, s, -s, -s, s, s, -s, -s, s, -s,
|
||||
-s, -s, s, s, -s, s, s, s, s, -s, s, s,
|
||||
];
|
||||
const indices = [
|
||||
4, 5, 6, 4, 6, 7, // +z
|
||||
1, 0, 3, 1, 3, 2, // -z
|
||||
5, 1, 2, 5, 2, 6, // +x
|
||||
0, 4, 7, 0, 7, 3, // -x
|
||||
7, 6, 2, 7, 2, 3, // +y
|
||||
0, 1, 5, 0, 5, 4, // -y
|
||||
];
|
||||
const posBin = float32Buffer(positions);
|
||||
const idxBin = uint16Buffer(indices);
|
||||
const bin = Buffer.concat([posBin, idxBin]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.85, 0.85, 0.85, 1] } }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: 8, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5123, count: 12 * 3, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
|
||||
{ buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length },
|
||||
],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 5000 个三角形的波浪网格(50x50 个格子,每格 2 个三角形) */
|
||||
function gridGlb(segments: number): Buffer {
|
||||
const n = segments;
|
||||
const positions: number[] = [];
|
||||
for (let j = 0; j <= n; j += 1) {
|
||||
for (let i = 0; i <= n; i += 1) {
|
||||
const x = (i / n) * 2 - 1;
|
||||
const y = (j / n) * 2 - 1;
|
||||
const z = 0.3 * Math.sin(x * 3) * Math.cos(y * 3);
|
||||
positions.push(x, y, z);
|
||||
}
|
||||
}
|
||||
const indices: number[] = [];
|
||||
for (let j = 0; j < n; j += 1) {
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = j * (n + 1) + i;
|
||||
const b = a + 1;
|
||||
const c = a + (n + 1);
|
||||
const d = c + 1;
|
||||
indices.push(a, b, d, a, d, c);
|
||||
}
|
||||
}
|
||||
const posBin = float32Buffer(positions);
|
||||
const idxBin = uint16Buffer(indices);
|
||||
const bin = Buffer.concat([posBin, idxBin]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: positions.length / 3, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5123, count: indices.length, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
|
||||
{ buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length },
|
||||
],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 列主序平移矩阵(与 glTF 的 matrix 布局一致) */
|
||||
function translationMat4(x: number, y: number, z: number): number[] {
|
||||
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 蒙皮四边形:4 顶点 2 三角形(无索引复用),2 个关节。
|
||||
* - 下半两个顶点绑到 joint0(世界矩阵 = 单位阵,IBM = 单位阵)→ 原地不动
|
||||
* - 上半两个顶点绑到 joint1(世界矩阵 = translate(0,4,0),IBM = translate(0,-2,0))
|
||||
* → 蒙皮矩阵 translate(0,2,0),顶点被抬到 y=3
|
||||
* 因此正确实现蒙皮后,几何应当等价于一个顶点为 y∈[-1,3] 的普通四边形。
|
||||
*/
|
||||
function skinnedQuadGlb(): Buffer {
|
||||
const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]);
|
||||
const joints = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
const weights = float32Buffer([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
|
||||
const inverseBind = float32Buffer([...translationMat4(0, 0, 0), ...translationMat4(0, -2, 0)]);
|
||||
const indices = uint16Buffer([0, 1, 2, 0, 2, 3]);
|
||||
|
||||
const views = [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
|
||||
{ buffer: 0, byteOffset: positions.length, byteLength: joints.length },
|
||||
{ buffer: 0, byteOffset: positions.length + joints.length, byteLength: weights.length },
|
||||
{ buffer: 0, byteOffset: positions.length + joints.length + weights.length, byteLength: inverseBind.length },
|
||||
{
|
||||
buffer: 0,
|
||||
byteOffset: positions.length + joints.length + weights.length + inverseBind.length,
|
||||
byteLength: indices.length,
|
||||
},
|
||||
];
|
||||
const bin = Buffer.concat([positions, joints, weights, inverseBind, indices]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0, 3] }],
|
||||
nodes: [
|
||||
{ children: [1, 2] }, // 0: 骨架根
|
||||
{ translation: [0, 0, 0] }, // 1: joint0(绑定姿态即身份)
|
||||
{ translation: [0, 4, 0] }, // 2: joint1(当前姿态被抬高 4)
|
||||
{ mesh: 0, skin: 0 }, // 3: 蒙皮网格,节点自身无变换
|
||||
],
|
||||
skins: [{ inverseBindMatrices: 3, joints: [1, 2], skeleton: 1 }],
|
||||
meshes: [
|
||||
{
|
||||
primitives: [
|
||||
{
|
||||
attributes: { POSITION: 0, JOINTS_0: 1, WEIGHTS_0: 2 },
|
||||
indices: 4,
|
||||
material: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5121, count: 4, type: 'VEC4' },
|
||||
{ bufferView: 2, componentType: 5126, count: 4, type: 'VEC4' },
|
||||
{ bufferView: 3, componentType: 5126, count: 2, type: 'MAT4' },
|
||||
{ bufferView: 4, componentType: 5123, count: 6, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: views,
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
/** 参照物:无蒙皮的四边形,上下边分别位于 y=-1 与 yTop,其余与 skinnedQuadGlb 等价 */
|
||||
function plainQuadGlb(yTop: number): Buffer {
|
||||
const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, yTop, 0, -1, yTop, 0]);
|
||||
const indices = uint16Buffer([0, 1, 2, 0, 2, 3]);
|
||||
const bin = Buffer.concat([positions, indices]);
|
||||
const json = {
|
||||
asset: { version: '2.0' },
|
||||
scene: 0,
|
||||
scenes: [{ nodes: [0] }],
|
||||
nodes: [{ mesh: 0 }],
|
||||
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
|
||||
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }],
|
||||
accessors: [
|
||||
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
|
||||
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
|
||||
],
|
||||
bufferViews: [
|
||||
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
|
||||
{ buffer: 0, byteOffset: positions.length, byteLength: indices.length },
|
||||
],
|
||||
buffers: [{ byteLength: bin.length }],
|
||||
};
|
||||
return buildGlb(json, bin);
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
interface DecodedPng {
|
||||
width: number;
|
||||
height: number;
|
||||
pixels: Buffer;
|
||||
}
|
||||
|
||||
/** 手工解 PNG:校验签名 / chunk 结构,用 inflateSync 还原 IDAT */
|
||||
function decodePng(png: Buffer): DecodedPng {
|
||||
expect(png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
||||
let offset = 8;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let sawIhdr = false;
|
||||
let sawIend = false;
|
||||
const idat: Buffer[] = [];
|
||||
while (offset + 8 <= png.length) {
|
||||
const length = png.readUInt32BE(offset);
|
||||
const type = png.toString('ascii', offset + 4, offset + 8);
|
||||
const data = png.subarray(offset + 8, offset + 8 + length);
|
||||
expect(offset + 12 + length).toBeLessThanOrEqual(png.length);
|
||||
if (type === 'IHDR') {
|
||||
sawIhdr = true;
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
expect(data[8]).toBe(8); // bit depth
|
||||
expect(data[9]).toBe(2); // color type = RGB
|
||||
expect(data[12]).toBe(0); // interlace = none
|
||||
} else if (type === 'IDAT') {
|
||||
idat.push(Buffer.from(data));
|
||||
} else if (type === 'IEND') {
|
||||
sawIend = true;
|
||||
break;
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
expect(sawIhdr).toBe(true);
|
||||
expect(sawIend).toBe(true);
|
||||
|
||||
const raw = inflateSync(Buffer.concat(idat));
|
||||
const stride = width * 3;
|
||||
expect(raw.length).toBe(height * (stride + 1));
|
||||
const pixels = Buffer.alloc(height * stride);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
expect(raw[y * (stride + 1)]).toBe(0); // filter = 0
|
||||
raw.copy(pixels, y * stride, y * (stride + 1) + 1, y * (stride + 1) + 1 + stride);
|
||||
}
|
||||
return { width, height, pixels };
|
||||
}
|
||||
|
||||
/** 统计与背景色不同的像素数量 */
|
||||
function countNonBackground(pixels: Buffer, background: [number, number, number]): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < pixels.length; i += 3) {
|
||||
if (
|
||||
Math.abs(pixels[i] - background[0]) > 2 ||
|
||||
Math.abs(pixels[i + 1] - background[1]) > 2 ||
|
||||
Math.abs(pixels[i + 2] - background[2]) > 2
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe('renderModelThumbnail', () => {
|
||||
it('最小三角形 GLB 能出图,PNG 头与 IHDR 尺寸正确', async () => {
|
||||
const path = write('triangle.glb', triangleGlb());
|
||||
const result = await renderModelThumbnail(path, { size: 128 });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.width).toBe(128);
|
||||
expect(result!.height).toBe(128);
|
||||
expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(result!.png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
||||
const decoded = decodePng(result!.png);
|
||||
expect(decoded.width).toBe(128);
|
||||
expect(decoded.height).toBe(128);
|
||||
});
|
||||
|
||||
it('带索引的立方体能出图,且画面里确实有非背景像素', async () => {
|
||||
const background: [number, number, number] = [24, 26, 32];
|
||||
const path = write('cube.glb', cubeGlb(1));
|
||||
const result = await renderModelThumbnail(path, { size: 256, background });
|
||||
expect(result).not.toBeNull();
|
||||
// 12 个三角形,背面剔除后会少画一部分
|
||||
expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1);
|
||||
expect(result!.trianglesDrawn).toBeLessThanOrEqual(12);
|
||||
|
||||
const decoded = decodePng(result!.png);
|
||||
const nonBackground = countNonBackground(decoded.pixels, background);
|
||||
expect(nonBackground).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('超大(±1000)与超小(±0.001)模型都能出图且不溢出', async () => {
|
||||
const background: [number, number, number] = [24, 26, 32];
|
||||
const bigPath = write('big.glb', cubeGlb(1000));
|
||||
const smallPath = write('small.glb', cubeGlb(0.001));
|
||||
|
||||
const big = await renderModelThumbnail(bigPath, { size: 128, background });
|
||||
const small = await renderModelThumbnail(smallPath, { size: 128, background });
|
||||
expect(big).not.toBeNull();
|
||||
expect(small).not.toBeNull();
|
||||
|
||||
const bigRatio = countNonBackground(decodePng(big!.png).pixels, background) / (128 * 128);
|
||||
const smallRatio = countNonBackground(decodePng(small!.png).pixels, background) / (128 * 128);
|
||||
expect(bigRatio).toBeGreaterThan(0);
|
||||
expect(smallRatio).toBeGreaterThan(0);
|
||||
// 构图一致,两者的覆盖率应当接近(都在画面中央占据相似比例)
|
||||
expect(Math.abs(bigRatio - smallRatio)).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('非法尺寸抛中文 Error', async () => {
|
||||
const path = write('cube-invalid.glb', cubeGlb(1));
|
||||
await expect(renderModelThumbnail(path, { size: 8 })).rejects.toThrow(/[\u4e00-\u9fff]/);
|
||||
await expect(renderModelThumbnail(path, { size: 4096 })).rejects.toThrow(/[\u4e00-\u9fff]/);
|
||||
});
|
||||
|
||||
it('非 glTF 文件(假 fbx)返回 null 且不抛错', async () => {
|
||||
const path = write('fake.fbx', Buffer.from('Kaydara FBX Binary \u0000\u001a\u0000', 'binary'));
|
||||
await expect(renderModelThumbnail(path)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('截断的 GLB 返回 null 且不抛错', async () => {
|
||||
const valid = triangleGlb();
|
||||
const truncated = valid.subarray(0, 20);
|
||||
const path = write('truncated.glb', truncated);
|
||||
await expect(renderModelThumbnail(path)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('5000 个三角形的模型渲染耗时低于 2000ms', async () => {
|
||||
const path = write('grid.glb', gridGlb(50));
|
||||
const started = performance.now();
|
||||
const result = await renderModelThumbnail(path, { size: 256 });
|
||||
const elapsed = performance.now() - started;
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.trianglesDrawn).toBeGreaterThan(0);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
it('蒙皮被真正应用:蒙皮结果与等价的无蒙皮几何逐像素一致', async () => {
|
||||
// 蒙皮后上下边在 y=-1 / y=3;参照物直接用同样的最终顶点位置
|
||||
const skinned = await renderModelThumbnail(write('skinned.glb', skinnedQuadGlb()), { size: 128 });
|
||||
const equivalent = await renderModelThumbnail(write('plain-sheared.glb', plainQuadGlb(3)), { size: 128 });
|
||||
// 绑定姿态(上下边在 y=-1 / y=1)作为反例
|
||||
const bindPose = await renderModelThumbnail(write('plain-bind.glb', plainQuadGlb(1)), { size: 128 });
|
||||
|
||||
expect(skinned).not.toBeNull();
|
||||
expect(equivalent).not.toBeNull();
|
||||
expect(bindPose).not.toBeNull();
|
||||
// 蒙皮矩阵(jointWorld × inverseBindMatrices × 权重)算对了才可能逐字节一致
|
||||
expect(skinned!.png.equals(equivalent!.png)).toBe(true);
|
||||
// 并确认它确实不是"忽略蒙皮"画出来的绑定姿态
|
||||
expect(skinned!.png.equals(bindPose!.png)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user