Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面

上周给学弟讲实验室那台六轴机械臂,我指着肩部那圈橙色问他:"这是电机还是关节?"他犹豫了半天。我想打开 SolidWorks 转个角度,结果装配体在老机器上加载了好一会儿;就算打开了,也没法当场把外壳拆开,让他看到里面的减速器和传动轴。

这次之后我一直觉得,讲结构这件事,CAD 不是最合适的载体:文件重、依赖软件、发给别人还不一定打得开。我想要的是一个打开浏览器就能转、能拆、能剖的教具,最好断网也能用------实验室的工控机经常不联网。

于是有了这个项目:六轴机械臂结构工作台。48 个可点选零件、8 个总成,支持完整装配、爆炸图、透明外壳和 X/Y/Z 剖面,最后打成一个双击就能运行的 index.html。文末是在线演示和 33 秒录屏。

功能演示:爆炸图 → 透明外壳 → Z 轴剖面 → 一键复位。完整 33 秒视频见文末。

六轴机械臂结构工作台功能演示

一、先定边界:不做 CAD,只做教具

动手前我把范围写死:模型程序化生成,不导入 GLB/STEP;不做关节运动仿真和物理仿真;不做后端和账号;最终产物是一个 HTML,运行时不发任何网络请求。

这个取舍很关键。如果一开始就想着"导入真实 CAD 模型",光模型轻量化、材质、坐标系对齐就够耗一周,而且教学演示并不需要工程级尺寸精度。程序化模型反而有好处:每个零件的 ID、中文名、所属总成、爆炸方向都在代码里,点选和讲解可以精确到"肩部减速器"这种粒度,而不是"某个 mesh"。

模型按 8 个总成组织:固定底座、一轴回转台、二轴肩关节、大臂总成、三轴肘关节、四轴小臂总成、五轴腕关节、六轴末端夹具。每个总成是一个 Group,零件挂在下面;外壳零件在注册时单独记进 shellMeshes,后面透明模式要用。48 个零件都带 userData.partId,Raycaster 命中后就能反查到名称、总成和说明。

配色也没有散落在各处。整机只有五种基础材质:象牙白外壳、橙色关节环、深色金属、银色电机、黑色夹具。每个零件注册时克隆一份,所以 48 个零件仍然只有五套参数要维护,改配色不会牵一发动全身,同时又能单独改某个零件的透明度而不影响同材质的其他零件。

二、四个模式共用一个状态机

最容易写乱的地方,是四个模式各自改场景。爆炸图改了零件位置,透明模式改了材质,剖面模式又改了裁剪面,切换时如果没人负责清理,就会留下"半爆炸 + 透明 + 还在剖"的混合状态。我把它收成一个纯函数 reducer:

js 复制代码
export const initialViewState = Object.freeze({
  mode: 'assembly', explosion: 0, shellTransparency: 0,
  clipAxis: 'x', clipPosition: 0, selectedPart: null
});

export function reduceViewState(state, action) {
  switch (action.type) {
    case 'SET_MODE': return { ...state, mode: modes.has(action.mode) ? action.mode : 'assembly' };
    case 'SET_EXPLOSION': return { ...state, explosion: clamp(action.value) };
    case 'SET_SHELL_TRANSPARENCY': return { ...state, shellTransparency: clamp(action.value) };
    case 'SET_CLIP_AXIS': return { ...state, clipAxis: axes.has(action.axis) ? action.axis : 'x' };
    case 'SET_CLIP_POSITION': return { ...state, clipPosition: clamp(action.value, -1, 1) };
    case 'SELECT_PART': return { ...state, selectedPart: action.partId ?? null };
    case 'RESET': return createViewState();
    default: return state;
  }
}

这样做有三个直接收益:所有越界值在入口就被夹住;"一键复位"只是 dispatch 一个 RESET,不用逐个模块清理;状态机不碰 DOM 和 Three.js,单元测试不需要 WebGL。

