Checkpoint page flip surface
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user