Selecting a Book

Clicking a BookNest Shelf to Select a Book

bookstore.js gains enableBookPicking(scene, books, onSelect). Its onPointerObservable handler ignores everything but POINTERTAP (so camera drags select nothing) and checks whether pickInfo.pickedMesh is one of books. It shelves the previous selection (restoring its material and playing its presentBook group backward with start(false, 1, 40, 0)), swaps in The Node Material Editor's rim-lit node material, plays the group forward, and calls onSelect(book.metadata), or onSelect(null) when the tap missed. Both effects are built on a book's first selection and reused; the rim material's view input (Fresnel Rim Light) lets it compile under lightRoom's cascaded shadows.

The figure taps the fifth spine and prints the callback's record:

The Clockmaker's Paradox, selected by a simulated clickHTML
<canvas id="view" style="width: 620px; height: 150px"></canvas>
<p id="log" style="font: 15px sans-serif; text-align: center; margin: 6px"></p>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script>
(async () => {
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  BookNestStore.createRoom(scene);
  const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
  const books = BookNestStore.placeBooks(scene, left);
  BookNestStore.coverBooks(scene, books);
  BookNestStore.lightRoom(scene);
  const target = new BABYLON.Vector3(-1.8, 1.3, 2.6);
  const camera = new BABYLON.ArcRotateCamera('browse', -1.75, 1.45, 1.3, target, scene);
  camera.minZ = 0.05;
  BookNestStore.enableBookPicking(scene, books, (book) => {
    log.textContent = `Selected: ${book?.title} by ${book?.author}, $${book?.price.toFixed(2)}`;
    setTimeout(() => { window.__done = true; }, 1000);   // after the 40-frame presentation
  });
  await scene.whenReadyAsync();
  engine.runRenderLoop(() => scene.render());
  scene.render();                                  // the camera's position is now current
  const aim = books[4].getBoundingInfo().boundingBox.centerWorld.subtract(camera.position);
  const hit = scene.pickWithRay(new BABYLON.Ray(camera.position, aim.normalize()));
  scene.simulatePointerDown(hit);
  scene.simulatePointerUp(hit);                    // a tap on book5
})();
</script>
Browser output of Listing 7.76
Browser output of 76
Tapping a spine selects the book: it slides out with a rim-light material and its record appearsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 280px; display: block; touch-action: none; outline: none; }
  #log { max-width: 600px; margin: 6px; font: 15px sans-serif; text-align: center; }
