需求 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 变为该模型
101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
/**
|
||
* 骨骼树过滤(纯函数,无 React / DOM 依赖,便于 vitest 直接单测)。
|
||
*
|
||
* `ModelStats.boneTree` 是**扁平数组、父在前子在后**(先序遍历),所以过滤时
|
||
* 不能简单地 `filter`:一旦命中叶子,若把它到根的祖先链丢掉,渲染出来的树就断了。
|
||
* 本模块保留「命中节点 + 其全部祖先」。
|
||
*/
|
||
|
||
import type { BoneNode } from '../api/types';
|
||
|
||
/**
|
||
* 计算每个节点的父节点下标。
|
||
*
|
||
* 主算法用先序遍历的性质:父节点必定是**前面最近的 depth - 1 的节点**,
|
||
* 用一个「每层最后一个节点」的栈 O(n) 求出,比按名字找父更稳(名字可能重复)。
|
||
* depth 数据异常时(例如 depth 跳级)再退回 parentName 查找。
|
||
*/
|
||
function buildParentIndex(tree: readonly BoneNode[]): number[] {
|
||
const parentIndex: number[] = new Array<number>(tree.length).fill(-1);
|
||
const lastNameIndex = new Map<string, number>();
|
||
const lastIndexAtDepth: Array<number | undefined> = [];
|
||
|
||
for (let index = 0; index < tree.length; index += 1) {
|
||
const node = tree[index];
|
||
const depth = Number.isFinite(node.depth) ? Math.max(0, Math.trunc(node.depth)) : 0;
|
||
|
||
let parent: number | undefined = depth > 0 ? lastIndexAtDepth[depth - 1] : undefined;
|
||
if (parent === undefined && node.parentName !== null && node.parentName !== '') {
|
||
const byName = lastNameIndex.get(node.parentName);
|
||
if (byName !== undefined && byName !== index) parent = byName;
|
||
}
|
||
parentIndex[index] = parent === undefined || parent === index ? -1 : parent;
|
||
|
||
lastIndexAtDepth[depth] = index;
|
||
if (!lastNameIndex.has(node.name)) lastNameIndex.set(node.name, index);
|
||
}
|
||
|
||
return parentIndex;
|
||
}
|
||
|
||
/**
|
||
* 按名字过滤骨骼树,并保留命中节点的祖先链。
|
||
*
|
||
* @param tree 扁平骨骼树(父在前、子在后)
|
||
* @param keyword 搜索关键字(大小写不敏感,中英混排按子串匹配;空白视为空)
|
||
* @param onlySkin true 时只保留 `inSkin` 的骨骼(其祖先仍会保留,避免树断裂)
|
||
* @returns 过滤后的新数组,保持原有先后顺序,且不会与命中节点重复
|
||
*/
|
||
export function filterBoneTree(
|
||
tree: readonly BoneNode[],
|
||
keyword: string,
|
||
onlySkin = false,
|
||
): BoneNode[] {
|
||
const needle = keyword.trim().toLowerCase();
|
||
|
||
// 无条件全量:连祖先计算都省掉,直接返回副本,避免调用方改动原数组。
|
||
if (needle === '' && !onlySkin) return [...tree];
|
||
|
||
const parentIndex = buildParentIndex(tree);
|
||
|
||
// 1) 命中集合:关键字命中(空关键字即全部)且通过 onlySkin 条件。
|
||
const keep: boolean[] = new Array<boolean>(tree.length).fill(false);
|
||
const pending: number[] = [];
|
||
for (let index = 0; index < tree.length; index += 1) {
|
||
const node = tree[index];
|
||
if (onlySkin && !node.inSkin) continue;
|
||
if (needle !== '' && !node.name.toLowerCase().includes(needle)) continue;
|
||
pending.push(index);
|
||
}
|
||
|
||
// 2) 沿父链向上补齐祖先;keep 同时用于防止异常数据成环时死循环。
|
||
while (pending.length > 0) {
|
||
const index = pending.pop() as number;
|
||
if (keep[index]) continue;
|
||
keep[index] = true;
|
||
const parent = parentIndex[index];
|
||
if (parent >= 0) pending.push(parent);
|
||
}
|
||
|
||
// 3) 按原顺序输出,父仍在子之前。
|
||
return tree.filter((_node, index) => keep[index]);
|
||
}
|
||
|
||
/** 骨骼树统计(总数 / 蒙皮数 / 已映射 humanoid 数 / 最大层级),供统计条使用。 */
|
||
export function boneTreeStats(tree: readonly BoneNode[]): {
|
||
total: number;
|
||
skin: number;
|
||
mapped: number;
|
||
maxDepth: number;
|
||
} {
|
||
let skin = 0;
|
||
let mapped = 0;
|
||
let maxDepth = 0;
|
||
for (const node of tree) {
|
||
if (node.inSkin) skin += 1;
|
||
if (node.humanoid !== null && node.humanoid !== '') mapped += 1;
|
||
if (Number.isFinite(node.depth) && node.depth > maxDepth) maxDepth = node.depth;
|
||
}
|
||
return { total: tree.length, skin, mapped, maxDepth };
|
||
}
|