A book is a page block between two boards, joined by a curved spine. Each part is a built-in geometry moved by translate(); the spine is a CylinderGeometry cut to an open half-tube (thetaStart -90 degrees, thetaLength 180) and flattened. mergeGeometries(parts, true) (Merging Geometries) keeps a group per part, so one mesh draws each part with its own material:
export const thickness = (book) => 0.05 + book.pages * 0.0003; // meters, from page count
export function bookGeometry(book, height, depth = 0.62) {
const t = thickness(book), b = 0.012, y = height / 2, z = -depth / 2;
const box = (w, h, d, x, zz = z) => new THREE.BoxGeometry(w, h, d).translate(x, y, zz);
const spine = new THREE.CylinderGeometry(t / 2, t / 2, height, 24, 1, true, -Math.PI / 2,
Math.PI).scale(1, 1, 0.5).translate(0, y, 0); // open half-tube, flattened
return mergeGeometries([
box(t - 2 * b, height - 0.03, depth - 0.02, 0, z + 0.01), // group 0: pages
box(b, height, depth, (b - t) / 2), // group 1: back board
box(b, height, depth, (t - b) / 2), // group 2: front board
spine, // group 3: spine
], true);
}The cylinder's UVs run around the curve and up the height, as spine lettering needs, and the page counts give 0.10 to 0.18 m of thickness.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
.ui { padding: 4px 8px; }
.key span { display: inline-block; width: 10px; height: 10px; margin: 0 3px 0 10px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="ui">Explode <input type="range" id="explode" min="0" max="1" step="0.01" value="0.6">
<span class="key"><span style="background:#f3ecdc"></span>0 pages<span style="background:#1f5f8b"></span>1 back board<span style="background:#e09a10"></span>2 front board<span style="background:#b5452f"></span>3 spine</span></div>
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 280);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 280, 0.1, 50);
camera.position.set(1.5, 1.2, 2.7);
camera.lookAt(0.05, 0.4, -0.25);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.3));
const sun = new THREE.DirectionalLight('#ffffff', 2.2);
sun.position.set(2, 3, 4);
scene.add(sun);
const thickness = (pages) => 0.05 + pages * 0.0003; // metres, from the page count
// Each part is a built-in geometry moved into place with translate()
function bookParts(pages, height, depth = 0.62) {
const t = thickness(pages), b = 0.012, y = height / 2, z = -depth / 2;
const box = (w, h, d, x, zz = z) => new THREE.BoxGeometry(w, h, d).translate(x, y, zz);
const spine = new THREE.CylinderGeometry(t / 2, t / 2, height, 24, 1, true, -Math.PI / 2, Math.PI)
.scale(1, 1, 0.5).translate(0, y, 0); // open half-tube, flattened
return [
box(t - 2 * b, height - 0.03, depth - 0.02, 0, z + 0.01), // group 0: pages
box(b, height, depth, (b - t) / 2), // group 1: back board
box(b, height, depth, (t - b) / 2), // group 2: front board
spine, // group 3: spine
];
}
const materials = ['#f3ecdc', '#1f5f8b', '#e09a10', '#b5452f'].map((color) =>
new THREE.MeshStandardMaterial({ color, roughness: 0.6, side: THREE.DoubleSide }));
// Left: the merged book, one mesh, one draw call per group
const merged = new THREE.Mesh(mergeGeometries(bookParts(428, 0.9), true), materials);
merged.position.x = -0.35;
scene.add(merged);
// Right: the same parts as separate meshes, pulled apart along x and z
const exploded = new THREE.Group();
const parts = bookParts(428, 0.9).map((geometry, i) => {
const mesh = new THREE.Mesh(geometry, materials[i]);
exploded.add(mesh);
return mesh;
});
exploded.position.x = 0.45;
scene.add(exploded);
const offsets = [[0, 0], [-0.12, 0], [0.12, 0], [0, 0.2]];
const slider = document.getElementById('explode');
renderer.setAnimationLoop((time) => {
const k = +slider.value;
parts.forEach((p, i) => p.position.set(offsets[i][0] * k, 0, offsets[i][1] * k));
merged.rotation.y = exploded.rotation.y = -0.9 + Math.sin(time / 1800) * 0.5;
renderer.render(scene, camera);
});
</script>