From 552bf14626a9185b12e9e063c638ccf5062d2f78 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Thu, 4 Jun 2026 22:13:06 +0200 Subject: [PATCH] Checkpoint procedural book shape lab --- public/js/webgl-book-shape-lab.js | 361 ++++++++++++++++++++++++++++++ public/webgl-book-shape-lab.html | 55 +++++ 2 files changed, 416 insertions(+) create mode 100644 public/js/webgl-book-shape-lab.js create mode 100644 public/webgl-book-shape-lab.html diff --git a/public/js/webgl-book-shape-lab.js b/public/js/webgl-book-shape-lab.js new file mode 100644 index 0000000..eb368b4 --- /dev/null +++ b/public/js/webgl-book-shape-lab.js @@ -0,0 +1,361 @@ +import * as THREE from 'https://esm.sh/three@0.165.0'; +import { OrbitControls } from 'https://esm.sh/three@0.165.0/examples/jsm/controls/OrbitControls.js'; + +const canvas = document.getElementById('scene'); +const progressInput = document.getElementById('progress'); +const progressValue = document.getElementById('progress_value'); +const urlParams = new URLSearchParams(window.location.search); + +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); +renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); +renderer.setClearColor(0x202124, 1); + +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 30); +if (urlParams.get('view') === 'profile') { + camera.position.set(0, 0.82, 5.8); +} else if (urlParams.get('view') === 'top') { + camera.position.set(0, 5.8, 0.001); +} else { + camera.position.set(0, 3.25, 5.4); +} + +const controls = new OrbitControls(camera, canvas); +controls.target.set(0, urlParams.get('view') === 'profile' ? 0.13 : 0.18, 0); +controls.enableDamping = true; +controls.minDistance = 2.2; +controls.maxDistance = 8.0; +controls.update(); + +const book = new THREE.Group(); +scene.add(book); + +const guide = new THREE.GridHelper(5.6, 16, 0x4c4c4c, 0x343434); +guide.position.y = -0.12; +scene.add(guide); + +const materials = { + cover: new THREE.MeshBasicMaterial({ color: 0x2c1810 }), + spine: new THREE.MeshBasicMaterial({ color: 0x9c1f1f, side: THREE.DoubleSide }), + pagesLeft: new THREE.MeshBasicMaterial({ color: 0xd8c7a4, side: THREE.DoubleSide }), + pagesRight: new THREE.MeshBasicMaterial({ color: 0xe7d6b4, side: THREE.DoubleSide }), + topPage: new THREE.MeshBasicMaterial({ color: 0xf1dfba, side: THREE.DoubleSide }), + edge: new THREE.MeshBasicMaterial({ color: 0xb99a68, side: THREE.DoubleSide }), + hinge: new THREE.MeshBasicMaterial({ color: 0x2b0808 }) +}; + +let readingProgress = readInitialProgress(); +progressInput.value = readingProgress.toFixed(3); +progressValue.value = readingProgress.toFixed(2); +rebuildBook(); +resize(); +animate(); + +progressInput.addEventListener('input', () => { + setReadingProgress(progressInput.value); +}); + +window.addEventListener('resize', resize); + +window.BookShapeLab = { + get progress() { + return readingProgress; + }, + setReadingProgress(value) { + setReadingProgress(value); + return readingProgress; + } +}; + +function readInitialProgress() { + const parsed = Number.parseFloat(urlParams.get('progress') ?? '0.25'); + return Number.isFinite(parsed) ? THREE.MathUtils.clamp(parsed, 0, 1) : 0.25; +} + +function setReadingProgress(value) { + const next = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1); + if (!Number.isFinite(next)) return; + readingProgress = next; + progressInput.value = readingProgress.toFixed(3); + progressValue.value = readingProgress.toFixed(2); + rebuildBook(); +} + +function rebuildBook() { + clearGroup(book); + + const coverDepth = 2.34; + const coverThickness = 0.022; + const pageWidth = 1.62; + const pageDepth = 2.24; + const gutter = 0.12; + const sheetTick = 0.0045; + const fullBlock = 0.41; + const leftThickness = THREE.MathUtils.lerp(sheetTick, fullBlock, readingProgress); + const rightThickness = THREE.MathUtils.lerp(fullBlock, sheetTick, readingProgress); + const foldX = clothFoldX(gutter); + + addCoverAssembly(pageWidth, coverDepth, coverThickness); + addSplinePageBlock(-1, pageWidth, pageDepth, leftThickness, foldX); + addSplinePageBlock(1, pageWidth, pageDepth, rightThickness, foldX); + addPageLayerLines(-1, pageWidth, pageDepth, leftThickness, foldX); + addPageLayerLines(1, pageWidth, pageDepth, rightThickness, foldX); + addClothSpine(pageDepth, gutter); +} + +function clearGroup(group) { + while (group.children.length) { + const child = group.children.pop(); + child.geometry?.dispose(); + if (Array.isArray(child.material)) { + child.material.forEach((material) => material.dispose?.()); + } + } +} + +function addCoverAssembly(pageWidth, depth, thickness) { + const cover = new THREE.Mesh(createCoverAssemblyGeometry(pageWidth, depth, thickness), materials.cover); + book.add(cover); +} + +function createCoverAssemblyGeometry(pageWidth, depth, thickness) { + const overhang = 0.055; + const hingeX = 0.18; + const centerX = 0.095; + const boardTopY = 0.023; + const centerTopY = -0.002; + const section = [ + { x: -pageWidth - overhang, y: boardTopY }, + { x: -hingeX, y: boardTopY }, + { x: -centerX, y: centerTopY }, + { x: centerX, y: centerTopY }, + { x: hingeX, y: boardTopY }, + { x: pageWidth + overhang, y: boardTopY } + ]; + const positions = []; + const uvs = []; + const indices = []; + const frontTop = []; + const backTop = []; + const frontBottom = []; + const backBottom = []; + + const push = (x, y, z, u, v) => { + const index = positions.length / 3; + positions.push(x, y, z); + uvs.push(u, v); + return index; + }; + + section.forEach((point, index) => { + const u = index / (section.length - 1); + frontTop[index] = push(point.x, point.y, depth * 0.5, u, 1); + backTop[index] = push(point.x, point.y, -depth * 0.5, u, 0); + frontBottom[index] = push(point.x, point.y - thickness, depth * 0.5, u, 1); + backBottom[index] = push(point.x, point.y - thickness, -depth * 0.5, u, 0); + }); + + for (let i = 0; i < section.length - 1; i += 1) { + indices.push(frontTop[i], backTop[i], frontTop[i + 1], frontTop[i + 1], backTop[i], backTop[i + 1]); + indices.push(frontBottom[i], frontBottom[i + 1], backBottom[i], frontBottom[i + 1], backBottom[i + 1], backBottom[i]); + indices.push(frontTop[i], frontTop[i + 1], frontBottom[i], frontTop[i + 1], frontBottom[i + 1], frontBottom[i]); + indices.push(backTop[i], backBottom[i], backTop[i + 1], backTop[i + 1], backBottom[i], backBottom[i + 1]); + } + + const last = section.length - 1; + indices.push(frontTop[0], frontBottom[0], backTop[0], backTop[0], frontBottom[0], backBottom[0]); + indices.push(frontTop[last], backTop[last], frontBottom[last], backTop[last], backBottom[last], frontBottom[last]); + + const geometry = new THREE.BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geometry.computeVertexNormals(); + return geometry; +} + +function addSplinePageBlock(side, width, depth, thickness, foldX) { + const block = new THREE.Mesh(createSplinePageBlockGeometry(side, width, depth, thickness, foldX), side < 0 ? materials.pagesLeft : materials.pagesRight); + book.add(block); +} + +function addPageLayerLines(side, width, depth, thickness, foldX) { + const material = new THREE.LineBasicMaterial({ color: 0x8f7750, transparent: true, opacity: 0.55 }); + const z = depth * 0.5 + 0.006; + const lineCount = Math.max(1, Math.round(thickness / 0.018)); + for (let layer = 1; layer < lineCount; layer += 1) { + const t = layer / lineCount; + const points = []; + for (let i = 0; i <= 40; i += 1) { + const u = 0.04 + 0.96 * (i / 40); + const top = pageBlockTopY(thickness, u, 0.5); + const bottom = pageBlockBottomY(thickness, u, 0.5); + points.push(new THREE.Vector3(pageX(side, foldX, width, u, 0.5), bottom + (top - bottom) * t, z)); + } + book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material)); + } +} + +function clothFoldX(gutter) { + const spineTravel = gutter * 0.52; + return THREE.MathUtils.lerp(-spineTravel, spineTravel, readingProgress); +} + +function addClothSpine(depth, gutter) { + const geometry = createClothSpineGeometry(depth, gutter); + const spine = new THREE.Mesh(geometry, materials.spine); + book.add(spine); +} + +function createClothSpineGeometry(depth, gutter) { + const columns = 24; + const rows = 18; + const positions = []; + const uvs = []; + const indices = []; + const radiusX = gutter * 0.52; + const radiusY = 0.04; + const baseY = -0.004; + + for (let row = 0; row <= rows; row += 1) { + const v = row / rows; + const z = (v - 0.5) * depth * 0.94; + for (let column = 0; column <= columns; column += 1) { + const u = column / columns; + const theta = Math.PI * (1 - u); + const x = Math.cos(theta) * radiusX; + const y = baseY + Math.sin(theta) * radiusY + x * 0.12; + const exposedY = Math.min(y, 0.038); + const lengthSag = -0.006 * Math.sin(Math.PI * v); + positions.push(x, exposedY + lengthSag, z); + uvs.push(u, v); + } + } + + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const a = row * (columns + 1) + column; + const b = a + 1; + const c = a + columns + 1; + const d = c + 1; + indices.push(a, c, b, b, c, d); + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geometry.computeVertexNormals(); + return geometry; +} + +function pageBlockTopY(thickness, u, v) { + const hingeWidth = 0.105; + const hinge = THREE.MathUtils.clamp(u / hingeWidth, 0, 1); + const t = easeOutCubic(hinge); + const sewnY = 0.066; + const stackY = 0.032 + thickness; + const flatCrown = 0.006 * Math.sin(Math.PI * v); + const foreCurl = 0.006 * smoothstep(THREE.MathUtils.clamp((u - 0.88) / 0.12, 0, 1)); + return sewnY * (1 - t) + (stackY + flatCrown + foreCurl) * t; +} + +function pageBlockBottomY(thickness, u, v) { + const hingeWidth = 0.105; + const hinge = THREE.MathUtils.clamp(u / hingeWidth, 0, 1); + const t = easeOutCubic(hinge); + const sewnY = 0.026; + const stackY = 0.026; + return sewnY * (1 - t) + stackY * t; +} + +function smoothstep(value) { + return value * value * (3 - 2 * value); +} + +function easeOutCubic(value) { + return 1 - Math.pow(1 - value, 3); +} + +function pageWidthAtDepth(width, v) { + return width; +} + +function pageX(side, foldX, width, u, v = 0.5) { + const foreX = side * pageWidthAtDepth(width, v); + return foldX * (1 - u) + foreX * u; +} + +function createSplinePageBlockGeometry(side, width, depth, thickness, foldX) { + const columns = 36; + const rows = 36; + const positions = []; + const uvs = []; + const indices = []; + const top = []; + const bottom = []; + + const push = (x, y, z, u, v) => { + const index = positions.length / 3; + positions.push(x, y, z); + uvs.push(u, v); + return index; + }; + + for (let row = 0; row <= rows; row += 1) { + const v = row / rows; + top[row] = []; + bottom[row] = []; + for (let column = 0; column <= columns; column += 1) { + const u = column / columns; + const z = (v - 0.5) * depth; + top[row][column] = push(pageX(side, foldX, width, u, v), pageBlockTopY(thickness, u, v), z, u, v); + bottom[row][column] = push(pageX(side, foldX, width, u, v), pageBlockBottomY(thickness, u, v), z, u, v); + } + } + + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + indices.push(top[row][column], top[row + 1][column], top[row][column + 1]); + indices.push(top[row][column + 1], top[row + 1][column], top[row + 1][column + 1]); + indices.push(bottom[row][column], bottom[row][column + 1], bottom[row + 1][column]); + indices.push(bottom[row][column + 1], bottom[row + 1][column + 1], bottom[row + 1][column]); + } + } + + for (let row = 0; row < rows; row += 1) { + indices.push(top[row][0], bottom[row][0], top[row + 1][0], top[row + 1][0], bottom[row][0], bottom[row + 1][0]); + indices.push(top[row][columns], top[row + 1][columns], bottom[row][columns], top[row + 1][columns], bottom[row + 1][columns], bottom[row][columns]); + } + + for (let column = 0; column < columns; column += 1) { + indices.push(top[0][column], top[0][column + 1], bottom[0][column], top[0][column + 1], bottom[0][column + 1], bottom[0][column]); + indices.push(top[rows][column], bottom[rows][column], top[rows][column + 1], top[rows][column + 1], bottom[rows][column], bottom[rows][column + 1]); + } + + const geometry = new THREE.BufferGeometry(); + geometry.setIndex(indices); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geometry.computeVertexNormals(); + return geometry; +} + +function resize() { + const width = window.innerWidth; + const height = window.innerHeight; + renderer.setSize(width, height, false); + camera.aspect = width / height; + camera.updateProjectionMatrix(); +} + +function animate() { + requestAnimationFrame(animate); + if (urlParams.get('animate') === '1') { + const t = performance.now() * 0.00035; + setReadingProgress(0.5 + Math.sin(t) * 0.48); + } + controls.update(); + renderer.render(scene, camera); +} diff --git a/public/webgl-book-shape-lab.html b/public/webgl-book-shape-lab.html new file mode 100644 index 0000000..093ce40 --- /dev/null +++ b/public/webgl-book-shape-lab.html @@ -0,0 +1,55 @@ + + + + + + Book Shape Lab + + + + +
+ + + 0.25 +
+ + +