Procedural Spines

Modeling a Book Spine Procedurally

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:

bookshelf.js: bookGeometry(), four parts merged with groupsJavaScript
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.

A book from four built-in geometries: pages, two boards and a half-tube spine, merged with groupsHTMLLive
<!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>