bookstore.js gains createDetailsPanel(scene, url = 'booknest-data.json'). It fetches the catalog (the summaries, years and stock flags BOOKS lacks) and builds a hidden fullscreen Rectangle (isPointerBlocker, idealWidth as in 7.12.1) around a Grid: the cover Image in a 96-pixel column, a StackPanel of text and an "Add to cart" button in the other. Its show(record) fills the panel from the record enableBookPicking passes (hiding it for null) and disables the button for books out of stock:
<canvas id="view" style="width: 620px; height: 170px"></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>
(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);
const target = new BABYLON.Vector3(-1.05, 1.3, 2.6); // the book left of center
const camera = new BABYLON.ArcRotateCamera('browse', -1.75, 1.45, 1.8, target, scene);
const details = await BookNestStore.createDetailsPanel(scene); // fetches the catalog
BookNestStore.enableBookPicking(scene, books, (book) => {
details.show(book); // null hides the panel
setTimeout(() => { window.__done = true; }, 1000);
});
await scene.whenReadyAsync();
engine.runRenderLoop(() => scene.render());
scene.render();
const aim = books[2].getBoundingInfo().boundingBox.centerWorld.subtract(camera.position);
const hit = scene.pickWithRay(new BABYLON.Ray(camera.position, aim.normalize()));
scene.simulatePointerDown(hit); // tap Salt and Saffron
scene.simulatePointerUp(hit);
})();
</script>
The catalog marks Salt and Saffron out of stock, so its button is gray and disabled.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 320px; 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 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);
});
}
// 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 {
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';
},
};
}
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);
const target = new BABYLON.Vector3(-1.2, 1.3, 2.6); // the books left of center
const camera = new BABYLON.ArcRotateCamera('browse', -1.75, 1.45, 1.8, target, scene);
camera.minZ = 0.05;
camera.attachControl(canvas, true);
const details = createDetailsPanel(scene);
enableBookPicking(scene, books, (book) => details.show(book)); // null hides the panel
engine.runRenderLoop(() => scene.render());
scene.executeWhenReady(() => { // tap Salt and Saffron once for the preview
scene.render();
const aim = books[2].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>