Details Panel

Connecting Picking to the Book Details Panel

enableBookPicking (Selecting a Book) now also returns its select(book) function, so a tap on one of Info Panel's cards does what a tap on a spine does: highlight the book, slide it out and fill the panel of BookNest's Book Details Panel:

bookstore.js: openStore continued, the panel, picking and the cardsJavaScript
  const details = await createDetailsPanel(scene);  // 7.21.4: a tap opens the panel
  details.ui.layer.applyPostProcess = false;        // draw the GUI after the color grade
  const { select } = enableBookPicking(scene, books, details.show);
  const cards = createShelfCards(scene, cases[0], books, select);   // so does a card

The second line fixes a problem that appears only in the assembled store. A fullscreen AdvancedDynamicTexture is drawn by a Layer, and layers draw before post-processing by default, so Final Polish's tone mapping and vignette grayed the panel. With applyPostProcess = false it draws after the pipeline, in its own colors.

Spines and floating cards both lead to one select(book): the book slides out, rim-lit, and the details panel opensHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
  canvas { width: 100%; max-width: 600px; height: 330px; 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 createWalkCamera(scene, canvas) {         // walk at eye height
  const start = new BABYLON.Vector3(0, 1.6, -2.2);  // eye height, near the open front
  const camera = new BABYLON.UniversalCamera('walk', start, scene);
  camera.setTarget(new BABYLON.Vector3(0, 1.4, 3));
  Object.assign(camera, {
    minZ: 0.05,                                     // the default 1 m clips nearby shelves
    speed: 0.05, angularSensibility: 4000,          // about 1.6 m/s, slower mouse look
    ellipsoid: new BABYLON.Vector3(0.3, 0.8, 0.3),  // a visitor 1.6 m tall, 0.6 m wide
    checkCollisions: true, applyGravity: true,
    needMoveForGravity: true,                       // fall even while no key is held
  });
  camera.keysUp.push(87); camera.keysDown.push(83);     // W and S beside the arrows
  camera.keysLeft.push(65); camera.keysRight.push(68);  // A and D
  scene.gravity = new BABYLON.Vector3(0, -9.81 / 60, 0);  // units per frame, not m/s^2
  scene.collisionsEnabled = true;
  const size = { width: ROOM.width, height: ROOM.height, depth: ROOM.wall };
  const glass = BABYLON.MeshBuilder.CreateBox('frontCollider', size, scene);
  glass.position.set(0, ROOM.height / 2, -ROOM.depth / 2);
  glass.isVisible = false;                          // the open front still stops you
  for (const name of ['floor', 'backWall', 'leftWall', 'rightWall', 'frontCollider']) {
    scene.getMeshByName(name).checkCollisions = true;
  }
  if (canvas) camera.attachControl(canvas, true);
  scene.activeCamera = camera;
  return camera;
}

// ---- A tiny stand-in for Havok ----
// Havok's WebAssembly module has to be downloaded, which this sandboxed preview cannot do, so
// these pages simulate with a few lines of their own: axis-aligned boxes that fall, bounce,
// slide with friction, stack and sleep (no rotation), stepped at a fixed 1/60 s like
// new BABYLON.HavokPlugin(false, havok). Comments name the Havok call each part stands for.
class MiniWorld {
  constructor(gravity = -9.81) {
    this.gravity = gravity;
    this.bodies = [];
    this.contacts = new Set();                       // pairs touching in the last step
    this.onCollision = null;                         // ({ a, b, impulse }) on first contact
    this.onTrigger = null;                           // ({ trigger, body }) when a body enters
  }
  // Like new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass, restitution, friction })
  addBox(mesh, { mass = 0, restitution = 0.2, friction = 0.6, isTrigger = false, half = null, center = null } = {}) {
    mesh.computeWorldMatrix(true);
    const box = mesh.getBoundingInfo().boundingBox;
    const body = { mesh, mass, restitution, friction, isTrigger, sleeping: false, still: 0,
      half: half ?? box.extendSizeWorld.clone(), center: (center ?? box.centerWorld).clone(),
      velocity: BABYLON.Vector3.Zero() };
    body.offset = mesh.getAbsolutePosition().subtract(body.center);
    this.bodies.push(body);
    return body;
  }
  // A static collider with no mesh of its own, like one child shape of a PhysicsShapeContainer
  addStaticBox(center, half) {
    const body = { mesh: { uniqueId: `static${this.bodies.length}`, parent: true }, mass: 0, restitution: 0.2,
      friction: 0.6, isTrigger: false, sleeping: false, half, center, velocity: BABYLON.Vector3.Zero() };
    this.bodies.push(body);
    return body;
  }
  step(dt = 1 / 60) {
    for (const b of this.bodies) {                   // integrate: gravity, then velocity
      if (!b.mass || b.sleeping) continue;
      b.velocity.y += this.gravity * dt;
      b.center.addInPlace(b.velocity.scale(dt));
    }
    const touching = new Set();
    for (let iteration = 0; iteration < 6; iteration++) {   // relax the contacts a few times
      for (let i = 0; i < this.bodies.length; i++) for (let j = i + 1; j < this.bodies.length; j++) {
        const pair = this.resolve(this.bodies[i], this.bodies[j], iteration === 0);
        if (pair) touching.add(pair);
      }
    }
    this.contacts = touching;
    for (const b of this.bodies) {                   // sleep bodies that have come to rest
      if (!b.mass) continue;
      b.still = b.velocity.length() < 0.05 ? b.still + 1 : 0;
      if (b.still > 20) { b.sleeping = true; b.velocity.setAll(0); }
      if (!b.mesh.parent) b.mesh.position.copyFrom(b.center.add(b.offset));
    }
  }
  resolve(a, b, firstPass) {
    if ((!a.mass || a.sleeping) && (!b.mass || b.sleeping)) return null;
    const d = a.center.subtract(b.center);
    const ox = a.half.x + b.half.x - Math.abs(d.x), oy = a.half.y + b.half.y - Math.abs(d.y),
      oz = a.half.z + b.half.z - Math.abs(d.z);
    if (ox <= 0 || oy <= 0 || oz <= 0) return null;  // no overlap on some axis: no contact
    const key = a.mesh.uniqueId + ':' + b.mesh.uniqueId;
    if (a.isTrigger || b.isTrigger) {                // triggers report, never push
      if (firstPass && !this.contacts.has(key) && this.onTrigger) {
        this.onTrigger(a.isTrigger ? { trigger: a, body: b } : { trigger: b, body: a });
      }
      return key;
    }
    const n = new BABYLON.Vector3();                 // push apart along the shallowest axis
    const depth = Math.min(ox, oy, oz);
    if (depth === oy) n.y = Math.sign(d.y) || 1; else if (depth === ox) n.x = Math.sign(d.x) || 1; else n.z = Math.sign(d.z) || 1;
    const ia = a.mass && !a.sleeping ? 1 / a.mass : 0, ib = b.mass && !b.sleeping ? 1 / b.mass : 0;
    a.center.addInPlace(n.scale(depth * ia / (ia + ib)));
    b.center.subtractInPlace(n.scale(depth * ib / (ia + ib)));
    const vn = BABYLON.Vector3.Dot(a.velocity.subtract(b.velocity), n);
    if (vn < 0) {                                    // approaching: bounce with restitution
      const e = vn < -0.5 ? Math.min(a.restitution, b.restitution) : 0;
      const impulse = -(1 + e) * vn / (ia + ib);
      a.velocity.addInPlace(n.scale(impulse * ia));
      b.velocity.subtractInPlace(n.scale(impulse * ib));
      const mu = Math.sqrt(a.friction * b.friction); // friction slows the sliding part
      for (const body of [a, b]) {
        const tangent = body.velocity.subtract(n.scale(BABYLON.Vector3.Dot(body.velocity, n)));
        body.velocity.subtractInPlace(tangent.scale(Math.min(mu * 0.5, 1)));
      }
      for (const body of [a, b]) {                   // a hard hit wakes a sleeping body
        if (impulse > 0.5 && body.mass && body.sleeping) { body.sleeping = false; body.still = 0; }
      }
      if (firstPass && !this.contacts.has(key) && this.onCollision) this.onCollision({ a, b, impulse });
    }
    return key;
  }
  // Like physicsEngine.raycastToRef(from, to, result): the nearest box a segment enters
  raycast(from, to) {
    const ray = BABYLON.Ray.CreateNewFromTo(from, to);
    let best = null;
    for (const body of this.bodies) {
      if (body.isTrigger) continue;
      const min = body.center.subtract(body.half), max = body.center.add(body.half);
      if (!ray.intersectsBoxMinMax(min, max)) continue;
      let distance = 0;                              // slab test: where the ray enters the box
      for (const axis of ['x', 'y', 'z']) {
        const inv = 1 / (ray.direction[axis] || 1e-9);
        const t1 = (min[axis] - ray.origin[axis]) * inv, t2 = (max[axis] - ray.origin[axis]) * inv;
        distance = Math.max(distance, Math.min(t1, t2));
      }
      if (distance <= ray.length && (!best || distance < best.distance)) {
        best = { body, distance, point: from.add(ray.direction.scale(distance)) };
      }
    }
    return best;
  }
}
// ---- end of the stand-in ----

function createDisplayTable(scene, world, x, z) {   // a top and four legs, one render mesh
  const parts = [[1.2, 0.05, 0.8, 0, 0.725, 0]];    // width, height, depth, offset
  for (const [lx, lz] of [[-0.55, -0.35], [0.55, -0.35], [-0.55, 0.35], [0.55, 0.35]]) parts.push([0.06, 0.7, 0.06, lx, 0.35, lz]);
  const meshes = parts.map(([w, h, d, px, py, pz]) => {
    const part = BABYLON.MeshBuilder.CreateBox('part', { width: w, height: h, depth: d }, scene);
    part.position.set(x + px, py, z + pz);
    return part;
  });
  // one static collider per part, like the child shapes of a PhysicsShapeContainer
  const colliders = meshes.map((part) => world.addBox(part, { mass: 0 }));
  const table = BABYLON.Mesh.MergeMeshes(meshes, true);
  table.name = 'displayTable';
  table.material = paint(scene, 'tableWood', '#8d6e4f');
  colliders.forEach((body) => { body.mesh = table; });   // the merged mesh stands for them all
  return { table, colliders };
}
function dropBooks(scene, world, shelved, x, z) {   // copies of the shelved books, tipped in
  return shelved.map((original, i) => {
    const book = original.clone(original.name + 'Copy');   // keeps metadata and material
    book.parent = null;
    book.position.set(x - 0.25 + (i % 3) * 0.22, 1 + i * 0.2, z - 0.1 + (i % 2) * 0.2);
    if (i % 3 !== 1) book.rotation.z = Math.PI / 2;  // most land flat, some stand up
    if (i % 2) book.rotation.y = Math.PI / 2;
    book.computeWorldMatrix(true);
    const pages = book.metadata.pages;
    world.addBox(book, { mass: pages * 0.002, friction: 0.6, restitution: 0.1 });   // 2 g a page
    return book;
  });
}

function fillShelves(scene, bookcase, shelves = [0, 1, 3]) {
  const stock = BABYLON.MeshBuilder.CreateBox(`${bookcase.name}Stock`, { depth: 0.2 }, scene);
  Object.assign(stock, { parent: bookcase, material: paint(scene, 'stock', '#ffffff') });
  let seed = 7;                                      // a repeatable pseudo-random sequence
  const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
  const matrices = [], colors = [];
  for (const shelf of shelves) {
    for (let x = -1.44; x < 1.36;) {                 // left to right along the board
      const width = 0.025 + 0.045 * random(), height = 0.22 + 0.1 * random();
      const at = new BABYLON.Vector3(x + width / 2, 0.12 + shelf * 0.5 + height / 2, -0.02);
      matrices.push(...BABYLON.Matrix.Compose(new BABYLON.Vector3(width, height, 1),
        BABYLON.Quaternion.Identity(), at).m);       // scale the unit box, then place it
      const color = BABYLON.Color3.FromHexString(BOOKS[Math.floor(random() * 6)].color);
      colors.push(...color.scale(0.6 + 0.5 * random()).asArray(), 1);   // palette, shaded
      x += width + 0.004;
    }
  }
  stock.thinInstanceSetBuffer('matrix', new Float32Array(matrices), 16);   // one draw call
  stock.thinInstanceSetBuffer('color', new Float32Array(colors), 4);       // per-book tint
  stock.freezeWorldMatrix();                         // the stock never moves
  return stock;
}

class SoldOutPlugin extends BABYLON.MaterialPluginBase {
  constructor(material) { super(material, 'SoldOut', 200, { SOLD_OUT: false }); this._enable(true); }
  prepareDefines(defines) { defines.SOLD_OUT = true; }
  getClassName() { return 'SoldOutPlugin'; }
  isCompatible() { return true; }
  getCustomCode(shaderType, language) {
    if (shaderType !== 'fragment') return null;
    const code = language === BABYLON.ShaderLanguage.WGSL
      ? `{ let luma = dot(fragmentOutputs.color.rgb, vec3f(0.299, 0.587, 0.114));
          fragmentOutputs.color = vec4f(mix(vec3f(luma), vec3f(0.93), 0.35), fragmentOutputs.color.a); }`
      : `{ float luma = dot(gl_FragColor.rgb, vec3(0.299, 0.587, 0.114));
          gl_FragColor.rgb = mix(vec3(luma), vec3(0.93), 0.35); }`;
    return { CUSTOM_FRAGMENT_MAIN_END: `#ifdef SOLD_OUT\n${code}\n#endif` };
  }
}

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) {   // returns select(book) for other callers
  let selected = null;
  const effects = new Map();                         // built on a book's first selection
  function select(book) {
    if (book === selected) return;
    if (selected) {                                  // shelve the previous one
      const { cover, group } = effects.get(selected);
      selected.material = cover;
      group.start(false, 1, 40, 0);
    }
    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);
  }
  scene.onPointerObservable.add(({ type, pickInfo }) => {
    if (type !== BABYLON.PointerEventTypes.POINTERTAP) return;   // drags select nothing
    select(books.includes(pickInfo?.pickedMesh) ? pickInfo.pickedMesh : null);
  });
  return { select };
}

