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

361 lines
14 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 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
};
let readingProgress = readInitialProgress();
progressInput.value = readingProgress.toFixed(3);
progressValue.value = readingProgress.toFixed(2);
rebuildBook();
resize();
animate();
progressInput.addEventListener('input', () => {
setReadingProgress(progressInput.value);
});
window.addEventListener('resize', resize);
window.BookShapeLab = {
get progress() {
return readingProgress;
},
setReadingProgress(value) {
setReadingProgress(value);
return readingProgress;
}
};
function readInitialProgress() {
const parsed = Number.parseFloat(urlParams.get('progress') ?? '0.25');
return Number.isFinite(parsed) ? THREE.MathUtils.clamp(parsed, 0, 1) : 0.25;
}
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 rebuildBook() {
clearGroup(book);
const coverDepth = 2.34;
const coverThickness = BOOK_PROFILE.coverThickness;
const pageWidth = 1.62;
const pageDepth = 2.24;
const gutter = 0.12;
const sheetTick = 0.0045;
const fullBlock = 0.41;
const leftThickness = THREE.MathUtils.lerp(sheetTick, fullBlock, readingProgress);
const rightThickness = THREE.MathUtils.lerp(fullBlock, sheetTick, readingProgress);
const spineWidth = fullBlock;
const fold = spineCurvePoint(readingProgress, spineWidth);
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
addSplinePageBlock(-1, pageWidth, pageDepth, leftThickness, fold, spineWidth);
addSplinePageBlock(1, pageWidth, pageDepth, rightThickness, fold, spineWidth);
addPageLayerLines(-1, pageWidth, pageDepth, leftThickness, spineWidth, fold);
addPageLayerLines(1, pageWidth, pageDepth, rightThickness, spineWidth, fold);
addClothSpine(pageDepth, spineWidth);
}
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 addSplinePageBlock(side, width, depth, thickness, fold, spineWidth) {
const block = new THREE.Mesh(createSplinePageBlockGeometry(side, width, depth, thickness, fold, spineWidth), side < 0 ? materials.pagesLeft : materials.pagesRight);
book.add(block);
}
function addPageLayerLines(side, width, depth, thickness, spineWidth, fold) {
const material = new THREE.LineBasicMaterial({ color: 0x8f7750, transparent: true, opacity: 0.55 });
const z = depth * 0.5 + 0.006;
const lineCount = Math.max(1, Math.round(thickness / 0.018));
for (let layer = 1; layer < lineCount; layer += 1) {
const t = layer / lineCount;
const curveT = side < 0
? THREE.MathUtils.lerp(0, readingProgress, t)
: THREE.MathUtils.lerp(1, readingProgress, t);
const lineFold = spineCurvePoint(curveT, spineWidth);
const points = [];
for (let i = 0; i <= 40; i += 1) {
const u = i / 40;
const top = pageBlockTopY(side, thickness, u, 0.5, lineFold, spineWidth);
const bottom = pageBlockBottomY(side, thickness, u, 0.5, lineFold, spineWidth);
points.push(new THREE.Vector3(pageX(side, lineFold, spineWidth, width, u, 0.5), bottom + (top - bottom) * t, z));
}
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
}
}
function addClothSpine(depth, spineWidth) {
const material = new THREE.LineBasicMaterial({ color: 0xb51f1f });
const profile = [];
for (let i = 0; i <= 32; i += 1) {
const u = i / 32;
profile.push(spineCurvePoint(u, spineWidth));
}
[depth * 0.5 + 0.008, -depth * 0.5 - 0.008].forEach((z) => {
const points = profile.map((point) => new THREE.Vector3(point.x, point.y, z));
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
});
}
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 pageBlockTopY(side, thickness, u, v, fold, spineWidth) {
const hingeWidth = 0.105;
const hinge = THREE.MathUtils.clamp(u / hingeWidth, 0, 1);
const t = easeOutCubic(hinge);
const sewnY = fold.y + 0.002;
const stackY = pageBlockBottomY(side, thickness, u, v, fold, spineWidth) + thickness;
const flatCrown = 0.006 * Math.sin(Math.PI * v);
const foreCurl = 0.006 * smoothstep(THREE.MathUtils.clamp((u - 0.88) / 0.12, 0, 1));
return sewnY * (1 - t) + (stackY + flatCrown + foreCurl) * t;
}
function pageBlockBottomY(side, thickness, u, v, fold, spineWidth) {
const curveEnd = 0.34;
if (u <= curveEnd) {
return pageCurvePoint(side, fold, spineWidth, u, curveEnd).y;
}
const flatY = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset;
return flatY;
}
function pageCurvePoint(side, fold, spineWidth, u, curveEnd) {
const along = THREE.MathUtils.clamp(u / curveEnd, 0, 1);
const targetT = side < 0 ? 0 : 1;
return spineCurvePoint(THREE.MathUtils.lerp(fold.t, targetT, along), spineWidth);
}
function smoothstep(value) {
return value * value * (3 - 2 * value);
}
function easeOutCubic(value) {
return 1 - Math.pow(1 - value, 3);
}
function pageWidthAtDepth(width, v) {
return width;
}
function pageX(side, fold, spineWidth, width, u, v = 0.5) {
const curveEnd = 0.34;
if (u <= curveEnd) {
return pageCurvePoint(side, fold, spineWidth, u, curveEnd).x;
}
const outerT = THREE.MathUtils.clamp((u - curveEnd) / (1 - curveEnd), 0, 1);
const curveEndX = spineCurvePoint(side < 0 ? 0 : 1, spineWidth).x;
const foreX = side * pageWidthAtDepth(width, v);
return curveEndX * (1 - outerT) + foreX * outerT;
}
function createSplinePageBlockGeometry(side, width, depth, thickness, fold, spineWidth) {
const columns = 36;
const rows = 36;
const positions = [];
const uvs = [];
const indices = [];
const top = [];
const bottom = [];
const push = (x, y, z, u, v) => {
const index = positions.length / 3;
positions.push(x, y, z);
uvs.push(u, v);
return index;
};
for (let row = 0; row <= rows; row += 1) {
const v = row / rows;
top[row] = [];
bottom[row] = [];
for (let column = 0; column <= columns; column += 1) {
const u = column / columns;
const z = (v - 0.5) * depth;
top[row][column] = push(pageX(side, fold, spineWidth, width, u, v), pageBlockTopY(side, thickness, u, v, fold, spineWidth), z, u, v);
bottom[row][column] = push(pageX(side, fold, spineWidth, width, u, v), pageBlockBottomY(side, thickness, u, v, fold, spineWidth), z, u, v);
}
}
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
indices.push(top[row][column], top[row + 1][column], top[row][column + 1]);
indices.push(top[row][column + 1], top[row + 1][column], top[row + 1][column + 1]);
indices.push(bottom[row][column], bottom[row][column + 1], bottom[row + 1][column]);
indices.push(bottom[row][column + 1], bottom[row + 1][column + 1], bottom[row + 1][column]);
}
}
for (let row = 0; row < rows; row += 1) {
indices.push(top[row][0], bottom[row][0], top[row + 1][0], top[row + 1][0], bottom[row][0], bottom[row + 1][0]);
indices.push(top[row][columns], top[row + 1][columns], bottom[row][columns], top[row + 1][columns], bottom[row + 1][columns], bottom[row][columns]);
}
for (let column = 0; column < columns; column += 1) {
indices.push(top[0][column], top[0][column + 1], bottom[0][column], top[0][column + 1], bottom[0][column + 1], bottom[0][column]);
indices.push(top[rows][column], bottom[rows][column], top[rows][column + 1], top[rows][column + 1], bottom[rows][column], bottom[rows][column + 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.computeVertexNormals();
return geometry;
}
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);
}