需求 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 变为该模型
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
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']);
|
|
});
|
|
});
|