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
+587
View File
@@ -0,0 +1,587 @@
// 程序化"素描"生成器:用带手绘抖动的线条绘制各条目的速写。
// 同一词条每次渲染结果一致(基于名称的种子),供 3D 页背与 HTML 面板复用。
const INK = '#3a2d22';
const INK_SOFT = 'rgba(58,45,34,0.55)';
const INK_FAINT = 'rgba(58,45,34,0.28)';
const WASH = 'rgba(150,120,80,0.10)';
// 确定性伪随机数(mulberry32)
function prng(seedStr) {
let a = 0;
for (let i = 0; i < seedStr.length; i++) {
a = (a * 31 + seedStr.charCodeAt(i)) >>> 0;
}
a = a || 123456789;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function renderSketch(entry, size = 760) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
const rand = prng(entry.name + entry.sketch);
ctx.clearRect(0, 0, size, size);
const cx = size / 2;
const cy = size / 2;
const r = size * 0.42;
const P = (x, y) => [cx + x * r, cy + y * r];
// 手绘折线
function line(pts, { color = INK, lw = 2.4, passes = 2, wobble = 0.015, close = false, dash = null } = {}) {
ctx.strokeStyle = color;
ctx.lineWidth = lw * (size / 760);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (dash) ctx.setLineDash(dash);
else ctx.setLineDash([]);
for (let p = 0; p < passes; p++) {
ctx.beginPath();
pts.forEach((pt, i) => {
const [x, y] = P(pt[0], pt[1]);
const jx = wobble * r * (rand() - 0.5) * 2;
const jy = wobble * r * (rand() - 0.5) * 2;
if (i === 0) ctx.moveTo(x + jx, y + jy);
else ctx.lineTo(x + jx, y + jy);
});
if (close) ctx.closePath();
ctx.stroke();
}
ctx.setLineDash([]);
}
// 形状填充(可选淡淡水彩底)
function fill(pts, color = WASH) {
ctx.fillStyle = color;
ctx.beginPath();
pts.forEach((pt, i) => {
const [x, y] = P(pt[0], pt[1]);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fill();
}
// 阴影排线(在裁剪区域内)
function hatch(pts, { spacing = 0.035, angle = -45, color = INK_SOFT, lw = 1.1, box = 2.4 } = {}) {
ctx.save();
ctx.beginPath();
pts.forEach((pt, i) => {
const [x, y] = P(pt[0], pt[1]);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.clip();
ctx.save();
ctx.translate(cx, cy);
ctx.rotate((angle * Math.PI) / 180);
ctx.strokeStyle = color;
ctx.lineWidth = lw * (size / 760);
ctx.beginPath();
const L = box * r;
const s = spacing * r;
for (let i = -60; i < 60; i++) {
ctx.moveTo(i * s, -L);
ctx.lineTo(i * s, L);
}
ctx.stroke();
ctx.restore();
ctx.restore();
}
function circle(cx0, cy0, rad, opts = {}) {
const { color = INK, lw = 2.2, passes = 1, fill: f = null } = opts;
const [x, y] = P(cx0, cy0);
const rr = rad * r;
if (f) { ctx.fillStyle = f; ctx.beginPath(); ctx.arc(x, y, rr, 0, Math.PI * 2); ctx.fill(); }
ctx.strokeStyle = color;
ctx.lineWidth = lw * (size / 760);
for (let p = 0; p < passes; p++) {
ctx.beginPath();
const j = (rand() - 0.5) * r * 0.02;
ctx.arc(x + j, y + j, rr, 0, Math.PI * 2);
ctx.stroke();
}
}
function dot(x, y, rad, color = INK) {
const [px, py] = P(x, y);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(px, py, rad * r, 0, Math.PI * 2);
ctx.fill();
}
// 各类型速写
const types = {
fossil() {
// 头骨
fill([[0, 0.35], [-0.55, 0.05], [-0.5, -0.5], [-0.2, -0.78], [0.3, -0.75], [0.58, -0.4], [0.5, 0.0], [0.3, 0.1]], 'rgba(150,120,80,0.12)');
line([[0, 0.32], [-0.52, 0.05], [-0.5, -0.42], [-0.3, -0.66], [0.0, -0.74], [0.32, -0.68], [0.55, -0.4], [0.5, 0.0], [0.32, 0.12]], { lw: 2.8 });
// 眼窝
circle(-0.22, -0.18, 0.16, { lw: 2 });
circle(0.28, -0.18, 0.15, { lw: 2 });
// 鼻腔
line([[-0.02, -0.05], [0.0, 0.14], [-0.16, 0.14]], { lw: 2 });
line([[-0.02, -0.05], [0.0, 0.14], [0.14, 0.14]], { lw: 1.4 });
// 上颌与牙齿
line([[0.05, 0.12], [0.22, 0.1], [0.32, 0.12]], { lw: 2.2 });
for (let i = 0; i < 5; i++) {
line([[-0.42 + i * 0.16, 0.02], [-0.42 + i * 0.16, 0.16]], { lw: 1.2, passes: 1, wobble: 0.004 });
}
// 骨裂纹理
line([[-0.1, -0.7], [-0.16, -0.55], [-0.05, -0.45]], { color: INK_FAINT, lw: 1 });
line([[0.2, -0.66], [0.1, -0.55], [0.18, -0.42]], { color: INK_FAINT, lw: 1 });
// 下方地平线与年代波纹
line([[-0.6, 0.5], [0.6, 0.5]], { color: INK_FAINT, lw: 1.2, passes: 1 });
line([[-0.45, 0.62], [0.45, 0.62]], { color: INK_FAINT, lw: 1, passes: 1 });
},
tablet() {
fill([[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]], 'rgba(170,140,95,0.16)');
line([[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]], { lw: 3, wobble: 0.01 });
// 楔形文字符号:小三角
for (let row = 0; row < 5; row++) {
for (let col = 0; col < 6; col++) {
const x = -0.36 + col * 0.15;
const y = -0.34 + row * 0.18;
const s = 0.04 + rand() * 0.02;
line([[x, y], [x - s, y - s * 1.4]], { lw: 1.6, passes: 1, wobble: 0.005 });
line([[x, y], [x + s, y - s * 1.4]], { lw: 1.6, passes: 1, wobble: 0.005 });
line([[x, y], [x, y - s * 1.8]], { lw: 1.6, passes: 1, wobble: 0.005 });
}
}
// 裂缝
line([[-0.1, 0.44], [0.02, 0.2], [-0.08, -0.05], [0.05, -0.3]], { color: INK_SOFT, lw: 1.4, passes: 1 });
line([[-0.44, -0.1], [-0.25, -0.15]], { color: INK_SOFT, lw: 1.2, passes: 1 });
},
pyramid() {
// 地平线
line([[-0.72, 0.42], [0.72, 0.42]], { lw: 2 });
// 主金字塔
fill([[-0.52, 0.42], [0.0, -0.62], [0.55, 0.42]], 'rgba(160,120,70,0.12)');
line([[-0.52, 0.42], [0.0, -0.62], [0.55, 0.42], [-0.52, 0.42]], { lw: 3 });
// 石砌纹理
for (let i = 1; i <= 5; i++) {
const t = i / 6;
const xl = -0.52 + (0.52) * t;
const xr = 0.55 - (0.55) * t;
const y = 0.42 - (1.04) * t;
line([[xl, y], [xr, y]], { color: INK_SOFT, lw: 1.1, passes: 1, wobble: 0.004 });
}
// 小金字塔
line([[-0.72, 0.42], [-0.5, 0.0], [-0.28, 0.42]], { lw: 2 });
line([[-0.72, 0.42], [-0.5, 0.0], [-0.28, 0.42], [-0.72, 0.42]], { lw: 2, color: INK_SOFT });
// 太阳
circle(0.55, -0.55, 0.16, { lw: 2.4 });
for (let i = 0; i < 10; i++) {
const a = (i / 10) * Math.PI * 2 + 0.3;
line([[0.55 + Math.cos(a) * 0.2, -0.55 + Math.sin(a) * 0.2], [0.55 + Math.cos(a) * 0.28, -0.55 + Math.sin(a) * 0.28]], { lw: 1.6, passes: 1 });
}
// 沙丘波纹
line([[-0.7, 0.62], [-0.2, 0.62]], { color: INK_FAINT, lw: 1.2, passes: 1 });
line([[0.1, 0.66], [0.65, 0.66]], { color: INK_FAINT, lw: 1.2, passes: 1 });
},
figure() {
// 圆形徽章背景
circle(0, 0, 0.85, { color: INK_FAINT, lw: 1.4, passes: 1 });
circle(0, 0, 0.72, { color: INK_FAINT, lw: 1, passes: 1 });
// 头部轮廓(侧影)
fill([[0.18, -0.28], [0.18, -0.1], [0.24, 0.04], [0.3, 0.14], [0.26, 0.24], [0.2, 0.3], [0.24, 0.5], [0.05, 0.55], [-0.05, 0.5], [-0.02, 0.34], [0.0, 0.24], [-0.05, 0.12], [-0.04, -0.1], [-0.05, -0.4], [0.02, -0.6], [0.12, -0.66], [0.18, -0.28]], 'rgba(120,95,70,0.12)');
line([[0.05, -0.62], [0.16, -0.56], [0.18, -0.3], [0.2, -0.12], [0.25, 0.02], [0.3, 0.12], [0.27, 0.22], [0.21, 0.3], [0.25, 0.46], [0.14, 0.55], [0.02, 0.56], [-0.04, 0.5], [-0.02, 0.34], [0.0, 0.24], [-0.05, 0.14], [-0.05, -0.08], [-0.04, -0.3], [0.0, -0.52], [0.05, -0.62]], { lw: 2.8 });
// 发髻 / 头发
line([[0.05, -0.62], [-0.02, -0.5], [0.02, -0.38], [-0.03, -0.26], [0.0, -0.14]], { lw: 2, color: INK_SOFT });
hatch([[0.02, -0.6], [0.16, -0.56], [0.18, -0.3], [0.0, -0.52], [0.02, -0.6]], { spacing: 0.03, angle: 60, color: INK_SOFT, lw: 1 });
// 眼睛、眉、耳
line([[0.2, -0.02], [0.28, -0.02], [0.25, 0.04], [0.2, -0.02]], { lw: 1.8, passes: 1 });
line([[0.19, -0.1], [0.3, -0.07]], { lw: 1.8, passes: 1 });
line([[-0.05, -0.08], [-0.02, -0.2]], { lw: 1.6, passes: 1 });
// 肩部与衣领
line([[-0.28, 0.55], [0.0, 0.55]], { lw: 2.6 });
line([[-0.55, 0.85], [0.0, 0.55], [0.6, 0.85]], { lw: 2.4 });
line([[-0.42, 0.7], [0.05, 0.58]], { lw: 1.6, color: INK_SOFT, passes: 1 });
},
temple() {
// 台基
line([[-0.7, 0.6], [0.7, 0.6]], { lw: 2.6 });
line([[-0.64, 0.66], [0.64, 0.66]], { lw: 2 });
// 柱子
for (let i = 0; i < 6; i++) {
const x = -0.5 + i * 0.2;
line([[x, 0.58], [x, -0.1]], { lw: 2 });
line([[x - 0.02, 0.58], [x - 0.02, -0.1]], { lw: 1, color: INK_SOFT, passes: 1 });
}
// 柱顶横梁
line([[-0.6, -0.1], [0.6, -0.1]], { lw: 2.4 });
line([[-0.58, -0.18], [0.58, -0.18]], { lw: 2 });
// 三角楣
fill([[-0.58, -0.18], [0.0, -0.66], [0.58, -0.18]], 'rgba(140,110,70,0.10)');
line([[-0.58, -0.18], [0.0, -0.66], [0.58, -0.18], [-0.58, -0.18]], { lw: 2.6 });
// 楣内浮雕圆
circle(0, -0.4, 0.1, { lw: 1.8, passes: 1 });
// 山门台阶
for (let i = 1; i <= 3; i++) {
line([[-0.3 - i * 0.08, 0.66 + i * 0.05], [0.3 + i * 0.08, 0.66 + i * 0.05]], { lw: 1.6, color: INK_SOFT, passes: 1 });
}
},
crown() {
// 冠身
fill([[-0.55, 0.5], [-0.55, -0.1], [0.55, -0.1], [0.55, 0.5]], 'rgba(160,120,50,0.14)');
line([[-0.55, 0.5], [-0.55, -0.1], [0.55, -0.1], [0.55, 0.5]], { lw: 3 });
// 齿尖与宝珠
const peaks = [-0.55, -0.28, 0.0, 0.28, 0.55];
peaks.forEach((px, i) => {
const h = 0.5 + (i % 2 === 0 ? 0.26 : 0.4);
line([[px, 0.5], [px + (i % 2 === 0 ? 0.02 : 0), h]], { lw: 2.4 });
dot(px, h + 0.07, 0.05, '#a03a2a');
});
// 冠带
line([[-0.58, 0.28], [0.58, 0.28]], { lw: 2, color: INK_SOFT });
// 中央纹饰
circle(0, 0.12, 0.14, { lw: 2, passes: 1 });
dot(0, 0.12, 0.05, '#a03a2a');
// 底部绒边
for (let i = 0; i < 14; i++) {
const x = -0.55 + i * 0.085;
dot(x, -0.12, 0.02, INK_SOFT);
}
hatch([[-0.55, 0.5], [-0.55, -0.1], [0.55, -0.1], [0.55, 0.5]], { spacing: 0.035, angle: -60, color: INK_FAINT, lw: 1 });
},
colosseum() {
// 底层地面
line([[-0.75, 0.6], [0.75, 0.6]], { lw: 2 });
// 三层椭圆拱券
const tiers = [
{ y: 0.12, rw: 0.68, rh: 0.16, n: 7 },
{ y: -0.1, rw: 0.74, rh: 0.15, n: 8 },
{ y: -0.32, rw: 0.8, rh: 0.14, n: 9 },
];
tiers.forEach((t) => {
// 顶部线(略残缺)
line([[-t.rw, t.y - t.rh], [t.rw, t.y - t.rh]], { lw: 2.2 });
});
tiers.forEach((t) => {
for (let i = 0; i <= t.n; i++) {
const a = -0.9 + (i / t.n) * 1.8; // 参数 -0.9..0.9
const x = a * t.rw;
const y = t.y;
// 拱券(简化小竖线 + 弧)
line([[x, y], [x, y + t.rh * 0.7]], { lw: 1.6, color: INK_SOFT, passes: 1 });
if (i < t.n) {
const x2 = (a + 0.9 / t.n) * t.rw;
const mx = (x + x2) / 2;
line([[x, y + t.rh * 0.7], [mx, y + t.rh * 1.15], [x2, y + t.rh * 0.7]], { lw: 1.4, color: INK_SOFT, passes: 1 });
}
}
});
// 残破上缘
line([[-0.82, -0.48], [-0.6, -0.42], [-0.4, -0.55], [-0.1, -0.4], [0.2, -0.52], [0.5, -0.4], [0.82, -0.46]], { lw: 2.2, passes: 1 });
line([[-0.82, 0.56], [-0.85, -0.3]], { lw: 2, passes: 1 });
line([[0.82, 0.56], [0.85, -0.3]], { lw: 2, passes: 1 });
},
scroll() {
// 卷轴两端
circle(-0.52, 0, 0.09, { lw: 2.2 });
circle(0.52, 0, 0.09, { lw: 2.2 });
// 展开的纸
fill([[-0.52, -0.28], [0.52, -0.28], [0.52, 0.28], [-0.52, 0.28]], 'rgba(210,190,150,0.20)');
line([[-0.52, -0.28], [0.52, -0.28]], { lw: 2.4 });
line([[-0.52, 0.28], [0.52, 0.28]], { lw: 2.4 });
// 竖排文字行
for (let i = 0; i < 7; i++) {
const x = -0.38 + i * 0.13;
line([[x, -0.18], [x, 0.18]], { lw: 1.3, color: INK_SOFT, passes: 1, wobble: 0.005 });
}
// 毛笔
line([[0.05, -0.6], [0.02, -0.9]], { lw: 2.2 });
line([[0.0, -0.92], [0.14, -0.98], [0.05, -0.72]], { lw: 2, color: INK_SOFT, passes: 1 });
// 墨滴
dot(0.3, 0.42, 0.03, INK_SOFT);
dot(0.36, 0.46, 0.02, INK_FAINT);
},
horseman() {
// 地平线
line([[-0.75, 0.55], [0.75, 0.55]], { lw: 1.8, color: INK_FAINT });
// 马(剪影线条)
fill([[0.2, 0.5], [0.32, 0.05], [0.55, 0.0], [0.6, 0.3], [0.42, 0.42], [0.2, 0.5]], 'rgba(90,65,45,0.10)');
line([[0.25, 0.5], [0.35, 0.15], [0.42, 0.05], [0.58, 0.05], [0.6, 0.28], [0.45, 0.4], [0.25, 0.5]], { lw: 2.6 });
// 马腿
line([[0.3, 0.45], [0.26, 0.55]], { lw: 2 });
line([[0.44, 0.42], [0.42, 0.55]], { lw: 2 });
line([[0.55, 0.3], [0.6, 0.55]], { lw: 2 });
// 马尾
line([[0.25, 0.5], [0.08, 0.35], [0.02, 0.5]], { lw: 2, color: INK_SOFT });
// 骑手
line([[-0.05, 0.4], [-0.02, 0.15], [0.05, 0.05], [0.12, 0.15], [0.1, 0.4]], { lw: 2.4 });
circle(-0.02, -0.02, 0.09, { lw: 1.8, passes: 1 });
// 弓
line([[-0.05, 0.18], [-0.3, 0.28]], { lw: 1.6, passes: 1 });
line([[-0.3, 0.28], [-0.32, 0.1]], { lw: 1.6, color: INK_SOFT, passes: 1 });
// 箭
line([[-0.28, 0.24], [-0.55, 0.14]], { lw: 1.4, passes: 1 });
// 旗帜
line([[0.1, 0.15], [0.4, -0.3]], { lw: 1.8 });
line([[0.4, -0.3], [0.32, -0.32], [0.38, -0.4], [0.42, -0.28]], { lw: 1.6, passes: 1 });
},
ship() {
// 波浪
line([[-0.75, 0.5], [-0.4, 0.5], [-0.2, 0.42], [0.0, 0.5], [0.3, 0.42], [0.55, 0.5], [0.75, 0.5]], { lw: 1.6, color: INK_SOFT, passes: 1 });
line([[-0.7, 0.66], [0.7, 0.66]], { lw: 1.4, color: INK_FAINT, passes: 1 });
// 船体
fill([[-0.5, 0.12], [0.5, 0.12], [0.38, 0.4], [-0.4, 0.4]], 'rgba(80,60,40,0.14)');
line([[-0.5, 0.12], [0.5, 0.12], [0.4, 0.38], [-0.42, 0.38], [-0.5, 0.12]], { lw: 2.6 });
// 桅杆
line([[0.0, 0.1], [0.0, -0.7]], { lw: 2.2 });
line([[-0.35, 0.14], [-0.35, -0.45]], { lw: 1.8 });
line([[0.35, 0.14], [0.35, -0.45]], { lw: 1.8 });
// 帆(鼓风)
fill([[0.0, -0.62], [0.42, -0.5], [0.3, -0.2], [0.0, -0.28]], 'rgba(200,180,140,0.22)');
line([[0.0, -0.62], [0.4, -0.5], [0.3, -0.2], [0.0, -0.28], [0.0, -0.62]], { lw: 1.8 });
fill([[-0.35, -0.4], [-0.05, -0.45], [-0.08, -0.2], [-0.35, -0.15]], 'rgba(200,180,140,0.18)');
line([[-0.35, -0.4], [-0.06, -0.44], [-0.1, -0.18], [-0.35, -0.14], [-0.35, -0.4]], { lw: 1.6 });
fill([[0.35, -0.4], [0.65, -0.48], [0.62, -0.2], [0.35, -0.15]], 'rgba(200,180,140,0.18)');
line([[0.35, -0.4], [0.62, -0.48], [0.6, -0.18], [0.35, -0.14], [0.35, -0.4]], { lw: 1.6 });
// 旗帜
line([[0.0, -0.7], [0.0, -0.78]], { lw: 1.6 });
line([[0.0, -0.76], [0.2, -0.74], [0.0, -0.7]], { lw: 1.4, passes: 1 });
// 海鸟
line([[-0.6, -0.4], [-0.5, -0.46], [-0.4, -0.4]], { lw: 1.2, color: INK_SOFT, passes: 1 });
line([[-0.3, -0.55], [-0.2, -0.61], [-0.1, -0.55]], { lw: 1.2, color: INK_SOFT, passes: 1 });
},
globe() {
circle(0, 0, 0.66, { lw: 2.6 });
// 经纬线
circle(0, 0, 0.66, { lw: 1, color: INK_FAINT, passes: 1 });
for (const k of [0.3, 0.6]) {
ctx.save();
ctx.translate(cx, cy);
ctx.strokeStyle = INK_FAINT;
ctx.lineWidth = 1 * (size / 760);
ctx.beginPath();
ctx.ellipse(0, 0, 0.66 * r * Math.sqrt(1 - k * k), 0.66 * r, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(0, 0, 0.66 * r, 0.66 * r * Math.sqrt(1 - k * k), 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 大陆点阵
for (let i = 0; i < 60; i++) {
const a = rand() * Math.PI * 2;
const rr = Math.sqrt(rand()) * 0.6;
dot(Math.cos(a) * rr, Math.sin(a) * rr, 0.012, INK_SOFT);
}
// 航线(虚线)与箭头
line([[0.3, -0.3], [0.55, -0.15], [0.6, 0.1], [0.45, 0.35], [0.15, 0.45]], { color: '#a03a2a', lw: 2, passes: 1, dash: [14, 9] });
line([[0.15, 0.45], [0.02, 0.52]], { color: '#a03a2a', lw: 2, passes: 1 });
line([[0.15, 0.45], [0.22, 0.55]], { color: '#a03a2a', lw: 2, passes: 1 });
// 起止点
dot(0.3, -0.3, 0.04, '#a03a2a');
dot(0.15, 0.45, 0.04, '#a03a2a');
},
apple() {
// 苹果
fill([[0, -0.55], [0.35, -0.42], [0.42, -0.1], [0.28, 0.22], [0.0, 0.42], [-0.3, 0.2], [-0.4, -0.15], [-0.3, -0.44]], 'rgba(150,60,40,0.14)');
line([[0.02, -0.5], [0.36, -0.4], [0.44, -0.08], [0.28, 0.24], [0.0, 0.44], [-0.3, 0.2], [-0.42, -0.16], [-0.32, -0.42]], { lw: 2.6 });
line([[0.02, -0.5], [0.36, -0.4]], { lw: 2.6 });
// 咬痕
circle(-0.52, -0.05, 0.18, { lw: 2, passes: 1, color: INK_SOFT });
// 果柄与叶
line([[0.0, -0.5], [0.02, -0.72]], { lw: 2.2 });
fill([[0.02, -0.72], [0.28, -0.78], [0.2, -0.58]], 'rgba(60,90,50,0.16)');
line([[0.02, -0.72], [0.28, -0.76], [0.2, -0.6], [0.02, -0.72]], { lw: 1.8 });
// 下落的轨迹(引力)
line([[-0.1, 0.55], [-0.25, 0.72]], { color: INK_SOFT, lw: 1.6, passes: 1, dash: [10, 8] });
dot(-0.3, 0.78, 0.03, INK_SOFT);
line([[0.55, 0.5], [0.62, 0.62]], { color: INK_FAINT, lw: 1.2, passes: 1 });
},
machine() {
// 底座
line([[-0.7, 0.55], [0.7, 0.55]], { lw: 2.4 });
// 锅炉
fill([[-0.45, -0.2], [0.1, -0.2], [0.1, 0.1], [-0.45, 0.1]], 'rgba(90,70,50,0.14)');
line([[-0.45, -0.2], [0.1, -0.2], [0.1, 0.1], [-0.45, 0.1], [-0.45, -0.2]], { lw: 2.4 });
// 铆钉
for (let i = 0; i < 5; i++) dot(-0.4 + i * 0.12, -0.2, 0.02, INK_SOFT);
for (let i = 0; i < 5; i++) dot(-0.4 + i * 0.12, 0.1, 0.02, INK_SOFT);
// 活塞杆
line([[0.1, -0.05], [0.42, -0.05]], { lw: 2.2 });
line([[0.42, -0.1], [0.42, 0.0]], { lw: 1.6 });
// 飞轮
circle(0.5, -0.05, 0.3, { lw: 2.4 });
circle(0.5, -0.05, 0.08, { lw: 1.8, passes: 1 });
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
line([[0.5 + Math.cos(a) * 0.08, -0.05 + Math.sin(a) * 0.08], [0.5 + Math.cos(a) * 0.3, -0.05 + Math.sin(a) * 0.3]], { lw: 1.4, passes: 1 });
}
// 蒸汽
line([[-0.3, -0.32], [-0.2, -0.45], [-0.32, -0.6]], { color: INK_SOFT, lw: 1.8, passes: 1 });
line([[-0.1, -0.4], [0.0, -0.55], [-0.08, -0.72]], { color: INK_SOFT, lw: 1.8, passes: 1 });
line([[0.1, -0.34], [0.22, -0.48], [0.14, -0.62]], { color: INK_FAINT, lw: 1.6, passes: 1 });
// 传动带
line([[0.1, -0.05], [0.2, -0.1], [0.5, -0.32]], { lw: 1.2, color: INK_SOFT, passes: 1, dash: [9, 7] });
},
bell() {
// 钟轭
line([[-0.5, -0.55], [0.5, -0.55]], { lw: 2.6 });
line([[-0.5, -0.55], [-0.42, -0.28]], { lw: 2 });
line([[0.5, -0.55], [0.42, -0.28]], { lw: 2 });
// 钟身
fill([[-0.42, -0.28], [0.42, -0.28], [0.55, 0.45], [0.3, 0.5], [-0.3, 0.5], [-0.55, 0.45]], 'rgba(120,95,70,0.12)');
line([[-0.42, -0.28], [0.42, -0.28], [0.55, 0.45], [0.3, 0.5], [-0.3, 0.5], [-0.55, 0.45], [-0.42, -0.28]], { lw: 2.6 });
// 底口线
line([[-0.55, 0.45], [0.55, 0.45]], { lw: 2 });
// 裂纹(自由钟标志)
line([[-0.05, -0.1], [0.02, 0.05], [-0.08, 0.22], [0.0, 0.42]], { lw: 2, color: '#a03a2a', passes: 1 });
// 铭文带
line([[-0.36, 0.05], [0.36, 0.05]], { lw: 1.4, color: INK_SOFT, passes: 1 });
// 钟舌
line([[0.0, -0.1], [0.0, 0.3]], { lw: 1.8 });
circle(0, 0.32, 0.07, { lw: 1.6, passes: 1 });
},
tree() {
// 树干
line([[-0.0, 0.55], [0.0, 0.2], [0.02, -0.1]], { lw: 2.6 });
// 树根
line([[-0.15, 0.55], [0.0, 0.35]], { lw: 2 });
line([[0.15, 0.55], [0.0, 0.35]], { lw: 2 });
// 递归枝干
const branch = (x, y, ang, len, depth) => {
const ex = x + Math.cos(ang) * len;
const ey = y + Math.sin(ang) * len;
line([[x, y], [ex, ey]], { lw: Math.max(0.8, 2.4 * (len / 0.4)), passes: 1, wobble: 0.006 });
if (depth > 0) {
branch(ex, ey, ang + 0.5, len * 0.68, depth - 1);
branch(ex, ey, ang - 0.5, len * 0.68, depth - 1);
branch(ex, ey, ang + 0.2, len * 0.55, depth - 1);
}
};
branch(0.02, -0.1, -Math.PI / 2, 0.42, 3);
// 叶点
for (let i = 0; i < 90; i++) {
const a = rand() * Math.PI * 2;
const rr = Math.sqrt(rand()) * 0.5;
dot(Math.cos(a) * rr * 1.2, -0.5 + Math.sin(a) * rr * 0.8, 0.012 + rand() * 0.012, INK_SOFT);
}
// 地面
line([[-0.6, 0.6], [0.6, 0.6]], { lw: 1.8, color: INK_FAINT });
},
atom() {
// 原子核
circle(0, 0, 0.1, { lw: 2 });
dot(0.03, 0.03, 0.03, '#a03a2a');
dot(-0.04, -0.02, 0.02, '#a03a2a');
// 轨道
const orbit = (rx, ry, rot) => {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rot);
ctx.strokeStyle = INK_SOFT;
ctx.lineWidth = 1.6 * (size / 760);
ctx.beginPath();
ctx.ellipse(0, 0, rx * r, ry * r, 0, 0, Math.PI * 2);
ctx.stroke();
// 电子
const ea = rand() * Math.PI * 2;
const ex = Math.cos(ea) * rx * r;
const ey = Math.sin(ea) * ry * r;
ctx.fillStyle = '#a03a2a';
ctx.beginPath();
ctx.arc(ex, ey, 0.045 * r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
orbit(0.66, 0.3, 0.2);
orbit(0.66, 0.3, 2.2);
orbit(0.5, 0.66, 1.2);
// 公式
ctx.save();
ctx.translate(cx, cy + 0.86 * r);
ctx.fillStyle = INK;
ctx.font = `${Math.round(0.16 * r)}px Georgia, serif`;
ctx.textAlign = 'center';
ctx.fillText('E = mc²', 0, 0);
ctx.restore();
},
dove() {
// 鸽身(飞翔剪影)
fill([[-0.4, 0.1], [-0.2, -0.1], [0.0, -0.12], [0.2, -0.1], [0.35, 0.05], [0.1, 0.18], [-0.2, 0.2]], 'rgba(200,190,170,0.22)');
line([[-0.42, 0.08], [-0.2, -0.1], [0.0, -0.14], [0.2, -0.1], [0.34, 0.05], [0.1, 0.18], [-0.2, 0.2], [-0.42, 0.08]], { lw: 2.4 });
// 翅膀
line([[-0.1, -0.06], [-0.3, -0.5], [-0.05, -0.62]], { lw: 2.2 });
line([[-0.3, -0.5], [-0.02, -0.28]], { lw: 1.4, color: INK_SOFT });
// 眼与喙
dot(-0.34, 0.02, 0.03, INK);
line([[-0.42, 0.06], [-0.52, 0.12], [-0.42, 0.12]], { lw: 1.6, passes: 1 });
// 橄榄枝
line([[-0.52, 0.12], [-0.62, 0.3]], { lw: 1.8 });
for (let i = 0; i < 5; i++) {
const a = -0.4 + i * 0.5;
const bx = -0.62 + Math.cos(a) * 0.1;
const by = 0.3 + Math.sin(a) * 0.05 + i * 0.01;
line([[-0.62 + (i * 0.02), 0.28 + i * 0.005], [bx, by]], { lw: 1.2, passes: 1 });
}
// 光芒
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
line([[Math.cos(a) * 0.72, Math.sin(a) * 0.72], [Math.cos(a) * 0.82, Math.sin(a) * 0.82]], { lw: 1.4, color: INK_FAINT, passes: 1 });
}
},
rocket() {
// 星空
for (let i = 0; i < 30; i++) {
dot(-0.85 + rand() * 1.7, -0.85 + rand() * 1.4, 0.008 + rand() * 0.01, INK_FAINT);
}
// 火箭主体
fill([[-0.14, -0.7], [0.14, -0.7], [0.14, 0.5], [-0.14, 0.5]], 'rgba(200,200,205,0.20)');
line([[-0.14, -0.7], [0.14, -0.7], [0.14, 0.5], [-0.14, 0.5], [-0.14, -0.7]], { lw: 2.4 });
// 头锥
line([[-0.14, -0.7], [0.0, -0.92], [0.14, -0.7]], { lw: 2.4 });
// 舷窗
circle(0, -0.3, 0.07, { lw: 1.6, passes: 1 });
// 尾翼
line([[-0.14, 0.2], [-0.3, 0.5]], { lw: 2 });
line([[-0.14, 0.2], [-0.16, 0.5]], { lw: 1.2, color: INK_SOFT });
line([[0.14, 0.2], [0.3, 0.5]], { lw: 2 });
line([[0.14, 0.2], [0.16, 0.5]], { lw: 1.2, color: INK_SOFT });
// 喷焰
line([[-0.08, 0.5], [0.0, 0.72], [0.08, 0.5]], { lw: 2, color: '#a03a2a' });
line([[-0.05, 0.5], [0.0, 0.62], [0.05, 0.5]], { lw: 1.4, color: '#c9a227' });
// 月球
circle(0.62, -0.6, 0.22, { lw: 2.2, fill: 'rgba(210,200,180,0.16)' });
circle(0.55, -0.66, 0.05, { lw: 1.2, passes: 1, color: INK_FAINT });
circle(0.68, -0.55, 0.035, { lw: 1.2, passes: 1, color: INK_FAINT });
// 旗帜
line([[0.4, 0.5], [0.4, 0.3]], { lw: 1.4 });
line([[0.4, 0.3], [0.56, 0.34], [0.4, 0.4]], { lw: 1.4, passes: 1 });
},
};
const draw = types[entry.sketch] || types.fossil;
draw();
return canvas;
}