// The catalog fields BOOKS lacks (the book fetches booknest-data.json; here it is inline)
const CATALOG = {
  1: { year: 2021, summary: 'A lighthouse keeper and a stranded sailor wait out a winter storm.' },
  2: { year: 2024, summary: 'How the hidden layers of the web are built, searched and defended.' },
  3: { year: 2019, summary: 'Spice-market recipes and the stories of the cooks behind them.' },
  4: { year: 2022, summary: 'Habits, one small step at a time, from a mountain guide.' },
  5: { year: 2020, summary: 'A watchmaker finds a clock that runs a minute into tomorrow.' },
  6: { year: 2023, summary: 'Terrariums, glasshouses and the gardeners who tend them.' },
};
function createDetailsPanel(scene) {
  const GUI = BABYLON.GUI;
  const ui = GUI.AdvancedDynamicTexture.CreateFullscreenUI('details', true, scene);
  ui.idealWidth = 600;                               // size controls in CSS pixels
  const panel = Object.assign(new GUI.Rectangle('panel'), { width: '300px', height: '180px', left: '140px',
    background: 'rgba(255,255,255,0.95)', cornerRadius: 10, color: '#c9bfae', isPointerBlocker: true, isVisible: false });
  ui.addControl(panel);
  const grid = new GUI.Grid('grid');
  grid.addColumnDefinition(96, true);                // the cover column
  grid.addColumnDefinition(1);
  grid.paddingLeft = grid.paddingTop = '10px';
  panel.addControl(grid);
  const cover = Object.assign(new GUI.Image('cover'), { width: '84px', height: '126px', verticalAlignment: 0 });
  grid.addControl(cover, 0, 0);
  const stack = Object.assign(new GUI.StackPanel('text'), { paddingLeft: '6px', paddingRight: '8px' });
  grid.addControl(stack, 0, 1);
  const text = (size, weight = 'normal', height = size + 8) => {
    const block = Object.assign(new GUI.TextBlock(), { fontSize: size, fontWeight: weight, height: `${height}px`,
      color: '#333', textWrapping: true, textHorizontalAlignment: 0 });
    stack.addControl(block);
    return block;
  };
  const title = text(15, 'bold', 38), byline = text(12), summary = text(11, 'normal', 44);
  const buy = GUI.Button.CreateSimpleButton('buy', 'Add to cart');
  Object.assign(buy, { height: '32px', cornerRadius: 6, color: 'white', paddingTop: '4px' });
  stack.addControl(buy);
  return {
    layer: ui.layer,                                 // the Layer that draws this fullscreen GUI
    show(record) {                                   // null hides the panel
      panel.isVisible = !!record;
      if (!record) return;
      const extra = CATALOG[record.id];
      cover.source = drawCover(scene, record).getContext().canvas.toDataURL();
      title.text = record.title;
      byline.text = `${record.author}, ${extra.year} · $${record.price.toFixed(2)}`;
      summary.text = extra.summary;
      buy.isEnabled = record.inStock;                // out of stock: gray and disabled
      buy.background = record.inStock ? '#1f5f8b' : '#9e9e9e';
      buy.textBlock.text = record.inStock ? 'Add to cart' : 'Out of stock';
    },
  };
}

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 };
}

