A pointer has one degree of freedom too few for 3D, so a drag needs a constraint, usually a plane: on pointerdown, note where the ray meets it; on each pointermove, move the object by the change in that point:
export function enableDrag({ renderer, camera }, meshes, height, x, z) {
const canvas = renderer.domElement, raycaster = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -height);
const start = new THREE.Vector3(), now = new THREE.Vector3(), from = new THREE.Vector3();
const max = new THREE.Vector3(x, Infinity, z), min = max.clone().negate();
let mesh = null;
canvas.addEventListener('pointerdown', (event) => {
mesh = aimRay(raycaster, event, camera).intersectObjects(meshes)[0]?.object ?? null;
if (!raycaster.ray.intersectPlane(plane, start)) mesh = null;
if (!mesh) return;
from.copy(mesh.position);
canvas.setPointerCapture(event.pointerId); // keep moves that leave the canvas
});
canvas.addEventListener('pointermove', (event) => {
if (!mesh || !aimRay(raycaster, event, camera).ray.intersectPlane(plane, now)) return;
mesh.position.copy(from).add(now.sub(start)).clamp(min, max); // grab point stays put
});
canvas.addEventListener('pointerup', () => { mesh = null; });
}This page scripts the pointer with sendPointer(), which dispatches new PointerEvent(type, { pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 1, bubbles: true, clientX, clientY }), to click a cover and drag The Quiet Harbor past the plank's end:
<script src="three-map.js"></script>
<script type="module">
import * as BN from './bookshelf.js';
const stage = BN.createStage({ width: 640, height: 190 });
const shelf = BN.createShelf(await BN.loadCovers());
const books = shelf.children.slice(1); // child 0 is the plank
stage.scene.add(shelf);
const fix = (v) => v.toArray().map((n) => n.toFixed(2)).join(' ');
BN.enablePicking(stage, books, (hit) => console.log(hit.object.name, hit.distance.toFixed(2),
'point', fix(hit.point), 'material', hit.face.materialIndex, 'uv', fix(hit.uv)));
BN.enableDrag(stage, books, -0.49, 1.6, 0.3); // the plank's top, inside its edges
const send = (type, x, y) => BN.sendPointer(stage.renderer.domElement, type, x, y);
stage.renderer.render(stage.scene, stage.camera); // sets the world matrices
send('click', 312, 90);
send('pointerdown', 230, 100);
for (let x = 230; x <= 470; x += 40) send('pointermove', x, 100 + (x - 230) / 6);
send('pointerup', 470, 140);
console.log(books[0].name, 'dropped at', fix(books[0].position));
BN.renderFrames(stage.renderer, stage.scene, stage.camera);
</script>
Salt and Saffron 3.88 point -0.10 -0.03 0.04 material 4 uv 0.91 0.47 The Quiet Harbor dropped at 1.60 0.00 0.30
The click hit the cover (material 4 in createBook()'s order) 91% across its texture; the clamps held the dragged book at the plank's corner. The DragControls add-on offers this pattern with a camera-facing plane (disable OrbitControls while dragging); TransformControls adds axis handles.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; touch-action: none; }
p { margin: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<p id="out">Drag the books along the plank; they stay inside its edges.</p>
<script type="module">
import * as THREE from 'three';
const colors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 280);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 280, 0.1, 50);
camera.position.set(0, 1.6, 3.4);
camera.lookAt(0, -0.3, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.06, 0.8), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
const books = colors.map((color, i) => {
// origin at the book's base, so it sits on the plank's top at y = -0.49
const book = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.98, 0.12).translate(0, 0.49, 0), new THREE.MeshStandardMaterial({ color }));
book.position.set((i - 2.5) * 0.5, -0.49, 0);
scene.add(book);
return book;
});
// The constraint: a plane at the plank's height. The grab point stays under the pointer.
function enableDrag(meshes, height, maxX, maxZ) {
const canvas = renderer.domElement, raycaster = new THREE.Raycaster();
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -height);
const start = new THREE.Vector3(), now = new THREE.Vector3(), from = new THREE.Vector3();
const max = new THREE.Vector3(maxX, Infinity, maxZ), min = max.clone().negate();
let mesh = null;
const aim = (e) => {
const r = canvas.getBoundingClientRect();
raycaster.setFromCamera(new THREE.Vector2((e.clientX - r.left) / r.width * 2 - 1, 1 - (e.clientY - r.top) / r.height * 2), camera);
return raycaster;
};
canvas.addEventListener('pointerdown', (event) => {
mesh = aim(event).intersectObjects(meshes)[0]?.object ?? null;
if (!mesh || !raycaster.ray.intersectPlane(plane, start)) { mesh = null; return; }
from.copy(mesh.position);
canvas.setPointerCapture?.(event.pointerId); // keep moves that leave the canvas
});
canvas.addEventListener('pointermove', (event) => {
if (!mesh || !aim(event).ray.intersectPlane(plane, now)) return;
mesh.position.copy(from).add(now.sub(start)).clamp(min, max);
document.getElementById('out').textContent =
`Dragging to x ${mesh.position.x.toFixed(2)}, z ${mesh.position.z.toFixed(2)}`;
});
canvas.addEventListener('pointerup', () => { mesh = null; });
}
enableDrag(books, -0.49, 1.55, 0.3);
renderer.setAnimationLoop(() => renderer.render(scene, camera));
// Script a drag: pick up the first book and pull it right and forward, past the plank's edge
renderer.render(scene, camera);
const r = renderer.domElement.getBoundingClientRect();
const send = (type, x, y) => renderer.domElement.dispatchEvent(new PointerEvent(type, {
pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 1, bubbles: true, clientX: r.left + x, clientY: r.top + y }));
const grab = books[0].localToWorld(new THREE.Vector3(0, 0.5, 0)).project(camera); // NDC of the book
const gx = (grab.x + 1) / 2 * r.width, gy = (1 - grab.y) / 2 * r.height;
send('pointerdown', gx, gy);
for (let d = 0; d <= 200; d += 20) send('pointermove', gx + d, gy + d / 2);
send('pointerup', gx + 200, gy + 100);
</script>