Checkpoint procedural book shape lab
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
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 }),
|
||||
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 })
|
||||
};
|
||||
|
||||
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 = 0.022;
|
||||
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 foldX = clothFoldX(gutter);
|
||||
|
||||
addCoverAssembly(pageWidth, coverDepth, coverThickness);
|
||||
addSplinePageBlock(-1, pageWidth, pageDepth, leftThickness, foldX);
|
||||
addSplinePageBlock(1, pageWidth, pageDepth, rightThickness, foldX);
|
||||
addPageLayerLines(-1, pageWidth, pageDepth, leftThickness, foldX);
|
||||
addPageLayerLines(1, pageWidth, pageDepth, rightThickness, foldX);
|
||||
addClothSpine(pageDepth, gutter);
|
||||
}
|
||||
|
||||
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) {
|
||||
const cover = new THREE.Mesh(createCoverAssemblyGeometry(pageWidth, depth, thickness), materials.cover);
|
||||
book.add(cover);
|
||||
}
|
||||
|
||||
function createCoverAssemblyGeometry(pageWidth, depth, thickness) {
|
||||
const overhang = 0.055;
|
||||
const hingeX = 0.18;
|
||||
const centerX = 0.095;
|
||||
const boardTopY = 0.023;
|
||||
const centerTopY = -0.002;
|
||||
const section = [
|
||||
{ x: -pageWidth - overhang, y: boardTopY },
|
||||
{ x: -hingeX, y: boardTopY },
|
||||
{ x: -centerX, y: centerTopY },
|
||||
{ x: centerX, y: centerTopY },
|
||||
{ x: hingeX, y: boardTopY },
|
||||
{ x: pageWidth + overhang, y: boardTopY }
|
||||
];
|
||||
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, foldX) {
|
||||
const block = new THREE.Mesh(createSplinePageBlockGeometry(side, width, depth, thickness, foldX), side < 0 ? materials.pagesLeft : materials.pagesRight);
|
||||
book.add(block);
|
||||
}
|
||||
|
||||
function addPageLayerLines(side, width, depth, thickness, foldX) {
|
||||
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 points = [];
|
||||
for (let i = 0; i <= 40; i += 1) {
|
||||
const u = 0.04 + 0.96 * (i / 40);
|
||||
const top = pageBlockTopY(thickness, u, 0.5);
|
||||
const bottom = pageBlockBottomY(thickness, u, 0.5);
|
||||
points.push(new THREE.Vector3(pageX(side, foldX, width, u, 0.5), bottom + (top - bottom) * t, z));
|
||||
}
|
||||
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
|
||||
}
|
||||
}
|
||||
|
||||
function clothFoldX(gutter) {
|
||||
const spineTravel = gutter * 0.52;
|
||||
return THREE.MathUtils.lerp(-spineTravel, spineTravel, readingProgress);
|
||||
}
|
||||
|
||||
function addClothSpine(depth, gutter) {
|
||||
const geometry = createClothSpineGeometry(depth, gutter);
|
||||
const spine = new THREE.Mesh(geometry, materials.spine);
|
||||
book.add(spine);
|
||||
}
|
||||
|
||||
function createClothSpineGeometry(depth, gutter) {
|
||||
const columns = 24;
|
||||
const rows = 18;
|
||||
const positions = [];
|
||||
const uvs = [];
|
||||
const indices = [];
|
||||
const radiusX = gutter * 0.52;
|
||||
const radiusY = 0.04;
|
||||
const baseY = -0.004;
|
||||
|
||||
for (let row = 0; row <= rows; row += 1) {
|
||||
const v = row / rows;
|
||||
const z = (v - 0.5) * depth * 0.94;
|
||||
for (let column = 0; column <= columns; column += 1) {
|
||||
const u = column / columns;
|
||||
const theta = Math.PI * (1 - u);
|
||||
const x = Math.cos(theta) * radiusX;
|
||||
const y = baseY + Math.sin(theta) * radiusY + x * 0.12;
|
||||
const exposedY = Math.min(y, 0.038);
|
||||
const lengthSag = -0.006 * Math.sin(Math.PI * v);
|
||||
positions.push(x, exposedY + lengthSag, z);
|
||||
uvs.push(u, v);
|
||||
}
|
||||
}
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const a = row * (columns + 1) + column;
|
||||
const b = a + 1;
|
||||
const c = a + columns + 1;
|
||||
const d = c + 1;
|
||||
indices.push(a, c, b, b, c, d);
|
||||
}
|
||||
}
|
||||
|
||||
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 pageBlockTopY(thickness, u, v) {
|
||||
const hingeWidth = 0.105;
|
||||
const hinge = THREE.MathUtils.clamp(u / hingeWidth, 0, 1);
|
||||
const t = easeOutCubic(hinge);
|
||||
const sewnY = 0.066;
|
||||
const stackY = 0.032 + 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(thickness, u, v) {
|
||||
const hingeWidth = 0.105;
|
||||
const hinge = THREE.MathUtils.clamp(u / hingeWidth, 0, 1);
|
||||
const t = easeOutCubic(hinge);
|
||||
const sewnY = 0.026;
|
||||
const stackY = 0.026;
|
||||
return sewnY * (1 - t) + stackY * t;
|
||||
}
|
||||
|
||||
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, foldX, width, u, v = 0.5) {
|
||||
const foreX = side * pageWidthAtDepth(width, v);
|
||||
return foldX * (1 - u) + foreX * u;
|
||||
}
|
||||
|
||||
function createSplinePageBlockGeometry(side, width, depth, thickness, foldX) {
|
||||
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, foldX, width, u, v), pageBlockTopY(thickness, u, v), z, u, v);
|
||||
bottom[row][column] = push(pageX(side, foldX, width, u, v), pageBlockBottomY(thickness, u, v), 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);
|
||||
}
|
||||
Reference in New Issue
Block a user