diff --git a/OPUS-BUILD-BRIEF-2.md b/OPUS-BUILD-BRIEF-2.md index 3c4d928..1ae598e 100644 --- a/OPUS-BUILD-BRIEF-2.md +++ b/OPUS-BUILD-BRIEF-2.md @@ -35,7 +35,59 @@ M0–M9 are shipped and verified: toasting (browning field), spreading (knife-an 5. **Silhouettes are gameplay** → procedural geometry for anything the player must identify or that carries physics. Generated GLBs for hero props and vessels. 6. **The judge is the reward.** Every new criterion ships with ~5 bad lines + 3 good lines in his voice: dry, specific, never cruel except about MITEY. -## 2. M10 finish line — verify the bakery (do this first) +## 0.5 STATUS — updated 2026-07-17, read this before §2 + +**M10 is DONE, verified and pushed** (commit `e5be266`). §2 below is history; skip it. +**M11 is code-complete and verified, committed as WIP** — see §3, and the notes here. + +What M11 landed (all typechecked, all played headless via the harness): +- `sim/ingredients.ts` — the nine-ingredient cast with the full profile from §3.1. +- `sim/cutting.ts` — `CutSession`, patterns halve/slices/wedges/dice, slip physics, + juice ejection, `pieceCV`. **Note:** CV for `slices`/`halve` runs over piece WIDTHS, + not volumes — on a round ingredient the end slices are thinner by geometry and a + perfect cut still scored CV 0.40, which is not a skill test. Wedges/dice keep volumes. +- `sim/mess.ts` — the judged mess field + solids list + mass-conserving wipe. +- `scenes/prep.ts` — the bench: aim/bite/saw, seams, pieces fan out at the end, + juice stain texture, instanced solids, live `bench: clean/smeared/a crime scene`. + Session end does NOT auto-exit — ENTER hands off, so you can wipe first. +- `judging.ts` — `PrepResult`, `cutEvennessCriterion` (The Pieces, w 1.3), + `benchCriterion` (The Bench, w 0.9), `clarkEvans` extracted for M15's toppings, + criteria tagged with `group` and the scorecard grouped by station. +- `lines.ts` — 8 lines each for `cutcv` and `bench`. +- Harness: `t.prep(ing, pattern, knife)`, `t.chop({pos, dy, wobble, press})`, + `t.wipe(passes)`, `t.prepStats()`, plus `t.start()`, `t.day(n)`, `t.pick(id)`, `t.slice()`. + +Measured on the bench (repeat these before changing tuning): +- Even deliberate slices (dinner knife, 0.045 u/frame) → CV ~0, bench mass 0.51. +- Sloppy positions + frantic speed (0.3 u/frame) → CV 0.72, mass 18.8, 16 solids, "a crime scene". +- Press a dinner knife on a tomato without sawing → it skates: 1 slip, aim jumped 0 → 0.12. +- Same press with The Best Thing → bites in 7 frames, 0 slips (sharp edges sink on downforce). +- Wipe two passes → mass 0.51 → 0.09. + +**M11 is NOT finished — remaining before it can be called done:** +1. No order actually routes through the bench yet. `Game.startPrep()` exists and the + harness drives it, but `Order.prep?: PrepStep[]` is declared and unused — no day + sends you there. Wire a day-10 prep order and play it end to end. +2. `dice` two-phase and `wedges` are implemented in the sim but never driven — + no rotate-the-board input, no verification. M14 needs the dice; do it there or here. +3. The pieces fan-out is placeholder-grade (cylinders). Pieces aren't physics bodies yet. +4. The wipe is the same drag as cutting, disambiguated only by distance from the + ingredient — it needs a real cloth tool/mode before it reads to a stranger. +5. No screenshots taken of the bench yet. Do that when the visuals are past placeholder. + +**BLOCKER — the asset farm is down for 3D.** Every `bg_remove_local` job fails: +m4pro's disk is 100% full (118MB free; ~3.4TB is `~/Music` — hands off, that's John's). +Flux 2D still works — `bruschetta_hero.png` and `wonderslice_bag.png` generated fine and +are committed. The whole 3D cast (bakery loaves, cutting board, prep-bench props) is +stuck at the cutout stage. `mb settings` is owner-only (403), so re-routing the +bg_remove lane needs John. Once there's space: `bash scripts/gen-assets.sh bakery` +then `bash scripts/gen-assets.sh prep` — retry-once is now built into `gen_3d`, seeds +are bumped, and the prep cast + 2D are already queued in the script. Everything +degrades gracefully: `loadProp` catches, so the procedural stand-ins just stay. + +--- + +## 2. M10 finish line — verify the bakery (DONE — kept for the record) Everything compiles and day 9 reaches the drawer with the right ticket (verified: chips show `the bakery loaf / doorstop — slice it yourself`, timer reads "they are waiting", and the drawer contains `bread_knife` AND `the_best_thing`). Remaining: diff --git a/public/assets/img/bruschetta_hero.png b/public/assets/img/bruschetta_hero.png new file mode 100644 index 0000000..665b509 Binary files /dev/null and b/public/assets/img/bruschetta_hero.png differ diff --git a/public/assets/img/wonderslice_bag.png b/public/assets/img/wonderslice_bag.png new file mode 100644 index 0000000..69faf15 Binary files /dev/null and b/public/assets/img/wonderslice_bag.png differ diff --git a/src/dev.ts b/src/dev.ts index 8126449..4c43d2d 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -203,6 +203,77 @@ export function installDevHarness(app: App, game: Game): void { return !drawer.root.visible; }, + /** Enter the prep bench with an ingredient, a knife, and a pattern. */ + prep(ing = 'tomato', pattern: { kind: string; n?: number } = { kind: 'slices', n: 5 }, knife = 'dinner_knife') { + (game as unknown as { startPrep(i: string, p: unknown, k: string): void }).startPrep(ing, pattern, knife); + run(3); + }, + + /** + * One cut at the prep bench: aim at `pos` (-0.5..0.5 along the axis), + * bite, saw to completion. `press` seconds of leaning without sawing + * first, to invite the slip. + */ + chop(opts: { pos?: number; dy?: number; wobble?: number; press?: number } = {}) { + const { pos = 0, dy = 0.045, wobble = 0, press = 0 } = opts; + const pv = game.prepView as unknown as { + session: { phase: string; aimPos: number; progress: number; slips: number; cuts: number[] }; + }; + const s = pv.session; + const ing = (game.prepView as unknown as { ing: { size: number } }).ing; + run(2); + const Z = -0.2; + const X = () => s.aimPos * ing.size; // slip moves the aim; follow it + point(pos * ing.size, 1.0, Z, false); + run(2); + point(pos * ing.size, 1.0, Z, true); + run(1); + if (press > 0) { + // Lean on it without sawing. + for (let i = 0; i < Math.round(press * 60); i++) { + point(X(), 1.0, Z, true); + run(1); + if (s.phase === 'aim') break; // it slipped and threw the knife off + } + } + let y = 1.0; + let dir = -1; + let f = 0; + while ((s.phase === 'bite' || s.phase === 'saw') && f < 3000) { + y += dir * dy; + if (y < 0.8 || y > 1.3) dir = -dir; + point(X() + (f % 2 ? wobble : -wobble), y, Z, true); + run(1); + f++; + } + app.input.down = false; + run(2); + return { phase: s.phase, cuts: [...s.cuts], slips: s.slips, frames: f }; + }, + + /** Drag the cloth across the board `passes` times. */ + wipe(passes = 1, vAt = 0.5) { + const bw = 4.6; + const bd = 2.4; + for (let p = 0; p < passes; p++) { + for (let i = 0; i <= 40; i++) { + const u = p % 2 ? 1 - i / 40 : i / 40; + const wx = (0.15 + u * 0.7) * bw - bw / 2; + const wz = (vAt - 0.5) * bd; + point(wx, 0.12, wz, true); + run(1); + } + } + app.input.down = false; + run(2); + return game.prepView.mess.stats(); + }, + + prepStats() { + const r = game.prepView.result(); + return { ...r, bench: game.prepView.mess.stats(), word: game.prepView.mess.benchWord() }; + }, + /** * Cut a slice off the loaf. `dy` is world-units per frame of saw motion * (0.05 ≈ deliberate, 0.4 ≈ slamming); `wobble` is sideways drift per frame. diff --git a/src/game/game.ts b/src/game/game.ts index 845e9a9..1d07544 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -2,6 +2,10 @@ import type { App } from '../core/app'; import { Rng } from '../core/rng'; import { KitchenView } from '../scenes/kitchen'; import { SlicerView } from '../scenes/slicer'; +import { PrepView } from '../scenes/prep'; +import type { CutPattern } from '../sim/cutting'; +import type { IngredientId } from '../sim/ingredients'; +import type { PrepResult } from './judging'; import { JudgeView } from '../scenes/judge'; import { DrawerView } from '../scenes/drawer'; import { TOOLS, type ToolId } from '../sim/cutlery'; @@ -30,6 +34,9 @@ export class Game { private judgeView: JudgeView; private drawer: DrawerView; private slicer: SlicerView; + private prep: PrepView; + /** What the prep bench reported for the current order, if it ran. */ + private prepResult: PrepResult | null = null; /** The pre-toast drawer trip on loaf days: you're after a bread knife. */ private knifeTrip = false; private rng = new Rng(20260716); @@ -47,13 +54,16 @@ export class Game { this.judgeView = new JudgeView(app); this.drawer = new DrawerView(app, 7); this.slicer = new SlicerView(app); + this.prep = new PrepView(app); app.scene.add(this.kitchen.root); app.scene.add(this.judgeView.root); app.scene.add(this.drawer.root); app.scene.add(this.slicer.root); + app.scene.add(this.prep.root); this.judgeView.root.visible = false; this.drawer.root.visible = false; this.slicer.root.visible = false; + this.prep.root.visible = false; this.ticket = new Panel('ticket'); this.ticketEl = el('div', 'ticket-body', this.ticket.root); @@ -120,6 +130,29 @@ export class Game { get slicerView(): SlicerView { return this.slicer; } + get prepView(): PrepView { + return this.prep; + } + + /** + * Send the player to the prep bench. Orders with prep steps route through + * here (M12+); the harness drives it directly for verification. + */ + startPrep(ingredient: IngredientId, pattern: CutPattern, knife: ToolId = 'dinner_knife', ask = ''): void { + this.prep.reset(ingredient, knife, pattern, ask || `${pattern.kind} the ${ingredient.replace(/_/g, ' ')}`); + this.kitchen.root.visible = false; + this.drawer.root.visible = false; + this.slicer.root.visible = false; + this.prep.root.visible = true; + this.app.setView(this.prep); + this.prep.onDone = (result) => { + this.prepResult = result; + this.prep.root.visible = false; + this.kitchen.root.visible = true; + this.app.setView(this.kitchen); + this.kitchen.flash('prep done — the bench remembers'); + }; + } /** Test hook: jump straight to a day, through the real order table. */ setDay(day: number): void { @@ -180,6 +213,7 @@ export class Game { if (!o.text) o.text = `${o.browningName}, ${o.amount} ${o.spread === 'mitey' ? 'MITEY' : o.spread}. ${o.noChar ? 'No burnt bits.' : ''}`; this.order = o; this.orderSeconds = 0; + this.prepResult = null; this.timing = true; const s = this.judgeView.release(); @@ -238,7 +272,7 @@ export class Game { this.timing = false; const seconds = this.orderSeconds; const slice = this.kitchen.currentSlice; - const v = judge(slice, this.order, this.kitchen.toolId, seconds); + const v = judge(slice, this.order, this.kitchen.toolId, seconds, this.prepResult ?? undefined); this.save.total += v.total; const key = `day${this.order.day}`; this.save.best[key] = Math.max(this.save.best[key] ?? 0, v.total); diff --git a/src/game/judging.ts b/src/game/judging.ts index 091d6c7..429ff85 100644 --- a/src/game/judging.ts +++ b/src/game/judging.ts @@ -12,6 +12,19 @@ export interface Criterion { weight: number; /** What the number actually was, in words. */ detail: string; + /** Which station earned it — the scorecard groups by this on prep orders. */ + group?: 'prep' | 'toast' | 'spread'; +} + +/** What the prep bench hands the judge, when the order had prep on it. */ +export interface PrepResult { + /** Coefficient of variation of piece volumes, from sim/cutting pieceCV. */ + cv?: number; + pieces?: number; + patternName?: string; + slips?: number; + /** From sim/mess Mess.stats() at serve time. */ + bench?: { mass: number; spreadFrac: number; solids: number }; } export type Grade = 'S' | 'A' | 'B' | 'C' | 'F'; @@ -35,7 +48,13 @@ export const COVER_FLOOR = 0.06; * Every number here is read off the same fields the player was pushing around * with the knife, so the scorecard can always point at something real. */ -export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: number): Verdict { +export function judge( + slice: Slice, + order: Order, + usedTool: ToolId, + seconds: number, + prep?: PrepResult, +): Verdict { const b = slice.browning.stats(slice.mask); const s = slice.spread.stats(slice.mask); const d = slice.damage.stats(slice.mask); @@ -54,8 +73,11 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num const covered = spreadStdevOverCovered(slice, COVER_FLOOR); const criteria: Criterion[] = [ + ...(prep?.cv !== undefined ? [cutEvennessCriterion(prep.cv, prep.pieces ?? 0, prep.patternName ?? 'cut')] : []), + ...(prep?.bench ? [benchCriterion(prep.bench)] : []), { key: 'browning', + group: 'toast', label: 'Browning', score: clamp01(1 - Math.abs(b.mean - order.browning) / 0.3), weight: 1.5, @@ -63,6 +85,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num }, { key: 'evenness', + group: 'toast', label: 'Evenness', score: clamp01(1 - b.stdev / 0.2), weight: 1.1, @@ -70,6 +93,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num }, { key: 'coverage', + group: 'spread', label: 'Coverage', score: clamp01((coverage - 0.15) / 0.75), weight: 1.3, @@ -77,6 +101,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num }, { key: 'uniformity', + group: 'spread', label: 'Uniformity', score: clamp01(1 - covered / 0.22), weight: 0.9, @@ -84,6 +109,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num }, { key: 'char', + group: 'toast', label: 'Char', score: order.noChar ? clamp01(1 - charFrac / 0.12) : clamp01(1 - charFrac / 0.45), weight: order.noChar ? 1.3 : 0.5, @@ -91,6 +117,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num }, { key: 'integrity', + group: 'spread', label: 'Integrity', score: clamp01(1 - d.mean / 0.06), weight: 1.2, @@ -101,6 +128,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num ...(def.separates ? [consistencyCriterion(slice)] : []), { key: 'amount', + group: 'spread', label: 'The Right Amount', score: amountScore(s.mean, lo, hi, target), weight: 1.4, @@ -154,7 +182,7 @@ export function judge(slice: Slice, order: Order, usedTool: ToolId, seconds: num function cutCriterion(slice: Slice, order: Order): Criterion { const c = slice.cut; if (!c) { - return { key: 'cut', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' }; + return { key: 'cut', group: 'prep', label: 'The Cut', score: 0.1, weight: 1.3, detail: 'it was never sliced' }; } const [lo, hi] = order.cutBand ?? [0.16, 0.24]; const mid = (lo + hi) / 2; @@ -172,6 +200,7 @@ function cutCriterion(slice: Slice, order: Order): Criterion { c.wedge > 0.45 ? 'a full wedge' : c.wedge > 0.18 ? 'a bit wedged' : c.squash > 0.3 ? 'squashed' : 'clean'; return { key: 'cut', + group: 'prep', label: 'The Cut', score, weight: 1.3, @@ -200,21 +229,7 @@ function rindCriterion(slice: Slice, name: string): Criterion { } // Mask area in UV units — the bread is the study region, not the unit square. const area = slice.mask.data.reduce((a, v) => a + (v > 0.5 ? 1 : 0), 0) / (slice.mask.n * slice.mask.n); - let sum = 0; - for (let i = 0; i < n; i++) { - let best = Infinity; - for (let j = 0; j < n; j++) { - if (i === j) continue; - const du = pts[i].u - pts[j].u; - const dv = pts[i].v - pts[j].v; - const d2 = du * du + dv * dv; - if (d2 < best) best = d2; - } - sum += Math.sqrt(best); - } - const observed = sum / n; - const expected = 0.5 * Math.sqrt(area / n); - const R = observed / expected; + const R = clarkEvans(pts, area); const score = clamp01(R / 0.95); const word = R < 0.45 ? 'one clump' : R < 0.75 ? 'clumped' : R < 1.05 ? 'scattered' : 'evenly strewn'; return { @@ -260,6 +275,74 @@ function consistencyCriterion(slice: Slice): Criterion { }; } +/** + * Clark–Evans nearest-neighbour index over a point set in a study region of + * `area` (UV² units). R ≈ 0 one clump, 1 random, >1 deliberately even. Used + * by the rind today; topping distribution (M15) takes the same point list. + */ +export function clarkEvans(pts: { u: number; v: number }[], area: number): number { + const n = pts.length; + if (n < 2 || area <= 0) return 0; + let sum = 0; + for (let i = 0; i < n; i++) { + let best = Infinity; + for (let j = 0; j < n; j++) { + if (i === j) continue; + const du = pts[i].u - pts[j].u; + const dv = pts[i].v - pts[j].v; + const d2 = du * du + dv * dv; + if (d2 < best) best = d2; + } + sum += Math.sqrt(best); + } + const observed = sum / n; + const expected = 0.5 * Math.sqrt(area / n); + return observed / expected; +} + +/** + * Piece evenness off the prep bench: the CV of piece volumes, banded the way + * the live readout words it. Anyone can cut five pieces; the marks are for + * five pieces that could be mistaken for each other. + */ +export function cutEvennessCriterion(cv: number, pieces: number, patternName: string): Criterion { + const score = clamp01(1 - (cv - 0.05) / 0.32); + const word = cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery'; + return { + key: 'cutcv', + group: 'prep', + label: 'The Pieces', + score, + weight: 1.3, + detail: `${pieces} ${patternName}, CV ${cv.toFixed(3)} — ${word}`, + }; +} + +/** + * The state of the board at serve time. Weighted mildly (0.9): mess is a + * running gag before it's a crime, but it IS on the card. + */ +export function benchCriterion(bench: { mass: number; spreadFrac: number; solids: number }): Criterion { + const badness = bench.spreadFrac * 3 + bench.solids * 0.04 + bench.mass * 0.4; + const score = clamp01(1 - badness / 1.1); + const worst = + bench.solids >= 3 && bench.solids * 0.04 > bench.spreadFrac * 3 + ? `${bench.solids} bits on my board` + : bench.spreadFrac > 0.08 + ? `juice over ${Math.round(bench.spreadFrac * 100)}% of it` + : badness < 0.25 + ? 'clean' + : 'smeared'; + return { + key: 'bench', + group: 'prep', + label: 'The Bench', + score, + weight: 0.9, + detail: worst, + }; +} + function cap(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/src/game/lines.ts b/src/game/lines.ts index bdf0a12..368d277 100644 --- a/src/game/lines.ts +++ b/src/game/lines.ts @@ -159,6 +159,34 @@ const BANK: Bank = { 'Correct utensil. Small thing. Not nothing.', ], }, + cutcv: { + bad: [ + 'These pieces are related, at best. Distant cousins.', + 'One of these is a slice. The rest are opinions.', + 'I lined them up by size. That took a while. That is the problem.', + 'You cut this the way weather cuts a coastline.', + 'Every piece is a surprise. Nobody ordered surprises.', + ], + good: [ + 'Even pieces. Actually even. I measured.', + 'You could deal these like cards.', + 'Uniform. The knife was under instruction, and it listened.', + ], + }, + bench: { + bad: [ + 'There is juice on everything. Everything.', + "Somebody's seeds are on my floor.", + 'The board looks like it lost an argument.', + 'You cooked here the way storms cook.', + 'I am not inspecting the food yet. I am inspecting the aftermath.', + ], + good: [ + "A clean board. You'd be surprised how rare.", + 'The bench is spotless. I checked the corners.', + 'Somebody wiped as they went. Somebody was raised right.', + ], + }, }; /** MITEY gets its own vocabulary. */ diff --git a/src/game/orders.ts b/src/game/orders.ts index e752f90..68d0bd0 100644 --- a/src/game/orders.ts +++ b/src/game/orders.ts @@ -2,6 +2,19 @@ import { BRANDS, type BreadId } from '../sim/bread'; import type { AmountClass, SpreadId } from '../sim/spreads'; import type { ToolId } from '../sim/cutlery'; import { CUT_BANDS, type CutClass } from '../sim/slicing'; +import type { CutPattern } from '../sim/cutting'; +import type { IngredientId } from '../sim/ingredients'; + +/** + * A stop at the prep bench before (or instead of) the toaster. M11 ships the + * 'cut' kind; juicing, zesting, grating and roasting arrive with their + * stations (M12+) and extend this union. + */ +export interface PrepStep { + kind: 'cut'; + ingredient: IngredientId; + pattern: CutPattern; +} export interface Order { day: number; @@ -28,6 +41,8 @@ export interface Order { text: string; /** Who's asking. */ who: string; + /** Work at the prep bench, when the order has any. */ + prep?: PrepStep[]; } export const BROWNING_NAMES: [number, string][] = [ diff --git a/src/scenes/judge.ts b/src/scenes/judge.ts index d285d67..4411850 100644 --- a/src/scenes/judge.ts +++ b/src/scenes/judge.ts @@ -105,7 +105,20 @@ export class JudgeView implements View { this.orderEl.textContent = `Day ${order.day} · ${order.who} asked for ${order.browningName}, ${order.amount} ${order.spread === 'mitey' ? 'MITEY' : order.spread}`; clear(this.cardEl); - for (const c of verdict.criteria) { + // On prep orders the card is long enough to need signposts: group rows by + // station. Ordinary toast orders keep the flat card they always had. + const grouped = verdict.criteria.some((c) => c.group === 'prep'); + const headers: Record = { prep: 'THE PREP', toast: 'THE TOAST', spread: 'THE SPREAD' }; + let lastGroup: string | undefined; + const rank = (g?: string) => (g === 'prep' ? 0 : g === 'toast' ? 1 : g === 'spread' ? 2 : 3); + const ordered = grouped + ? [...verdict.criteria].sort((a, b) => rank(a.group) - rank(b.group)) + : verdict.criteria; + for (const c of ordered) { + if (grouped && c.group !== lastGroup) { + lastGroup = c.group; + if (c.group) el('div', 'crit-group', this.cardEl, headers[c.group]); + } const row = el('div', 'crit', this.cardEl); el('span', 'crit-name', row, c.label); const bar = el('span', 'crit-bar', row); diff --git a/src/scenes/prep.ts b/src/scenes/prep.ts new file mode 100644 index 0000000..c4edd41 --- /dev/null +++ b/src/scenes/prep.ts @@ -0,0 +1,429 @@ +import * as THREE from 'three'; +import type { App, View } from '../core/app'; +import { audio } from '../core/audio'; +import { INGREDIENTS, type Ingredient, type IngredientId } from '../sim/ingredients'; +import { + aimCut, + beginBite, + cutFrame, + cvWord, + liftKnife, + newCutSession, + pieceCV, + pieceVolumes, + plannedCuts, + type CutPattern, + type CutSession, +} from '../sim/cutting'; +import { Mess } from '../sim/mess'; +import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery'; +import type { PrepResult } from '../game/judging'; +import { el, Panel } from '../ui/hud'; +import { Rng } from '../core/rng'; +import { loadProp } from './assets'; + +const BOARD_Y = 0.06; +const BOARD_W = 4.6; +const BOARD_D = 2.4; + +/** + * The prep bench. One board, one knife, one ingredient at a time, and a mess + * field that remembers everything you did to all three. + * + * Controls mirror the slicer on purpose: horizontal mouse aims the cut, press + * bites, vertical strokes saw. The two new verbs are the ingredient's, not + * yours: slippery skin skates if you lean without sawing, and wet flesh + * juices the board. Drag on the empty board (off the ingredient) to wipe. + */ +export class PrepView implements View { + readonly root = new THREE.Group(); + readonly mess = new Mess(96); + + private session: CutSession | null = null; + private ing!: Ingredient; + private ingMesh: THREE.Group | null = null; + private pieces: THREE.Group | null = null; + private seams: THREE.Mesh[] = []; + private knifeMesh!: THREE.Group; + private rng = new Rng(20260717); + + private messTexData: Uint8Array; + private messTex: THREE.DataTexture; + private messVersion = -1; + private solidsMesh: THREE.InstancedMesh; + + private ray = new THREE.Raycaster(); + private planePt = new THREE.Vector3(); + private plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0.2); + private boardPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -(BOARD_Y * 2)); + private prev = new THREE.Vector2(); + private hasPrev = false; + private wipePrev: THREE.Vector3 | null = null; + private sawPhase = 0; + private doneTimer = 0; + + private panel: Panel; + private askEl!: HTMLElement; + private cutsEl!: HTMLElement; + private progFill!: HTMLElement; + private benchEl!: HTMLElement; + private hintEl!: HTMLElement; + + onDone: ((result: PrepResult) => void) | null = null; + + constructor(private app: App) { + this.buildScenery(); + const n = this.mess.field.n; + this.messTexData = new Uint8Array(n * n * 4); + this.messTex = new THREE.DataTexture(this.messTexData, n, n, THREE.RGBAFormat); + this.messTex.minFilter = THREE.LinearFilter; + this.messTex.magFilter = THREE.LinearFilter; + const stain = new THREE.Mesh( + new THREE.PlaneGeometry(BOARD_W, BOARD_D), + new THREE.MeshBasicMaterial({ map: this.messTex, transparent: true, depthWrite: false }), + ); + stain.rotation.x = -Math.PI / 2; + stain.position.y = BOARD_Y * 2 + 0.004; + this.root.add(stain); + + // Solids: seeds and skins, instanced like the rind bits. + const seedGeo = new THREE.SphereGeometry(0.028, 8, 6); + seedGeo.scale(1, 0.45, 0.72); + this.solidsMesh = new THREE.InstancedMesh( + seedGeo, + new THREE.MeshStandardMaterial({ color: 0xf0e6c8, roughness: 0.6 }), + 256, + ); + this.solidsMesh.count = 0; + this.solidsMesh.castShadow = true; + this.root.add(this.solidsMesh); + + this.panel = new Panel('prep-panel'); + this.buildUi(); + this.panel.hide(); + } + + private buildUi(): void { + const p = this.panel.root; + const card = el('div', 'slicer-card', p); + el('div', 'drawer-lbl', card, 'PREP IT'); + this.askEl = el('div', 'slicer-ask', card, ''); + this.cutsEl = el('div', 'slicer-thick', card, ''); + const bar = el('div', 'timer-bar', card); + this.progFill = el('div', 'timer-fill', bar); + this.benchEl = el('div', 'slicer-wobble', card, ''); + this.hintEl = el('div', 'drawer-hint', p, ''); + } + + private buildScenery(): void { + const bench = new THREE.Mesh( + new THREE.BoxGeometry(24, 0.4, 12), + new THREE.MeshStandardMaterial({ color: 0x4a3524, roughness: 0.85 }), + ); + bench.position.y = -0.2; + bench.receiveShadow = true; + this.root.add(bench); + const board = new THREE.Mesh( + new THREE.BoxGeometry(BOARD_W, BOARD_Y * 2, BOARD_D), + new THREE.MeshStandardMaterial({ color: 0xa87c4f, roughness: 0.7 }), + ); + board.position.y = BOARD_Y; + board.receiveShadow = true; + board.castShadow = true; + this.root.add(board); + void loadProp('/assets/models/cutting_board.glb', 2.2) + .then((prop) => { + prop.position.y = BOARD_Y; + board.visible = false; + this.root.add(prop); + }) + .catch(() => undefined); + } + + /** Procedural stand-in silhouettes — the shapes carry the physics. */ + private buildIngredientMesh(ing: Ingredient): THREE.Group { + const g = new THREE.Group(); + const skin = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB(...ing.colors.skin, THREE.SRGBColorSpace), + roughness: 0.55, + }); + let mesh: THREE.Mesh; + const r = ing.size / 2; + switch (ing.shape) { + case 'ovoid': { + mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin); + mesh.scale.set(1.25, 0.92, 0.92); + break; + } + case 'block': { + mesh = new THREE.Mesh(new THREE.BoxGeometry(ing.size, ing.size * 0.6, ing.size * 0.7), skin); + break; + } + case 'wedge': { + mesh = new THREE.Mesh(new THREE.CylinderGeometry(r, r, ing.size * 0.5, 24, 1, false, 0, Math.PI / 2.2), skin); + mesh.rotation.y = Math.PI / 4; + break; + } + case 'bulb': { + mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 20, 14), skin); + mesh.scale.set(1, 0.85, 1); + break; + } + default: + mesh = new THREE.Mesh(new THREE.SphereGeometry(r, 28, 20), skin); + } + mesh.castShadow = true; + mesh.receiveShadow = true; + g.add(mesh); + g.position.y = BOARD_Y * 2 + (ing.shape === 'block' ? ing.size * 0.3 : r * (ing.shape === 'bulb' ? 0.85 : 0.92)); + return g; + } + + /** A fresh ingredient and a fresh session. */ + reset(ingredientId: IngredientId, knife: ToolId, pattern: CutPattern, askText: string): void { + this.ing = INGREDIENTS[ingredientId]; + this.session = newCutSession(this.ing, knife, pattern); + if (this.ingMesh) this.root.remove(this.ingMesh); + if (this.pieces) this.root.remove(this.pieces); + this.pieces = null; + for (const s of this.seams) this.root.remove(s); + this.seams = []; + this.ingMesh = this.buildIngredientMesh(this.ing); + this.root.add(this.ingMesh); + if (this.knifeMesh) this.root.remove(this.knifeMesh); + this.knifeMesh = makeCutleryMesh(TOOLS[knife]); + this.root.add(this.knifeMesh); + this.mess.reset(); + this.askEl.textContent = askText; + this.doneTimer = 0; + this.hasPrev = false; + this.wipePrev = null; + this.hintEl.textContent = 'aim · press on it and saw up-down · drag the board to wipe'; + } + + enter(): void { + this.panel.show(); + } + exit(): void { + this.panel.hide(); + } + + /** Board-local UV under the pointer, if the ray hits the board. */ + private boardUV(): { u: number; v: number } | null { + const pt = new THREE.Vector3(); + if (!this.ray.ray.intersectPlane(this.boardPlane, pt)) return null; + const u = (pt.x + BOARD_W / 2) / BOARD_W; + const v = (pt.z + BOARD_D / 2) / BOARD_D; + if (u < 0 || u > 1 || v < 0 || v > 1) return null; + return { u, v }; + } + + update(dt: number): void { + const inp = this.app.input; + this.app.camera.position.set(1.2, 2.3, 4.4); + this.app.camera.lookAt(0.6, 0.5, -0.1); + const s = this.session; + if (!s) return; + + this.ray.setFromCamera(inp.ndc, this.app.camera); + this.plane.set(new THREE.Vector3(0, 0, 1), 0.2); + const hit = this.ray.ray.intersectPlane(this.plane, this.planePt); + + // Cutting happens on the vertical plane near the ingredient; wiping on the + // board everywhere else. "Near" = within a size of the centre. + const nearIngredient = hit && Math.abs(this.planePt.x) < this.ing.size * 0.85 && s.phase !== 'done'; + + if (s.phase !== 'done' && nearIngredient && hit) { + if (s.phase === 'aim') { + aimCut(s, this.planePt.x / this.ing.size); + if (inp.down) { + beginBite(s); + audio.clatter(0.15, 1.8); + } + } else if (inp.down && this.hasPrev) { + const dy = this.planePt.y - this.prev.y; + const dx = this.planePt.x - this.prev.x; + const f = cutFrame(s, dy, dx, dt, () => this.rng.next()); + if (Math.abs(dy) > 0.004) { + this.sawPhase += Math.abs(dy) * 3; + audio.scrape(0.4, (Math.abs(dy) / Math.max(dt, 1e-3)) * 0.22); + } + this.applyFrame(f, dy); + } else if (!inp.down) { + liftKnife(s); + } + } else if (inp.down) { + // Off the ingredient with the button down: the cloth. + const uv = this.boardUV(); + if (uv && this.wipePrev) { + this.mess.wipe(this.wipePrev.x, this.wipePrev.y, uv.u, uv.v); + } + if (uv) this.wipePrev = new THREE.Vector3(uv.u, uv.v, 0); + } + if (!inp.down) this.wipePrev = null; + + if (s.phase === 'done') { + this.doneTimer += dt; + // No auto-exit: the bench is judged at serve, so the moment after the + // last cut is exactly when you'd want the cloth. ENTER hands it over. + this.hintEl.textContent = 'done — wipe the board if you dare care · ENTER takes it to the kitchen'; + if (inp.justPressed('Enter') && this.onDone) { + const result = this.result(); + const cb = this.onDone; + this.onDone = null; + cb(result); + return; + } + } + + this.prev.set(this.planePt.x, this.planePt.y); + this.hasPrev = !!hit; + this.positionKnife(s); + this.syncMess(); + this.updateHud(s); + } + + /** What the judge reads. Snapshot at any time; final after onDone. */ + result(): PrepResult { + const s = this.session!; + return { + cv: pieceCV(s), + pieces: pieceVolumes(s).length, + patternName: s.pattern.kind === 'halve' ? 'halves' : s.pattern.kind, + slips: s.slips, + bench: this.mess.stats(), + }; + } + + private applyFrame(f: ReturnType, dy: number): void { + const s = this.session!; + const cutWorldX = s.aimPos * this.ing.size; + const u = (cutWorldX + BOARD_W / 2) / BOARD_W; + const v = 0.5; + if (f.juice > 0) { + // Sprayed along the blade (board Z), the side picked by the stroke. + const side = dy > 0 ? 1 : -1; + this.mess.addJuice(u, v, f.juice, (this.rng.next() - 0.5) * 0.3, side * (0.6 + this.rng.next() * 0.4)); + } + if (f.slipped) { + audio.clatter(0.5, 0.9); + this.app.shake = 0.015; + this.hintEl.textContent = 'it skated. saw as you press — the blade needs to bite'; + } + if (f.cutDone) { + audio.clunk(false); + this.addSeam(s.cuts[s.cuts.length - 1]); + for (let i = 0; i < f.seedsSpilled; i++) { + this.mess.addSolid(u + (this.rng.next() - 0.5) * 0.12, v + (this.rng.next() - 0.5) * 0.3, 'seed'); + } + } + if (f.sessionDone) { + this.showPieces(); + } + } + + /** Committed cuts read as dark seams on the ingredient. */ + private addSeam(pos: number): void { + const seam = new THREE.Mesh( + new THREE.BoxGeometry(0.016, this.ing.size * 0.95, this.ing.size * 0.95), + new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB( + this.ing.colors.flesh[0] * 0.45, + this.ing.colors.flesh[1] * 0.45, + this.ing.colors.flesh[2] * 0.45, + THREE.SRGBColorSpace, + ), + roughness: 1, + }), + ); + seam.position.set(pos * this.ing.size, this.ingMesh!.position.y, 0); + this.root.add(seam); + this.seams.push(seam); + } + + /** Session done: the whole becomes pieces, fanned apart so the CV is visible. */ + private showPieces(): void { + if (!this.session || this.pieces) return; + const s = this.session; + if (this.ingMesh) this.ingMesh.visible = false; + for (const seam of this.seams) this.root.remove(seam); + this.seams = []; + this.pieces = new THREE.Group(); + const flesh = new THREE.MeshStandardMaterial({ + color: new THREE.Color().setRGB(...this.ing.colors.flesh, THREE.SRGBColorSpace), + roughness: 0.5, + }); + const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5]; + let x = -this.ing.size * 0.55 - edges.length * 0.02; + for (let i = 0; i + 1 < edges.length; i++) { + const w = (edges[i + 1] - edges[i]) * this.ing.size; + const mid = (edges[i] + edges[i + 1]) / 2; + const rHere = Math.sqrt(Math.max(0.05, 0.25 - mid * mid)) * this.ing.size; + const piece = new THREE.Mesh(new THREE.CylinderGeometry(rHere, rHere, Math.max(0.02, w * 0.92), 20), flesh); + piece.rotation.z = Math.PI / 2; + piece.castShadow = true; + piece.position.set(x + w / 2, BOARD_Y * 2 + rHere, 0.1); + this.pieces.add(piece); + x += w + 0.05; + } + this.root.add(this.pieces); + } + + private positionKnife(s: CutSession): void { + if (!this.knifeMesh) return; + const topY = this.ingMesh ? this.ingMesh.position.y + this.ing.size * 0.55 : 1; + this.knifeMesh.rotation.set(0, Math.PI / 2, Math.PI / 2); + const sawZ = s.phase === 'saw' ? Math.sin(this.sawPhase * 6) * 0.3 : 0.15; + const sink = s.phase === 'aim' || s.phase === 'done' ? 0 : s.progress * this.ing.size * 0.9; + this.knifeMesh.visible = s.phase !== 'done'; + this.knifeMesh.position.set(s.aimPos * this.ing.size, topY + 0.22 - sink, sawZ + 0.35); + } + + /** Push the mess field into the stain texture when it changed. */ + private syncMess(): void { + if (this.mess.version === this.messVersion) return; + this.messVersion = this.mess.version; + const d = this.mess.field.data; + const px = this.messTexData; + for (let i = 0; i < d.length; i++) { + const m = Math.min(1, d[i] * 2.2); + px[i * 4] = 190; + px[i * 4 + 1] = 68; + px[i * 4 + 2] = 36; + px[i * 4 + 3] = Math.round(Math.min(0.85, m) * 255); + } + this.messTex.needsUpdate = true; + const solids = this.mess.solids.filter((s) => !s.collected); + const mat = new THREE.Matrix4(); + let n = 0; + for (const s of solids) { + if (n >= 256) break; + mat.setPosition(s.u * BOARD_W - BOARD_W / 2, BOARD_Y * 2 + 0.02, s.v * BOARD_D - BOARD_D / 2); + this.solidsMesh.setMatrixAt(n++, mat); + } + this.solidsMesh.count = n; + this.solidsMesh.instanceMatrix.needsUpdate = true; + } + + private updateHud(s: CutSession): void { + const total = plannedCuts(s.pattern); + const done = s.cuts.length + s.diceCutsA.length; + if (s.phase === 'done') { + const cv = pieceCV(s); + this.cutsEl.textContent = `${pieceVolumes(s).length} pieces — CV ${cv.toFixed(3)}, ${cvWord(cv)}`; + } else { + this.cutsEl.textContent = `cut ${Math.min(done + 1, total)} of ${total}${s.phase === 'aim' ? ' (aiming)' : ''}`; + } + this.progFill.style.width = `${Math.round(s.progress * 100)}%`; + this.progFill.style.background = '#e8a53a'; + const bench = this.mess.benchWord(); + const wobble = + s.phase === 'saw' && s.wedge > 0.12 ? (s.wedge > 0.3 ? ' · that cut is wandering' : ' · drifting…') : ''; + this.benchEl.textContent = `bench: ${bench}${wobble}${s.slips ? ` · slips: ${s.slips}` : ''}`; + this.benchEl.style.color = bench === 'clean' ? '' : bench === 'smeared' ? '#e8a53a' : '#e2603a'; + } + + dispose(): void { + this.panel.dispose(); + } +} diff --git a/src/sim/cutting.ts b/src/sim/cutting.ts new file mode 100644 index 0000000..9f7bf38 --- /dev/null +++ b/src/sim/cutting.ts @@ -0,0 +1,336 @@ +import { DEFAULT_SAW, TOOLS, type ToolId } from './cutlery'; +import type { Ingredient } from './ingredients'; + +/** + * Cutting, generalized from the loaf (sim/slicing.ts) to anything on the board. + * + * A CutSession is an ingredient, a knife, and a pattern. Each cut in the + * pattern runs the same three-act physics the loaf taught us — bite, saw, + * through — with two additions the loaf never needed: + * + * slip — skin.slippery × pressing-without-sawing. A serrated blade in + * motion bites; a flat blade leaning on a tomato skates. When it + * skates the cut position jumps and the juice goes somewhere. + * juice — flesh.moisture ejected along the stroke, superlinear in speed, + * so the frantic cut soaks the board and the deliberate one doesn't. + * + * The judged number is piece evenness: the coefficient of variation of piece + * volumes. CV < 0.08 reads as "even"; past 0.25 it's a lottery. + */ + +export type CutPattern = + | { kind: 'halve' } + | { kind: 'slices'; n: number } + | { kind: 'wedges'; n: number } + | { kind: 'dice'; n: number }; + +export interface CutFrame { + /** Skated this frame: the aim jumped to `aimPos`, juice sprayed. */ + slipped: boolean; + /** Juice mass ejected this frame (scene feeds it to the mess field). */ + juice: number; + /** A cut just went through. */ + cutDone: boolean; + /** Seeds shaken loose by the cut that just finished. */ + seedsSpilled: number; + /** The whole pattern is complete. */ + sessionDone: boolean; +} + +export interface CutSession { + ing: Ingredient; + knife: ToolId; + pattern: CutPattern; + phase: 'aim' | 'bite' | 'saw' | 'done'; + /** Committed cut positions, -0.5..0.5 along the axis (fraction of size). + * For wedges the same range reads as a fraction of the half-turn. */ + cuts: number[]; + /** Dice is two passes: 0 = first slices, 1 = rotated criss-cross. */ + dicePhase: 0 | 1; + diceCutsA: number[]; + aimPos: number; + /** Per-cut accumulators, reset when a cut commits. */ + progress: number; + wedge: number; + squash: number; + strokes: number; + biteTravel: number; + pressTime: number; + lastDir: number; + strokeTravel: number; + /** Session totals the judge and the sting math read. */ + slips: number; + juiceTotal: number; + /** Crushing over the whole session — onion sting scales with this (M14). */ + aggression: number; + wedgeWorst: number; + squashWorst: number; +} + +const STROKE_GAIN = 0.11; +const WOBBLE_GAIN = 0.55; +const SPEED_CAP = 3.2; +const SQUASH_GAIN = 0.55; +/** Seconds of leaning on slippery skin before it skates, at slippery = 1 + * with a knife that has no saw stats worth the name. */ +const SLIP_PATIENCE = 0.35; + +export function plannedCuts(pattern: CutPattern): number { + switch (pattern.kind) { + case 'halve': + return 1; + case 'slices': + return pattern.n - 1; + case 'wedges': + return Math.ceil(pattern.n / 2); // diametral cuts, each makes two wedges + case 'dice': + return (pattern.n - 1) * 2; // two rotated passes of n-1 + } +} + +export function newCutSession(ing: Ingredient, knife: ToolId, pattern: CutPattern): CutSession { + return { + ing, + knife, + pattern, + phase: 'aim', + cuts: [], + dicePhase: 0, + diceCutsA: [], + aimPos: 0, + progress: 0, + wedge: 0, + squash: 0, + strokes: 0, + biteTravel: 0, + pressTime: 0, + lastDir: 0, + strokeTravel: 0, + slips: 0, + juiceTotal: 0, + aggression: 0, + wedgeWorst: 0, + squashWorst: 0, + }; +} + +export function cutSawStats(knife: ToolId): { eff: number; steady: number; gentle: number } { + return TOOLS[knife].saw ?? DEFAULT_SAW; +} + +/** While aiming: where the next cut will land. */ +export function aimCut(s: CutSession, pos: number): void { + if (s.phase !== 'aim') return; + s.aimPos = Math.min(0.5, Math.max(-0.5, pos)); +} + +/** Mouse went down on the ingredient: the blade is on the skin. */ +export function beginBite(s: CutSession): void { + if (s.phase === 'aim') { + s.phase = 'bite'; + s.biteTravel = 0; + s.pressTime = 0; + } +} + +/** Mouse released mid-cut: progress keeps, the blade comes off the skin. */ +export function liftKnife(s: CutSession): void { + if (s.phase === 'bite') s.phase = 'aim'; +} + +/** + * One frame of blade-on-ingredient. `dz` is saw motion along the blade, `dx` + * sideways wander. `rand` feeds the slip jump only — everything else is + * deterministic, which is what makes it verifiable. + */ +export function cutFrame(s: CutSession, dz: number, dx: number, dt: number, rand: () => number): CutFrame { + const out: CutFrame = { slipped: false, juice: 0, cutDone: false, seedsSpilled: 0, sessionDone: false }; + if (s.phase !== 'bite' && s.phase !== 'saw') return out; + const k = cutSawStats(s.knife); + const speed = dt > 0 ? Math.abs(dz) / dt : 0; + + if (s.phase === 'bite') { + // The first bite: saw motion earns grip, leaning earns a skate. A truly + // sharp edge (The Best Thing) sinks in on downforce alone — that is the + // whole legend of it. + s.biteTravel += Math.abs(dz) + Math.max(0, k.eff - 1.05) * 0.08 * dt; + s.pressTime += dt; + const biteNeed = 0.015 + s.ing.skin.resistance * 0.05 * (1.6 - Math.min(1.2, k.eff)); + if (s.biteTravel >= biteNeed) { + s.phase = 'saw'; + } else { + const patience = (SLIP_PATIENCE / (s.ing.skin.slippery + 0.05)) * (0.4 + k.eff); + if (s.pressTime > patience) { + // It skates. The cut position jumps, the juice goes on the board. + s.slips++; + s.pressTime = 0; + s.biteTravel = 0; + const jump = (0.07 + rand() * 0.09) * (rand() < 0.5 ? -1 : 1); + s.aimPos = Math.min(0.5, Math.max(-0.5, s.aimPos + jump)); + out.slipped = true; + out.juice = Math.max(0, s.ing.flesh.moisture - 0.3) * 0.35; + s.juiceTotal += out.juice; + } + return out; + } + } + + // Sawing. Same speed-capped progress as the loaf, scaled by flesh fight. + const hardness = 0.35 / (0.12 + s.ing.flesh.resistance * 0.55); + const sizeFactor = 0.5 / (0.2 + s.ing.size * 0.6); + const useful = Math.min(Math.abs(dz), SPEED_CAP * dt); + s.progress += useful * STROKE_GAIN * k.eff * hardness * sizeFactor * 14; + + const excess = Math.max(0, speed - SPEED_CAP * (0.6 + k.gentle)); + const crush = excess * SQUASH_GAIN * (1 - k.gentle) * (1.3 - s.ing.flesh.resistance) * dt; + s.squash = Math.min(1, s.squash + crush); + s.aggression += crush; + + s.wedge = Math.min(1, s.wedge + Math.abs(dx) * WOBBLE_GAIN * (1 - k.steady)); + + const dir = Math.sign(dz); + if (dir !== 0 && dir !== s.lastDir && s.strokeTravel > 0.08) { + s.strokes++; + s.strokeTravel = 0; + } + if (dir !== 0) s.lastDir = dir; + s.strokeTravel += Math.abs(dz); + + // Juice: moisture × speed², so the deliberate cut leaks and the frantic one + // hoses. Only flesh wetter than 0.4 juices at all. + if (s.ing.flesh.moisture > 0.4) { + out.juice = (s.ing.flesh.moisture - 0.35) * speed * Math.abs(dz) * 0.1; + s.juiceTotal += out.juice; + } + + if (s.progress >= 1) { + out.cutDone = true; + s.cuts.push(s.aimPos); + s.wedgeWorst = Math.max(s.wedgeWorst, s.wedge); + s.squashWorst = Math.max(s.squashWorst, s.squash); + out.seedsSpilled = seedsFor(s); + s.progress = 0; + s.wedge = 0; + s.squash = 0; + s.biteTravel = 0; + s.pressTime = 0; + if (s.pattern.kind === 'dice' && s.dicePhase === 0 && s.cuts.length >= plannedCuts(s.pattern) / 2) { + // First pass done: bank it, rotate the board, go again. + s.diceCutsA = s.cuts; + s.cuts = []; + s.dicePhase = 1; + s.phase = 'aim'; + } else if (totalCutsDone(s) >= plannedCuts(s.pattern)) { + s.phase = 'done'; + out.sessionDone = true; + } else { + s.phase = 'aim'; + } + } + return out; +} + +function totalCutsDone(s: CutSession): number { + return s.cuts.length + s.diceCutsA.length; +} + +function seedsFor(s: CutSession): number { + const seeds = s.ing.seeds; + if (!seeds) return 0; + const justCut = s.cuts[s.cuts.length - 1] ?? 0; + if (seeds.pocket === 'core') { + return Math.abs(justCut) < 0.12 ? Math.ceil(seeds.count * 0.6) : 0; + } + // Distributed pockets shed a share per cut, more when squashed. + return Math.round((seeds.count / Math.max(1, plannedCuts(s.pattern))) * (0.35 + s.squash * 0.5)); +} + +/** + * Piece volumes implied by the committed cuts. For slices/halve these are + * slab volumes through the actual solid of revolution, so a cut near the end + * of a sphere honestly yields a sliver. + */ +export function pieceVolumes(s: CutSession): number[] { + const p = s.pattern; + if (p.kind === 'wedges') { + // Diametral cuts at angles (aimPos as fraction of the half-turn). + const angles = [...s.cuts].map((c) => (c + 0.5) * Math.PI).sort((a, b) => a - b); + if (angles.length === 0) return []; + const out: number[] = []; + for (let i = 0; i < angles.length; i++) { + const a = angles[i]; + const b = i + 1 < angles.length ? angles[i + 1] : angles[0] + Math.PI; + out.push(b - a, b - a); // each diametral gap is two opposite wedges + } + return out; + } + if (p.kind === 'dice') { + const a = widths(s.diceCutsA); + const b = widths(s.cuts); + const cells: number[] = []; + for (const wa of a) for (const wb of b) cells.push(wa * wb); + return cells; + } + const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5]; + const out: number[] = []; + for (let i = 0; i + 1 < edges.length; i++) out.push(slabVolume(s.ing, edges[i], edges[i + 1])); + return out; +} + +/** Sorted cut positions -> piece widths including both ends. */ +function widths(cuts: number[]): number[] { + const c = [...cuts].sort((a, b) => a - b); + const edges = [-0.5, ...c, 0.5]; + const out: number[] = []; + for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]); + return out; +} + +/** Volume of the slab between fractions a..a+w of the solid, unit size. */ +function slabVolume(ing: Ingredient, a: number, b: number): number { + // Numeric integral of the cross-section through a sphere/ovoid; blocks and + // wedges are uniform enough that width is volume. + if (ing.shape === 'block' || ing.shape === 'wedge') return Math.abs(b - a); + const steps = 24; + let v = 0; + for (let i = 0; i < steps; i++) { + const x = a + ((i + 0.5) / steps) * (b - a); + const r2 = Math.max(0, 0.25 - x * x); // unit sphere of radius 0.5 + v += r2; + } + return (v / steps) * Math.abs(b - a) * Math.PI; +} + +/** + * The judged statistic: coefficient of variation of the pieces. + * + * For straight slices the CV runs over piece WIDTHS, not volumes — on a round + * ingredient the end slices are thinner by geometry, and a statistic the + * player cannot get under 0.4 with a laser-guided knife isn't a skill test. + * Widths are exactly the thing the hand controls. Wedges and dice keep the + * volume/cell statistic: there the spread really is all yours. + */ +export function pieceCV(s: CutSession): number { + const vals = s.pattern.kind === 'slices' || s.pattern.kind === 'halve' ? sliceWidths(s) : pieceVolumes(s); + if (vals.length < 2) return 0; + const mean = vals.reduce((a, v) => a + v, 0) / vals.length; + if (mean <= 0) return 1; + const acc = vals.reduce((a, v) => a + (v - mean) * (v - mean), 0) / vals.length; + return Math.sqrt(acc) / mean; +} + +function sliceWidths(s: CutSession): number[] { + const edges = [-0.5, ...[...s.cuts].sort((a, b) => a - b), 0.5]; + const out: number[] = []; + for (let i = 0; i + 1 < edges.length; i++) out.push(edges[i + 1] - edges[i]); + return out; +} + +/** Halve quality: centre offset and the worst wedge — M12's yield cap reads this. */ +export function halveQuality(s: CutSession): { offset: number; wedge: number } { + return { offset: Math.abs(s.cuts[0] ?? 0.5), wedge: s.wedgeWorst }; +} + +export function cvWord(cv: number): string { + return cv < 0.08 ? 'even' : cv < 0.16 ? 'respectable' : cv < 0.25 ? 'uneven' : 'a lottery'; +} diff --git a/src/sim/ingredients.ts b/src/sim/ingredients.ts new file mode 100644 index 0000000..6637937 --- /dev/null +++ b/src/sim/ingredients.ts @@ -0,0 +1,139 @@ +/** + * The prep-bench cast. Everything a knife can be taken to, described by the + * numbers the cutting sim actually consumes — shape for the silhouette and the + * piece math, skin for the slip check, flesh for juice and layering, and the + * station-specific extras (seeds, sting, temperature needs, gratability). + * + * 1 world unit ≈ 11cm, same convention as the loaf. + */ + +export type IngredientShape = 'sphere' | 'ovoid' | 'block' | 'wedge' | 'bulb'; + +export interface Ingredient { + id: string; + name: string; + shape: IngredientShape; + /** Characteristic diameter/length in world units. */ + size: number; + /** resistance: how hard the skin fights the first bite. slippery: how keenly + * it skates when pressed without sawing — the tomato number. */ + skin: { resistance: number; slippery: number }; + /** moisture > 0.4 juices when cut; layered flesh falls apart when mistreated. */ + flesh: { resistance: number; moisture: number; layered?: boolean }; + seeds?: { count: number; pocket: 'core' | 'distributed' }; + /** Onions. 0..1, scales how fast cutting stings the eyes. */ + sting?: number; + temp?: { state: 'fridge' | 'bench'; softensInSec?: number; needsSoft?: boolean; needsCold?: boolean }; + gratable?: { yield: number; pithDepth?: number }; + roastable?: boolean; + /** Authored sRGB, same convention as bread crumb/crust. */ + colors: { skin: [number, number, number]; flesh: [number, number, number] }; +} + +export type IngredientId = + | 'tomato' + | 'roast_tomato' + | 'orange' + | 'onion' + | 'garlic' + | 'parmesan' + | 'brie' + | 'cucumber' + | 'avocado'; + +export const INGREDIENTS: Record = { + tomato: { + id: 'tomato', + name: 'tomato', + shape: 'sphere', + size: 0.6, + skin: { resistance: 0.35, slippery: 0.9 }, + flesh: { resistance: 0.2, moisture: 0.75 }, + seeds: { count: 20, pocket: 'distributed' }, + roastable: true, + colors: { skin: [0.82, 0.18, 0.12], flesh: [0.9, 0.35, 0.22] }, + }, + roast_tomato: { + id: 'roast_tomato', + name: 'roast tomato', + shape: 'sphere', + size: 0.52, + // The skin blisters off in the oven; what's left is a water balloon. + skin: { resistance: 0.1, slippery: 0.55 }, + flesh: { resistance: 0.08, moisture: 0.95 }, + colors: { skin: [0.62, 0.16, 0.1], flesh: [0.75, 0.28, 0.16] }, + }, + orange: { + id: 'orange', + name: 'orange', + shape: 'ovoid', + size: 0.68, + skin: { resistance: 0.55, slippery: 0.45 }, + flesh: { resistance: 0.3, moisture: 0.85 }, + seeds: { count: 5, pocket: 'core' }, + gratable: { yield: 1.0, pithDepth: 0.35 }, + colors: { skin: [0.95, 0.55, 0.1], flesh: [0.98, 0.68, 0.2] }, + }, + onion: { + id: 'onion', + name: 'brown onion', + shape: 'sphere', + size: 0.62, + skin: { resistance: 0.4, slippery: 0.6 }, + flesh: { resistance: 0.45, moisture: 0.55, layered: true }, + sting: 1.0, + colors: { skin: [0.72, 0.5, 0.28], flesh: [0.93, 0.9, 0.82] }, + }, + garlic: { + id: 'garlic', + name: 'garlic', + shape: 'bulb', + size: 0.28, + skin: { resistance: 0.25, slippery: 0.35 }, + flesh: { resistance: 0.5, moisture: 0.3, layered: true }, + sting: 0.15, + colors: { skin: [0.9, 0.86, 0.8], flesh: [0.95, 0.92, 0.85] }, + }, + parmesan: { + id: 'parmesan', + name: 'parmesan', + shape: 'block', + size: 0.5, + skin: { resistance: 0.8, slippery: 0.15 }, + flesh: { resistance: 0.85, moisture: 0.1 }, + temp: { state: 'fridge', softensInSec: 90, needsCold: true }, + gratable: { yield: 0.9 }, + colors: { skin: [0.85, 0.75, 0.5], flesh: [0.93, 0.87, 0.68] }, + }, + brie: { + id: 'brie', + name: 'brie', + shape: 'wedge', + size: 0.45, + skin: { resistance: 0.3, slippery: 0.2 }, + flesh: { resistance: 0.15, moisture: 0.35 }, + temp: { state: 'fridge', softensInSec: 120, needsSoft: true }, + colors: { skin: [0.95, 0.93, 0.88], flesh: [0.97, 0.92, 0.78] }, + }, + cucumber: { + id: 'cucumber', + name: 'cucumber', + shape: 'ovoid', + size: 1.4, + skin: { resistance: 0.5, slippery: 0.5 }, + flesh: { resistance: 0.25, moisture: 0.8 }, + seeds: { count: 14, pocket: 'distributed' }, + colors: { skin: [0.2, 0.45, 0.16], flesh: [0.85, 0.94, 0.75] }, + }, + avocado: { + id: 'avocado', + name: 'avocado', + shape: 'ovoid', + size: 0.62, + skin: { resistance: 0.7, slippery: 0.3 }, + // Ripeness is a lottery: roll flesh resistance per-avocado at spawn. + flesh: { resistance: 0.5, moisture: 0.45 }, + seeds: { count: 1, pocket: 'core' }, + colors: { skin: [0.16, 0.2, 0.1], flesh: [0.78, 0.85, 0.5] }, + }, +}; diff --git a/src/sim/mess.ts b/src/sim/mess.ts new file mode 100644 index 0000000..8d55531 --- /dev/null +++ b/src/sim/mess.ts @@ -0,0 +1,156 @@ +import { Field } from '../core/field'; + +/** + * The bench is judged now. + * + * Juice, pulp and stray solids live on the cutting board's own UV square — + * the same Field machinery as the toast, pointed at the crime scene around + * it. Two kinds of mess: + * + * the field — juice and smear, a mass per texel + * solids — seeds, skins, zest: discrete bits with positions, rendered + * instanced, counted by the judge individually ("three pips. + * I counted.") + * + * The wipe is spreading in reverse, and it keeps the spread's honesty: a + * cloth pass picks SOME mass up and pushes the rest ahead of itself, so one + * pass smears and the second one cleans. That's the feel; it's also why + * wiping late beats wiping never but costs you twice the service clock. + */ + +export type SolidKind = 'seed' | 'skin' | 'zest' | 'pith' | 'pulp' | 'crumb'; + +export interface Solid { + u: number; + v: number; + kind: SolidKind; + /** Set when a wipe collects it; the scene fades it out. */ + collected?: boolean; +} + +/** "There is visibly juice on this texel." Shared with the live readout. */ +export const MESS_FLOOR = 0.03; + +export class Mess { + readonly field: Field; + readonly mask: Field; + readonly solids: Solid[] = []; + /** What the cloth is currently carrying. */ + clothLoad = 0; + /** Bumped whenever anything changes, so the scene knows to re-texture. */ + version = 0; + + constructor(n = 96) { + this.field = new Field(n); + this.mask = new Field(n, 1); + } + + /** + * Juice lands as a streak: a few blobs along the stroke direction from the + * cut point, weaker with distance. `du,dv` is the (unnormalized) stroke + * direction; more speed = longer throw. + */ + addJuice(u: number, v: number, amount: number, du: number, dv: number): void { + if (amount <= 0) return; + const len = Math.hypot(du, dv); + const nu = len > 1e-6 ? du / len : 0; + const nv = len > 1e-6 ? dv / len : 1; + const throwLen = Math.min(0.28, 0.06 + amount * 0.5); + const blobs = 3; + for (let i = 0; i < blobs; i++) { + const t = i / (blobs - 1); + const bu = u + nu * throwLen * t; + const bv = v + nv * throwLen * t; + const share = (amount * (1 - t * 0.55)) / blobs; + const r = 0.03 + amount * 0.04; + const total = this.field.brush(bu, bv, r, () => undefined); + if (total <= 0) continue; + this.field.brush(bu, bv, r, (idx, w) => { + this.field.data[idx] += (share * w) / total; + }); + } + this.version++; + } + + addSolid(u: number, v: number, kind: SolidKind): void { + this.solids.push({ u: clamp01(u), v: clamp01(v), kind }); + this.version++; + } + + /** + * One cloth step from (pu,pv) to (u,v). Picks up ~half of what it crosses, + * shoves the rest ahead of the leading edge — mass conserving until the + * cloth finally absorbs it. Collects solids it passes over. Returns mass + * picked up this step (the scene can wring the cloth on a threshold). + */ + wipe(pu: number, pv: number, u: number, v: number, radius = 0.07): number { + const du = u - pu; + const dv = v - pv; + const len = Math.hypot(du, dv); + if (len < 1e-5) return 0; + const nu = du / len; + const nv = dv / len; + let lifted = 0; + let pushed = 0; + this.field.brush(u, v, radius, (idx, w) => { + const take = this.field.data[idx] * Math.min(1, w * 0.9); + const absorb = take * 0.55; + lifted += absorb; + pushed += take - absorb; + this.field.data[idx] -= take; + }); + // The un-absorbed mass lands just past the leading edge: the smear. + if (pushed > 0) { + const fu = u + nu * radius * 1.35; + const fv = v + nv * radius * 1.35; + const total = this.field.brush(fu, fv, radius * 0.8, () => undefined); + if (total > 0) { + this.field.brush(fu, fv, radius * 0.8, (idx, w) => { + this.field.data[idx] += (pushed * w) / total; + }); + } + } + for (const s of this.solids) { + if (s.collected) continue; + const d = Math.hypot(s.u - u, s.v - v); + if (d < radius) { + s.collected = true; + } + } + this.clothLoad += lifted; + this.version++; + return lifted; + } + + /** The judge's numbers: total juice mass, how far it's spread, loose bits. */ + stats(): { mass: number; spreadFrac: number; solids: number } { + const d = this.field.data; + let mass = 0; + let dirty = 0; + for (let i = 0; i < d.length; i++) { + mass += d[i]; + if (d[i] > MESS_FLOOR) dirty++; + } + return { + mass, + spreadFrac: dirty / d.length, + solids: this.solids.filter((s) => !s.collected).length, + }; + } + + /** The live readout, in the judge's own thresholds. */ + benchWord(): 'clean' | 'smeared' | 'a crime scene' { + const s = this.stats(); + const badness = s.spreadFrac * 3 + s.solids * 0.04 + s.mass * 0.4; + return badness < 0.25 ? 'clean' : badness < 0.9 ? 'smeared' : 'a crime scene'; + } + + reset(): void { + this.field.clear(); + this.solids.length = 0; + this.clothLoad = 0; + this.version++; + } +} + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); diff --git a/src/style.css b/src/style.css index 076f7f8..d3b2350 100644 --- a/src/style.css +++ b/src/style.css @@ -325,6 +325,16 @@ canvas#c { padding: 4px 0; } +/* Station headers on prep-order scorecards. */ +.crit-group { + font-size: 9px; + letter-spacing: 0.22em; + opacity: 0.55; + margin: 8px 0 2px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + padding-bottom: 3px; +} + .crit-name { font-size: 10px; letter-spacing: 0.1em;