Raycast Selection

Selecting a Book with Raycasting

enablePicking() (Picking and Highlighting) finishes the shelf: hovering brightens a book, and a click lifts it off the shelf, turns its cover out and fills a DOM label (2D and DOM Overlays) from userData.book:

BookNest's bookshelf with a book pulled out by a clickHTML
<script src="three-map.js"></script>
<script type="module">
import * as BN from './bookshelf.js';
const stage = BN.createStage({ width: 640, height: 240 });
const bookcase = BN.createBookcase();
stage.scene.add(bookcase);
BN.lightBookcase(stage);
const label = document.body.appendChild(document.createElement('div'));
label.style.cssText = 'position: absolute; left: 14px; top: 10px; font: 15px Georgia, serif';
const parts = ['pages', 'back board', 'cover', 'spine'];      // the geometry's groups
BN.enablePicking(stage, bookcase.children.slice(1), (hit) => {  // child 0 is the plank
  if (!hit) return;
  const book = hit.object;
  book.position.set(book.position.x, -0.37, book.position.z + 0.65);   // up and out
  book.rotation.y -= 0.5;                                              // cover toward you
  const { title, author, price, rating } = book.userData.book;
  label.textContent = `${title} by ${author}, $${price.toFixed(2)}, rated ${rating}`;
  const uv = hit.uv.toArray().map((n) => n.toFixed(2)).join(' ');
  console.log(title, '- clicked its', parts[hit.face.materialIndex], 'at uv', uv);
});
stage.renderer.render(stage.scene, stage.camera);
BN.sendPointer(stage.renderer.domElement, 'click', 280, 110);
BN.renderFrames(stage.renderer, stage.scene, stage.camera);
</script>
Browser output of Listing 5.39
Browser output of 39
Output of 39
The Clockmaker's Paradox - clicked its spine at uv 0.49 0.63

The click landed halfway around the spine's curve (u 0.49). A shop page would also store each book's place, restore it when another book is chosen, and ease between the two with Tweening's damping.

Hover brightens a book; a click lifts it off the shelf and shows its details in a DOM labelHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; position: relative; }
  canvas { display: block; max-width: 100%; }
  #label { position: absolute; left: 14px; top: 10px; font: 15px Georgia, serif; background: rgb(255 255 255 / 0.8); padding: 2px 6px; }
</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>
<div id="label">Click a book</div>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

const BOOKS = [
  { title: 'The Quiet Harbor', author: 'Elena Marsh', color: '#1f5f8b', rating: 4.6, pages: 312, price: 14.99 },
  { title: 'Patterns of the Deep Web', author: 'Ravi Menon', color: '#5b3f99', rating: 4.3, pages: 428, price: 29.99 },
  { title: 'Salt and Saffron', author: 'Leila Haddad', color: '#e09a10', rating: 4.8, pages: 256, price: 24.50 },
  { title: 'Small Steps to Big Summits', author: 'Tom Okafor', color: '#3f7d3a', rating: 4.1, pages: 198, price: 12.99 },
  { title: "The Clockmaker's Paradox", author: 'Iris Vale', color: '#b5452f', rating: 4.5, pages: 344, price: 18.00 },
  { title: 'Gardens in Glass', author: 'June Park', color: '#2a9d8f', rating: 4.4, pages: 176, price: 21.00 },
];

// --- Book model: pages, two boards and a curved spine, merged with one group per part ---
const thickness = (book) => 0.05 + book.pages * 0.0003;          // metres, from the page count
function bookGeometry(book, height, depth = 0.62) {
  const t = thickness(book), b = 0.012, y = height / 2, z = -depth / 2;
  const box = (w, h, d, x, zz = z) => new THREE.BoxGeometry(w, h, d).translate(x, y, zz);
  const spine = new THREE.CylinderGeometry(t / 2, t / 2, height, 24, 1, true, -Math.PI / 2, Math.PI)
    .scale(1, 1, 0.5).translate(0, y, 0);
  return mergeGeometries([box(t - 2 * b, height - 0.03, depth - 0.02, 0, z + 0.01),
    box(b, height, depth, (b - t) / 2), box(b, height, depth, (t - b) / 2), spine], true);
}
function paint(book, width, height, draw) {
  const canvas = Object.assign(document.createElement('canvas'), { width, height });
  const g = canvas.getContext('2d');
  g.fillStyle = book.color;  g.fillRect(0, 0, width, height);
  g.fillStyle = '#ffffff';
  draw(g);
  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}
const coverArt = (book) => paint(book, 360, 520, (g) => {
  g.font = 'bold 50px Georgia, serif';
  book.title.split(' ').forEach((word, i) => g.fillText(word, 28, 84 + i * 56, 304));
  g.font = 'italic 28px Georgia, serif';
  g.fillText(`${book.author}, ${book.rating} / 5`, 28, 460);
});
const spineArt = (book) => paint(book, 128, 640, (g) => {
  g.rotate(Math.PI / 2);
  g.textBaseline = 'middle';
  g.font = 'bold 44px Georgia, serif';
  g.fillText(book.title, 24, -64, 440);
  g.font = '28px Georgia, serif';
  g.fillText(book.author.split(' ').at(-1), 490, -64, 130);
});
function createSpineBook(book, height = 0.9) {
  const material = (options) => new THREE.MeshStandardMaterial({ roughness: 0.6, ...options });
  const mesh = new THREE.Mesh(bookGeometry(book, height), [material({ color: '#f3ecdc' }),
    material({ color: book.color }), material({ map: coverArt(book) }), material({ map: spineArt(book) })]);
  return Object.assign(mesh, { name: book.title, userData: { book } });
}

