import type { Slice } from './slice'; import { Rng } from '../core/rng'; /** * Browning rate. Measured, not guessed: the heat map's mean is ~0.77 (coils, * edge falloff and the cool top all bite), so this is set so power 6 reaches a * golden 0.5 in ~10s, power 10 is nearly black by then, and power 3 takes ~20s. */ const RATE = 0.11; /** Where the elements actually put their heat. */ export function buildHeatMap(slice: Slice, rng: Rng): Float32Array { const n = slice.mask.n; const out = new Float32Array(n * n); // Real toasters have vertical element wires spaced across the slot, so the // hot spots are stripes, not a wash. Jitter the phase per run. const coils = 4 + rng.int(0, 2); const phase = rng.range(0, Math.PI * 2); for (let y = 0; y < n; y++) { for (let x = 0; x < n; x++) { const u = x / (n - 1); const v = y / (n - 1); // Subtle: at much more than this the slice reads as a grill plate, not toast. const coil = 0.92 + 0.08 * Math.sin(u * Math.PI * 2 * coils + phase); // The top of the slice sticks up out of the slot and stays pale — the // single most recognisable thing about real toast. const vert = v > 0.72 ? 1 - 0.5 * Math.pow((v - 0.72) / 0.28, 1.5) : 1; // and the very bottom sits on the rack, slightly shaded const base = v < 0.06 ? 0.82 + (v / 0.06) * 0.18 : 1; // heat escapes at the left/right edges const edge = 0.82 + 0.18 * Math.sin(Math.min(1, Math.max(0, u)) * Math.PI); // Cheap bread takes heat like a raffle: the noise amplitude scales with // how bad the brand is. Good bread flattens it out. const amp = 0.12 + 0.42 * (1 - slice.quality); const bias = 1 - amp / 2 + slice.heatBias[y * n + x] * amp; // A wedge-shaped slice has a thin edge, and thin edges brown first: the // bad cut follows you into the toaster. const wedge = slice.cut ? 1 + slice.cut.wedge * 0.55 * (u - 0.5) * 2 : 1; out[y * n + x] = coil * vert * base * edge * bias * Math.max(0.3, wedge); } } return out; } export interface ToastTick { /** Mean browning over the slice — drives the smell cues. */ mean: number; /** How much of the slice is past char. */ charFrac: number; /** 0..1, how smoky it is right now. */ smoke: number; } /** * Advance the browning one tick. * * Two things make this more than a timer: * - moisture has to boil off before the surface can brown at full rate, so a wet * crumb stalls and then accelerates once it's dry (sourdough's whole trick); * - sugar browns early and burns early (raisin bread's whole trick). */ export function toastStep(slice: Slice, heat: Float32Array, power: number, dt: number): void { const b = slice.bread; const p = Math.max(0, Math.min(10, power)) / 10; const moistureCap = 0.4 + b.moisture * 2.2; const thick = 0.6 + b.thickness * 3; const sugar = 1 + b.sugar * 0.8; const br = slice.browning.data; const dry = slice.dryness.data; const mask = slice.mask.data; for (let i = 0; i < br.length; i++) { if (mask[i] < 0.5) continue; const h = heat[i] * p; if (h <= 0) continue; dry[i] = Math.min(1, dry[i] + (h * dt) / moistureCap); const rate = (RATE * h * (0.25 + 0.75 * dry[i]) * sugar) / thick; br[i] = Math.min(1.15, br[i] + rate * dt); } slice.touch(); } /** Warmth decays in real time. Thick slices hold heat longer. */ export function coolStep(slice: Slice, dt: number): void { const tau = 18 + slice.bread.thickness * 90; slice.warmth *= Math.exp(-dt / tau); slice.material.uniforms.uWarmth.value = slice.warmth; } export function readToast(slice: Slice): ToastTick { const s = slice.browning.stats(slice.mask); const charFrac = slice.browning.fraction(slice.mask, (v) => v > 0.85); const smoke = Math.max(0, Math.min(1, (s.max - 0.78) / 0.3)); return { mean: s.mean, charFrac, smoke }; } /** * No timer, no numbers — you go by smell. Deliberately vague until it isn't. */ export function smellCue(t: ToastTick): string { if (t.smoke > 0.75) return 'SMOKE. ACTUAL SMOKE.'; if (t.smoke > 0.45) return 'something is burning'; if (t.mean > 0.62) return 'dark, and getting darker'; if (t.mean > 0.42) return 'smells like toast'; if (t.mean > 0.22) return 'smells toasty'; if (t.mean > 0.08) return 'smells warm'; if (t.mean > 0.01) return 'a faint warmth'; return 'smells like bread'; }