toastsim/src/sim/cutlery.ts
monster eb74170011 M2: spreading — one control, three behaviours
Knife angle (wheel) is the only input, and spread/scrape/gouge all fall out of it.
Verified by driving real input through the real code paths:

  cold toast + fridge butter -> damage 0.015, spread 0.029   (tears, as designed)
  fresh warm toast           -> damage 0,     spread 0.117   (flows cleanly)
  burnt toast, knife on edge -> char 99.9% -> 82.7% -> 54.5%
                                evenness    0.029 -> 0.123 -> 0.226
  pale toast, knife on edge  -> gouges; steak knife gouges 3.2x harder

That evenness column is the mechanic: scraping rescues you from char and wrecks
uniformity doing it. The judge will have opinions.

The trap is calibrated, not hoped for. Pressure comes from steepness, but past
SCRAPE_ANGLE the knife stops spreading:

  fridge butter / cold toast   yield 0.72  needs angle 0.75  IMPOSSIBLE
  bench butter  / cold toast   yield 0.54  needs angle 0.57  (cliff at 0.62)
  fridge butter / fresh toast  yield 0.36  needs angle 0.38  fine
  soft butter   / fresh toast  yield 0.16  needs angle 0.12  dream mode

So cold toast + hard butter cannot be spread at any angle, and the way out isn't
technique — it's not dawdling. The pressure gauge draws both marks so you can
see the gold sitting past the red and understand why you're losing.

Emergent and kept: a steak knife's narrow blade concentrates pressure enough to
beat cold butter's yield. It's the right tool for cold butter and a menace
everywhere else.

- cutlery.ts: 9 hand-authored archetypes (silhouettes are gameplay — the drawer
  has to be fair) + compound box colliders for M4.
- dev.ts: harness that drives real gestures deterministically. Earns its keep.

Two bugs: cutlery meshes used mesh.rotation.x = -PI/2, which sends a profile
drawn toward +y to -z and one drawn toward -y to +z — the handle and blade were
laid out in opposite directions, overlapping, nowhere near the cursor. Now
rotated at the geometry level. And resize() computed aspect = 0/0 = NaN when the
container reports zero, which poisons the projection matrix so every raycast
silently misses — i.e. the entire mechanic stops with no error.

Metals need something to reflect: added a RoomEnvironment IBL and rebalanced the
direct rig, which was tuned before it existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:51:34 +10:00

354 lines
9.6 KiB
TypeScript