</style>
<canvas id="view"></canvas>
<p id="log">Tap a spine to select it.</p>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
// ---- BookNest's bookstore: the helpers the book keeps in bookstore.js ----
const ROOM = { width: 8, depth: 6, height: 3, wall: 0.15 };
const BOOKS = [
  { id: 1, title: 'The Quiet Harbor', author: 'Mara Ellison', color: '#1f5f8b', pages: 312, price: 14.99, rating: 4.6, inStock: true },
  { id: 2, title: 'Patterns of the Deep Web', author: 'Tomas Reyes', color: '#5b3f99', pages: 428, price: 32.5, rating: 4.4, inStock: true },
  { id: 3, title: 'Salt and Saffron', author: 'Priya Nair', color: '#e09a10', pages: 256, price: 24, rating: 4.8, inStock: false },
  { id: 4, title: 'Small Steps to Big Summits', author: 'Dana Whitfield', color: '#3f7d3a', pages: 198, price: 18.5, rating: 4.3, inStock: true },
  { id: 5, title: "The Clockmaker's Paradox", author: 'Julian Voss', color: '#b5452f', pages: 344, price: 16.75, rating: 4.7, inStock: true },
  { id: 6, title: 'Gardens in Glass', author: 'Lena Hart', color: '#2a9d8f', pages: 176, price: 21.3, rating: 4.5, inStock: true },
];
function createScene(engine) {
  const scene = new BABYLON.Scene(engine);
  scene.clearColor = BABYLON.Color4.FromHexString('#cfd8dcff');
  const sky = new BABYLON.HemisphericLight('sky', new BABYLON.Vector3(0.3, 1, -0.4), scene);
  sky.groundColor = new BABYLON.Color3(0.35, 0.32, 0.3);
  return scene;
}
function paint(scene, name, hex) {                  // a matte color
  const material = new BABYLON.StandardMaterial(name, scene);
  material.diffuseColor = BABYLON.Color3.FromHexString(hex);
  material.specularColor = BABYLON.Color3.Black();
  return material;
}
function createRoom(scene) {                        // floor and three walls, front open
  const room = new BABYLON.TransformNode('room', scene);
  const floor = BABYLON.MeshBuilder.CreateGround('floor', { width: ROOM.width, height: ROOM.depth }, scene);
  floor.material = paint(scene, 'oak', '#b08d6a');
  floor.parent = room;
  const plaster = paint(scene, 'plaster', '#fff3e0');
  for (const [name, width, x, z] of [['backWall', 8, 0, 3], ['leftWall', 6, -4, 0], ['rightWall', 6, 4, 0]]) {
    const wall = BABYLON.MeshBuilder.CreateBox(name, { width, height: ROOM.height, depth: ROOM.wall }, scene);
    wall.position.set(x, ROOM.height / 2, z);
    wall.rotation.y = x ? Math.PI / 2 : 0;
    wall.material = plaster;
    wall.parent = room;
  }
  return room;
}
function createBookcase(scene, name, x) {           // 8 boxes merged into 1 mesh
  const [w, h, d, t] = [3, 2.2, 0.35, 0.04];
  const parts = [[t, h, d, -w / 2, h / 2, 0], [t, h, d, w / 2, h / 2, 0], [w, h, t, 0, h / 2, d / 2 - t / 2]];
  for (let i = 0; i < 5; i++) parts.push([w, t, d, 0, 0.1 + i * 0.5, 0]);
  const bookcase = BABYLON.Mesh.MergeMeshes(parts.map(([width, height, depth, px, py, pz]) => {
    const part = BABYLON.MeshBuilder.CreateBox('part', { width, height, depth }, scene);
    part.position.set(px, py, pz);
    return part;
  }), true);
  bookcase.name = name;
  bookcase.material = paint(scene, 'walnut', '#6d4c33');
  bookcase.position.set(x, 0, ROOM.depth / 2 - ROOM.wall / 2 - d / 2);   // against the back wall
  return bookcase;
}
function drawCover(scene, book) {                   // cover art painted on a canvas
  const tex = new BABYLON.DynamicTexture('cover' + book.id, { width: 256, height: 384 }, scene);
  const c = tex.getContext();
  c.fillStyle = book.color; c.fillRect(0, 0, 256, 384);
  c.fillStyle = 'rgba(0,0,0,0.4)'; c.fillRect(0, 0, 18, 384);         // the dark left edge
  c.strokeStyle = '#fff8e1'; c.lineWidth = 3; c.strokeRect(34, 24, 202, 336);
  c.fillStyle = '#fff8e1'; c.textAlign = 'center'; c.font = 'bold 30px Georgia';
  const lines = [''];
  for (const word of book.title.split(' ')) {
    const test = (lines[lines.length - 1] + ' ' + word).trim();
    if (c.measureText(test).width > 180) lines.push(word); else lines[lines.length - 1] = test;
  }
  lines.forEach((line, i) => c.fillText(line, 135, 110 + i * 38));
  c.font = '20px Georgia'; c.fillText(book.author, 135, 330);
  tex.update();
  return tex;
}
function placeBooks(scene, bookcase, shelf = 2) {   // six books, spines out, covers on +x
  const strip = new BABYLON.Vector4(0, 0, 0.07, 1);
  const faceUV = [strip, strip, new BABYLON.Vector4(0, 0, 1, 1), strip, strip, strip];
  let x = -0.35;                                    // left end of the row, local to the bookcase
  return BOOKS.map((record) => {
    const width = record.pages / 4000 + 0.02;       // spine thickness from page count
    const book = BABYLON.MeshBuilder.CreateBox(`book${record.id}`, { width, height: 0.3, depth: 0.22, faceUV, wrap: true }, scene);
    book.parent = bookcase;
    book.position.set(x + width / 2, 0.12 + shelf * 0.5 + 0.15, -0.02);
    x += width + 0.012;
    book.material = new BABYLON.StandardMaterial(record.title, scene);
    book.material.diffuseTexture = drawCover(scene, record);
    book.material.specularColor = new BABYLON.Color3(0.15, 0.15, 0.15);
    book.metadata = record;                         // the record, for picking
    return book;
  });
}
// The book cuts the storefront with CSG2 (Manifold WebAssembly); this sandbox cannot download
// the .wasm, so the older pure-JavaScript BABYLON.CSG makes the same cuts here.
function createFrontWall(scene) {
  const box = (width, height, depth, x, y) => {
    const mesh = BABYLON.MeshBuilder.CreateBox('cut', { width, height, depth }, scene);
    mesh.position.set(x, y, -ROOM.depth / 2);
    return mesh;
  };
  const solid = box(ROOM.width, ROOM.height, ROOM.wall, 0, ROOM.height / 2);
  const windowHole = box(3.2, 1.6, 1, -1.4, 1.6), doorHole = box(1.1, 2.2, 1, 2.3, 1.1);
  const wall = BABYLON.CSG.FromMesh(solid).subtract(BABYLON.CSG.FromMesh(windowHole))
    .subtract(BABYLON.CSG.FromMesh(doorHole)).toMesh('frontWall', scene.getMaterialByName('plaster'), scene);
  [solid, windowHole, doorHole].forEach((mesh) => mesh.dispose());
  return wall;
}
// ---- end of the bookstore helpers ----

