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:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+669
View File
@@ -0,0 +1,669 @@
import { readFile } from 'node:fs/promises';
/**
* 模型检查器 —— 审核界面「体积 / 面数 / 骨骼」数据的来源。
*
* 为什么骨骼要单独建模:客户端(Godot)是**按骨骼名**把模型骨架映射到 humanoid
* 规范(`opendance-windows/src/model/humanoid_map.gd`),再驱动 33 个点。
* 「模型能加载」不等于「能被驱动」;服务端必须在审核阶段就算出覆盖情况,
* 否则要等玩家进游戏才发现模型加载了却驱动不了。
*
* 本文件只解析 glTF 系容器(`.glb` 二进制 / `.gltf` JSON;VRM 就是带扩展的 glTF)。
* fbx / pmx 等非 glTF 容器返回 `container='unknown'` 的降级结果,**绝不抛错** ——
* 审核接口需要的是「信息」,不是 500。
*
* 统计口径(与 ModelStats 字段注释一一对应):
* - vertices = 所有 mesh primitive 的 POSITION accessor 的 count 之和(缺 accessor 记 0)
* - triangles = 索引 accessor 的 count / 3;该 primitive 没有 indices 时用 POSITION count / 3
* - materials / textures / images / animations 直接取 glTF 顶层数组长度
* - meshes = meshes 数组长度;primitives = 所有 mesh 的 primitives 数量之和
* - morphTargets = 所有 primitive 的 `targets` 数量之和
* - nodes = nodes 数组长度;skins = skins 数组长度
* - bones = 所有 skin 的 joints 去重后的节点数(= 真正参与蒙皮、可被驱动的骨骼)
* - humanoidTotal 固定为 22(客户端 HUMANOID_BONES 的长度);humanoidMapped 为命中数
* - 一切数字都经过 toCount(),保证是**有限非负整数**,绝不出现 NaN / Infinity
*
* 与客户端的映射一致性由 `test/model-inspector.spec.ts` 直接抽 `humanoid_map.gd`
* 的 CANDIDATES / STRIPPABLE_PREFIXES / HUMANOID_BONES 逐键比对来兜底:
* 客户端一旦改了名字映射,这条测试会立刻失败,而不是让审核数据悄悄失真。
*/
/* ------------------------------------------------------------------ 对外接口 */
export interface BoneNode {
index: number;
name: string;
/** 相对根的层级深度,根为 0 */
depth: number;
parentName: string | null;
childCount: number;
/** 是否被 skin.joints 引用(= 真正参与蒙皮、可被驱动) */
inSkin: boolean;
/** 映射到的 humanoid 规范名(如 leftUpperArm);映射不到为 null */
humanoid: string | null;
}
export interface ModelStats {
container: 'glb' | 'gltf' | 'vrm' | 'unknown';
vrmVersion: string | null;
fileSize: number;
meshes: number;
primitives: number;
vertices: number;
triangles: number;
materials: number;
textures: number;
images: number;
animations: number;
morphTargets: number;
nodes: number;
/** skin.joints 的数量(真正参与蒙皮的骨骼) */
bones: number;
/** skin 数量 */
skins: number;
/** humanoid 规范骨里能映射上的数量 / 总数(22) */
humanoidMapped: number;
humanoidTotal: number;
/** 驱动客户端(33 点)所必需的 16 根里缺哪些(humanoid 规范名) */
missingRequiredBones: string[];
/** 16 根齐全 → true */
drivable: boolean;
maxBoneDepth: number;
/** 扁平化的骨骼树(父在前、子在后),前端按 depth 缩进渲染 */
boneTree: BoneNode[];
/** 无法解析或需要注意的点,中文短语 */
warnings: string[];
}
/* ----------------------------------------------------- humanoid 规范骨(与客户端一致) */
/**
* humanoid 规范骨(22 根),顺序即层级、父在前。
* 与客户端 `humanoid_map.gd` 的 HUMANOID_BONES 逐字一致(测试会比对)。
*/
export const HUMANOID_BONES: readonly string[] = [
'hips',
'spine',
'chest',
'upperChest',
'neck',
'head',
'leftShoulder',
'leftUpperArm',
'leftLowerArm',
'leftHand',
'rightShoulder',
'rightUpperArm',
'rightLowerArm',
'rightHand',
'leftUpperLeg',
'leftLowerLeg',
'leftFoot',
'leftToes',
'rightUpperLeg',
'rightLowerLeg',
'rightFoot',
'rightToes',
];
/**
* 驱动客户端(33 点)所必需的 16 根 humanoid 骨。
* 缺任何一根就意味着「模型能加载但驱动不了」,审核界面据此直接给出结论。
*/
export const HUMANOID_REQUIRED: readonly string[] = [
'hips',
'spine',
'neck',
'head',
'leftUpperArm',
'leftLowerArm',
'leftHand',
'rightUpperArm',
'rightLowerArm',
'rightHand',
'leftUpperLeg',
'leftLowerLeg',
'leftFoot',
'rightUpperLeg',
'rightLowerLeg',
'rightFoot',
];
/**
* 归一化时剥离的前缀(**归一化之后**的形态:小写、无下划线/冒号)。
* 只剥离 J_Bip / mixamorig / Bip01 —— 故意不剥离 J_Aim_ / J_Roll_ / J_Sec_,
* 因为 VRoid 用这些前缀标记扭转辅助骨(如 J_Aim_L_UpperLeg),
* 若一并剥离会与真正的 J_Bip_L_UpperLeg 撞名,导致驱动到错误的骨骼上。
* 与客户端 `humanoid_map.gd` 的 STRIPPABLE_PREFIXES 一致。
*/
export const STRIPPABLE_PREFIXES: readonly string[] = ['jbip', 'mixamorig', 'bip001', 'bip01'];
/**
* 每个 canonical 骨骼的候选名(全部为归一化后的形态,含 compact 与 expanded 写法)。
* 与客户端 `humanoid_map.gd` 的 CANDIDATES 逐字一致(测试会逐键比对)。
*/
export const HUMANOID_CANDIDATES: Record<string, string[]> = {
hips: ['chips', 'hips', 'hip', 'pelvis', 'root'],
spine: ['cspine', 'spine', 'spine1', 'spinemid'],
chest: ['cchest', 'chest', 'spine2', 'spineupper'],
upperChest: ['cupperchest', 'upperchest', 'chest2', 'spine3'],
neck: ['cneck', 'neck', 'neck1'],
head: ['chead', 'head'],
leftShoulder: ['lshoulder', 'leftshoulder', 'shoulderl', 'shoulderleft', 'claviclel', 'lclavicle'],
leftUpperArm: ['lupperarm', 'leftupperarm', 'larm', 'leftarm', 'luparm'],
leftLowerArm: ['llowerarm', 'leftlowerarm', 'lforearm', 'leftforearm', 'lelbow'],
leftHand: ['lhand', 'lefthand', 'lwrist', 'leftwrist'],
rightShoulder: ['rshoulder', 'rightshoulder', 'shoulderr', 'shoulderright', 'clavicler', 'rclavicle'],
rightUpperArm: ['rupperarm', 'rightupperarm', 'rarm', 'rightarm', 'ruparm'],
rightLowerArm: ['rlowerarm', 'rightlowerarm', 'rforearm', 'rightforearm', 'relbow'],
rightHand: ['rhand', 'righthand', 'rwrist', 'rightwrist'],
leftUpperLeg: ['lupperleg', 'leftupperleg', 'lthigh', 'leftthigh', 'lupleg', 'lhip', 'lefthip'],
leftLowerLeg: [
'llowerleg',
'leftlowerleg',
'lleg',
'leftleg',
'lshin',
'leftshin',
'lknee',
'leftknee',
],
leftFoot: ['lfoot', 'leftfoot', 'lankle', 'leftankle'],
leftToes: ['ltoes', 'lefttoes', 'ltoe', 'lefttoe', 'ltoebase', 'lefttoebase'],
rightUpperLeg: ['rupperleg', 'rightupperleg', 'rthigh', 'rightthigh', 'rupleg', 'rhip', 'righthip'],
rightLowerLeg: [
'rlowerleg',
'rightlowerleg',
'rleg',
'rightleg',
'rshin',
'rightshin',
'rknee',
'rightknee',
],
rightFoot: ['rfoot', 'rightfoot', 'rankle', 'rightankle'],
rightToes: ['rtoes', 'righttoes', 'rtoe', 'righttoe', 'rtoebase', 'righttoebase'],
};
/* -------------------------------------------------------------- 客户端逻辑移植 */
/**
* 归一化骨骼名:小写 + 只保留 a-z0-9(分隔符、冒号、空格全部丢弃)。
* 移植自客户端 `humanoid_map.gd` 的 normalize()。
* 例如 "J_Bip_L_UpperArm" → "jbiplupperarm"。
*/
export function normalizeBoneName(nodeName: string): string {
const lower = nodeName.toLowerCase();
let out = '';
for (const ch of lower) {
if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) out += ch;
}
return out;
}
/** 去掉可剥离前缀后的形态。移植自客户端 strip_prefix()。 */
function stripPrefix(normalized: string): string {
for (const prefix of STRIPPABLE_PREFIXES) {
if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
}
return normalized;
}
/**
* 生成用于比较的形态集合。移植自客户端 variants():
* compact —— left/right 缩写为 l/r:"leftupperarm" → "lupperarm"
* expanded —— 首字母 l/r 展开为 left/right:"lupperarm" → "leftupperarm"
* 返回值与客户端一致地保留重复项(base / compact / expanded 共 3 项)。
*/
export function boneNameVariants(nodeName: string): string[] {
const base = stripPrefix(normalizeBoneName(nodeName));
const out = [base];
let compact = base;
if (base.startsWith('left')) compact = 'l' + base.slice(4);
else if (base.startsWith('right')) compact = 'r' + base.slice(5);
out.push(compact);
let expanded = base;
if (base.startsWith('l') && !base.startsWith('left')) expanded = 'left' + base.slice(1);
else if (base.startsWith('r') && !base.startsWith('right')) expanded = 'right' + base.slice(1);
out.push(expanded);
return out;
}
/* ------------------------------------------------------------------ glTF 解析工具 */
/** glTF JSON 的宽松视图:外部文件不可信,一律按 unknown 逐字段守卫 */
type Json = Record<string, unknown>;
const GLB_MAGIC = 0x46546c67; // 'glTF'
const GLB_VERSION = 2;
const CHUNK_JSON = 0x4e4f534a; // 'JSON';BIN chunk 是 0x004E4942,本检查器用不到
function isRecord(value: unknown): value is Json {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/** 把任意值收敛为有限非负整数;NaN / Infinity / 负数 / 非数字一律记 0 */
function toCount(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
/** 合法的数组下标(用于节点索引、accessor 索引);不合法返回 null */
function toIndex(value: unknown, length: number): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value >= length) return null;
return value;
}
/** 非空字符串(用于 VRM 版本号等);其余返回 null */
function asText(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
function align4(value: number): number {
return value + ((4 - (value % 4)) % 4);
}
function looksLikeGlb(buffer: Buffer): boolean {
return buffer.length >= 4 && buffer.readUInt32LE(0) === GLB_MAGIC;
}
function looksLikeGltf(json: Json): boolean {
return (
isRecord(json.asset) ||
Array.isArray(json.meshes) ||
Array.isArray(json.nodes) ||
Array.isArray(json.scenes)
);
}
/**
* 解析 GLB 的 JSON chunk。
* 12 字节头(magic / version / 总长)+ chunk(JSON 0x4E4F534A / BIN 0x004E4942),
* chunk 按 4 字节对齐 —— 与 `glb-textures.ts` 同一套布局(此处不 import,避免耦合)。
* 任何异常都转成 warnings,返回 null 由调用方降级,不抛错。
*/
function parseGlbJson(buffer: Buffer, warnings: string[]): Json | null {
if (buffer.length < 12) {
warnings.push('GLB 文件过小(不足 12 字节头),无法解析');
return null;
}
const version = buffer.readUInt32LE(4);
if (version !== GLB_VERSION) {
warnings.push(`GLB 版本为 ${version},只支持版本 2`);
return null;
}
const declared = buffer.readUInt32LE(8);
if (declared > buffer.length) {
warnings.push(`GLB 头声明总长 ${declared} 字节,实际只有 ${buffer.length} 字节(文件可能被截断)`);
}
const end = declared > 0 ? Math.min(declared, buffer.length) : buffer.length;
let offset = 12;
let json: Json | null = null;
while (offset + 8 <= end) {
const chunkLength = buffer.readUInt32LE(offset);
const chunkType = buffer.readUInt32LE(offset + 4);
const dataStart = offset + 8;
if (dataStart + chunkLength > buffer.length) {
warnings.push('GLB chunk 长度越界,文件可能被截断');
break;
}
if (chunkType === CHUNK_JSON) {
try {
const parsed: unknown = JSON.parse(
buffer.subarray(dataStart, dataStart + chunkLength).toString('utf8'),
);
if (isRecord(parsed)) json = parsed;
else warnings.push('GLB 的 JSON chunk 不是对象');
} catch {
warnings.push('GLB 的 JSON chunk 解析失败(文件可能被截断)');
}
}
// BIN chunk 本检查器用不到(面数/骨骼只需要 accessor 的 count),跳过即可
offset = dataStart + align4(chunkLength);
}
if (!json) warnings.push('GLB 里没有可用的 JSON chunk');
return json;
}
/** 解析纯文本 `.gltf`;非 glTF JSON 一律返回 null 并写 warnings */
function parseGltfText(buffer: Buffer, warnings: string[]): Json | null {
const text = buffer.toString('utf8');
if (!/^\s*\{/.test(text)) {
warnings.push('不是 GLB,也不是 JSON 文本(疑似 fbx / pmx 等非 glTF 容器)');
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
warnings.push('JSON 解析失败(文件可能被截断或不完整)');
return null;
}
if (!isRecord(parsed)) {
warnings.push('JSON 顶层不是对象,不是 glTF 文档');
return null;
}
if (!looksLikeGltf(parsed)) {
warnings.push('JSON 里没有 asset / meshes / nodes / scenes,不是 glTF 文档');
return null;
}
return parsed;
}
/* ------------------------------------------------------------------ 统计与骨骼树 */
/** 非 glTF(或彻底解析失败)时的降级结果:全部记 0,缺全部必需骨骼 */
function emptyStats(container: ModelStats['container'], fileSize: number, warnings: string[]): ModelStats {
return {
container,
vrmVersion: null,
fileSize,
meshes: 0,
primitives: 0,
vertices: 0,
triangles: 0,
materials: 0,
textures: 0,
images: 0,
animations: 0,
morphTargets: 0,
nodes: 0,
bones: 0,
skins: 0,
humanoidMapped: 0,
humanoidTotal: HUMANOID_BONES.length,
missingRequiredBones: [...HUMANOID_REQUIRED],
drivable: false,
maxBoneDepth: 0,
boneTree: [],
warnings,
};
}
/** 读 accessor 的 count;索引缺失/越界/不是对象都记 0 */
function accessorCount(accessors: unknown[], ref: unknown): number {
const index = toIndex(ref, accessors.length);
if (index === null) return 0;
const accessor = accessors[index];
return isRecord(accessor) ? toCount(accessor.count) : 0;
}
/** VRM 识别:VRMC_vrm → 1.0;VRM → 0.x。返回版本号(非 VRM 为 null) */
function detectVrmVersion(gltf: Json, warnings: string[]): string | null {
const extensions = isRecord(gltf.extensions) ? gltf.extensions : {};
const vrmc = extensions['VRMC_vrm'];
if (isRecord(vrmc)) {
// VRM 1.0:meta 缺失时审核拿不到授权/来源信息,值得提示
if (!isRecord(vrmc.meta)) warnings.push('VRM 1.0 扩展缺少 meta,模型来源与授权信息未知');
return asText(vrmc.specVersion) ?? '1.0';
}
const vrm0 = extensions['VRM'];
if (isRecord(vrm0)) {
if (!isRecord(vrm0.meta)) warnings.push('VRM 0.x 扩展缺少 meta,模型来源与授权信息未知');
return asText(vrm0.specVersion) ?? asText(vrm0.exporterVersion) ?? '0.x';
}
return null;
}
interface FlatNode {
index: number;
name: string;
depth: number;
parentName: string | null;
childCount: number;
}
/** 分析一份已解析的 glTF 文档(glb / gltf / vrm 共用) */
function analyzeGltfDocument(
gltf: Json,
baseContainer: 'glb' | 'gltf',
fileSize: number,
warnings: string[],
): ModelStats {
const vrmVersion = detectVrmVersion(gltf, warnings);
// VRM 就是带扩展的 glTF:识别到扩展就把容器标成 vrm
const container: ModelStats['container'] = vrmVersion !== null ? 'vrm' : baseContainer;
const meshList = asArray(gltf.meshes);
const accessors = asArray(gltf.accessors);
const nodeList = asArray(gltf.nodes);
const skinList = asArray(gltf.skins).filter(isRecord);
/* ---------------------------------------------------------- 网格统计 */
let primitives = 0;
let vertices = 0;
let triangles = 0;
let morphTargets = 0;
for (let mi = 0; mi < meshList.length; mi += 1) {
const mesh = meshList[mi];
if (!isRecord(mesh)) continue;
const primitiveList = asArray(mesh.primitives);
for (let pi = 0; pi < primitiveList.length; pi += 1) {
const primitive = primitiveList[pi];
if (!isRecord(primitive)) continue;
primitives += 1;
const attributes = isRecord(primitive.attributes) ? primitive.attributes : {};
const positionCount = accessorCount(accessors, attributes['POSITION']);
if (positionCount === 0) {
warnings.push(`mesh[${mi}].primitive[${pi}] 缺少可用的 POSITION accessor,顶点数按 0 计`);
}
vertices += positionCount;
// 有索引按索引数/3;无索引按顶点数/3(口径见文件头注释)
const indexCount = accessorCount(accessors, primitive.indices);
triangles += Math.floor((indexCount > 0 ? indexCount : positionCount) / 3);
morphTargets += asArray(primitive.targets).length;
const mode = primitive.mode;
if (typeof mode === 'number' && mode !== 4) {
warnings.push(`mesh[${mi}].primitive[${pi}] 的 mode=${mode} 不是 TRIANGLES,面数按索引数/3 估算`);
}
}
}
/* ---------------------------------------------------------- 蒙皮统计 */
// bones = 所有 skin.joints 去重;越界的 joint 索引视为脏数据丢弃(否则会凭空多出骨骼)
const joints = new Set<number>();
for (const skin of skinList) {
for (const joint of asArray(skin.joints)) {
const index = toIndex(joint, nodeList.length);
if (index !== null) joints.add(index);
}
}
/* ------------------------------------------------------ 骨骼树(DFS) */
const nodeAt = (index: number): Json => {
const node = nodeList[index];
return isRecord(node) ? node : {};
};
const nodeName = (index: number): string => {
const name = nodeAt(index).name;
return typeof name === 'string' && name.trim().length > 0 ? name : `node_${index}`;
};
const childrenOf = (index: number): number[] => {
const out: number[] = [];
for (const child of asArray(nodeAt(index).children)) {
const valid = toIndex(child, nodeList.length);
if (valid !== null && !out.includes(valid)) out.push(valid);
}
return out;
};
const childIndices = new Set<number>();
for (let i = 0; i < nodeList.length; i += 1) {
for (const child of childrenOf(i)) childIndices.add(child);
}
// 根:优先 scenes[scene].nodes;没有场景信息时用「不作为任何节点子节点」的节点
const sceneList = asArray(gltf.scenes).filter(isRecord);
const sceneRef = gltf.scene;
const sceneIndex =
typeof sceneRef === 'number' && Number.isInteger(sceneRef) && sceneRef >= 0 && sceneRef < sceneList.length
? sceneRef
: 0;
const roots: number[] = [];
const sceneNodes = sceneList.length > 0 ? asArray(sceneList[sceneIndex]?.nodes) : [];
for (const node of sceneNodes) {
const index = toIndex(node, nodeList.length);
if (index !== null && !roots.includes(index)) roots.push(index);
}
if (roots.length === 0) {
for (let i = 0; i < nodeList.length; i += 1) {
if (!childIndices.has(i)) roots.push(i);
}
}
const flat: FlatNode[] = [];
const visited = new Set<number>();
const visit = (index: number, depth: number, parentName: string | null): void => {
if (visited.has(index)) return; // 环形 children 保护
visited.add(index);
const children = childrenOf(index);
flat.push({ index, name: nodeName(index), depth, parentName, childCount: children.length });
// 多根时每个根 depth=0;父在前、子在后(前序)
for (const child of children) visit(child, depth + 1, nodeName(index));
};
for (const root of roots) visit(root, 0, null);
// 不在场景图里的孤立节点也要能看到(按根处理),否则审核会漏骨骼
const orphans: number[] = [];
for (let i = 0; i < nodeList.length; i += 1) {
if (!visited.has(i)) orphans.push(i);
}
if (orphans.length > 0) {
warnings.push(`有 ${orphans.length} 个节点不在场景图中,已按根节点(depth=0)列出`);
for (const orphan of orphans) visit(orphan, 0, null);
}
/* -------------------------------------------------- humanoid 映射(与客户端一致) */
// variants → 节点索引;同名冲突取**先出现**的节点(骨骼树父在前,真正的 humanoid 骨通常更靠前)
const variantNode = new Map<string, number>();
for (const node of flat) {
for (const variant of boneNameVariants(node.name)) {
if (!variantNode.has(variant)) variantNode.set(variant, node.index);
}
}
const matched = new Map<string, number>();
for (const canonical of HUMANOID_BONES) {
for (const candidate of HUMANOID_CANDIDATES[canonical] ?? []) {
const hit = variantNode.get(candidate);
if (hit !== undefined) {
matched.set(canonical, hit);
break;
}
}
}
// 一个节点理论上可能被多个 canonical 命中(如 spine / spine1),
// 这里按 HUMANOID_BONES 顺序取第一次命中作为该节点的 humanoid 名
const humanoidByNode = new Map<number, string>();
for (const canonical of HUMANOID_BONES) {
const nodeIndex = matched.get(canonical);
if (nodeIndex !== undefined && !humanoidByNode.has(nodeIndex)) {
humanoidByNode.set(nodeIndex, canonical);
}
}
const boneTree: BoneNode[] = flat.map((node) => ({
index: node.index,
name: node.name,
depth: node.depth,
parentName: node.parentName,
childCount: node.childCount,
inSkin: joints.has(node.index),
humanoid: humanoidByNode.get(node.index) ?? null,
}));
let maxBoneDepth = 0;
for (const node of boneTree) {
if (node.depth > maxBoneDepth) maxBoneDepth = node.depth;
}
const missingRequiredBones = HUMANOID_REQUIRED.filter((bone) => !matched.has(bone));
if (nodeList.length === 0) warnings.push('文件里没有 nodes,无法分析骨骼树');
return {
container,
vrmVersion,
fileSize,
meshes: meshList.length,
primitives,
vertices,
triangles,
materials: asArray(gltf.materials).length,
textures: asArray(gltf.textures).length,
images: asArray(gltf.images).length,
animations: asArray(gltf.animations).length,
morphTargets,
nodes: nodeList.length,
bones: joints.size,
skins: asArray(gltf.skins).length,
humanoidMapped: matched.size,
humanoidTotal: HUMANOID_BONES.length,
missingRequiredBones,
drivable: missingRequiredBones.length === 0,
maxBoneDepth,
boneTree,
warnings,
};
}
/* ------------------------------------------------------------------ 入口 */
/**
* 读取并分析模型文件。
*
* @param absPath 模型文件绝对路径
* @param fileSize 可选的已知文件大小(如数据库里记录的值);不传则用实际读到的字节数
*
* 非 glTF 容器(fbx / pmx 等)、文件不存在、magic 不对、JSON 截断、缺 accessor……
* 一律返回 `container='unknown'`(或已解析部分)+ `warnings`,**不抛异常**。
*/
export async function inspectModelFile(absPath: string, fileSize?: number): Promise<ModelStats> {
const warnings: string[] = [];
const hasSize = typeof fileSize === 'number' && Number.isFinite(fileSize);
let buffer: Buffer;
try {
buffer = await readFile(absPath);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return emptyStats('unknown', hasSize ? toCount(fileSize) : 0, [`读取文件失败:${reason}`]);
}
const size = hasSize ? toCount(fileSize) : buffer.length;
let gltf: Json | null;
const glb = looksLikeGlb(buffer);
if (glb) {
gltf = parseGlbJson(buffer, warnings);
} else {
gltf = parseGltfText(buffer, warnings);
}
if (!gltf) return emptyStats('unknown', size, warnings);
return analyzeGltfDocument(gltf, glb ? 'glb' : 'gltf', size, warnings);
}