diff --git a/public/js/webgl-book-shape-lab.js b/public/js/webgl-book-shape-lab.js index c241e32..29c968d 100644 --- a/public/js/webgl-book-shape-lab.js +++ b/public/js/webgl-book-shape-lab.js @@ -6,6 +6,9 @@ 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 flipBackwardButton = document.getElementById('flip_backward'); +const flipForwardButton = document.getElementById('flip_forward'); +const flipCountValue = document.getElementById('flip_count'); const urlParams = new URLSearchParams(window.location.search); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); @@ -42,6 +45,7 @@ const materials = { 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 }) }; @@ -59,10 +63,15 @@ let readingProgress = readInitialProgress(); let pageCount = readInitialPageCount(); let lastLengthError = 0; let lastSpacingError = 0; +let lastBookModel = null; +let activeFlip = null; +let activeFlipMesh = null; +let pendingPageFlips = 0; progressInput.value = readingProgress.toFixed(3); progressValue.value = readingProgress.toFixed(2); pageCountInput.value = String(pageCount); pageCountValue.value = String(pageCount); +updateFlipControls(); rebuildBook(); resize(); animate(); @@ -75,6 +84,14 @@ pageCountInput.addEventListener('input', () => { setPageCount(pageCountInput.value); }); +flipBackwardButton.addEventListener('click', () => { + startPageFlip(-1); +}); + +flipForwardButton.addEventListener('click', () => { + startPageFlip(1); +}); + window.addEventListener('resize', resize); window.BookShapeLab = { @@ -97,6 +114,12 @@ window.BookShapeLab = { setPageCount(value) { setPageCount(value); return pageCount; + }, + flipForward() { + return startPageFlip(1); + }, + flipBackward() { + return startPageFlip(-1); } }; @@ -118,19 +141,24 @@ function snapPageCount(value) { 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() { @@ -145,11 +173,13 @@ function rebuildBook() { const lines = simulatePageLines(bundleCount, pageWidth, spineWidth); lastLengthError = measureLineLengthError(lines, pageWidth); lastSpacingError = measureStackSpacingError(lines); + lastBookModel = { coverDepth, pageWidth, pageDepth, bundleCount, spineWidth, lines }; addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth); addClothSpine(pageDepth, spineWidth); addSimulatedStackBodies(lines, pageDepth); addSimulatedPageLines(lines, pageDepth); + updateFlipControls(); } function clearGroup(group) { @@ -749,6 +779,261 @@ function createEndpointPolyline(lines, depth) { return new THREE.BufferGeometry().setFromPoints(points); } +function startPageFlip(direction) { + if (activeFlip || !lastBookModel || !canPageFlip(direction)) return false; + const sourceSide = direction > 0 ? 1 : -1; + const sourceLine = topVisibleLine(sourceSide); + const destinationLine = topVisibleLine(-sourceSide); + if (!sourceLine || !destinationLine) return false; + + activeFlip = { + direction, + sourceLine, + destinationLine, + startTime: performance.now(), + duration: 1800 + }; + updateFlipControls(); + updateActiveFlip(activeFlip.startTime); + return true; +} + +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 updateActiveFlip(now) { + if (!activeFlip || !lastBookModel) return; + const elapsed = (now - activeFlip.startTime) / activeFlip.duration; + const t = THREE.MathUtils.clamp(elapsed, 0, 1); + const surface = buildFlippingPageSurface(activeFlip.sourceLine, activeFlip.destinationLine, activeFlip.direction, easeInOutCubic(t)); + setActivePageGeometry(surface); + if (t < 1) return; + + finishActiveFlip(); +} + +function buildFlippingPageSurface(sourceLine, destinationLine, direction, t) { + const widthSegments = sourceLine.points.length - 1; + const depthSegments = 18; + const zFront = lastBookModel.pageDepth * 0.5 + 0.018; + const zBack = -lastBookModel.pageDepth * 0.5 - 0.018; + 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 surface = []; + for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) { + const u = widthIndex / widthSegments; + const radius = lastBookModel.pageWidth * u; + 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 + 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(surface) { + const geometry = createFlippingPageGeometry(surface); + if (!activeFlipMesh) { + activeFlipMesh = new THREE.Mesh(geometry, materials.flippingPage); + book.add(activeFlipMesh); + return; + } + activeFlipMesh.geometry.dispose(); + activeFlipMesh.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() { + const direction = activeFlip.direction; + clearActiveFlip(); + pendingPageFlips += direction; + if (Math.abs(pendingPageFlips) >= 10) { + const commitDirection = Math.sign(pendingPageFlips); + pendingPageFlips -= commitDirection * 10; + const step = 1 / (lastBookModel.bundleCount - 1); + setReadingProgress(readingProgress + commitDirection * step); + return; + } + updateFlipControls(); +} + +function clearActiveFlip() { + activeFlip = null; + if (!activeFlipMesh) return; + book.remove(activeFlipMesh); + activeFlipMesh.geometry.dispose(); + activeFlipMesh = null; +} + +function updateFlipControls() { + flipBackwardButton.disabled = Boolean(activeFlip) || !canPageFlip(-1); + flipForwardButton.disabled = Boolean(activeFlip) || !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; @@ -763,6 +1048,7 @@ function animate() { const t = performance.now() * 0.00035; setReadingProgress(0.5 + Math.sin(t) * 0.48); } + updateActiveFlip(performance.now()); controls.update(); renderer.render(scene, camera); } diff --git a/public/webgl-book-shape-lab.html b/public/webgl-book-shape-lab.html index 5a1ce00..fd30fa5 100644 --- a/public/webgl-book-shape-lab.html +++ b/public/webgl-book-shape-lab.html @@ -42,6 +42,21 @@ #page_count { width: 100%; } + + button { + min-height: 32px; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 4px; + background: #2b2d30; + color: #f0f0f0; + font: inherit; + cursor: pointer; + } + + button:disabled { + cursor: default; + opacity: 0.45; + } @@ -53,7 +68,10 @@ 240 + + + 0 / 10 - +