New bone src/sim/heatsource.ts: a HeatSource sits between the knob and the browning integrator — the dial sets target, output chases it on a fuel-specific lag, every station reads output. Five fuels (gas/electric/ induction/charcoal/radiant) + appliance SKUs (oven_gas, oven_fan, cheap_electric). roasting.ts retrofit is golden-preserving: optional src?, reads src.output ?? power, byte-identical when absent. Verified in-browser: - GOLDEN: no-src oven = mean 0.698 / blister 1.0 / collapse 0 (== HEAD). - M-H1 chase 3->9: gas 0.433s, electric 6.0s, induction 0.133s (distinct). - residual after 9->0: gas 0@1s, electric 6.6@6s, charcoal 9@30s. - M-H2: gas oven stdev 0.1207 vs fan 0.0016 at equal mean 0.6001 (75x). - day 14 bruschetta now roasts on oven_gas, still grades S (9.62); oven HUD reads 'GAS OVEN 8 · blue flame', tomatoes visibly hot-at-centre. Doctrine: OPUS-BUILD-BRIEF-HEAT.md (the cross-cutting heat & environment doctrine — 'you never cook the knob, you cook the lag'). Harness: t.heatChase/residual/ovenStdev/ovenGolden/ovenDemo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
168 lines
6.5 KiB
TypeScript
168 lines
6.5 KiB
TypeScript
import { Field } from '../core/field';
|
||
import { Rng, valueNoise2D } from '../core/rng';
|
||
import { type HeatSource, setKnob, heatStep, applyToHeatMap } from './heatsource';
|
||
|
||
/**
|
||
* Roasting tomatoes — the oven is the toaster, generalized.
|
||
*
|
||
* It is the same browning Field the toast has always used, pointed at tomato
|
||
* halves on a tray instead of a slice in a slot. The math is toastStep's twin:
|
||
* moisture has to boil off before the surface can colour, and tomatoes are
|
||
* basically water balloons, so they stall a long time and then go fast — the
|
||
* "leave them in, nothing's happening, leave them in, they're gone" of a real
|
||
* oven.
|
||
*
|
||
* Two things are the tomato's and not the toast's:
|
||
* blister — past 0.6 the skin blisters and speckles (a different colour ramp
|
||
* and a speckle mask in the scene; here it's just a fraction the
|
||
* judge rewards — blistered is the target, not black).
|
||
* collapse — past ~0.9 the half slumps into sauce. The moisture it was holding
|
||
* becomes mess the moment you handle it, and it soaks the toast
|
||
* faster (the assembly reads collapseFrac into the sog clock).
|
||
*
|
||
* Pure — Field + Rng, no three.js — so the harness roasts headless and reads the
|
||
* exact same numbers the judge will.
|
||
*/
|
||
|
||
/** Blistered skin begins here — the target window's floor. */
|
||
export const BLISTER_AT = 0.6;
|
||
/** Past this the half has collapsed into sauce. */
|
||
export const COLLAPSE_AT = 0.9;
|
||
|
||
/** Roasts a touch slower than toast browns: the oven is gentler than the coils,
|
||
* and the wet flesh drinks the first stretch of heat. Measured headless at power
|
||
* 6: the halves stall wet until ~26s (mean ~0.56, barely blistered), the blister
|
||
* window opens ~28–36s (mean ~0.61→0.79, fully blistered, none slumped), and past
|
||
* ~40s they collapse to sauce — "nothing's happening, nothing's happening, gone". */
|
||
const RATE = 0.042;
|
||
|
||
export interface RoastSession {
|
||
/** 0 raw .. 1 blackened. Blister past 0.6, collapse past 0.9. */
|
||
roast: Field;
|
||
/** Moisture that must cook off first — tomatoes carry a lot of it. */
|
||
dry: Field;
|
||
/** 1 where a tomato half sits on the tray; every stat is masked by this. */
|
||
mask: Field;
|
||
/** Where the oven puts its heat — hotter in the middle, like a real element. */
|
||
heat: Float32Array;
|
||
seconds: number;
|
||
power: number;
|
||
/** Optional fuel character (gas oven vs electric fan). When absent, the oven
|
||
* behaves EXACTLY as it always has — the knob maps straight to power. */
|
||
src?: HeatSource;
|
||
}
|
||
|
||
export interface RoastResult {
|
||
/** Mean roast over the tomatoes. */
|
||
mean: number;
|
||
/** Evenness — a rack that roasted unevenly reads here. */
|
||
stdev: number;
|
||
/** Fraction blistered (past 0.6) — the good bit. */
|
||
blisterFrac: number;
|
||
/** Fraction collapsed into sauce (past 0.9) — the overdone bit. */
|
||
collapseFrac: number;
|
||
}
|
||
|
||
const N = 96;
|
||
|
||
/**
|
||
* A tray of `halves` tomato halves. They sit in a row; the mask is a set of
|
||
* discs so the stats are over the tomatoes, not the empty tray between them.
|
||
*/
|
||
export function newRoastSession(halves = 4, seed = 20260718, power = 6, src?: HeatSource): RoastSession {
|
||
const roast = new Field(N);
|
||
const dry = new Field(N);
|
||
const mask = new Field(N);
|
||
const rng = new Rng(seed);
|
||
|
||
// Lay the halves out across the tray as discs.
|
||
const r = 0.5 / halves; // disc radius in UV, so they tile the width
|
||
for (let h = 0; h < halves; h++) {
|
||
const cu = (h + 0.5) / halves;
|
||
const cv = 0.5;
|
||
stampDisc(mask, cu, cv, r * 0.82);
|
||
}
|
||
|
||
// The oven's heat: hotter toward the centre/back, gently blotched, so an even
|
||
// roast is a real (if mild) skill — the tuning keeps it forgiving.
|
||
const bias = valueNoise2D(rng, N, N, 3);
|
||
const heat = new Float32Array(N * N);
|
||
for (let y = 0; y < N; y++) {
|
||
for (let x = 0; x < N; x++) {
|
||
const u = x / (N - 1);
|
||
const v = y / (N - 1);
|
||
// Radial: the middle of the tray runs hotter than the corners.
|
||
const dc = Math.hypot(u - 0.5, v - 0.5);
|
||
const centre = 1 - 0.18 * Math.min(1, dc / 0.6);
|
||
const blot = 0.9 + 0.2 * bias[y * N + x];
|
||
heat[y * N + x] = centre * blot;
|
||
}
|
||
}
|
||
// A fuel character reshapes the heat map (gas oven top-hot & blotchy vs fan
|
||
// even) — normalized over the mask so equal cook time gives equal MEAN, and
|
||
// the fuel shows up as evenness (stdev), not as a head start. Omit src and the
|
||
// heat map above is untouched: the oven is byte-for-byte its old self.
|
||
if (src) applyToHeatMap(heat, N, src, new Rng(seed ^ 0x5f), mask);
|
||
return { roast, dry, mask, heat, seconds: 0, power, src };
|
||
}
|
||
|
||
function stampDisc(mask: Field, cu: number, cv: number, r: number): void {
|
||
const n = mask.n;
|
||
for (let y = 0; y < n; y++) {
|
||
for (let x = 0; x < n; x++) {
|
||
const u = x / (n - 1);
|
||
const v = y / (n - 1);
|
||
if (Math.hypot(u - cu, v - cv) <= r) mask.data[y * n + x] = 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Advance the roast one tick. The twin of toastStep: dryness climbs first, and
|
||
* only a dried surface colours at full rate. `power` 1..10 is the oven dial.
|
||
*/
|
||
export function roastStep(s: RoastSession, dt: number): void {
|
||
// The knob commands a target; the fuel decides how fast the heat obeys. With
|
||
// no source this is `s.power` unchanged — the whole retrofit is invisible.
|
||
let effective = s.power;
|
||
if (s.src) {
|
||
setKnob(s.src, s.power);
|
||
heatStep(s.src, dt);
|
||
effective = s.src.output;
|
||
}
|
||
const p = Math.max(0, Math.min(10, effective)) / 10;
|
||
const r = s.roast.data;
|
||
const d = s.dry.data;
|
||
const m = s.mask.data;
|
||
// Tomatoes hold a lot of water — a high cap means a long stall then a rush.
|
||
const moistureCap = 2.4;
|
||
for (let i = 0; i < r.length; i++) {
|
||
if (m[i] < 0.5) continue;
|
||
const h = s.heat[i] * p;
|
||
if (h <= 0) continue;
|
||
d[i] = Math.min(1, d[i] + (h * dt) / moistureCap);
|
||
const rate = RATE * h * (0.2 + 0.8 * d[i]);
|
||
r[i] = Math.min(1.1, r[i] + rate * dt);
|
||
}
|
||
s.seconds += dt;
|
||
}
|
||
|
||
export function roastResult(s: RoastSession): RoastResult {
|
||
const st = s.roast.stats(s.mask);
|
||
return {
|
||
mean: st.mean,
|
||
stdev: st.stdev,
|
||
blisterFrac: s.roast.fraction(s.mask, (v) => v >= BLISTER_AT && v < COLLAPSE_AT),
|
||
collapseFrac: s.roast.fraction(s.mask, (v) => v >= COLLAPSE_AT),
|
||
};
|
||
}
|
||
|
||
/** The live-readout word for the oven, in the judge's own thresholds. */
|
||
export function roastWord(r: RoastResult): string {
|
||
if (r.collapseFrac > 0.4) return 'collapsing to sauce';
|
||
if (r.mean < 0.28) return 'still raw';
|
||
if (r.mean < BLISTER_AT) return 'softening';
|
||
if (r.blisterFrac > 0.25) return 'blistered';
|
||
return 'coming along';
|
||
}
|