commit 10adb268a0b1396796677ae6aed4653d642eb128 Author: monster Date: Thu Jul 16 21:16:18 2026 +1000 M0: scaffold — Vite+TS+Three+Rapier, slice sim core, asset pipeline The bread slice is the whole game's canvas, so it's built first and properly: - Field: a 128x128 scalar field with bilinear sample, a mass-conserving brush, self-levelling relax, and the masked statistics the judge will read. - Slice: extruded loaf silhouette (domed top), planar-projected UVs so the sim grid lines up with the bread, per-texel silhouette mask, and four fields (browning/dryness/spread/damage) packed into one RGBA8 texture per frame. - Custom shader: browning ramp with blotchy char, crumb/crust/inclusions, spread wetting + opacity + thickness relief, gloss, gouge exposure, and flat data views for the judge screen. - bread.ts / spreads.ts: the archetypes and the rheology the difficulty curve will live in. - scripts/gen-assets.sh: reproducible fixed-seed asset generation through MODELBEAST (flux_local -> bg_remove_local -> hunyuan3d_mlx). All on-device. Legibility work that mattered: butter rendered invisible at first. Fixes were a gamma-style wetting darkening (a multiply *brightens* pale crumb), a normal perturbation taken in UV units rather than raw texel deltas (~30x too small), and pulling the lighting back under 1.0 so ACES stops desaturating yellow to cream. Co-Authored-By: Claude Fable 5 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..716823b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "toastsim", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aed094d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.DS_Store +*.log +.vite/ diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md new file mode 100644 index 0000000..ce1e8a9 --- /dev/null +++ b/OPUS-BUILD-BRIEF.md @@ -0,0 +1,144 @@ +# TOASTSIM — Build Brief + +**For: Claude Opus 4.8, executing in Claude Code on this Mac (M3 Ultra, cwd `/Users/m3ultra/Documents/toastsim`).** +**Approved by the owner. Build the whole thing; commit and push per milestone. Asset generation is free and pre-authorized — see §3.** + +--- + +## 1. What we're building + +**TOASTSIM** — a browser game about making toast, where the *inputs are analog* and the *output is judged*. Two connected activities: + +1. **The Toast Round** — toast bread (timing + power), spread it (pressure/angle physics per spread type), scrape burnt bits (risk/reward), get scored by a deadpan judge. +2. **The Cutlery Drawer** — a 3D physics minigame: a tangled drawer of *similar-but-not-identical* metal cutlery; fish out the specific requested item against a timer, **while your toast cools** (cold toast = hard butter = harder spread round). + +Tone: dry, characterful, slightly absurd. PowerWash-Simulator-style satisfaction with a scoring screen that makes you go "one more toast." + +**Arcade loop:** a customer order appears ("dark toast, thin vegemite, NO burnt bits") → drawer → toast → spread/scrape → judgment → next order, escalating. + +## 2. Stack & repo + +- **Vite + TypeScript + Three.js** for everything (both scenes 3D — the toast slice is a real GLB you paint on). **@dimforge/rapier3d-compat** for drawer physics. No React needed; DOM overlay for UI is fine. Target 60fps in Chrome. +- Repo: the folder is empty. `git init`, build in it, remote is `https://gitea.partly.party/monster/toastsim.git` (exists, empty). Push `main` after each milestone with a clear message. +- Create `.claude/launch.json` with the Vite dev server so the app is previewable via the Browser pane. Verify visually with screenshots as you go — don't trust "it compiles." +- Generated assets go in `public/assets/` (committed — they're small GLBs/PNGs and reproducibility matters less than a working repo). + +## 3. Asset factory — MODELBEAST (free, local, pre-authorized) + +This Mac runs MODELBEAST, a job server wrapping local ML tools. **You are explicitly allowed to use it. Everything below is $0 (on-device).** Full reference: `~/Documents/MODELBEAST/AGENTS.md` — read it before your first job. + +### Auth & basics +```bash +cd ~/Documents/MODELBEAST +source data/agent.env # sets MB_HOST + MB_TOKEN (guest: local ops only, free by design) +./mb ops -v # live operator catalog — trust this over docs +``` +If the server is down (`curl http://localhost:8777/api/health` fails): `~/Documents/MODELBEAST/scripts/serve.sh`. + +Guest tokens **cannot** run paid cloud ops (server-enforced 403) — never try to work around that; the local chain below is the intended path. + +### The two pipelines you'll use + +**Prompt → 2D image** (UI art, judge portraits, backdrops, labels, title screen): +```bash +./mb run flux_local -p prompt="..." -p model=flux2-klein-4b -p steps=4 -p seed=7 --wait --download out/ +``` +Fixed seeds = reproducible. 1024×1024 default; set `width`/`height` for backdrops. + +**Prompt → 3D GLB** (bread, toaster, cutlery, jars, plate): +```bash +# 1. generate a clean "product shot" image of the object +./mb run flux_local -p prompt=", single object centered on plain light grey background, soft studio lighting, 3/4 view, stylized chunky game asset" -p seed= --wait +# 2. cut it out (biggest quality lever for image→3D) +./mb run bg_remove_local --asset --wait +# 3. mesh it (GLB + PBR texture, ~4-5 min, no login needed) +./mb run hunyuan3d_mlx --asset -p octree_resolution=256 -p texture_size=1024 --wait --download public/assets/models/ +``` +`blender_convert` exists if you need format conversion. `mflux_image_edit` for prompt-based image fixes. + +### Job etiquette (hard rules) +- **GPU jobs run one at a time** (serial lane). Queue asset jobs early, then *keep writing code while they run* — check with `./mb jobs` / `./mb log -f`. Never spam retries on `queued`. +- First run of an operator may download weights (10–30 min). Be patient; don't cancel. +- Don't delete assets you didn't create. `./mb rm-asset` your own intermediates when done. + +### Art direction & asset manifest + +Pick ONE style token and use it in every prompt for coherence. Recommended: **"chunky stylized claymation kitchen, soft matte plastic look, warm colors"** — chunky geometry hides image→3D artifacts, reads well at game scale, and gives cutlery thicker (physics-friendlier) forms. + +Generate (3D via the pipeline above, one flux seed noted per asset so it's re-runnable): + +| Asset | Notes | +|---|---| +| Bread slices ×4: white, thick-cut sourdough, multigrain, raisin | These are the paint canvases — need a clear flat top face. If Hunyuan's top-face UVs are unusable for painting, **fallback: procedural slice** (rounded-box extrusion in Three.js) with a flux-generated bread-crust texture — the paint tech matters more than the mesh source. | +| Toaster (2-slot, retro) | Hero prop of the toast scene | +| Cutlery: butter knife, dinner knife, steak knife, spreader; dinner fork, dessert fork; teaspoon, dessert spoon, soup spoon | The "similar but not identical" cast. If thin tines/blades come out mangled from image→3D, **model cutlery procedurally** (extruded 2D profiles) — silhouettes must be hand-controlled anyway for fair gameplay. Decide after one test generation. | +| Butter dish, peanut-butter jar, vegemite jar (fictional brand: "MITEY") | Bench props / selection UI | +| Plate, kitchen bench surface | Bench = one big flux texture on a plane is fine | +| Judge portraits ×5 expressions (neutral, intrigued, impressed, disappointed, horrified) | 2D, flux + bg_remove. Same character every time: describe them exactly, fix the seed, vary only the expression phrase. A stern older health-inspector type with a clipboard works. | +| Title art, order-ticket paper texture, UI icons | 2D | + +**Normalize every GLB on load in code** (bounding-box → unit scale, recenter pivot) — don't hand-fix meshes. + +## 4. Game design spec + +### 4.1 Toasting +- Browning is a **2D scalar field** (~64×64) per slice face, advanced each tick by heat input; render it by compositing into the slice's diffuse texture (white → golden → brown → black, with char speckle past 0.85). +- Heat input = toaster power dial (1–10) × coil proximity map (hotspots near coil lines, cooler edges, slight random per-run bias so no two runs identical). +- Per-bread params: `thickness` (thermal lag), `moisture` (delays browning then accelerates — sourdough), `sugar` (raisin browns fast and burns faster). +- Player controls: power dial + lever down; **no timer readout** — you judge by smell cues (text wisps: "smells warm… toasty… SMOKY") and can pop early anytime. Popping early and re-toasting is allowed but re-toasting browns unevenly (field already has gradient). + +### 4.2 Spreading & scraping — the star mechanic +- Knife follows the mouse over the slice's top face (raycast → UV → paint into spread field, a second 2D field with `amount` per texel). +- **Scroll wheel = knife angle.** Flat (low angle) = wide contact, gentle pressure, spreads. Steep = narrow contact, high pressure: scrapes spread *off* — or drags burnt char off (reduces browning field where >0.85, spawns crumb particles). Too steep + too fast on clean bread = **gouge** (permanent damage decal, integrity penalty). One unified control, three behaviors. +- Dip into the butter/jar to load the knife (visible blob on blade, depletes as you spread). +- **Spread rheology** per type: `{ yieldPressure, viscosity, pickupAmount, tempSoftening }` + - **Butter**: high yieldPressure when cold — below it the butter doesn't spread, it *tears the bread* (drags the surface, integrity damage). `effectiveHardness = f(butterTemp, toastWarmth)`; toast warmth decays in real time (drawer dawdling matters). Soft butter = dream mode. + - **Peanut butter**: high viscosity, big pickup — goes on thick, hard to get even, drags. + - **Vegemite**: low viscosity, tiny correct dose — the skill is spreading then **scraping most of it back off**. Judge wants a translucent film; opaque = massive penalty ("This is a hate crime"). +- Wrong tool from the drawer applies modifiers: spoon = blotchy coverage, fork = tears, steak knife = gouge-prone. + +### 4.3 The drawer +- Rapier scene: drawer box, 10–14 cutlery pieces spawned tangled (drop-settle at load, cached). Order card shows a **silhouette** of the required item. +- Mouse grabs via spring joint (raycast to nearest piece, joint to grab point); pieces clatter and snag realistically. Lift the right item fully clear of the drawer rim to win. +- Timer = your toast cooling. Grabbing the *wrong* item and committing (button) lets you proceed with the wrong-tool modifier — sometimes the smart play. +- **Colliders: hand-authored compound primitives** (capsules/boxes) per archetype, NOT trimesh — stability and speed. Visual mesh can be the generated GLB. +- Start with 8 pieces; only raise the count once tangling feels good. +- Metal-on-metal clatter sounds are non-negotiable for feel (see §4.5). + +### 4.4 Judging +Score 0–10, itemized so the player sees exactly why: +| Criterion | Measures | +|---|---| +| Browning match | mean browning vs. order target | +| Evenness | browning field variance | +| Coverage | % of face with spread in the target thickness band | +| Uniformity | spread field variance | +| Char | texels >0.85 remaining (scraping fixed it? no penalty) | +| Integrity | gouges, tears, over-scrape | +| Order compliance | right spread, right amount class (thin/normal/thick) | +| Time bonus | small — never dominant | + +Judge delivers 1–2 dry lines keyed to the worst and best criterion (write a bank of ~40; e.g. "The coverage is admirable. The crater is not."). Show the toast rotating on a pedestal with a heatmap toggle. Grade stamp (S/A/B/C/F). **This screen is the game — make it excellent.** + +### 4.5 Sound & feel +- Web Audio, synthesized where possible: toaster tick, lever clunk, pop (with slice launch physics!), knife scrape (pitch ∝ pressure), butter-tear rip, cutlery clatter (Rapier collision events → filtered noise bursts, velocity-scaled), judge stamp thunk. +- Screen-shake on pop, crumb particles on scrape, steam wisps off warm toast. + +## 5. Milestones (commit + push each; verify in browser before moving on) + +- **M0 — Scaffold**: Vite+TS+Three+Rapier boots, launch.json, placeholder cube scene, pushed. *Also: fire off the first MODELBEAST asset jobs now so weights download while you build M1.* +- **M1 — Toasting**: procedural slice + browning field + toaster controls + pop. Playable: make toast of any darkness. +- **M2 — Spreading**: knife, angle control, butter + vegemite + PB rheology, scraping, gouges. This milestone is where the fun lives — iterate until dragging cold butter genuinely feels bad in a funny way. +- **M3 — Judging**: full rubric, judge lines, grade screen, "next order" loop with 5 handwritten orders. +- **M4 — Drawer**: physics drawer with 8 pieces, silhouette orders, spring grab, toast-cooling link, wrong-tool modifiers. +- **M5 — Content & escalation**: 4 breads, order generator with difficulty curve (day 1: butter on white → day 7: thin vegemite on warm crumpet-thick sourdough with a steak knife because the drawer's a mess), persistent day/score. +- **M6 — Art & polish pass**: swap placeholders for generated assets, judge portraits, title screen, sound polish, perf check (60fps with drawer active). + +At each milestone: run it, screenshot it, check the console is clean, then commit. If a milestone's feel is wrong, fixing feel beats advancing. + +## 6. Judgment calls you're pre-authorized to make + +- Procedural vs. generated meshes per asset (test generation first, choose per §3 fallbacks). +- Any tuning of the rheology/browning constants — the spec's numbers are starting points. +- Cutting scope inside a milestone to protect feel (e.g., 2 spreads that feel great > 3 that don't). +- Do **not** add paid/cloud dependencies, accounts, or external services. Do not touch anything in `~/Documents/MODELBEAST` except via the `mb` CLI as documented. diff --git a/index.html b/index.html new file mode 100644 index 0000000..1059bf7 --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + + TOASTSIM + + +
+ +
+
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..165f1b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1353 @@ +{ + "name": "toastsim", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toastsim", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@dimforge/rapier3d-compat": "^0.19.3", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/three": "^0.185.1", + "typescript": "^7.0.2", + "vite": "^8.1.5" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.19.3.tgz", + "integrity": "sha512-mMVdSj1PRTT108s9Swbu2GQOmHbn8kbJANRV5xfczL3s0T4vkgZAuoMRgvBzQcHanpKusbC0ZJj6z3mC3aj3vg==", + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.1.tgz", + "integrity": "sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/three/node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ce25f65 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "toastsim", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dimforge/rapier3d-compat": "^0.19.3", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/three": "^0.185.1", + "typescript": "^7.0.2", + "vite": "^8.1.5" + } +} diff --git a/public/assets/models/butter_dish.glb b/public/assets/models/butter_dish.glb new file mode 100644 index 0000000..4e3b3b0 Binary files /dev/null and b/public/assets/models/butter_dish.glb differ diff --git a/public/assets/models/toaster.glb b/public/assets/models/toaster.glb new file mode 100644 index 0000000..fb366ab Binary files /dev/null and b/public/assets/models/toaster.glb differ diff --git a/scripts/gen-assets.sh b/scripts/gen-assets.sh new file mode 100755 index 0000000..43bb317 --- /dev/null +++ b/scripts/gen-assets.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# TOASTSIM asset generation — 100% local, 100% free, via MODELBEAST on the M3 Ultra. +# +# ./scripts/gen-assets.sh 3d # product shot -> cutout -> GLB (slow: ~5 min each, gpu lane is serial) +# ./scripts/gen-assets.sh 2d # flux images (judge portraits, backdrops, ui) +# ./scripts/gen-assets.sh all +# +# Seeds are fixed so every asset is re-generatable. Style token is shared for coherence. +set -uo pipefail + +MB_DIR="$HOME/Documents/MODELBEAST" +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_MODELS="$REPO/public/assets/models" +OUT_IMG="$REPO/public/assets/img" +mkdir -p "$OUT_MODELS" "$OUT_IMG" + +cd "$MB_DIR" || exit 1 +# shellcheck disable=SC1091 +source data/agent.env + +STYLE="chunky stylized claymation kitchen prop, soft matte plastic look, warm colors" +SHOT="single object centered on plain light grey background, soft studio lighting, 3/4 view, chunky stylized game asset" + +# run_flux [w] [h] -> asset id on stdout +run_flux() { + local seed="$1" prompt="$2" w="${3:-1024}" h="${4:-1024}" + ./mb run flux_local -p prompt="$prompt" -p model=flux2-klein-4b -p steps=4 \ + -p seed="$seed" -p width="$w" -p height="$h" --wait 2>/dev/null \ + | awk '/image/{print $1}' | tail -1 +} + +# gen_3d +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; } + 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; } + 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" +} + +# gen_2d [w] [h] [cutout] +gen_2d() { + local name="$1" seed="$2" prompt="$3" w="${4:-1024}" h="${5:-1024}" cutout="${6:-no}" + if [ -f "$OUT_IMG/$name.png" ]; then echo " = $name.png exists, skipping"; return; fi + echo "→ [$name] flux..." + local img; img=$(run_flux "$seed" "$prompt" "$w" "$h") + [ -z "$img" ] && { echo " ! $name: flux failed"; return 1; } + if [ "$cutout" = "cut" ]; then + echo "→ [$name] cutout..." + local c; c=$(./mb run bg_remove_local --asset "$img" -p resolution=2048 --wait 2>/dev/null | awk '/image/{print $1}' | tail -1) + [ -n "$c" ] && img="$c" + fi + local tmp; tmp=$(mktemp -d) + ./mb get "$img" -o "$tmp" >/dev/null 2>&1 + local f; f=$(find "$tmp" -iname '*.png' -o -iname '*.jpg' | head -1) + [ -n "$f" ] && { mv "$f" "$OUT_IMG/$name.png"; echo " ✓ $name.png"; } + rm -rf "$tmp" +} + +# ---- the judge: same person every time, seed fixed, only the expression phrase varies ---- +JUDGE="portrait of a stern elderly australian food inspector, deep wrinkles, bushy grey eyebrows, thin wire glasses, grey moustache, wearing a brown cardigan over a shirt and tie, holding a clipboard, plain light grey background, head and shoulders, claymation stop-motion character, soft matte clay texture, warm studio light" + +do_3d() { + gen_3d toaster 11 "a retro two-slot pop-up toaster, cream and chrome, with a browning dial" + gen_3d butter_dish 23 "a ceramic butter dish with a thick block of yellow butter on it, lid off" + gen_3d pb_jar 31 "an open glass jar of peanut butter, chunky brown spread visible, cream label" + gen_3d mitey_jar 37 "a small squat glass jar of dark brown yeast extract spread with a bright red and yellow label reading MITEY, lid off" + gen_3d plate 43 "a simple round white ceramic side plate, empty, seen at an angle" +} + +do_2d() { + gen_2d judge_neutral 101 "$JUDGE, neutral unimpressed expression" 768 768 cut + gen_2d judge_intrigued 101 "$JUDGE, one eyebrow raised, mildly intrigued expression" 768 768 cut + gen_2d judge_impressed 101 "$JUDGE, small approving smile, impressed expression" 768 768 cut + gen_2d judge_disappointed 101 "$JUDGE, deeply disappointed frowning expression, looking down at clipboard" 768 768 cut + gen_2d judge_horrified 101 "$JUDGE, horrified appalled expression, eyes wide, recoiling" 768 768 cut + gen_2d bench_wood 202 "seamless top-down texture of a warm honey-toned wooden kitchen benchtop, subtle grain and knife marks, claymation soft matte look, flat even lighting, no objects" 1024 1024 + gen_2d kitchen_backdrop 205 "blurry cosy 1970s australian kitchen interior background, warm cream tiles, soft bokeh, claymation set, no people, no text" 1280 768 + gen_2d ticket_paper 210 "seamless texture of a plain cream paper order docket, faint blue lines, slightly crumpled, flat even lighting, no text" 512 512 + gen_2d title_art 220 "a single slice of golden toast with a pat of butter melting on it, dramatic hero shot, claymation stop-motion, soft matte clay, warm rim light, plain dark background" 1024 768 cut +} + +case "${1:-all}" in + 3d) do_3d ;; + 2d) do_2d ;; + *) do_3d; do_2d ;; +esac +echo "done." diff --git a/src/core/app.ts b/src/core/app.ts new file mode 100644 index 0000000..948e293 --- /dev/null +++ b/src/core/app.ts @@ -0,0 +1,190 @@ +import * as THREE from 'three'; + +/** Shared key light direction — the slice shader rolls its own lighting, so it + * has to be told the same thing the real lights are doing. */ +export const LIGHT_DIR = new THREE.Vector3(0.5, 0.9, 0.42).normalize(); + +export interface View { + readonly root: THREE.Object3D; + enter(): void; + exit(): void; + update(dt: number): void; + resize?(w: number, h: number): void; + dispose?(): void; +} + +export class Input { + /** Normalised device coords (-1..1). */ + readonly ndc = new THREE.Vector2(); + /** Pixel coords. */ + readonly px = new THREE.Vector2(); + /** Pixel delta since last frame. */ + readonly delta = new THREE.Vector2(); + down = false; + rightDown = false; + /** Accumulated wheel since last frame (normalised-ish). */ + wheel = 0; + private prev = new THREE.Vector2(); + private first = true; + private keys = new Set(); + private pressed = new Set(); + + constructor(el: HTMLElement) { + el.addEventListener('pointermove', (e) => { + const r = el.getBoundingClientRect(); + this.px.set(e.clientX - r.left, e.clientY - r.top); + this.ndc.set((this.px.x / r.width) * 2 - 1, -(this.px.y / r.height) * 2 + 1); + if (this.first) { + this.prev.copy(this.px); + this.first = false; + } + }); + el.addEventListener('pointerdown', (e) => { + if (e.button === 0) this.down = true; + if (e.button === 2) this.rightDown = true; + }); + window.addEventListener('pointerup', (e) => { + if (e.button === 0) this.down = false; + if (e.button === 2) this.rightDown = false; + }); + el.addEventListener('contextmenu', (e) => e.preventDefault()); + el.addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + this.wheel += Math.sign(e.deltaY) * Math.min(1, Math.abs(e.deltaY) / 50); + }, + { passive: false }, + ); + window.addEventListener('keydown', (e) => { + if (!this.keys.has(e.code)) this.pressed.add(e.code); + this.keys.add(e.code); + }); + window.addEventListener('keyup', (e) => this.keys.delete(e.code)); + window.addEventListener('blur', () => { + this.keys.clear(); + this.down = false; + this.rightDown = false; + }); + } + + held(code: string): boolean { + return this.keys.has(code); + } + justPressed(code: string): boolean { + return this.pressed.has(code); + } + + endFrame(): void { + this.delta.subVectors(this.px, this.prev); + this.prev.copy(this.px); + this.wheel = 0; + this.pressed.clear(); + } +} + +export class App { + readonly renderer: THREE.WebGLRenderer; + readonly scene = new THREE.Scene(); + readonly camera: THREE.PerspectiveCamera; + readonly input: Input; + readonly timer = new THREE.Timer(); + /** Additive screen shake, applied after the camera is positioned. */ + shake = 0; + + private view: View | null = null; + private tickers: ((dt: number) => void)[] = []; + + constructor(canvas: HTMLCanvasElement) { + this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }); + this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFShadowMap; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.05; + this.camera = new THREE.PerspectiveCamera(42, 1, 0.05, 100); + this.input = new Input(canvas); + // Pauses the clock while the tab is hidden, so you don't come back to charcoal. + this.timer.connect(document); + this.scene.fog = new THREE.Fog(0x1b1512, 8, 22); + window.addEventListener('resize', () => this.resize()); + this.resize(); + } + + onTick(fn: (dt: number) => void): void { + this.tickers.push(fn); + } + + setView(v: View | null): void { + if (this.view === v) return; + if (this.view) { + this.view.exit(); + this.view.root.visible = false; + } + this.view = v; + if (v) { + v.root.visible = true; + v.enter(); + const s = this.renderer.getSize(new THREE.Vector2()); + v.resize?.(s.x, s.y); + } + } + + get currentView(): View | null { + return this.view; + } + + resize(): void { + const w = window.innerWidth; + const h = window.innerHeight; + this.renderer.setSize(w, h, false); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.view?.resize?.(w, h); + } + + start(): void { + const loop = (ts?: number) => { + requestAnimationFrame(loop); + this.timer.update(ts); + // Clamp so a single frame hitch can't teleport the sim forward. + const dt = Math.min(this.timer.getDelta(), 1 / 20); + for (const t of this.tickers) t(dt); + this.view?.update(dt); + if (this.shake > 0.0001) { + const s = this.shake; + this.camera.position.x += (Math.random() - 0.5) * s; + this.camera.position.y += (Math.random() - 0.5) * s; + this.camera.rotateZ((Math.random() - 0.5) * s * 0.35); + this.shake *= Math.pow(0.0015, dt); + if (this.shake < 0.0005) this.shake = 0; + } + this.renderer.render(this.scene, this.camera); + this.input.endFrame(); + }; + loop(); + } +} + +/** Standard kitchen lighting rig, shared by every view. */ +export function makeLights(scene: THREE.Scene): THREE.DirectionalLight { + const key = new THREE.DirectionalLight(0xfff2dd, 2.6); + key.position.copy(LIGHT_DIR).multiplyScalar(6); + key.castShadow = true; + key.shadow.mapSize.set(2048, 2048); + key.shadow.camera.near = 0.5; + key.shadow.camera.far = 18; + const d = 3.2; + key.shadow.camera.left = -d; + key.shadow.camera.right = d; + key.shadow.camera.top = d; + key.shadow.camera.bottom = -d; + key.shadow.bias = -0.0016; + key.shadow.normalBias = 0.02; + scene.add(key); + scene.add(new THREE.HemisphereLight(0x9fb4d6, 0x3a2a20, 0.75)); + const fill = new THREE.DirectionalLight(0xbdd6ff, 0.45); + fill.position.set(-3, 2.2, -2.5); + scene.add(fill); + return key; +} diff --git a/src/core/field.ts b/src/core/field.ts new file mode 100644 index 0000000..891e986 --- /dev/null +++ b/src/core/field.ts @@ -0,0 +1,130 @@ +/** + * A square scalar field over the slice's UV space. Everything the player does to + * the toast — browning, spread, damage — is one of these, and everything the judge + * measures is a statistic over one of these. + */ +export class Field { + readonly n: number; + readonly data: Float32Array; + + constructor(n: number, fill = 0) { + this.n = n; + this.data = new Float32Array(n * n); + if (fill !== 0) this.data.fill(fill); + } + + idx(x: number, y: number): number { + return y * this.n + x; + } + + /** Bilinear sample in UV space (0..1). */ + sample(u: number, v: number): number { + const n = this.n; + const fx = Math.min(Math.max(u * (n - 1), 0), n - 1); + const fy = Math.min(Math.max(v * (n - 1), 0), n - 1); + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const x1 = Math.min(x0 + 1, n - 1); + const y1 = Math.min(y0 + 1, n - 1); + const tx = fx - x0; + const ty = fy - y0; + const d = this.data; + const a = d[y0 * n + x0] * (1 - tx) + d[y0 * n + x1] * tx; + const b = d[y1 * n + x0] * (1 - tx) + d[y1 * n + x1] * tx; + return a * (1 - ty) + b * ty; + } + + /** + * Apply `fn` to every texel within `radius` (UV units) of (u,v), weighted by a + * smooth falloff. Returns the total weight touched — callers use it to conserve + * mass (a knife can only deposit what it's carrying). + */ + brush(u: number, v: number, radius: number, fn: (i: number, w: number) => void): number { + const n = this.n; + const cx = u * (n - 1); + const cy = v * (n - 1); + const r = radius * (n - 1); + const r2 = r * r; + const x0 = Math.max(0, Math.floor(cx - r)); + const x1 = Math.min(n - 1, Math.ceil(cx + r)); + const y0 = Math.max(0, Math.floor(cy - r)); + const y1 = Math.min(n - 1, Math.ceil(cy + r)); + let total = 0; + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const dx = x - cx; + const dy = y - cy; + const d2 = dx * dx + dy * dy; + if (d2 > r2) continue; + const t = 1 - Math.sqrt(d2) / r; + const w = t * t * (3 - 2 * t); // smoothstep falloff + fn(y * n + x, w); + total += w; + } + } + return total; + } + + /** Blur toward the neighbour average — how a spread self-levels. */ + relax(amount: number, mask?: Field): void { + if (amount <= 0) return; + const n = this.n; + const d = this.data; + const src = d.slice(); + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const i = y * n + x; + if (mask && mask.data[i] < 0.5) continue; + const xm = x > 0 ? x - 1 : x; + const xp = x < n - 1 ? x + 1 : x; + const ym = y > 0 ? y - 1 : y; + const yp = y < n - 1 ? y + 1 : y; + const avg = + (src[y * n + xm] + src[y * n + xp] + src[ym * n + x] + src[yp * n + x]) * 0.25; + d[i] = src[i] + (avg - src[i]) * amount; + } + } + } + + /** mean / stdev / max over the masked region. */ + stats(mask: Field): { mean: number; stdev: number; max: number; count: number } { + const d = this.data; + const m = mask.data; + let sum = 0; + let count = 0; + let max = 0; + for (let i = 0; i < d.length; i++) { + if (m[i] < 0.5) continue; + sum += d[i]; + if (d[i] > max) max = d[i]; + count++; + } + if (count === 0) return { mean: 0, stdev: 0, max: 0, count: 0 }; + const mean = sum / count; + let acc = 0; + for (let i = 0; i < d.length; i++) { + if (m[i] < 0.5) continue; + const dv = d[i] - mean; + acc += dv * dv; + } + return { mean, stdev: Math.sqrt(acc / count), max, count }; + } + + /** Fraction of the masked region for which `pred` holds. */ + fraction(mask: Field, pred: (v: number) => boolean): number { + const d = this.data; + const m = mask.data; + let hit = 0; + let count = 0; + for (let i = 0; i < d.length; i++) { + if (m[i] < 0.5) continue; + if (pred(d[i])) hit++; + count++; + } + return count === 0 ? 0 : hit / count; + } + + clear(v = 0): void { + this.data.fill(v); + } +} diff --git a/src/core/rng.ts b/src/core/rng.ts new file mode 100644 index 0000000..a983b40 --- /dev/null +++ b/src/core/rng.ts @@ -0,0 +1,58 @@ +/** Small deterministic RNG (mulberry32) — seeded so a run can be replayed. */ +export class Rng { + private s: number; + constructor(seed = 1) { + this.s = seed >>> 0; + } + next(): number { + this.s = (this.s + 0x6d2b79f5) >>> 0; + let t = this.s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + range(a: number, b: number): number { + return a + this.next() * (b - a); + } + int(a: number, b: number): number { + return Math.floor(this.range(a, b + 1)); + } + pick(arr: readonly T[]): T { + return arr[Math.floor(this.next() * arr.length)]; + } + chance(p: number): boolean { + return this.next() < p; + } +} + +/** Cheap 2D value noise, used to give each toasting run its own character. */ +export function valueNoise2D(rng: Rng, w: number, h: number, octaves = 3): Float32Array { + const out = new Float32Array(w * h); + let amp = 1; + let total = 0; + for (let o = 0; o < octaves; o++) { + const cells = 2 << o; + const grid = new Float32Array((cells + 1) * (cells + 1)); + for (let i = 0; i < grid.length; i++) grid[i] = rng.next(); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const fx = (x / (w - 1)) * cells; + const fy = (y / (h - 1)) * cells; + const x0 = Math.floor(fx); + const y0 = Math.floor(fy); + const tx = fx - x0; + const ty = fy - y0; + const sx = tx * tx * (3 - 2 * tx); + const sy = ty * ty * (3 - 2 * ty); + const g = (gx: number, gy: number) => grid[gy * (cells + 1) + gx]; + const a = g(x0, y0) * (1 - sx) + g(x0 + 1, y0) * sx; + const b = g(x0, y0 + 1) * (1 - sx) + g(x0 + 1, y0 + 1) * sx; + out[y * w + x] += (a * (1 - sy) + b * sy) * amp; + } + } + total += amp; + amp *= 0.5; + } + for (let i = 0; i < out.length; i++) out[i] /= total; + return out; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..e79448b --- /dev/null +++ b/src/main.ts @@ -0,0 +1,64 @@ +import * as THREE from 'three'; +import { App, makeLights } from './core/app'; +import { Slice } from './sim/slice'; +import { BREADS } from './sim/bread'; +import { Rng } from './core/rng'; +import './style.css'; + +const canvas = document.getElementById('c') as HTMLCanvasElement; +const app = new App(canvas); +makeLights(app.scene); +app.scene.background = new THREE.Color(0x1d1613); + +const bench = new THREE.Mesh( + new THREE.BoxGeometry(9, 0.3, 5), + new THREE.MeshStandardMaterial({ color: 0x8a5a33, roughness: 0.85 }), +); +bench.position.y = -0.15; +bench.receiveShadow = true; +app.scene.add(bench); + +const slice = new Slice(BREADS.white, new Rng(7)); +slice.mesh.position.y = 0.12; +app.scene.add(slice.mesh); + +// M0 smoke test: a browning gradient + a smear of butter, so the shader gets exercised. +for (let y = 0; y < slice.browning.n; y++) { + for (let x = 0; x < slice.browning.n; x++) { + const i = y * slice.browning.n + x; + slice.browning.data[i] = (x / slice.browning.n) * 1.0; + const dx = x / slice.spread.n - 0.5; + const dy = y / slice.spread.n - 0.55; + slice.spread.data[i] = Math.max(0, 0.45 - Math.hypot(dx, dy) * 1.2); + } +} +slice.setSpread({ + id: 'butter', + name: 'Butter', + blurb: '', + color: [0.97, 0.72, 0.19], + gloss: 0.85, + opaqueAt: 0.5, + bump: 0.1, + baseYield: 0.72, + tempSoftening: 0.78, + viscosity: 0.72, + pickup: 0.55, + transferRate: 1.5, + drag: 0.5, + scrapeEase: 1, + amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] }, +}); +slice.touch(); +slice.sync(); + +(window as unknown as { __t: unknown }).__t = { app, slice, THREE }; + +app.camera.position.set(0, 1.5, 1.6); +app.camera.lookAt(0, 0.1, 0); + +app.onTick((dt) => { + slice.mesh.rotation.y += dt * 0.4; +}); + +app.start(); diff --git a/src/sim/bread.ts b/src/sim/bread.ts new file mode 100644 index 0000000..fbe657c --- /dev/null +++ b/src/sim/bread.ts @@ -0,0 +1,101 @@ +/** + * Bread archetypes. The three numbers that matter for the toasting sim: + * thickness — thermal lag (slow to brown) but holds warmth longer, which + * matters later because warm toast is what softens hard butter. + * moisture — water must boil off before the crust can brown, so a wet crumb + * stalls, then catches up fast once it dries. Sourdough's trap. + * sugar — browns early and burns early. Raisin bread is a countdown. + */ +export interface Bread { + id: BreadId; + name: string; + blurb: string; + thickness: number; // world units + width: number; + height: number; + domeH: number; // how much of the top is the loaf's dome + moisture: number; // 0..1 + sugar: number; // 0..1 + crumb: [number, number, number]; + crust: [number, number, number]; + crumbScale: number; // crumb noise frequency + crumbContrast: number; + /** Speckles baked into the crumb: raisins, grain kibble. */ + inclusions: number; + inclusionColor: [number, number, number]; +} + +export type BreadId = 'white' | 'sourdough' | 'multigrain' | 'raisin'; + +export const BREADS: Record = { + white: { + id: 'white', + name: 'Supermarket White', + blurb: 'Forgiving. Browns like it wants to be liked.', + thickness: 0.13, + width: 1.0, + height: 1.0, + domeH: 0.26, + moisture: 0.18, + sugar: 0.22, + crumb: [0.95, 0.9, 0.78], + crust: [0.78, 0.6, 0.36], + crumbScale: 34, + crumbContrast: 0.07, + inclusions: 0, + inclusionColor: [0, 0, 0], + }, + sourdough: { + id: 'sourdough', + name: 'Thick-Cut Sourdough', + blurb: 'Wet, dense, and stubborn. Stalls, then catches you out.', + thickness: 0.23, + width: 1.06, + height: 1.02, + domeH: 0.3, + moisture: 0.72, + sugar: 0.1, + crumb: [0.93, 0.87, 0.72], + crust: [0.62, 0.42, 0.22], + crumbScale: 16, + crumbContrast: 0.16, + inclusions: 0, + inclusionColor: [0, 0, 0], + }, + multigrain: { + id: 'multigrain', + name: 'Multigrain', + blurb: 'The seeds catch first. They always catch first.', + thickness: 0.15, + width: 1.0, + height: 0.98, + domeH: 0.24, + moisture: 0.34, + sugar: 0.3, + crumb: [0.82, 0.72, 0.55], + crust: [0.55, 0.4, 0.24], + crumbScale: 30, + crumbContrast: 0.13, + inclusions: 0.55, + inclusionColor: [0.36, 0.26, 0.15], + }, + raisin: { + id: 'raisin', + name: 'Raisin Loaf', + blurb: 'Sugar. Everywhere. You have less time than you think.', + thickness: 0.14, + width: 0.98, + height: 0.98, + domeH: 0.26, + moisture: 0.3, + sugar: 0.92, + crumb: [0.93, 0.84, 0.66], + crust: [0.7, 0.48, 0.26], + crumbScale: 26, + crumbContrast: 0.09, + inclusions: 0.85, + inclusionColor: [0.22, 0.11, 0.12], + }, +}; + +export const BREAD_ORDER: BreadId[] = ['white', 'multigrain', 'raisin', 'sourdough']; diff --git a/src/sim/slice.ts b/src/sim/slice.ts new file mode 100644 index 0000000..1f989e9 --- /dev/null +++ b/src/sim/slice.ts @@ -0,0 +1,497 @@ +import * as THREE from 'three'; +import { Field } from '../core/field'; +import { Rng, valueNoise2D } from '../core/rng'; +import type { Bread } from './bread'; +import type { SpreadDef } from './spreads'; + +export const FIELD_N = 128; + +/** Past this, the surface has stopped being toast and started being charcoal. */ +export const CHAR_THRESHOLD = 0.85; + +/** + * One slice of bread: the mesh, the four scalar fields the whole game reads and + * writes, and the shader that turns them into something appetising (or not). + * + * Fields are packed into a single RGBA8 texture — one small upload per frame + * instead of four, and the shader gets free bilinear smoothing over the 128x128 + * sim grid, which is what makes knife strokes look continuous. + */ +export class Slice { + readonly bread: Bread; + readonly mesh: THREE.Mesh; + readonly material: THREE.ShaderMaterial; + + /** 0 = raw, 0.85 = char threshold, 1 = carbon. */ + readonly browning: Field; + /** Water that must boil off before browning gets going. */ + readonly dryness: Field; + /** Spread thickness. ~0.12 thin, ~0.4 normal, ~0.8 thick. */ + readonly spread: Field; + /** Torn/gouged surface. Permanent. */ + readonly damage: Field; + /** 1 inside the bread silhouette. Every judged statistic is masked by this. */ + readonly mask: Field; + /** Per-run heat bias — why no two slices toast identically. */ + readonly heatBias: Float32Array; + + /** 1 right out of the toaster, decays to 0. Softens butter. */ + warmth = 0; + /** Which spread is currently on the slice (for judging + shading). */ + spreadDef: SpreadDef | null = null; + + private tex: THREE.DataTexture; + private texData: Uint8Array; + private dirty = true; + + constructor(bread: Bread, rng: Rng) { + this.bread = bread; + this.browning = new Field(FIELD_N); + this.dryness = new Field(FIELD_N); + this.spread = new Field(FIELD_N); + this.damage = new Field(FIELD_N); + this.mask = new Field(FIELD_N); + + const shape = breadShape(bread); + buildMask(this.mask, shape, bread); + this.heatBias = valueNoise2D(rng, FIELD_N, FIELD_N, 3); + + this.texData = new Uint8Array(FIELD_N * FIELD_N * 4); + this.tex = new THREE.DataTexture(this.texData, FIELD_N, FIELD_N, THREE.RGBAFormat); + this.tex.minFilter = THREE.LinearFilter; + this.tex.magFilter = THREE.LinearFilter; + this.tex.wrapS = THREE.ClampToEdgeWrapping; + this.tex.wrapT = THREE.ClampToEdgeWrapping; + this.tex.needsUpdate = true; + + const geo = buildGeometry(shape, bread); + this.material = buildMaterial(bread, this.tex); + this.mesh = new THREE.Mesh(geo, this.material); + this.mesh.castShadow = true; + this.mesh.receiveShadow = true; + this.sync(); + } + + get uniforms() { + return this.material.uniforms; + } + + /** Push the sim fields into the GPU texture. Cheap: 64KB. */ + sync(): void { + if (!this.dirty) return; + const d = this.texData; + const b = this.browning.data; + const s = this.spread.data; + const g = this.damage.data; + const m = this.mask.data; + for (let i = 0, j = 0; i < b.length; i++, j += 4) { + d[j] = clamp255(b[i] * 255); + d[j + 1] = clamp255(s[i] * 255); + d[j + 2] = clamp255(g[i] * 255); + d[j + 3] = clamp255(m[i] * 255); + } + this.tex.needsUpdate = true; + this.dirty = false; + } + + touch(): void { + this.dirty = true; + } + + setSpread(def: SpreadDef | null): void { + this.spreadDef = def; + const u = this.material.uniforms; + if (def) { + u.uSpreadColor.value.setRGB(def.color[0], def.color[1], def.color[2]); + u.uSpreadGloss.value = def.gloss; + u.uSpreadOpaqueAt.value = def.opaqueAt; + u.uSpreadBump.value = def.bump; + } else { + u.uSpreadOpaqueAt.value = 1e9; + } + } + + setHeatGlow(v: number): void { + this.material.uniforms.uHeatGlow.value = v; + } + + setHeatmap(mode: 0 | 1 | 2): void { + this.material.uniforms.uHeatmap.value = mode; + } + + dispose(): void { + this.mesh.geometry.dispose(); + this.material.dispose(); + this.tex.dispose(); + } +} + +function clamp255(v: number): number { + return v < 0 ? 0 : v > 255 ? 255 : v | 0; +} + +/** The classic sandwich-loaf silhouette: square-ish body, domed top. */ +function breadShape(bread: Bread): THREE.Shape { + const hw = bread.width / 2; + const hh = bread.height / 2; + const dome = bread.domeH; + const r = 0.09; + const bodyTop = hh - dome; + const s = new THREE.Shape(); + s.moveTo(-hw + r, -hh); + s.lineTo(hw - r, -hh); + s.quadraticCurveTo(hw, -hh, hw, -hh + r); + s.lineTo(hw, bodyTop); + s.bezierCurveTo(hw, bodyTop + dome * 0.86, hw * 0.62, hh, 0, hh); + s.bezierCurveTo(-hw * 0.62, hh, -hw, bodyTop + dome * 0.86, -hw, bodyTop); + s.lineTo(-hw, -hh + r); + s.quadraticCurveTo(-hw, -hh, -hw + r, -hh); + s.closePath(); + return s; +} + +/** + * Extrude the silhouette, then planar-project UVs from the shape's own XY before + * we lay the slice flat — so the field grid lines up with the bread exactly, and + * both faces share it. + */ +function buildGeometry(shape: THREE.Shape, bread: Bread): THREE.BufferGeometry { + const bevel = Math.min(0.03, bread.thickness * 0.28); + const geo = new THREE.ExtrudeGeometry(shape, { + depth: bread.thickness - bevel * 2, + bevelEnabled: true, + bevelThickness: bevel, + bevelSize: bevel, + bevelSegments: 3, + curveSegments: 32, + }); + + geo.computeBoundingBox(); + const bb = geo.boundingBox!; + const w = bb.max.x - bb.min.x; + const h = bb.max.y - bb.min.y; + const pos = geo.attributes.position; + const uv = new Float32Array(pos.count * 2); + for (let i = 0; i < pos.count; i++) { + uv[i * 2] = (pos.getX(i) - bb.min.x) / w; + uv[i * 2 + 1] = (pos.getY(i) - bb.min.y) / h; + } + geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); + + // Lay it flat: the far extrude cap becomes the top face (+Y). + geo.rotateX(-Math.PI / 2); + geo.center(); + geo.computeVertexNormals(); + return geo; +} + +/** Rasterise the silhouette into the mask field (point-in-polygon per texel). */ +function buildMask(mask: Field, shape: THREE.Shape, bread: Bread): void { + const pts = shape.getPoints(96); + const hw = bread.width / 2; + const hh = bread.height / 2; + // Same normalisation the geometry uses (shape bbox ≈ the slice bbox). + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const p of pts) { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + } + void hw; + void hh; + const n = mask.n; + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const px = minX + ((x + 0.5) / n) * (maxX - minX); + const py = minY + ((y + 0.5) / n) * (maxY - minY); + mask.data[y * n + x] = pointInPoly(px, py, pts) ? 1 : 0; + } + } + // Pull the mask in by ~1 texel so brushes can't paint into the crust seam. + erode(mask); +} + +function pointInPoly(x: number, y: number, pts: THREE.Vector2[]): boolean { + let inside = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const xi = pts[i].x; + const yi = pts[i].y; + const xj = pts[j].x; + const yj = pts[j].y; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +function erode(mask: Field): void { + const n = mask.n; + const src = mask.data.slice(); + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + if (src[y * n + x] < 0.5) continue; + const xm = Math.max(0, x - 1); + const xp = Math.min(n - 1, x + 1); + const ym = Math.max(0, y - 1); + const yp = Math.min(n - 1, y + 1); + if ( + src[y * n + xm] < 0.5 || + src[y * n + xp] < 0.5 || + src[ym * n + x] < 0.5 || + src[yp * n + x] < 0.5 + ) { + mask.data[y * n + x] = 0; + } + } + } +} + +const VERT = /* glsl */ ` +varying vec2 vUv; +varying vec3 vNormalW; +varying vec3 vPosW; +varying vec3 vPosL; +void main() { + vUv = uv; + vPosL = position; + vNormalW = normalize(mat3(modelMatrix) * normal); + vec4 wp = modelMatrix * vec4(position, 1.0); + vPosW = wp.xyz; + gl_Position = projectionMatrix * viewMatrix * wp; +} +`; + +const FRAG = /* glsl */ ` +precision highp float; + +uniform sampler2D uField; // R browning, G spread, B damage, A mask +uniform float uFieldN; +uniform vec3 uCrumb; +uniform vec3 uCrust; +uniform float uCrumbScale; +uniform float uCrumbContrast; +uniform float uInclusions; +uniform vec3 uInclusionColor; +uniform vec3 uSpreadColor; +uniform float uSpreadGloss; +uniform float uSpreadOpaqueAt; // spread thickness at which it fully hides the toast +uniform float uSpreadBump; +uniform vec3 uLightDir; +uniform vec3 uLightColor; +uniform vec3 uAmbientSky; +uniform vec3 uAmbientGround; +uniform float uHeatGlow; +uniform float uWarmth; +uniform int uHeatmap; + +varying vec2 vUv; +varying vec3 vNormalW; +varying vec3 vPosW; +varying vec3 vPosL; + +float hash21(vec2 p) { + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + for (int i = 0; i < 4; i++) { + v += a * vnoise(p); + p *= 2.03; + a *= 0.5; + } + return v; +} + +vec3 brownRamp(float b) { + vec3 c0 = vec3(0.95, 0.91, 0.79); + vec3 c1 = vec3(0.91, 0.77, 0.48); + vec3 c2 = vec3(0.79, 0.54, 0.24); + vec3 c3 = vec3(0.50, 0.28, 0.11); + vec3 c4 = vec3(0.19, 0.11, 0.06); + vec3 c5 = vec3(0.05, 0.04, 0.04); + if (b < 0.20) return mix(c0, c1, b / 0.20); + if (b < 0.45) return mix(c1, c2, (b - 0.20) / 0.25); + if (b < 0.70) return mix(c2, c3, (b - 0.45) / 0.25); + if (b < 0.88) return mix(c3, c4, (b - 0.70) / 0.18); + return mix(c4, c5, clamp((b - 0.88) / 0.12, 0.0, 1.0)); +} + +vec3 heatRamp(float t) { + vec3 a = vec3(0.10, 0.05, 0.35); + vec3 b = vec3(0.15, 0.55, 0.75); + vec3 c = vec3(0.95, 0.85, 0.25); + vec3 d = vec3(0.85, 0.15, 0.10); + if (t < 0.33) return mix(a, b, t / 0.33); + if (t < 0.66) return mix(b, c, (t - 0.33) / 0.33); + return mix(c, d, clamp((t - 0.66) / 0.34, 0.0, 1.0)); +} + +void main() { + vec4 f = texture2D(uField, vUv); + float browning = f.r; + float spread = f.g; + float damage = f.b; + + vec3 N = normalize(vNormalW); + // 1 on the two flat faces, 0 around the crust edge. + float faceness = smoothstep(0.45, 0.85, abs(N.y)); + // Spread only ever lands on the top. + float topFace = smoothstep(0.45, 0.85, N.y); + + // --- crumb --- + float crumbN = fbm(vUv * uCrumbScale); + vec3 crumb = uCrumb * (1.0 + (crumbN - 0.5) * uCrumbContrast * 2.0); + // holes in the crumb + float holes = smoothstep(0.62, 0.78, fbm(vUv * uCrumbScale * 0.55 + 11.0)); + crumb *= 1.0 - holes * 0.35; + + // seeds / raisins + float inc = smoothstep(0.80, 0.92, vnoise(vUv * uCrumbScale * 0.9 + 37.0)); + crumb = mix(crumb, uInclusionColor, inc * uInclusions); + + // --- browning --- + vec3 toastFace = brownRamp(browning) * (0.86 + crumbN * 0.28); + // inclusions scorch first and stay dark + toastFace = mix(toastFace, toastFace * 0.45, inc * uInclusions * smoothstep(0.15, 0.6, browning)); + toastFace = mix(crumb, toastFace, smoothstep(0.0, 0.06, browning)); + + // char blotches, not a clean gradient + float charN = fbm(vUv * 46.0 + 5.0); + float char = smoothstep(0.82, 1.0, browning + (charN - 0.5) * 0.22); + toastFace = mix(toastFace, vec3(0.035, 0.03, 0.03), char * 0.92); + + // crust: always browner than the face, and it browns too + vec3 crust = uCrust * (0.75 + fbm(vUv * 26.0 + 3.0) * 0.5); + crust = mix(crust, crust * 0.35, smoothstep(0.4, 1.0, browning)); + + vec3 albedo = mix(crust, toastFace, faceness); + + // --- damage: torn/gouged surface exposes pale raw crumb in a dark crevice --- + float dmgN = fbm(vUv * 60.0 + 21.0); + float dmg = clamp(damage * (0.7 + dmgN * 0.6), 0.0, 1.0); + albedo = mix(albedo, uCrumb * 0.88, dmg * 0.8); + albedo *= 1.0 - dmg * 0.28; // crevice shading + + // --- spread --- + float sTex = 1.0 / uFieldN; + float sL = texture2D(uField, vUv - vec2(sTex, 0.0)).g; + float sR = texture2D(uField, vUv + vec2(sTex, 0.0)).g; + float sD = texture2D(uField, vUv - vec2(0.0, sTex)).g; + float sU = texture2D(uField, vUv + vec2(0.0, sTex)).g; + + // Wetting. This is what actually makes a spread readable: long before a film is + // thick enough to hide the toast, it soaks in and saturates it, the way oil + // darkens paper. Without this, yellow butter on pale crumb is invisible. + // Must be a gamma-style darkening — a multiply would *brighten* pale crumb. + float wet = clamp(spread / max(uSpreadOpaqueAt * 0.35, 0.0001), 0.0, 1.0) * topFace; + albedo = mix(albedo, pow(albedo, vec3(1.65)) * 0.88, wet * 0.8); + + float cover = clamp(spread / max(uSpreadOpaqueAt, 0.0001), 0.0, 1.0); + cover *= topFace; + // a little grain in the film so thin spread doesn't look like flat paint + float filmN = fbm(vUv * 70.0 + 13.0); + vec3 spreadCol = uSpreadColor * (0.85 + filmN * 0.3); + albedo = mix(albedo, spreadCol, cover); + + // Thickness relief. Central differences in *UV* units, not texels — a smooth + // smear only changes ~0.01 per texel, so the raw difference is far too small + // to bend a normal with. + vec2 grad = vec2(sR - sL, sU - sD) / (2.0 * sTex); + N = normalize(N + vec3(-grad.x, 0.0, grad.y) * uSpreadBump * topFace); + // torn bread is rough + N = normalize(N + vec3(dmgN - 0.5, 0.0, fbm(vUv * 60.0 + 44.0) - 0.5) * dmg * 0.9); + + // --- lighting --- + vec3 L = normalize(uLightDir); + vec3 V = normalize(cameraPosition - vPosW); + vec3 H = normalize(L + V); + float ndl = max(dot(N, L), 0.0); + float wrap = (ndl + 0.35) / 1.35; // soft claymation falloff + vec3 ambient = mix(uAmbientGround, uAmbientSky, N.y * 0.5 + 0.5); + + // Gloss: spread is shiny, bread is not; melting butter is shinier still. + // A tight lobe puts one hotspot somewhere off the slice and reads as nothing, + // so pair a broad sheen with a fresnel rim — that's the cue that says "wet". + float gloss = uSpreadGloss * smoothstep(0.008, 0.09, spread) * topFace; + gloss *= 1.0 + uWarmth * 0.6; + // Keep the lobe tight: a broad one just washes the whole slice white and drowns + // the colour. Tight + the thickness ridges = glints along the knife marks. + float shin = mix(18.0, 52.0, uSpreadGloss); + float spec = pow(max(dot(N, H), 0.0), shin) * gloss * 0.6; + float fres = pow(1.0 - max(dot(N, V), 0.0), 3.0); + spec += fres * gloss * 0.22; + spec *= 1.0 - char * 0.7; + spec *= 1.0 - dmg * 0.6; + + vec3 color = albedo * (ambient + uLightColor * wrap * 0.68) + uLightColor * spec; + + // element glow while it's down in the toaster + if (uHeatGlow > 0.001) { + float coil = 0.5 + 0.5 * sin(vPosL.x * 34.0); + color += vec3(1.0, 0.28, 0.06) * uHeatGlow * (0.16 + coil * 0.16) * faceness; + } + + // a hot slice keeps a faint warmth in the crumb + color += vec3(0.05, 0.015, 0.0) * uWarmth * faceness; + + // Judge-screen data views: flat, unlit, deliberately not appetising. + if (uHeatmap == 1) { + color = mix(vec3(0.10), heatRamp(clamp(browning, 0.0, 1.0)), faceness * 0.88 + 0.12); + } else if (uHeatmap == 2) { + color = mix(vec3(0.10), heatRamp(clamp(spread * 2.0, 0.0, 1.0)), topFace * 0.88 + 0.12); + } + + gl_FragColor = vec4(color, 1.0); + + // Match the tone mapping + output colour space the standard materials get, + // otherwise the toast reads as a different render from everything around it. + #include + #include +} +`; + +function buildMaterial(bread: Bread, tex: THREE.DataTexture): THREE.ShaderMaterial { + return new THREE.ShaderMaterial({ + vertexShader: VERT, + fragmentShader: FRAG, + uniforms: { + uField: { value: tex }, + uFieldN: { value: FIELD_N }, + uCrumb: { value: new THREE.Color(...bread.crumb) }, + uCrust: { value: new THREE.Color(...bread.crust) }, + uCrumbScale: { value: bread.crumbScale }, + uCrumbContrast: { value: bread.crumbContrast }, + uInclusions: { value: bread.inclusions }, + uInclusionColor: { value: new THREE.Color(...bread.inclusionColor) }, + uSpreadColor: { value: new THREE.Color(1, 0.85, 0.3) }, + uSpreadGloss: { value: 0.5 }, + uSpreadOpaqueAt: { value: 1e9 }, + uSpreadBump: { value: 0.1 }, + uLightDir: { value: new THREE.Vector3(0.45, 0.85, 0.35).normalize() }, + uLightColor: { value: new THREE.Color(1.0, 0.95, 0.86) }, + uAmbientSky: { value: new THREE.Color(0.26, 0.27, 0.31) }, + uAmbientGround: { value: new THREE.Color(0.14, 0.11, 0.09) }, + uHeatGlow: { value: 0 }, + uWarmth: { value: 0 }, + uHeatmap: { value: 0 }, + }, + }); +} diff --git a/src/sim/spreads.ts b/src/sim/spreads.ts new file mode 100644 index 0000000..682021d --- /dev/null +++ b/src/sim/spreads.ts @@ -0,0 +1,125 @@ +/** + * Spread rheology. The whole difficulty curve of this game lives in these numbers. + * + * The central tension: you need PRESSURE to make a stiff spread flow, but pressure + * comes from tilting the knife steeper — and past `SCRAPE_ANGLE` a steep knife stops + * spreading and starts scraping. So cold butter can want more pressure than you are + * physically allowed to apply without flipping into scrape mode. The way out isn't a + * better technique, it's warmer toast: don't dawdle in the drawer. + */ + +export type SpreadId = 'butter' | 'peanut' | 'mitey'; +export type AmountClass = 'thin' | 'normal' | 'thick'; + +export interface SpreadDef { + id: SpreadId; + name: string; + blurb: string; + color: [number, number, number]; + gloss: number; + /** Thickness at which the film fully hides the toast underneath. */ + opaqueAt: number; + /** Surface relief strength. */ + bump: number; + + /** Pressure needed to make it flow, before any warming. */ + baseYield: number; + /** How much heat (soft butter + hot toast) reduces that. */ + tempSoftening: number; + /** 0 self-levels instantly, 1 stays exactly where you put it. */ + viscosity: number; + /** How much the knife picks up per dip. */ + pickup: number; + /** How fast it leaves the knife once it IS flowing. */ + transferRate: number; + /** How much the spread resists the knife (drag on the cursor). */ + drag: number; + /** How readily it comes back off when you scrape. */ + scrapeEase: number; + + amounts: Record; +} + +export const SPREADS: Record = { + butter: { + id: 'butter', + name: 'Butter', + blurb: 'Straightforward — as long as something in this scene is warm.', + color: [0.97, 0.72, 0.19], + gloss: 0.85, + opaqueAt: 0.5, + bump: 0.10, + baseYield: 0.72, + tempSoftening: 0.78, + viscosity: 0.72, + pickup: 0.55, + transferRate: 1.5, + drag: 0.5, + scrapeEase: 1.0, + amounts: { thin: [0.1, 0.22], normal: [0.25, 0.5], thick: [0.55, 0.9] }, + }, + peanut: { + id: 'peanut', + name: 'Peanut Butter', + blurb: 'Goes on thick, drags like wet sand, never evens itself out.', + color: [0.64, 0.41, 0.18], + gloss: 0.3, + opaqueAt: 0.16, + bump: 0.28, + baseYield: 0.38, + tempSoftening: 0.3, + viscosity: 0.96, + pickup: 0.95, + transferRate: 2.2, + drag: 1.0, + scrapeEase: 0.75, + amounts: { thin: [0.18, 0.32], normal: [0.38, 0.68], thick: [0.75, 1.1] }, + }, + mitey: { + id: 'mitey', + name: 'MITEY', + blurb: 'Spreads like nothing. The skill is taking almost all of it back off.', + color: [0.14, 0.08, 0.05], + gloss: 0.62, + opaqueAt: 0.26, + bump: 0.03, + baseYield: 0.14, + tempSoftening: 0.5, + viscosity: 0.34, + pickup: 0.5, + transferRate: 2.6, + drag: 0.25, + scrapeEase: 1.25, + amounts: { thin: [0.03, 0.11], normal: [0.14, 0.28], thick: [0.32, 0.6] }, + }, +}; + +/** Knife angle past which contact narrows enough to scrape instead of spread. */ +export const SCRAPE_ANGLE = 0.62; + +/** Angle (0 flat .. 1 on-edge) -> contact pressure. */ +export function pressureFromAngle(angle: number): number { + return 0.1 + 0.9 * Math.pow(Math.max(0, Math.min(1, angle)), 1.3); +} + +/** Angle -> contact patch radius in UV units. Flat knife = wide, steep = a line. */ +export function contactRadius(angle: number): number { + return 0.115 - 0.078 * Math.max(0, Math.min(1, angle)); +} + +/** + * What it actually takes to make this spread flow right now. Butter on a cold + * slice with fridge-hard butter is the worst case in the game, by design. + */ +export function effectiveYield(def: SpreadDef, softness: number, toastWarmth: number): number { + const heat = Math.min(1, softness * 0.55 + toastWarmth * 0.65); + return def.baseYield * (1 - def.tempSoftening * heat); +} + +export function amountClassOf(def: SpreadDef, mean: number): AmountClass | 'none' | 'obscene' { + if (mean < def.amounts.thin[0] * 0.5) return 'none'; + if (mean <= def.amounts.thin[1]) return 'thin'; + if (mean <= def.amounts.normal[1]) return 'normal'; + if (mean <= def.amounts.thick[1]) return 'thick'; + return 'obscene'; +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..75fb721 --- /dev/null +++ b/src/style.css @@ -0,0 +1,51 @@ +:root { + --ink: #f3e9dc; + --ink-dim: #a9998a; + --paper: #efe4cf; + --char: #16110e; + --gold: #e8a53a; + --red: #c0392b; + --font: 'Helvetica Neue', Helvetica, Arial, sans-serif; + --mono: ui-monospace, 'SF Mono', Menlo, monospace; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + height: 100%; + overflow: hidden; + background: var(--char); + color: var(--ink); + font-family: var(--font); + -webkit-font-smoothing: antialiased; +} + +#app { + position: fixed; + inset: 0; +} + +canvas#c { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + touch-action: none; +} + +#ui { + position: absolute; + inset: 0; + pointer-events: none; + user-select: none; +} + +#ui > * { + pointer-events: auto; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9951516 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4e17448 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { port: 5173, strictPort: true }, + // rapier3d-compat ships wasm inlined as base64, but it still wants to be pre-bundled + optimizeDeps: { exclude: [] }, + build: { target: 'es2022', chunkSizeWarningLimit: 1500 }, +});