27 · 鼠标拾取与拖动家具

返回系列目录

需求与几何约束

编辑器中拖动家具时,家具应沿地面移动,按下鼠标不能突然把家具中心移到光标下。二维鼠标没有深度信息,必须增加约束:本例规定家具原点沿世界 y = 0 平面移动。射线和平面的交点给出世界坐标,按下时保存“家具原点减交点”的偏移。

射线 p(t) = o + td 与平面 n·p + c = 0 的交点参数为:

t=−n⋅o+cn⋅d. t=-\frac{n\cdot o+c}{n\cdot d}.

分母接近零表示视线近平行于地面,交点会非常远甚至不存在。遇到这种情况应暂停更新。最后必须将世界位置转换为父节点局部位置,才能赋给 object.position。

完整核心代码

依赖 README 的 createDemo();相机应位于地面上方并看向原点。为观察父空间差异,本例给家具设置了旋转父节点。

import { createDemo } from './runtime.js';

const { THREE, scene, camera, renderer, onDispose } = createDemo();
camera.position.set(7, 7, 9);
camera.lookAt(0, 0, 0);
const parent = new THREE.Group();
parent.rotation.y = 0.4;
scene.add(parent);
const furniture = new THREE.Mesh(
  new THREE.BoxGeometry(2, 1, 1).translate(0, 0.5, 0),
  new THREE.MeshNormalMaterial()
);
parent.add(furniture);
scene.add(new THREE.GridHelper(20, 20));
const canvas = renderer.domElement;
canvas.style.touchAction = 'none';
const pointer = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const hit = new THREE.Vector3();
const offset = new THREE.Vector3();
const desiredWorld = new THREE.Vector3();
let activePointer = null;

function updateRay(event) {
  const rect = canvas.getBoundingClientRect();
  pointer.set(
    (event.clientX - rect.left) / rect.width * 2 - 1,
    1 - (event.clientY - rect.top) / rect.height * 2
  );
  camera.updateWorldMatrix(true, false);
  raycaster.setFromCamera(pointer, camera);
}

function intersectGround() {
  if (Math.abs(raycaster.ray.direction.dot(ground.normal)) < 1e-5) return false;
  return raycaster.ray.intersectPlane(ground, hit) !== null;
}

function down(event) {
  if (activePointer !== null || event.button !== 0) return;
  updateRay(event);
  furniture.updateWorldMatrix(true, false);
  if (!raycaster.intersectObject(furniture).length || !intersectGround()) return;
  furniture.getWorldPosition(offset);
  offset.sub(hit);
  activePointer = event.pointerId;
  canvas.setPointerCapture(activePointer);
}

function move(event) {
  if (event.pointerId !== activePointer) return;
  updateRay(event);
  if (!intersectGround()) return;
  desiredWorld.copy(hit).add(offset);
  desiredWorld.x = THREE.MathUtils.clamp(desiredWorld.x, -8, 8);
  desiredWorld.z = THREE.MathUtils.clamp(desiredWorld.z, -8, 8);
  parent.updateWorldMatrix(true, false);
  furniture.position.copy(parent.worldToLocal(desiredWorld));
}

function end(event) {
  if (event.pointerId !== activePointer) return;
  const id = activePointer;
  activePointer = null;
  if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
}

canvas.addEventListener('pointerdown', down);
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
canvas.addEventListener('lostpointercapture', end);

onDispose(() => {
  canvas.removeEventListener('pointerdown', down);
  canvas.removeEventListener('pointermove', move);
  canvas.removeEventListener('pointerup', end);
  canvas.removeEventListener('pointercancel', end);
  canvas.removeEventListener('lostpointercapture', end);
});

验证与扩展

分别抓住家具中心与边缘,按下时都不应跳动;指针离开 canvas 后松开也应结束拖动。桌面和触屏都使用 Pointer Events。若外壳集成 OrbitControls,开始拖拽时禁用 controls,结束时恢复,避免两种交互争夺同一指针。

范围限制约束的是家具原点,不保证模型边界不越墙。实际编辑器可使用 Box3 计算带尺寸的允许范围。网格吸附可以在世界点上对 x、z 除以格距、四舍五入再乘回来。父节点矩阵必须可逆,零缩放父节点没有可靠的世界到局部转换。

练习(暂不提供答案)

  1. 加入 0.5 单位网格吸附,并决定拖动过程中还是松手时吸附。
  2. 将拖拽平面替换为竖直墙面,实现挂画移动。
  3. 把家具放入带缩放的父节点,检查世界范围约束。
  4. 用家具包围盒限制整个家具,而不只是原点。