function lightRoom(scene) {                         // call after the meshes exist
  scene.getLightByName('sky').intensity = 0.5;      // the sky light becomes fill light
  const sun = new BABYLON.DirectionalLight('sun', new BABYLON.Vector3(0.2, -0.33, 1), scene);
  sun.intensity = 0.8;
  sun.diffuse = BABYLON.Color3.FromHexString('#fff1d6');   // warm afternoon sun
  const ceiling = BABYLON.MeshBuilder.CreateBox('ceiling', { width: ROOM.width, height: ROOM.wall, depth: ROOM.depth }, scene);
  ceiling.position.y = ROOM.height + ROOM.wall / 2;
  ceiling.layerMask = 0x10000000;                   // no camera draws it; it still casts
  const shadows = new BABYLON.CascadedShadowGenerator(2048, sun);
  Object.assign(shadows, { numCascades: 3, shadowMaxZ: 20, lambda: 1,
    autoCalcDepthBounds: true, bias: 0.002, normalBias: 0.01 });
  for (const mesh of scene.meshes) {
    if (mesh.name !== 'floor') shadows.addShadowCaster(mesh);
    mesh.receiveShadows = true;
  }
  const lamp = new BABYLON.PointLight('lamp', new BABYLON.Vector3(-1.2, 2.3, 1.4), scene);
  lamp.diffuse = BABYLON.Color3.FromHexString('#ffd9a0');
  Object.assign(lamp, { intensity: 0.7, range: 5 });   // a warm reading lamp
  return { sun, lamp, shadows };
}

function createSpineHighlight(scene, texture, rimHex = '#ffe082') {
  const material = new BABYLON.NodeMaterial('spineHighlight', scene, { emitComments: true });
  const input = (name, value) => {                  // attribute, system value or uniform
    const node = new BABYLON.InputBlock(name);
    if (value === 'attribute') node.setAsAttribute(name);
    else if (typeof value !== 'string') node.value = value;
    else node.setAsSystemValue(BABYLON.NodeMaterialSystemValues[value]);
    return node.output;
  };
  const block = (type, name, inputs, props = {}) => {   // a block with its inputs wired
    const node = Object.assign(new BABYLON[type](name), props);
    for (const [port, source] of Object.entries(inputs)) source.connectTo(node[port]);
    return node;
  };
  const world = input('world', 'World');
  const eye = input('cameraPosition', 'CameraPosition');
  const worldPos = block('TransformBlock', 'worldPos',
    { vector: input('position', 'attribute'), transform: world }).output;
  const clipPos = block('TransformBlock', 'clipPos',
    { vector: worldPos, transform: input('viewProjection', 'ViewProjection') });
  const vertexOut = block('VertexOutputBlock', 'vertexOut', { vector: clipPos.output });
  const normal = block('TransformBlock', 'worldNormal', { vector: input('normal', 'attribute'),
    transform: world }, { complementW: 0 }).output;       // a direction: no translation
  const cover = block('TextureBlock', 'cover', { uv: input('uv', 'attribute') }, { texture });
  const light = block('LightBlock', 'light', { worldPosition: worldPos, worldNormal: normal,
    cameraPosition: eye, diffuseColor: cover.rgb, view: input('view', 'View') });   // the scene's lights
  const toEye = block('ViewDirectionBlock', 'toEye', { worldPosition: worldPos, cameraPosition: eye });
  const fresnel = block('FresnelBlock', 'fresnel', { worldNormal: normal,
    viewDirection: toEye.output, bias: input('bias', 0), power: input('power', 1) });
  const edge = block('OneMinusBlock', 'edge', { input: fresnel.fresnel });  // 1 at the rim
  const amount = block('MultiplyBlock', 'amount', { left: edge.output, right: input('strength', 1) });
  const rim = block('ScaleBlock', 'rim', { factor: amount.output,
    input: input('rimColor', BABYLON.Color3.FromHexString(rimHex)) });
  const sum = block('AddBlock', 'sum', { left: light.diffuseOutput, right: rim.output });
  const fragmentOut = block('FragmentOutputBlock', 'fragmentOut', { rgb: sum.output });
  [vertexOut, fragmentOut].forEach((node) => material.addOutputNode(node));
  material.build();
  return material;
}

