M10: the bakery, verified — the wobble follows you into the toaster
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>
This commit is contained in:
parent
31f2eff28e
commit
e5be266ce6
@ -29,24 +29,38 @@ run_flux() {
|
||||
| awk '/image/{print $1}' | tail -1
|
||||
}
|
||||
|
||||
# gen_3d <name> <seed> <prompt>
|
||||
# gen_3d <name> <seed> <prompt> — each stage retries once (transient rsync/infra errors happen)
|
||||
gen_3d() {
|
||||
local name="$1" seed="$2" prompt="$3"
|
||||
if [ -f "$OUT_MODELS/$name.glb" ]; then echo " = $name.glb exists, skipping"; return; fi
|
||||
echo "→ [$name] flux..."
|
||||
local img cut
|
||||
img=$(run_flux "$seed" "$prompt, $STYLE, $SHOT")
|
||||
[ -z "$img" ] && { echo " ! $name: flux failed"; return 1; }
|
||||
if [ -z "$img" ]; then
|
||||
echo " ~ $name: flux failed, retrying once (seed+1)..."
|
||||
img=$(run_flux "$((seed+1))" "$prompt, $STYLE, $SHOT")
|
||||
fi
|
||||
[ -z "$img" ] && { echo " ! $name: flux failed twice"; return 1; }
|
||||
echo "→ [$name] cutout ($img)..."
|
||||
cut=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1)
|
||||
[ -z "$cut" ] && { echo " ! $name: bg_remove failed"; return 1; }
|
||||
if [ -z "$cut" ]; then
|
||||
echo " ~ $name: bg_remove failed, retrying once..."
|
||||
cut=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1)
|
||||
fi
|
||||
[ -z "$cut" ] && { echo " ! $name: bg_remove failed twice"; return 1; }
|
||||
echo "→ [$name] mesh ($cut) — ~5 min..."
|
||||
local tmp; tmp=$(mktemp -d)
|
||||
./mb run hunyuan3d_mlx --asset "$cut" -p octree_resolution=256 -p texture_size=1024 \
|
||||
-p max_num_view=6 --wait --download "$tmp" >/dev/null 2>&1
|
||||
local glb; glb=$(find "$tmp" -iname '*.glb' | head -1)
|
||||
if [ -n "$glb" ]; then mv "$glb" "$OUT_MODELS/$name.glb"; echo " ✓ $name.glb"; else echo " ! $name: no glb"; fi
|
||||
rm -rf "$tmp"
|
||||
local try glb
|
||||
for try in 1 2; do
|
||||
local tmp; tmp=$(mktemp -d)
|
||||
./mb run hunyuan3d_mlx --asset "$cut" -p octree_resolution=256 -p texture_size=1024 \
|
||||
-p max_num_view=6 --wait --download "$tmp" >/dev/null 2>&1
|
||||
glb=$(find "$tmp" -iname '*.glb' | head -1)
|
||||
if [ -n "$glb" ]; then mv "$glb" "$OUT_MODELS/$name.glb"; echo " ✓ $name.glb"; rm -rf "$tmp"; return 0; fi
|
||||
rm -rf "$tmp"
|
||||
[ "$try" = 1 ] && echo " ~ $name: no glb, retrying mesh once..."
|
||||
done
|
||||
echo " ! $name: no glb after retry"
|
||||
return 1
|
||||
}
|
||||
|
||||
# gen_2d <name> <seed> <prompt> [w] [h] [cutout]
|
||||
@ -92,20 +106,43 @@ do_2d() {
|
||||
}
|
||||
|
||||
# ---- M10: the bakery (loaves), plus bruschetta ingredients for the milestone after ----
|
||||
# (seeds bumped +1 and prompts reworded after the first batch died to transient rsync errors)
|
||||
do_bakery() {
|
||||
gen_3d sourdough_loaf 61 "a rustic sourdough boule loaf, round crusty artisan bread with flour-dusted scored top, unsliced"
|
||||
gen_3d batard_loaf 67 "a rustic batard bread loaf, oval crusty artisan bread, scored diagonal cuts on top, unsliced"
|
||||
gen_3d cutting_board 71 "a thick wooden cutting board with worn edge grain, rectangular with rounded corners"
|
||||
gen_3d tomato_vine 73 "three ripe red tomatoes on a green vine, glossy"
|
||||
gen_3d roast_tomatoes 79 "a small metal roasting tray of blistered roasted tomato halves, charred edges, juicy"
|
||||
gen_3d sourdough_loaf 62 "a round rustic sourdough boule of bread, thick crackled golden crust, flour dusted, scored cross on top, whole and unsliced"
|
||||
gen_3d batard_loaf 68 "an oval batard loaf of crusty artisan bread, golden brown crust with diagonal score marks, whole and unsliced"
|
||||
gen_3d cutting_board 72 "a thick rectangular wooden chopping board, rounded corners, warm honey wood with worn grain"
|
||||
gen_3d tomato_vine 74 "three plump ripe red tomatoes attached to one green vine stem, glossy skin"
|
||||
gen_3d roast_tomatoes 80 "a small metal roasting tray holding blistered oven-roasted tomato halves, charred edges, glistening"
|
||||
gen_3d cheese_hard 83 "a wedge of hard aged parmesan style cheese, pale gold, cracked granular texture"
|
||||
gen_3d cheese_soft 89 "a small round of soft white brie style cheese with one wedge cut out showing creamy interior"
|
||||
}
|
||||
|
||||
# ---- M12–M15: the prep bench cast (Build Brief 2 §8) ----
|
||||
do_prep() {
|
||||
gen_3d orange_whole 103 "a single whole orange with dimpled peel and a small green stem nub"
|
||||
gen_3d orange_half 107 "half an orange cut face up, showing juicy segments in a radial pattern and white pith ring"
|
||||
gen_3d juicer_pyramid 109 "an old fashioned glass citrus juicer reamer with a tall ribbed pyramid cone in the centre and a moat rim, pressed glass ribs clearly visible"
|
||||
gen_3d juicer_deluxe 113 "a sturdy pressed glass citrus juicer with a ribbed reaming cone, deep seed-catching moat with strainer notches, art deco look"
|
||||
gen_3d juicernaut 127 "a magnificent heavy chromed citrus juicer with a fluted ribbed cone, gleaming polished metal, trophy-like, over-engineered"
|
||||
gen_3d juice_glass 131 "a small plain drinking glass tumbler, empty, slightly tapered"
|
||||
gen_3d box_grater 137 "a stainless steel box grater with four faces and a rolled handle on top"
|
||||
gen_3d onion_brown 139 "a whole brown onion with papery golden skin and a wispy root"
|
||||
gen_3d garlic_bulb 149 "a whole garlic bulb, papery white skin with purple streaks"
|
||||
gen_3d basil_sprig 151 "a small sprig of fresh basil with several green leaves on a stem"
|
||||
gen_3d oil_bottle 157 "a tall glass olive oil bottle with a narrow spout, golden green oil visible inside"
|
||||
gen_3d oven_tray 163 "a plain rectangular metal oven baking tray with a rolled edge"
|
||||
}
|
||||
|
||||
do_prep2d() {
|
||||
gen_2d bruschetta_hero 230 "rustic bruschetta on toasted sourdough, roasted tomato pieces and basil on top, dramatic hero shot, claymation stop-motion, soft matte clay, warm rim light, plain dark background" 1024 768 cut
|
||||
gen_2d wonderslice_bag 233 "a cheerful retro plastic bread bag with bold cartoon lettering style packaging design, red and yellow, claymation prop look, plain light grey background, no readable text" 768 768 cut
|
||||
}
|
||||
|
||||
case "${1:-all}" in
|
||||
3d) do_3d ;;
|
||||
2d) do_2d ;;
|
||||
bakery) do_bakery ;;
|
||||
prep) do_prep; do_prep2d ;;
|
||||
*) do_3d; do_2d ;;
|
||||
esac
|
||||
echo "done."
|
||||
|
||||
110
src/dev.ts
110
src/dev.ts
@ -125,6 +125,14 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
load: r(kitchen.knife.load),
|
||||
angle: r(kitchen.knife.angle),
|
||||
mode: kitchen.lastFx?.mode ?? 'idle',
|
||||
cut: sl.cut
|
||||
? {
|
||||
thickness: r(sl.cut.thickness),
|
||||
wedge: r(sl.cut.wedge),
|
||||
squash: r(sl.cut.squash),
|
||||
knife: sl.cut.knife,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
|
||||
@ -134,6 +142,108 @@ export function installDevHarness(app: App, game: Game): void {
|
||||
app.camera.lookAt(new THREE.Vector3(...at));
|
||||
app.renderer.render(app.scene, app.camera);
|
||||
},
|
||||
|
||||
/** Click through the title screen. */
|
||||
start() {
|
||||
(document.querySelector('.title .btn') as HTMLButtonElement | null)?.click();
|
||||
app.resize();
|
||||
run(5);
|
||||
},
|
||||
|
||||
/** Jump to a day, through the real order table. */
|
||||
day(n: number) {
|
||||
game.setDay(n);
|
||||
run(5);
|
||||
},
|
||||
|
||||
grade: () => game.gradeNow(),
|
||||
|
||||
/**
|
||||
* Fish a specific tool out of the drawer the honest way: grab it, drag it
|
||||
* above the rim, and hold it there until the dwell commits. Grabs whatever
|
||||
* is on top of it out of the way first if it has to.
|
||||
*/
|
||||
pick(id: string, maxSeconds = 20): boolean {
|
||||
const drawer = game.drawerView;
|
||||
const d = drawer as unknown as {
|
||||
pieces: { id: string; body: { translation(): { x: number; y: number; z: number } } }[];
|
||||
grabbed: { id: string } | null;
|
||||
};
|
||||
const piece = d.pieces.find((p) => p.id === id);
|
||||
if (!piece) throw new Error(`no ${id} in this drawer`);
|
||||
let frames = Math.round(maxSeconds * 60);
|
||||
while (frames-- > 0 && drawer.root.visible) {
|
||||
const t = piece.body.translation();
|
||||
if (!d.grabbed) {
|
||||
point(t.x, t.y, t.z, false);
|
||||
run(1);
|
||||
point(t.x, t.y, t.z, true);
|
||||
run(1);
|
||||
frames -= 2;
|
||||
continue;
|
||||
}
|
||||
if (d.grabbed.id !== id) {
|
||||
// Grabbed a blocker lying on top — sling it toward a corner, drop it.
|
||||
const dir = t.x > 0 ? -1 : 1;
|
||||
for (let i = 0; i < 25 && d.grabbed; i++) {
|
||||
point(dir * 1.2, 1.4, t.z > 0 ? -0.7 : 0.7, true);
|
||||
run(1);
|
||||
}
|
||||
app.input.down = false;
|
||||
run(8);
|
||||
frames -= 35;
|
||||
continue;
|
||||
}
|
||||
// Holding the right piece: haul it up past the rim and keep it there.
|
||||
point(t.x * 0.7, Math.min(2.2, t.y + 0.4), t.z * 0.7, true);
|
||||
run(1);
|
||||
}
|
||||
app.input.down = false;
|
||||
run(2);
|
||||
return !drawer.root.visible;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
slice(opts: { thickness?: number; dy?: number; wobble?: number } = {}) {
|
||||
const { thickness = 0.31, dy = 0.05, wobble = 0 } = opts;
|
||||
const sl = game.slicerView as unknown as {
|
||||
cutting: { phase: string; thickness: number; progress: number; wedge: number; squash: number; strokes: number };
|
||||
endX: number;
|
||||
};
|
||||
run(2); // let the slicer take the camera before we project points
|
||||
const c = sl.cutting;
|
||||
const X = sl.endX - thickness;
|
||||
const Z = -0.2;
|
||||
point(X, 1.0, Z, false);
|
||||
run(2);
|
||||
point(X, 1.0, Z, true);
|
||||
run(1); // first bite locks thickness
|
||||
let y = 1.0;
|
||||
let dir = -1;
|
||||
let f = 0;
|
||||
while (c.phase === 'saw' && f < 4000) {
|
||||
y += dir * dy;
|
||||
// ~0.5 world units per sweep — the length of a deliberate human stroke.
|
||||
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(80); // the done beat, the fall, and the handoff to the kitchen
|
||||
const r = (n: number) => +n.toFixed(3);
|
||||
return {
|
||||
phase: c.phase,
|
||||
thickness: r(c.thickness),
|
||||
wedge: r(c.wedge),
|
||||
squash: r(c.squash),
|
||||
strokes: c.strokes,
|
||||
frames: f,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
(window as unknown as { __t: unknown }).__t = harness;
|
||||
|
||||
@ -117,6 +117,16 @@ export class Game {
|
||||
get drawerView(): DrawerView {
|
||||
return this.drawer;
|
||||
}
|
||||
get slicerView(): SlicerView {
|
||||
return this.slicer;
|
||||
}
|
||||
|
||||
/** Test hook: jump straight to a day, through the real order table. */
|
||||
setDay(day: number): void {
|
||||
this.save.day = day;
|
||||
writeSave(this.save);
|
||||
this.beginDay();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.drawer.init();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import * as THREE from 'three';
|
||||
import type { App, View } from '../core/app';
|
||||
import { audio } from '../core/audio';
|
||||
import { BREADS, type BreadId } from '../sim/bread';
|
||||
import { BREADS, type Bread, type BreadId } from '../sim/bread';
|
||||
import { makeCutleryMesh, TOOLS, type ToolId } from '../sim/cutlery';
|
||||
import {
|
||||
aim,
|
||||
@ -37,6 +37,11 @@ export class SlicerView implements View {
|
||||
private endX = 0; // world X of the loaf's cut end
|
||||
private knifeMesh!: THREE.Group;
|
||||
private cutting!: Slicing;
|
||||
private breadDef!: Bread;
|
||||
private seam!: THREE.Mesh;
|
||||
private fallSlice: THREE.Group | null = null;
|
||||
private loafTop = 1.1;
|
||||
private loafDepth = 1;
|
||||
|
||||
private ray = new THREE.Raycaster();
|
||||
private planePt = new THREE.Vector3();
|
||||
@ -128,12 +133,63 @@ export class SlicerView implements View {
|
||||
this.loafBody.receiveShadow = true;
|
||||
this.loaf.add(this.loafBody);
|
||||
this.endX = LOAF_LEN / 2;
|
||||
this.breadDef = bread;
|
||||
this.loafTop = BOARD_Y * 2 - minY + geo.boundingBox!.max.y;
|
||||
this.loafDepth = geo.boundingBox!.max.z - geo.boundingBox!.min.z;
|
||||
|
||||
// The cut line: a dark band that sinks with the blade. It protrudes a hair
|
||||
// past the crust on purpose — that's what makes it read from this camera.
|
||||
this.seam = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 1, 1),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color().setRGB(
|
||||
bread.crust[0] * 0.3,
|
||||
bread.crust[1] * 0.3,
|
||||
bread.crust[2] * 0.3,
|
||||
THREE.SRGBColorSpace,
|
||||
),
|
||||
roughness: 1,
|
||||
}),
|
||||
);
|
||||
this.seam.visible = false;
|
||||
this.loaf.add(this.seam);
|
||||
this.root.add(this.loaf);
|
||||
}
|
||||
|
||||
/** The cut slice, freed: it tips away from the loaf and lands crumb-side up. */
|
||||
private spawnFallingSlice(thickness: number): void {
|
||||
const geo = new THREE.ExtrudeGeometry(breadShape(this.breadDef), {
|
||||
depth: thickness,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 24,
|
||||
});
|
||||
geo.rotateY(Math.PI / 2);
|
||||
geo.center();
|
||||
geo.computeBoundingBox();
|
||||
const bb = geo.boundingBox!;
|
||||
const mk = (rgb: [number, number, number], rough: number) =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color().setRGB(...rgb, THREE.SRGBColorSpace),
|
||||
roughness: rough,
|
||||
});
|
||||
// Extrusion caps are material 0 — the cut faces, so they get the crumb.
|
||||
const mesh = new THREE.Mesh(geo, [mk(this.breadDef.crumb, 0.95), mk(this.breadDef.crust, 0.82)]);
|
||||
mesh.castShadow = true;
|
||||
mesh.position.set(-bb.max.x, -bb.min.y, 0);
|
||||
const pivot = new THREE.Group();
|
||||
pivot.position.set(this.endX, BOARD_Y * 2, 0);
|
||||
pivot.add(mesh);
|
||||
this.fallSlice = pivot;
|
||||
this.root.add(pivot);
|
||||
}
|
||||
|
||||
/** A fresh loaf and a fresh cut. */
|
||||
reset(breadId: BreadId, knife: ToolId, askText: string): void {
|
||||
this.cutting = newSlicing(knife);
|
||||
if (this.fallSlice) {
|
||||
this.root.remove(this.fallSlice);
|
||||
this.fallSlice = null;
|
||||
}
|
||||
this.buildLoaf(breadId);
|
||||
if (this.knifeMesh) this.root.remove(this.knifeMesh);
|
||||
this.knifeMesh = makeCutleryMesh(TOOLS[knife]);
|
||||
@ -189,11 +245,18 @@ export class SlicerView implements View {
|
||||
// the moment it went through
|
||||
audio.clunk(false);
|
||||
this.app.shake = 0.02;
|
||||
this.seam.visible = false;
|
||||
this.spawnFallingSlice(c.thickness);
|
||||
}
|
||||
}
|
||||
} else if (c.phase === 'done') {
|
||||
this.doneTimer += dt;
|
||||
if (this.doneTimer > 0.7) {
|
||||
// The slice tips off the loaf and lands flat, crumb up. Gravity-ish.
|
||||
if (this.fallSlice) {
|
||||
const t = Math.min(1, this.doneTimer / 0.55);
|
||||
this.fallSlice.rotation.z = -(t * t) * (Math.PI / 2);
|
||||
}
|
||||
if (this.doneTimer > 1.05) {
|
||||
const cut = finishCut(c);
|
||||
this.onSliced?.(cut);
|
||||
return;
|
||||
@ -208,6 +271,14 @@ export class SlicerView implements View {
|
||||
// Squash reads as the loaf flinching.
|
||||
const squashNow = c.phase === 'saw' ? c.squash : 0;
|
||||
this.loafBody.scale.y = 1 - squashNow * 0.12 - (inp.down && c.phase === 'saw' ? 0.02 : 0);
|
||||
|
||||
// The cut line deepens as the blade gets through.
|
||||
if (c.phase === 'saw' && c.progress > 0.01) {
|
||||
const depth = (this.loafTop - BOARD_Y * 2) * c.progress;
|
||||
this.seam.visible = true;
|
||||
this.seam.scale.set(0.022, Math.max(0.001, depth), this.loafDepth + 0.03);
|
||||
this.seam.position.set(this.endX - c.thickness, this.loafTop - depth / 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private positionKnife(c: Slicing): void {
|
||||
|
||||
@ -90,7 +90,9 @@ export class Slice {
|
||||
|
||||
const shape = breadShape(bread);
|
||||
buildMask(this.mask, shape, bread);
|
||||
this.heatBias = valueNoise2D(rng, FIELD_N, FIELD_N, 3);
|
||||
// Cheap bread doesn't just take heat unevenly — it blotches in small
|
||||
// patches. Good bread's bias is broad and gentle.
|
||||
this.heatBias = valueNoise2D(rng, FIELD_N, FIELD_N, Math.round(3 + (1 - this.quality) * 3));
|
||||
|
||||
this.texData = new Uint8Array(FIELD_N * FIELD_N * 4);
|
||||
this.tex = new THREE.DataTexture(this.texData, FIELD_N, FIELD_N, THREE.RGBAFormat);
|
||||
|
||||
@ -35,8 +35,9 @@ export const CUT_BANDS: Record<CutClass, [number, number]> = {
|
||||
doorstop: [0.26, 0.36],
|
||||
};
|
||||
|
||||
/** Progress a full stroke contributes, before knife efficiency. */
|
||||
const STROKE_GAIN = 0.16;
|
||||
/** Progress a full stroke contributes, before knife efficiency. Tuned so a
|
||||
* clean bread-knife doorstop is ~5 deliberate strokes, The Best Thing ~3. */
|
||||
const STROKE_GAIN = 0.1;
|
||||
/** Sideways wander → wedge, tempered by the knife's steadiness. */
|
||||
const WOBBLE_GAIN = 0.55;
|
||||
/** Sawing faster than the knife can bite leans on the loaf instead. */
|
||||
|
||||
@ -31,7 +31,7 @@ export function buildHeatMap(slice: Slice, rng: Rng): Float32Array {
|
||||
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.24 * (1.4 - slice.quality * 0.8);
|
||||
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user