/** * 骨骼树过滤(纯函数,无 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(tree.length).fill(-1); const lastNameIndex = new Map(); const lastIndexAtDepth: Array = []; 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(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 }; }