Info Panel

A Floating Info Panel Above BookNest's Shelves

bookstore.js gains createShelfCards(scene, bookcase, books, onPick): a PlanePanel of six holographic cards linked above a bookcase, each with a book's cover, price and rating, and its title as a tooltip:

bookstore.js: a floating panel of book cards above a bookcaseJavaScript
function createShelfCards(scene, bookcase, books, onPick = () => {}) {   // Section 7.13.4
  const GUI = BABYLON.GUI;                          // the babylonjs-gui script
  const manager = new GUI.GUI3DManager(scene);      // controls live in a utility layer
  const panel = new GUI.PlanePanel('shelfCards');
  manager.addControl(panel);                        // add first, then link
  panel.linkToTransformNode(bookcase);              // position is now bookcase space
  panel.position.set(0, 2.62, -0.1);                // just above the top board
  panel.scaling.setAll(0.36);                       // 1-unit buttons become 36 cm cards
  Object.assign(panel, { columns: 6, margin: 0.08, blockLayout: true });
  const cards = books.map((book, i) => {
    const { title, price, rating } = book.metadata;
    const label = `$${price.toFixed(2)}  \u2605 ${rating}`;  // price and stars
    const card = new GUI.HolographicButton(`card${i + 1}`);
    panel.addControl(card);                         // set content after addControl
    const face = card.content = new GUI.StackPanel();   // 2D controls on the card
    face.addControl(Object.assign(new GUI.Image('cover', `booknest-cover-${i + 1}.png`),
      { width: '140px', height: '210px' }));
    face.addControl(Object.assign(new GUI.TextBlock('label', label),
      { color: 'white', fontSize: 30, height: '40px' }));
    card.tooltipText = title;                       // shown while the pointer hovers
    card.onPointerClickObservable.add(() => onPick(book));
    return card;
  });
  panel.blockLayout = false;                        // lay out all six at once
  return { manager, panel, cards };
}
BookNest's floating cards above the left bookcaseHTML
<canvas id="view" style="width: 620px; height: 140px"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.js"></script>
<script src="bookstore.js"></script>
<script>
{
  const engine = new BABYLON.Engine(view, true, {}, true);
  const scene = BookNestStore.createScene(engine);
  BookNestStore.createRoom(scene);
  const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
  BookNestStore.createShelfCards(scene, left, BookNestStore.placeBooks(scene, left));
  const target = new BABYLON.Vector3(-1.8, 2.6, 2.65);
  new BABYLON.ArcRotateCamera('browse', -Math.PI / 2, 1.6, 0.95, target, scene).minZ = 0.05;
  BookNestStore.renderWhenReady(engine, scene, 10);
}
</script>
Browser output of Listing 7.85
Browser output of 85

A scripted tap on the third card (touch-type DOM pointer events; synthetic mouse events sent POINTERDOWN twice and never clicked) slid Salt and Saffron out through an onPick running Doors and Covers's presentBook.

Six holographic cards with cover, price and rating floating above a bookcase; tap one to present its bookHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 300px; display: block; touch-action: none; outline: none; }
</style>
<canvas id="view"></canvas>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.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 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 createShelfCards(scene, bookcase, books, onPick = () => {}) {
  const GUI = BABYLON.GUI;
  const manager = new GUI.GUI3DManager(scene);      // controls live in a utility layer
  const panel = new GUI.PlanePanel('shelfCards');
  manager.addControl(panel);                        // add first, then link
  panel.linkToTransformNode(bookcase);              // position is now bookcase space
  panel.position.set(0, 2.62, -0.1);                // just above the top board
  panel.scaling.setAll(0.36);                       // 1-unit buttons become 36 cm cards
  Object.assign(panel, { columns: 6, margin: 0.08, blockLayout: true });
  const cards = books.map((book, i) => {
    const { title, price, rating } = book.metadata;
    const label = `$${price.toFixed(2)}  ★ ${rating}`;   // price and stars
    const card = new GUI.HolographicButton(`card${i + 1}`);
    panel.addControl(card);                         // set content after addControl
    const face = card.content = new GUI.StackPanel();   // 2D controls on the card
    const coverUrl = book.material.diffuseTexture.getContext().canvas.toDataURL();   // the book's cover art
    face.addControl(Object.assign(new GUI.Image('cover', coverUrl), { width: '140px', height: '210px' }));
    face.addControl(Object.assign(new GUI.TextBlock('label', label), { color: 'white', fontSize: 30, height: '40px' }));
    card.tooltipText = title;                       // shown while the pointer hovers
    card.onPointerClickObservable.add(() => onPick(book));
    return card;
  });
  panel.blockLayout = false;                        // lay out all six at once
  return { manager, panel, cards };
}

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, 4);            // the top shelf, right under the cards
const groups = new Map();
let shown = null;
createShelfCards(scene, left, books, (book) => {    // present the tapped book, shelve the last one
  if (shown) groups.get(shown).start(false, 1, 40, 0);
  if (!groups.has(book)) groups.set(book, presentBook(scene, book));
  groups.get(book).start(false, 1, 0, 40);
  shown = book;
});
const target = new BABYLON.Vector3(-1.8, 2.45, 2.65);
const camera = new BABYLON.ArcRotateCamera('browse', -Math.PI / 2, 1.55, 1.5, target, scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>