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 pageCountInput = document.getElementById('page_count'); const pageCountValue = document.getElementById('page_count_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, bundleSpacing: 0.014 }; let readingProgress = readInitialProgress(); let pageCount = readInitialPageCount(); let lastLengthError = 0; let lastSpacingError = 0; progressInput.value = readingProgress.toFixed(3); progressValue.value = readingProgress.toFixed(2); pageCountInput.value = String(pageCount); pageCountValue.value = String(pageCount); rebuildBook(); resize(); animate(); progressInput.addEventListener('input', () => { setReadingProgress(progressInput.value); }); pageCountInput.addEventListener('input', () => { setPageCount(pageCountInput.value); }); window.addEventListener('resize', resize); window.BookShapeLab = { get progress() { return readingProgress; }, get pageCount() { return pageCount; }, get lastLengthError() { return lastLengthError; }, get lastSpacingError() { return lastSpacingError; }, setReadingProgress(value) { setReadingProgress(value); return readingProgress; }, setPageCount(value) { setPageCount(value); return pageCount; } }; function readInitialProgress() { const parsed = Number.parseFloat(urlParams.get('progress') ?? '0.25'); return Number.isFinite(parsed) ? THREE.MathUtils.clamp(parsed, 0, 1) : 0.25; } function readInitialPageCount() { const parsed = Number.parseInt(urlParams.get('pages') ?? '240', 10); if (!Number.isFinite(parsed)) return 240; return snapPageCount(parsed); } function snapPageCount(value) { return THREE.MathUtils.clamp(Math.round(value / 10) * 10, 40, 600); } 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 setPageCount(value) { const next = snapPageCount(Number.parseFloat(value)); if (!Number.isFinite(next)) return; pageCount = next; pageCountInput.value = String(pageCount); pageCountValue.value = String(pageCount); rebuildBook(); } function rebuildBook() { clearGroup(book); const coverDepth = 2.30; const coverThickness = BOOK_PROFILE.coverThickness; const pageWidth = 1.62; const pageDepth = 2.24; const bundleCount = Math.max(4, Math.round(pageCount / 10)); const spineWidth = Math.max(0.16, bundleCount * BOOK_PROFILE.bundleSpacing); const lines = simulatePageLines(bundleCount, pageWidth, spineWidth); lastLengthError = measureLineLengthError(lines, pageWidth); lastSpacingError = measureStackSpacingError(lines); addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth); addClothSpine(pageDepth, spineWidth); addSimulatedStackBodies(lines, pageDepth); addSimulatedPageLines(lines, pageDepth); } 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.13; 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 addClothSpine(depth, spineWidth) { const spine = new THREE.Mesh(createClothSpineGeometry(depth, spineWidth), materials.spine); book.add(spine); } function createClothSpineGeometry(depth, spineWidth) { const profile = []; for (let i = 0; i <= 32; i += 1) { const u = i / 32; profile.push(spineCurvePoint(u, spineWidth)); } const positions = []; const indices = []; const front = []; const back = []; const push = (point, z) => { const index = positions.length / 3; positions.push(point.x, point.y, z); return index; }; profile.forEach((point) => { front.push(push(point, depth * 0.5 + 0.012)); back.push(push(point, -depth * 0.5 - 0.012)); }); for (let i = 0; i < profile.length - 1; i += 1) { indices.push(front[i], back[i], front[i + 1]); indices.push(front[i + 1], back[i], back[i + 1]); } const geometry = new THREE.BufferGeometry(); geometry.setIndex(indices); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); return geometry; } function spineCurvePoint(t, spineWidth) { const radiusX = spineWidth * 0.42; const radiusY = 0.018; const baseY = BOOK_PROFILE.tableY + BOOK_PROFILE.coverThickness + 0.002; const theta = Math.PI * (1 - THREE.MathUtils.clamp(t, 0, 1)); return { t: THREE.MathUtils.clamp(t, 0, 1), x: Math.cos(theta) * radiusX, y: baseY + Math.sin(theta) * radiusY }; } function simulatePageLines(bundleCount, pageWidth, spineWidth) { const lines = []; const segments = 24; const stepLength = pageWidth / segments; const entries = []; const spineSamples = sampleSpineByArc(bundleCount, spineWidth); const leftLimit = Math.floor((bundleCount - 1) * readingProgress); for (let index = 0; index < bundleCount; index += 1) { const t = spineSamples[index].t; const side = index <= leftLimit ? -1 : 1; entries.push({ index, t, side }); } [-1, 1].forEach((side) => { const sideEntries = entries.filter((entry) => entry.side === side); sideEntries.forEach((entry, rank) => { entry.rank = rank; entry.sideCount = sideEntries.length; }); }); [-1, 1].forEach((side) => { const sideEntries = entries .filter((entry) => entry.side === side) .sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t); if (!sideEntries.length) return; let lowerLine = null; sideEntries.forEach((entry, rank) => { const anchor = spineCurvePoint(entry.t, spineWidth); const target = restingTarget(side, pageWidth, rank, sideEntries.length); const points = buildOptimizedSplineLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount); const line = { index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1] }; lines.push(line); lowerLine = line; }); }); return lines; } function measureLineLengthError(lines, pageWidth) { return lines.reduce((maxError, line) => { let length = 0; for (let i = 0; i < line.points.length - 1; i += 1) { length += Math.hypot(line.points[i + 1].x - line.points[i].x, line.points[i + 1].y - line.points[i].y); } return Math.max(maxError, Math.abs(length - pageWidth)); }, 0); } function measureStackSpacingError(lines) { let maxError = 0; [-1, 1].forEach((side) => { const sideLines = lines .filter((line) => line.side === side) .sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t); for (let row = 1; row < sideLines.length; row += 1) { const lower = sideLines[row - 1]; const upper = sideLines[row]; for (let col = 1; col < upper.points.length; col += 1) { const distance = Math.hypot(upper.points[col].x - lower.points[col].x, upper.points[col].y - lower.points[col].y); maxError = Math.max(maxError, Math.abs(distance - BOOK_PROFILE.bundleSpacing)); } } }); return maxError; } function sampleSpineByArc(count, spineWidth) { const samples = []; const steps = 240; let length = 0; let previous = spineCurvePoint(0, spineWidth); samples.push({ point: previous, length }); for (let i = 1; i <= steps; i += 1) { const t = i / steps; const point = spineCurvePoint(t, spineWidth); length += Math.hypot(point.x - previous.x, point.y - previous.y); samples.push({ point, length }); previous = point; } const points = []; for (let i = 0; i < count; i += 1) { const target = count === 1 ? length * 0.5 : length * (i / (count - 1)); const found = samples.findIndex((sample) => sample.length >= target); if (found <= 0) { points.push(samples[0].point); continue; } const before = samples[found - 1]; const after = samples[found]; const span = after.length - before.length || 1; const t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span); points.push(spineCurvePoint(t, spineWidth)); } return points; } function initialPageLine(anchor, target, segments) { const points = []; for (let i = 0; i <= segments; i += 1) { const u = i / segments; const sag = 0.04 * Math.sin(Math.PI * u); points.push({ x: THREE.MathUtils.lerp(anchor.x, target.x, u), y: THREE.MathUtils.lerp(anchor.y, target.y, u) - sag * u }); } return points; } function restingTarget(side, pageWidth, rank, sideCount) { const local = sideCount <= 1 ? 0 : rank / (sideCount - 1); const foreCurve = 0.11 * Math.sin(Math.PI * local); const x = side * (pageWidth - foreCurve); const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * BOOK_PROFILE.bundleSpacing + 0.002 * Math.sin(Math.PI * local); return { x, y }; } function buildOptimizedSplineLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount) { const points = initialSplineGuess(anchor, target, lowerLine, side, segments, bundleCount); const iterations = lowerLine ? 120 : 90; for (let iteration = 0; iteration < iterations; iteration += 1) { smoothSplineCurvature(points, iteration < iterations * 0.65 ? 0.34 : 0.14); attractSplineToSupport(points, lowerLine, side, bundleCount, lowerLine ? 0.46 : 0.18); attractEndpoint(points, target, 0.08); enforceOutwardProgress(points, side); enforceLineLength(points, anchor, stepLength, 5); keepSplineOnSupport(points, lowerLine, side, bundleCount); enforceLineLength(points, anchor, stepLength, 3); } smoothSplineCurvature(points, 0.08); enforceLineLength(points, anchor, stepLength, 12); keepSplineOnSupport(points, lowerLine, side, bundleCount); enforceLineLength(points, anchor, stepLength, 12); enforceForwardLineLength(points, anchor, stepLength); return points; } function initialSplineGuess(anchor, target, lowerLine, side, segments, bundleCount) { const points = []; for (let i = 0; i <= segments; i += 1) { const u = i / segments; if (lowerLine) { const lowerPoint = lowerLine.points[i]; const normal = upwardNormalAt(lowerLine.points, i); const anchorBlend = Math.pow(1 - u, 2.2); points.push({ x: lowerPoint.x + normal.x * BOOK_PROFILE.bundleSpacing + (anchor.x - (lowerLine.anchor.x + normal.x * BOOK_PROFILE.bundleSpacing)) * anchorBlend, y: lowerPoint.y + normal.y * BOOK_PROFILE.bundleSpacing + (anchor.y - (lowerLine.anchor.y + normal.y * BOOK_PROFILE.bundleSpacing)) * anchorBlend }); } else { const crown = 0.026 * Math.sin(Math.PI * u) * Math.pow(u, 0.7); points.push({ x: THREE.MathUtils.lerp(anchor.x, target.x, u), y: THREE.MathUtils.lerp(anchor.y, target.y, u) + crown }); } points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + coverClearance(bundleCount)); } points[0].x = anchor.x; points[0].y = anchor.y; return points; } function smoothSplineCurvature(points, strength) { const nextPoints = points.map((point) => ({ x: point.x, y: point.y })); for (let i = 1; i < points.length - 1; i += 1) { const midpointX = (points[i - 1].x + points[i + 1].x) * 0.5; const midpointY = (points[i - 1].y + points[i + 1].y) * 0.5; nextPoints[i].x = THREE.MathUtils.lerp(points[i].x, midpointX, strength); nextPoints[i].y = THREE.MathUtils.lerp(points[i].y, midpointY, strength); } for (let i = 1; i < points.length - 1; i += 1) { points[i].x = nextPoints[i].x; points[i].y = nextPoints[i].y; } } function attractSplineToSupport(points, lowerLine, side, bundleCount, strength) { for (let i = 1; i < points.length; i += 1) { if (lowerLine) { const support = lowerLine.points[i]; const normal = upwardNormalAt(lowerLine.points, i); const targetX = support.x + normal.x * BOOK_PROFILE.bundleSpacing; const targetY = support.y + normal.y * BOOK_PROFILE.bundleSpacing; points[i].x = THREE.MathUtils.lerp(points[i].x, targetX, strength); points[i].y = THREE.MathUtils.lerp(points[i].y, targetY, strength); } else { const floor = coverTopYAtX(points[i].x) + coverClearance(bundleCount); points[i].y = THREE.MathUtils.lerp(points[i].y, floor, strength * 0.12); } points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); } } function attractEndpoint(points, target, strength) { const end = points[points.length - 1]; end.x = THREE.MathUtils.lerp(end.x, target.x, strength); end.y = THREE.MathUtils.lerp(end.y, target.y, strength); } function enforceOutwardProgress(points, side) { for (let i = 1; i < points.length; i += 1) { if (side < 0) { points[i].x = Math.min(points[i].x, points[i - 1].x - 0.0005); } else { points[i].x = Math.max(points[i].x, points[i - 1].x + 0.0005); } } } function keepSplineOnSupport(points, lowerLine, side, bundleCount) { for (let i = 1; i < points.length; i += 1) { points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + coverClearance(bundleCount)); if (lowerLine) { const support = lowerLine.points[i]; const normal = upwardNormalAt(lowerLine.points, i); const dx = points[i].x - support.x; const dy = points[i].y - support.y; const normalDistance = dx * normal.x + dy * normal.y; if (normalDistance < BOOK_PROFILE.bundleSpacing) { points[i].x += normal.x * (BOOK_PROFILE.bundleSpacing - normalDistance); points[i].y += normal.y * (BOOK_PROFILE.bundleSpacing - normalDistance); } const nearest = closestPointOnPolyline(points[i], lowerLine.points); const nearestDistance = Math.hypot(points[i].x - nearest.x, points[i].y - nearest.y); if (nearestDistance < BOOK_PROFILE.bundleSpacing * 0.72) { points[i].y += BOOK_PROFILE.bundleSpacing * 0.72 - nearestDistance; } } } } function closestPointOnPolyline(point, polyline) { let best = polyline[0]; let bestDistance = Number.POSITIVE_INFINITY; for (let i = 0; i < polyline.length - 1; i += 1) { const candidate = closestPointOnSegment(point, polyline[i], polyline[i + 1]); const distance = Math.hypot(point.x - candidate.x, point.y - candidate.y); if (distance < bestDistance) { best = candidate; bestDistance = distance; } } return best; } function closestPointOnSegment(point, a, b) { const dx = b.x - a.x; const dy = b.y - a.y; const lengthSquared = dx * dx + dy * dy || 0.0001; const t = THREE.MathUtils.clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared, 0, 1); return { x: a.x + dx * t, y: a.y + dy * t }; } function relaxPageLine(points, anchor, stepLength, side, local, bundleCount) { const gravity = 0.00072; const stackPressure = 0.0011 * (1 - local); const bendStrength = 0.52; const iterations = 72; for (let iteration = 0; iteration < iterations; iteration += 1) { points[0].x = anchor.x; points[0].y = anchor.y; for (let i = 1; i < points.length; i += 1) { const u = i / (points.length - 1); points[i].y -= gravity * u + stackPressure * u * u; } applyBendingResistance(points, bendStrength); for (let pass = 0; pass < 3; pass += 1) { points[0].x = anchor.x; points[0].y = anchor.y; enforceLineLength(points, anchor, stepLength, 3); keepPageAboveCover(points, side, bundleCount); } } } function applyBendingResistance(points, strength) { const updates = points.map((point) => ({ x: point.x, y: point.y })); for (let i = 1; i < points.length - 1; i += 1) { const previous = points[i - 1]; const current = points[i]; const next = points[i + 1]; updates[i].x += (previous.x + next.x - current.x * 2) * strength; updates[i].y += (previous.y + next.y - current.y * 2) * strength; } for (let i = 1; i < points.length - 1; i += 1) { points[i].x = updates[i].x; points[i].y = updates[i].y; } } function enforceLineLength(points, anchor, stepLength, passes) { for (let pass = 0; pass < passes; pass += 1) { points[0].x = anchor.x; points[0].y = anchor.y; for (let i = 0; i < points.length - 1; i += 1) { constrainSegment(points[i], points[i + 1], stepLength, i === 0); } for (let i = points.length - 2; i >= 0; i -= 1) { constrainSegment(points[i], points[i + 1], stepLength, i === 0); } } } function constrainSegment(a, b, length, anchorA) { const dx = b.x - a.x; const dy = b.y - a.y; const distance = Math.hypot(dx, dy) || 0.0001; const correction = (distance - length) / distance; if (anchorA) { b.x -= dx * correction; b.y -= dy * correction; return; } a.x += dx * correction * 0.5; a.y += dy * correction * 0.5; b.x -= dx * correction * 0.5; b.y -= dy * correction * 0.5; } function enforceForwardLineLength(points, anchor, stepLength) { points[0].x = anchor.x; points[0].y = anchor.y; for (let i = 1; i < points.length; i += 1) { const previous = points[i - 1]; const current = points[i]; const dx = current.x - previous.x; const dy = current.y - previous.y; const distance = Math.hypot(dx, dy) || 0.0001; current.x = previous.x + dx / distance * stepLength; current.y = previous.y + dy / distance * stepLength; } } function keepPageAboveCover(points, side, bundleCount) { for (let i = 1; i < points.length; i += 1) { points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + coverClearance(bundleCount)); points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); } } function coverClearance(bundleCount) { return BOOK_PROFILE.paperContactOffset + 0.0002 * bundleCount; } function enforceStackConstraints(lines, stepLength, bundleCount) { const iterations = 44; [-1, 1].forEach((side) => { const sideLines = lines .filter((line) => line.side === side) .sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t); for (let iteration = 0; iteration < iterations; iteration += 1) { sideLines.forEach((line) => { line.points[0].x = line.anchor.x; line.points[0].y = line.anchor.y; applyBendingResistance(line.points, 0.22); enforceLineLength(line.points, line.anchor, stepLength, 3); keepPageAboveCover(line.points, side, bundleCount); }); for (let row = 1; row < sideLines.length; row += 1) { const lower = sideLines[row - 1]; const upper = sideLines[row]; for (let col = 1; col < upper.points.length; col += 1) { const normal = upwardNormalAt(lower.points, col); const targetX = lower.points[col].x + normal.x * BOOK_PROFILE.bundleSpacing; const targetY = lower.points[col].y + normal.y * BOOK_PROFILE.bundleSpacing; upper.points[col].x = THREE.MathUtils.lerp(upper.points[col].x, targetX, 0.28); upper.points[col].y = Math.max(upper.points[col].y, THREE.MathUtils.lerp(upper.points[col].y, targetY, 0.42)); } upper.points[0].x = upper.anchor.x; upper.points[0].y = upper.anchor.y; applyBendingResistance(upper.points, 0.2); enforceLineLength(upper.points, upper.anchor, stepLength, 3); keepPageAboveCover(upper.points, side, bundleCount); } } sideLines.forEach((line) => { applyBendingResistance(line.points, 0.32); enforceLineLength(line.points, line.anchor, stepLength, 10); keepPageAboveCover(line.points, side, bundleCount); enforceLineLength(line.points, line.anchor, stepLength, 6); }); sideLines.forEach((line) => { line.endpoint = line.points[line.points.length - 1]; }); }); } function offsetPageLine(basePoints, anchor, distance) { return basePoints.map((point, index) => { if (index === 0) return { x: anchor.x, y: anchor.y }; const normal = upwardNormalAt(basePoints, index); return { x: point.x + normal.x * distance, y: point.y + normal.y * distance }; }); } function upwardNormalAt(points, index) { const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)]; const dx = next.x - previous.x; const dy = next.y - previous.y; const length = Math.hypot(dx, dy) || 0.0001; let nx = -dy / length; let ny = dx / length; if (ny < 0) { nx = -nx; ny = -ny; } return { x: nx, y: ny }; } function coverTopYAtX(x) { const ax = Math.abs(x); const spineHalf = currentSpineHalf(); const hingeX = spineHalf + 0.07; const outerX = 1.62 + 0.055; if (ax <= spineHalf) return BOOK_PROFILE.coverThickness; if (ax <= hingeX) { const t = (ax - spineHalf) / (hingeX - spineHalf); return THREE.MathUtils.lerp(BOOK_PROFILE.coverThickness, BOOK_PROFILE.raisedHingeY, t); } const t = THREE.MathUtils.clamp((ax - hingeX) / (outerX - hingeX), 0, 1); return THREE.MathUtils.lerp(BOOK_PROFILE.raisedHingeY, BOOK_PROFILE.coverThickness, t); } function currentSpineHalf() { return Math.max(0.16, Math.round(pageCount / 10) * BOOK_PROFILE.bundleSpacing) * 0.5; } function addSimulatedPageLines(lines, depth) { const leftMaterial = new THREE.LineBasicMaterial({ color: 0x8f7750, transparent: true, opacity: 0.72 }); const rightMaterial = new THREE.LineBasicMaterial({ color: 0x9a8058, transparent: true, opacity: 0.72 }); const z = depth * 0.5 + 0.006; lines.forEach((line) => { const points = smoothLinePoints(line.points, 4).map((point) => new THREE.Vector3(point.x, point.y, z)); book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), line.side < 0 ? leftMaterial : rightMaterial)); }); } function addSimulatedStackBodies(lines, depth) { [-1, 1].forEach((side) => { const sideLines = lines.filter((line) => line.side === side); if (sideLines.length < 2) return; const material = side < 0 ? materials.pagesLeft : materials.pagesRight; book.add(new THREE.Mesh(createLoftedLineBody(sideLines, depth), material)); book.add(new THREE.Line(createEndpointPolyline(sideLines, depth), new THREE.LineBasicMaterial({ color: 0xb99a68, transparent: true, opacity: 0.62 }))); }); } function createLoftedLineBody(lines, depth) { const positions = []; const indices = []; const smoothLines = lines.map((line) => smoothLinePoints(line.points, 4)); const push = (point, z) => { const index = positions.length / 3; positions.push(point.x, point.y, z); return index; }; const front = smoothLines.map((points) => points.map((point) => push(point, depth * 0.5))); const back = smoothLines.map((points) => points.map((point) => push(point, -depth * 0.5))); for (let row = 0; row < smoothLines.length - 1; row += 1) { for (let col = 0; col < smoothLines[row].length - 1; col += 1) { indices.push(front[row][col], front[row + 1][col], front[row][col + 1]); indices.push(front[row][col + 1], front[row + 1][col], front[row + 1][col + 1]); indices.push(back[row][col], back[row][col + 1], back[row + 1][col]); indices.push(back[row][col + 1], back[row + 1][col + 1], back[row + 1][col]); } } for (let row = 0; row < smoothLines.length - 1; row += 1) { const last = smoothLines[row].length - 1; indices.push(front[row][last], front[row + 1][last], back[row][last]); indices.push(front[row + 1][last], back[row + 1][last], back[row][last]); } for (let col = 0; col < smoothLines[0].length - 1; col += 1) { const bottomRow = 0; const topRow = smoothLines.length - 1; indices.push(front[bottomRow][col], front[bottomRow][col + 1], back[bottomRow][col]); indices.push(front[bottomRow][col + 1], back[bottomRow][col + 1], back[bottomRow][col]); indices.push(front[topRow][col], back[topRow][col], front[topRow][col + 1]); indices.push(front[topRow][col + 1], back[topRow][col], back[topRow][col + 1]); } for (let row = 0; row < smoothLines.length - 1; row += 1) { indices.push(front[row][0], back[row][0], front[row + 1][0]); indices.push(front[row + 1][0], back[row][0], back[row + 1][0]); } const geometry = new THREE.BufferGeometry(); geometry.setIndex(indices); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); return geometry; } function createEndpointPolyline(lines, depth) { const points = lines.map((line) => new THREE.Vector3(line.endpoint.x, line.endpoint.y, depth * 0.5 + 0.008)); return new THREE.BufferGeometry().setFromPoints(points); } function smoothLinePoints(points, subdivisions) { const result = []; for (let i = 0; i < points.length - 1; i += 1) { const p0 = points[Math.max(0, i - 1)]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[Math.min(points.length - 1, i + 2)]; for (let step = 0; step < subdivisions; step += 1) { const t = step / subdivisions; result.push(catmullRomPoint(p0, p1, p2, p3, t)); } } result.push(points[points.length - 1]); return result; } function catmullRomPoint(p0, p1, p2, p3, t) { const tt = t * t; const ttt = tt * t; return { x: 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * tt + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * ttt), y: 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * tt + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * ttt) }; } 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); }