UI 层只负责把按钮和滑块翻译成 action,显示控制器只负责把 state 落到场景,两边都不保存自己的状态。这样从爆炸图直接切到剖面图,也不会出现"爆炸位移没归零"的残留;选中零件与当前模式解耦,切模式时说明面板不会被清空。

四个模式的分工如下:

模式 实际改动 可调参数
完整装配 不改几何,只做拾取与相机聚焦 无
爆炸图 零件位置 = 基准位置 + 爆炸向量 × 比例 爆炸距离 0--100%
透明外壳 外壳材质的透明度与 depthWrite 外壳透明度 0--100%
剖面图 所有材质的 clippingPlanes X/Y/Z 轴、剖切位置

三、爆炸图:按装配方向走,不要随机散开

随机给每个零件加一个方向,看起来也会"炸开",但看不出装配关系。我的做法是给 8 个总成各定一个方向,例如回转台沿 Z 向后、肩关节沿 X 向左、大臂向上、肘关节向前、腕部和夹具向右,然后零件的爆炸向量 = 总成向量 + 自己的小偏移。底座固定不动,作为参照。

每帧只做一次插值,用指数阻尼让运动与帧率无关:

js 复制代码
const target = basePosition.clone();
if (state.mode === 'exploded') {
  target.addScaledVector(part.explodeVector, explosion);
}
part.object.position.lerp(target, 1 - Math.exp(-12 * delta));

1 - Math.exp(-12 * delta) 这一项,在 60 帧和 120 帧下衰减速度一致;如果直接写 lerp(target, 0.2),高刷屏上会明显更快。滑块拖动时只改比例,位移仍然由这条插值追上去,所以既有即时反馈,又不会瞬移。滑杆对外显示 0--100%,状态里存 0--1,单位转换只发生在 UI 边界,测试和渲染都用同一套数值,不会出现"65 还是 0.65"的歧义。

四、透明外壳:内部必须保持实体

透明模式失败最常见的样子,是整机一起变半透明,内部电机也跟着淡掉,最后什么都看不清。我在建模阶段就把外壳材质单独登记,切换时只动这一组:

js 复制代码
const transparent = state.mode === 'shell' && transparency > 0;
for (const material of shellMaterials) {
  if (material.transparent !== transparent) {
    material.transparent = transparent;
    material.needsUpdate = true;
  }
  material.opacity = 1 - transparency;
  material.depthWrite = !transparent;
}
for (const material of internalMaterials) material.opacity = 1;

两个细节值得说。第一,每个零件的材质在注册时都 clone() 过,所以改外壳不会把内部零件一起染色;第二,透明时把 depthWrite 关掉,能减少多层外壳互相遮挡产生的闪烁,但内部材质始终 opacity = 1,电机、减速器和传动轴仍然是不透明的实体。needsUpdate 只在"是否透明"翻转时置位,拖滑块改透明度不需要反复重编译材质。

五、剖面图:裁剪面要挂到所有材质上

剖面用的是 Three.js 的局部裁剪面。渲染器先打开 localClippingEnabled,然后根据当前轴创建一个平面,法线取正轴的反方向,常数由滑杆位置映射到模型尺度:

js 复制代码
plane.normal.set(...axisNormals[state.clipAxis]); // x:[−1,0,0] y:[0,−1,0] z:[0,0,−1]
plane.constant = state.clipPosition * 4;

const assigned = activeClipAxis ? [plane] : [];
for (const material of materials) {
  material.clippingPlanes = assigned;
  material.clipShadows = Boolean(activeClipAxis);
}

这里踩过一个坑:一开始我只把裁剪面挂给外壳,结果切开后内部零件完好无损地悬在空中,看起来像一个空心壳。裁剪必须覆盖场景里所有材质,包括内部电机和传动轴,切面才是真实的。另外 clipShadows 要跟着开,否则地面阴影还是完整的;剖面辅助线用 PlaneHelper 画出来,同时把它的 raycast 置空,避免点选时命中辅助线。

六、单文件与离线验证

