Add 3D loose-leaf chronicle of human civilisation

A Three.js site presenting 20 world history milestones as
double-sided loose-leaf pages, with three views: a leather
binder with page-flip animation, a golden timeline, and a
dotted globe with great-circle arcs.

All textures (paper, leather, registration cards, and 18
hand-drawn sketches) are generated procedurally via Canvas
2D, so no external image assets are needed beyond the Earth
map. Includes dev helper scripts for screenshots, ASCII
previews, and headless verification.
This commit is contained in:
2026-09-18 19:19:53 +08:00
commit 4e818d53e3
24 changed files with 4374 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
import * as THREE from 'three';
import { ENTRIES } from '../data/entries.js';
import { PAGE_W, PAGE_H, HOLE_U, getPageTextures } from '../textures/pages.js';
import { clamp, easeInOutQuint } from '../utils/easing.js';
const RING_R = 0.085;
const RING_TUBE = 0.0115;
const STACK_GAP = 0.004;
const PAGE_GAP = 0.02; // 页内缘离书脊的小空隙
function makeLeatherTexture({ emblem = false } = {}) {
const w = 512, h = 640;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
// 皮革底色(斜向渐变)
const g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, '#5c3f2c');
g.addColorStop(0.5, '#4a3223');
g.addColorStop(1, '#392516');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
// 皮革颗粒
for (let i = 0; i < 32000; i++) {
const x = Math.random() * w, y = Math.random() * h;
ctx.fillStyle = Math.random() > 0.5 ? 'rgba(28,16,8,0.16)' : 'rgba(178,128,74,0.10)';
ctx.fillRect(x, y, Math.random() * 3 + 0.5, Math.random() * 3 + 0.5);
}
// 大块斑驳
for (let i = 0; i < 70; i++) {
const x = Math.random() * w, y = Math.random() * h, r = Math.random() * 90 + 20;
const rg = ctx.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, Math.random() > 0.5 ? 'rgba(0,0,0,0.09)' : 'rgba(205,145,85,0.07)');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(0, 0, w, h);
}
// 金线压花边框 + 角饰
ctx.strokeStyle = 'rgba(201,162,39,0.5)';
ctx.lineWidth = 3;
ctx.strokeRect(20, 20, w - 40, h - 40);
ctx.strokeStyle = 'rgba(201,162,39,0.26)';
ctx.lineWidth = 1.2;
ctx.strokeRect(28, 28, w - 56, h - 56);
ctx.fillStyle = 'rgba(201,162,39,0.72)';
for (const [cx, cy] of [[20, 20], [w - 20, 20], [20, h - 20], [w - 20, h - 20]]) {
ctx.save(); ctx.translate(cx, cy); ctx.rotate(Math.PI / 4); ctx.fillRect(-4, -4, 8, 8); ctx.restore();
}
if (emblem) {
// 中央烫金徽记
ctx.strokeStyle = 'rgba(201,162,39,0.55)';
ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(w / 2, h / 2, 84, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(w / 2, h / 2, 70, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = 'rgba(224,196,106,0.92)';
ctx.font = '700 72px "Kaiti SC","STKaiti","KaiTi",serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('史', w / 2, h / 2 + 6);
}
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
// 圆角盒体(ExtrudeGeometry 拉伸 + 倒角)
function roundedRectShape(w, h, r) {
const s = new THREE.Shape();
const x = -w / 2, y = -h / 2;
s.moveTo(x + r, y);
s.lineTo(x + w - r, y);
s.quadraticCurveTo(x + w, y, x + w, y + r);
s.lineTo(x + w, y + h - r);
s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
s.lineTo(x + r, y + h);
s.quadraticCurveTo(x, y + h, x, y + h - r);
s.lineTo(x, y + r);
s.quadraticCurveTo(x, y, x + r, y);
return s;
}
function makeRoundedBox(w, h, d, r, bevel = 0.012) {
const geo = new THREE.ExtrudeGeometry(roundedRectShape(w, h, r), {
depth: d,
bevelEnabled: true,
bevelThickness: bevel,
bevelSize: bevel * 0.85,
bevelSegments: 3,
curveSegments: 10,
});
geo.translate(0, 0, -d / 2);
return geo;
}
export class Binder {
constructor() {
this.group = new THREE.Group();
this.pages = [];
this.current = 0;
this.flip = null; // { index, from, to, t, duration }
this.onChange = null;
this.coverTex = makeLeatherTexture({ emblem: true });
this.spineTex = makeLeatherTexture({ emblem: false });
this._buildCovers();
this._buildRings();
this._buildPages();
// 微微后仰,像阅读架
this.group.rotation.x = -0.09;
}
_buildCovers() {
const coverW = PAGE_W + 0.12;
const coverH = PAGE_H + 0.18;
const thick = 0.05;
const coverMat = new THREE.MeshStandardMaterial({
map: this.coverTex, roughness: 0.58, metalness: 0.1,
});
const spineMat = new THREE.MeshStandardMaterial({
map: this.spineTex, roughness: 0.6, metalness: 0.08,
});
// 圆角倒角封面
const coverGeo = makeRoundedBox(coverW, coverH, thick, 0.07);
this.coverL = new THREE.Mesh(coverGeo, coverMat);
this.coverL.position.set(-(0.05 + coverW / 2), 0, -0.115);
this.coverL.receiveShadow = true;
this.coverL.castShadow = true;
this.coverR = new THREE.Mesh(coverGeo, coverMat);
this.coverR.position.set(0.05 + coverW / 2, 0, -0.115);
this.coverR.receiveShadow = true;
this.coverR.castShadow = true;
// 圆角书脊
const spineGeo = makeRoundedBox(0.1, coverH, 0.16, 0.03, 0.008);
this.spine = new THREE.Mesh(spineGeo, spineMat);
this.spine.position.set(0, 0, -0.11);
this.spine.receiveShadow = true;
// 书脊上下金属箍
const bandMat = new THREE.MeshStandardMaterial({
color: 0xc9a227, metalness: 0.85, roughness: 0.32,
});
const bandGeo = makeRoundedBox(0.115, 0.07, 0.18, 0.02, 0.004);
for (const by of [-0.72, 0.72]) {
const band = new THREE.Mesh(bandGeo, bandMat);
band.position.set(0, by, -0.11);
band.castShadow = true;
this.group.add(band);
}
this.group.add(this.coverL, this.coverR, this.spine);
}
_buildRings() {
const mat = new THREE.MeshStandardMaterial({
color: 0xd8d2c6, metalness: 0.95, roughness: 0.22,
});
// 更平滑的活页环
const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56);
this.rings = [];
for (const u of HOLE_U) {
const y = (0.5 - u) * PAGE_H;
const ring = new THREE.Mesh(geo, mat);
ring.rotation.x = Math.PI / 2; // 环面落在 XZ 平面(绕书脊)
ring.position.set(0, y, 0);
ring.castShadow = true;
this.rings.push(ring);
this.group.add(ring);
}
}
_buildPages() {
const segX = 72, segY = 6;
ENTRIES.forEach((entry, i) => {
const geo = new THREE.PlaneGeometry(PAGE_W, PAGE_H, segX, segY);
// 页内缘离书脊留出小空隙,左右页不紧贴
geo.translate(PAGE_W / 2 + PAGE_GAP, 0, 0);
const base = Float32Array.from(geo.attributes.position.array);
const { front, back } = getPageTextures(entry);
const frontMat = new THREE.MeshStandardMaterial({
map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide,
});
const backMat = new THREE.MeshStandardMaterial({
map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide,
});
const frontMesh = new THREE.Mesh(geo, frontMat);
frontMesh.position.z = 0.0008;
frontMesh.castShadow = true;
const backMesh = new THREE.Mesh(geo, backMat);
backMesh.position.z = -0.0008;
backMesh.castShadow = false;
const g = new THREE.Group();
g.add(frontMesh, backMesh);
g.userData.index = i;
g.userData.entry = entry;
this.pages.push({
group: g,
geometry: geo,
base,
frontMat,
backMat,
angle: 0,
y: 0,
});
this.group.add(g);
});
this._restack();
}
// 各页休息位置:角度 + 世界 z(堆叠深度)
_restack() {
this.pages.forEach((p, j) => {
const angle = j < this.current ? -Math.PI : 0;
p.angle = angle;
p.group.rotation.y = angle;
const depth = j < this.current ? (this.current - 1 - j) : (j - this.current);
const z = -depth * STACK_GAP;
p.group.position.z = z;
p.group.position.y = 0;
p.group.position.x = 0;
// 下层页略暗
const dim = 1 - Math.min(depth * 0.06, 0.22);
p.frontMat.color.setScalar(dim);
p.backMat.color.setScalar(dim);
});
this._updateCurvature();
}
_updateCurvature() {
// 翻页时:靠近书脊的一段平滑卷曲,顶边卷曲略强于底边(斜向扭转),静止时完全平整
this.pages.forEach((p) => {
const pos = p.geometry.attributes.position;
const arr = pos.array;
const theta = Math.abs(p.angle); // 0..π
const c = Math.sin(theta); // 0 静止,1 翻页中途
const L = 0.16 * c; // 卷曲延伸长度
const h = 0.15 * c; // 卷曲抬升高度
for (let i = 0; i < arr.length; i += 3) {
const x = p.base[i];
const y = p.base[i + 1];
const s = Math.max(0, x - PAGE_GAP); // 距页内缘 0..W(防浮点误差产生负值→NaN)
let z = 0;
if (s < L) {
const t = s / L;
const yNorm = (y + PAGE_H / 2) / PAGE_H; // 0 底边 .. 1 顶边
const twist = 0.55 + 0.45 * yNorm;
z = h * twist * Math.sin((Math.PI * t) / 2);
}
arr[i + 2] = p.base[i + 2] + z;
}
pos.needsUpdate = true;
});
}
syncTo(index) {
this.current = clamp(index, 0, this.pages.length - 1);
this.flip = null;
this._restack();
if (this.onChange) this.onChange(this.current);
}
canFlipForward() { return !this.flip && this.current < this.pages.length - 1; }
canFlipBackward() { return !this.flip && this.current > 0; }
flipForward() {
if (!this.canFlipForward()) return;
this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 1.15 };
this.pages[this.current].group.position.z = 0.006;
}
flipBackward() {
if (!this.canFlipBackward()) return;
this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 1.15 };
this.pages[this.current - 1].group.position.z = 0.006;
}
update(dt) {
if (this.flip) {
const f = this.flip;
f.t += dt / f.duration;
const k = clamp(f.t, 0, 1);
const e = easeInOutQuint(k);
const angle = f.from + (f.to - f.from) * e;
const p = this.pages[f.index];
p.angle = angle;
p.group.rotation.y = angle;
p.group.position.y = 0.05 * Math.sin(Math.PI * k);
this._updateCurvature();
if (k >= 1) {
if (f.to === -Math.PI) this.current += 1; else this.current -= 1;
this.flip = null;
this._restack();
if (this.onChange) this.onChange(this.current);
}
}
}
dispose() {
this.pages.forEach((p) => {
p.geometry.dispose();
p.frontMat.map.dispose();
p.frontMat.dispose();
p.backMat.map.dispose();
p.backMat.dispose();
});
}
}