fix: smooth donut lab

This commit is contained in:
zhom committed 2026-10-20 10:28:48 +04:00
1 parent 88197492ad
commit 7ed16725cf
5 files changed
+1075 -289

No files matched your search

+11
View File
@@ -2097,6 +2097,15 @@ test("About shows diagnostics and hides a donut lab that takes bites inside the
const node = document.querySelector('[data-slot="donut-lab"]');
return node ? { bites: Number(node.dataset.bites), state: node.dataset.state } : null;
`);
// Captures show the finished donut, not the reveal out of the logo.
const labReady = () =>
app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-slot="donut-lab"]')?.dataset.phase === "ready";`,
),
{ description: "the donut at rest after its reveal" },
);
await openAbout();
const system = await app.invoke("get_system_info");
@@ -2172,6 +2181,7 @@ test("About shows diagnostics and hides a donut lab that takes bites inside the
"the lab covers the About content",
);
assert.deepEqual(await lab(), { bites: 0, state: "whole" });
await labReady();
await app.capture("donut-lab");
await app.clickSelector(`${slot("donut-lab")} canvas`);
@@ -2227,6 +2237,7 @@ test("About shows diagnostics and hides a donut lab that takes bites inside the
await resize(app, 640, 400);
await assertContained(app, '[role="dialog"]');
await assertContained(app, slot("donut-lab"));
await labReady();
await app.capture("donut-lab-small");
});
});
+11 -4
View File
@@ -124,7 +124,11 @@ export function AboutDialog({
const [licenseQuery, setLicenseQuery] = useState("");
const [check, setCheck] = useState<CheckState>("idle");
const [copied, setCopied] = useState(false);
const [lab, setLab] = useState<{ x: number; y: number } | null>(null);
const [lab, setLab] = useState<{
x: number;
y: number;
size: number;
} | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const logoRef = useRef<HTMLButtonElement>(null);
@@ -245,7 +249,11 @@ export function AboutDialog({
clicks.length = 0;
const rect = logo.getBoundingClientRect();
contentRef.current?.scrollTo({ top: 0 });
setLab({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
setLab({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
size: rect.width,
});
return;
}
// Each press squeezes a little harder, as if something were inside.
@@ -486,8 +494,7 @@ export function AboutDialog({
</nav>
</div>
<footer className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border pt-3 text-[11px] text-muted-foreground">
<p>{t("about.footer")}</p>
<footer className="flex items-center justify-end border-t border-border pt-3 text-[11px] text-muted-foreground">
<LinkButton onClick={() => void openUrl(REPOSITORY_URL)}>
<LuGithub aria-hidden="true" className="size-3.5" />
{t("about.sourceCode")}
+294 -245
View File
@@ -19,24 +19,39 @@ import {
addBite,
type Bite,
bakeDonut,
type CellMetrics,
CRUMB_GLYPHS,
type Crumb,
centerLens,
crumbAlpha,
DEFAULT_CELL,
type Donut,
type Frame,
findBiteTarget,
forEachGlyph,
forEachRun,
GLYPHS,
type Grid,
isEaten,
KIND_CRUMB,
KIND_DOUGH,
KIND_EMPTY,
KIND_GLAZE,
KIND_SPRINKLE,
layoutGrid,
MAX_FLICK,
type Mat3,
type Momentum,
materialAt,
type Pose,
REVEAL_MS,
type RevealOrigin,
remainingShare,
renderFrame,
revealPose,
SPRINKLE_COLORS,
START_ORIENTATION,
spin,
spinStep,
stepCrumbs,
turn,
} from "@/lib/donut-lab";
import { MOTION_DURATION, MOTION_EASE_OUT } from "@/lib/motion";
@@ -46,36 +61,15 @@ import { cn } from "@/lib/utils";
/** Radians per pixel of drag, and per arrow key press. */
const DRAG_TURN = 0.011;
const KEY_TURN = 0.26;
/** Radians per second the donut turns on its own axis when left alone. */
const IDLE_SPIN = 0.55;
/** How fast a flick slows down, per second. */
const FRICTION = 2.6;
const MAX_FLICK = 9;
const ASSEMBLY_MS = 700;
const CHOMP_MS = 140;
const GRAVITY = 1100;
const CRUMB_GLYPHS = ".,'`:;*";
const SCRAMBLE = "@%#*+=-:.";
const TARGET_COLS = 64;
type Assembly = {
start: number;
/** "burst": out of the logo. "rings": in from rings around the view. */
mode: "burst" | "rings";
x: number;
y: number;
};
interface Crumb {
x: number;
y: number;
vx: number;
vy: number;
char: string;
kind: number;
age: number;
life: number;
}
/** The longest step the clock takes between two frames, in seconds. */
const MAX_STEP = 0.1;
/** Ends the reveal when frames stop coming (a paused webview). */
const REVEAL_FALLBACK_MS = REVEAL_MS + 400;
/** The logo's size, when the opener does not give it. */
const LOGO_SIZE = 56;
const MONO_FALLBACK =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace';
interface Palette {
foreground: string;
@@ -112,14 +106,46 @@ function resolvePalette(host: HTMLElement): Palette {
return { foreground, sprinkles, ink };
}
function easeOut(t: number) {
return 1 - (1 - t) ** 3;
/**
* The mono font stack. The `font-mono` class does not reach it: the theme
* reads --font-geist-mono at :root, and next/font sets it on body.
*/
function monoFamily(host: HTMLElement) {
return (
getComputedStyle(host).getPropertyValue("--font-geist-mono").trim() ||
MONO_FALLBACK
);
}
/** A stable pseudo-random 0..1 for a cell, changing every `tick`. */
function flicker(index: number, tick: number) {
const n = Math.sin(index * 12.9898 + tick * 78.233) * 43758.5453;
return n - Math.floor(n);
/**
* The cell of the font the canvas draws with: its widest glyph, its line, and
* whether every glyph has the same advance.
*/
function measureCell(
context: CanvasRenderingContext2D,
family: string,
): CellMetrics {
context.font = `100px ${family}`;
let widest = 0;
let narrowest = Number.POSITIVE_INFINITY;
for (const char of GLYPHS) {
const width = context.measureText(char).width;
widest = Math.max(widest, width);
narrowest = Math.min(narrowest, width);
}
const metrics = context.measureText("M");
const line =
(metrics.fontBoundingBoxAscent ?? 0) +
(metrics.fontBoundingBoxDescent ?? 0);
return {
advance: widest / 100 || DEFAULT_CELL.advance,
lineHeight: line > 0 ? line / 100 : DEFAULT_CELL.lineHeight,
mono: widest > 0 && widest - narrowest < 0.2,
};
}
function easeOut(t: number) {
return 1 - (1 - t) ** 3;
}
const KIND_ALPHA: Record<number, number> = {
@@ -129,14 +155,15 @@ const KIND_ALPHA: Record<number, number> = {
};
interface DonutLabProps {
/** The logo's center in client pixels: the donut grows out of it. */
origin: { x: number; y: number } | null;
/** The logo's center (and width) in client pixels: the donut comes out of it. */
origin: { x: number; y: number; size?: number } | null;
onClose: () => void;
}
/**
* The About easter egg: a donut drawn in text, ray marched live. Drag to
* turn it, click or press Space to bite it, bake another when it is gone.
* Every motion runs on one clock that advances with frame time.
*/
export function DonutLab({ origin, onClose }: DonutLabProps) {
const { t } = useTranslation();
@@ -149,8 +176,7 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
const donutRef = useRef<Donut | null>(null);
donutRef.current ??= bakeDonut();
const orientationRef = useRef<Mat3>(START_ORIENTATION);
const flickRef = useRef({ x: 0, y: 0 });
const idleRef = useRef(1);
const momentumRef = useRef<Momentum>({ x: 0, y: 0, idle: 1 });
const dragRef = useRef<{
id: number;
x: number;
@@ -163,17 +189,22 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
const suppressClickRef = useRef(false);
const crumbsRef = useRef<Crumb[]>([]);
const chompRef = useRef<{ bite: Bite; start: number } | null>(null);
const assemblyRef = useRef<Assembly | null>(null);
/** Milliseconds of animation so far: frame time, each step clamped. */
const clockRef = useRef(0);
const lastFrameRef = useRef<number | null>(null);
const revealRef = useRef<{ start: number; origin: RevealOrigin } | null>(
null,
);
const revealTimerRef = useRef(0);
const poseRef = useRef<Pose | null>(null);
const layoutRef = useRef<{
grid: Grid;
fontSize: number;
font: string;
dpr: number;
mono: boolean;
} | null>(null);
const frameRef = useRef<Frame | null>(null);
const paletteRef = useRef<Palette | null>(null);
const rafRef = useRef(0);
const lastTickRef = useRef(0);
const motionRef = useRef({ reducedMotion, motionAllowed });
motionRef.current = { reducedMotion, motionAllowed };
@@ -181,237 +212,202 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
const [left, setLeft] = useState(100);
const [eaten, setEaten] = useState(false);
const [dragging, setDragging] = useState(false);
const [phase, setPhase] = useState<"reveal" | "ready">(() =>
origin && motionAllowed ? "reveal" : "ready",
);
const draw = useCallback((now: number) => {
const draw = useCallback(() => {
const canvas = canvasRef.current;
const layout = layoutRef.current;
const palette = paletteRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !layout || !palette || !context) return;
const { grid, font, dpr } = layout;
const donut = donutRef.current;
if (!donut) return;
const context = canvas?.getContext("2d");
if (!canvas || !layout || !palette || !donut || !context) return;
const { grid, font, mono } = layout;
const reveal = revealRef.current;
const pose = revealPose(
orientationRef.current,
grid,
reveal?.origin ?? null,
reveal ? clockRef.current - reveal.start : Number.POSITIVE_INFINITY,
);
poseRef.current = pose;
const frame = renderFrame(
donut,
orientationRef.current,
pose.orientation,
grid,
palette.ink,
frameRef.current ?? undefined,
pose.lens,
);
frameRef.current = frame;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, grid.width, grid.height);
context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
context.clearRect(0, 0, canvas.width, canvas.height);
context.setTransform(
canvas.width / grid.width,
0,
0,
canvas.height / grid.height,
0,
0,
);
context.font = font;
context.fontKerning = "none";
context.textBaseline = "middle";
context.textAlign = "left";
const colorOf = (kind: number) =>
kind >= KIND_SPRINKLE
? palette.sprinkles[kind - KIND_SPRINKLE]
: palette.foreground;
const alphaOf = (kind: number) => KIND_ALPHA[kind] ?? 1;
const assembly = assemblyRef.current;
const progress = assembly
? Math.min(1, (now - assembly.start) / ASSEMBLY_MS)
: 1;
if (assembly && progress < 1) {
// Every character flies to its place: out of the logo, or in from
// rings around the view, still scrambled until it lands.
const cx = grid.width / 2;
const cy = grid.height / 2;
const reach = Math.hypot(cx, cy);
const tick = Math.floor(now / 45);
for (
let kind = KIND_DOUGH;
kind < KIND_SPRINKLE + SPRINKLE_COLORS;
kind++
) {
context.fillStyle = colorOf(kind);
for (let i = 0; i < frame.kinds.length; i++) {
if (frame.kinds[i] !== kind) continue;
const col = i % grid.cols;
const row = (i - col) / grid.cols;
const fx = grid.offsetX + (col + 0.5) * grid.cellWidth;
const fy = grid.offsetY + (row + 0.5) * grid.cellHeight;
const distance = Math.hypot(fx - cx, fy - cy) / reach;
const delay =
(assembly.mode === "burst" ? distance : 1 - distance) * 0.35;
const local = easeOut(
Math.min(1, Math.max(0, (progress - delay) / 0.65)),
);
let x: number;
let y: number;
if (assembly.mode === "burst") {
const sx = assembly.x + (fx - assembly.x) * 0.15;
const sy = assembly.y + (fy - assembly.y) * 0.15;
x = sx + (fx - sx) * local;
y = sy + (fy - sy) * local;
} else {
const angle = Math.atan2(fy - cy, fx - cx) + 0.9 * (1 - local);
const radius =
Math.hypot(fx - cx, fy - cy) + reach * 0.6 * (1 - local);
x = cx + Math.cos(angle) * radius;
y = cy + Math.sin(angle) * radius;
}
const char =
local < 0.75
? SCRAMBLE[Math.floor(flicker(i, tick) * SCRAMBLE.length)]
: frame.chars[i];
context.globalAlpha = alphaOf(kind) * (0.55 + 0.45 * local);
context.fillText(char, x - grid.cellWidth / 2, y);
}
}
} else {
if (assembly) assemblyRef.current = null;
// One string per row and color: spaces keep the columns.
const rowChars = Array.from(
{ length: KIND_SPRINKLE + SPRINKLE_COLORS },
() => new Array<string>(grid.cols),
);
const used = new Array<boolean>(KIND_SPRINKLE + SPRINKLE_COLORS);
for (let row = 0; row < grid.rows; row++) {
used.fill(false);
for (const chars of rowChars) chars.fill(" ");
for (let col = 0; col < grid.cols; col++) {
const i = row * grid.cols + col;
const kind = frame.kinds[i];
if (kind === KIND_EMPTY) continue;
rowChars[kind][col] = frame.chars[i];
used[kind] = true;
}
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
for (let kind = KIND_DOUGH; kind < rowChars.length; kind++) {
if (!used[kind]) continue;
context.fillStyle = colorOf(kind);
context.globalAlpha = alphaOf(kind);
context.fillText(rowChars[kind].join(""), grid.offsetX, y);
}
}
// Glyphs stay in their cells: a monospace font draws runs of cells from
// their left edge, any other font draws each glyph at its cell's center.
context.textAlign = mono ? "left" : "center";
const fill = (text: string, x: number, y: number) => {
context.fillText(text, x, y);
};
for (
let kind = KIND_DOUGH;
kind < KIND_SPRINKLE + SPRINKLE_COLORS;
kind++
) {
context.fillStyle = colorOf(kind);
context.globalAlpha = KIND_ALPHA[kind] ?? 1;
if (mono) forEachRun(frame, grid, fill, kind);
else forEachGlyph(frame, grid, fill, kind);
}
context.textAlign = "center";
for (const crumb of crumbsRef.current) {
context.fillStyle = colorOf(crumb.kind);
context.globalAlpha =
alphaOf(crumb.kind) * Math.min(1, (crumb.life - crumb.age) / 0.4);
context.globalAlpha = (KIND_ALPHA[crumb.kind] ?? 1) * crumbAlpha(crumb);
context.fillText(crumb.char, crumb.x, crumb.y);
}
context.globalAlpha = 1;
}, []);
const finishReveal = useCallback(() => {
window.clearTimeout(revealTimerRef.current);
if (!revealRef.current) return;
revealRef.current = null;
setPhase("ready");
}, []);
const needsFrames = useCallback(() => {
const flick = flickRef.current;
const momentum = momentumRef.current;
return (
!motionRef.current.reducedMotion ||
Math.abs(flick.x) + Math.abs(flick.y) > 0.01 ||
momentum.x !== 0 ||
momentum.y !== 0 ||
crumbsRef.current.length > 0 ||
chompRef.current !== null ||
assemblyRef.current !== null
revealRef.current !== null
);
}, []);
const tick = useCallback(
(now: number) => {
rafRef.current = 0;
const dt = Math.min(
0.05,
Math.max(0, (now - lastTickRef.current) / 1000),
);
lastTickRef.current = now;
const { reducedMotion: reduced } = motionRef.current;
const last = lastFrameRef.current;
lastFrameRef.current = now;
const dt =
last === null
? 0
: Math.min(MAX_STEP, Math.max(0, (now - last) / 1000));
clockRef.current += dt * 1000;
const clock = clockRef.current;
if (!dragRef.current?.moved) {
const flick = flickRef.current;
let m = orientationRef.current;
if (Math.abs(flick.x) + Math.abs(flick.y) > 0.01) {
m = turn(m, flick.x * dt, flick.y * dt);
const decay = Math.exp(-FRICTION * dt);
flick.x *= decay;
flick.y *= decay;
} else {
flick.x = 0;
flick.y = 0;
}
if (!reduced) {
idleRef.current = Math.min(1, idleRef.current + dt * 0.8);
m = spin(m, IDLE_SPIN * idleRef.current * dt);
}
orientationRef.current = m;
orientationRef.current = spinStep(
orientationRef.current,
momentumRef.current,
dt,
!motionRef.current.reducedMotion,
);
}
const reveal = revealRef.current;
if (reveal && clock - reveal.start >= REVEAL_MS) finishReveal();
const chomp = chompRef.current;
if (chomp) {
const p = Math.min(1, (now - chomp.start) / CHOMP_MS);
const p = Math.min(1, (clock - chomp.start) / CHOMP_MS);
chomp.bite.grow = 0.35 + 0.65 * easeOut(p);
if (p >= 1) chompRef.current = null;
}
const layout = layoutRef.current;
if (layout && crumbsRef.current.length > 0) {
const floor = layout.grid.height - layout.grid.cellHeight * 0.6;
crumbsRef.current = crumbsRef.current.filter((crumb) => {
crumb.age += dt;
crumb.vy += GRAVITY * dt;
crumb.x += crumb.vx * dt;
crumb.y += crumb.vy * dt;
if (crumb.y > floor) {
crumb.y = floor;
crumb.vy = Math.abs(crumb.vy) < 60 ? 0 : -crumb.vy * 0.3;
crumb.vx *= 0.55;
}
return crumb.age < crumb.life;
});
crumbsRef.current = stepCrumbs(
crumbsRef.current,
dt,
layout.grid.height - layout.grid.cellHeight * 0.6,
);
}
draw(now);
draw();
if (document.visibilityState === "visible" && needsFrames()) {
rafRef.current = requestAnimationFrame(tick);
}
},
[draw, needsFrames],
[draw, finishReveal, needsFrames],
);
/** Draws now, and keeps drawing while something moves. */
const wake = useCallback(() => {
const now = performance.now();
draw(now);
draw();
if (
!rafRef.current &&
document.visibilityState === "visible" &&
needsFrames()
) {
lastTickRef.current = now;
lastFrameRef.current = null;
rafRef.current = requestAnimationFrame(tick);
}
}, [draw, needsFrames, tick]);
// Size the canvas to its box and lay the character grid over it.
/** Starts the donut at `start` (canvas pixels), or at rest when motion is off. */
const startReveal = useCallback(
(start: RevealOrigin | null) => {
window.clearTimeout(revealTimerRef.current);
if (!start || !motionRef.current.motionAllowed) {
revealRef.current = null;
setPhase("ready");
return;
}
revealRef.current = { start: clockRef.current, origin: start };
setPhase("reveal");
revealTimerRef.current = window.setTimeout(() => {
finishReveal();
draw();
}, REVEAL_FALLBACK_MS);
},
[draw, finishReveal],
);
// Size the canvas to its box, in device pixels, and lay the grid over it
// again whenever the box, the pixel ratio or the font changes.
useLayoutEffect(() => {
const canvas = canvasRef.current;
const host = hostRef.current;
if (!canvas || !host) return;
const context = canvas?.getContext("2d");
if (!canvas || !host || !context) return;
paletteRef.current = resolvePalette(host);
const family = getComputedStyle(canvas).fontFamily || "monospace";
const measure = canvas.getContext("2d");
if (measure) measure.font = `100px ${family}`;
let advance = (measure?.measureText("M").width ?? 60) / 100;
const family = monoFamily(host);
let cell = measureCell(context, family);
const resize = () => {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (width === 0 || height === 0) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const { grid, fontSize } = layoutGrid(
width,
height,
advance,
TARGET_COLS,
);
const backingWidth = Math.max(1, Math.round(width * dpr));
const backingHeight = Math.max(1, Math.round(height * dpr));
if (canvas.width !== backingWidth) canvas.width = backingWidth;
if (canvas.height !== backingHeight) canvas.height = backingHeight;
const { grid, fontSize } = layoutGrid(width, height, cell);
layoutRef.current = {
grid,
fontSize,
font: `${fontSize}px ${family}`,
dpr,
mono: cell.mono === true,
};
frameRef.current = null;
wake();
@@ -419,33 +415,59 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
// Measure again once the mono font is in, if it was not yet.
let alive = true;
void document.fonts?.load(`16px ${family}`).then(() => {
if (!alive || !measure) return;
measure.font = `100px ${family}`;
advance = measure.measureText("M").width / 100 || advance;
let media: MediaQueryList | null = null;
const onPixelRatio = () => {
watchPixelRatio();
resize();
});
};
const watchPixelRatio = () => {
media?.removeEventListener("change", onPixelRatio);
media =
window.matchMedia?.(
`(resolution: ${window.devicePixelRatio || 1}dppx)`,
) ?? null;
media?.addEventListener("change", onPixelRatio);
};
watchPixelRatio();
// The mono font may load after the first frame: measure it again then.
let alive = true;
const remeasure = () => {
if (!alive) return;
cell = measureCell(context, family);
resize();
};
void document.fonts?.load(`16px ${family}`, GLYPHS).then(remeasure);
document.fonts?.addEventListener("loadingdone", remeasure);
return () => {
alive = false;
observer.disconnect();
media?.removeEventListener("change", onPixelRatio);
document.fonts?.removeEventListener("loadingdone", remeasure);
};
}, [wake]);
// The donut takes the keys, and grows out of the logo the first time.
// The donut takes the keys, and comes out of the logo the first time.
// biome-ignore lint/correctness/useExhaustiveDependencies: once, on open
useLayoutEffect(() => {
const canvas = canvasRef.current;
canvas?.focus({ preventScroll: true });
if (!canvas || !origin || !motionRef.current.motionAllowed) return;
if (!canvas || !origin) {
startReveal(null);
wake();
return;
}
// The box may be scaled by an entry animation: map client pixels to its own.
const rect = canvas.getBoundingClientRect();
assemblyRef.current = {
start: performance.now(),
mode: "burst",
x: origin.x - rect.left,
y: origin.y - rect.top,
};
const scale = rect.width > 0 ? canvas.clientWidth / rect.width : 1;
const clamp = (value: number, max: number) =>
Math.min(max, Math.max(0, value));
startReveal({
x: clamp((origin.x - rect.left) * scale, canvas.clientWidth),
y: clamp((origin.y - rect.top) * scale, canvas.clientHeight),
size: (origin.size ?? LOGO_SIZE) * scale,
});
wake();
}, []);
@@ -462,30 +484,43 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
document.removeEventListener("visibilitychange", onVisibility);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
window.clearTimeout(revealTimerRef.current);
};
}, [wake]);
// Reduced motion stops the idle spin; a change of setting restarts frames.
useEffect(() => {
if (reducedMotion) {
flickRef.current = { x: 0, y: 0 };
momentumRef.current = { x: 0, y: 0, idle: 0 };
crumbsRef.current = [];
}
wake();
}, [reducedMotion, wake]);
/** Where the donut is drawn now, so a press bites what it is on. */
const currentPose = useCallback((grid: Grid): Pose => {
return (
poseRef.current ?? {
orientation: orientationRef.current,
lens: centerLens(grid),
}
);
}, []);
const takeBite = useCallback(
(x: number, y: number, reach: number) => {
const layout = layoutRef.current;
const donut = donutRef.current;
if (!layout || !donut || isEaten(donut)) return;
const pose = currentPose(layout.grid);
const target = findBiteTarget(
donut,
orientationRef.current,
pose.orientation,
layout.grid,
x,
y,
reach,
pose.lens,
);
if (!target) return;
const { material, sprinkle } = materialAt(donut, target.point);
@@ -498,7 +533,7 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
);
playUiSound("crunch");
if (animate) {
chompRef.current = { bite, start: performance.now() };
chompRef.current = { bite, start: clockRef.current };
const kind =
material === "glaze"
? KIND_GLAZE
@@ -524,8 +559,8 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
});
}
// The donut rocks a little under the bite.
flickRef.current.x += (Math.random() - 0.5) * 1.2;
flickRef.current.y += (Math.random() - 0.5) * 1.2;
momentumRef.current.x += (Math.random() - 0.5) * 1.2;
momentumRef.current.y += (Math.random() - 0.5) * 1.2;
}
const share = remainingShare(donut);
setBites(donut.bites.length);
@@ -533,34 +568,42 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
setEaten(isEaten(donut, share));
wake();
},
[wake],
[currentPose, wake],
);
const biteNearCenter = useCallback(() => {
const layout = layoutRef.current;
if (!layout) return;
const { lens } = currentPose(layout.grid);
const { width, height } = layout.grid;
takeBite(width / 2, height / 2, Math.max(width, height));
}, [takeBite]);
takeBite(lens.x, lens.y, Math.max(width, height));
}, [currentPose, takeBite]);
const bakeAnother = useCallback(() => {
donutRef.current = bakeDonut();
chompRef.current = null;
crumbsRef.current = [];
setBites(0);
setLeft(100);
setEaten(false);
const layout = layoutRef.current;
if (layout && motionRef.current.motionAllowed) {
assemblyRef.current = {
start: performance.now(),
mode: "rings",
x: layout.grid.width / 2,
y: layout.grid.height / 2,
};
}
// The new donut grows out of the middle.
startReveal(
layout
? { x: layout.grid.width / 2, y: layout.grid.height / 2, size: 0 }
: null,
);
canvasRef.current?.focus();
wake();
}, [wake]);
}, [startReveal, wake]);
/** A pointer position in the canvas's own pixels, whatever its transform. */
const canvasPoint = (canvas: HTMLCanvasElement, x: number, y: number) => {
const rect = canvas.getBoundingClientRect();
const scaleX = rect.width > 0 ? canvas.clientWidth / rect.width : 1;
const scaleY = rect.height > 0 ? canvas.clientHeight / rect.height : 1;
return { x: (x - rect.left) * scaleX, y: (y - rect.top) * scaleY };
};
const onPointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
if (event.button !== 0 || dragRef.current) return;
@@ -579,7 +622,6 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
moved: false,
};
suppressClickRef.current = false;
flickRef.current = { x: 0, y: 0 };
};
const onPointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
@@ -591,9 +633,14 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
) {
return;
}
const momentum = momentumRef.current;
if (!drag.moved) {
drag.moved = true;
setDragging(true);
// The hand holds the donut: no flick, no idle spin, until it lets go.
momentum.x = 0;
momentum.y = 0;
momentum.idle = 0;
}
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
@@ -607,9 +654,8 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
dx * DRAG_TURN,
);
// The flick is the drag's recent speed, smoothed.
const flick = flickRef.current;
flick.x = flick.x * 0.6 + ((dy * DRAG_TURN) / dt) * 0.4;
flick.y = flick.y * 0.6 + ((dx * DRAG_TURN) / dt) * 0.4;
momentum.x = momentum.x * 0.6 + ((dy * DRAG_TURN) / dt) * 0.4;
momentum.y = momentum.y * 0.6 + ((dx * DRAG_TURN) / dt) * 0.4;
wake();
};
@@ -620,16 +666,15 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
if (!drag.moved) return;
suppressClickRef.current = true;
setDragging(false);
idleRef.current = 0;
const flick = flickRef.current;
const momentum = momentumRef.current;
// A drag that stopped before release does not fling.
const still = event.timeStamp - drag.at > 80;
if (motionRef.current.reducedMotion || still) {
flick.x = 0;
flick.y = 0;
momentum.x = 0;
momentum.y = 0;
} else {
flick.x = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, flick.x));
flick.y = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, flick.y));
momentum.x = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, momentum.x));
momentum.y = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, momentum.y));
}
wake();
};
@@ -644,8 +689,7 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
const step = turns[event.key];
if (step) {
event.preventDefault();
flickRef.current = { x: 0, y: 0 };
idleRef.current = 0;
momentumRef.current = { x: 0, y: 0, idle: 0 };
orientationRef.current = turn(orientationRef.current, ...step);
wake();
return;
@@ -667,11 +711,12 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
data-slot="donut-lab"
data-bites={bites}
data-state={eaten ? "eaten" : "whole"}
data-phase={phase}
// Readable from the first frame: the entry only settles its scale.
initial={motionAllowed ? { scale: 0.985 } : false}
animate={{ scale: 1 }}
transition={{ duration: MOTION_DURATION.base, ease: MOTION_EASE_OUT }}
className="absolute inset-0 z-20 overflow-hidden rounded-[inherit] bg-background bg-[radial-gradient(circle_at_50%_48%,color-mix(in_oklab,var(--foreground)_7%,transparent),transparent_62%)]"
className="absolute inset-0 z-20 overflow-hidden rounded-[inherit] bg-background bg-[radial-gradient(circle_at_50%_50%,color-mix(in_oklab,var(--foreground)_7%,transparent),transparent_62%)]"
>
<canvas
ref={canvasRef}
@@ -694,16 +739,20 @@ export function DonutLab({ origin, onClose }: DonutLabProps) {
biteNearCenter();
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const point = canvasPoint(
event.currentTarget,
event.clientX,
event.clientY,
);
takeBite(
event.clientX - rect.left,
event.clientY - rect.top,
point.x,
point.y,
Math.min(layout.grid.width, layout.grid.height) * 0.22,
);
}}
onKeyDown={onKeyDown}
className={cn(
"absolute inset-0 size-full touch-none font-mono outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"absolute inset-0 size-full touch-none outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
dragging ? "cursor-grabbing" : "cursor-grab",
)}
/>
+371 -5
View File
@@ -3,32 +3,52 @@ import test from "node:test";
import {
addBite,
bakeDonut,
CRUMB_GLYPHS,
CRUMB_RAMP,
castRay,
centerLens,
crumbAlpha,
DEFAULT_CELL,
DONUT_SPAN,
DOUGH_RAMP,
donutDistance,
findBiteTarget,
forEachGlyph,
forEachRun,
GLAZE_RAMP,
GLYPHS,
identity,
idleOrientation,
isEaten,
KIND_DOUGH,
KIND_EMPTY,
KIND_GLAZE,
KIND_SPRINKLE,
layoutGrid,
MAJOR_RADIUS,
MAX_BITES,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
MINOR_RADIUS,
materialAt,
normalAt,
REVEAL_MS,
rampFor,
remainingShare,
renderFrame,
revealPose,
rotationX,
START_ORIENTATION,
seededRandom,
shade,
spin,
spinStep,
sprinkleGlyph,
stepCrumbs,
surfaceAt,
toObject,
turn,
viewRay,
} from "./donut-lab.ts";
const VIEW = { width: 600, height: 340 };
@@ -200,19 +220,365 @@ test("turns and spins stay rotations", () => {
test("a frame shows glaze, dough and sprinkles around an empty hole", () => {
const donut = bakeDonut();
const { grid, fontSize } = layoutGrid(VIEW.width, VIEW.height);
assert.equal(grid.cols, 64);
assert.ok(grid.rows >= 20 && grid.rows <= 40);
assert.ok(fontSize > 8);
assert.ok(grid.cols >= 48 && grid.rows >= 20);
assert.ok(fontSize >= MIN_FONT_SIZE && fontSize <= MAX_FONT_SIZE);
const frame = renderFrame(donut, START_ORIENTATION, grid, false);
const count = (predicate) => frame.kinds.filter(predicate).length;
assert.ok(count((kind) => kind === KIND_GLAZE) > 100);
assert.ok(count((kind) => kind === KIND_DOUGH) > 10);
assert.ok(count((kind) => kind >= KIND_SPRINKLE) > 10);
const middle =
Math.floor(grid.rows / 2) * grid.cols + Math.floor(grid.cols / 2);
const middle = cellAt(grid, grid.width / 2, grid.height / 2);
assert.equal(frame.kinds[middle], KIND_EMPTY);
assert.equal(frame.chars[middle], " ");
for (const [i, char] of frame.chars.entries()) {
assert.equal(char === " ", frame.kinds[i] === KIND_EMPTY);
}
});
// The canvas sizes the lab from these: a narrow dialog and a wide one.
const GRIDS = {
narrow: { width: 300, height: 220 },
wide: { width: 640, height: 300 },
};
const GEIST = { advance: 0.6, lineHeight: 1.3, mono: true };
const TALL = { advance: 0.5, lineHeight: 1.7, mono: true };
function cellAt(grid, x, y) {
const col = Math.floor((x - grid.offsetX) / grid.cellWidth);
const row = Math.floor((y - grid.offsetY) / grid.cellHeight);
return row * grid.cols + col;
}
/** Where the canvas draws each glyph: the center of its cell, in pixels. */
function glyphs(frame, grid) {
const out = [];
forEachGlyph(frame, grid, (char, x, y, kind) =>
out.push({ char, x, y, kind }),
);
return out;
}
/** The smallest distance from a glyph's ray to the torus's core circle. */
function rayToRing(m, grid, lens, x, y) {
const ray = viewRay(grid, x, y, lens);
const origin = toObject(m, ray.origin);
const dir = toObject(m, ray.dir);
let best = Number.POSITIVE_INFINITY;
for (let t = 3; t <= 11; t += 0.004) {
const px = origin[0] + dir[0] * t;
const py = origin[1] + dir[1] * t;
const pz = origin[2] + dir[2] * t;
const q = Math.hypot(px, pz) - MAJOR_RADIUS;
best = Math.min(best, Math.hypot(q, py));
}
return best;
}
test("the grid is whole cells, centered on the canvas, sized from the measured cell", () => {
for (const view of [...Object.values(GRIDS), { width: 1360, height: 873 }]) {
for (const cell of [GEIST, TALL, { advance: 0.92, lineHeight: 1.2 }]) {
const { grid, fontSize } = layoutGrid(view.width, view.height, cell);
assert.ok(fontSize >= MIN_FONT_SIZE && fontSize <= MAX_FONT_SIZE);
assert.ok(Math.abs(grid.cellWidth - fontSize * cell.advance) < 1e-9);
assert.ok(grid.offsetX >= 0 && grid.offsetX < grid.cellWidth / 2 + 1e-9);
assert.ok(grid.offsetY >= 0 && grid.offsetY < grid.cellHeight / 2 + 1e-9);
assert.ok(
Math.abs(2 * grid.offsetX + grid.cols * grid.cellWidth - view.width) <
1e-9,
);
assert.ok(
Math.abs(2 * grid.offsetY + grid.rows * grid.cellHeight - view.height) <
1e-9,
);
}
}
// A font that failed to load or measure falls back to the default cell.
const broken = layoutGrid(600, 340, { advance: 0.08, lineHeight: 0 });
const plain = layoutGrid(600, 340, DEFAULT_CELL);
assert.deepEqual(broken, plain);
});
test("the donut is drawn in the middle of the canvas at every size and moment", () => {
const donut = bakeDonut();
for (const view of Object.values(GRIDS)) {
for (const cell of [GEIST, TALL]) {
const { grid } = layoutGrid(view.width, view.height, cell);
const span = DONUT_SPAN * Math.min(view.width, view.height);
for (let t = 0; t <= 9; t += 0.37) {
const drawn = glyphs(
renderFrame(donut, idleOrientation(t), grid, false),
grid,
);
assert.ok(drawn.length > 150, `a whole donut at t=${t}`);
const cx = drawn.reduce((sum, g) => sum + g.x, 0) / drawn.length;
const cy = drawn.reduce((sum, g) => sum + g.y, 0) / drawn.length;
// Perspective makes the near side a little bigger, nothing more.
const off = Math.hypot(cx - view.width / 2, cy - view.height / 2);
assert.ok(
off < span * 0.07,
`centroid ${off.toFixed(1)} px off at t=${t}`,
);
for (const g of drawn) {
assert.ok(
g.x > 0 && g.x < view.width && g.y > 0 && g.y < view.height,
);
}
}
}
}
});
test("a face-on donut is round: its cell box has the shape of the measured cell", () => {
const donut = bakeDonut();
const faceOn = rotationX(Math.PI / 2);
for (const view of Object.values(GRIDS)) {
for (const cell of [GEIST, TALL]) {
const { grid } = layoutGrid(view.width, view.height, cell);
const frame = renderFrame(donut, faceOn, grid, false);
let minCol = Infinity,
maxCol = -Infinity,
minRow = Infinity,
maxRow = -Infinity;
for (const [i, kind] of frame.kinds.entries()) {
if (kind === KIND_EMPTY) continue;
const col = i % grid.cols;
const row = (i - col) / grid.cols;
minCol = Math.min(minCol, col);
maxCol = Math.max(maxCol, col);
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
}
const cols = maxCol - minCol + 1;
const rows = maxRow - minRow + 1;
const aspect = grid.cellHeight / grid.cellWidth;
assert.ok(
Math.abs(cols / rows - aspect) / aspect < 0.08,
`${cols}x${rows} cells, cell aspect ${aspect.toFixed(2)}`,
);
// The same in pixels: as wide as it is tall, to about one cell.
assert.ok(
Math.abs(cols * grid.cellWidth - rows * grid.cellHeight) <=
1.5 * Math.max(grid.cellWidth, grid.cellHeight),
);
}
}
});
test("no glyph lies outside the donut, and the donut has no gaps", () => {
const donut = bakeDonut();
for (const view of Object.values(GRIDS)) {
const { grid } = layoutGrid(view.width, view.height, GEIST);
const lens = centerLens(grid);
for (const t of [0, 0.8, 2.1, 3.5, 5, 7.3]) {
const m = idleOrientation(t);
const frame = renderFrame(donut, m, grid, false);
for (const g of glyphs(frame, grid)) {
// Tube, glaze and a sprinkle's height, and a ray's graze.
const reach = rayToRing(m, grid, lens, g.x, g.y);
assert.ok(
reach < MINOR_RADIUS + 0.2,
`'${g.char}' ${reach.toFixed(2)} off the ring`,
);
}
// Every cell whose ray goes well inside the tube is drawn.
for (let i = 0; i < frame.kinds.length; i++) {
const col = i % grid.cols;
const row = (i - col) / grid.cols;
const x = grid.offsetX + (col + 0.5) * grid.cellWidth;
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
if (rayToRing(m, grid, lens, x, y) < MINOR_RADIUS - 0.08) {
assert.notEqual(frame.kinds[i], KIND_EMPTY, `a gap at ${col},${row}`);
}
}
}
}
});
test("runs of a monospace font put every glyph in its own cell", () => {
const donut = bakeDonut();
const { grid } = layoutGrid(GRIDS.wide.width, GRIDS.wide.height, GEIST);
const frame = renderFrame(donut, idleOrientation(1.3), grid, false);
const fromRuns = [];
forEachRun(frame, grid, (text, x, y, kind) => {
[...text].forEach((char, j) => {
fromRuns.push({ char, x: x + (j + 0.5) * grid.cellWidth, y, kind });
});
});
const sort = (list) => list.sort((a, b) => a.y - b.y || a.x - b.x);
const expected = sort(glyphs(frame, grid));
const actual = sort(fromRuns);
assert.equal(actual.length, expected.length);
for (const [i, g] of actual.entries()) {
assert.equal(g.char, expected[i].char);
assert.equal(g.kind, expected[i].kind);
assert.ok(Math.abs(g.x - expected[i].x) < 1e-6 && g.y === expected[i].y);
}
});
test("the reveal comes out of the logo and ends exactly on the frame at rest", () => {
const donut = bakeDonut();
const origin = { x: 40, y: 36, size: 56 };
for (const view of Object.values(GRIDS)) {
const { grid } = layoutGrid(view.width, view.height, GEIST);
const m = idleOrientation(REVEAL_MS / 1000);
const rest = renderFrame(donut, m, grid, false);
for (const elapsed of [REVEAL_MS, REVEAL_MS + 1, 10_000]) {
const pose = revealPose(m, grid, origin, elapsed);
assert.deepEqual(pose.lens, centerLens(grid));
const frame = renderFrame(
donut,
pose.orientation,
grid,
false,
undefined,
pose.lens,
);
assert.deepEqual(frame.chars, rest.chars);
assert.deepEqual(frame.kinds, rest.kinds);
}
assert.deepEqual(revealPose(m, grid, null, 0).lens, centerLens(grid));
// The first frame is already a donut, small, on the logo.
const first = revealPose(m, grid, origin, 0);
const start = glyphs(
renderFrame(donut, first.orientation, grid, false, undefined, first.lens),
grid,
);
assert.ok(start.length > 8 && start.length < glyphs(rest, grid).length / 4);
const cx = start.reduce((sum, g) => sum + g.x, 0) / start.length;
const cy = start.reduce((sum, g) => sum + g.y, 0) / start.length;
assert.ok(Math.hypot(cx - origin.x, cy - origin.y) < 30);
// On the way it only grows and moves toward the middle.
let zoom = 0;
let distance = Infinity;
for (let elapsed = 0; elapsed < REVEAL_MS; elapsed += 20) {
const { lens } = revealPose(m, grid, origin, elapsed);
const d = Math.hypot(lens.x - grid.width / 2, lens.y - grid.height / 2);
assert.ok(lens.zoom >= zoom && d <= distance + 1e-9);
zoom = lens.zoom;
distance = d;
}
}
});
test("the idle spin turns the same at any frame rate", () => {
for (const fps of [144, 60, 23, 7]) {
let m = START_ORIENTATION;
const momentum = { x: 0, y: 0, idle: 1 };
const steps = Math.round(3 * fps);
for (let i = 0; i < steps; i++) m = spinStep(m, momentum, 1 / fps);
const expected = idleOrientation(steps / fps);
for (let i = 0; i < 9; i++) assert.ok(Math.abs(m[i] - expected[i]) < 1e-9);
}
// A constant angular speed: equal times, equal turns.
const angle = (a, b) =>
Math.acos(
Math.min(
1,
(a[0] * b[0] +
a[1] * b[1] +
a[2] * b[2] +
a[3] * b[3] +
a[4] * b[4] +
a[5] * b[5] +
a[6] * b[6] +
a[7] * b[7] +
a[8] * b[8] -
1) /
2,
),
);
const turns = [0, 1.5, 3, 4.5].map((t) =>
angle(idleOrientation(t), idleOrientation(t + 0.1)),
);
for (const value of turns) assert.ok(Math.abs(value - turns[0]) < 0.02);
});
test("consecutive frames turn smoothly", () => {
const donut = bakeDonut();
for (const view of Object.values(GRIDS)) {
const { grid } = layoutGrid(view.width, view.height, GEIST);
let previous = null;
for (let t = 0; t < 8; t += 1 / 60) {
const frame = renderFrame(donut, idleOrientation(t), grid, false);
const drawn = frame.kinds.filter((kind) => kind !== KIND_EMPTY).length;
if (previous) {
let changed = 0;
for (let i = 0; i < frame.kinds.length; i++) {
if (
(frame.kinds[i] === KIND_EMPTY) !==
(previous[i] === KIND_EMPTY)
) {
changed++;
}
}
assert.ok(
changed / drawn < 0.08,
`${changed} of ${drawn} cells jump at t=${t.toFixed(2)}`,
);
}
previous = frame.kinds.slice();
}
}
});
test("a flick fades back into the idle spin", () => {
const run = (fps) => {
let m = START_ORIENTATION;
const momentum = { x: 6, y: -4, idle: 0 };
for (let i = 0; i < 4 * fps; i++) m = spinStep(m, momentum, 1 / fps);
return { m, momentum };
};
const fast = run(120);
const slow = run(30);
assert.equal(fast.momentum.x, 0);
assert.equal(fast.momentum.y, 0);
assert.ok(fast.momentum.idle > 0.99);
for (let i = 0; i < 9; i++) assert.ok(Math.abs(fast.m[i] - slow.m[i]) < 1e-3);
// Without the idle spin (reduced motion) the donut comes to rest.
const momentum = { x: 3, y: 2, idle: 1 };
let m = START_ORIENTATION;
for (let i = 0; i < 300; i++) m = spinStep(m, momentum, 1 / 60, false);
assert.deepEqual(momentum, { x: 0, y: 0, idle: 0 });
assert.deepEqual(spinStep(m, momentum, 1 / 60, false), m);
});
test("crumbs fall, fade and are gone when their life is over", () => {
let crumbs = Array.from({ length: 10 }, (_, i) => ({
x: 100,
y: 50,
vx: (i - 5) * 30,
vy: -200,
char: CRUMB_GLYPHS[i % CRUMB_GLYPHS.length],
kind: KIND_DOUGH,
age: 0,
life: 1.1 + i * 0.08,
}));
let alpha = 1;
for (let i = 0; i < 60 * 3 && crumbs.length > 0; i++) {
crumbs = stepCrumbs(crumbs, 1 / 60, 200);
for (const crumb of crumbs) assert.ok(crumb.y <= 200);
if (crumbs.length > 0) {
const last = crumbs[crumbs.length - 1];
assert.ok(crumbAlpha(last) <= alpha + 1e-9);
alpha = crumbAlpha(last);
}
}
assert.equal(crumbs.length, 0);
});
test("the lab measures every glyph it draws", () => {
for (const char of DOUGH_RAMP + GLAZE_RAMP + CRUMB_RAMP + CRUMB_GLYPHS) {
if (char !== " ") assert.ok(GLYPHS.includes(char), char);
}
for (const axis of [
[1, 0, 0],
[0, 1, 0],
unit([1, 1, 0]),
unit([-1, 1, 0]),
[0, 0, 1],
]) {
assert.ok(GLYPHS.includes(sprinkleGlyph(axis)));
}
});
+388 -35
View File
@@ -1,11 +1,15 @@
/**
* The donut of the About easter egg: a signed distance field, a ray marcher,
* bites carved out of the field, a mass estimate, and text shading. Pure math,
* so it runs in node tests; the canvas lives in `donut-lab.tsx`.
* bites carved out of the field, a mass estimate, text shading, the character
* grid, and the motion (reveal, idle spin, flick, crumbs) as functions of
* time. Pure math, so it runs in node tests; the canvas lives in
* `donut-lab.tsx` and only paints what this module computes.
*
* Object space: the ring lies in the XZ plane around the Y axis, and +Y is the
* glazed top. View space: +X right, +Y up, the camera on +Z looking at the
* origin. A row-major rotation matrix turns object space into view space.
* Rays go through canvas pixels with one scale on both axes, so the donut is
* round whatever the shape of a character cell.
*/
export type Vec3 = [number, number, number];
@@ -26,7 +30,7 @@ export const MINOR_RADIUS = 0.45;
export const GLAZE_THICKNESS = 0.06;
/** Every point of the donut, sprinkles included, lies inside this sphere. */
export const BOUNDS_RADIUS = 1.62;
export const MAX_STEPS = 28;
export const MAX_STEPS = 56;
const SLAB_BOTTOM = -MINOR_RADIUS - 0.02;
const SLAB_TOP = MINOR_RADIUS + GLAZE_THICKNESS + 0.08;
const HIT_DISTANCE = 0.01;
@@ -38,13 +42,24 @@ const BITE_SMOOTHING = 0.06;
/** The donut is gone when this share of it is eaten, or after this many bites. */
export const EATEN_SHARE = 0.85;
export const MAX_BITES = 30;
const CAMERA_DISTANCE = 5;
const CAMERA_DISTANCE = 7;
/** Half the side of the view, in object units, at the smaller canvas side. */
const FRAME_RADIUS = 1.72;
export const DOUGH_RAMP = " .:-=+*#%@";
export const GLAZE_RAMP = " .,~;oO08@";
export const CRUMB_RAMP = " .,:;io0&@";
export const SPRINKLE_GLYPHS = "-/|\\o";
/** The glyphs of the crumbs that fly off a bite. */
export const CRUMB_GLYPHS = ".,'`:;*";
/** Every glyph the lab draws: the widest sets the width of a cell. */
export const GLYPHS = [
...new Set(
DOUGH_RAMP + GLAZE_RAMP + CRUMB_RAMP + SPRINKLE_GLYPHS + CRUMB_GLYPHS,
),
]
.filter((char) => char !== " ")
.join("");
export const SPRINKLE_COLORS = 5;
export type Material = "dough" | "glaze" | "crumb" | "sprinkle";
@@ -505,6 +520,12 @@ export function rotationY(angle: number): Mat3 {
return [c, 0, s, 0, 1, 0, -s, 0, c];
}
export function rotationZ(angle: number): Mat3 {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [c, -s, 0, s, c, 0, 0, 0, 1];
}
/** Keeps a matrix a rotation after many small turns. */
export function orthonormalize(m: Mat3): Mat3 {
const x: Vec3 = [m[0], m[1], m[2]];
@@ -530,10 +551,34 @@ export function orthonormalize(m: Mat3): Mat3 {
return [...x, ...y, ...z] as Mat3;
}
/** Turns the donut about the screen's axes: a drag, or an arrow key. */
/** A rotation by `angle` about a unit axis. */
export function rotationAbout(axis: Vec3, angle: number): Mat3 {
const [x, y, z] = axis;
const c = Math.cos(angle);
const s = Math.sin(angle);
const t = 1 - c;
return [
t * x * x + c,
t * x * y - s * z,
t * x * z + s * y,
t * x * y + s * z,
t * y * y + c,
t * y * z - s * x,
t * x * z - s * y,
t * y * z + s * x,
t * z * z + c,
];
}
/**
* Turns the donut about the screen's axes, as one turn about their sum: a
* drag, an arrow key, or a flick.
*/
export function turn(m: Mat3, aroundX: number, aroundY: number): Mat3 {
const angle = Math.hypot(aroundX, aroundY);
if (angle === 0) return m;
return orthonormalize(
multiply(multiply(rotationY(aroundY), rotationX(aroundX)), m),
multiply(rotationAbout([aroundX / angle, aroundY / angle, 0], angle), m),
);
}
@@ -567,12 +612,36 @@ export interface View {
height: number;
}
/** Where the donut sits on a canvas: its center in CSS pixels, and its scale. */
export interface Lens {
x: number;
y: number;
zoom: number;
}
/** The donut at rest: in the middle of the canvas, at full size. */
export function centerLens(view: View): Lens {
return { x: view.width / 2, y: view.height / 2, zoom: 1 };
}
/** Object units per canvas pixel, the same on both axes. */
function unitsPerPixel(view: View, lens: Lens) {
return (
(2 * FRAME_RADIUS) /
(Math.max(1, Math.min(view.width, view.height)) * lens.zoom)
);
}
/** The ray through a canvas point, in view space. */
export function viewRay(view: View, px: number, py: number) {
const scale =
(2 * FRAME_RADIUS) / Math.max(1, Math.min(view.width, view.height));
const x = (px - view.width / 2) * scale;
const y = (view.height / 2 - py) * scale;
export function viewRay(
view: View,
px: number,
py: number,
lens = centerLens(view),
) {
const scale = unitsPerPixel(view, lens);
const x = (px - lens.x) * scale;
const y = (lens.y - py) * scale;
const length = Math.sqrt(x * x + y * y + CAMERA_DISTANCE * CAMERA_DISTANCE);
return {
origin: [0, 0, CAMERA_DISTANCE] as Vec3,
@@ -581,14 +650,13 @@ export function viewRay(view: View, px: number, py: number) {
}
/** Where an object-space point lands on the canvas. */
export function project(view: View, m: Mat3, p: Vec3) {
export function project(view: View, m: Mat3, p: Vec3, lens = centerLens(view)) {
const [x, y, z] = toView(m, p);
const scale =
(2 * FRAME_RADIUS) / Math.max(1, Math.min(view.width, view.height));
const scale = unitsPerPixel(view, lens);
const perspective = CAMERA_DISTANCE / (CAMERA_DISTANCE - z);
return {
x: view.width / 2 + (x * perspective) / scale,
y: view.height / 2 - (y * perspective) / scale,
x: lens.x + (x * perspective) / scale,
y: lens.y - (y * perspective) / scale,
};
}
@@ -607,8 +675,9 @@ export function surfaceAt(
view: View,
px: number,
py: number,
lens = centerLens(view),
): SurfacePoint | null {
const ray = viewRay(view, px, py);
const ray = viewRay(view, px, py, lens);
const hit = castRay(donut, toObject(m, ray.origin), toObject(m, ray.dir));
if (!hit) return null;
const normal = normalAt(donut, hit.point);
@@ -631,9 +700,10 @@ export function findBiteTarget(
px: number,
py: number,
reach: number,
lens = centerLens(view),
step = 6,
): (SurfacePoint & { x: number; y: number }) | null {
const direct = surfaceAt(donut, m, view, px, py);
const direct = surfaceAt(donut, m, view, px, py, lens);
if (direct) return { ...direct, x: px, y: py };
for (let radius = step; radius <= reach; radius += step) {
const count = Math.max(8, Math.ceil((TAU * radius) / step));
@@ -642,13 +712,38 @@ export function findBiteTarget(
const x = px + Math.cos(angle) * radius;
const y = py + Math.sin(angle) * radius;
if (x < 0 || y < 0 || x > view.width || y > view.height) continue;
const surface = surfaceAt(donut, m, view, x, y);
const surface = surfaceAt(donut, m, view, x, y, lens);
if (surface) return { ...surface, x, y };
}
}
return null;
}
/**
* The shape of a character cell, per pixel of font size, measured from the
* font the canvas draws with.
*/
export interface CellMetrics {
/** The advance of the widest glyph the lab draws. */
advance: number;
/** Ascent plus descent. */
lineHeight: number;
/** Every glyph has the same advance: a run of cells can be one string. */
mono?: boolean;
}
/** Geist Mono: every glyph 0.6 wide, ascent 1.005, descent 0.295. */
export const DEFAULT_CELL: CellMetrics = { advance: 0.6, lineHeight: 1.3 };
/** Rows sit closer than lines of text: the ink of a glyph is shorter than its line. */
const ROW_PITCH = 0.72;
export const MIN_FONT_SIZE = 9;
export const MAX_FONT_SIZE = 24;
/** Columns across the donut, when the font size allows it. */
export const COLUMNS_ACROSS = 48;
/** The donut's width at rest, as a share of the canvas's smaller side. */
export const DONUT_SPAN =
(MAJOR_RADIUS + MINOR_RADIUS + GLAZE_THICKNESS) / FRAME_RADIUS;
/** The character grid laid over a canvas. */
export interface Grid extends View {
cols: number;
@@ -665,21 +760,34 @@ export interface Frame {
}
/**
* Lays a grid of about `targetCols` columns over a canvas. `advance` is the
* width of one character per pixel of font size; rows are a little shorter
* than the font, so the grid is denser than plain text.
* Lays a grid of whole cells over a canvas, centered on it, so the donut is
* about `across` columns wide. The cell is as wide as the font's widest glyph,
* and each glyph is drawn at the center of its cell, so the font never moves
* a glyph.
*/
export function layoutGrid(
width: number,
height: number,
advance = 0.6,
targetCols = 64,
cell: CellMetrics = DEFAULT_CELL,
across = COLUMNS_ACROSS,
) {
const cols = Math.max(24, Math.min(96, targetCols));
const cellWidth = width / cols;
const fontSize = cellWidth / advance;
const cellHeight = fontSize * 0.92;
const rows = Math.max(8, Math.floor(height / cellHeight));
const advance =
cell.advance >= 0.3 && cell.advance <= 1.5
? cell.advance
: DEFAULT_CELL.advance;
const lineHeight =
cell.lineHeight >= 0.8 && cell.lineHeight <= 2.5
? cell.lineHeight
: DEFAULT_CELL.lineHeight;
const span = DONUT_SPAN * Math.max(1, Math.min(width, height));
const fontSize = Math.min(
MAX_FONT_SIZE,
Math.max(MIN_FONT_SIZE, span / across / advance),
);
const cellWidth = fontSize * advance;
const cellHeight = fontSize * lineHeight * ROW_PITCH;
const cols = Math.max(1, Math.floor(width / cellWidth));
const rows = Math.max(1, Math.floor(height / cellHeight));
const grid: Grid = {
width,
height,
@@ -687,7 +795,7 @@ export function layoutGrid(
rows,
cellWidth,
cellHeight,
offsetX: 0,
offsetX: (width - cols * cellWidth) / 2,
offsetY: (height - rows * cellHeight) / 2,
};
return { grid, fontSize };
@@ -704,17 +812,23 @@ export function renderFrame(
m: Mat3,
grid: Grid,
ink: boolean,
frame: Frame = {
chars: new Array(grid.cols * grid.rows).fill(" "),
kinds: new Uint8Array(grid.cols * grid.rows),
},
reuse?: Frame,
lens = centerLens(grid),
): Frame {
const size = grid.cols * grid.rows;
const frame =
reuse && reuse.kinds.length === size
? reuse
: {
chars: new Array<string>(size).fill(" "),
kinds: new Uint8Array(size),
};
for (let row = 0; row < grid.rows; row++) {
const py = grid.offsetY + (row + 0.5) * grid.cellHeight;
for (let col = 0; col < grid.cols; col++) {
const index = row * grid.cols + col;
const px = grid.offsetX + (col + 0.5) * grid.cellWidth;
const surface = surfaceAt(donut, m, grid, px, py);
const surface = surfaceAt(donut, m, grid, px, py, lens);
if (!surface) {
frame.chars[index] = " ";
frame.kinds[index] = KIND_EMPTY;
@@ -746,3 +860,242 @@ export function renderFrame(
}
return frame;
}
/**
* Calls `draw` for every drawn cell (only those of `kind`, when given) with
* the center of its cell in canvas pixels. The canvas draws each glyph there,
* centered, so glyphs of every kind and width stay on the grid.
*/
export function forEachGlyph(
frame: Frame,
grid: Grid,
draw: (char: string, x: number, y: number, kind: number) => void,
kind?: number,
) {
for (let i = 0; i < frame.kinds.length; i++) {
const k = frame.kinds[i];
if (k === KIND_EMPTY || (kind !== undefined && k !== kind)) continue;
const col = i % grid.cols;
const row = (i - col) / grid.cols;
draw(
frame.chars[i],
grid.offsetX + (col + 0.5) * grid.cellWidth,
grid.offsetY + (row + 0.5) * grid.cellHeight,
k,
);
}
}
/**
* Calls `draw` for every run of adjacent cells of one kind (only `kind`, when
* given) in a row, with the run's text, the left edge of its first cell and
* the middle of its row. With a monospace font whose advance is the cell
* width, a run drawn from its left edge puts each glyph in its own cell, in
* far fewer draw calls than one per glyph. Spaces are never drawn, so glyphs
* of other kinds never shift a run.
*/
export function forEachRun(
frame: Frame,
grid: Grid,
draw: (text: string, x: number, y: number, kind: number) => void,
kind?: number,
) {
for (let row = 0; row < grid.rows; row++) {
const start = row * grid.cols;
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
let col = 0;
while (col < grid.cols) {
const k = frame.kinds[start + col];
if (k === KIND_EMPTY || (kind !== undefined && k !== kind)) {
col++;
continue;
}
let end = col + 1;
while (end < grid.cols && frame.kinds[start + end] === k) end++;
draw(
frame.chars.slice(start + col, start + end).join(""),
grid.offsetX + col * grid.cellWidth,
y,
k,
);
col = end;
}
}
}
/** The frame as plain text, one line per row. */
export function frameToText(frame: Frame, grid: Grid) {
const lines: string[] = [];
for (let row = 0; row < grid.rows; row++) {
lines.push(
frame.chars.slice(row * grid.cols, (row + 1) * grid.cols).join(""),
);
}
return lines.join("\n");
}
/**
* The idle spin, in radians per second: the classic two-axis turn. The donut
* tumbles about its own X axis while the picture rolls about the line of
* sight. One turn acts in object space and the other in view space, so they
* commute: the spin after t seconds is the same at any frame rate.
*/
export const IDLE_TUMBLE = 0.9;
export const IDLE_ROLL = 0.42;
/** Turns the donut by `seconds` of the idle spin at full speed. */
export function idleStep(m: Mat3, seconds: number): Mat3 {
if (seconds === 0) return m;
return orthonormalize(
multiply(
multiply(rotationZ(IDLE_ROLL * seconds), m),
rotationX(IDLE_TUMBLE * seconds),
),
);
}
/** The donut `seconds` into the idle spin from `start`. */
export function idleOrientation(seconds: number, start = START_ORIENTATION) {
return idleStep(start, seconds);
}
/** How fast a flick fades into the idle spin, per second. */
export const SETTLE_RATE = 2.6;
/** The fastest flick, in radians per second. */
export const MAX_FLICK = 9;
/**
* A flick's speed about the screen's X and Y axes, in radians per second, and
* the share of the idle spin that runs (0 while a hand holds the donut).
*/
export interface Momentum {
x: number;
y: number;
idle: number;
}
/**
* Moves the donut `dt` seconds on. The flick fades and the idle spin comes
* back at the same rate, so a flick hands over to the idle spin with no jump.
* Each is integrated exactly, and the flick sits between two halves of the
* idle step, so the turn hardly depends on the frame rate.
*/
export function spinStep(
m: Mat3,
momentum: Momentum,
dt: number,
idle = true,
): Mat3 {
if (!(dt > 0)) return m;
const fade = Math.exp(-SETTLE_RATE * dt);
const halfFade = Math.exp((-SETTLE_RATE * dt) / 2);
// Integrals of e^(-k s) over the step and over half of it.
const share = (1 - fade) / SETTLE_RATE;
const halfShare = (1 - halfFade) / SETTLE_RATE;
// The idle share is 1 - (1 - w0) e^(-k s); its integral over each half:
const lag = idle ? 1 - momentum.idle : 1;
const firstHalf = idle ? dt / 2 - lag * halfShare : 0;
const secondHalf = idle ? dt / 2 - lag * halfFade * halfShare : 0;
let next = idleStep(m, firstHalf);
if (momentum.x !== 0 || momentum.y !== 0) {
next = turn(next, momentum.x * share, momentum.y * share);
momentum.x *= fade;
momentum.y *= fade;
if (Math.abs(momentum.x) + Math.abs(momentum.y) < 1e-3) {
momentum.x = 0;
momentum.y = 0;
}
}
momentum.idle = idle ? 1 - lag * fade : 0;
return idleStep(next, secondHalf);
}
/** How long the donut takes to come out of the logo. */
export const REVEAL_MS = 560;
/** The extra tumble of the reveal: the donut flips over once as it lands. */
const REVEAL_TWIST = Math.PI;
/** The smallest scale a reveal starts at, so its first frame is a donut already. */
const REVEAL_MIN_ZOOM = 0.22;
/** Where a reveal starts: the logo's center and size, in canvas pixels. */
export interface RevealOrigin {
x: number;
y: number;
size: number;
}
export interface Pose {
orientation: Mat3;
lens: Lens;
}
/**
* The donut `elapsedMs` into its reveal: it comes out of the logo at the
* logo's size, moves to the middle as it grows, and flips over once, fast at
* first. From REVEAL_MS on, and with no origin, it is exactly the pose at rest.
*/
export function revealPose(
m: Mat3,
view: View,
origin: RevealOrigin | null,
elapsedMs: number,
): Pose {
const rest = centerLens(view);
if (!origin || !(elapsedMs < REVEAL_MS))
return { orientation: m, lens: rest };
const p = Math.max(0, elapsedMs) / REVEAL_MS;
const eased = 1 - (1 - p) ** 3;
const span = DONUT_SPAN * Math.max(1, Math.min(view.width, view.height));
const startZoom = Math.min(1, Math.max(REVEAL_MIN_ZOOM, origin.size / span));
return {
orientation: orthonormalize(
multiply(m, rotationX(-REVEAL_TWIST * (1 - eased))),
),
lens: {
x: origin.x + (rest.x - origin.x) * eased,
y: origin.y + (rest.y - origin.y) * eased,
zoom: startZoom + (1 - startZoom) * eased,
},
};
}
/** A crumb that flies off a bite, in canvas pixels. */
export interface Crumb {
x: number;
y: number;
vx: number;
vy: number;
char: string;
kind: number;
/** Seconds since the bite, and seconds the crumb lasts. */
age: number;
life: number;
}
/** Pixels per second, per second. */
export const CRUMB_GRAVITY = 1100;
/** Seconds a crumb takes to fade out at the end of its life. */
const CRUMB_FADE = 0.4;
/**
* Moves the crumbs `dt` seconds on, bouncing on `floor`, and returns the ones
* whose life is not over.
*/
export function stepCrumbs(crumbs: Crumb[], dt: number, floor: number) {
return crumbs.filter((crumb) => {
crumb.age += dt;
crumb.vy += CRUMB_GRAVITY * dt;
crumb.x += crumb.vx * dt;
crumb.y += crumb.vy * dt;
if (crumb.y > floor) {
crumb.y = floor;
crumb.vy = Math.abs(crumb.vy) < 60 ? 0 : -crumb.vy * 0.3;
crumb.vx *= 0.55;
}
return crumb.age < crumb.life;
});
}
export function crumbAlpha(crumb: Crumb) {
return Math.min(1, Math.max(0, (crumb.life - crumb.age) / CRUMB_FADE));
}