// ---- openStore: the whole bookstore, assembled step by step (stage limits how far it goes) ----
async function openStore(engine, canvas, stage = 7) {
  const scene = createScene(engine);
  createRoom(scene);                                // 1: room, walls, sidewalk, cases
  const camera = createWalkCamera(scene, canvas);   // before the wall replaces its collider
  const wall = createFrontWall(scene);
  wall.checkCollisions = true;
  scene.getMeshByName('frontCollider')?.dispose();
  const sidewalk = BABYLON.MeshBuilder.CreateGround('sidewalk', { width: ROOM.width, height: 3 }, scene);
  Object.assign(sidewalk, { material: paint(scene, 'paving', '#9e9e9e'), checkCollisions: true });
  sidewalk.position.z = -ROOM.depth / 2 - 1.5;      // 3 m of pavement outside the front
  const cases = [['bookcaseLeft', -1.8], ['bookcaseRight', 1.8]].map(([name, x]) => createBookcase(scene, name, x));
  cases.forEach((bookcase) => { bookcase.checkCollisions = true; });
  const store = { scene, camera, cases };
  if (stage < 2) return store;

  const books = placeBooks(scene, cases[0]);        // 2: spine widths from page counts
  books.forEach((book) => book.metadata.inStock || new SoldOutPlugin(book.material));   // the catalog's flags
  fillShelves(scene, cases[0]);                     // stock around the six
  fillShelves(scene, cases[1], [0, 1, 2, 3]);
  const world = new MiniWorld();                    // new arrivals tipped onto a table in the window
  world.addStaticBox(new BABYLON.Vector3(0, -0.05, 0), new BABYLON.Vector3(4, 0.05, 3));
  const { table } = createDisplayTable(scene, world, -1.4, -2.3);
  table.checkCollisions = true;
  dropBooks(scene, world, books, -1.4, -2.3);
  let steps = 0;
  scene.onBeforeRenderObservable.add(() => { if (steps++ < 150) world.step(1 / 60); });   // then it sleeps
  Object.assign(store, { books, world });
  camera.position.set(2.3, 1.6, -5);                // 3: on the sidewalk, facing the door
  camera.setTarget(new BABYLON.Vector3(2.3, 1.6, 0));
  if (stage < 4) return store;

  const details = createDetailsPanel(scene);        // 4: a tap opens the panel
  const { select } = enableBookPicking(scene, books, details.show);
  const cards = createShelfCards(scene, cases[0], books, select);   // so does a card
  Object.assign(store, { details, select, cards });
  if (stage < 5) return store;

  store.lights = lightRoom(scene);                  // 5: everything above casts
  scene.getMeshByName('ceiling').layerMask = camera.layerMask;   // walkers see a ceiling
  useEnvironment(scene);
  if (stage < 6) return store;

  store.dust = addDust(scene);                      // 6: motes, and sound after the first click
  store.ambience = await createAmbience(scene, camera).catch(() => null);
  if (stage < 7) return store;

  const look = createStorefrontLook(scene, camera); // 7: sign, glow, color grade
  details.layer.applyPostProcess = false;           // draw the GUI after the color grade
  let outside = true;
  scene.onBeforeRenderObservable.add(() => {        // the look follows the visitor
    outside = camera.position.z < -ROOM.depth / 2;  // in front of the front wall
    look.pipeline.imageProcessing.exposure = outside ? 1 : 1.8;   // eyes adapt indoors
  });
  look.sheen.onApply = (effect) => effect.setFloat('strength', outside ? 0.15 : 0);
  store.look = look;
  return store;
}

const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true, {}, true);
openStore(engine, canvas, 4).then(({ scene, camera, books, select }) => {
  camera.position.set(-1.3, 1.6, 0.6);               // walked up to the left bookcase
  camera.setTarget(new BABYLON.Vector3(-1.9, 1.75, 2.6));
  engine.runRenderLoop(() => scene.render());
  scene.executeWhenReady(() => select(books[3]));    // as a tap on a spine or a card would
});
addEventListener('resize', () => engine.resize());
</script>