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
+24
View File
@@ -0,0 +1,24 @@
import fs from 'node:fs';
import { PNG } from 'pngjs';
for (const name of process.argv.slice(2)) {
const png = PNG.sync.read(fs.readFileSync(name));
const { width: w, height: h, data } = png;
let sum = 0, bright = 0, n = 0;
// 分区域统计
const zones = { center: 0, left: 0, right: 0 };
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
sum += lum; n++;
if (lum > 40) bright++;
if (lum > 40) {
if (x < w / 3) zones.left++;
else if (x > (2 * w) / 3) zones.right++;
else zones.center++;
}
}
}
console.log(`${name}: ${w}x${h} avgLum=${(sum / n).toFixed(1)} brightPct=${((bright / n) * 100).toFixed(1)}% left/center/right=${zones.left}/${zones.center}/${zones.right}`);
}
+29
View File
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import { PNG } from 'pngjs';
// 以 ASCII 字符打印截图的亮度分布,粗略预览构图
for (const name of process.argv.slice(2)) {
const png = PNG.sync.read(fs.readFileSync(name));
const { width: w, height: h, data } = png;
const cols = 72, rows = 26;
const chars = ' .:-=+*#%@';
console.log(`\n=== ${name} ===`);
for (let r = 0; r < rows; r++) {
let line = '';
for (let c = 0; c < cols; c++) {
const x0 = Math.floor((c / cols) * w), x1 = Math.floor(((c + 1) / cols) * w);
const y0 = Math.floor((r / rows) * h), y1 = Math.floor(((r + 1) / rows) * h);
let sum = 0, n = 0;
for (let y = y0; y < y1; y += 3) {
for (let x = x0; x < x1; x += 3) {
const i = (y * w + x) * 4;
sum += 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
n++;
}
}
const lum = sum / n;
line += chars[Math.min(chars.length - 1, Math.floor((lum / 255) * chars.length))];
}
console.log(line);
}
}
+37
View File
@@ -0,0 +1,37 @@
import fs from 'node:fs';
import { PNG } from 'pngjs';
// 颜色分类预览:G=金, R=红, B=蓝, E=绿, P=纸(亮), .=暗
// 用于检查地球上的弧线(G)与标记(R/B/E)
for (const name of process.argv.slice(2)) {
const png = PNG.sync.read(fs.readFileSync(name));
const { width: w, height: h, data } = png;
const cols = 90, rows = 36;
console.log(`\n=== ${name} ===`);
for (let r = 0; r < rows; r++) {
let line = '';
for (let c = 0; c < cols; c++) {
const x0 = Math.floor((c / cols) * w), x1 = Math.floor(((c + 1) / cols) * w);
const y0 = Math.floor((r / rows) * h), y1 = Math.floor(((r + 1) / rows) * h);
let R = 0, G = 0, B = 0, n = 0;
for (let y = y0; y < y1; y += 2) {
for (let x = x0; x < x1; x += 2) {
const i = (y * w + x) * 4;
R += data[i]; G += data[i + 1]; B += data[i + 2]; n++;
}
}
R /= n; G /= n; B /= n;
const lum = 0.299 * R + 0.587 * G + 0.114 * B;
let ch = '.';
if (lum > 150) ch = 'P';
else if (R > 150 && G > 110 && B < 120) ch = 'G'; // 金
else if (R > 110 && G < 90 && B < 90) ch = 'R'; // 红
else if (B > 90 && R < 90 && G < 110) ch = 'B'; // 蓝
else if (G > 70 && R < 110 && B < 110) ch = 'E'; // 绿
else if (lum > 60) ch = ':';
else if (lum > 30) ch = '-';
line += ch;
}
console.log(line);
}
}
+43
View File
@@ -0,0 +1,43 @@
// 截图 + 控制台错误采集(开发辅助脚本)
import puppeteer from 'puppeteer-core';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const URL = process.argv[2] || 'http:/localhost:5199/';
const OUT = process.argv[3] || 'shot';
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--window-size=1440,900'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const logs = [];
page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`));
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await new Promise((r) => setTimeout(r, 6000));
// 依次截图三种视图
await page.screenshot({ path: `${OUT}-binder.png` });
await page.click('.view-btn[data-view="timeline"]');
await new Promise((r) => setTimeout(r, 2600));
await page.screenshot({ path: `${OUT}-timeline.png` });
await page.click('.view-btn[data-view="globe"]');
await new Promise((r) => setTimeout(r, 2600));
await page.screenshot({ path: `${OUT}-globe.png` });
// 翻页后截图(回到活页本并翻一页)
await page.click('.view-btn[data-view="binder"]');
await new Promise((r) => setTimeout(r, 2600));
await page.click('#btn-next');
await new Promise((r) => setTimeout(r, 1600));
await page.screenshot({ path: `${OUT}-binder-flipped.png` });
console.log('LOGS:\n' + logs.join('\n'));
await browser.close();
+71
View File
@@ -0,0 +1,71 @@
import puppeteer from 'puppeteer-core';
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const URL = process.argv[2] || 'http://localhost:5199/';
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--window-size=1440,900'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900 });
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
await new Promise((r) => setTimeout(r, 6000));
async function snapshot(label) {
const info = await page.evaluate(() => {
const app = window.__app;
const gl = app.renderer.getContext();
const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
const buf = new Uint8Array(w * h * 4);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
let sum = 0, n = 0, bright = 0;
for (let i = 0; i < buf.length; i += 4) {
const lum = 0.299 * buf[i] + 0.587 * buf[i + 1] + 0.114 * buf[i + 2];
sum += lum; n++;
if (lum > 40) bright++;
}
return {
view: app.view,
selected: app.selected,
binderCurrent: app.binder.current,
pages: app.binder.pages.length,
timelineCards: app.timeline.cards.length,
globeMarkers: app.globe.markers.length,
detailName: document.getElementById('detail-name')?.textContent,
detailNo: document.getElementById('detail-no')?.textContent,
avgLum: +(sum / n).toFixed(2),
brightPct: +((bright / n) * 100).toFixed(2),
};
});
console.log(label, JSON.stringify(info, null, 0));
}
await snapshot('binder ');
await page.click('.view-btn[data-view="timeline"]');
await new Promise((r) => setTimeout(r, 2400));
await snapshot('timeline');
await page.click('.view-btn[data-view="globe"]');
await new Promise((r) => setTimeout(r, 2400));
await snapshot('globe ');
await page.click('.view-btn[data-view="binder"]');
await new Promise((r) => setTimeout(r, 2400));
await page.click('#btn-next');
await new Promise((r) => setTimeout(r, 1500));
await snapshot('flipped ');
await page.click('#btn-prev');
await new Promise((r) => setTimeout(r, 1500));
await snapshot('back ');
console.log('ERRORS:', errors.length ? errors.join('\n') : '(none)');
await browser.close();