Skip to content

编辑能力

可交互 demo · 鼠标拖拽旋转,滚轮缩放,空格键 fit在新窗口打开 ↗

到这里为止的指南讲的都是浏览:怎么看、看哪里、看到什么。这一页讲编辑——修改模型的呈现并把结果保存下来。纯阅读器可以完全跳过本页。

需要先讲清楚一件事:引擎从不修改 .fmbv 里的几何数据。所有编辑都是叠在原始数据之上的覆盖层,导出时是一份独立的 sidecar(编辑器写成 .fmbview.json),原始模型文件始终不变。

能编辑什么

类型管在哪改什么作用域
位置变换viewer.transform零件挪到哪、转成什么姿态视图级
编辑态爆炸viewer.explodeEdit按范围 / 方向 / 层级批量摆位(写进变换通道)视图级
序号标签viewer.labels装配图上的 ①②③ 气泡与引线视图级
外观viewer.model颜色、金属度粗糙度、材质预设(可细到面组)视图级
可见性viewer.model藏了哪些零件视图级
结构删除合并viewer.model结构树上删掉 / 折叠哪些节点文档级

视图级 vs 文档级是一条重要分界:视图级的改动随视图各存一份(同一个零件在「总览」视图里是红的、在「剖视」视图里是灰的,互不干扰);文档级的改动跨视图恒生效(一个零件被删掉了,就是在所有视图里都不该出现)。结构删除与合并是唯一的文档级编辑。

统一撤销栈

上表里的全部编辑,共用一条撤销栈(viewer.history)。用户不需要知道上一步动的是哪个域——⌘Z 就是回到上一步:

ts
viewer.on("history-changed", (e) => {
  undoBtn.disabled = !e.canUndo;
  redoBtn.disabled = !e.canRedo;
});
undoBtn.onclick = () => viewer.history.undo();

早期各域各有一条栈,结果是「隐藏一个零件再挪一下,撤销两次却只退回一步」。现在只有一条。各域的 undo()(如 transform.undo())也都转发到它,新代码直接用 viewer.history

多视图宿主还要多做一步视图对齐:撤销一步之前,先看它属于哪个视图,必要时切过去,否则用户会看到「按了撤销但画面没变」(实际变的是另一个视图):

ts
const scope = viewer.history.peekUndoScope();
if (scope?.viewId && scope.viewId !== currentViewId) switchToView(scope.viewId);
viewer.history.undo();

引擎本身不切视图——这是刻意的守层:引擎不该反过来驱动宿主的 UI 导航。

连续交互的三段式

拖滑块、拖 gizmo、拖气泡这类连续交互有一个共同模式:拖动期间实时预览且不入栈,松手才压成一条撤销步。三个域用的是同一套形状:

ts
// 外观(滑块 / 取色器)
viewer.model.beginAppearanceEdit(ids);
viewer.model.inputAppearance(patch); // 逐帧
viewer.model.commitAppearanceEdit();

// 编辑态爆炸(滑块)
viewer.explodeEdit.begin();
viewer.explodeEdit.preview(amount); // 逐帧
viewer.explodeEdit.commit();

// 标签气泡(拖拽)
viewer.labels.beginMove(number);
viewer.labels.moveLabel(number, pos); // 逐帧
viewer.labels.commitMove();

三段必须配齐。跳过 begin 直接调中间那一步,不会形成正确的撤销基线——改动会生效,但撤销时退不回去。

变换的真值模型

理解 transform 只需要记住一个公式:

text
节点渲染世界矩阵 = Translate(浏览态爆炸) · D · 原始世界矩阵

D 是每个节点一个的世界增量矩阵,未编辑时是单位阵。数值输入、gizmo 拖拽、编辑态爆炸——全都归结为改这个 D。所以:

  • 编辑可以叠加:先用编辑态爆炸批量散开,再手动微调某个零件,两者写的是同一个 D
  • 编辑可以序列化:getOverrides() 导出 [{ nodeId, delta }],setOverrides() 还原。
  • 浏览态爆炸(viewer.explode)走独立的乘法通道、不写 D,所以它和编辑正交——可以在已编辑的摆位上再预览散开。

编辑只对带几何实例的叶节点生效。选中一个装配整体平移是成立的,引擎会先把它展开成子树叶子再逐叶施加;isTransformable(id) 给出与「实际能被移动的集合」一致的判定,适合用来 gate UI 按钮。

持久化

引擎不做文件 I/O,持久化是宿主的事。需要导出的东西:

ts
const doc = {
  views: [{ id, name, snap: viewer.views.capture() }], // 视图级的全部编辑都在 snap 里
  structure: viewer.model.exportStructureBlock(), // 文档级:删除 + 合并
};

views.capture() 已经包含了可见性、变换、外观、标签——不需要逐域导出。结构块是唯一需要单独取的,因为它是文档级的、与视图平行。

还原时反过来:

ts
viewer.model.applyStructureBlock(doc.structure); // 先应用文档级结构
viewer.views.apply(doc.views[0].snap, { animate: false }); // 再套视图级状态

顺序有讲究——结构编辑改变的是「有哪些有效叶子」,视图状态里的节点 id 要落在这个结果上。

相关