Skip to content

控制器轴开关与手势门控

关掉旋转 —— 无论是为了 2D 视图,还是只在用户拖别的东西的那一小会儿 —— 是两行代码, 而这两行写错的次数比写对的多。3.9.0 新增了一组小工具,让这两类错误写不出来。

命名陷阱

两套拼法,而且是反的:

旋转平移缩放
OrbitControlsCopper3dOrbitControlsenableRotateenablePanenableZoom
TrackballControlsCopper3dTrackballControlsnoRotatenoPannoZoom

两个类都不会校验对方的属性名。在轨迹球上写 enableRotate 不报错、不警告、也什么都不做 —— 它只是多出一个没人读的字段。视图照转不误,然后几个月后才有人报告说"那张 2D 图会转"。

setRotateEnabled 会写实例上真正存在的那套拼法:

ts
import Copper from "copper3d";

Copper.setRotateEnabled(scene.controls, false);   // 轨迹球还是 orbit 都对
Copper.setPanEnabled(scene.controls, false);
Copper.setZoomEnabled(scene.controls, true);

if (Copper.isRotateEnabled(scene.controls)) { /* ... */ }
import Copper from "copper3d";

Copper.setRotateEnabled(scene.controls, false);   // 轨迹球还是 orbit 都对
Copper.setPanEnabled(scene.controls, false);
Copper.setZoomEnabled(scene.controls, true);

if (Copper.isRotateEnabled(scene.controls)) { /* ... */ }

同时提供:isPanEnabledisZoomEnabledsetPanEnabledsetZoomEnabled

它们遵守三条规则:

  • 两套拼法都没有的轴,读出来是"启用",写的时候原样不动。既然没有东西在管它,凭空造 一个类根本不读的属性,只是一个长得像修复的谎。
  • 两套都有的实例(包装层,或者曾经写错过一次的代码库)两个都写,免得它自己跟自己 矛盾。
  • 它们接受任何带这些字段的对象,所以从发布 bundle 里拿到的控制器实例照样能用。

手势门控

另一半。当一次指针拖拽的含义不是"移动相机" —— 拖切片平面、拖标注锚点、在网格上画 —— 旋转必须在恰好这段手势的时间里被压住:

ts
import Copper from "copper3d";

let release: (() => void) | null = null;

function onPointerDown(event) {
  if (!hitsMyDraggableThing(event)) return;
  release = Copper.beginGesture(scene.controls);
}

function onPointerUp() {
  release?.();
  release = null;
}

// 那个 `true` 是关键 —— 见下文
el.addEventListener("pointerdown", onPointerDown, true);
el.addEventListener("pointerup", onPointerUp);
el.addEventListener("pointercancel", onPointerUp);
el.addEventListener("pointerleave", onPointerUp);
import Copper from "copper3d";

let release: (() => void) | null = null;

function onPointerDown(event) {
  if (!hitsMyDraggableThing(event)) return;
  release = Copper.beginGesture(scene.controls);
}

function onPointerUp() {
  release?.();
  release = null;
}

// 那个 `true` 是关键 —— 见下文
el.addEventListener("pointerdown", onPointerDown, true);
el.addEventListener("pointerup", onPointerUp);
el.addEventListener("pointercancel", onPointerUp);
el.addEventListener("pointerleave", onPointerUp);

默认只压旋转。手势需要更多就明说:

ts
Copper.beginGesture(scene.controls, { rotate: true, pan: true, zoom: true });
Copper.beginGesture(scene.controls, { rotate: false, pan: true });  // 只压平移
Copper.beginGesture(scene.controls, { rotate: true, pan: true, zoom: true });
Copper.beginGesture(scene.controls, { rotate: false, pan: true });  // 只压平移

为什么不能直接设回"启用"

因为在你的手势开始之前,视图可能本来就是锁着的。一个出厂就关掉旋转的 2D 模态,会被第一次 拖拽悄悄解锁,而且此后再没有任何东西把它锁回去 —— 这个 bug 在造成它的那次手势里完全看不 出来,要等到某个本不该转的视图转起来时才暴露。

beginGesture 在压任何东西之前先把值记下来,结束时原样放回去。本来锁着的,还它锁着。

监听要挂在捕获阶段

这件事工具替你做不了,而写错要付出整整一次手势的代价。

Copper3dTrackballControls 的监听挂在 canvas 上、冒泡阶段,而它的 pointerdown handler 就在那里锁定了 rotate 状态 —— 它在那一刻读 noRotate 并记下拖拽起点。挂在祖先元素 冒泡阶段的监听器跑在它之后,于是你刚设的压制来晚了一整个手势,第一次拖拽会既滚切片 又转相机。

capture: true 注册,把你的 handler 排到它前面:

ts
el.addEventListener("pointerdown", onPointerDown, true);
el.addEventListener("pointerdown", onPointerDown, true);

释放

  • 返回的函数是幂等的。同时挂到 pointeruppointercancelpointerleave 上是安全 的,而且你正需要这样:指针在拖拽途中移出元素时,只有 pointerleave 会触发,少了它视图 就永久转不动了。
  • 同一个 controls 对象上的门控会引用计数。两个重叠的手势都压制,最后一个释放时才还回去。
  • isGestureActive(controls) 告诉你当前有没有东西持有它。

从 3.8.x 升级

纯新增 —— 只多了导出,没有改任何既有行为。如果你现在直接写 noRotate 并且工作正常,它 继续正常。值得改用这组工具的场景是:控制器的类型不是静态已知的,或者一次手势需要"还原 成它找到的样子"。