Files
opendance-web/web-server/admin/src/lib/bone-tree.ts
T
zijunchiang 53bf182476 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 变为该模型
2026-09-15 20:37:53 +08:00

101 lines
3.9 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.
/**
* 骨骼树过滤(纯函数,无 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 };
}