Files
ai.interactive.fiction/public/js/webgl-book-shape-lab.js
T

1485 lines
55 KiB
JavaScript

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 = 48;
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 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 segmentLengths = pageSegmentLengths(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, segmentLengths, 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 pageSegmentLengths(totalLength, segments) {
const weights = [];
for (let index = 0; index < segments; index += 1) {
const u = index / Math.max(1, segments - 1);
weights.push(0.32 + 1.68 * u * u);
}
const weightTotal = weights.reduce((sum, weight) => sum + weight, 0);
return weights.map((weight) => totalLength * weight / weightTotal);
}
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, segmentLengths, bundleCount, bundleSpacing) {
const points = [{ x: anchor.x, y: anchor.y }];
const supportPath = createLineSupportPath(anchor, lowerLine, side, bundleCount, bundleSpacing);
const support = createMeasuredPath(supportPath);
let cursor = 0;
for (let index = 1; index <= segments; index += 1) {
const stepLength = segmentLengths[index - 1];
const next = nextPointOnSupportPath(support, cursor, points[index - 1], stepLength, side, target);
points.push(next.point);
cursor = next.cursor;
}
return points;
}
function createLineSupportPath(anchor, lowerLine, side, bundleCount, bundleSpacing) {
const path = [{ x: anchor.x, y: anchor.y }];
const source = lowerLine
? offsetPaperSupportPath(lowerLine.points, bundleSpacing)
: coverBaseSupportPath(anchor, side, bundleCount);
source.forEach((point) => {
if (side * (point.x - anchor.x) >= -0.0001) {
path.push(point);
}
});
return compactPath(path);
}
function coverBaseSupportPath(anchor, side, bundleCount) {
const path = [];
const clearance = coverClearance(bundleCount);
const steps = 16;
for (let sample = 1; sample <= steps; sample += 1) {
const u = sample / steps;
const t = side > 0
? THREE.MathUtils.lerp(anchor.t, 1, u)
: THREE.MathUtils.lerp(anchor.t, 0, u);
const point = spineCurvePoint(t, activeSpineHalf / 0.42);
path.push({ x: point.x, y: point.y + clearance });
}
const profile = coverProfilePointsFromFrame(currentSpineHalf(), activeCoverOuterX)
.filter((point) => side < 0 ? point.x <= -currentSpineHalf() : point.x >= currentSpineHalf())
.sort((a, b) => side < 0 ? b.x - a.x : a.x - b.x);
profile.forEach((point) => path.push({ x: point.x, y: point.y + clearance }));
return path;
}
function offsetPaperSupportPath(points, distance) {
return points.map((point, index) => {
const normal = upwardNormalAt(points, index);
return {
x: point.x + normal.x * distance,
y: point.y + normal.y * distance
};
});
}
function compactPath(path) {
const compacted = [];
path.forEach((point) => {
const previous = compacted[compacted.length - 1];
if (!previous || Math.hypot(point.x - previous.x, point.y - previous.y) > 0.000001) {
compacted.push(point);
}
});
return compacted;
}
function createMeasuredPath(points) {
const lengths = [0];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const point = points[index];
lengths[index] = lengths[index - 1] + Math.hypot(point.x - previous.x, point.y - previous.y);
}
return { points, lengths, totalLength: lengths[lengths.length - 1] ?? 0 };
}
function nextPointOnSupportPath(support, cursor, previous, segmentLength, side, target) {
let segmentIndex = Math.max(0, support.lengths.findIndex((length) => length > cursor) - 1);
if (segmentIndex < 0) segmentIndex = support.points.length - 2;
let startDistance = cursor;
let from = pointAtMeasuredPathDistance(support, cursor);
while (segmentIndex < support.points.length - 1) {
const to = support.points[segmentIndex + 1];
const endDistance = support.lengths[segmentIndex + 1];
const hit = circleSegmentIntersection(previous, from, to, segmentLength);
if (hit !== null && side * (hit.point.x - previous.x) >= -0.000001) {
return {
point: hit.point,
cursor: THREE.MathUtils.lerp(startDistance, endDistance, hit.t)
};
}
segmentIndex += 1;
from = support.points[segmentIndex];
startDistance = support.lengths[segmentIndex];
}
return extendSupportPathEnd(support, previous, segmentLength, side, target);
}
function pointAtMeasuredPathDistance(support, distance) {
const target = THREE.MathUtils.clamp(distance, 0, support.totalLength);
for (let index = 0; index < support.points.length - 1; index += 1) {
if (target <= support.lengths[index + 1]) {
const from = support.points[index];
const to = support.points[index + 1];
const span = support.lengths[index + 1] - support.lengths[index] || 1;
const t = (target - support.lengths[index]) / span;
return {
x: THREE.MathUtils.lerp(from.x, to.x, t),
y: THREE.MathUtils.lerp(from.y, to.y, t)
};
}
}
return { ...support.points[support.points.length - 1] };
}
function circleSegmentIntersection(center, from, to, radius) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const fx = from.x - center.x;
const fy = from.y - center.y;
const a = dx * dx + dy * dy;
const b = 2 * (fx * dx + fy * dy);
const c = fx * fx + fy * fy - radius * radius;
const discriminant = b * b - 4 * a * c;
if (a <= 0 || discriminant < 0) return null;
const root = Math.sqrt(discriminant);
const t0 = (-b - root) / (2 * a);
const t1 = (-b + root) / (2 * a);
const t = [t0, t1].filter((value) => value >= -0.000001 && value <= 1.000001).sort((left, right) => left - right)[0];
if (t === undefined) return null;
const clamped = THREE.MathUtils.clamp(t, 0, 1);
return {
t: clamped,
point: {
x: THREE.MathUtils.lerp(from.x, to.x, clamped),
y: THREE.MathUtils.lerp(from.y, to.y, clamped)
}
};
}
function extendSupportPathEnd(support, previous, segmentLength, side, target) {
const last = support.points[support.points.length - 1];
const before = support.points[Math.max(0, support.points.length - 2)];
const supportDirection = normalizedVector(last.x - before.x, last.y - before.y);
const targetDirection = normalizedVector(target.x - previous.x, target.y - previous.y);
const direction = side * supportDirection.x >= 0.05 ? supportDirection : targetDirection;
const point = {
x: previous.x + direction.x * segmentLength,
y: previous.y + direction.y * segmentLength
};
return { point, cursor: support.totalLength };
}
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);
}