Skip to content

viewer-ui 总览与接线

@modelcubes/viewer-ui 是 FMB Viewer 的 UI 组件层:6 个标准 Web Components 自定义元素(基于 Lit 构建),外加 design tokens 与图标集。组件是框架无关的——原生 HTML、React、Vue 都可以直接挂载;它们与引擎之间只经 @modelcubes/viewer-core 的公开 API 和事件总线通信,不触碰渲染内部。

组件清单

标签职责
<fmb-viewer-context>接线根:持有 Viewer 实例,经 @lit/context 注入所有后代组件;宿主只需设一次 .viewer
<fmb-model-tree>BOM 结构树:层级展开/折叠、名称过滤、点选与引擎选中双向同步、逐行可见性切换、装配行零件计数徽标
<fmb-toolbar>全视口工具浮层(空白透传画布),内部三层:底部工具坞(Select/Orbit/Pan、取景适配、渲染模式、剖切、爆炸、SVG 导出)+ 状态条(拾取范围 Part/Face/Edge/All 等活跃状态)+ 可拖配置卡片;dock 属性是大按钮档准入,实际档位按宽度自适应
<fmb-inspector>属性面板:未选中时显示文档概览(零件/装配计数);选中后显示节点包围盒、世界坐标、Mesh ID、材质与 BOM 属性
<fmb-view-cube>CSS 3D 视图立方:点击面切 Top/Front/Right 标准视图,Home 按钮回等轴测
<fmb-context-menu>右键菜单:对当前选中节点执行 Isolate/Hide/Show all/Copy reference 等动作;由宿主经 x/y 属性定位并控制显隐

每个组件的属性、事件与行为细节,见上表各标签链接的组件参考页。

视图立方二选一

viewer-core 引擎自带 WebGL NavCube(Viewer.create 默认开启,右上角)。如果改用 <fmb-view-cube> 组件,创建时传 navCube: { enabled: false } 关掉内建立方,避免两者重叠。

接线机制

所有组件经 @lit/context 共享同一个 Viewer 实例:

  • <fmb-viewer-context>provider:它有一个 viewer 属性(property,非 attribute),宿主创建引擎后赋值一次即可。它把子内容渲染在 light DOM(自身不建 shadow root),保证后代组件的 context-request 事件可靠冒泡。
  • 其余组件是 consumer:各自声明 @consume({ context: viewerContext, subscribe: true }),拿到实例后直接调用 viewer-core 公开 API(选择、可见性、相机、剖切……)并订阅引擎事件(如 selection-changed)。

因为 consumer 带 subscribe: true,赋值时机不受限:组件先挂载、viewer 后到也能自动激活;注入前组件渲染空态。宿主侧的全部接线就是一行:

ts
ctx.viewer = viewer; // ctx 是 <fmb-viewer-context> 元素

组件之间没有直接耦合——树里点选、工具条切拾取范围、属性面板刷新,全部经由引擎状态与事件总线同步。宿主也可以只挂用得上的组件,不要求全套。

三种宿主接法

无论哪种宿主,都建议引入一次 @modelcubes/viewer-ui/tokens.css(不引时组件以内置浅色回退值渲染),见主题定制

原生 HTML + TypeScript

独立阅读器 @fmb/viewer 的真实接法。注意第一段注释:@modelcubes/viewer-ui 的入口是纯 re-export,生产构建时 bare 的 import "@modelcubes/viewer-ui" 可能被 tree-shake 掉,导致 <fmb-*> 元素未注册——所以要具名导入组件类并保留引用:

ts
// main.ts
// 具名导入并引用组件类:求值各模块即执行 @customElement(...) 注册,
// 防止 re-export-only 入口在 vite build 时被 tree-shake。
import { FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector, FmbContextMenu } from "@modelcubes/viewer-ui";
import { Viewer } from "@modelcubes/viewer-core";

void [FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector, FmbContextMenu];

const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const ctx = document.getElementById("ctx") as HTMLElement & { viewer?: Viewer };

const viewer = await Viewer.create(canvas, { antialias: true });
ctx.viewer = viewer; // 接线完成:所有后代组件自动激活
html
<fmb-viewer-context id="ctx">
  <fmb-model-tree></fmb-model-tree>
  <div class="viewport">
    <canvas id="canvas"></canvas>
    <!-- 全视口浮层容器:inset:0 + pointer-events:none,见组件参考 · toolbar -->
    <div class="toolbar-overlay"><fmb-toolbar dock></fmb-toolbar></div>
  </div>
  <fmb-inspector></fmb-inspector>
  <fmb-context-menu hidden></fmb-context-menu>
</fmb-viewer-context>

