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
+349
View File
@@ -0,0 +1,349 @@
import * as THREE from 'three';
import { CATEGORY_INFO } from '../data/entries.js';
import { renderSketch } from './sketch.js';
// —— 页面几何 / 纹理常量 ——
export const PAGE_W = 1.6; // 世界单位:页宽
export const PAGE_H = 2.24; // 世界单位:页高
export const TEX_W = 768;
export const TEX_H = 1075;
export const HOLE_U = [0.085, 0.24, 0.4, 0.56, 0.72, 0.855]; // 活页孔(自顶部归一化)
export const HOLE_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02)
export const HOLE_R = 0.052; // 孔半径(世界)
const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",Georgia,serif';
const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif';
const NUM = 'Georgia,"Times New Roman",serif';
// 版面边距(左边缘避开活页孔,右/上/下为常规边距)
const MARGIN = { left: 86, right: 58, top: 50, bottom: 50 };
const CONTENT_L = MARGIN.left + 18; // 内容左界
const CONTENT_R = TEX_W - MARGIN.right - 18; // 内容右界
const CONTENT_W = CONTENT_R - CONTENT_L; // 内容宽度
// —— 基础纸张 ——
export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed = 0 } = {}) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
// 底色(暖黄纸)
const base = [232 - tone, 216 - tone, 182 - tone];
const g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, `rgb(${base[0] + 6},${base[1] + 5},${base[2] + 4})`);
g.addColorStop(0.5, `rgb(${base[0]},${base[1]},${base[2]})`);
g.addColorStop(1, `rgb(${base[0] - 8},${base[1] - 7},${base[2] - 6})`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
// 随机纤维与斑点
const rand = mulberry(seed + 1);
for (let i = 0; i < w * h / 90; i++) {
const x = rand() * w, y = rand() * h;
const dark = rand() > 0.5;
ctx.fillStyle = dark ? 'rgba(120,95,60,0.10)' : 'rgba(255,250,230,0.14)';
ctx.fillRect(x, y, rand() * 2 + 0.4, rand() * 2 + 0.4);
}
// 纤维线条
ctx.strokeStyle = 'rgba(140,110,70,0.07)';
ctx.lineWidth = 0.7;
for (let i = 0; i < w * h / 2600; i++) {
const x = rand() * w, y = rand() * h, a = rand() * Math.PI, l = rand() * 22 + 6;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l);
ctx.stroke();
}
// 纸面晕染
for (let i = 0; i < 16; i++) {
const x = rand() * w, y = rand() * h, r = rand() * 140 + 40;
const gr = ctx.createRadialGradient(x, y, 0, x, y, r);
const warm = rand() > 0.5;
gr.addColorStop(0, warm ? 'rgba(180,150,95,0.05)' : 'rgba(120,100,70,0.05)');
gr.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gr;
ctx.fillRect(0, 0, w, h);
}
// 边缘做旧(暗角)
const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72);
edge.addColorStop(0, 'rgba(0,0,0,0)');
edge.addColorStop(1, 'rgba(90,60,30,0.16)');
ctx.fillStyle = edge;
ctx.fillRect(0, 0, w, h);
// 横格线
if (ruled) {
ctx.strokeStyle = 'rgba(120,95,60,0.28)';
ctx.lineWidth = 0.8;
for (let y = 90; y < h - 60; y += 34) {
ctx.beginPath(); ctx.moveTo(margin ? 84 : 40, y); ctx.lineTo(w - 40, y); ctx.stroke();
}
}
// 红色边线
if (margin) {
ctx.strokeStyle = 'rgba(160,60,40,0.45)';
ctx.lineWidth = 1.1;
ctx.beginPath(); ctx.moveTo(84, 40); ctx.lineTo(84, h - 40); ctx.stroke();
}
return { canvas: c, ctx };
}
function mulberry(seed) {
let a = seed || 1;
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;
};
}
// 画活页孔
function drawHoles(ctx, w, h) {
const hx = (HOLE_X / PAGE_W) * w;
const hr = (HOLE_R / PAGE_W) * w;
for (const u of HOLE_U) {
const y = u * h;
// 孔内阴影(穿过去的感觉)
const g = ctx.createRadialGradient(hx, y, 0, hx, y, hr);
g.addColorStop(0, '#2b1f13');
g.addColorStop(0.62, '#4a3826');
g.addColorStop(0.82, '#6b5740');
g.addColorStop(1, '#8a735a');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(hx, y, hr, 0, Math.PI * 2); ctx.fill();
// 孔缘
ctx.strokeStyle = 'rgba(70,50,32,0.85)';
ctx.lineWidth = 2.2;
ctx.beginPath(); ctx.arc(hx, y, hr, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = 'rgba(255,244,214,0.35)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(hx, y, hr - 2, Math.PI * 0.15, Math.PI * 0.85); ctx.stroke();
}
}
// 描边 + 填充的通用文字
function text(ctx, str, x, y, { font, size, color = '#3a2d22', align = 'left', baseline = 'alphabetic', weight = 'normal', ls = 0 } = {}) {
ctx.font = `${weight} ${size}px ${font}`;
ctx.fillStyle = color;
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(str, x, y);
}
// 双线装饰框(左边缘避开活页孔)
function drawBorder(ctx, w, h, color = 'rgba(90,61,43,0.55)') {
const m = MARGIN;
ctx.strokeStyle = color;
ctx.lineWidth = 1.4;
ctx.strokeRect(m.left, m.top, w - m.left - m.right, h - m.top - m.bottom);
ctx.lineWidth = 0.7;
ctx.strokeRect(m.left + 8, m.top + 8, w - m.left - m.right - 16, h - m.top - m.bottom - 16);
// 角饰
ctx.fillStyle = color;
const corners = [[m.left, m.top], [w - m.right, m.top], [m.left, h - m.bottom], [w - m.right, h - m.bottom]];
for (const [cx, cy] of corners) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(Math.PI / 4);
ctx.fillRect(-3, -3, 6, 6);
ctx.restore();
}
}
// 类别印章
function drawSeal(ctx, category, x, y, size = 52) {
const info = CATEGORY_INFO[category] || CATEGORY_INFO['事'];
ctx.save();
ctx.translate(x, y);
ctx.rotate(-0.05);
ctx.fillStyle = info.color;
ctx.globalAlpha = 0.92;
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.strokeStyle = 'rgba(255,240,210,0.85)';
ctx.lineWidth = 2;
ctx.strokeRect(-size / 2 + 4, -size / 2 + 4, size - 8, size - 8);
ctx.fillStyle = '#f5e8cf';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${size * 0.62}px ${KAI}`;
ctx.fillText(category, 0, 2);
ctx.restore();
ctx.globalAlpha = 1;
}
// —— 正面:登记卡 ——
export function renderFront(entry) {
const w = TEX_W, h = TEX_H;
const { canvas, ctx } = makePaper(w, h, { seed: hash(entry.id) });
const info = CATEGORY_INFO[entry.category] || CATEGORY_INFO['事'];
drawBorder(ctx, w, h);
drawHoles(ctx, w, h);
// 页眉:编号 + 印章
text(ctx, '编 号 · ' + entry.id, CONTENT_L, 92, { font: NUM, size: 26, color: '#5a432c' });
drawSeal(ctx, entry.category, CONTENT_R - 30, 86, 58);
text(ctx, info.en, CONTENT_R - 30, 128, { font: NUM, size: 15, color: '#7a6147', align: 'center' });
// 顶部分隔线
ctx.strokeStyle = 'rgba(90,61,43,0.4)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(CONTENT_L, 160); ctx.lineTo(CONTENT_R, 160); ctx.stroke();
// 名称(大字,居中)
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 36, 86)}px ${KAI}`;
ctx.fillStyle = '#33271a';
ctx.fillText(entry.name, w / 2, 248);
// 名称下划线装饰
ctx.strokeStyle = info.color;
ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(w / 2 - 220, 314); ctx.lineTo(w / 2 + 220, 314); ctx.stroke();
ctx.fillStyle = info.color;
ctx.beginPath(); ctx.arc(w / 2, 314, 4, 0, Math.PI * 2); ctx.fill();
// 登记字段
const labelX = CONTENT_L + 56; // 标签右对齐
const colonX = CONTENT_L + 64; // 冒号
const valX = CONTENT_L + 82; // 值起始
const fy = 372, rowH = 62;
const fields = [
['时间', entry.time],
['地区', entry.region],
['类别', `${entry.category} · ${info.en}`],
];
fields.forEach(([label, value], i) => {
const y = fy + i * rowH;
text(ctx, label, labelX, y, { font: SERIF, size: 26, color: '#7a5c38', align: 'right' });
text(ctx, ':', colonX, y, { font: SERIF, size: 26, color: '#7a5c38' });
text(ctx, value, valX, y, { font: SERIF, size: 26, color: '#33271a' });
// 值后虚线延伸至右界
const vw = ctx.measureText(value).width;
ctx.strokeStyle = 'rgba(120,95,60,0.35)';
ctx.lineWidth = 0.9;
ctx.setLineDash([2, 5]);
ctx.beginPath(); ctx.moveTo(valX + vw + 14, y + 12); ctx.lineTo(CONTENT_R, y + 12); ctx.stroke();
ctx.setLineDash([]);
});
// 描述区
const dy = 600;
text(ctx, '描 述', CONTENT_L, dy, { font: SERIF, size: 26, color: '#7a5c38' });
ctx.strokeStyle = 'rgba(120,95,60,0.3)';
ctx.lineWidth = 0.9;
ctx.beginPath(); ctx.moveTo(CONTENT_L, dy + 18); ctx.lineTo(CONTENT_R, dy + 18); ctx.stroke();
ctx.font = `400 24px ${SERIF}`;
ctx.fillStyle = '#33271a';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const maxW = CONTENT_W;
let line = '', yy = dy + 40;
for (const ch of entry.description) {
const test = line + ch;
if (ctx.measureText(test).width > maxW && line) {
ctx.fillText(line, CONTENT_L, yy);
line = ch; yy += 40;
} else line = test;
}
if (line) ctx.fillText(line, CONTENT_L, yy);
// 页脚
text(ctx, '人类文明里程碑 · 活页编年史', w / 2, h - 52, { font: SERIF, size: 19, color: '#7a6147', align: 'center' });
text(ctx, entry.id, CONTENT_R, h - 52, { font: NUM, size: 18, color: '#8a735a', align: 'right' });
return canvas;
}
// —— 背面:素描 ——
export function renderBack(entry) {
const w = TEX_W, h = TEX_H;
const { canvas, ctx } = makePaper(w, h, { seed: hash(entry.id) + 7 });
drawBorder(ctx, w, h);
drawHoles(ctx, w, h);
// 内容水平镜像(背面用 BackSide 渲染,镜像后正读)——关于竖直中线镜像
ctx.save();
ctx.translate(w / 2, 0);
ctx.scale(-1, 1);
ctx.translate(-w / 2, 0);
// 页眉(居中)
text(ctx, '素 描 · SKETCH', w / 2, 92, { font: NUM, size: 24, color: '#6b5740', align: 'center' });
text(ctx, entry.id, w / 2, 124, { font: NUM, size: 20, color: '#8a735a', align: 'center' });
ctx.strokeStyle = 'rgba(90,61,43,0.35)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(w / 2 - 90, 152); ctx.lineTo(w / 2 + 90, 152); ctx.stroke();
// 速写(居中)
const sketch = renderSketch(entry, 520);
const sx = (w - 520) / 2;
const sy = 170;
ctx.drawImage(sketch, sx, sy);
ctx.strokeStyle = 'rgba(90,61,43,0.3)';
ctx.lineWidth = 0.8;
ctx.strokeRect(sx - 8, sy - 8, 520 + 16, 520 + 16);
// 题注(居中)
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `700 ${fitFont(ctx, entry.name, CONTENT_W - 40, 32, 78)}px ${KAI}`;
ctx.fillStyle = '#33271a';
ctx.fillText(entry.name, w / 2, 772);
ctx.font = `400 25px ${SERIF}`;
ctx.fillStyle = '#6b5740';
ctx.fillText(`${entry.time} · ${entry.region}`, w / 2, 828);
// 波浪线(居中)
ctx.strokeStyle = 'rgba(90,61,43,0.5)';
ctx.lineWidth = 1.4;
ctx.beginPath();
for (let x = w / 2 - 130; x <= w / 2 + 130; x += 10) {
const yy = 868 + Math.sin((x - w / 2) / 26) * 5;
if (x === w / 2 - 130) ctx.moveTo(x, yy); else ctx.lineTo(x, yy);
}
ctx.stroke();
ctx.restore();
return canvas;
}
// 名称自适应字号
function fitFont(ctx, str, maxW, minSize, maxSize) {
if (minSize > maxSize) [minSize, maxSize] = [maxSize, minSize]; // 容错:确保 min < max
let size = maxSize;
ctx.font = `700 ${size}px ${KAI}`;
while (ctx.measureText(str).width > maxW && size > minSize) {
size -= 2;
ctx.font = `700 ${size}px ${KAI}`;
}
return size;
}
function hash(str) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return h;
}
// 生成页面的两个纹理
const texCache = new Map();
export function getPageTextures(entry) {
if (texCache.has(entry.id)) return texCache.get(entry.id);
const front = new THREE.CanvasTexture(renderFront(entry));
front.colorSpace = THREE.SRGBColorSpace;
front.anisotropy = 8;
const back = new THREE.CanvasTexture(renderBack(entry));
back.colorSpace = THREE.SRGBColorSpace;
back.anisotropy = 8;
const pair = { front, back };
texCache.set(entry.id, pair);
return pair;
}
+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;
}