← ALL NOTES
METHOD·2026.09·6 MIN READ
3D Gizmo 的桌面端交互适配
3d-visualization3d-design

这篇记录 cube-gizmo 从触屏方案扩展到桌面端时做的一轮适配。移动端的触控方案已经单独整理过,这里只保留桌面端相关的内容。

桌面端沿用同一个 Gizmo、同一套移动和旋转语义,输入方式换成鼠标后,交互细节需要重新调整。鼠标有 hover、左右键、中键、滚轮和键盘,这些输入能力可以直接承担一部分原来由长按和手势处理的任务。

桌面端的输入映射

最终桌面端使用这套操作:

输入行为
左键按下 + 拖拽直接操作 Gizmo handle
鼠标 Hover提前高亮当前可操作 handle
右键拖拽旋转相机
中键拖拽平移相机
滚轮缩放场景
Escape取消当前 Gizmo 操作并恢复操作前状态

移动端需要 260ms 长按来确认拖拽意图,桌面端直接在 pointerdown 后进入拖拽。鼠标的指向精度更高,同时有稳定的 Hover,用户在按下之前已经能确认目标。

这套处理保留了两端相同的操作对象和结果:用户仍然直接操作 Gizmo 上的轴、平面和旋转手柄。输入设备只改变触发方式。

Hover:把"可操作"和"正在操作"分开

桌面端最明显的新状态是 Hover。

移动端没有稳定的悬停状态,触屏方案只能等用户真正按下以后再反馈。鼠标可以在按下前给出预反馈,因此我把状态分成三层:默认保持轴本身的红 / 绿 / 蓝;Hover 是 #FFD060 暖黄;Active 是 #FF8A00 橙色。

暖黄用于提示"当前命中这个 handle",橙色表示操作已经开始。鼠标移到可拖动区域时,光标同步切换成 grab。

Hover 检测直接挂在 pointermove 上。为了避免鼠标每移动一个像素都触发重复状态更新,会先把当前命中结果转换成一个稳定的 hitKey,只有目标发生变化时才更新状态:

JAVASCRIPT
const hit = intersectHandle(event)
const hitKey = hit ? `${hit.type}-${hit.axis ?? hit.plane}` : null
const prevKey = state.hover ? `${state.hover.type}-${state.hover.axis ?? state.hover.plane}` : null

if (hitKey !== prevKey) {
  state.hover = hit
  renderer.domElement.style.cursor = hit ? 'grab' : 'default'
}

这个处理对 Gizmo 很有用,因为鼠标移动事件非常密集,而大多数连续事件其实仍然落在同一个 handle 上。

Handle 和标签重叠时,先决定事件归谁

桌面版还有一个实际冲突:边长标签和 Gizmo handle 在屏幕投影上可能重叠。

标签本身可以单击进入数值编辑;Gizmo handle 可以拖拽。用户点在两者重叠的位置时,需要先判断这次输入应该进入哪条交互链。

当前规则是:Gizmo 命中优先。

pointerdown 时先执行一次 handle hit test:

JAVASCRIPT
if (state.variant === 4) {
  if (intersectHandle(event)) {
    pcDidRoute = true
    onPointerDown(event)
  } else {
    pcDidRoute = false
  }
}

后续 click 再根据 pcDidRoute 判断是否打开标签编辑器:

JAVASCRIPT
if (state.variant === 4 && !pcDidRoute) {
  openDimensionEditor()
}

这里单独保留 pcDidRoute,是因为 click 会在 pointerup 之后触发。用于拖拽的临时状态在 pointerup 阶段已经清掉,不能再拿它判断刚才的 pointerdown 最终被路由到了哪里。

这类重叠区域最好在输入刚开始时就决定归属。拖到一半再切换目标,或者同时让两个交互响应,都会让桌面操作变得很难预测。

鼠标按键分工

桌面端可以利用不同鼠标按键把对象操作和相机操作分开。

当前约定:左键选择和拖动 Gizmo;右键旋转相机;中键平移相机;滚轮缩放。

右键用于相机旋转时,需要关闭浏览器默认上下文菜单:

JAVASCRIPT
renderer.domElement.addEventListener('contextmenu', event => {
  event.preventDefault()
})

这样左键可以一直保持对象操作语义,用户不需要通过额外模式按钮切换"现在是在改物体还是在转相机"。

Escape 取消当前操作

桌面工具里,Escape 是很自然的取消入口。

Gizmo 开始拖拽时会保存一份 operationStart 快照,包括立方体的位置和旋转。拖动过程中,每一帧都基于"初始状态 + 当前 delta"重新计算结果。

