Checkpoint page flip surface

This commit is contained in:
2026-06-05 03:32:50 +02:00
parent 44fb461eae
commit ae8068ad8a
2 changed files with 305 additions and 1 deletions
+286
View File
@@ -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);
}