// --- The arrangement ---
function createBookcase() {
  const bookcase = new THREE.Group();
  const plank = new THREE.Mesh(new THREE.BoxGeometry(3.5, 0.06, 0.8), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
  plank.name = 'plank';
  plank.position.set(0, -0.52, 0);
  bookcase.add(plank);
  const top = BOOKS.reduce((best, book) => (book.rating > best.rating ? book : best));
  let x = -0.85;
  BOOKS.forEach((book, i) => {
    const mesh = createSpineBook(book, 0.84 + 0.05 * (i % 3));   // three trim heights
    if (book === top) {
      mesh.position.set(0.3, -0.49, 0.05);
      mesh.rotation.y = -Math.PI / 2 + 0.35;                     // cover forward, turned a little
    } else {
      mesh.position.set(x + thickness(book) / 2, -0.49, 0.3);    // running x plus half a thickness
      x += thickness(book) + 0.005;
    }
    bookcase.add(mesh);
  });
  return bookcase;
}

// --- Stage and lighting: sun and hemisphere, RoomEnvironment fill, Neutral tone mapping, shadows ---
function createStage(width, height, renderer = new THREE.WebGLRenderer({ antialias: true })) {
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.setSize(width, height);
  document.body.prepend(renderer.domElement);
  const scene = new THREE.Scene();
  scene.background = new THREE.Color('#eceff1');
  const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 50);
  scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1));
  const sun = new THREE.DirectionalLight('#ffffff', 2.5);
  sun.name = 'sun';
  scene.add(sun);
  return { renderer, scene, camera };
}
function lightBookcase({ renderer, scene, camera }, PMREM = THREE.PMREMGenerator) {
  renderer.toneMapping = THREE.NeutralToneMapping;
  const pmrem = new PMREM(renderer);
  scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
  scene.environmentIntensity = 0.5;
  pmrem.dispose();
  const wall = new THREE.Mesh(new THREE.PlaneGeometry(12, 6), new THREE.MeshStandardMaterial({ color: '#eceff1' }));
  wall.name = 'wall';
  wall.position.set(0, 1, -0.45);
  scene.add(wall);
  renderer.shadowMap.enabled = true;
  const sun = scene.getObjectByName('sun');
  sun.castShadow = true;
  sun.shadow.mapSize.set(1024, 1024);
  sun.shadow.normalBias = 0.02;
  Object.assign(sun.shadow.camera, { left: -2, right: 2, top: 1.5, bottom: -1.5 });
  scene.traverse((o) => { if (o.isMesh) o.castShadow = o.receiveShadow = true; });
  sun.position.set(-1.5, 3, 4);                        // light from the left, shadows fall right
  camera.position.set(0.15, 0.45, 2.75);
  camera.lookAt(-0.1, -0.05, 0);
}

const stage = createStage(600, 280);
const bookcase = createBookcase();
stage.scene.add(bookcase);
lightBookcase(stage);
const books = bookcase.children.slice(1);             // child 0 is the plank
const home = new Map(books.map((b) => [b, { position: b.position.clone(), rotation: b.rotation.y }]));

const canvas = stage.renderer.domElement, raycaster = new THREE.Raycaster();
const pick = (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), stage.camera);
  return raycaster.intersectObjects(books)[0] ?? null;
};
// Hover: raise emissive on every material of the book
let hovered = null;
const glow = (mesh, color) => mesh.material.forEach((m) => m.emissive.set(color));
canvas.addEventListener('pointermove', (e) => {
  const mesh = pick(e)?.object ?? null;
  if (mesh === hovered) return;
  if (hovered) glow(hovered, 0);
  if (mesh) glow(mesh, '#303030');
  hovered = mesh;
  canvas.style.cursor = mesh ? 'pointer' : '';
});
// Click: put the previous book back, then lift this one up and out, cover toward the reader
const parts = ['pages', 'back board', 'cover', 'spine'];      // the geometry's groups
let chosen = null;
canvas.addEventListener('click', (e) => {
  const hit = pick(e);
  if (!hit) return;
  if (chosen) { chosen.position.copy(home.get(chosen).position); chosen.rotation.y = home.get(chosen).rotation; }
  chosen = hit.object;
  chosen.position.y = -0.37;
  chosen.position.z += 0.65;
  chosen.rotation.y -= 0.5;
  const { title, author, price, rating } = chosen.userData.book;
  document.getElementById('label').textContent =
    `${title} by ${author}, $${price.toFixed(2)}, rated ${rating} (clicked its ${parts[hit.face.materialIndex]})`;
});
stage.renderer.setAnimationLoop(() => stage.renderer.render(stage.scene, stage.camera));

// Simulate a click on the third spine from the left
stage.renderer.render(stage.scene, stage.camera);
const target = books[3].localToWorld(new THREE.Vector3(0, 0.45, 0.02)).project(stage.camera);
const r = canvas.getBoundingClientRect();
canvas.dispatchEvent(new MouseEvent('click', { clientX: r.left + (target.x + 1) / 2 * r.width, clientY: r.top + (1 - target.y) / 2 * r.height }));
</script>