Add burst page flip controls

This commit is contained in:
2026-06-05 03:41:26 +02:00
parent ae8068ad8a
commit ae84eb8976
2 changed files with 121 additions and 42 deletions
+118 -41
View File
@@ -6,8 +6,10 @@ const progressInput = document.getElementById('progress');
const progressValue = document.getElementById('progress_value'); const progressValue = document.getElementById('progress_value');
const pageCountInput = document.getElementById('page_count'); const pageCountInput = document.getElementById('page_count');
const pageCountValue = document.getElementById('page_count_value'); const pageCountValue = document.getElementById('page_count_value');
const fastBackwardButton = document.getElementById('fast_backward');
const flipBackwardButton = document.getElementById('flip_backward'); const flipBackwardButton = document.getElementById('flip_backward');
const flipForwardButton = document.getElementById('flip_forward'); const flipForwardButton = document.getElementById('flip_forward');
const fastForwardButton = document.getElementById('fast_forward');
const flipCountValue = document.getElementById('flip_count'); const flipCountValue = document.getElementById('flip_count');
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@@ -58,14 +60,17 @@ const BOOK_PROFILE = {
singlePageCoverGap: 0.006, singlePageCoverGap: 0.006,
bundleSpacing: 0.014 bundleSpacing: 0.014
}; };
const NORMAL_FLIP_DURATION = 1800;
const FAST_FLIP_DURATION = 900;
const FAST_FLIP_COUNT = 10;
const FAST_FLIP_OVERLAP = 5;
let readingProgress = readInitialProgress(); let readingProgress = readInitialProgress();
let pageCount = readInitialPageCount(); let pageCount = readInitialPageCount();
let lastLengthError = 0; let lastLengthError = 0;
let lastSpacingError = 0; let lastSpacingError = 0;
let lastBookModel = null; let lastBookModel = null;
let activeFlip = null; let activeFlips = [];
let activeFlipMesh = null;
let pendingPageFlips = 0; let pendingPageFlips = 0;
progressInput.value = readingProgress.toFixed(3); progressInput.value = readingProgress.toFixed(3);
progressValue.value = readingProgress.toFixed(2); progressValue.value = readingProgress.toFixed(2);
@@ -84,6 +89,10 @@ pageCountInput.addEventListener('input', () => {
setPageCount(pageCountInput.value); setPageCount(pageCountInput.value);
}); });
fastBackwardButton.addEventListener('click', () => {
startFastPageFlip(-1);
});
flipBackwardButton.addEventListener('click', () => { flipBackwardButton.addEventListener('click', () => {
startPageFlip(-1); startPageFlip(-1);
}); });
@@ -92,6 +101,10 @@ flipForwardButton.addEventListener('click', () => {
startPageFlip(1); startPageFlip(1);
}); });
fastForwardButton.addEventListener('click', () => {
startFastPageFlip(1);
});
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
window.BookShapeLab = { window.BookShapeLab = {
@@ -120,6 +133,12 @@ window.BookShapeLab = {
}, },
flipBackward() { flipBackward() {
return startPageFlip(-1); return startPageFlip(-1);
},
fastForward() {
return startFastPageFlip(1);
},
fastBackward() {
return startFastPageFlip(-1);
} }
}; };
@@ -780,22 +799,55 @@ function createEndpointPolyline(lines, depth) {
} }
function startPageFlip(direction) { function startPageFlip(direction) {
if (activeFlip || !lastBookModel || !canPageFlip(direction)) return false; 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 sourceSide = direction > 0 ? 1 : -1;
const sourceLine = topVisibleLine(sourceSide); const sourceLine = topVisibleLine(sourceSide);
const destinationLine = topVisibleLine(-sourceSide); const destinationLine = topVisibleLine(-sourceSide);
if (!sourceLine || !destinationLine) return false; if (!sourceLine || !destinationLine) return null;
activeFlip = { return {
direction, direction,
sourceLine, sourceLine,
destinationLine, destinationLine,
startTime: performance.now(), startTime,
duration: 1800 duration,
pageOffset: 0,
commitBundleOnFinish: false,
countAsPending: true,
mesh: null
}; };
updateFlipControls();
updateActiveFlip(activeFlip.startTime);
return true;
} }
function canPageFlip(direction) { function canPageFlip(direction) {
@@ -811,18 +863,23 @@ function topVisibleLine(side) {
return sideLines[sideLines.length - 1] ?? null; return sideLines[sideLines.length - 1] ?? null;
} }
function updateActiveFlip(now) { function updateActiveFlips(now) {
if (!activeFlip || !lastBookModel) return; if (!activeFlips.length || !lastBookModel) return;
const elapsed = (now - activeFlip.startTime) / activeFlip.duration; const completed = [];
const t = THREE.MathUtils.clamp(elapsed, 0, 1); activeFlips.forEach((flip) => {
const surface = buildFlippingPageSurface(activeFlip.sourceLine, activeFlip.destinationLine, activeFlip.direction, easeInOutCubic(t)); const elapsed = (now - flip.startTime) / flip.duration;
setActivePageGeometry(surface); if (elapsed < 0) return;
if (t < 1) return; const t = THREE.MathUtils.clamp(elapsed, 0, 1);
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
finishActiveFlip(); setActivePageGeometry(flip, surface);
if (t >= 1) completed.push(flip);
});
completed.forEach((flip) => {
finishActiveFlip(flip);
});
} }
function buildFlippingPageSurface(sourceLine, destinationLine, direction, t) { function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) {
const widthSegments = sourceLine.points.length - 1; const widthSegments = sourceLine.points.length - 1;
const depthSegments = 18; const depthSegments = 18;
const zFront = lastBookModel.pageDepth * 0.5 + 0.018; const zFront = lastBookModel.pageDepth * 0.5 + 0.018;
@@ -852,7 +909,7 @@ function buildFlippingPageSurface(sourceLine, destinationLine, direction, t) {
const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift); const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift);
const point = { const point = {
x: anchor.x + Math.cos(angle) * radius, x: anchor.x + Math.cos(angle) * radius,
y: relaxedY + 0.055 * lift * Math.sin(Math.PI * u), y: relaxedY + pageOffset + 0.055 * lift * Math.sin(Math.PI * u),
z z
}; };
keepFlippingSurfacePointAboveStacks(point, lift); keepFlippingSurfacePointAboveStacks(point, lift);
@@ -930,15 +987,15 @@ function lineYAtX(points, x) {
return y; return y;
} }
function setActivePageGeometry(surface) { function setActivePageGeometry(flip, surface) {
const geometry = createFlippingPageGeometry(surface); const geometry = createFlippingPageGeometry(surface);
if (!activeFlipMesh) { if (!flip.mesh) {
activeFlipMesh = new THREE.Mesh(geometry, materials.flippingPage); flip.mesh = new THREE.Mesh(geometry, materials.flippingPage);
book.add(activeFlipMesh); book.add(flip.mesh);
return; return;
} }
activeFlipMesh.geometry.dispose(); flip.mesh.geometry.dispose();
activeFlipMesh.geometry = geometry; flip.mesh.geometry = geometry;
} }
function createFlippingPageGeometry(surface) { function createFlippingPageGeometry(surface) {
@@ -1002,31 +1059,51 @@ function createFlippingPageGeometry(surface) {
} }
} }
function finishActiveFlip() { function finishActiveFlip(flip) {
const direction = activeFlip.direction; removeFlipMesh(flip);
clearActiveFlip(); activeFlips = activeFlips.filter((active) => active !== flip);
pendingPageFlips += direction; if (flip.commitBundleOnFinish) {
shiftReadingProgressByBundle(flip.direction);
return;
}
if (!flip.countAsPending) {
updateFlipControls();
return;
}
pendingPageFlips += flip.direction;
if (Math.abs(pendingPageFlips) >= 10) { if (Math.abs(pendingPageFlips) >= 10) {
const commitDirection = Math.sign(pendingPageFlips); const commitDirection = Math.sign(pendingPageFlips);
pendingPageFlips -= commitDirection * 10; pendingPageFlips -= commitDirection * 10;
const step = 1 / (lastBookModel.bundleCount - 1); shiftReadingProgressByBundle(commitDirection);
setReadingProgress(readingProgress + commitDirection * step);
return; return;
} }
updateFlipControls(); updateFlipControls();
} }
function shiftReadingProgressByBundle(direction) {
const step = 1 / (lastBookModel.bundleCount - 1);
setReadingProgress(readingProgress + direction * step);
}
function clearActiveFlip() { function clearActiveFlip() {
activeFlip = null; activeFlips.forEach(removeFlipMesh);
if (!activeFlipMesh) return; activeFlips = [];
book.remove(activeFlipMesh); }
activeFlipMesh.geometry.dispose();
activeFlipMesh = null; function removeFlipMesh(flip) {
if (!flip.mesh) return;
book.remove(flip.mesh);
flip.mesh.geometry.dispose();
flip.mesh = null;
} }
function updateFlipControls() { function updateFlipControls() {
flipBackwardButton.disabled = Boolean(activeFlip) || !canPageFlip(-1); const busy = activeFlips.length > 0;
flipForwardButton.disabled = Boolean(activeFlip) || !canPageFlip(1); 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`; flipCountValue.textContent = `${Math.abs(pendingPageFlips)} / 10`;
} }
@@ -1048,7 +1125,7 @@ function animate() {
const t = performance.now() * 0.00035; const t = performance.now() * 0.00035;
setReadingProgress(0.5 + Math.sin(t) * 0.48); setReadingProgress(0.5 + Math.sin(t) * 0.48);
} }
updateActiveFlip(performance.now()); updateActiveFlips(performance.now());
controls.update(); controls.update();
renderer.render(scene, camera); renderer.render(scene, camera);
} }
+3 -1
View File
@@ -68,10 +68,12 @@
<label for="page_count">Book pages</label> <label for="page_count">Book pages</label>
<input id="page_count" type="range" min="40" max="600" step="10" value="240"> <input id="page_count" type="range" min="40" max="600" step="10" value="240">
<output id="page_count_value" for="page_count">240</output> <output id="page_count_value" for="page_count">240</output>
<button id="fast_backward" type="button">Fast Backward</button>
<button id="flip_backward" type="button">Backward</button> <button id="flip_backward" type="button">Backward</button>
<button id="flip_forward" type="button">Forward</button> <button id="flip_forward" type="button">Forward</button>
<button id="fast_forward" type="button">Fast Forward</button>
<output id="flip_count">0 / 10</output> <output id="flip_count">0 / 10</output>
</div> </div>
<script type="module" src="/js/webgl-book-shape-lab.js?v=flip-3d-surface-3"></script> <script type="module" src="/js/webgl-book-shape-lab.js?v=flip-burst-1"></script>
</body> </body>
</html> </html>