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:
<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>
<!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>