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

673 lines
26 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 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 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 = -0.12;
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 }),
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,
bundleSpacing: 0.014
};
let readingProgress = readInitialProgress();
let pageCount = readInitialPageCount();
let lastLengthError = 0;
let lastSpacingError = 0;
progressInput.value = readingProgress.toFixed(3);
progressValue.value = readingProgress.toFixed(2);
pageCountInput.value = String(pageCount);
pageCountValue.value = String(pageCount);
rebuildBook();
resize();
animate();
progressInput.addEventListener('input', () => {
setReadingProgress(progressInput.value);
});
pageCountInput.addEventListener('input', () => {
setPageCount(pageCountInput.value);
});
window.addEventListener('resize', resize);
window.BookShapeLab = {
get progress() {
return readingProgress;
},
get pageCount() {
return pageCount;
},
get lastLengthError() {
return lastLengthError;
},
get lastSpacingError() {
return lastSpacingError;
},
setReadingProgress(value) {
setReadingProgress(value);
return readingProgress;
},
setPageCount(value) {
setPageCount(value);
return pageCount;
}
};
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 / 10) * 10, 40, 600);
}
function setReadingProgress(value) {
const next = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1);
if (!Number.isFinite(next)) return;
readingProgress = next;
progressInput.value = readingProgress.toFixed(3);
progressValue.value = readingProgress.toFixed(2);
rebuildBook();
}
function setPageCount(value) {
const next = snapPageCount(Number.parseFloat(value));
if (!Number.isFinite(next)) return;
pageCount = next;
pageCountInput.value = String(pageCount);
pageCountValue.value = String(pageCount);
rebuildBook();
}
function rebuildBook() {
clearGroup(book);
const coverDepth = 2.34;
const coverThickness = BOOK_PROFILE.coverThickness;
const pageWidth = 1.62;
const pageDepth = 2.24;
const bundleCount = Math.max(4, Math.round(pageCount / 10));
const spineWidth = Math.max(0.16, bundleCount * BOOK_PROFILE.bundleSpacing);
const lines = simulatePageLines(bundleCount, pageWidth, spineWidth);
lastLengthError = measureLineLengthError(lines, pageWidth);
lastSpacingError = measureStackSpacingError(lines);
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
addClothSpine(pageDepth, spineWidth);
addSimulatedStackBodies(lines, pageDepth);
addSimulatedPageLines(lines, pageDepth);
}
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) {
const cover = new THREE.Mesh(createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth), materials.cover);
book.add(cover);
}
function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth) {
const overhang = 0.055;
const spineHalf = spineWidth * 0.5;
const hingeInset = 0.07;
const outerX = pageWidth + overhang;
const hingeX = spineHalf + hingeInset;
const outerTopY = BOOK_PROFILE.tableY + thickness;
const connectionTopY = BOOK_PROFILE.raisedHingeY;
const spineTopY = BOOK_PROFILE.tableY + thickness;
const section = [
{ x: -outerX, y: outerTopY },
{ x: -hingeX, y: connectionTopY },
{ x: -spineHalf, y: spineTopY },
{ x: spineHalf, y: spineTopY },
{ x: hingeX, y: connectionTopY },
{ x: outerX, y: outerTopY }
];
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.012));
back.push(push(point, -depth * 0.5 - 0.012));
});
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 = spineWidth * 0.42;
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 simulatePageLines(bundleCount, pageWidth, spineWidth) {
const lines = [];
const segments = 16;
const stepLength = pageWidth / segments;
const entries = [];
const spineSamples = sampleSpineByArc(bundleCount, spineWidth);
const leftLimit = Math.floor((bundleCount - 1) * readingProgress);
for (let index = 0; index < bundleCount; index += 1) {
const t = spineSamples[index].t;
const side = index <= leftLimit ? -1 : 1;
entries.push({ index, t, side });
}
[-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;
const bottomEntry = sideEntries[0];
const bottomAnchor = spineCurvePoint(bottomEntry.t, spineWidth);
const bottomTarget = restingTarget(side, pageWidth, 0, sideEntries.length);
const bottomPoints = initialPageLine(bottomAnchor, bottomTarget, segments);
relaxPageLine(bottomPoints, bottomAnchor, stepLength, side, 0, bundleCount);
keepPageAboveCover(bottomPoints, side, bundleCount);
sideEntries.forEach((entry, rank) => {
const anchor = spineCurvePoint(entry.t, spineWidth);
const points = rank === 0
? bottomPoints.map((point) => ({ ...point }))
: offsetPageLine(bottomPoints, anchor, rank * BOOK_PROFILE.bundleSpacing);
keepPageAboveCover(points, side, bundleCount);
lines.push({ index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1] });
});
});
enforceStackConstraints(lines, stepLength, bundleCount);
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) {
let maxError = 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 = 0; col < upper.points.length; col += 1) {
const distance = Math.hypot(upper.points[col].x - lower.points[col].x, upper.points[col].y - lower.points[col].y);
maxError = Math.max(maxError, Math.abs(distance - BOOK_PROFILE.bundleSpacing));
}
}
});
return maxError;
}
function sampleSpineByArc(count, 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;
}
const points = [];
for (let i = 0; i < count; i += 1) {
const target = count === 1 ? length * 0.5 : length * (i / (count - 1));
const found = samples.findIndex((sample) => sample.length >= target);
if (found <= 0) {
points.push(samples[0].point);
continue;
}
const before = samples[found - 1];
const after = samples[found];
const span = after.length - before.length || 1;
const t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span);
points.push(spineCurvePoint(t, spineWidth));
}
return points;
}
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, pageWidth, rank, sideCount) {
const local = sideCount <= 1 ? 0 : rank / (sideCount - 1);
const foreCurve = 0.11 * Math.sin(Math.PI * local);
const x = side * (pageWidth - foreCurve);
const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * BOOK_PROFILE.bundleSpacing + 0.002 * Math.sin(Math.PI * local);
return { x, y };
}
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 keepPageAboveCover(points, side, bundleCount) {
for (let i = 1; i < points.length; i += 1) {
const clearance = BOOK_PROFILE.paperContactOffset + 0.0002 * bundleCount;
points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + clearance);
points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01);
}
}
function enforceStackConstraints(lines, stepLength, bundleCount) {
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 * BOOK_PROFILE.bundleSpacing;
const targetY = lower.points[col].y + normal.y * BOOK_PROFILE.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 spineHalf = currentSpineHalf();
const hingeX = spineHalf + 0.07;
const outerX = 1.62 + 0.055;
if (ax <= spineHalf) return BOOK_PROFILE.coverThickness;
if (ax <= hingeX) {
const t = (ax - spineHalf) / (hingeX - spineHalf);
return THREE.MathUtils.lerp(BOOK_PROFILE.coverThickness, BOOK_PROFILE.raisedHingeY, t);
}
const t = THREE.MathUtils.clamp((ax - hingeX) / (outerX - hingeX), 0, 1);
return THREE.MathUtils.lerp(BOOK_PROFILE.raisedHingeY, BOOK_PROFILE.coverThickness, t);
}
function currentSpineHalf() {
return Math.max(0.16, Math.round(pageCount / 10) * BOOK_PROFILE.bundleSpacing) * 0.5;
}
function addSimulatedPageLines(lines, depth) {
const leftMaterial = new THREE.LineBasicMaterial({ color: 0x8f7750, transparent: true, opacity: 0.72 });
const rightMaterial = new THREE.LineBasicMaterial({ color: 0x9a8058, transparent: true, opacity: 0.72 });
const z = depth * 0.5 + 0.11;
lines.forEach((line) => {
const points = smoothLinePoints(line.points, 4).map((point) => new THREE.Vector3(point.x, point.y, z));
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), line.side < 0 ? leftMaterial : rightMaterial));
});
}
function addSimulatedStackBodies(lines, depth) {
[-1, 1].forEach((side) => {
const sideLines = lines.filter((line) => line.side === side);
if (sideLines.length < 2) return;
const material = side < 0 ? materials.pagesLeft : materials.pagesRight;
book.add(new THREE.Mesh(createLoftedLineBody(sideLines, depth), material));
book.add(new THREE.Line(createEndpointPolyline(sideLines, depth), new THREE.LineBasicMaterial({ color: 0xb99a68, transparent: true, opacity: 0.62 })));
});
}
function createLoftedLineBody(lines, depth) {
const positions = [];
const indices = [];
const smoothLines = lines.map((line) => smoothLinePoints(line.points, 4));
const push = (point, z) => {
const index = positions.length / 3;
positions.push(point.x, point.y, z);
return index;
};
const front = smoothLines.map((points) => points.map((point) => push(point, depth * 0.5 + 0.09)));
const back = smoothLines.map((points) => points.map((point) => push(point, -depth * 0.5 + 0.09)));
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]);
}
}
for (let row = 0; row < smoothLines.length - 1; row += 1) {
const last = smoothLines[row].length - 1;
indices.push(front[row][last], front[row + 1][last], back[row][last]);
indices.push(front[row + 1][last], back[row + 1][last], back[row][last]);
}
for (let col = 0; col < smoothLines[0].length - 1; col += 1) {
const bottomRow = 0;
const topRow = smoothLines.length - 1;
indices.push(front[bottomRow][col], front[bottomRow][col + 1], back[bottomRow][col]);
indices.push(front[bottomRow][col + 1], back[bottomRow][col + 1], back[bottomRow][col]);
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]);
}
for (let row = 0; row < smoothLines.length - 1; row += 1) {
indices.push(front[row][0], back[row][0], front[row + 1][0]);
indices.push(front[row + 1][0], back[row][0], back[row + 1][0]);
}
const geometry = new THREE.BufferGeometry();
geometry.setIndex(indices);
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.computeVertexNormals();
return geometry;
}
function createEndpointPolyline(lines, depth) {
const points = lines.map((line) => new THREE.Vector3(line.endpoint.x, line.endpoint.y, depth * 0.5 + 0.112));
return new THREE.BufferGeometry().setFromPoints(points);
}
function smoothLinePoints(points, subdivisions) {
const result = [];
for (let i = 0; i < points.length - 1; i += 1) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
for (let step = 0; step < subdivisions; step += 1) {
const t = step / subdivisions;
result.push(catmullRomPoint(p0, p1, p2, p3, t));
}
}
result.push(points[points.length - 1]);
return result;
}
function catmullRomPoint(p0, p1, p2, p3, t) {
const tt = t * t;
const ttt = tt * t;
return {
x: 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * tt + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * ttt),
y: 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * tt + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * ttt)
};
}
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);
}
controls.update();
renderer.render(scene, camera);
}