这种计算方式有两个好处:帧率波动不会造成连续 delta 累积误差;Escape 可以直接恢复操作开始前的状态。

JAVASCRIPT
function onKeyDown(event) {
  if (event.key === 'Escape' && state.variant === 4) {
    if (state.operationStart) {
      cubeGroup.position.copy(state.operationStart.cubePosition)
      cubeGroup.quaternion.copy(state.operationStart.cubeQuaternion)
    }

    state.active = null
    state.operationStart = null
    state.draggingCamera = false
    state.panningCamera = false
  }
}

keydown 监听挂在 window 上,因此场景销毁时也要对应 removeEventListener,避免重复挂载和内存泄漏。

自动判断输入能力

桌面 / 触屏模式没有通过 User Agent 判断。

目前使用:

JAVASCRIPT
window.matchMedia('(pointer: fine)').matches

pointer: fine 表示当前环境提供高精度指针,通常对应鼠标或触控板;coarse 更接近手指触控。

JAVASCRIPT
React.useEffect(() => {
  if (window.matchMedia('(pointer: fine)').matches) {
    setVariant(4)
  }
}, [])

这样判断和实际输入能力更接近,也能避开 iPad、桌面浏览器模拟设备等场景里 User Agent 带来的误判。

需要留意混合设备。例如 iPad 接入鼠标以后可能进入桌面模式;Surface 一类设备也可能在触屏和精确指针之间切换。如果产品以后需要完整支持混合输入,可以继续监听 media query 的变化,而不只在页面初始化时判断一次。

Demo 里没有再提供手动切换移动 / 桌面方案的 Tabs。打开页面后直接根据输入能力进入对应模式,减少一次和实际任务无关的选择。

共用的 3D 拾取基础

桌面适配没有重新做一套 Gizmo 几何体和拾取逻辑,两端共用同一套 Raycaster 和 handle 数据。

基本过程还是:把屏幕坐标转换成 NDC;从相机发射 Ray;用 Raycaster.intersectObjects() 获取命中对象;根据距离和 handle priority 决定最终目标。

Gizmo 视觉轴很细,所以真正参与 hit test 的仍然是更大的不可见命中几何体。视觉大小和交互区域分开以后,移动端和桌面端可以共享一套 Gizmo 外观,再针对输入能力调整命中范围和触发方式。

当前命中优先级里,轴末端箭头高于轴中段;平面和旋转弧也有独立 priority。多个命中区域重叠时,优先级可以保持交互结果稳定。

实现结构

输入事件。 桌面端主要使用:pointerdown / pointermove / pointerup / wheel / contextmenu / keydown。Pointer Events 继续统一 mouse 和 touch,避免维护两套完全独立的事件系统。

核心状态。 createScene() 内保留几个明确状态:active(正在操作的 handle)、selected(已选中对象 / handle)、hover(当前鼠标悬停目标)、operationStart(操作开始快照)、pointers(当前活跃指针)、draggingCamera(正在旋转相机)、panningCamera(正在平移相机)。

React / Three.js 边界。 React 负责 variant、面板、编辑器等声明式 UI 状态;Three.js 场景继续用命令式方式管理 Renderer、Camera、Gizmo、Raycaster 和实时拖拽。createScene() 接收 DOM 容器与回调,返回 setVariant、reset、setDimension 等命令式 API。React 通过 useRef 保存这组 API,通过回调接收场景状态变化。

做完桌面适配后,我会检查这些点
  • 鼠标移到每个 handle 时,Hover 目标和光标是否稳定;
  • Hover 与 Active 的视觉状态能否清楚区分;
  • 左键拖 Gizmo 时,相机不会同时响应;
  • 右键 / 中键操作相机时,不会误触对象;
  • 标签与 handle 重叠时,事件优先级是否符合预期;
  • Escape 能否完整恢复操作前的位置和旋转;
  • 切换模型、重建场景后,键盘和鼠标监听没有重复注册;
  • 鼠标、触控板、触屏以及混合输入设备的模式判断是否合理。

这次桌面端适配里,Gizmo 的操作语义和 3D 拾取基础都沿用了触屏版。桌面输入多出来的 Hover、鼠标按键和键盘取消能力,直接接进已有状态系统。这样两端可以共享大部分场景逻辑,同时保留各自更顺手的输入方式。

CONTINUE READING
从 Figma 组件属性到代码 Props2026.09Figma 设计系统的工程化落地2026.09