Skip to content

viewer.model — ModelManager

设计意图

viewer.model 是模型生命周期域:一个 .fmbv 模型从装入场景(load)、被查询浏览,到被替换或卸载(unload)的全部状态都收敛在这里。加载是流式的——load() 在结构树骨架就绪时就 resolve,此刻即可取景、选中、遍历结构树,几何分块在后台渐进到达。ModelSource 支持 URL / URL 对象 / Uint8Array / Blob / 自定义 Range fetcher 五种形态(见模型数据 (.fmbv))。

域内的统一句柄是结构树节点 NodeId:元数据查询(getNodeInfo / iterateNodes / findNodesByName / getNodeProperties)与外观操作(颜色 / 可见性 / 高亮)都以它为入参。外观操作的关键抽象是覆盖层——染色与显隐不修改原始材质,只在其上叠加可撤销的覆盖,对应的 unset* / reset* 总能恢复模型原貌。这让「按检验结果染色」「隔离显示」这类业务标注可以放心叠加、一键退出。

何时用它:凡是「关于模型本体」的事——加载进度、BOM 结构、零件显隐与染色、业务属性——都从 viewer.model 入手;「往哪看」归 viewer.camera,「用户选了什么」归 viewer.selection

典型用法

按 BOM 节点隔离显示(只留目标子树,隐藏其余叶子):

ts
const targets = viewer.model.findNodesByName("BRAKE_ASSY");
viewer.model.isolateNodes(targets); // 装配自动展开为子树,其余叶子隐藏
viewer.camera.fitView({ nodeIds: targets });

按检验结果给零件染色,复查后一键恢复:

ts
viewer.model.setNodesColor(failedIds, { r: 0.9, g: 0.2, b: 0.2 });
viewer.model.setNodesColor(warnIds, { r: 0.95, g: 0.75, b: 0.1 });
// ……复查完毕,恢复原始材质色
viewer.model.resetNodesColor();

选中零件时读它的业务属性(按需拉取,自动缓存):

ts
viewer.on("selection-changed", async (e) => {
  const item = e.current[0];
  if (!item) return;
  const props = await viewer.model.getNodeProperties(item.nodeId);
  console.table(props.map((p) => ({ group: p.group, key: p.key, value: p.value })));
});

获取产品结构树

model.load() 在结构树骨架就绪后返回,此时可以立即遍历节点,无需等待几何分块全部加载。iterateNodes() 从根节点开始深度优先访问全部装配和零件节点:

ts
import type { NodeInfo } from "@modelcubes/viewer-core";

await viewer.model.load(modelSource);

const nodes: NodeInfo[] = [];
viewer.model.iterateNodes((node) => {
  nodes.push(node);
});

每个 NodeInfo 都带有 parentIdchildrenIdsmeshId === null 表示装配节点;meshId 非空表示节点关联几何网格,通常是零件节点:

ts
for (const node of nodes) {
  const type = node.meshId === null ? "assembly" : "part";
  console.log(type, node.id, node.name);
}

需要保留树层级时,从根节点递归读取 childrenIds

ts
import type { NodeId } from "@modelcubes/viewer-core";

function walkNode(nodeId: NodeId, depth = 0): void {
  const node = viewer.model.getNodeInfo(nodeId);
  if (!node) return;

  console.log("  ".repeat(depth), node.name, node.id);
  for (const childId of node.childrenIds) {
    walkNode(childId, depth + 1);
  }
}

const rootId = viewer.model.getRootNodeId();
if (rootId !== null) walkNode(rootId);

getLeafNodeIds() 只返回带几何的叶子节点,不包含装配节点,因此不能替代 iterateNodes() 获取完整产品结构树。

iterateNodes()NodeInfo.childrenIds 描述模型的基础结构。应用结构删除或合并后,如需遍历当前有效树,请从 getRootNodeId() 出发,并用 visibleTreeChildren(nodeId) 取得每一层的直接子节点;该方法会跳过已删除节点,并把合并根视为叶子。

几何总量统计

整模型的三角形 / 边线总数在骨架就绪后由引擎后台读各 chunk 头汇总(不解码几何),算好时发 model-totals-ready:

ts
viewer.on("model-totals-ready", (t) => statusBar.set(`${t.triangles} 三角 · ${t.edges} 边`));
const totals = viewer.model.getModelTotals(); // 尚未算好 / 未加载返回 null

单个子树的同口径统计用 getSubtreeTotals(root)(异步,按 mesh 实例直方图汇总;未加载或 root 不存在返回 null):

ts
const sub = await viewer.model.getSubtreeTotals(nodeId);

两者都是 LOD-0(满分辨率)口径并计入实例倍数,与结构删除后的重算口径一致。