<fmb-toolbar> 是铺满视口的浮层(内部自行布局工具坞/状态条/卡片),宿主给它一个 position: absolute; inset: 0; z-index: 11; pointer-events: none 的 overlay 容器即可,摆法细节见组件参考 · toolbar

React 19

React 19 原生支持 custom elements,无需任何桥接库。桌面客户端 @fmb/desktop 的真实接法(节选自其 ViewerSurface 组件):JSX 直接写 <fmb-*> 标签,用 ref 拿到 context 元素后赋 viewer:

tsx
import { useEffect, useRef } from "react";
import { Viewer } from "@modelcubes/viewer-core";
import { FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector, FmbContextMenu } from "@modelcubes/viewer-ui";

void [FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector, FmbContextMenu];

export function ViewerSurface() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const ctxRef = useRef<HTMLElement & { viewer?: Viewer }>(null);

  useEffect(() => {
    let viewer: Viewer | undefined;
    let cancelled = false;
    void (async () => {
      if (!canvasRef.current || !ctxRef.current) return;
      viewer = await Viewer.create(canvasRef.current, { antialias: true });
      if (cancelled) {
        viewer.dispose?.();
        return;
      }
      ctxRef.current.viewer = viewer;
    })();
    return () => {
      cancelled = true;
      viewer?.dispose?.();
    };
  }, []);

  return (
    <fmb-viewer-context ref={ctxRef} className="main">
      <fmb-model-tree className="tree" />
      <div className="viewport">
        <canvas ref={canvasRef} className="canvas" />
        <div className="toolbar-overlay">
          <fmb-toolbar dock />
        </div>
      </div>
      <fmb-inspector />
    </fmb-viewer-context>
  );
}

TypeScript 下还需给 JSX 补自定义元素声明(同样取自 desktop 真实代码):

ts
// jsx-custom-elements.d.ts
import type { DetailedHTMLProps, HTMLAttributes, Ref } from "react";

type CE<T = HTMLElement> = DetailedHTMLProps<HTMLAttributes<T>, T> & { ref?: Ref<T> };

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "fmb-viewer-context": CE;
      "fmb-model-tree": CE;
      "fmb-toolbar": CE;
      "fmb-inspector": CE;
      "fmb-context-menu": CE;
    }
  }
}

这份声明文件放进 src/(或任何被 tsconfig include 覆盖的目录)即可生效,无需手动 import。

两个实践细节:创建 Viewer 的 effect 用空依赖数组 + cleanup 里 dispose,避免宿主重渲染时重建引擎(来自 desktop 真实代码);dock 是响应式 Boolean property,JSX 里直接写 <fmb-toolbar dock /> 或经 ref 赋值 toolbarRef.current.dock = true 均可——desktop 现仍用 toolbarRef.current?.setAttribute("dock", ""),同样有效(设 attribute 会同步更新 property)。toolbar-overlay 容器是 position: absolute; inset: 0; z-index: 11; pointer-events: none 的全视口浮层,见组件参考 · toolbar

Vue 3

仓库内没有 Vue 宿主,以下是在 Vue 中使用自定义元素的标准接法。先在构建配置里告诉 Vue 编译器 fmb-* 是自定义元素:

ts
// vite.config.ts
import vue from "@vitejs/plugin-vue";

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: { isCustomElement: (tag) => tag.startsWith("fmb-") },
      },
    }),
  ],
};

组件里用模板 ref 赋值(与原生/React 同一模式),或用 .prop 修饰符把响应式实例绑到 viewer property:

vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { Viewer } from "@modelcubes/viewer-core";
import { FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector } from "@modelcubes/viewer-ui";

void [FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector];

const canvasEl = ref<HTMLCanvasElement | null>(null);
const ctxEl = ref<(HTMLElement & { viewer?: Viewer }) | null>(null);
let viewer: Viewer | undefined;

onMounted(async () => {
  viewer = await Viewer.create(canvasEl.value!, { antialias: true });
  ctxEl.value!.viewer = viewer; // 或在模板上写 :viewer.prop="viewerRef"
});
onBeforeUnmount(() => viewer?.dispose?.());
</script>

<template>
  <fmb-viewer-context ref="ctxEl" class="main">
    <fmb-model-tree class="tree" />
    <div class="viewport">
      <canvas ref="canvasEl"></canvas>
      <div class="toolbar-overlay"><fmb-toolbar dock /></div>
    </div>
    <fmb-inspector />
  </fmb-viewer-context>
</template>

viewer 是 property 而非 attribute(对象值无法序列化为 HTML 属性),所以 Vue 模板绑定必须带 .prop 修饰符(:viewer.prop="..."),普通 :viewer 绑定不生效。

下一步

  • 主题定制 — design tokens 全表、暗色模式与覆写方式。
  • 完整集成示例 — 一份可抄的查看器页面:布局 + 文件打开 + 主题切换 + 右键菜单。