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); }