结构编辑:删除与合并

结构编辑改的是结构树的逻辑呈现,不动底层几何数据:删除把节点连同子树从有效树中摘掉并隐藏其几何,合并把一个装配折叠成单一零件行。两者都是文档级改动(不随视图切换而变),压入共享的 viewer.history 撤销栈:

ts
viewer.model.deleteNodes([nodeId]); // 逻辑删除(含子树),一条撤销步
viewer.model.mergeNode(asmId); // 折叠为单一零件
viewer.model.unmergeNode(asmId); // 展开(独立一步,不是 merge 的 redo)

viewer.model.isNodeDeleted(id);
viewer.model.isNodeMerged(id);
viewer.model.mergeRootOf(id); // 该节点所属的合并根,不在合并组内返回 null
viewer.model.getMergeLeafCount(root); // 合并根下折叠了多少叶子(树上显示「合 N」)

持久化走一对导入 / 导出原语,宿主自己决定存哪(编辑器写进 .fmbview.json 的文档级块):

ts
const block = viewer.model.exportStructureBlock(); // { deleted: number[], merges: [...] }
// ……下次加载完模型后
viewer.model.applyStructureBlock(block); // 批量应用,发一次 structure-changed{ change: "apply" }

订阅 structure-changed 刷新结构树 UI。change"apply" 时是 load-time 批量应用,订阅方应全量重建并忽略 affected;其余(delete / undelete / merge / unmerge)带受影响的根,可做增量更新。"undelete" 额外用于驱动「撤销删除」的绿色闪烁提示。

外观编辑

除了 setNodesColor 这类直接染色,本域还提供一套面向编辑器交互的外观 API:颜色之外还能覆盖金属度 / 粗糙度(SurfaceOverride)与材质预设,粒度可以细到面组。

ts
// 一次性设定(可撤销的一步)
viewer.model.setNodesAppearance(ids, { color, surface: { metalness: 0.9, roughness: 0.2 } });
viewer.model.unsetNodesAppearance(ids); // 清除覆盖,回到原始材质
const ap = viewer.model.getNodeAppearance(id); // { color, surface, presetId, faces? }

// 连续拖动(滑块 / 取色器):三段式,拖动期间不入栈,松手压成一条撤销步
viewer.model.beginAppearanceEdit(ids);
viewer.model.inputAppearance({ surface: { metalness: v, roughness: r } }); // 逐帧
viewer.model.commitAppearanceEdit();

// 面组粒度
viewer.model.setFaceGroupAppearance(nodeId, faceGroupId, { color });

注意事项

  • load() 的 resolve 时机是骨架就绪,不是几何全部到达——resolve 后立即取景 / 遍历结构树没问题,但几何仍在后台流式加载;全量到齐以 model-loaded 事件为准。
  • 再次 load() 自动卸旧:旧模型触发 model-disposed,未完成的旧加载以 Cancelled reject;不需要先手动 unload()
  • 错误模式分两类:写操作(setNodesColor / setNodesVisibility 等)在未加载时抛 ViewerError(ModelNotLoaded);查询类(getNodeInfo / getBoundingBox 等)未加载返回 null / 空数组,不抛错。
  • 加载时应用持久化视图:load(source, { view, animateView }) 可在骨架就绪时套用一个 ViewOnLoad({ state } / { document } / { fetch: true }),在几何到齐前即正确取景,消除首帧闪烁;应用结果经 view-applied 事件回报,失败 skip 不 reject。详见加载时应用视图
  • 透明度 API 预留至 v2:setNodesOpacity 等当前调用必抛错,getNodeOpacity 恒返回 1。
  • 结构编辑是文档级的,染色 / 显隐是视图级的:删除与合并跨视图恒生效,不随视图切换而变;颜色、可见性、变换随视图各存一份。
  • applyStructureBlockstructure-changed{ change: "apply" }affected 为空数组——这是「批量替换」的信号,订阅方要全量重建,不能按 affected 做增量。
  • 外观三段式必须配齐:beginAppearanceEdit → 若干次 inputAppearancecommitAppearanceEdit。跳过 begin 直接 input 不会形成正确的撤销步。
  • 隔离显示用 isolateNodes(ids):恰好显示 ids(装配节点自动展开为整个子树),其余叶子隐藏;保留集内先前隐藏的叶子复显;空数组静默 no-op。可见性实际变化时(hide / show / isolate / reset)触发 visibility-changed 事件(payload 带当前隐藏叶子总数 hiddenCount)。相机编排(isolate 保存位姿并 fit、Show all 复显并还原)由 viewer-ui 的 isolateZoom helper 负责,引擎侧不动相机。

完整签名与延伸