构建用 esbuild 把 main.js、Three.js 和 OrbitControls 打成一个 IIFE,再把 CSS 和 JS 内联进模板。注意 replace 要传函数而不是字符串,否则压缩后的代码里出现 $& 这类片段时会被当成替换模式:

js 复制代码
const result = await build({
  entryPoints: ['src/main.js'], bundle: true, minify: true,
  format: 'iife', target: ['es2020'], write: false
});
const js = result.outputFiles[0].text.replace(/<\/script/gi, '<\\/script');
const html = template
  .replace('/* INLINE_CSS */', () => css)
  .replace('/* INLINE_JS */', () => js);

"离线可用"不能只靠断网试一次。浏览器测试会把只有 index.html 的临时目录用 file:// 打开,监听所有请求并断言没有 http(s) 外部请求,同时收集 pageerror 和控制台错误;WebGL2 降级则用 addInitScript 让 getContext('webgl2') 返回 null,检查提示文案是否出现、有没有未捕获异常。测试结果:13 项单元测试、16 项真实浏览器测试全部通过。

检查项 验证方式
单文件 临时目录只复制 index.html,file:// 打开
无外链 监听 request,断言不存在 http(s) 请求
四种模式 断言爆炸位移、外壳 0.25 / 内部 1、Z 轴裁剪生效
窄屏 390×844 下展开底部面板,复位按钮可达
WebGL 降级 屏蔽 webgl2 上下文,断言提示可见且无报错
空闲性能 断言无动画时停止 RAF,交互后恢复

最后一项是我比较在意的:场景只在有动画或相机变化时渲染,静止时不空转。对教学演示这种"打开后放着讲"的场景,长期占用 GPU 没有意义。

拾取也做了同样的边界处理:pointerup 时如果指针移动超过 5 像素,就判定为拖拽旋转而不是点击,避免转完相机误选零件;点空白处则清空选中。高亮不是换材质,而是记录每个 mesh 原来的自发光颜色,选中时改成橙色,换选或复位时原样恢复,这样不会破坏外壳透明和剖面模式下的材质状态。

七、演示与使用

完整流程是:完整装配下旋转观察 → 点击零件看说明 → 左侧总成聚焦 → 爆炸图拉到 65% → 透明外壳拉到 75% → Z 轴剖面 → 一键复位。我录了一段 33 秒的无声演示,可以直接看操作节奏。

如果只是想在课上或组会上讲结构,直接下载仓库里的 index.html 双击即可,不需要 Node.js、服务器或网络。模型是按教学示意生成的,不宣称工程级尺寸和装配精度;如果要继续做,我会先加关节运动学和 GLB 导入,让"看结构"和"看运动"能放在同一个页面里。

相关推荐
花间相见1 小时前
【计算基础|MySQL14】MySQL 锁(上):种类与加锁规则
1024程序员节
花间相见1 小时前
【计算基础|MySQL15】MySQL 锁(下):锁全表与死锁
1024程序员节
sdm0704271 小时前
back_insert_iterator-尾插迭代器类
1024程序员节
DisonTangor1 小时前
MOSS-Image-Base 实战:从文生图到 JSON 布局精准控图
人工智能·ai作画·开源·aigc·1024程序员节
TechVoyager_82461 小时前
十六个调试引脚的两副面孔:IT66322 切换芯片的引脚复用与功耗分档
1024程序员节·切换芯片·引脚复用·功耗分档
lunar_coder1 小时前
【C++】如何解决智能指针的析构函数,如何实现shared_ptr的引用计数以及解决shared_ptr的循环引用
c++·1024程序员节
瑞克-桑切斯1 小时前
脚本陷入自循环的解决方法
1024程序员节
Java开发追求者1 小时前
windows快速安装docker
windows·docker·安装·1024程序员节
我叫洋洋1 小时前
用 Qoder(AI 编程助手)把 Cadence 16.6 从零配到能画板
人工智能·单片机·嵌入式硬件·电路·1024程序员节