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, side: THREE.DoubleSide }), 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 }) }; const BOOK_PROFILE = { tableY: 0, coverThickness: 0.03, raisedHingeY: 0.056, paperContactOffset: 0.0012 }; 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 = BOOK_PROFILE.coverThickness; 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); const spineWidth = fullBlock; addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth); 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, spineWidth); } 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, spineWidth) { const cover = new THREE.Mesh(createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth), materials.cover); book.add(cover); } function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth) { const overhang = 0.055; const spineHalf = spineWidth * 0.5; const hingeInset = 0.07; const outerX = pageWidth + overhang; const hingeX = spineHalf + hingeInset; const outerTopY = BOOK_PROFILE.tableY + thickness; const connectionTopY = BOOK_PROFILE.raisedHingeY; const spineTopY = BOOK_PROFILE.tableY + thickness; const section = [ { x: -outerX, y: outerTopY }, { x: -hingeX, y: connectionTopY }, { x: -spineHalf, y: spineTopY }, { x: spineHalf, y: spineTopY }, { x: hingeX, y: connectionTopY }, { x: outerX, y: outerTopY } ]; 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, spineWidth) { const material = new THREE.LineBasicMaterial({ color: 0xb51f1f }); const radiusX = spineWidth * 0.42; const radiusY = 0.018; const baseY = BOOK_PROFILE.tableY + BOOK_PROFILE.coverThickness + 0.002; const profile = []; for (let i = 0; i <= 32; i += 1) { const u = i / 32; const theta = Math.PI * (1 - u); const x = Math.cos(theta) * radiusX; const y = baseY + Math.sin(theta) * radiusY; profile.push({ x, y }); } [depth * 0.5 + 0.008, -depth * 0.5 - 0.008].forEach((z) => { const points = profile.map((point) => new THREE.Vector3(point.x, point.y, z)); book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material)); }); } 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 = pageBlockBottomY(thickness, u, v) + 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 supportY = coverSupportTopY(u); return supportY + BOOK_PROFILE.paperContactOffset; } function coverSupportTopY(u) { const hingeSpan = 0.14; const t = THREE.MathUtils.clamp((u - hingeSpan) / (1 - hingeSpan), 0, 1); return THREE.MathUtils.lerp(BOOK_PROFILE.raisedHingeY, BOOK_PROFILE.coverThickness, 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); }