Replace Three.js globe with Cesium

Migrate the globe view to a Cesium viewer with Esri satellite
imagery and terrain, dropping the custom blue-marble texture and
dot-bead rendering. Add vite-plugin-cesium and a container element
for the viewer.

Also rework the binder leather and suede materials with dedicated
normal maps, and refine timeline labels and page hole sizing.
This commit is contained in:
2026-09-18 21:29:30 +08:00
parent ac12405bdf
commit 13d3ef180a
14 changed files with 1281 additions and 384 deletions
+73 -17
View File
@@ -1,5 +1,43 @@
import * as THREE from 'three';
// 超采样倍数:文字以更高分辨率栅格化,拉近/放大后依然清晰
const SS = 4;
// 折行:尽量均分两行(标题较短),均分不满足时逐字折行
function wrapText(ctx, text, maxW) {
if (maxW === Infinity || ctx.measureText(text).width <= maxW) return [text];
let best = 1;
let bestDiff = Infinity;
for (let i = 1; i < text.length; i++) {
const a = ctx.measureText(text.slice(0, i)).width;
const b = ctx.measureText(text.slice(i)).width;
if (a > maxW || b > maxW) continue;
const diff = Math.abs(a - b);
if (diff < bestDiff) { bestDiff = diff; best = i; }
}
const first = text.slice(0, best);
const second = text.slice(best);
if (ctx.measureText(first).width <= maxW && ctx.measureText(second).width <= maxW) {
return [first, second];
}
// 兜底:逐字折行
const lines = [];
let line = '';
for (const ch of text) {
if (line && ctx.measureText(line + ch).width > maxW) {
lines.push(line);
line = ch;
} else {
line += ch;
}
}
if (line) lines.push(line);
if (!lines.length) lines.push('');
return lines;
}
// 用 Canvas 生成文字精灵(始终面向相机的标签)
export function makeTextSprite(text, {
fontSize = 42,
@@ -9,36 +47,54 @@ export function makeTextSprite(text, {
padding = 24,
font = 'Georgia, "Noto Serif SC", serif',
weight = '600',
letterSpacing = '0.08em',
scale = 1, // 单行文字的精灵世界高度
maxWidth = Infinity, // 精灵世界最大宽度,超宽则居中换行
lineHeight = 1.6, // 行高倍数(相对字号)
} = {}) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = `${weight} ${fontSize}px ${font}`;
const w = Math.ceil(ctx.measureText(text).width) + padding * 2;
const h = Math.ceil(fontSize * 1.6);
canvas.width = w;
canvas.height = h;
const fontStr = `${weight} ${fontSize}px ${font}`;
ctx.font = fontStr;
const lineH = Math.ceil(fontSize * lineHeight);
// 每行文本像素预算:保证该行显示宽度 ≤ maxWidth 世界单位
const textBudget = maxWidth === Infinity ? Infinity : (maxWidth * lineH) / scale - padding * 2;
const lines = wrapText(ctx, text, textBudget);
const maxLineW = Math.max(0, ...lines.map((l) => ctx.measureText(l).width));
const w = Math.ceil(maxLineW) + padding * 2;
const h = lineH * lines.length;
canvas.width = Math.ceil(w * SS);
canvas.height = Math.ceil(h * SS);
const c2 = canvas.getContext('2d');
c2.font = ctx.font;
c2.scale(SS, SS);
c2.font = fontStr;
c2.textBaseline = 'middle';
c2.textAlign = 'center';
c2.lineJoin = 'round';
if (stroke) {
c2.lineWidth = strokeWidth;
c2.strokeStyle = stroke;
c2.strokeText(text, w / 2, h / 2);
}
c2.fillStyle = color;
c2.fillText(text, w / 2, h / 2);
lines.forEach((line, i) => {
const y = lineH * (i + 0.5);
if (stroke) {
c2.lineWidth = strokeWidth;
c2.strokeStyle = stroke;
c2.strokeText(line, w / 2, y);
}
c2.fillStyle = color;
c2.fillText(line, w / 2, y);
});
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
tex.anisotropy = 8;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.generateMipmaps = true;
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(mat);
const aspect = w / h;
sprite.scale.set(aspect, 1, 1);
// 单行世界高度 = scale;多行总高度 = numLines * scale(宽度按 w/lineH 同步换算)
sprite.scale.set((w / lineH) * scale, lines.length * scale, 1);
return sprite;
}