Appearance
完整集成示例
本页给出一个完整可抄的查看器页面:canvas + 全部 6 个组件 + 文件打开 + 主题切换 + 右键菜单接线。它是独立阅读器 @fmb/viewer 的简化版,与其同构:index.html(结构)+ src/main.ts(接线)+ src/app.css(布局)。
前提:Vite(或任何支持 bare specifier 的构建工具)项目,已安装 @modelcubes/viewer-core 与 @modelcubes/viewer-ui(见安装)。
index.html
html
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>FMB Viewer</title>
</head>
<body>
<fmb-viewer-context id="ctx">
<header class="hd">
<button id="theme-toggle" title="切换主题">◐</button>
<button id="open-file">打开 .fmbv</button>
<input type="file" id="file-input" accept=".fmbv" hidden />
</header>
<div class="main">
<fmb-model-tree id="tree" class="tree"></fmb-model-tree>
<div class="viewport" id="viewport">
<canvas id="canvas"></canvas>
<fmb-view-cube></fmb-view-cube>
<div class="toolbar-overlay"><fmb-toolbar dock></fmb-toolbar></div>
</div>
<fmb-inspector></fmb-inspector>
</div>
<fmb-context-menu id="ctxmenu" hidden></fmb-context-menu>
</fmb-viewer-context>
<script type="module" src="/src/main.ts"></script>
</body>
</html>src/main.ts
ts
// 具名导入并引用组件类:求值各模块即执行 @customElement(...) 注册,
// 防止 re-export-only 入口在生产构建时被 tree-shake(见总览页)。
import { FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector, FmbViewCube, FmbContextMenu } from "@modelcubes/viewer-ui";
import "./app.css";
import { Viewer } from "@modelcubes/viewer-core";
void [FmbViewerContext, FmbModelTree, FmbToolbar, FmbInspector, FmbViewCube, FmbContextMenu];
async function boot() {
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const ctx = document.getElementById("ctx") as HTMLElement & { viewer?: Viewer };
// 本例用 <fmb-view-cube> 组件,关掉引擎内建 NavCube 避免右上角重叠
const viewer = await Viewer.create(canvas, { antialias: true, navCube: { enabled: false } });
ctx.viewer = viewer; // 接线:所有后代组件自动激活
// ── 主题切换(CSS token + 画布背景双轨同步) ──
let theme: "light" | "dark" = "light";
const BG = {
light: { r: 0xf4 / 255, g: 0xf6 / 255, b: 0xf9 / 255 }, // 对齐 --fmb-bg
dark: { r: 0x0a / 255, g: 0x0e / 255, b: 0x14 / 255 },
};
const applyTheme = () => {
document.documentElement.setAttribute("data-theme", theme);
viewer.background.setColor(BG[theme]);
};
applyTheme();
document.getElementById("theme-toggle")!.addEventListener("click", () => {
theme = theme === "light" ? "dark" : "light";
applyTheme();
});
// ── 文件打开 ──
const input = document.getElementById("file-input") as HTMLInputElement;
document.getElementById("open-file")!.addEventListener("click", () => input.click());
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
await viewer.model.load(bytes);
viewer.camera.fitView();
} catch (err) {
console.error("load failed", err);
}
});
// ── 右键菜单:视口与树上右键打开,点外面 / Escape / 菜单动作后关闭 ──
const menu = document.getElementById("ctxmenu") as HTMLElement & { x: number; y: number };
const viewport = document.getElementById("viewport")!;
const tree = document.getElementById("tree")!;
const openMenu = (e: Event) => {
const me = e as MouseEvent;
e.preventDefault();
// 钳在视口内(菜单约 220×290)
menu.x = Math.max(0, Math.min(me.clientX, window.innerWidth - 220));
menu.y = Math.max(0, Math.min(me.clientY, window.innerHeight - 290));
menu.hidden = false;
};
const closeMenu = () => {
menu.hidden = true;
};
viewport.addEventListener("contextmenu", openMenu);
tree.addEventListener("contextmenu", openMenu);
document.addEventListener("click", (e) => {
if (!menu.contains(e.target as Node)) closeMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu();
});
menu.addEventListener("fmb-ctx-close", closeMenu);
}
boot().catch((e) => console.error("boot failed", e));src/app.css
最小可用布局:顶栏 + 三栏主区(树 | 视口 | 属性面板),canvas 撑满视口。
css
@import "@modelcubes/viewer-ui/tokens.css";
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
font: 400 var(--fmb-base-fs, 14px) / 1.4 var(--fmb-font);
color: var(--fmb-fg-1);
background: var(--fmb-bg);
}
#ctx {
height: 100%;
display: flex;
flex-direction: column;
}
.hd {
height: var(--fmb-header-h, 48px);
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 12px;
background: var(--fmb-surface);
border-bottom: 1px solid var(--fmb-border);
flex-shrink: 0;
}
.main {
flex: 1;
display: flex;
min-height: 0;
}
.tree {
width: var(--fmb-lpanel-w, 256px);
flex-shrink: 0;
}
.viewport {
flex: 1;
min-width: 0;
position: relative;
overflow: hidden;
border-left: 1px solid var(--fmb-border);
}
#canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
/* 全视口工具浮层:工具坞/状态条/配置卡片由 fmb-toolbar 内部布局 */
.toolbar-overlay {
position: absolute;
inset: 0;
z-index: 11;
pointer-events: none; /* 仅 fmb-toolbar 内部元素接收点击,空白透传画布 */
}跑起来后:点「打开 .fmbv」加载模型(示例模型见快速开始),左树点选与视口高亮同步,工具条切渲染模式/剖切/爆炸,右键弹菜单做 Isolate/Hide/Show all。
逐段解说
HTML 结构
<fmb-viewer-context id="ctx">包住整页内容(含 canvas 与全部组件)。它渲染 light DOM、自身无样式,所以可以直接当布局容器用(本例给它 flex 列布局)。- canvas 放在一个
position: relative的视口容器里,绝对定位撑满;<fmb-view-cube>(自带position: absolute右上角定位)和工具浮层都挂在同一容器内。 <fmb-toolbar>是铺满视口的浮层,宿主给它一个inset: 0; pointer-events: none的 overlay 容器(.toolbar-overlay),工具坞/状态条/配置卡片由组件内部布局,空白区域点击透传画布。dock是大按钮档的准入开关:设了它且宽度放得下时升 52px 大按钮档;宽度不够自动降为 36px 紧凑条,再不够把低频按钮收进 "⋯" 溢出菜单(组件用 ResizeObserver 测自身宽度,实际档位反射在tierattribute)。细节见组件参考 · toolbar。<fmb-inspector>自带宽度(--fmb-rpanel-w)与左边框,不需要额外布局类;<fmb-model-tree>需要宿主给宽度。<fmb-context-menu hidden>初始隐藏,放在 context 内任意位置均可(position: fixed自行定位)。
接线
Viewer.create(canvas, ...) 创建引擎后,一行 ctx.viewer = viewer 完成全部接线——组件经 @lit/context 拿到实例,彼此之间靠引擎状态与事件同步,机制详见总览与接线。本例传了 navCube: { enabled: false }:因为挂了 <fmb-view-cube> 组件,关掉引擎默认开启的内建 WebGL NavCube,两者择一即可。
文件打开
隐藏的 <input type="file" accept=".fmbv"> 由按钮代理点击;选中文件后读成 Uint8Array 交给 viewer.model.load(bytes),完成后 fitView() 取景。load() 也接受 URL,部署在服务端的模型可直接传地址(见加载与流式)。
主题切换
两轨同步:data-theme attribute 驱动全部 CSS token(组件界面),viewer.background.setColor 驱动 3D 画布背景(引擎绘制,CSS 管不到)。两组背景色取自 --fmb-bg 的浅/暗两个主题值,详见主题定制。
右键菜单
<fmb-context-menu> 只负责菜单本体与动作执行(对当前选中节点 Isolate/Hide/Copy reference,以及不依赖选中集的 Show all),何时何地打开由宿主决定:监听视口与树的 contextmenu 事件,设置组件的 x/y 属性并清掉 hidden;坐标钳在视窗内防止溢出。关闭路径有三条——点菜单外、按 Escape、以及菜单自身派发的 fmb-ctx-close 事件(点菜单项后触发)。菜单动作执行后还会派发 fmb-ctx-action(detail: { action, nodeIds },冒泡、穿 Shadow DOM),宿主需要联动时可监听。属性、事件与接线细节见组件参考 · context-menu。
与 @fmb/viewer 阅读器 app 的差异
本例为最小集成,@fmb/viewer(仓库内的独立阅读器)还包含以下本例略去的内容:
- 品牌顶栏与面包屑:显示产品标识与当前加载的文件名(加载成功/失败时更新)。
- 左侧图标 rail(BOM 等入口)与树/属性面板的折叠把手按钮。
- 拖放打开:监听
window的dragover/drop,把拖入的.fmbv直接加载。 - URL 深链:启动时读
?model=<url>参数自动加载模型(便于分享与 e2e)。 - 视图立方取舍相反:viewer app 未挂
<fmb-view-cube>,用的是 viewer-core 内建 WebGL NavCube(Viewer.create默认开启)。 - 画布背景更讲究:viewer app 用
viewer.background.setGradient(top, bottom)画冷中性垂直渐变(浅色档顶部值刻意 >1,抵消引擎常开的 ACES 色调映射压缩),本例简化为setColor纯色对齐--fmb-bg。 - Google Fonts 引入 Inter / JetBrains Mono(token 字体栈的首选项),以及更完整的布局视觉 CSS。