function presentBook(scene, book) {                 // slide out, lift, turn the cover to the aisle
  const group = new BABYLON.AnimationGroup('present' + book.name, scene);
  const track = (property, type, keys) => {
    const animation = new BABYLON.Animation(property, property, 60, type);
    animation.setKeys(keys.map(([frame, value]) => ({ frame, value })));
    const ease = new BABYLON.CubicEase();
    ease.setEasingMode(BABYLON.EasingFunction.EASINGMODE_EASEINOUT);
    animation.setEasingFunction(ease);
    group.addTargetedAnimation(animation, book);
  };
  const { z, y } = book.position;
  const FLOAT = BABYLON.Animation.ANIMATIONTYPE_FLOAT;
  track('position.z', FLOAT, [[0, z], [20, z - 0.3], [40, z - 0.3]]);        // 0.3 m out in 20 frames
  track('position.y', FLOAT, [[0, y], [20, y], [40, y + 0.12]]);             // then lift it
  track('rotation.y', FLOAT, [[0, 0], [20, 0], [40, Math.PI / 2]]);          // and face the +x cover out
  return group;
}

function enableBookPicking(scene, books, onSelect) {
  let selected = null;
  const effects = new Map();                         // built on a book's first selection
  const shelve = (book) => {
    const { cover, group } = effects.get(book);
    book.material = cover;
    group.start(false, 1, 40, 0);                    // play the presentation backward
  };
  scene.onPointerObservable.add(({ type, pickInfo }) => {
    if (type !== BABYLON.PointerEventTypes.POINTERTAP) return;   // drags select nothing
    const book = books.includes(pickInfo?.pickedMesh) ? pickInfo.pickedMesh : null;
    if (book === selected) return;
    if (selected) shelve(selected);
    selected = book;
    if (book) {
      if (!effects.has(book)) effects.set(book, { cover: book.material, group: presentBook(scene, book),
        rim: createSpineHighlight(scene, book.material.diffuseTexture) });
      const { rim, group } = effects.get(book);
      book.material = rim;
      group.start(false, 1, 0, 40);
    }
    onSelect(book ? book.metadata : null);
  });
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
const scene = createScene(engine);
createRoom(scene);
const left = createBookcase(scene, 'bookcaseLeft', -1.8);
const books = placeBooks(scene, left);
lightRoom(scene);
const target = new BABYLON.Vector3(-1.95, 1.35, 2.6);
const camera = new BABYLON.ArcRotateCamera('browse', -1.75, 1.45, 1.5, target, scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);
const log = document.getElementById('log');
enableBookPicking(scene, books, (book) => {
  log.textContent = book ? `Selected: ${book.title} by ${book.author}, $${book.price.toFixed(2)}` : 'Nothing selected';
});
engine.runRenderLoop(() => scene.render());
scene.executeWhenReady(() => {                       // tap the fifth spine once for the preview
  scene.render();
  const aim = books[4].getBoundingInfo().boundingBox.centerWorld.subtract(camera.position);
  const hit = scene.pickWithRay(new BABYLON.Ray(camera.position, aim.normalize()));
  scene.simulatePointerDown(hit);
  scene.simulatePointerUp(hit);
});
addEventListener('resize', () => engine.resize());
</script>