Played end-to-end via the extended harness (t.start/day/pick/slice): - Day 9 knife trip: bread_knife dragged above the rim, 0.45s dwell commits. - Clean doorstop (bread knife, 0.05 u/frame): 5 strokes, wedge 0, squash 0. STROKE_GAIN 0.16 -> 0.10 to hit the 4-6 deliberate-stroke feel. - The Best Thing: 3 strokes, and the same wobble input that wedges the bread knife to 0.299 only reaches 0.044 — nearly wobble-proof, as specced. - Coupling: wobbled cut toasts at browning stdev 0.088 vs 0.071 clean, same mean — 'a bit wedged' band (>0.18) confirmed in the Cut criterion. - Slam cut (0.4 u/frame): squash 1.0, arrives pre-damaged (damage 0.02 pre-toast). - Soup spoon siege: 17 strokes / 166 frames, miserable but possible. - Brands: quality now drives heat-noise amp (0.12+0.42*(1-q)) AND blotch frequency (3+3*(1-q)); WONDERSLICE visibly patchier than Crustworthy at 10s. - Slicer visuals: cut seam deepens with progress; freed slice tips off the loaf and lands crumb-up (doneTimer 0.7 -> 1.05 for the fall). - gen-assets.sh: retry-once per stage (rsync flakes), bakery seeds bumped, prep-bench cast queued ($1 prep). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
110 lines
4.3 KiB
TypeScript
110 lines
4.3 KiB
TypeScript
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';
|
|
}
|