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 fastBackwardButton = document.getElementById('fast_backward'); const flipBackwardButton = document.getElementById('flip_backward'); const flipForwardButton = document.getElementById('flip_forward'); const fastForwardButton = document.getElementById('fast_forward'); const flipCountValue = document.getElementById('flip_count'); 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 GRID_Y = -0.12; 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 = GRID_Y; 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 }), flippingPage: new THREE.MeshBasicMaterial({ color: 0xf3dfb6, 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, singlePageCoverGap: 0.006, bundleSpacing: 0.014 }; book.position.y = GRID_Y - BOOK_PROFILE.tableY; const NORMAL_FLIP_DURATION = 1800; const FAST_FLIP_DURATION = 900; const FAST_FLIP_COUNT = 10; const FAST_FLIP_OVERLAP = 5; const OPEN_SEAM_GAP = 0.003; const PAGE_COUNT_MIN = 40; const PAGE_COUNT_STEP = 10; const PAGE_WIDTH = 1.62; const PAGE_SPLINE_LENGTH = 1.955; const PAGE_LINE_SEGMENTS = 24; const PAGE_DEPTH = 2.24; const COVER_DEPTH = 2.30; const COVER_OVERHANG = (COVER_DEPTH - PAGE_DEPTH) * 0.5; const COVER_SUPPORT_OVERHANG = COVER_OVERHANG; const SUPPORT_ANGLE_STEPS = 720; const SUPPORT_ANGLE_CANDIDATES = Array.from({ length: SUPPORT_ANGLE_STEPS }, (_, sample) => { const angle = sample / SUPPORT_ANGLE_STEPS * Math.PI * 2; return { angle, cos: Math.cos(angle), sin: Math.sin(angle) }; }); const maximumPageCount = calculateMaximumPageCount(); let readingProgress = readInitialProgress(); let pageCount = readInitialPageCount(); let lastLengthError = 0; let lastSpacingError = 0; let lastBookModel = null; let activeSpineHalf = 0.08; let activeCoverOuterX = activeSpineHalf + PAGE_WIDTH + COVER_OVERHANG; let activeFlips = []; let pendingPageFlips = 0; pageCountInput.max = String(maximumPageCount); progressInput.value = readingProgress.toFixed(3); progressValue.value = readingProgress.toFixed(2); pageCountInput.value = String(pageCount); pageCountValue.value = String(pageCount); updateFlipControls(); rebuildBook(); resize(); animate(); progressInput.addEventListener('input', () => { setReadingProgress(progressInput.value); }); pageCountInput.addEventListener('input', () => { setPageCount(pageCountInput.value); }); fastBackwardButton.addEventListener('click', () => { startFastPageFlip(-1); }); flipBackwardButton.addEventListener('click', () => { startPageFlip(-1); }); flipForwardButton.addEventListener('click', () => { startPageFlip(1); }); fastForwardButton.addEventListener('click', () => { startFastPageFlip(1); }); window.addEventListener('resize', resize); window.BookShapeLab = { get progress() { return readingProgress; }, get pageCount() { return pageCount; }, get lastLengthError() { return lastLengthError; }, get lastSpacingError() { return lastSpacingError; }, get pageMetrics() { return visiblePageMetrics(); }, get frameMetrics() { if (!lastBookModel) return null; return { spineHalf: spineArcHalf(lastBookModel.spineWidth), hingeWidth: hingeInset(), hingeHeight: BOOK_PROFILE.raisedHingeY - BOOK_PROFILE.coverThickness, pageWidth: lastBookModel.pageWidth, pageDepth: lastBookModel.pageDepth, coverDepth: lastBookModel.coverDepth, coverOuterX: lastBookModel.coverOuterX, solvedStackOuterX: solvedStackOuterX(lastBookModel.lines), coverWidthOverhang: COVER_OVERHANG, actualCoverWidthOverhang: lastBookModel.coverOuterX - solvedStackOuterX(lastBookModel.lines), coverDepthOverhang: (lastBookModel.coverDepth - lastBookModel.pageDepth) * 0.5 }; }, setReadingProgress(value) { setReadingProgress(value); return readingProgress; }, setPageCount(value) { setPageCount(value); return pageCount; }, flipForward() { return startPageFlip(1); }, flipBackward() { return startPageFlip(-1); }, fastForward() { return startFastPageFlip(1); }, fastBackward() { return startFastPageFlip(-1); } }; function visiblePageMetrics() { if (!lastBookModel) return null; const result = {}; [-1, 1].forEach((side) => { const line = topVisibleLine(side); if (!line) return; let pathLength = 0; for (let index = 0; index < line.points.length - 1; index += 1) { pathLength += Math.hypot(line.points[index + 1].x - line.points[index].x, line.points[index + 1].y - line.points[index].y); } result[side < 0 ? 'left' : 'right'] = { anchorX: line.anchor.x, anchorY: line.anchor.y, endpointX: line.endpoint.x, endpointY: line.endpoint.y, xSpan: Math.abs(line.endpoint.x - line.anchor.x), ySpan: Math.abs(line.endpoint.y - line.anchor.y), pathLength }; }); return result; } 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 / PAGE_COUNT_STEP) * PAGE_COUNT_STEP, PAGE_COUNT_MIN, maximumPageCount); } function setReadingProgress(value) { const next = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1); if (!Number.isFinite(next)) return; clearActiveFlip(); readingProgress = next; progressInput.value = readingProgress.toFixed(3); progressValue.value = readingProgress.toFixed(2); rebuildBook(); updateFlipControls(); } function setPageCount(value) { const next = snapPageCount(Number.parseFloat(value)); if (!Number.isFinite(next)) return; clearActiveFlip(); pendingPageFlips = 0; pageCount = next; pageCountInput.value = String(pageCount); pageCountValue.value = String(pageCount); rebuildBook(); updateFlipControls(); } function rebuildBook() { clearGroup(book); const coverDepth = COVER_DEPTH; const coverThickness = BOOK_PROFILE.coverThickness; const pageWidth = PAGE_WIDTH; const pageSplineLength = PAGE_SPLINE_LENGTH; const pageDepth = PAGE_DEPTH; const bundleCount = Math.max(4, Math.round(pageCount / 10)); const spineWidth = calculateSpineWidth(bundleCount); const leftCount = calculateLeftBundleCount(bundleCount); const spineHalf = spineArcHalf(spineWidth); const hingeX = spineHalf + hingeInset(); const foreEdgeX = spineHalf + pageWidth; const bundleSpacing = calculateBundleSpacing(bundleCount, spineWidth, leftCount); activeSpineHalf = spineHalf; activeCoverOuterX = spineHalf + pageWidth + COVER_OVERHANG; const lines = simulatePageLines(bundleCount, pageWidth, pageSplineLength, spineWidth, foreEdgeX, bundleSpacing, leftCount); const coverOuterX = solvedStackOuterX(lines) + COVER_OVERHANG; activeCoverOuterX = coverOuterX; lastLengthError = measureLineLengthError(lines, pageSplineLength); lastSpacingError = measureStackSpacingError(lines, bundleSpacing); lastBookModel = { coverDepth, pageWidth, pageSplineLength, pageDepth, bundleCount, spineWidth, hingeX, foreEdgeX, coverOuterX, bundleSpacing, lines }; addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth, coverOuterX); addClothSpine(pageDepth, spineWidth); addSimulatedStackBodies(lines, pageDepth); updateFlipControls(); } 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, coverOuterX) { const cover = new THREE.Mesh(createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth, coverOuterX), materials.cover); book.add(cover); } function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth, coverOuterX) { const section = coverProfilePoints(spineWidth, coverOuterX ?? spineArcHalf(spineWidth) + pageWidth + COVER_OVERHANG); 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.024)); back.push(push(point, -depth * 0.5 - 0.024)); }); 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 = spineArcHalf(spineWidth); 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 spineArcHalf(spineWidth) { return spineWidth * 0.42; } function hingeInset() { return Math.max(0.001, BOOK_PROFILE.raisedHingeY - BOOK_PROFILE.coverThickness); } function coverProfilePoints(spineWidth, coverOuterX) { return coverProfilePointsFromFrame(spineArcHalf(spineWidth), coverOuterX); } function coverProfilePointsFromFrame(spineHalf, coverOuterX) { const hingeX = spineHalf + hingeInset(); const outerTopY = BOOK_PROFILE.tableY + BOOK_PROFILE.coverThickness; const connectionTopY = BOOK_PROFILE.raisedHingeY; const spineTopY = BOOK_PROFILE.tableY + BOOK_PROFILE.coverThickness; return [ { x: -coverOuterX, y: outerTopY }, { x: -hingeX, y: connectionTopY }, { x: -spineHalf, y: spineTopY }, { x: spineHalf, y: spineTopY }, { x: hingeX, y: connectionTopY }, { x: coverOuterX, y: outerTopY } ]; } function calculateSpineWidth(bundleCount) { const minimumWidth = 0.16; if (bundleCount <= 1) return minimumWidth; const targetArcLength = (bundleCount - 1) * BOOK_PROFILE.bundleSpacing + OPEN_SEAM_GAP; let low = minimumWidth; let high = Math.max(minimumWidth, bundleCount * BOOK_PROFILE.bundleSpacing * 1.4); while (measureSpineArcLength(high) < targetArcLength) { high *= 1.25; } for (let i = 0; i < 24; i += 1) { const mid = (low + high) * 0.5; if (measureSpineArcLength(mid) < targetArcLength) { low = mid; } else { high = mid; } } return high; } function calculateMaximumPageCount() { let maximum = PAGE_COUNT_MIN; for (let candidate = PAGE_COUNT_MIN; candidate <= 1000; candidate += PAGE_COUNT_STEP) { const bundleCount = Math.max(4, Math.round(candidate / 10)); if (!isBundleCountReachable(bundleCount)) break; maximum = candidate; } return maximum; } function isBundleCountReachable(bundleCount) { const spineWidth = calculateSpineWidth(bundleCount); const bundleSpacing = calculateBundleSpacing(bundleCount, spineWidth, bundleCount); const foreEdgeX = spineWidth * 0.5 + PAGE_WIDTH; const target = restingTarget(1, foreEdgeX, bundleCount - 1, bundleCount, bundleSpacing); const anchor = spineCurvePoint(1, spineWidth); const chordLength = Math.hypot(target.x - anchor.x, target.y - anchor.y); const solverSlack = PAGE_SPLINE_LENGTH - chordLength; const minimumSlack = PAGE_SPLINE_LENGTH / (PAGE_LINE_SEGMENTS + 1); return solverSlack >= minimumSlack; } function calculateBundleSpacing(bundleCount, spineWidth, leftCount) { const rightCount = bundleCount - leftCount; const stackIntervals = Math.max(0, leftCount - 1) + Math.max(0, rightCount - 1); if (stackIntervals <= 0) return BOOK_PROFILE.bundleSpacing; return Math.max(0.001, (measureSpineArcLength(spineWidth) - OPEN_SEAM_GAP) / stackIntervals); } function measureSpineArcLength(spineWidth) { const steps = 240; let length = 0; let previous = spineCurvePoint(0, spineWidth); for (let i = 1; i <= steps; i += 1) { const point = spineCurvePoint(i / steps, spineWidth); length += Math.hypot(point.x - previous.x, point.y - previous.y); previous = point; } return length; } function calculateLeftBundleCount(bundleCount) { return THREE.MathUtils.clamp(Math.round(bundleCount * readingProgress), 0, bundleCount); } function solvedStackOuterX(lines) { return lines.reduce((outer, line) => { return line.points.reduce((lineOuter, point) => Math.max(lineOuter, Math.abs(point.x)), outer); }, 0); } function simulatePageLines(bundleCount, pageWidth, pageSplineLength, spineWidth, foreEdgeX, bundleSpacing, leftCount) { const lines = []; const segments = PAGE_LINE_SEGMENTS; const stepLength = pageSplineLength / segments; const entries = []; const spineArc = buildSpineArcSamples(spineWidth); const rightCount = bundleCount - leftCount; const leftSpan = Math.max(0, leftCount - 1) * bundleSpacing; const rightSpan = Math.max(0, rightCount - 1) * bundleSpacing; const seamLeftLength = leftSpan; const seamRightLength = seamLeftLength + OPEN_SEAM_GAP; for (let index = 0; index < bundleCount; index += 1) { const side = index < leftCount ? -1 : 1; const sideRank = side < 0 ? index : index - leftCount; const arcLength = side < 0 ? seamLeftLength - (leftCount - 1 - sideRank) * bundleSpacing : seamRightLength + sideRank * bundleSpacing; const point = pointAtSpineArcLength(spineArc, arcLength); entries.push({ index, t: point.t, side }); } if (leftCount === 0) { const point = pointAtSpineArcLength(spineArc, seamLeftLength); entries.push({ index: -1, t: point.t, side: -1, isHairPage: true }); } if (rightCount === 0) { const point = pointAtSpineArcLength(spineArc, seamRightLength); entries.push({ index: bundleCount, t: point.t, side: 1, isHairPage: true }); } [-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, foreEdgeX, rank, sideEntries.length, bundleSpacing); const points = buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount, bundleSpacing); const line = { index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1], isHairPage: entry.isHairPage === true }; 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, bundleSpacing) { let maxViolation = 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 closest = closestPointOnPolyline(upper.points[col], lower.points); const distance = Math.hypot(upper.points[col].x - closest.x, upper.points[col].y - closest.y); maxViolation = Math.max(maxViolation, Math.max(0, bundleSpacing - distance)); } } }); return maxViolation; } function buildSpineArcSamples(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; } return { samples, length, spineWidth }; } function pointAtSpineArcLength(spineArc, targetLength) { const target = THREE.MathUtils.clamp(targetLength, 0, spineArc.length); let low = 0; let high = spineArc.samples.length - 1; while (low < high) { const mid = Math.floor((low + high) * 0.5); if (spineArc.samples[mid].length < target) { low = mid + 1; } else { high = mid; } } if (low <= 0) return spineArc.samples[0].point; const before = spineArc.samples[low - 1]; const after = spineArc.samples[low]; const span = after.length - before.length || 1; const t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span); return spineCurvePoint(t, spineArc.spineWidth); } 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, foreEdgeX, rank, sideCount, bundleSpacing) { const local = sideCount <= 1 ? 0 : rank / (sideCount - 1); const foreCurve = 0.11 * Math.sin(Math.PI * local); const x = side * (foreEdgeX - foreCurve); const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * bundleSpacing + 0.002 * Math.sin(Math.PI * local); return { x, y }; } function buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount, bundleSpacing) { const points = [{ x: anchor.x, y: anchor.y }]; let tangent = coverTangentAtX(anchor.x, side); for (let index = 1; index <= segments; index += 1) { const u = index / segments; const supportTangent = lowerLine ? lineTangentAt(lowerLine.points, index) : coverTangentAtX(points[index - 1].x, side); const point = chooseClosestSupportedPoint(points[index - 1], tangent, supportTangent, target, lowerLine, index, side, stepLength, bundleCount, bundleSpacing, u); points.push(point); tangent = normalizedVector(point.x - points[index - 1].x, point.y - points[index - 1].y); } return points; } function chooseClosestSupportedPoint(previous, tangent, supportTangent, target, lowerLine, index, side, stepLength, bundleCount, bundleSpacing, u) { const blendTangent = normalizedVector(tangent.x + supportTangent.x * 2, tangent.y + supportTangent.y * 2); const angleHint = Math.atan2(blendTangent.y, blendTangent.x); let best = null; for (const sample of SUPPORT_ANGLE_CANDIDATES) { const candidate = { x: previous.x + sample.cos * stepLength, y: previous.y + sample.sin * stepLength }; const score = scoreSupportedPoint(candidate, previous, tangent, supportTangent, sample.angle, angleHint, target, lowerLine, index, side, bundleCount, bundleSpacing, u); if (best === null || score < best.score) best = { point: candidate, score }; } if (Number.isFinite(best?.score)) return best.point; let fallback = null; for (const sample of SUPPORT_ANGLE_CANDIDATES) { const candidate = { x: previous.x + sample.cos * stepLength, y: previous.y + sample.sin * stepLength }; const fallbackScore = scoreSupportedPoint(candidate, previous, tangent, supportTangent, sample.angle, angleHint, target, lowerLine, index, side, bundleCount, bundleSpacing, u, true); if (fallback === null || fallbackScore < fallback.score) fallback = { point: candidate, score: fallbackScore }; } return fallback.point; } function scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle, angleHint, target, lowerLine, index, side, bundleCount, bundleSpacing, u, allowViolation = false) { const backward = Math.max(0, side * (previous.x - candidate.x)); if (!allowViolation && backward > 0.00001) return Number.POSITIVE_INFINITY; let supportError; let supportViolation = 0; if (lowerLine) { const closest = closestPointOnPolyline(candidate, lowerLine.points); const closestDistance = Math.hypot(candidate.x - closest.x, candidate.y - closest.y); supportViolation = Math.max(0, bundleSpacing - closestDistance) + Math.max(0, closest.y - candidate.y); if (!allowViolation && supportViolation > 0.00001) return Number.POSITIVE_INFINITY; supportError = closestDistance - bundleSpacing; } else { const floor = coverTopYAtX(candidate.x) + coverClearance(bundleCount); supportViolation = Math.max(0, floor - candidate.y); if (!allowViolation && supportViolation > 0.00001) return Number.POSITIVE_INFINITY; supportError = candidate.y - floor; } const candidateTangent = normalizedVector(candidate.x - previous.x, candidate.y - previous.y); const bend = 1 - Math.max(-1, Math.min(1, candidateTangent.x * tangent.x + candidateTangent.y * tangent.y)); const supportAlignment = 1 - Math.max(-1, Math.min(1, candidateTangent.x * supportTangent.x + candidateTangent.y * supportTangent.y)); const angleDelta = Math.abs(Math.atan2(Math.sin(angle - angleHint), Math.cos(angle - angleHint))); const outwardTarget = Math.max(0, side * (target.x - candidate.x)); const targetHeight = Math.abs(candidate.y - target.y); return Math.abs(supportError) * 1200 + supportViolation * 100000 + backward * 100000 + supportAlignment * 0.85 + bend * 0.22 + angleDelta * 0.04 + outwardTarget * 0.01 + targetHeight * 0.006; } 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 coverTangentAtX(x, side) { const delta = 0.002; const y0 = coverTopYAtX(x - delta); const y1 = coverTopYAtX(x + delta); return normalizedVector(side * delta * 2, y1 - y0); } function lineTangentAt(points, index) { const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)]; return normalizedVector(next.x - previous.x, next.y - previous.y); } function normalizedVector(x, y) { const length = Math.hypot(x, y) || 0.0001; return { x: x / length, y: y / length }; } 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 bundleSpacing = lastBookModel?.bundleSpacing ?? BOOK_PROFILE.bundleSpacing; 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 * bundleSpacing; const targetY = lower.points[col].y + normal.y * 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 profile = coverProfilePointsFromFrame(currentSpineHalf(), activeCoverOuterX) .filter((point) => point.x >= 0) .sort((a, b) => a.x - b.x); if (ax <= profile[0].x) return profile[0].y; for (let index = 0; index < profile.length - 1; index += 1) { const from = profile[index]; const to = profile[index + 1]; if (ax <= to.x) { const t = (ax - from.x) / (to.x - from.x || 1); return THREE.MathUtils.lerp(from.y, to.y, t); } } return profile[profile.length - 1].y; } function currentSpineHalf() { return activeSpineHalf; } function addSimulatedStackBodies(lines, depth) { [-1, 1].forEach((side) => { const sideLines = lines.filter((line) => line.side === side); if (!sideLines.length) return; const bodyLines = sideLines.length === 1 ? createSinglePageBodyLines(sideLines[0]) : sideLines; const stackMaterials = createStackBodyMaterials(bodyLines, side); book.add(new THREE.Mesh(createLoftedLineBody(bodyLines, depth), stackMaterials)); }); } function createSinglePageBodyLines(line) { const bundleCount = Math.max(4, Math.round(pageCount / 10)); const bundleSpacing = lastBookModel?.bundleSpacing ?? BOOK_PROFILE.bundleSpacing; const supportPoints = line.points.map((point) => ({ x: point.x, y: Math.max(coverTopYAtX(point.x) + coverClearance(bundleCount) + BOOK_PROFILE.singlePageCoverGap, point.y - bundleSpacing) })); return [ { ...line, points: supportPoints, endpoint: supportPoints[supportPoints.length - 1] }, line ]; } function createStackBodyMaterials(lines, side) { const baseColor = side < 0 ? '#d8c7a4' : '#e7d6b4'; const lineColor = '#9a8058'; const layerTexture = createStackLayerTexture(lastBookModel.bundleCount, baseColor, lineColor); return [ new THREE.MeshBasicMaterial({ map: layerTexture, side: THREE.DoubleSide }), new THREE.MeshBasicMaterial({ map: layerTexture, side: THREE.DoubleSide }), new THREE.MeshBasicMaterial({ color: baseColor, side: THREE.DoubleSide }) ]; } function createStackLayerTexture(bundleCount, baseColor, lineColor) { const canvas = document.createElement('canvas'); canvas.width = 2048; canvas.height = 1024; const context = canvas.getContext('2d'); context.fillStyle = baseColor; context.fillRect(0, 0, canvas.width, canvas.height); context.strokeStyle = lineColor; context.globalAlpha = 0.95; context.lineWidth = 4.2; context.lineCap = 'square'; for (let row = 0; row < bundleCount; row += 1) { const v = bundleCount <= 1 ? 0.5 : row / (bundleCount - 1); const y = (1 - v) * canvas.height; context.beginPath(); context.moveTo(-8, y); context.lineTo(canvas.width + 8, y); context.stroke(); } return createCanvasTexture(canvas); } function createCanvasTexture(canvas) { const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy()); texture.needsUpdate = true; return texture; } function createLoftedLineBody(lines, depth) { const positions = []; const uvs = []; const indices = []; const smoothLines = lines.map((line) => line.points); const bundleCount = lastBookModel?.bundleCount ?? smoothLines.length; const push = (point, z, uv) => { const index = positions.length / 3; positions.push(point.x, point.y, z); uvs.push(uv.u, uv.v); return index; }; const rowUv = (row) => { const line = lines[row]; const index = line.isHairPage ? (line.side < 0 ? 0 : bundleCount - 1) : line.index; return bundleCount <= 1 ? 0.5 : index / (bundleCount - 1); }; const colUv = (points, col) => ( points.length <= 1 ? 0.5 : col / (points.length - 1) ); const lineUv = (row, col) => ({ u: colUv(smoothLines[row], col), v: rowUv(row) }); const backLineUv = (row, col) => ({ u: colUv(smoothLines[row], col), v: rowUv(row) }); const sideUv = (row, z) => ({ u: (z + depth * 0.5) / depth, v: rowUv(row) }); const front = smoothLines.map((points, row) => points.map((point, col) => push(point, depth * 0.5, lineUv(row, col)))); const back = smoothLines.map((points, row) => points.map((point, col) => push(point, -depth * 0.5, backLineUv(row, col)))); 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]); } } const sideStart = indices.length; for (let row = 0; row < smoothLines.length - 1; row += 1) { const last = smoothLines[row].length - 1; const a = smoothLines[row][last]; const b = smoothLines[row + 1][last]; const frontA = push(a, depth * 0.5, sideUv(row, depth * 0.5)); const frontB = push(b, depth * 0.5, sideUv(row + 1, depth * 0.5)); const backA = push(a, -depth * 0.5, sideUv(row, -depth * 0.5)); const backB = push(b, -depth * 0.5, sideUv(row + 1, -depth * 0.5)); indices.push(frontA, frontB, backA); indices.push(frontB, backB, backA); } const bottomStart = indices.length; for (let col = 0; col < smoothLines[0].length - 1; col += 1) { indices.push(front[0][col], front[0][col + 1], back[0][col]); indices.push(front[0][col + 1], back[0][col + 1], back[0][col]); } const topStart = indices.length; for (let col = 0; col < smoothLines[0].length - 1; col += 1) { const topRow = smoothLines.length - 1; 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]); } 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.clearGroups(); geometry.addGroup(0, sideStart, 0); geometry.addGroup(sideStart, bottomStart - sideStart, 1); geometry.addGroup(bottomStart, topStart - bottomStart, 2); geometry.addGroup(topStart, indices.length - topStart, 2); geometry.computeVertexNormals(); return geometry; } function startPageFlip(direction) { if (activeFlips.length || !lastBookModel || !canPageFlip(direction)) return false; const flip = createPageFlip(direction, performance.now(), NORMAL_FLIP_DURATION); if (!flip) return false; activeFlips.push(flip); updateFlipControls(); updateActiveFlips(flip.startTime); return true; } function startFastPageFlip(direction) { if (activeFlips.length || !lastBookModel || !canPageFlip(direction)) return false; const firstFlip = createPageFlip(direction, performance.now(), FAST_FLIP_DURATION); if (!firstFlip) return false; const startTime = firstFlip.startTime; const interval = FAST_FLIP_DURATION / FAST_FLIP_OVERLAP; for (let index = 0; index < FAST_FLIP_COUNT; index += 1) { activeFlips.push({ ...firstFlip, mesh: null, startTime: startTime + index * interval, pageOffset: index * 0.002, commitBundleOnFinish: index === FAST_FLIP_COUNT - 1, countAsPending: false }); } updateFlipControls(); updateActiveFlips(startTime); return true; } function createPageFlip(direction, startTime, duration) { const sourceSide = direction > 0 ? 1 : -1; const sourceLine = topVisibleLine(sourceSide); const destinationLine = topVisibleLine(-sourceSide); if (!sourceLine || !destinationLine) return null; return { direction, sourceLine, destinationLine, startTime, duration, pageOffset: 0, commitBundleOnFinish: false, countAsPending: true, mesh: null }; } function canPageFlip(direction) { if (!lastBookModel) return false; if (direction > 0) return readingProgress < 1; return readingProgress > 0; } function topVisibleLine(side) { const sideLines = lastBookModel.lines .filter((line) => line.side === side) .sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t); return sideLines[sideLines.length - 1] ?? null; } function updateActiveFlips(now) { if (!activeFlips.length || !lastBookModel) return; const completed = []; activeFlips.forEach((flip) => { const elapsed = (now - flip.startTime) / flip.duration; if (elapsed < 0) return; const t = THREE.MathUtils.clamp(elapsed, 0, 1); const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset); setActivePageGeometry(flip, surface); if (t >= 1) completed.push(flip); }); completed.forEach((flip) => { finishActiveFlip(flip); }); } function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) { const widthSegments = sourceLine.points.length - 1; const depthSegments = 18; const zFront = lastBookModel.pageDepth * 0.5; const zBack = -lastBookModel.pageDepth * 0.5; if (t <= 0) return createRestingPageSurface(sourceLine.points, depthSegments, zFront, zBack); if (t >= 1) return createRestingPageSurface(destinationLine.points, depthSegments, zFront, zBack); const anchorT = THREE.MathUtils.lerp(sourceLine.t, destinationLine.t, t); const anchor = spineCurvePoint(anchorT, lastBookModel.spineWidth); const sourceSide = direction > 0 ? 1 : -1; const startAngle = sourceSide > 0 ? 0 : Math.PI; const baseAngle = startAngle + direction * Math.PI * t; const lift = Math.sin(Math.PI * t); const curlStrength = direction * 0.48 * lift; const sourceAnchor = sourceLine.anchor; const surface = []; for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) { const u = widthIndex / widthSegments; const sourcePoint = sourceLine.points[widthIndex]; const radius = Math.max(0, sourceSide * (sourcePoint.x - sourceAnchor.x)); const row = []; for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) { const v = depthIndex / depthSegments; const z = THREE.MathUtils.lerp(zFront, zBack, v); const depthWave = (v - 0.5) * 0.22 * lift * (0.15 + u * 0.85); const curl = curlStrength * Math.sin(Math.PI * u) + direction * depthWave; const angle = baseAngle + curl; const stackPoint = interpolatePagePoint(sourceLine.points, destinationLine.points, widthIndex, t); const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift); const point = { x: anchor.x + Math.cos(angle) * radius, y: relaxedY + pageOffset + 0.055 * lift * Math.sin(Math.PI * u), z }; keepFlippingSurfacePointAboveStacks(point, lift); row.push(point); } surface.push(row); } return surface; } function createRestingPageSurface(points, depthSegments, zFront, zBack) { return points.map((point) => { const row = []; for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) { row.push({ x: point.x, y: point.y, z: THREE.MathUtils.lerp(zFront, zBack, depthIndex / depthSegments) }); } return row; }); } function interpolatePagePoint(sourcePoints, destinationPoints, index, t) { const source = sourcePoints[index]; const destination = destinationPoints[index]; return { x: THREE.MathUtils.lerp(source.x, destination.x, t), y: THREE.MathUtils.lerp(source.y, destination.y, t) }; } function keepFlippingSurfacePointAboveStacks(point, lift) { const envelopeY = stackEnvelopeYAtX(point.x); if (envelopeY === null) return; const clearance = 0.016 + lift * 0.045; point.y = Math.max(point.y, envelopeY + clearance); } function keepFlippingPageAboveStacks(points, lift) { for (let index = 1; index < points.length; index += 1) { const u = index / (points.length - 1); const envelopeY = stackEnvelopeYAtX(points[index].x); if (envelopeY === null) continue; const clearance = 0.018 + lift * (0.05 + 0.05 * Math.sin(Math.PI * u)); points[index].y = Math.max(points[index].y, envelopeY + clearance); } } function stackEnvelopeYAtX(x) { let envelope = null; lastBookModel.lines.forEach((line) => { const y = lineYAtX(line.points, x); if (y === null) return; envelope = envelope === null ? y : Math.max(envelope, y); }); return envelope; } function lineYAtX(points, x) { let y = null; for (let index = 0; index < points.length - 1; index += 1) { const a = points[index]; const b = points[index + 1]; const minX = Math.min(a.x, b.x) - 0.00001; const maxX = Math.max(a.x, b.x) + 0.00001; if (x < minX || x > maxX) continue; const span = b.x - a.x; const segmentY = Math.abs(span) < 0.00001 ? Math.max(a.y, b.y) : THREE.MathUtils.lerp(a.y, b.y, (x - a.x) / span); y = y === null ? segmentY : Math.max(y, segmentY); } return y; } function setActivePageGeometry(flip, surface) { const geometry = createFlippingPageGeometry(surface); if (!flip.mesh) { flip.mesh = new THREE.Mesh(geometry, materials.flippingPage); book.add(flip.mesh); return; } flip.mesh.geometry.dispose(); flip.mesh.geometry = geometry; } function createFlippingPageGeometry(surface) { const positions = []; const indices = []; const topGrid = []; const bottomGrid = []; const pageThickness = 0.006; const widthSegments = surface.length - 1; const depthSegments = surface[0].length - 1; const push = (point, yOffset) => { const index = positions.length / 3; positions.push(point.x, point.y + yOffset, point.z); return index; }; surface.forEach((rowPoints) => { const topRow = []; const bottomRow = []; rowPoints.forEach((point) => { topRow.push(push(point, pageThickness)); bottomRow.push(push(point, 0)); }); topGrid.push(topRow); bottomGrid.push(bottomRow); }); for (let index = 0; index < widthSegments; index += 1) { for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) { const a = topGrid[index][zIndex]; const b = topGrid[index + 1][zIndex]; const c = topGrid[index][zIndex + 1]; const d = topGrid[index + 1][zIndex + 1]; const bottomA = bottomGrid[index][zIndex]; const bottomB = bottomGrid[index + 1][zIndex]; const bottomC = bottomGrid[index][zIndex + 1]; const bottomD = bottomGrid[index + 1][zIndex + 1]; indices.push(a, c, b); indices.push(b, c, d); indices.push(bottomA, bottomB, bottomC); indices.push(bottomB, bottomD, bottomC); } } for (let index = 0; index < widthSegments; index += 1) { addWall(topGrid[index][0], topGrid[index + 1][0], bottomGrid[index][0], bottomGrid[index + 1][0]); addWall(topGrid[index][depthSegments], topGrid[index + 1][depthSegments], bottomGrid[index][depthSegments], bottomGrid[index + 1][depthSegments]); } for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) { addWall(topGrid[0][zIndex], topGrid[0][zIndex + 1], bottomGrid[0][zIndex], bottomGrid[0][zIndex + 1]); addWall(topGrid[widthSegments][zIndex], topGrid[widthSegments][zIndex + 1], bottomGrid[widthSegments][zIndex], bottomGrid[widthSegments][zIndex + 1]); } const geometry = new THREE.BufferGeometry(); geometry.setIndex(indices); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.computeVertexNormals(); return geometry; function addWall(topA, topB, bottomA, bottomB) { indices.push(topA, bottomA, topB); indices.push(topB, bottomA, bottomB); } } function finishActiveFlip(flip) { removeFlipMesh(flip); activeFlips = activeFlips.filter((active) => active !== flip); if (flip.commitBundleOnFinish) { shiftReadingProgressByBundle(flip.direction); return; } if (!flip.countAsPending) { updateFlipControls(); return; } pendingPageFlips += flip.direction; if (Math.abs(pendingPageFlips) >= 10) { const commitDirection = Math.sign(pendingPageFlips); pendingPageFlips -= commitDirection * 10; shiftReadingProgressByBundle(commitDirection); return; } updateFlipControls(); } function shiftReadingProgressByBundle(direction) { const step = 1 / (lastBookModel.bundleCount - 1); setReadingProgress(readingProgress + direction * step); } function clearActiveFlip() { activeFlips.forEach(removeFlipMesh); activeFlips = []; } function removeFlipMesh(flip) { if (!flip.mesh) return; book.remove(flip.mesh); flip.mesh.geometry.dispose(); flip.mesh = null; } function updateFlipControls() { const busy = activeFlips.length > 0; fastBackwardButton.disabled = busy || !canPageFlip(-1); flipBackwardButton.disabled = busy || !canPageFlip(-1); flipForwardButton.disabled = busy || !canPageFlip(1); fastForwardButton.disabled = busy || !canPageFlip(1); flipCountValue.textContent = `${Math.abs(pendingPageFlips)} / 10`; } function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) * 0.5; } 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); } updateActiveFlips(performance.now()); controls.update(); renderer.render(scene, camera); }