The model paints; the page sets the type. composeCover() draws a painting into a 256 x 384 canvas, averages the luminance of its empty lower third (ImageData) to choose cream or ink lettering, and adds Canvas 2D's fitted title and mark. Each canvas is drawn on a strip and handed to Babylon.js 89,603 as a DynamicTexture on Cover Textures's face-out shelf:
<canvas id="strip" style="width: 684px; height: 173px"></canvas>
<canvas id="view" style="width: 684px; height: 180px"></canvas>
<script src="hidpi.js"></script><script src="wrap.js"></script>
<script src="booknest-mark.js"></script><script src="booknest-data.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="bookstore.js"></script>
<script type="module">
const W = 256, H = 384;
function composeCover(art, book) { // Chapter 2's cover, over the art
const canvas = Object.assign(document.createElement('canvas'), { width: W, height: H });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(art, 0, 0, W, H);
const { data } = ctx.getImageData(0, H * 0.68, W, H * 0.32); // the empty third
let luma = 0; // Rec. 709 weights, 0-255
for (let i = 0; i < data.length; i += 4) {
luma += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
}
ctx.fillStyle = luma / (data.length / 4) < 150 ? '#fff3e0' : '#263238';
let size = 36, lines;
do { // fitTitle: shrink to two lines
ctx.font = `bold ${--size}px Georgia`;
lines = wrapLines(ctx, book.title, W - 36);
} while (lines.length > 2 || lines.some(line => ctx.measureText(line).width > W - 36));
ctx.textAlign = 'center';
lines.forEach((line, k) => ctx.fillText(line, W / 2, H - 92 + k * size * 1.1));
ctx.font = 'italic 17px Georgia';
ctx.fillText(book.author, W / 2, H - 22);
drawMark(ctx, 14, H - 50, 0.2);
return canvas;
}
const arts = BOOKS.map((book, i) =>
Object.assign(new Image(), { src: `booknest-art-${i + 1}.jpg` }));
await Promise.all(arts.map(art => art.decode()));
const covers = arts.map((art, i) => composeCover(art, BOOKS[i]));
const ctx = hidpi(strip);
covers.forEach((cover, i) => ctx.drawImage(cover, 4 + i * 114, 4, 110, 165));
const engine = new BABYLON.Engine(view, true, {}, true);
const scene = BookNestStore.createScene(engine);
const left = BookNestStore.createBookcase(scene, 'bookcaseLeft', -1.8);
const books = BookNestStore.placeBooks(scene, left, 3);
BookNestStore.coverBooks(scene, books).forEach((material, i) => {
material.albedoTexture.dispose(); // replace Section 7.5.9's cover
material.albedoTexture = new BABYLON.DynamicTexture(`art${i + 1}`, covers[i], scene);
material.albedoTexture.update(); // upload the canvas to the GPU
books[i].rotation.y = Math.PI / 2; // face out
books[i].position.x = -1.3 + i * 0.26;
});
new BABYLON.ArcRotateCamera('browse', -1.66, 1.5, 0.75,
new BABYLON.Vector3(-2.45, 1.78, 2.6), scene).minZ = 0.05;
BookNestStore.renderWhenReady(engine, scene, 10);
</script>
The three cream-bottomed paintings took ink lettering and the three dark ones cream. In Three.js 30,815 the same canvas becomes a THREE.CanvasTexture with colorSpace = THREE.SRGBColorSpace (Texture Types and Color Space). A canvas title can be corrected or translated without paying for a new image.
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #fafaf7; font: 11px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
</style>
<canvas id="strip" width="600" height="270"></canvas>
<p>Titles are canvas text, so they can be corrected or translated without paying for a new image. Each canvas can become a THREE.CanvasTexture (with SRGBColorSpace) or a Babylon DynamicTexture.</p>
<script>
const BOOKS = [
{ title: 'The Quiet Harbor', author: 'Elena Marsh', color: '#1f5f8b', light: false },
{ title: 'Patterns of the Deep Web', author: 'Ravi Menon', color: '#5b3f99', light: false },
{ title: 'Salt and Saffron', author: 'Leila Haddad', color: '#e09a10', light: true },
{ title: 'Small Steps to Big Summits', author: 'Tom Okafor', color: '#3f7d3a', light: true },
{ title: "The Clockmaker's Paradox", author: 'Iris Vale', color: '#b5452f', light: false },
{ title: 'Gardens in Glass', author: 'June Park', color: '#2a9d8f', light: true },
];
const W = 256, H = 384;
// Stand-in for booknest-art-N.jpg: a painting in the book's color, its lower third light or dark
function painting(book) {
const c = Object.assign(document.createElement('canvas'), { width: W, height: H }), g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, book.color); grad.addColorStop(0.62, book.color);
grad.addColorStop(0.72, book.light ? '#f3e6cc' : '#1b2b36'); grad.addColorStop(1, book.light ? '#fff3e0' : '#101a22');
g.fillStyle = grad; g.fillRect(0, 0, W, H);
g.fillStyle = 'rgba(255,243,224,0.5)';
for (let i = 0; i < 5; i++) { g.beginPath(); g.arc(60 + i * 35, 120 + (i % 2) * 40, 24 - i * 2, 0, 7); g.fill(); }
return c;
}
function wrapLines(g, text, maxWidth) {
const lines = []; let line = '';
for (const word of text.split(' ')) {
const test = line ? line + ' ' + word : word;
if (line && g.measureText(test).width > maxWidth) { lines.push(line); line = word; } else line = test;
}
return lines.concat(line ? [line] : []);
}
function composeCover(art, book) {
const canvas = Object.assign(document.createElement('canvas'), { width: W, height: H });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(art, 0, 0, W, H);
const { data } = ctx.getImageData(0, H * 0.68, W, H * 0.32); // the empty third
let luma = 0; // Rec. 709 weights, 0-255
for (let i = 0; i < data.length; i += 4) luma += 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
luma /= data.length / 4;
ctx.fillStyle = luma < 150 ? '#fff3e0' : '#263238';
let size = 36, lines;
do { // fitTitle: shrink to two lines
ctx.font = `bold ${--size}px Georgia`;
lines = wrapLines(ctx, book.title, W - 36);
} while (lines.length > 2 || lines.some(line => ctx.measureText(line).width > W - 36));
ctx.textAlign = 'center';
lines.forEach((line, k) => ctx.fillText(line, W / 2, H - 92 + k * size * 1.1));
ctx.font = 'italic 17px Georgia';
ctx.fillText(book.author, W / 2, H - 22);
return { canvas, luma };
}
const strip = document.getElementById('strip').getContext('2d');
BOOKS.forEach((book, i) => {
const { canvas, luma } = composeCover(painting(book), book);
strip.drawImage(canvas, 6 + i * 99, 6, 92, 138);
strip.fillStyle = '#263238'; strip.font = '11px system-ui';
strip.fillText(`luma ${Math.round(luma)}`, 6 + i * 99, 160);
strip.fillText(luma < 150 ? '→ cream type' : '→ ink type', 6 + i * 99, 174);
});
// A simple face-out shelf, in perspective, from the same canvases
strip.fillStyle = '#8d6e63'; strip.fillRect(0, 252, 600, 12);
BOOKS.forEach((book, i) => {
const { canvas } = composeCover(painting(book), book);
strip.save(); strip.transform(1, -0.08, 0, 1, 20 + i * 95, 190 + i * 1.5);
strip.drawImage(canvas, 0, 0, 44, 66); strip.restore();
});
</script>