diff --git a/.gitignore b/.gitignore
index 93aaaf8..6f4121d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ __pycache__/
.DS_Store
node_modules/
dist/
+
+# Lane I: farm GLBs staged for local serving (large, not committed)
+public/assets/meshes/
diff --git a/demos/lane-i.html b/demos/lane-i.html
new file mode 100644
index 0000000..5fa5dd0
--- /dev/null
+++ b/demos/lane-i.html
@@ -0,0 +1,22 @@
+
+
+
BLOBBO lane i demo — asset slots
+
+
+
+
+ BLOBBO — Lane I: asset slots
+ Left column: empty manifest (today's game).
+ Right column: the same code with a test manifest pointing at the farm GLBs.
+ The panel top-left reports every slot's load status, the paintability check
+ for the blob body, and a scripted splat's coverage before/after.
+
+
+
+
diff --git a/docs/ASSET-SLOTS.md b/docs/ASSET-SLOTS.md
new file mode 100644
index 0000000..8ee7231
--- /dev/null
+++ b/docs/ASSET-SLOTS.md
@@ -0,0 +1,127 @@
+# Custom assets — how the slots work
+
+Drop a `.glb` into a named slot and the game uses it. No code changes.
+With no manifest at all, the game builds exactly what it builds today.
+
+## The one URL rule
+
+```ts
+import.meta.env.BASE_URL + 'assets/manifest.json'
+```
+
+Never `/assets/...` (that drops the `/blobbo/` deploy prefix) and never a bare
+`assets/...` (that resolves against the *page* directory, which breaks on the
+demo pages one level down). `assetUrl()` in `src/assets/manifest.ts` does this
+for you.
+
+## Where files live
+
+| What | Path | Ships? |
+|---|---|---|
+| The manifest | `public/assets/manifest.json` | yes → `dist/assets/manifest.json` |
+| A shipped asset pack | `public/assets/live/*.glb` | yes → `dist/assets/live/*.glb` |
+| Raw farm output | `assets/meshes/*.glb` | **no** — outside the build |
+
+`public/` is vite's default publicDir, so everything under it is copied into
+`dist/` verbatim with the base path already applied. Before this lane, nothing
+copied `assets/` into `dist/` at all — every asset URL would have 404'd live.
+
+The four farm GLBs are ~43 MB of raw output with embedded textures. Stage them
+for local work with `./scripts/stage-farm-assets.sh` (gitignored). Compress them
+offline before any of them goes into `public/assets/live/` — `deploy.sh` rsyncs
+and `docker cp`s the whole of `dist/` on every deploy.
+
+## Manifest format
+
+```json
+{
+ "machine.boot": {
+ "url": "assets/live/boot.glb",
+ "offset": [0, 0.2, 0],
+ "rotationDeg": [0, 90, 0],
+ "scale": 1.5,
+ "idleClip": "idle"
+ }
+}
+```
+
+Everything except `url` is optional. A bad field is dropped with a warning and
+the rest of the entry still loads; a bad entry is dropped and the rest of the
+pack still loads; a file that isn't JSON at all falls back to `{}`. A GLB that
+fails to load leaves the built-in version in place and logs one warning.
+
+Load order, later wins: `public/assets/manifest.json`, then the editor's
+IndexedDB override (`blobbo-workshop`). An override url may be `idb:`,
+which resolves to a blob stored in the same database — that is what makes a
+custom asset testable on the live site with zero deploys.
+
+## Slots
+
+| Slot id | Replaces | Stays procedural |
+|---|---|---|
+| `blob.body` | the paintable body | — |
+| `blob.face` | the googly eyes | — |
+| `ghost.body` | the ghost racer (defaults to `blob.body`) | — |
+| `cannon.barrel` | cannon base + tube | the aiming pivot |
+| `machine.plate` | the plate frame | the pressed pad (it lights up) |
+| `machine.boot` | pad + coils | — |
+| `machine.bucket` | the shell | the paint fill (it is re-tinted) |
+| `machine.arch` | posts, bar, bubbles | — |
+| `machine.belt` | the belt slab | the scrolling chevrons |
+| `machine.fan` | the housing | the spinning blades |
+| `machine.seesaw` | the plank | the fulcrum |
+| `course.scenery.cereal` | the giant cereal box | — |
+| `course.scenery.block` | the purple block | — |
+| `fx.puddle` | the puddle slab | — |
+
+Sub-parts that move every frame stay procedural on purpose: a custom model can't
+accidentally stop the belt scrolling, the fan spinning or the plate lighting up,
+and those motions are what make each machine readable.
+
+## Modelling conventions
+
+Y-up, metres, one material, embedded textures ≤ 2048².
+
+- **`blob.body`** is the only slot that is not purely cosmetic. It must be a
+ single mesh with a single material and one UV island inside 0..1 — the paint
+ is stamped through those UVs. It is automatically re-centred and re-scaled to
+ the 1.0u body, because `boundingRadius` is a gameplay number (the puddles
+ compute belly contact from it). `scale` is ignored for this slot; `offset` and
+ `rotationDeg` are baked into the geometry before the refit. If the mesh fails
+ the paint checks the built-in blob is kept and the reason is logged — paint
+ never breaks silently.
+- **`machine.bucket`** — origin at the tipping edge, pours toward +X.
+- **`machine.boot`** — origin at the base; it shakes about its own origin.
+- **`machine.fan`** — faces +Z.
+- **`machine.seesaw`** — plank only, long axis along X, origin at the centre.
+- **Course boxes** keep their collider whatever you drop in, so a custom prop
+ can never open a hole in the course or grow an invisible wall. Fill the volume.
+- Materials are converted to `MeshStandardMaterial` on load if they aren't one
+ already: the machine danger-flash only drives standard materials, so a Basic
+ or Phong export would silently kill the telegraph.
+
+## Wiring (integration)
+
+`src/main.ts` needs one line before `installGame(world)`:
+
+```ts
+await initAssets()
+```
+
+Without it the registry stays empty and every slot falls back — which is safe,
+but nothing ever swaps. It must be awaited: the frozen `game.ts` captures
+`blob.mesh` by reference the moment `createBlob` returns, so the body asset has
+to be in the cache before the game is built.
+
+## Checking your work
+
+```
+npm run build
+node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts
+node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts
+node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts
+```
+
+The last one parses the real farm GLBs, runs the paintability check on each and
+fires PaintSkin's own outside-in raycast against the fitted blob body, so you can
+confirm a splat would land without opening a browser.
diff --git a/public/assets/manifest.json b/public/assets/manifest.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/public/assets/manifest.json
@@ -0,0 +1 @@
+{}
diff --git a/scripts/farm-assets.check.ts b/scripts/farm-assets.check.ts
new file mode 100644
index 0000000..b8e38d1
--- /dev/null
+++ b/scripts/farm-assets.check.ts
@@ -0,0 +1,95 @@
+/**
+ * Headless audit of the four farm GLBs in assets/meshes/. Run:
+ * node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts
+ *
+ * This is the closest thing to browser verification the asset runtime has: it
+ * parses each real GLB, runs the same paintability check the game runs, and —
+ * for the blob body — normalises the geometry and fires the same
+ * outside-in raycast PaintSkin uses to turn a world point into a UV. If that
+ * raycast returns a UV, a splat lands; if it returns null, splats silently do
+ * nothing, which is the failure mode this file exists to catch.
+ *
+ * `THREE.GLTFLoader: Couldn't load texture` warnings are expected here — node
+ * has no ImageBitmap. Geometry, UVs and materials all still parse.
+ */
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import * as THREE from 'three'
+import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { paintableInfo, normalizeToRadius } from '../src/assets/registry'
+
+// GLTFLoader reaches for `self` when wiring its texture loaders.
+;(globalThis as unknown as { self: unknown }).self = globalThis
+
+const MESH_DIR = fileURLToPath(new URL('../assets/meshes/', import.meta.url))
+const FILES = ['blobbo-base', 'prop-spring-boot', 'prop-paint-bucket', 'prop-toaster-launcher']
+
+const loader = new GLTFLoader()
+let failures = 0
+
+function parse(file: string): Promise {
+ const buf = readFileSync(MESH_DIR + file + '.glb')
+ const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
+ return new Promise((res) => loader.parse(ab, '', res, () => res(null)))
+}
+
+for (const file of FILES) {
+ const gltf = await parse(file)
+ if (!gltf) {
+ console.log(`${file}: PARSE FAILED`)
+ failures++
+ continue
+ }
+ const info = paintableInfo(gltf.scene)
+ console.log(
+ `${file.padEnd(22)} meshes=${info.meshCount} materials=${info.materialCount} ` +
+ `tris=${info.triCount} uvOk=${info.uvOk} skinned=${info.skinned} paintSafe=${info.ok}` +
+ (gltf.animations.length ? ` clips=${gltf.animations.map((a) => a.name).join(',')}` : ''),
+ )
+ for (const p of info.problems) console.log(` ! ${p}`)
+}
+
+// ---- the blob body, end to end ---------------------------------------------
+const gltf = await parse('blobbo-base')
+if (!gltf) {
+ failures++
+} else {
+ let mesh: THREE.Mesh | null = null
+ gltf.scene.traverse((o) => { if (!mesh && (o as THREE.Mesh).isMesh) mesh = o as THREE.Mesh })
+ const body = mesh as unknown as THREE.Mesh
+ const geo = body.geometry.clone()
+ normalizeToRadius(geo, 0.5)
+ const fitted = new THREE.Mesh(geo, new THREE.MeshStandardMaterial())
+ fitted.updateWorldMatrix(true, false)
+
+ const r = geo.boundingSphere!.radius
+ const c = geo.boundingSphere!.center
+ console.log(`\nblob.body fitted: boundingRadius=${r.toFixed(6)} centre=(${c.x.toFixed(6)}, ${c.y.toFixed(6)}, ${c.z.toFixed(6)})`)
+ if (Math.abs(r - 0.5) > 1e-5) { console.log(' ! radius is not 0.5 — puddle belly contact would drift'); failures++ }
+ if (c.length() > 1e-5) { console.log(' ! not centred on the origin — PaintSkin raycasts would miss'); failures++ }
+
+ // PaintSkin.uvAtWorldPoint, reproduced: cast from outside, inward through the
+ // centre, and take the UV of the first hit.
+ const raycaster = new THREE.Raycaster()
+ let hits = 0
+ const dirs = [
+ new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0),
+ new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0),
+ new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, -1),
+ new THREE.Vector3(1, 1, 1).normalize(),
+ ]
+ for (const dir of dirs) {
+ const origin = dir.clone().multiplyScalar(r * 2.2)
+ raycaster.set(origin, dir.clone().negate())
+ raycaster.near = 0
+ raycaster.far = r * 4.4
+ const hit = raycaster.intersectObject(fitted, false).find((h) => h.uv)
+ if (hit?.uv) hits++
+ else console.log(` ! no UV hit casting along ${dir.toArray().map((n) => n.toFixed(2)).join(',')} — a splat there would vanish`)
+ }
+ console.log(`splat raycast: ${hits}/${dirs.length} directions returned a UV`)
+ if (hits !== dirs.length) failures++
+}
+
+console.log(failures === 0 ? '\nfarm-assets.check: OK' : `\nfarm-assets.check: ${failures} FAILURE(S)`)
+if (failures > 0) process.exitCode = 1
diff --git a/scripts/stage-farm-assets.sh b/scripts/stage-farm-assets.sh
new file mode 100755
index 0000000..9f87ef5
--- /dev/null
+++ b/scripts/stage-farm-assets.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Copy the four farm GLBs into the served asset directory so the lane-i demo
+# (and any manifest that names them) can actually fetch them.
+#
+# They are NOT committed there: the four total ~43 MB of raw farm output with
+# embedded textures, and deploy.sh rsyncs + docker-cps the whole dist/ on every
+# deploy. Compress them offline before shipping a pack to production.
+set -euo pipefail
+root="$(cd "$(dirname "$0")/.." && pwd)"
+mkdir -p "$root/public/assets/meshes"
+cp "$root"/assets/meshes/*.glb "$root/public/assets/meshes/"
+du -sh "$root/public/assets/meshes"
+echo "staged -> public/assets/meshes/ (gitignored; served at \${BASE_URL}assets/meshes/)"
diff --git a/scripts/ts-resolve.mjs b/scripts/ts-resolve.mjs
new file mode 100644
index 0000000..0cdc266
--- /dev/null
+++ b/scripts/ts-resolve.mjs
@@ -0,0 +1,25 @@
+/**
+ * Node resolve hook so the repo's extensionless TS imports (`./manifest`) work
+ * under `node --experimental-strip-types`, which otherwise demands a real file
+ * extension. Vite resolves these at build time; node does not.
+ *
+ * node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts
+ */
+import { registerHooks } from 'node:module'
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+registerHooks({
+ resolve(specifier, context, next) {
+ try {
+ return next(specifier, context)
+ } catch (err) {
+ if (!specifier.startsWith('.') || !context.parentURL) throw err
+ for (const ext of ['.ts', '/index.ts']) {
+ const url = new URL(specifier + ext, context.parentURL)
+ if (existsSync(fileURLToPath(url))) return next(specifier + ext, context)
+ }
+ throw err
+ }
+ },
+})
diff --git a/src/assets/blobBody.ts b/src/assets/blobBody.ts
new file mode 100644
index 0000000..ce048b2
--- /dev/null
+++ b/src/assets/blobBody.ts
@@ -0,0 +1,124 @@
+/**
+ * Lane I — the `blob.body` slot, which is the only slot that is not cosmetic.
+ *
+ * PaintSkin stamps into a canvas through this mesh's UVs and derives
+ * `boundingRadius` from its geometry — and that radius is read back as a
+ * GAMEPLAY number (puddles.ts computes the belly contact point from it). The
+ * frozen game.ts also captures `blob.mesh` by reference the instant createBlob
+ * returns. So this slot is resolved SYNCHRONOUSLY from the preloaded cache, is
+ * always a single leaf Mesh with a single MeshStandardMaterial, and its
+ * geometry is re-centred and re-scaled to the procedural radius. Anything that
+ * fails those tests keeps the procedural sphere and says why, out loud.
+ */
+import * as THREE from 'three'
+import type { System, World } from '../contracts'
+import { assets, normalizeToRadius, paintableInfo } from './registry'
+
+/**
+ * Returns the paintable body mesh for the blob: the swapped-in GLB mesh when
+ * `blob.body` is filled AND paint-safe, otherwise the caller's own sphere.
+ */
+export function resolveBlobBody(
+ world: World,
+ radius: number,
+ buildFallback: () => THREE.Mesh,
+): THREE.Mesh {
+ const reg = assets()
+ const inst = reg.instanceSync('blob.body')
+ if (!inst) {
+ if (reg.has('blob.body')) {
+ console.warn(
+ '[assets] blob.body was not preloaded in time — keeping the built-in blob. ' +
+ 'Call `await initAssets()` before installGame().',
+ )
+ }
+ return buildFallback()
+ }
+
+ const info = paintableInfo(inst.object)
+ if (!info.ok) {
+ console.warn(
+ '[assets] blob.body cannot be painted, so the built-in blob is being used instead:\n - ' +
+ info.problems.join('\n - '),
+ )
+ return buildFallback()
+ }
+ if (info.problems.length) {
+ console.warn('[assets] blob.body loaded with warnings:\n - ' + info.problems.join('\n - '))
+ }
+
+ let found: THREE.Mesh | null = null
+ inst.object.traverse((o) => {
+ if (!found && (o as THREE.Mesh).isMesh) found = o as THREE.Mesh
+ })
+ const mesh = found as THREE.Mesh | null
+ if (!mesh) return buildFallback()
+
+ // Bake the manifest's offset/rotation into the geometry rather than a parent
+ // node: feel.ts owns this mesh's whole local transform and would clobber it,
+ // and PaintSkin raycasts in the mesh's own space. `scale` is intentionally
+ // ignored — the body is always normalised back to the collider radius.
+ const entry = inst.entry
+ const geo = mesh.geometry.clone()
+ if (entry.offset || entry.rotationDeg) {
+ const m = new THREE.Matrix4()
+ const k = Math.PI / 180
+ const rot = entry.rotationDeg
+ ? new THREE.Euler(entry.rotationDeg[0] * k, entry.rotationDeg[1] * k, entry.rotationDeg[2] * k)
+ : new THREE.Euler()
+ const off = entry.offset ?? [0, 0, 0]
+ m.compose(new THREE.Vector3(off[0], off[1], off[2]), new THREE.Quaternion().setFromEuler(rot), new THREE.Vector3(1, 1, 1))
+ geo.applyMatrix4(m)
+ }
+ if (entry.scale !== undefined) {
+ console.warn('[assets] blob.body "scale" is ignored — the body is always fitted to the blob size.')
+ }
+ normalizeToRadius(geo, radius)
+ mesh.geometry = geo
+
+ // feel.ts writes `emissiveIntensity` for the super-glow and PaintSkin forces
+ // the colour white; an imported material usually has neither set up.
+ const mat = mesh.material as THREE.MeshStandardMaterial
+ if (!mat.emissive || mat.emissive.getHex() === 0x000000) {
+ mat.emissive = new THREE.Color('#88e0ff')
+ }
+ mat.emissiveIntensity = 0
+ mesh.castShadow = true
+ mesh.position.set(0, 0, 0)
+ mesh.rotation.set(0, 0, 0)
+ mesh.scale.set(1, 1, 1)
+
+ installIdleClip(world, mesh, inst.object, inst.animations, entry.idleClip)
+ return mesh
+}
+
+/**
+ * Idle animation runs in the FIXED step, never onFrame: render frames stop
+ * entirely in a hidden tab (see machine/telegraph.ts) and a rigged body's rest
+ * pose is what paint UVs are read against — letting it drift with wall-clock
+ * time would desync a backgrounded run from a foregrounded one.
+ */
+function installIdleClip(
+ world: World,
+ mesh: THREE.Mesh,
+ root: THREE.Object3D,
+ animations: THREE.AnimationClip[],
+ clipName: string | undefined,
+): void {
+ if (!clipName) return
+ const clip = animations.find((c) => c.name === clipName)
+ if (!clip) {
+ console.warn(
+ `[assets] blob.body has no animation named "${clipName}" ` +
+ `(found: ${animations.map((c) => c.name).join(', ') || 'none'}).`,
+ )
+ return
+ }
+ // The mesh is re-parented out of the GLB scene, so drive the mixer from the
+ // mesh itself; bone tracks still resolve through its skeleton.
+ const target = (mesh as THREE.SkinnedMesh).isSkinnedMesh ? mesh : root
+ const mixer = new THREE.AnimationMixer(target)
+ mixer.clipAction(clip).play()
+ const system: System = { update: (dt) => mixer.update(dt) }
+ world.addSystem(system)
+}
diff --git a/src/assets/idb.ts b/src/assets/idb.ts
new file mode 100644
index 0000000..0454c13
--- /dev/null
+++ b/src/assets/idb.ts
@@ -0,0 +1,123 @@
+/**
+ * Lane I — local override store (`blobbo-workshop`).
+ *
+ * Why IndexedDB and not a server: John must be able to try a custom asset on
+ * the LIVE site with zero deploys. The editor writes a manifest + the raw .glb
+ * bytes here; the game reads them at boot and they win over the shipped pack.
+ *
+ * Two stores:
+ * `manifest` — single record under key 'current', a Manifest object.
+ * `blobs` — key -> ArrayBuffer of a .glb, addressed as `idb:` urls.
+ *
+ * Every function resolves rather than rejects when IndexedDB is unavailable
+ * (private mode, old browser, node): no override is a normal state, not an error.
+ */
+import { validateManifest, type Manifest } from './manifest'
+
+export const DB_NAME = 'blobbo-workshop'
+export const DB_VERSION = 1
+export const STORE_MANIFEST = 'manifest'
+export const STORE_BLOBS = 'blobs'
+const MANIFEST_KEY = 'current'
+
+export const IDB_URL_PREFIX = 'idb:'
+
+export function isIdbUrl(url: string): boolean {
+ return url.startsWith(IDB_URL_PREFIX)
+}
+
+export function idbKeyFromUrl(url: string): string {
+ return url.slice(IDB_URL_PREFIX.length)
+}
+
+function openDb(): Promise {
+ return new Promise((resolve) => {
+ if (typeof indexedDB === 'undefined') return resolve(null)
+ let req: IDBOpenDBRequest
+ try {
+ req = indexedDB.open(DB_NAME, DB_VERSION)
+ } catch {
+ return resolve(null)
+ }
+ req.onupgradeneeded = () => {
+ const db = req.result
+ if (!db.objectStoreNames.contains(STORE_MANIFEST)) db.createObjectStore(STORE_MANIFEST)
+ if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
+ }
+ req.onsuccess = () => resolve(req.result)
+ req.onerror = () => resolve(null)
+ req.onblocked = () => resolve(null)
+ })
+}
+
+function request(store: IDBObjectStore, run: (s: IDBObjectStore) => IDBRequest): Promise {
+ return new Promise((resolve) => {
+ let req: IDBRequest
+ try {
+ req = run(store)
+ } catch {
+ return resolve(null)
+ }
+ req.onsuccess = () => resolve(req.result as T)
+ req.onerror = () => resolve(null)
+ })
+}
+
+async function withStore(
+ name: string,
+ mode: IDBTransactionMode,
+ run: (s: IDBObjectStore) => IDBRequest,
+): Promise {
+ const db = await openDb()
+ if (!db) return null
+ try {
+ const tx = db.transaction(name, mode)
+ const out = await request(tx.objectStore(name), run)
+ return out
+ } catch {
+ return null
+ } finally {
+ db.close()
+ }
+}
+
+/** The editor's saved pack, or `{}` when there is none. Never throws. */
+export async function loadOverrideManifest(): Promise {
+ const raw = await withStore(STORE_MANIFEST, 'readonly', (s) => s.get(MANIFEST_KEY))
+ if (raw === null || raw === undefined) return {}
+ const { manifest, problems } = validateManifest(raw)
+ if (problems.length) console.warn('[assets] local override manifest problems:', problems)
+ return manifest
+}
+
+export async function saveOverrideManifest(manifest: Manifest): Promise {
+ const out = await withStore(STORE_MANIFEST, 'readwrite', (s) =>
+ s.put(manifest, MANIFEST_KEY),
+ )
+ return out !== null
+}
+
+export async function clearOverrides(): Promise {
+ await withStore(STORE_MANIFEST, 'readwrite', (s) => s.clear())
+ await withStore(STORE_BLOBS, 'readwrite', (s) => s.clear())
+}
+
+export async function putBlob(key: string, data: ArrayBuffer): Promise {
+ const out = await withStore(STORE_BLOBS, 'readwrite', (s) => s.put(data, key))
+ return out !== null
+}
+
+export async function getBlob(key: string): Promise {
+ return withStore(STORE_BLOBS, 'readonly', (s) => s.get(key))
+}
+
+export async function listBlobKeys(): Promise {
+ const keys = await withStore(STORE_BLOBS, 'readonly', (s) => s.getAllKeys())
+ return (keys ?? []).map(String)
+}
+
+/** True when a local pack is active — the game shows a "custom assets" pill. */
+export async function hasOverrides(): Promise {
+ const m = await loadOverrideManifest()
+ return Object.keys(m).length > 0
+}
diff --git a/src/assets/manifest.test.ts b/src/assets/manifest.test.ts
new file mode 100644
index 0000000..bb53783
--- /dev/null
+++ b/src/assets/manifest.test.ts
@@ -0,0 +1,92 @@
+/**
+ * Unit checks for the manifest schema. No test runner / no deps — run directly:
+ * node --experimental-strip-types src/assets/manifest.test.ts
+ * (Node ≥22; on Node ≥23 the flag is unnecessary.) Same pattern as
+ * paint/coverage-math.test.ts: console + throw only, never bundled.
+ *
+ * The manifest is hand-edited by a non-programmer, so the property under test
+ * is "one bad line never costs you the whole pack".
+ */
+import { validateManifest, mergeManifests, type Manifest } from './manifest'
+import { SLOT_IDS, isSlotId, SLOT_LABELS } from './slots'
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+
+// ---- garbage in, empty manifest out (never throws) --------------------------
+for (const junk of [null, undefined, 42, 'nope', [], true]) {
+ const r = validateManifest(junk)
+ ok(Object.keys(r.manifest).length === 0, `junk input ${JSON.stringify(junk)} -> {}`)
+}
+
+// ---- the shipped empty manifest ---------------------------------------------
+ok(Object.keys(validateManifest(JSON.parse('{}')).manifest).length === 0, 'empty {} -> {}')
+ok(validateManifest({}).problems.length === 0, 'empty {} has no problems')
+
+// ---- a good entry survives intact -------------------------------------------
+{
+ const r = validateManifest({
+ 'machine.boot': {
+ url: 'assets/live/boot.glb',
+ offset: [0, 1, 0],
+ rotationDeg: [0, 90, 0],
+ scale: 2,
+ idleClip: 'idle',
+ },
+ })
+ const e = r.manifest['machine.boot']
+ ok(r.problems.length === 0, 'clean entry produces no problems')
+ ok(e?.url === 'assets/live/boot.glb', 'url kept')
+ ok(e?.offset?.[1] === 1, 'offset kept')
+ ok(e?.rotationDeg?.[1] === 90, 'rotationDeg kept')
+ ok(e?.scale === 2, 'scale kept')
+ ok(e?.idleClip === 'idle', 'idleClip kept')
+}
+
+// ---- one bad line does not cost you the pack --------------------------------
+{
+ const r = validateManifest({
+ 'machine.boot': { url: 'good.glb' },
+ 'machine.bucket': { url: 'also-good.glb', offset: 'sideways' },
+ 'not.a.slot': { url: 'x.glb' },
+ 'machine.arch': { nourl: true },
+ 'machine.fan': 'just a string',
+ })
+ ok(r.manifest['machine.boot'] !== undefined, 'good entry survives a bad neighbour')
+ ok(r.manifest['machine.bucket'] !== undefined, 'entry with a bad field still loads')
+ ok(r.manifest['machine.bucket']?.offset === undefined, 'bad offset dropped, not kept')
+ ok(r.manifest['machine.arch'] === undefined, 'entry without a url is dropped')
+ ok((r.manifest as Record)['not.a.slot'] === undefined, 'unknown slot dropped')
+ ok(r.problems.length === 4, `4 problems reported (got ${r.problems.length})`)
+ ok(r.problems.some((p) => p.slot === 'not.a.slot'), 'unknown slot is named in problems')
+}
+
+// ---- scale accepts a number or a triple, rejects zero -----------------------
+ok(validateManifest({ 'machine.fan': { url: 'a', scale: [1, 2, 3] } }).manifest['machine.fan']?.scale !== undefined, 'triple scale ok')
+ok(validateManifest({ 'machine.fan': { url: 'a', scale: 0 } }).manifest['machine.fan']?.scale === undefined, 'zero scale rejected')
+ok(validateManifest({ 'machine.fan': { url: 'a', offset: [1, 2, NaN] } }).manifest['machine.fan']?.offset === undefined, 'NaN offset rejected')
+
+// ---- merge: later source wins whole entries ---------------------------------
+{
+ const shipped: Manifest = {
+ 'machine.boot': { url: 'shipped.glb', scale: 3 },
+ 'machine.fan': { url: 'fan.glb' },
+ }
+ const local: Manifest = { 'machine.boot': { url: 'local.glb' } }
+ const m = mergeManifests(shipped, local)
+ ok(m['machine.boot']?.url === 'local.glb', 'local override wins')
+ ok(m['machine.boot']?.scale === undefined, 'override replaces the whole entry, not per-field')
+ ok(m['machine.fan']?.url === 'fan.glb', 'untouched shipped entry survives the merge')
+ ok(shipped['machine.boot']?.url === 'shipped.glb', 'merge does not mutate its inputs')
+}
+
+// ---- slot table is coherent --------------------------------------------------
+ok(new Set(SLOT_IDS).size === SLOT_IDS.length, 'no duplicate slot ids')
+ok(SLOT_IDS.every((s) => typeof SLOT_LABELS[s] === 'string'), 'every slot has a plain-language label')
+ok(SLOT_IDS.every(isSlotId), 'every slot id passes isSlotId')
+ok(!isSlotId('blob.bodyy') && !isSlotId(''), 'isSlotId rejects near-misses')
+
+console.log(`manifest.test.ts: ${passed} checks passed`)
diff --git a/src/assets/manifest.ts b/src/assets/manifest.ts
new file mode 100644
index 0000000..96310cd
--- /dev/null
+++ b/src/assets/manifest.ts
@@ -0,0 +1,139 @@
+/**
+ * Lane I — manifest schema, validation and load order.
+ *
+ * The manifest is the whole "no code changes" promise: a JSON map of slot id ->
+ * where the GLB is and how to sit it in place. It is hand-editable by a
+ * non-programmer, so validation is forgiving by design — a broken ENTRY is
+ * dropped with a warning and the rest of the pack still loads; only a manifest
+ * that isn't an object at all collapses to `{}`.
+ *
+ * This module deliberately imports neither `three` nor anything DOM-only at the
+ * top level, so `validateManifest`/`mergeManifests` can be node-tested.
+ */
+import { isSlotId, type SlotId } from './slots'
+
+export interface SlotEntry {
+ /** GLB url. `idb:` resolves to a blob stored by the editor. */
+ url: string
+ /** Local offset in metres, applied before rotation/scale. */
+ offset?: [number, number, number]
+ /** Local rotation in DEGREES (artist units), XYZ order. */
+ rotationDeg?: [number, number, number]
+ /** Uniform number or per-axis. Ignored for `blob.body` (auto-fit). */
+ scale?: number | [number, number, number]
+ /** Name of an animation clip to loop (SkinnedMesh assets). */
+ idleClip?: string
+}
+
+export type Manifest = Partial>
+
+/** Reported by the validator so the editor can show what it threw away. */
+export interface ManifestProblem {
+ slot: string
+ reason: string
+}
+
+export interface ValidationResult {
+ manifest: Manifest
+ problems: ManifestProblem[]
+}
+
+function isTriple(v: unknown): v is [number, number, number] {
+ return (
+ Array.isArray(v) && v.length === 3 &&
+ v.every((n) => typeof n === 'number' && Number.isFinite(n))
+ )
+}
+
+function isScale(v: unknown): v is number | [number, number, number] {
+ if (typeof v === 'number') return Number.isFinite(v) && v !== 0
+ return isTriple(v)
+}
+
+/**
+ * Pure. Never throws — a non-object input yields an empty manifest, a bad entry
+ * is skipped. `problems` is advisory (editor surface + one console.warn).
+ */
+export function validateManifest(raw: unknown): ValidationResult {
+ const problems: ManifestProblem[] = []
+ const manifest: Manifest = {}
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
+ if (raw !== undefined) problems.push({ slot: '(root)', reason: 'not a JSON object' })
+ return { manifest, problems }
+ }
+
+ for (const [key, value] of Object.entries(raw as Record)) {
+ if (!isSlotId(key)) {
+ problems.push({ slot: key, reason: 'unknown slot id' })
+ continue
+ }
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
+ problems.push({ slot: key, reason: 'entry is not an object' })
+ continue
+ }
+ const v = value as Record
+ if (typeof v.url !== 'string' || v.url.length === 0) {
+ problems.push({ slot: key, reason: 'missing "url"' })
+ continue
+ }
+ const entry: SlotEntry = { url: v.url }
+ if (v.offset !== undefined) {
+ if (isTriple(v.offset)) entry.offset = v.offset
+ else problems.push({ slot: key, reason: 'offset must be [x,y,z] numbers — ignored' })
+ }
+ if (v.rotationDeg !== undefined) {
+ if (isTriple(v.rotationDeg)) entry.rotationDeg = v.rotationDeg
+ else problems.push({ slot: key, reason: 'rotationDeg must be [x,y,z] numbers — ignored' })
+ }
+ if (v.scale !== undefined) {
+ if (isScale(v.scale)) entry.scale = v.scale
+ else problems.push({ slot: key, reason: 'scale must be a non-zero number or [x,y,z] — ignored' })
+ }
+ if (v.idleClip !== undefined) {
+ if (typeof v.idleClip === 'string') entry.idleClip = v.idleClip
+ else problems.push({ slot: key, reason: 'idleClip must be a string — ignored' })
+ }
+ manifest[key] = entry
+ }
+ return { manifest, problems }
+}
+
+/** Later sources win, per whole entry (not per field). Pure. */
+export function mergeManifests(...sources: Manifest[]): Manifest {
+ const out: Manifest = {}
+ for (const src of sources) {
+ for (const [k, v] of Object.entries(src) as [SlotId, SlotEntry][]) {
+ if (v) out[k] = v
+ }
+ }
+ return out
+}
+
+/**
+ * The one correct way to address a shipped asset in this repo. `/assets/...`
+ * drops the `/blobbo/` deploy prefix and a bare `assets/...` resolves against
+ * the PAGE directory, which breaks on the demo pages (one level down).
+ */
+export function assetUrl(relative: string): string {
+ const base = import.meta.env.BASE_URL || '/'
+ return base + relative.replace(/^\/+/, '')
+}
+
+export const MANIFEST_URL_PATH = 'assets/manifest.json'
+
+/** Fetch the shipped pack. A 404 is a normal, expected outcome (no pack). */
+export async function fetchShippedManifest(): Promise {
+ let raw: unknown
+ try {
+ const res = await fetch(assetUrl(MANIFEST_URL_PATH), { cache: 'no-cache' })
+ if (!res.ok) return {}
+ raw = await res.json()
+ } catch {
+ return {}
+ }
+ const { manifest, problems } = validateManifest(raw)
+ if (problems.length) {
+ console.warn('[assets] manifest.json problems:', problems)
+ }
+ return manifest
+}
diff --git a/src/assets/registry.test.ts b/src/assets/registry.test.ts
new file mode 100644
index 0000000..4ed6b4b
--- /dev/null
+++ b/src/assets/registry.test.ts
@@ -0,0 +1,133 @@
+/**
+ * Headless checks for the asset runtime's geometry/material logic. Run:
+ * node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts
+ * No renderer is created, so this runs without WebGL — three's BufferGeometry
+ * and material classes are pure data.
+ *
+ * The load-bearing test here is EMPTY-MANIFEST PARITY: with no manifest, the
+ * blob hook must hand back the caller's own mesh object, not a rebuild of it.
+ */
+import * as THREE from 'three'
+import type { World } from '../contracts'
+import { AssetRegistry, setAssets, paintableInfo, normalizeToRadius, makeFitNode, ensureStandardMaterials } from './registry'
+import { resolveBlobBody } from './blobBody'
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+function near(a: number, b: number, msg: string, eps = 1e-6): void {
+ ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
+}
+
+// ---- EMPTY-MANIFEST PARITY --------------------------------------------------
+{
+ setAssets(new AssetRegistry({}))
+ const systems: unknown[] = []
+ const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
+
+ const built = new THREE.Mesh(
+ new THREE.SphereGeometry(0.5, 48, 36),
+ new THREE.MeshStandardMaterial({ color: '#F5F5F7' }),
+ )
+ const out = resolveBlobBody(fakeWorld, 0.5, () => built)
+ ok(out === built, 'empty manifest returns the caller\'s own mesh object (identity, not a rebuild)')
+ ok(out.geometry === built.geometry, 'geometry untouched')
+ ok(out.material === built.material, 'material untouched')
+ ok(systems.length === 0, 'no system registered when no asset is in play')
+
+ // and the slot helpers are pass-throughs too
+ const fb = new THREE.Object3D()
+ ok(new AssetRegistry({}).slotObject('machine.boot', () => fb) === fb,
+ 'slotObject with an empty manifest returns the fallback itself (no wrapper Group)')
+
+ const host = new THREE.Object3D()
+ new AssetRegistry({}).attachSlot('machine.boot', host)
+ ok(host.children.length === 0, 'attachSlot with an empty manifest adds nothing')
+}
+
+// ---- paintableInfo ----------------------------------------------------------
+{
+ const good = new THREE.Mesh(new THREE.SphereGeometry(0.5, 24, 18), new THREE.MeshStandardMaterial())
+ const info = paintableInfo(good)
+ ok(info.ok, 'a plain UV sphere is paintable')
+ // three offsets pole UVs slightly outside 0..1; the stock blob must pass.
+ ok(info.uvOk, 'the stock blob sphere passes the UV test despite its pole offset')
+ ok(info.materialCount === 1, 'one material')
+ ok(info.meshCount === 1, 'one mesh')
+ ok(info.triCount > 0, `tri count reported (${info.triCount})`)
+ ok(info.problems.length === 0, 'no problems reported')
+}
+{
+ const m = new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 6), [
+ new THREE.MeshStandardMaterial(), new THREE.MeshStandardMaterial(),
+ ])
+ const info = paintableInfo(m)
+ ok(!info.ok, 'a two-material mesh is rejected (paint would be silently invisible)')
+ ok(info.materialCount === 2, 'material count reported')
+ ok(info.problems.some((p) => p.includes('materials')), 'the reason names the materials')
+}
+{
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(9), 3))
+ const info = paintableInfo(new THREE.Mesh(geo, new THREE.MeshStandardMaterial()))
+ ok(!info.ok && !info.uvOk, 'a mesh with no UVs is rejected')
+ ok(info.problems.some((p) => p.includes('UV')), 'the reason names UVs')
+}
+{
+ const geo = new THREE.PlaneGeometry(1, 1)
+ const uv = geo.getAttribute('uv') as THREE.BufferAttribute
+ ;(uv.array as Float32Array)[0] = 3.5 // an atlas/tiled layout
+ const info = paintableInfo(new THREE.Mesh(geo, new THREE.MeshStandardMaterial()))
+ ok(!info.uvOk, 'UVs outside 0..1 are rejected')
+}
+{
+ const info = paintableInfo(new THREE.Group())
+ ok(!info.ok && info.meshCount === 0, 'an empty file is rejected without throwing')
+}
+
+// ---- normalizeToRadius ------------------------------------------------------
+{
+ // A model authored 7x too big with its origin at the feet.
+ const geo = new THREE.SphereGeometry(3.5, 16, 12)
+ geo.translate(0, 3.5, 0)
+ normalizeToRadius(geo, 0.5)
+ geo.computeBoundingSphere()
+ const bs = geo.boundingSphere!
+ near(bs.radius, 0.5, 'normalised to the collider radius')
+ near(bs.center.length(), 0, 'recentred on the origin', 1e-5)
+}
+{
+ const degenerate = new THREE.BufferGeometry()
+ degenerate.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3))
+ normalizeToRadius(degenerate, 0.5) // must not divide by zero / throw
+ ok(true, 'a zero-size geometry does not crash the normaliser')
+}
+
+// ---- makeFitNode ------------------------------------------------------------
+{
+ const fit = makeFitNode({ url: 'x', offset: [1, 2, 3], rotationDeg: [0, 90, 0], scale: 2 })
+ ok(fit.position.x === 1 && fit.position.z === 3, 'offset applied')
+ near(fit.rotation.y, Math.PI / 2, 'degrees converted to radians')
+ ok(fit.scale.x === 2 && fit.scale.y === 2, 'uniform scale applied')
+ const fit2 = makeFitNode({ url: 'x', scale: [1, 2, 3] }, new THREE.Vector3(2, 2, 2))
+ ok(fit2.scale.x === 2 && fit2.scale.y === 4 && fit2.scale.z === 6, 'per-axis scale times the caller fit')
+ const id = makeFitNode({ url: 'x' })
+ ok(id.position.length() === 0 && id.scale.x === 1, 'an entry with no transform is identity')
+}
+
+// ---- ensureStandardMaterials ------------------------------------------------
+{
+ // telegraph.ts only flashes MeshStandardMaterial; anything else silently
+ // breaks the mandatory danger telegraph, so imports are converted.
+ const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: '#ff0000' }))
+ const n = ensureStandardMaterials(m)
+ ok(n === 1, 'one material converted')
+ const mat = m.material as unknown as THREE.MeshStandardMaterial
+ ok(mat.isMeshStandardMaterial === true, 'basic material became standard')
+ ok(mat.color.getHexString() === 'ff0000', 'colour preserved through the conversion')
+ ok(ensureStandardMaterials(m) === 0, 'an already-standard material is left alone')
+}
+
+console.log(`registry.test.ts: ${passed} checks passed`)
diff --git a/src/assets/registry.ts b/src/assets/registry.ts
new file mode 100644
index 0000000..ceefe3d
--- /dev/null
+++ b/src/assets/registry.ts
@@ -0,0 +1,375 @@
+/**
+ * Lane I — the asset registry.
+ *
+ * Every construction site in the game asks the registry for its slot instead of
+ * building a mesh directly. With an empty manifest the registry hands straight
+ * back the caller's own fallback builder, untouched — that is what makes
+ * "empty manifest == today's game" true by construction rather than by
+ * re-implementation.
+ *
+ * Two shapes:
+ * `attachSlot(slot, host, opts)` — decorate an object that already exists and
+ * whose transform someone else owns (greybox boxes, the see-saw plank).
+ * `slotObject(slot, buildFallback)` — return a holder Group containing the
+ * fallback now, swapped for the GLB when it arrives.
+ *
+ * The holder/host is never given the manifest transform: telegraph.ts writes
+ * `root.position`/`root.scale` absolutely every tick and feel.ts owns the blob
+ * mesh's entire local transform. The manifest offset/rotation/scale therefore
+ * always lands on a private "fit" node the registry inserts underneath.
+ */
+import * as THREE from 'three'
+import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { clone as skeletonClone } from 'three/examples/jsm/utils/SkeletonUtils.js'
+import type { Manifest, SlotEntry } from './manifest'
+import { fetchShippedManifest, mergeManifests } from './manifest'
+import { loadOverrideManifest, isIdbUrl, idbKeyFromUrl, getBlob } from './idb'
+import type { SlotId } from './slots'
+
+export interface PaintableInfo {
+ /** A single Mesh with a single material and 0..1 UVs was found. */
+ ok: boolean
+ uvOk: boolean
+ triCount: number
+ materialCount: number
+ meshCount: number
+ skinned: boolean
+ /** Plain-language reasons the asset is not paint-safe (editor surface). */
+ problems: string[]
+}
+
+interface LoadedAsset {
+ scene: THREE.Group
+ animations: THREE.AnimationClip[]
+}
+
+export interface AttachOptions {
+ /** Called once when the GLB has arrived and been parented under `host`. */
+ onSwap?: (asset: THREE.Object3D) => void
+ /** Extra scale multiplied into the fit node (e.g. a puddle's slab size). */
+ fit?: THREE.Vector3
+}
+
+const loader = new GLTFLoader()
+
+/** How far outside 0..1 a UV may stray before the layout is judged unpaintable. */
+const UV_MARGIN = 0.1
+
+function degToRadTriple(d: [number, number, number]): THREE.Euler {
+ const k = Math.PI / 180
+ return new THREE.Euler(d[0] * k, d[1] * k, d[2] * k)
+}
+
+/** Applies a manifest entry's offset/rotation/scale to a fresh parent node. */
+export function makeFitNode(entry: SlotEntry, extra?: THREE.Vector3): THREE.Group {
+ const fit = new THREE.Group()
+ fit.name = 'slot-fit'
+ if (entry.offset) fit.position.set(entry.offset[0], entry.offset[1], entry.offset[2])
+ if (entry.rotationDeg) fit.rotation.copy(degToRadTriple(entry.rotationDeg))
+ if (typeof entry.scale === 'number') fit.scale.setScalar(entry.scale)
+ else if (entry.scale) fit.scale.set(entry.scale[0], entry.scale[1], entry.scale[2])
+ if (extra) fit.scale.multiply(extra)
+ return fit
+}
+
+/** Shadows are per-mesh flags; a raw GLB import has them all off. */
+export function enableShadows(root: THREE.Object3D): void {
+ root.traverse((o) => {
+ if ((o as THREE.Mesh).isMesh) {
+ o.castShadow = true
+ o.receiveShadow = true
+ }
+ })
+}
+
+/**
+ * telegraph.ts only collects emissives from `isMeshStandardMaterial`, and
+ * feel.ts writes `emissiveIntensity` unconditionally — a Basic/Lambert/Phong
+ * material would silently kill the danger flash and the super-glow. Convert
+ * rather than reject so an artist's export still works.
+ */
+export function ensureStandardMaterials(root: THREE.Object3D): number {
+ let converted = 0
+ root.traverse((o) => {
+ const mesh = o as THREE.Mesh
+ if (!mesh.isMesh) return
+ const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
+ const out = mats.map((m) => {
+ const std = m as THREE.MeshStandardMaterial
+ if (std.isMeshStandardMaterial) return m
+ const src = m as THREE.MeshBasicMaterial
+ converted++
+ return new THREE.MeshStandardMaterial({
+ color: src.color ? src.color.clone() : new THREE.Color('#ffffff'),
+ map: src.map ?? null,
+ transparent: src.transparent,
+ opacity: src.opacity,
+ side: src.side,
+ })
+ })
+ mesh.material = Array.isArray(mesh.material) ? out : out[0]
+ })
+ return converted
+}
+
+/**
+ * Pure-ish geometry inspection. Reports whether an object can carry PaintSkin:
+ * one mesh, one material, a `uv` attribute inside 0..1 (the skin's stamp wrap
+ * assumes an equirectangular layout, so out-of-range or missing UVs mean splats
+ * land somewhere unrelated — or nowhere).
+ */
+export function paintableInfo(object: THREE.Object3D): PaintableInfo {
+ const meshes: THREE.Mesh[] = []
+ object.traverse((o) => {
+ if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh)
+ })
+ const problems: string[] = []
+ if (meshes.length === 0) {
+ return {
+ ok: false, uvOk: false, triCount: 0, materialCount: 0, meshCount: 0,
+ skinned: false, problems: ['no mesh in the file'],
+ }
+ }
+ if (meshes.length > 1) problems.push(`${meshes.length} meshes — only the first is painted`)
+
+ const mesh = meshes[0]
+ const skinned = (mesh as THREE.SkinnedMesh).isSkinnedMesh === true
+ const materialCount = Array.isArray(mesh.material) ? mesh.material.length : 1
+ if (materialCount > 1) {
+ problems.push(`${materialCount} materials — paint needs exactly one (splats would be invisible)`)
+ }
+
+ const geo = mesh.geometry
+ const pos = geo.getAttribute('position')
+ const index = geo.getIndex()
+ const triCount = index ? index.count / 3 : (pos ? pos.count / 3 : 0)
+
+ const uv = geo.getAttribute('uv')
+ let uvOk = false
+ if (!uv) {
+ problems.push('no UV map — the paint has nowhere to go')
+ } else {
+ let min = Infinity
+ let max = -Infinity
+ for (let i = 0; i < uv.count * uv.itemSize; i++) {
+ const v = uv.array[i] as number
+ if (!Number.isFinite(v)) { min = NaN; break }
+ if (v < min) min = v
+ if (v > max) max = v
+ }
+ // Tolerance, not equality: three's own SphereGeometry — the mesh this game
+ // ships — pushes pole UVs to ±0.5/widthSegments outside 0..1, so a strict
+ // test would reject the stock blob. What actually breaks paint is an ATLAS
+ // or tiled layout (u running 0..4), which is far outside this margin.
+ uvOk = Number.isFinite(min) && min >= -UV_MARGIN && max <= 1 + UV_MARGIN
+ if (!uvOk) {
+ problems.push(`UVs run ${Number.isFinite(min) ? min.toFixed(2) : '?'}..${Number.isFinite(max) ? max.toFixed(2) : '?'} — they must stay inside 0..1 (one unwrapped island, no tiling)`)
+ }
+ }
+ if (skinned) problems.push('rigged mesh — paint is placed using the rest pose')
+
+ return {
+ ok: uvOk && materialCount === 1,
+ uvOk, triCount: Math.round(triCount), materialCount,
+ meshCount: meshes.length, skinned, problems,
+ }
+}
+
+/**
+ * Re-centres and re-sizes geometry so its bounding sphere is exactly
+ * `radius` around the origin. PaintSkin derives `boundingRadius` from this and
+ * that number is GAMEPLAY (puddles.ts computes belly contact as
+ * `y - boundingRadius * size`), so a custom body must not be allowed to move it.
+ */
+export function normalizeToRadius(geo: THREE.BufferGeometry, radius: number): void {
+ geo.computeBoundingSphere()
+ const bs = geo.boundingSphere
+ if (!bs || bs.radius <= 1e-6) return
+ geo.translate(-bs.center.x, -bs.center.y, -bs.center.z)
+ geo.scale(radius / bs.radius, radius / bs.radius, radius / bs.radius)
+ geo.computeBoundingSphere()
+ geo.computeVertexNormals()
+}
+
+export class AssetRegistry {
+ readonly manifest: Manifest
+ private readonly cache = new Map>()
+ private readonly resolved = new Map()
+ private readonly objectUrls = new Map()
+ private readonly warned = new Set()
+
+ constructor(manifest: Manifest = {}) {
+ this.manifest = manifest
+ }
+
+ /** Shipped pack, then the editor's local override (later wins). */
+ static async create(): Promise {
+ const [shipped, local] = await Promise.all([
+ fetchShippedManifest(),
+ loadOverrideManifest(),
+ ])
+ const reg = new AssetRegistry(mergeManifests(shipped, local))
+ await reg.preload()
+ return reg
+ }
+
+ entry(slot: SlotId): SlotEntry | undefined {
+ return this.manifest[slot]
+ }
+
+ has(slot: SlotId): boolean {
+ return this.manifest[slot] !== undefined
+ }
+
+ /**
+ * Resolve every url the manifest mentions. Integration must await this before
+ * building the game: `blob.mesh`'s identity is captured by reference in the
+ * frozen game.ts, so the body asset cannot arrive after createBlob returns.
+ */
+ async preload(): Promise {
+ const urls = new Set(Object.values(this.manifest).map((e) => e.url))
+ await Promise.all([...urls].map((u) => this.load(u).catch(() => null)))
+ }
+
+ private warnOnce(key: string, ...args: unknown[]): void {
+ if (this.warned.has(key)) return
+ this.warned.add(key)
+ console.warn(...args)
+ }
+
+ private async resolveUrl(url: string): Promise {
+ if (!isIdbUrl(url)) return url
+ const cached = this.objectUrls.get(url)
+ if (cached) return cached
+ const buf = await getBlob(idbKeyFromUrl(url))
+ if (!buf) throw new Error(`no local asset stored under "${idbKeyFromUrl(url)}"`)
+ const objUrl = URL.createObjectURL(new Blob([buf], { type: 'model/gltf-binary' }))
+ this.objectUrls.set(url, objUrl)
+ return objUrl
+ }
+
+ private load(url: string): Promise {
+ const hit = this.cache.get(url)
+ if (hit) return hit
+ const p = (async () => {
+ const real = await this.resolveUrl(url)
+ const gltf: GLTF = await loader.loadAsync(real)
+ const asset: LoadedAsset = { scene: gltf.scene, animations: gltf.animations }
+ this.resolved.set(url, asset)
+ return asset
+ })()
+ p.catch((err) => {
+ this.warnOnce(`load:${url}`, `[assets] could not load "${url}" — keeping the built-in version.`, err)
+ })
+ this.cache.set(url, p)
+ return p
+ }
+
+ /** A cloned, shadow-enabled, standard-material instance of a loaded url. */
+ private instantiate(asset: LoadedAsset): THREE.Group {
+ const copy = skeletonClone(asset.scene) as THREE.Group
+ // SkeletonUtils.clone shares materials with the source. Instances get tinted
+ // per-slot (puddle colour, ghost translucency), so each needs its own.
+ copy.traverse((o) => {
+ const mesh = o as THREE.Mesh
+ if (!mesh.isMesh) return
+ mesh.material = Array.isArray(mesh.material)
+ ? mesh.material.map((m) => m.clone())
+ : mesh.material.clone()
+ })
+ ensureStandardMaterials(copy)
+ enableShadows(copy)
+ return copy
+ }
+
+ /** Already-preloaded instance, or null. Sync — safe inside createBlob. */
+ instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null {
+ const entry = this.manifest[slot]
+ if (!entry) return null
+ const asset = this.resolved.get(entry.url)
+ if (!asset) return null
+ return { object: this.instantiate(asset), entry, animations: asset.animations }
+ }
+
+ /**
+ * Parent the slot's asset under an object that already exists. The host's own
+ * transform is never touched — only a private fit node is added.
+ */
+ attachSlot(slot: SlotId, host: THREE.Object3D, opts: AttachOptions = {}): void {
+ const entry = this.manifest[slot]
+ if (!entry) return
+ void this.load(entry.url).then(
+ (asset) => {
+ const fit = makeFitNode(entry, opts.fit)
+ fit.add(this.instantiate(asset))
+ host.add(fit)
+ opts.onSwap?.(fit)
+ },
+ () => { /* warned in load(); fallback stays */ },
+ )
+ }
+
+ /**
+ * Synchronous placeholder: returns a holder Group containing `buildFallback()`
+ * right away. If the slot is filled, the fallback is removed and the GLB takes
+ * its place as soon as it loads (immediately, if preload already ran).
+ */
+ slotObject(slot: SlotId, buildFallback: () => THREE.Object3D): THREE.Object3D {
+ const fallback = buildFallback()
+ const entry = this.manifest[slot]
+ if (!entry) return fallback
+
+ const holder = new THREE.Group()
+ holder.name = `slot:${slot}`
+ holder.add(fallback)
+ this.attachSlot(slot, holder, {
+ onSwap: () => { holder.remove(fallback) },
+ })
+ return holder
+ }
+
+ /** Editor/demo surface: how paint-safe is whatever is in `blob.body`? */
+ paintability(slot: SlotId = 'blob.body'): PaintableInfo | null {
+ const inst = this.instanceSync(slot)
+ return inst ? paintableInfo(inst.object) : null
+ }
+
+ dispose(): void {
+ for (const url of this.objectUrls.values()) URL.revokeObjectURL(url)
+ this.objectUrls.clear()
+ }
+}
+
+// ---- module singleton ------------------------------------------------------
+// Construction sites live in files owned by other lanes and are called from the
+// frozen game.ts, so their signatures cannot grow a `registry` parameter. A
+// singleton set once at boot is the only wiring that needs no frozen edit
+// beyond a single `await initAssets()` in main.ts.
+
+let current = new AssetRegistry({})
+let booting: Promise | null = null
+
+/** The active registry. Always non-null — an empty one until initAssets runs. */
+export function assets(): AssetRegistry {
+ return current
+}
+
+/** Await before building the game. Idempotent. */
+export function initAssets(): Promise {
+ if (!booting) {
+ booting = AssetRegistry.create().then(
+ (reg) => { current = reg; return reg },
+ (err) => {
+ console.warn('[assets] registry failed to start — using built-in assets only.', err)
+ return current
+ },
+ )
+ }
+ return booting
+}
+
+/** Tests/demos: install a hand-built registry without touching the network. */
+export function setAssets(reg: AssetRegistry): void {
+ current = reg
+ booting = Promise.resolve(reg)
+}
diff --git a/src/assets/slots.ts b/src/assets/slots.ts
new file mode 100644
index 0000000..0f7e984
--- /dev/null
+++ b/src/assets/slots.ts
@@ -0,0 +1,72 @@
+/**
+ * Lane I — the slot inventory.
+ *
+ * A "slot" is a named place in the scene where a custom GLB may replace the
+ * procedural mesh the game builds today. Slot ids are a closed set on purpose:
+ * the editor (lane J) lists them, the manifest keys on them, and an unknown key
+ * in a hand-edited manifest should be reported rather than silently ignored.
+ *
+ * Kept free of `three` imports so the validator can be node-tested headlessly.
+ */
+
+export const SLOT_IDS = [
+ 'blob.body',
+ 'blob.face',
+ 'ghost.body',
+ 'cannon.barrel',
+ 'machine.plate',
+ 'machine.boot',
+ 'machine.bucket',
+ 'machine.arch',
+ 'machine.belt',
+ 'machine.fan',
+ 'machine.seesaw',
+ 'course.scenery.cereal',
+ 'course.scenery.block',
+ 'fx.puddle',
+] as const
+
+export type SlotId = (typeof SLOT_IDS)[number]
+
+const SLOT_SET: ReadonlySet = new Set(SLOT_IDS)
+
+export function isSlotId(v: unknown): v is SlotId {
+ return typeof v === 'string' && SLOT_SET.has(v)
+}
+
+/** Plain-language labels for the editor UI (lane J reads this). */
+export const SLOT_LABELS: Record = {
+ 'blob.body': 'Blobbo body (paintable)',
+ 'blob.face': 'Blobbo eyes',
+ 'ghost.body': 'Ghost racer',
+ 'cannon.barrel': 'Paint cannon',
+ 'machine.plate': 'Pressure plate',
+ 'machine.boot': 'Spring boot',
+ 'machine.bucket': 'Paint bucket',
+ 'machine.arch': 'Bubble arch',
+ 'machine.belt': 'Conveyor belt',
+ 'machine.fan': 'Fan',
+ 'machine.seesaw': 'See-saw plank',
+ 'course.scenery.cereal': 'Giant cereal box',
+ 'course.scenery.block': 'Purple block',
+ 'fx.puddle': 'Paint puddle',
+}
+
+/**
+ * Slots whose replacement is constrained beyond "looks different". Surfaced in
+ * the editor so John sees the rule at the moment he drops a file on the slot.
+ */
+export const SLOT_NOTES: Partial> = {
+ 'blob.body':
+ 'Must be ONE mesh with ONE material and clean 0..1 UVs — the paint is stamped ' +
+ 'through those UVs. Auto-resized to the 1.0u body; manifest scale is ignored here.',
+ 'ghost.body':
+ 'Copied from blob.body automatically unless you set this slot; materials are ' +
+ 'forced translucent.',
+ 'machine.boot': 'Model it standing on the floor, origin at the base — it shakes about its origin.',
+ 'machine.bucket': 'Origin at the TIPPING EDGE, pours toward +X, up axis +Y.',
+ 'machine.belt': 'Model the belt slab only; the moving chevrons stay procedural.',
+ 'machine.fan': 'Face +Z. The blades stay procedural so they keep spinning.',
+ 'machine.seesaw': 'Plank only, long axis along X, origin at the plank centre.',
+ 'machine.plate': 'Frame only — the pressed pad stays procedural so it can light up.',
+}
diff --git a/src/blob/createBlob.ts b/src/blob/createBlob.ts
index b83ff84..e7cd53d 100644
--- a/src/blob/createBlob.ts
+++ b/src/blob/createBlob.ts
@@ -11,6 +11,8 @@ import * as THREE from 'three'
import type RAPIER from '@dimforge/rapier3d-compat'
import type { Blob, World } from '../contracts'
import { BASE_WHITE, defaultModifiers } from '../contracts'
+import { assets } from '../assets/registry'
+import { resolveBlobBody } from '../assets/blobBody'
export interface CreateBlobOptions {
position?: THREE.Vector3
@@ -67,20 +69,27 @@ export function createBlob(world: World, opts: CreateBlobOptions = {}): BlobHand
// Paintable body: clean UV sphere. Default THREE sphere UVs are exactly what
// Lane B wants (equirectangular, seam at the back). Do not decorate this mesh.
- const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
- const material = new THREE.MeshStandardMaterial({
- color: BASE_WHITE,
- roughness: 0.5,
- metalness: 0.0,
- emissive: new THREE.Color('#88e0ff'),
- emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
- })
- const mesh = new THREE.Mesh(geometry, material)
- mesh.castShadow = true
+ // Slot `blob.body` may replace it, but only with a mesh that passes the same
+ // paint requirements and is refitted to `radius` (see assets/blobBody.ts).
+ const buildBodyMesh = (): THREE.Mesh => {
+ const geometry = new THREE.SphereGeometry(radius, seg, Math.round(seg * 0.75))
+ const material = new THREE.MeshStandardMaterial({
+ color: BASE_WHITE,
+ roughness: 0.5,
+ metalness: 0.0,
+ emissive: new THREE.Color('#88e0ff'),
+ emissiveIntensity: 0.0, // driven by modifiers.glow in the feel layer
+ })
+ const m = new THREE.Mesh(geometry, material)
+ m.castShadow = true
+ return m
+ }
+ const mesh = resolveBlobBody(world, radius, buildBodyMesh)
group.add(mesh)
// Cosmetic face, parented to the group (yaws to face travel, scales with size).
- const face = buildFace(radius)
+ const face = new THREE.Group()
+ face.add(assets().slotObject('blob.face', () => buildFace(radius)))
group.add(face)
// Physics proxy: dynamic ball, rotations locked so it stays an upright
diff --git a/src/course/greybox.ts b/src/course/greybox.ts
index db07e94..e0a0f1d 100644
--- a/src/course/greybox.ts
+++ b/src/course/greybox.ts
@@ -12,6 +12,8 @@
*/
import * as THREE from 'three'
import type { World } from '../contracts'
+import { assets } from '../assets/registry'
+import type { SlotId } from '../assets/slots'
export interface GreyboxHandle {
/** Recommended blob spawn point (just above the start plateau). */
@@ -69,6 +71,17 @@ export function buildGreybox(world: World): GreyboxHandle {
return mesh
}
+ /**
+ * Park a custom prop inside a greybox box. The box's material is switched off
+ * rather than the mesh itself so the prop (a child) still renders, and the
+ * mesh stays in `meshes[]` so the handle's contract is unchanged.
+ */
+ function decorate(slot: SlotId, mesh: THREE.Mesh): void {
+ assets().attachSlot(slot, mesh, {
+ onSwap: () => { (mesh.material as THREE.Material).visible = false },
+ })
+ }
+
// ---- base floor: catches everything, spans the whole course ----
addBox(0, -0.5, -15, 28, 0.5, 60, { color: '#efe3c6', roughness: 1 })
@@ -104,8 +117,11 @@ export function buildGreybox(world: World): GreyboxHandle {
addBox(0, 0.6, -64, 9, 0.6, 6, { color: '#FF6EB4', surface: 'finish', cast: true })
// ---- absurd-proportion scenery: a giant cereal box beside the start ----
- addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true })
- addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true })
+ // Slot hooks go on the MESH only, after the collider exists: the box volume
+ // is still solid whatever model is dropped in, so a custom prop can never
+ // open a hole in the course or grow an invisible wall.
+ decorate('course.scenery.cereal', addBox(-20, 7, 26, 4, 7, 3, { color: '#FFD60A', cast: true }))
+ decorate('course.scenery.block', addBox(20, 5, 12, 3, 5, 3, { color: '#AF52DE', cast: true }))
return {
spawn: new THREE.Vector3(0, 3.5, 30),
diff --git a/src/demo/lane-i.ts b/src/demo/lane-i.ts
new file mode 100644
index 0000000..68a07ef
--- /dev/null
+++ b/src/demo/lane-i.ts
@@ -0,0 +1,139 @@
+/**
+ * Lane I demo — every slot, twice.
+ *
+ * Left column is built with an EMPTY registry (today's game). Right column is
+ * built with a test manifest pointing at the four farm GLBs. Same factories,
+ * same arguments — the only difference is which registry is installed when the
+ * factory runs, which is exactly the property the asset runtime promises.
+ *
+ * The paint check is deliberately numeric rather than visual: it stamps a splat
+ * at a known point on the swapped body and prints coverage before/after, so
+ * "the paint landed" is a number you can read, not a thing you squint at.
+ *
+ * NOTE: this page is not in vite.config.ts's `rollupOptions.input` (frozen
+ * file), so it is served by `npm run dev` but is NOT emitted by `npm run build`
+ * until integration adds the entry.
+ */
+import * as THREE from 'three'
+import { createWorld } from '../world'
+import type { World } from '../contracts'
+import { PaintSkin } from '../paint/skin'
+import { AssetRegistry, setAssets, paintableInfo } from '../assets/registry'
+import { resolveBlobBody } from '../assets/blobBody'
+import { assetUrl, type Manifest } from '../assets/manifest'
+import { SLOT_LABELS, type SlotId } from '../assets/slots'
+import {
+ createPressurePlate, createSpringBoot, createBucketDump,
+ createConveyorBelt, createBubbleArch, createFan,
+} from '../machine/parts'
+
+const FARM = (name: string) => assetUrl(`assets/meshes/${name}.glb`)
+
+/**
+ * Fit transforms measured against the farm GLBs' own scale (they export large
+ * and Z-up-ish); committed here so the demo is a real fitting example, not a
+ * pile of identity transforms.
+ */
+const TEST_MANIFEST: Manifest = {
+ 'machine.boot': { url: FARM('prop-spring-boot'), scale: 1.2, offset: [0, 0, 0] },
+ 'machine.bucket': { url: FARM('prop-paint-bucket'), scale: 1.0, offset: [0, -0.6, 0] },
+ 'course.scenery.cereal': { url: FARM('prop-toaster-launcher'), scale: 3.0, rotationDeg: [0, 180, 0] },
+ 'blob.body': { url: FARM('blobbo-base') },
+}
+
+const log: string[] = []
+const panel = document.createElement('div')
+panel.style.cssText =
+ 'position:fixed;left:8px;top:8px;max-height:96vh;overflow:auto;width:400px;' +
+ 'font:12px/1.5 ui-monospace,monospace;background:#0b0b12ee;color:#dfe;' +
+ 'padding:10px 12px;border-radius:8px;white-space:pre-wrap;z-index:10'
+document.body.appendChild(panel)
+function say(line: string): void {
+ log.push(line)
+ panel.textContent = log.join('\n')
+ console.log('[lane-i] ' + line)
+}
+
+/** Builds the whole slot line-up at an X offset. Identical calls both times. */
+function buildColumn(world: World, x: number): void {
+ createPressurePlate(world, { id: `plate${x}`, position: [x, 0.2, 6], massThreshold: 1, emits: 'sig' })
+ createSpringBoot(world, { id: `boot${x}`, position: [x, 0.2, 2], impulse: 8, onSignal: 'sig' })
+ createBucketDump(world, { id: `bucket${x}`, position: [x, 4, -2], color: 'blue', radius: 1.4, onSignal: 'sig' })
+ createConveyorBelt(world, { id: `belt${x}`, position: [x, 0.3, -8], velocity: [2, 0, 0], size: [8, 0.5, 3] })
+ createBubbleArch(world, { id: `arch${x}`, position: [x, 0.2, -14], fraction: 0.5 })
+ createFan(world, { id: `fan${x}`, position: [x, 1.5, -20], force: 4, direction: [0, 0, 1] })
+}
+
+const world = await createWorld(document.getElementById('app')!)
+world.camera.position.set(0, 9, 22)
+world.camera.lookAt(0, 1, -6)
+
+// ---- LEFT: empty manifest = today's game ------------------------------------
+const emptyReg = new AssetRegistry({})
+setAssets(emptyReg)
+buildColumn(world, -9)
+const stockBody = resolveBlobBody(world, 0.5, () => makeStockSphere())
+stockBody.position.set(-9, 2, 12)
+world.scene.add(stockBody)
+say('LEFT (x=-9): empty manifest — procedural, unchanged.')
+
+// ---- RIGHT: test manifest ---------------------------------------------------
+const testReg = new AssetRegistry(TEST_MANIFEST)
+say('\nRIGHT (x=+9): loading test manifest…')
+for (const [slot, entry] of Object.entries(TEST_MANIFEST) as [SlotId, { url: string }][]) {
+ say(` ${SLOT_LABELS[slot]} <- ${entry.url.split('/').pop()}`)
+}
+await testReg.preload()
+setAssets(testReg)
+buildColumn(world, 9)
+
+const customBody = resolveBlobBody(world, 0.5, () => makeStockSphere())
+customBody.position.set(9, 2, 12)
+world.scene.add(customBody)
+
+// ---- per-slot status --------------------------------------------------------
+say('\n--- slot status ---')
+for (const slot of Object.keys(TEST_MANIFEST) as SlotId[]) {
+ const inst = testReg.instanceSync(slot)
+ say(` ${slot.padEnd(24)} ${inst ? 'LOADED' : 'FAILED -> fallback kept'}`)
+}
+
+// ---- paintability report for blob.body --------------------------------------
+say('\n--- blob.body paintability ---')
+const info = testReg.paintability('blob.body')
+if (!info) {
+ say(' no asset resolved; the built-in sphere is in use.')
+} else {
+ say(` meshes ${info.meshCount} materials ${info.materialCount} triangles ${info.triCount}`)
+ say(` UVs inside 0..1: ${info.uvOk ? 'yes' : 'NO'}${info.skinned ? ' (rigged)' : ''}`)
+ say(` paint-safe: ${info.ok ? 'YES' : 'NO'}`)
+ for (const p of info.problems) say(` ! ${p}`)
+}
+const swapped = customBody !== stockBody && customBody.geometry !== stockBody.geometry
+say(` body actually swapped: ${swapped ? 'YES' : 'no — using the built-in sphere'}`)
+say(` bounding radius: ${customBody.geometry.boundingSphere?.radius.toFixed(4)} (must be 0.5000 — it is a gameplay number)`)
+
+// ---- scripted splat: does paint land on whatever body is in use? ------------
+say('\n--- scripted splat ---')
+for (const [label, mesh] of [['stock', stockBody], ['custom', customBody]] as const) {
+ const skin = new PaintSkin(mesh, { size: 256 })
+ const before = skin.coverage().total
+ // A point on the +X face of the body, in world space.
+ const p = mesh.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0.5, 0, 0))
+ skin.splatAtPoint(p, 'red', 0.25)
+ const after = skin.coverage()
+ say(` ${label}: coverage ${before.toFixed(4)} -> ${after.total.toFixed(4)} ` +
+ `red=${after.byColor.red.toFixed(4)} ${after.total > before ? 'PAINT LANDED' : 'NOTHING LANDED'}`)
+}
+
+say('\nSlots with no entry above fall back silently — that is the empty-manifest path.')
+world.start()
+
+function makeStockSphere(): THREE.Mesh {
+ const m = new THREE.Mesh(
+ new THREE.SphereGeometry(0.5, 48, 36),
+ new THREE.MeshStandardMaterial({ color: '#F5F5F7', roughness: 0.5, emissive: new THREE.Color('#88e0ff'), emissiveIntensity: 0 }),
+ )
+ m.castShadow = true
+ return m
+}
diff --git a/src/ghost/player.ts b/src/ghost/player.ts
index 732c309..2b7d4e7 100644
--- a/src/ghost/player.ts
+++ b/src/ghost/player.ts
@@ -14,6 +14,7 @@
import * as THREE from 'three'
import type { World } from '../contracts'
import { loadGhost, sampleAt, type GhostSample, type GhostTrack } from './format'
+import { assets } from '../assets/registry'
const GHOST_OPACITY = 0.35
const BOB_AMPLITUDE = 0.06 // metres
@@ -80,6 +81,32 @@ export class GhostPlayer {
g.renderOrder = 2 // composite over the opaque scene
this.mats.push(bodyMat, eyeMat)
this.geos.push(bodyGeo, eyeGeo)
+
+ // Slot `ghost.body`, falling back to whatever is in `blob.body` so a custom
+ // blob automatically gets a matching ghost. Materials are cloned per
+ // instance by the registry, so forcing them translucent here cannot make
+ // the real blob see-through.
+ const reg = assets()
+ const slot = reg.has('ghost.body') ? 'ghost.body' : 'blob.body'
+ reg.attachSlot(slot, g, {
+ onSwap: (asset) => {
+ body.visible = false
+ asset.traverse((o) => {
+ const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
+ if (!m) return
+ m.transparent = true
+ m.opacity = GHOST_OPACITY
+ m.depthWrite = false
+ if (m.isMeshStandardMaterial) {
+ m.color.set('#eaf6ff')
+ m.emissive.set('#bfe6ff')
+ m.emissiveIntensity = 0.5
+ m.map = null // the real blob's paint canvas is not the ghost's
+ }
+ this.mats.push(m)
+ })
+ },
+ })
return g
}
diff --git a/src/machine/parts.ts b/src/machine/parts.ts
index b9f8aec..294bb9a 100644
--- a/src/machine/parts.ts
+++ b/src/machine/parts.ts
@@ -23,6 +23,20 @@ import type RAPIER from '@dimforge/rapier3d-compat'
import type { World, PaintColor } from '../contracts'
import { PALETTE } from '../contracts'
import { telegraph, isTelegraphing } from './telegraph'
+import { assets } from '../assets/registry'
+
+/**
+ * Slot hook shared by every part: park the custom model under the part's root
+ * and hide the primitives it replaces. Only the primitives listed in `replaces`
+ * go away — animated sub-parts (the plate cap, the belt chevrons, the fan
+ * blades) stay procedural so they keep moving.
+ */
+function slotPart(slot: Parameters['attachSlot']>[0],
+ root: THREE.Object3D, replaces: THREE.Object3D[]): void {
+ assets().attachSlot(slot, root, {
+ onSwap: () => { for (const o of replaces) o.visible = false },
+ })
+}
export type Vec3 = [number, number, number]
@@ -110,6 +124,8 @@ export function createPressurePlate(world: World, cfg: PressurePlateConfig): Mac
cap.receiveShadow = true
group.add(cap)
+ slotPart('machine.plate', group, [base])
+
// physics: fixed platform the blob actually rests on
const body = physics.createRigidBody(
rapier.RigidBodyDesc.fixed().setTranslation(pos.x, pos.y + cap.position.y, pos.z),
@@ -195,13 +211,17 @@ export function createSpringBoot(world: World, cfg: SpringBootConfig): MachinePa
// coil spring under the pad (telegraph target scales/shakes the whole group)
const coilMat = new THREE.MeshStandardMaterial({ color: '#7f8c8d', metalness: 0.6, roughness: 0.3 })
+ const coils: THREE.Mesh[] = []
for (let i = 0; i < 3; i++) {
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.7, 0.09, 8, 20), coilMat)
ring.rotation.x = Math.PI / 2
ring.position.y = -0.15 - i * 0.22
group.add(ring)
+ coils.push(ring)
}
+ slotPart('machine.boot', group, [pad, ...coils])
+
// physics pad so the ball can rest here between signal and kick
if (cfg.pad !== false) {
const body = physics.createRigidBody(
@@ -305,6 +325,13 @@ export function createSeeSaw(world: World, cfg: SeeSawConfig): MachinePart {
plank.receiveShadow = true
group.add(plank)
+ // The plank's world pose is copied off the rigid body every frame, so the
+ // custom model rides as its CHILD and the primitive is hidden by turning its
+ // material off — hiding the plank itself would hide the child too.
+ assets().attachSlot('machine.seesaw', plank, {
+ onSwap: () => { (plank.material as THREE.Material).visible = false },
+ })
+
world.onFrame(() => {
const t = plankBody.translation()
const r = plankBody.rotation()
@@ -356,6 +383,9 @@ export function createBucketDump(world: World, cfg: BucketDumpConfig): MachinePa
fill.position.y = -0.15
group.add(fill)
+ // Fill stays procedural: it is the colour read, and it is re-tinted at runtime.
+ slotPart('machine.bucket', group, [shell])
+
let dumping = false
let tip = 0 // current tip angle
let tipTarget = 0
@@ -519,6 +549,9 @@ export function createConveyorBelt(world: World, cfg: ConveyorBeltConfig): Machi
group.add(c)
chevrons.push(c)
}
+
+ // Chevrons stay procedural — they scroll every frame to advertise direction.
+ slotPart('machine.belt', group, [belt])
const placeChevron = (c: THREE.Mesh, offset: number) => {
const t = ((offset % span) + span) % span - span * 0.5
if (alongX) c.position.x = t
@@ -577,15 +610,18 @@ export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePa
scene.add(group)
const postMat = new THREE.MeshStandardMaterial({ color: '#00bcd4', roughness: 0.3, metalness: 0.2 })
+ const archParts: THREE.Mesh[] = []
for (const sx of [-1, 1]) {
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, h, 12), postMat)
post.position.set((sx * w) / 2, h / 2, 0)
post.castShadow = true
group.add(post)
+ archParts.push(post)
}
const bar = new THREE.Mesh(new THREE.BoxGeometry(w + 0.4, 0.4, 0.4), postMat)
bar.position.y = h
group.add(bar)
+ archParts.push(bar)
// a few permanent decorative bubbles clinging to the arch (ambient telegraph)
const bubbleMat = new THREE.MeshStandardMaterial({ color: '#e0f7ff', transparent: true, opacity: 0.5, roughness: 0.05 })
@@ -593,8 +629,11 @@ export function createBubbleArch(world: World, cfg: BubbleArchConfig): MachinePa
const b = new THREE.Mesh(new THREE.SphereGeometry(0.15 + Math.random() * 0.2, 10, 8), bubbleMat)
b.position.set((Math.random() - 0.5) * w, Math.random() * h, (Math.random() - 0.5) * d)
group.add(b)
+ archParts.push(b)
}
+ slotPart('machine.arch', group, archParts)
+
const detCenter = { x: pos.x, y: pos.y + h * 0.5, z: pos.z }
const detHalf = { x: w * 0.5, y: h * 0.5, z: d * 0.5 }
@@ -705,6 +744,9 @@ export function createFan(world: World, cfg: FanConfig): MachinePart {
blades.position.z = 0.05
group.add(blades)
+ // Blades stay procedural — the constant spin IS this part's telegraph.
+ slotPart('machine.fan', group, [housing])
+
const beam = beamBox(pos, dir, range, spread)
world.addSystem({
diff --git a/src/paint/cannon.ts b/src/paint/cannon.ts
index 39454d8..aab3971 100644
--- a/src/paint/cannon.ts
+++ b/src/paint/cannon.ts
@@ -12,6 +12,7 @@ import * as THREE from 'three'
import type { PaintColor, System, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
+import { assets } from '../assets/registry'
export interface PaintCannonConfig {
world: World
@@ -218,6 +219,8 @@ export class PaintCannon implements System {
private buildBarrel(hex: string): THREE.Group {
const g = new THREE.Group()
+ // Slot `cannon.barrel` decorates the base; the named `pivot` child and its
+ // local +Z stay procedural because aimBarrel() steers them every step.
const base = new THREE.Mesh(
new THREE.CylinderGeometry(0.45, 0.55, 0.5, 16),
new THREE.MeshStandardMaterial({ color: '#3a3a44', roughness: 0.7 }),
@@ -237,6 +240,12 @@ export class PaintCannon implements System {
pivot.add(tube)
pivot.name = 'pivot'
g.add(pivot)
+ assets().attachSlot('cannon.barrel', g, {
+ onSwap: () => {
+ base.visible = false
+ tube.visible = false
+ },
+ })
return g
}
diff --git a/src/paint/puddles.ts b/src/paint/puddles.ts
index 8a33f1b..1e3a729 100644
--- a/src/paint/puddles.ts
+++ b/src/paint/puddles.ts
@@ -21,6 +21,7 @@ import * as THREE from 'three'
import type { Blob, PaintColor, World } from '../contracts'
import { PALETTE } from '../contracts'
import type { PaintSkin } from './skin'
+import { assets } from '../assets/registry'
export interface PaintPuddleConfig {
/** Strip centre in world space; y is the track surface the strip lies on. */
@@ -83,6 +84,22 @@ export class PaintPuddle {
this.mesh.position.set(x, y + 0.03, z)
this.mesh.receiveShadow = true
world.scene.add(this.mesh)
+
+ // Slot `fx.puddle`: a 1x1 decal model is stretched to this strip's footprint.
+ // Visual only — the trigger is pure maths on the config numbers below.
+ assets().attachSlot('fx.puddle', this.mesh, {
+ fit: new THREE.Vector3(w, 1, l),
+ onSwap: (asset) => {
+ mat.visible = false
+ asset.traverse((o) => {
+ const m = (o as THREE.Mesh).material as THREE.MeshStandardMaterial | undefined
+ if (m && m.isMeshStandardMaterial) {
+ m.color.set(PALETTE[cfg.color])
+ m.emissive.set(PALETTE[cfg.color])
+ }
+ })
+ },
+ })
}
/** True while the blob's belly is resting inside this strip's footprint. */