import * as THREE from 'three';
/**
* The cutlery cast. Deliberately procedural rather than generated: these
* silhouettes are gameplay — the drawer asks you to find "the dessert fork"
* among things that are almost dessert forks, and that's only fair if the
* differences are authored. It also keeps the tines thin without a mesher
* mangling them, and lets each piece carry its own physics colliders.
*/
export type ToolId =
| 'butter_knife'
| 'dinner_knife'
| 'steak_knife'
| 'spreader'
| 'dinner_fork'
| 'dessert_fork'
| 'teaspoon'
| 'dessert_spoon'
| 'soup_spoon';
export type ToolKind = 'knife' | 'fork' | 'spoon';
export interface Tool {
id: ToolId;
name: string;
kind: ToolKind;
/** Overall length in slice-units (1 unit ~ 11cm). */
length: number;
/** Width of the business end. */
headW: number;
headL: number;
/** Multiplies the knife's contact patch — a spreader is wide, a steak knife isn't. */
contactScale: number;
/** How well it moves spread at all. */
transferScale: number;
/** Multiplier on gouge risk. Serrated things are bad news. */
gougeProne: number;
/** 0..1 — how blotchy the deposit is. A spoon can't lay a flat film. */
blotch: number;
/** Multiplier on tearing when the spread won't yield. */
tearProne: number;
/** The right tool for spreading. */
ideal: boolean;
blurb: string;
}
export const TOOLS: Record<ToolId, Tool> = {
butter_knife: {
id: 'butter_knife',
name: 'Butter Knife',
kind: 'knife',
length: 1.75,
headW: 0.2,
headL: 0.72,
contactScale: 1,
transferScale: 1,
gougeProne: 1,
blotch: 0,
tearProne: 1,
ideal: true,
blurb: 'Round-tipped, wide, dull. The correct answer.',
},
dinner_knife: {
id: 'dinner_knife',
name: 'Dinner Knife',
kind: 'knife',
length: 2.0,
headW: 0.16,
headL: 0.85,
contactScale: 0.86,
transferScale: 0.95,
gougeProne: 1.35,
blotch: 0.05,
tearProne: 1.1,
ideal: false,
blurb: 'Longer, narrower, and it has opinions about the crumb.',
},
steak_knife: {
id: 'steak_knife',
name: 'Steak Knife',
kind: 'knife',
length: 1.95,
headW: 0.13,
headL: 0.88,
contactScale: 0.62,
transferScale: 0.8,
gougeProne: 3.2,
blotch: 0.12,
tearProne: 1.6,
ideal: false,
blurb: 'Serrated. Every stroke is a small act of violence.',
},
spreader: {
id: 'spreader',
name: 'Pâté Spreader',
kind: 'knife',
length: 1.4,
headW: 0.3,
headL: 0.5,
contactScale: 1.35,
transferScale: 1.15,
gougeProne: 0.55,
blotch: 0,
tearProne: 0.7,
ideal: true,
blurb: 'Stubby, wide, blameless. Somehow always at the back.',
},
dinner_fork: {
id: 'dinner_fork',
name: 'Dinner Fork',
kind: 'fork',
length: 1.85,
headW: 0.26,
headL: 0.42,
contactScale: 0.7,
transferScale: 0.55,
gougeProne: 2.4,
blotch: 0.75,
tearProne: 3.0,
ideal: false,
blurb: 'Four tines. Four furrows.',
},
dessert_fork: {
id: 'dessert_fork',
name: 'Dessert Fork',
kind: 'fork',
length: 1.5,
headW: 0.23,
headL: 0.34,
contactScale: 0.6,
transferScale: 0.5,
gougeProne: 2.2,
blotch: 0.78,
tearProne: 2.8,
ideal: false,
blurb: 'Like a dinner fork, but smaller. That is the entire difference.',
},
teaspoon: {
id: 'teaspoon',
name: 'Teaspoon',
kind: 'spoon',
length: 1.3,
headW: 0.24,
headL: 0.34,
contactScale: 0.75,
transferScale: 0.7,
gougeProne: 0.5,
blotch: 0.6,
tearProne: 1.2,
ideal: false,
blurb: 'You can, technically. It will show.',
},
dessert_spoon: {
id: 'dessert_spoon',
name: 'Dessert Spoon',
kind: 'spoon',
length: 1.65,
headW: 0.3,
headL: 0.44,
contactScale: 0.85,
transferScale: 0.75,
gougeProne: 0.45,
blotch: 0.55,
tearProne: 1.15,
ideal: false,
blurb: 'A teaspoon that has been to the gym.',
},
soup_spoon: {
id: 'soup_spoon',
name: 'Soup Spoon',
kind: 'spoon',
length: 1.6,
headW: 0.38,
headL: 0.4,
contactScale: 0.9,
transferScale: 0.7,
gougeProne: 0.4,
blotch: 0.62,
tearProne: 1.1,
ideal: false,
blurb: 'Round. Deep. Utterly wrong, but confidently so.',
},
};
export const TOOL_IDS = Object.keys(TOOLS) as ToolId[];
const STEEL = new THREE.MeshStandardMaterial({
color: 0xd2d7dd,
roughness: 0.24,
metalness: 0.95,
});
/**
* Extrude a profile drawn in shape-space (x = width, y = length) into a part
* lying in the XZ plane: shape +y becomes +z, and the extrusion thickness ends
* up centred on y=0.
*
* Done at the geometry level on purpose. Setting mesh.rotation.x = -PI/2 instead
* sends a profile drawn toward +y to -z and one drawn toward -y to +z — which
* silently lays the handle and the blade out in opposite directions, on top of
* each other, and the piece is nowhere near where the code says it is.
*/
function extrudeFlat(shape: THREE.Shape, depth: number, bevel: number): THREE.BufferGeometry {
const geo = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: bevel > 0,
bevelThickness: bevel,
bevelSize: bevel,
bevelSegments: 2,
curveSegments: 12,
});
geo.rotateX(Math.PI / 2); // shape +y -> +z, extrusion depth -> -y
geo.translate(0, depth / 2, 0);
return geo;
}
/**
* Build a piece of cutlery lying in the XZ plane, handle at -Z, head at +Z.
* Y is thickness.
*/
export function makeCutleryMesh(tool: Tool): THREE.Group {
const g = new THREE.Group();
const L = tool.length;
const handleL = L - tool.headL - 0.12;
// handle: a tapered, slightly domed bar
const handleShape = new THREE.Shape();
const hw0 = 0.052; // at the neck
const hw1 = 0.085; // at the butt
handleShape.moveTo(-hw0, 0);
handleShape.lineTo(-hw1 * 0.92, -handleL * 0.55);
handleShape.quadraticCurveTo(-hw1, -handleL, 0, -handleL);
handleShape.quadraticCurveTo(hw1, -handleL, hw1 * 0.92, -handleL * 0.55);
handleShape.lineTo(hw0, 0);
handleShape.closePath();
const handle = new THREE.Mesh(extrudeFlat(handleShape, 0.036, 0.014), STEEL);
handle.position.z = -0.02;
g.add(handle);
// neck
const neck = new THREE.Mesh(new THREE.BoxGeometry(0.055, 0.028, 0.16), STEEL);
neck.position.z = 0.06;
g.add(neck);
if (tool.kind === 'knife') g.add(makeBlade(tool));
else if (tool.kind === 'fork') g.add(makeForkHead(tool));
else g.add(makeSpoonBowl(tool));
for (const c of g.children) {
c.castShadow = true;
c.receiveShadow = true;
}
return g;
}
function makeBlade(tool: Tool): THREE.Mesh {
const w = tool.headW / 2;
const l = tool.headL;
const s = new THREE.Shape();
s.moveTo(-0.028, 0);
s.lineTo(-w * 0.8, l * 0.22);
s.lineTo(-w, l * 0.55);
// rounded tip for a butter knife, a point for the aggressive ones
if (tool.id === 'butter_knife' || tool.id === 'spreader') {
s.quadraticCurveTo(-w, l, 0, l);
s.quadraticCurveTo(w, l, w, l * 0.55);
} else {
s.lineTo(-w * 0.55, l * 0.93);
s.quadraticCurveTo(0, l * 1.02, w * 0.72, l * 0.86);
s.lineTo(w, l * 0.55);
}
s.lineTo(w * 0.8, l * 0.22);
s.lineTo(0.028, 0);
s.closePath();
const blade = new THREE.Mesh(extrudeFlat(s, 0.014, 0.006), STEEL);
blade.position.set(0, 0, 0.12);
return blade;
}
function makeForkHead(tool: Tool): THREE.Group {
const g = new THREE.Group();
const w = tool.headW / 2;
const l = tool.headL;
// the shoulder the tines grow out of
const base = new THREE.Shape();
base.moveTo(-0.03, 0);
base.lineTo(-w, l * 0.34);
base.lineTo(w, l * 0.34);
base.lineTo(0.03, 0);
base.closePath();
const shoulder = new THREE.Mesh(extrudeFlat(base, 0.016, 0), STEEL);
shoulder.position.set(0, 0, 0.12);
g.add(shoulder);
// four tines
const tineL = l * 0.66;
const tineW = (w * 2) / 7;
for (let i = 0; i < 4; i++) {
const x = (i - 1.5) * (w * 2) / 4;
const tine = new THREE.Mesh(new THREE.BoxGeometry(tineW, 0.014, tineL), STEEL);
tine.position.set(x, 0, 0.12 + l * 0.34 + tineL / 2);
g.add(tine);
const tip = new THREE.Mesh(new THREE.ConeGeometry(tineW * 0.5, 0.05, 6), STEEL);
tip.rotation.x = Math.PI / 2;
tip.position.set(x, 0, 0.12 + l * 0.34 + tineL + 0.02);
g.add(tip);
}
return g;
}
function makeSpoonBowl(tool: Tool): THREE.Mesh {
const geo = new THREE.SphereGeometry(0.5, 20, 14, 0, Math.PI * 2, 0, Math.PI * 0.52);
geo.scale(tool.headW * 0.5, 0.11, tool.headL * 0.6);
geo.rotateX(Math.PI); // open side up
const bowl = new THREE.Mesh(geo, STEEL);
bowl.position.set(0, 0.005, 0.12 + tool.headL * 0.42);
return bowl;
}
/**
* Compound collider primitives for the drawer, in the mesh's local space.
* Boxes only, and few of them: a trimesh of a fork is both slow and a stability
* nightmare when a dozen of them are tangled together.
*/
export interface ColliderPart {
half: [number, number, number];
pos: [number, number, number];
}
export function colliderParts(tool: Tool): ColliderPart[] {
const L = tool.length;
const handleL = L - tool.headL - 0.12;
const parts: ColliderPart[] = [
{ half: [0.075, 0.03, handleL / 2], pos: [0, 0, -0.02 - handleL / 2] },
{ half: [0.03, 0.016, 0.08], pos: [0, 0, 0.06] },
];
if (tool.kind === 'spoon') {
parts.push({
half: [tool.headW * 0.5, 0.055, tool.headL * 0.32],
pos: [0, 0, 0.12 + tool.headL * 0.42],
});
} else {
// one slab for a blade; for a fork this is the tine envelope, which is what
// actually matters — individual tines catching each other is a physics trap.
parts.push({
half: [tool.headW * 0.5, 0.012, tool.headL * 0.5],
pos: [0, 0, 0.12 + tool.headL * 0.5],
});
}
return parts;
}