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 = { 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; }