diff --git a/demos/lane-j.html b/demos/lane-j.html
new file mode 100644
index 0000000..27b11cb
--- /dev/null
+++ b/demos/lane-j.html
@@ -0,0 +1,90 @@
+
+
+
BLOBBO lane j demo — workshop editor
+
+
+
+ LANE J — SCRIPTED DROP ONTO THE SPRING BOOT SLOT
+
+
+
diff --git a/editor.html b/editor.html
new file mode 100644
index 0000000..d348cc2
--- /dev/null
+++ b/editor.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+BLOBBO workshop
+
+
+
+
+
+
+
diff --git a/src/demo/lane-j.ts b/src/demo/lane-j.ts
new file mode 100644
index 0000000..9f33e71
--- /dev/null
+++ b/src/demo/lane-j.ts
@@ -0,0 +1,156 @@
+/**
+ * Lane J demo — the workshop editor booted and then driven by a script.
+ *
+ * The scripted drop goes through `bootEditor().dropFile`, which is the exact
+ * function the real drag-drop handler calls, so a green run here means the
+ * button works too. Checks, in order: the stage object actually swapped, the
+ * fit controls move it, the export round-trips, reset restores the procedural
+ * build, and Clear leaves nothing behind.
+ *
+ * Asset note: `assets/meshes/*.glb` is NOT in the vite build graph today
+ * (nothing copies `assets/` into dist/), so the real boot GLB is fetched
+ * best-effort and a generated stand-in GLB is used when it 404s. Which one ran
+ * is printed — the pipeline is identical either way.
+ */
+import { bootEditor } from '../editor/main'
+import {
+ manifestsEqual, parseManifest, isIdbUrl, idbKey,
+} from '../editor/manifest-io'
+import { buildTinyGlb } from '../editor/tiny-glb'
+import { getGlb, loadOverrideManifest } from '../editor/store'
+
+const SLOT = 'machine.boot'
+const BOOT_URL = import.meta.env.BASE_URL + 'assets/meshes/prop-spring-boot.glb'
+
+const out = document.getElementById('results')!
+let passed = 0
+let failed = 0
+
+function check(cond: boolean, msg: string, detail = ''): void {
+ const line = document.createElement('div')
+ line.className = cond ? 'pass' : 'fail'
+ line.textContent = `${cond ? '✓' : '✗'} ${msg}${detail ? ` — ${detail}` : ''}`
+ out.append(line)
+ if (cond) passed++
+ else failed++
+ console[cond ? 'log' : 'error'](`${cond ? 'PASS' : 'FAIL'}: ${msg}`, detail)
+}
+
+function note(msg: string): void {
+ const line = document.createElement('div')
+ line.className = 'note'
+ line.textContent = msg
+ out.append(line)
+ console.log(msg)
+}
+
+async function fetchBootGlb(): Promise<{ name: string; bytes: ArrayBuffer; real: boolean }> {
+ try {
+ const res = await fetch(BOOT_URL)
+ if (res.ok) {
+ const bytes = await res.arrayBuffer()
+ if (bytes.byteLength > 0) {
+ return { name: 'prop-spring-boot.glb', bytes, real: true }
+ }
+ }
+ note(`Real farm GLB not served at ${BOOT_URL} (${res.status}) — using a generated stand-in.`)
+ } catch {
+ note(`Real farm GLB not reachable at ${BOOT_URL} — using a generated stand-in.`)
+ }
+ return { name: 'prop-spring-boot.glb', bytes: buildTinyGlb({ half: 1.1 }), real: false }
+}
+
+async function run(): Promise {
+ const editor = await bootEditor(document.getElementById('workshop')!)
+ const { stage, workshop } = editor
+
+ // ---- a clean session must be all-procedural ------------------------------
+ check(stage.slots.has(SLOT), 'the spring boot slot exists on the stage')
+ check(!workshop.hasCustom(SLOT), 'nothing is swapped before the drop')
+ const entryRef = stage.slots.get(SLOT)!
+ check(entryRef.fitNode === null, 'the slot has no custom wrapper yet')
+ check(entryRef.procedural.every((p) => p.visible), 'the procedural boot is visible')
+ const proceduralChildCount = entryRef.procedural[0]!.children.length
+
+ editor.select(SLOT)
+
+ // ---- the scripted drop ---------------------------------------------------
+ const file = await fetchBootGlb()
+ note(`Dropping ${file.name} (${file.bytes.byteLength.toLocaleString()} bytes, ` +
+ `${file.real ? 'real farm asset' : 'generated stand-in'}) onto the spring boot slot.`)
+ const result = await editor.dropFile(SLOT, file.name, file.bytes)
+
+ check(!result.rejected, 'the drop was accepted', result.message)
+ check(entryRef.fitNode !== null, 'a custom wrapper now holds the dropped model')
+ check(entryRef.fitNode !== null && entryRef.fitNode.children.length > 0,
+ 'the dropped model is actually inside the wrapper')
+ check(entryRef.procedural.every((p) => !p.visible),
+ 'the procedural boot is hidden (swapped, not stacked)')
+ check(entryRef.procedural[0]!.children.length === proceduralChildCount,
+ 'the procedural boot was hidden, not destroyed (reset can restore it)')
+ check(isIdbUrl(result.entry.url), 'the manifest entry points at browser storage',
+ result.entry.url)
+ const stored = await getGlb(idbKey(result.entry.url))
+ check(stored !== undefined && stored.bytes.byteLength === file.bytes.byteLength,
+ 'the .glb bytes went into IndexedDB intact')
+ check(stage.currentObject(SLOT) === entryRef.fitNode,
+ 'the fit panel now acts on the dropped model')
+
+ // Where the swapped boot sits before any nudging.
+ const basePos = entryRef.fitNode!.position.clone()
+
+ // ---- fit controls actually move it ---------------------------------------
+ workshop.setFit(SLOT, { offset: [0, 1.5, 0], rotationDeg: [0, 90, 0], scale: 2 })
+ const moved = entryRef.fitNode!
+ check(Math.abs(moved.position.y - (basePos.y + 1.5)) < 1e-6,
+ 'raising the model moves it up by exactly that much',
+ `y ${basePos.y.toFixed(3)} → ${moved.position.y.toFixed(3)}`)
+ check(Math.abs(moved.rotation.y - Math.PI / 2) < 1e-6, 'turning it applies the rotation')
+ check(Math.abs(moved.scale.x - 2) < 1e-6, 'resizing it applies the scale')
+
+ // ---- export round-trips --------------------------------------------------
+ const text = editor.exportManifest()
+ const reparsed = parseManifest(text)
+ check(manifestsEqual(workshop.getManifest(), reparsed),
+ 'the downloaded manifest.json parses back to exactly what the editor holds')
+ check(reparsed[SLOT] !== undefined && reparsed[SLOT]!.offset?.[1] === 1.5,
+ 'the fit survives the round trip')
+ check(typeof (JSON.parse(text) as { _readme?: string })._readme === 'string',
+ 'the download carries a plain-language note about shipping it')
+
+ // Same trip again through a real Blob/File, i.e. the bytes a download produces.
+ const downloaded = new Blob([text], { type: 'application/json' })
+ const roundTripped = parseManifest(await downloaded.text())
+ check(manifestsEqual(reparsed, roundTripped),
+ 'a real downloaded file round-trips identically')
+
+ // ---- save + reload persistence ------------------------------------------
+ await editor.save()
+ const persisted = await loadOverrideManifest()
+ check(manifestsEqual(workshop.getManifest(), persisted),
+ 'saving writes the same manifest the live game will read back')
+
+ // ---- reset restores the procedural build ---------------------------------
+ workshop.reset(SLOT)
+ check(entryRef.fitNode === null, 'reset removes the custom wrapper')
+ check(entryRef.procedural.every((p) => p.visible), 'reset shows the original boot again')
+ check(!workshop.hasCustom(SLOT), 'reset drops the manifest entry')
+
+ // ---- clear leaves nothing behind ----------------------------------------
+ await editor.clearAll()
+ const afterClear = await loadOverrideManifest()
+ check(Object.keys(afterClear).length === 0, 'Clear removes the saved override manifest')
+
+ const summary = document.createElement('div')
+ summary.className = failed === 0 ? 'summary pass' : 'summary fail'
+ summary.textContent = failed === 0
+ ? `All ${passed} checks passed ✓`
+ : `${failed} of ${passed + failed} checks FAILED`
+ out.append(summary)
+ ;(window as unknown as { LANE_J: unknown }).LANE_J = { editor, passed, failed }
+}
+
+void run().catch((err) => {
+ console.error(err)
+ check(false, 'the demo crashed', String(err))
+})
diff --git a/src/editor/glb.ts b/src/editor/glb.ts
new file mode 100644
index 0000000..da5aef4
--- /dev/null
+++ b/src/editor/glb.ts
@@ -0,0 +1,28 @@
+/**
+ * Lane J — GLB bytes in, scene graph out.
+ *
+ * Its own module (rather than living in stage.ts) so the drop pipeline can be
+ * imported without dragging in the whole game module graph — stage.ts pulls
+ * world/course/machine/paint, and a headless test has no business booting any
+ * of that.
+ */
+import * as THREE from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { clone as skeletonClone } from 'three/examples/jsm/utils/SkeletonUtils.js'
+
+/** Parse GLB bytes. Rejects rather than handing back half a model. */
+export function loadGlb(bytes: ArrayBuffer): Promise {
+ const loader = new GLTFLoader()
+ return new Promise((resolve, reject) => {
+ loader.parse(bytes, '', (gltf) => resolve(gltf.scene), reject)
+ })
+}
+
+/** Clone an asset safely — SkeletonUtils for anything rigged, plain clone else. */
+export function cloneAsset(object: THREE.Object3D): THREE.Object3D {
+ let skinned = false
+ object.traverse((o) => {
+ if ((o as THREE.SkinnedMesh).isSkinnedMesh) skinned = true
+ })
+ return skinned ? skeletonClone(object) : object.clone(true)
+}
diff --git a/src/editor/idb.ts b/src/editor/idb.ts
new file mode 100644
index 0000000..31d8d72
--- /dev/null
+++ b/src/editor/idb.ts
@@ -0,0 +1,71 @@
+/**
+ * Lane J — the local override store.
+ *
+ * Database/store names come from lanes/LANE-I §Deliverables 1 ('blobbo-workshop'
+ * with `manifest` + `blobs`): the editor WRITES here and the shipped game READS
+ * here, which is what makes a custom asset playable on the live site with no
+ * deploy. The manifest is a single record under MANIFEST_KEY; GLB bytes live in
+ * `blobs` under the key embedded in each `idb:` url.
+ *
+ * Raw IDB rather than a wrapper because no new deps are allowed, and the surface
+ * needed here is four calls wide.
+ */
+
+export const DB_NAME = 'blobbo-workshop'
+export const DB_VERSION = 1
+export const MANIFEST_STORE = 'manifest'
+export const BLOB_STORE = 'blobs'
+/** The single record in the `manifest` store that holds the working manifest. */
+export const MANIFEST_KEY = 'current'
+
+let dbPromise: Promise | null = null
+
+function openDb(): Promise {
+ if (dbPromise) return dbPromise
+ dbPromise = new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION)
+ req.onupgradeneeded = () => {
+ const db = req.result
+ if (!db.objectStoreNames.contains(MANIFEST_STORE)) db.createObjectStore(MANIFEST_STORE)
+ if (!db.objectStoreNames.contains(BLOB_STORE)) db.createObjectStore(BLOB_STORE)
+ }
+ req.onsuccess = () => resolve(req.result)
+ req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'))
+ })
+ return dbPromise
+}
+
+function run(
+ store: string,
+ mode: IDBTransactionMode,
+ fn: (s: IDBObjectStore) => IDBRequest,
+): Promise {
+ return openDb().then(
+ (db) =>
+ new Promise((resolve, reject) => {
+ const tx = db.transaction(store, mode)
+ const req = fn(tx.objectStore(store))
+ req.onsuccess = () => resolve(req.result)
+ req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed'))
+ }),
+ )
+}
+
+export const idbGet = (store: string, key: string): Promise =>
+ run(store, 'readonly', (s) => s.get(key) as IDBRequest)
+
+export const idbPut = (store: string, key: string, value: unknown): Promise =>
+ run(store, 'readwrite', (s) => s.put(value, key) as IDBRequest)
+
+export const idbDelete = (store: string, key: string): Promise =>
+ run(store, 'readwrite', (s) => s.delete(key) as IDBRequest)
+
+export const idbKeys = (store: string): Promise =>
+ run(store, 'readonly', (s) => s.getAllKeys())
+
+export const idbClear = (store: string): Promise =>
+ run(store, 'readwrite', (s) => s.clear() as IDBRequest)
+
+/** True when this browser can hold overrides at all (private modes sometimes can't). */
+export const idbAvailable = (): boolean =>
+ typeof indexedDB !== 'undefined' && indexedDB !== null
diff --git a/src/editor/main.ts b/src/editor/main.ts
new file mode 100644
index 0000000..4767427
--- /dev/null
+++ b/src/editor/main.ts
@@ -0,0 +1,421 @@
+/**
+ * Lane J — the workshop editor page.
+ *
+ * Three panels in a CSS grid, no framework: slots (left), the real course
+ * (centre), fit controls (right). Written for an artist, not a programmer —
+ * no slot ids, file paths or type names appear on screen, and every action has
+ * exactly one obvious button.
+ *
+ * The whole page is visual: it never registers a system and never ticks the
+ * sim (see stage.ts), so the hidden-tab fixed-step rule doesn't apply here.
+ */
+import { createEditorStage } from './stage'
+import type { EditorStage } from './stage'
+import { Workshop } from './workshop'
+import type { DropResult } from './workshop'
+import { SLOTS, SLOT_GROUPS, slotById } from './slots'
+import type { SlotSpec } from './slots'
+import { idbAvailable } from './idb'
+import type { SlotEntry } from './manifest-io'
+
+const el = (
+ tag: K, cls?: string, text?: string,
+): HTMLElementTagNameMap[K] => {
+ const node = document.createElement(tag)
+ if (cls) node.className = cls
+ if (text !== undefined) node.textContent = text
+ return node
+}
+
+const asTriple = (v: number | [number, number, number] | undefined): [number, number, number] =>
+ v === undefined ? [0, 0, 0] : typeof v === 'number' ? [v, v, v] : v
+
+const uniformScale = (v: SlotEntry['scale']): number =>
+ v === undefined ? 1 : typeof v === 'number' ? v : v[0]
+
+interface FitRow {
+ label: string
+ get: (e: SlotEntry) => number
+ set: (e: SlotEntry, v: number) => Partial
+ min: number
+ max: number
+ step: number
+}
+
+const AXES = ['left / right', 'up / down', 'front / back'] as const
+
+const FIT_ROWS: FitRow[] = [
+ ...AXES.map((axis, i): FitRow => ({
+ label: `Move ${axis}`,
+ min: -6, max: 6, step: 0.05,
+ get: (e) => asTriple(e.offset)[i],
+ set: (e, v) => {
+ const next = asTriple(e.offset)
+ next[i] = v
+ return { offset: next }
+ },
+ })),
+ ...(['Tilt', 'Turn', 'Roll'] as const).map((name, i): FitRow => ({
+ label: name,
+ min: -180, max: 180, step: 1,
+ get: (e) => asTriple(e.rotationDeg)[i],
+ set: (e, v) => {
+ const next = asTriple(e.rotationDeg)
+ next[i] = v
+ return { rotationDeg: next }
+ },
+ })),
+ {
+ label: 'Size', min: 0.05, max: 8, step: 0.05,
+ get: (e) => uniformScale(e.scale),
+ set: (_e, v) => ({ scale: v }),
+ },
+]
+
+export async function bootEditor(root: HTMLElement): Promise<{
+ stage: EditorStage
+ workshop: Workshop
+ /** Same code path a real drag-drop takes — the demo calls this. */
+ dropFile: (slot: string, name: string, bytes: ArrayBuffer) => Promise
+ select: (slot: string) => void
+ save: () => Promise
+ exportManifest: () => string
+ clearAll: () => Promise
+ status: () => string
+}> {
+ root.classList.add('workshop')
+
+ // ---- skeleton ------------------------------------------------------------
+ const left = el('aside', 'panel panel-slots')
+ const centre = el('main', 'panel panel-stage')
+ const right = el('aside', 'panel panel-fit')
+ const stageHost = el('div', 'stage-host')
+ centre.append(stageHost)
+ root.append(left, centre, right)
+
+ const stage = await createEditorStage(stageHost)
+ const workshop = new Workshop(stage)
+
+ // ---- left panel ----------------------------------------------------------
+ left.append(el('h1', 'brand', 'BLOBBO workshop'))
+ left.append(el('p', 'lede', 'Pick a thing, drop your model on it, nudge it until it looks right.'))
+
+ const slotButtons = new Map()
+ for (const group of SLOT_GROUPS) {
+ const members = SLOTS.filter((s) => s.group === group)
+ if (!members.length) continue
+ left.append(el('h2', 'group', group))
+ const list = el('div', 'slot-list')
+ for (const spec of members) {
+ const button = el('button', 'slot')
+ button.append(el('span', 'slot-name', spec.label))
+ button.append(el('span', 'slot-state', ''))
+ button.title = spec.hint
+ button.addEventListener('click', () => select(spec.id))
+ list.append(button)
+ slotButtons.set(spec.id, button)
+ }
+ left.append(list)
+ }
+
+ const bigActions = el('div', 'big-actions')
+ const saveBtn = el('button', 'action primary', 'Save to this browser')
+ const testBtn = el('button', 'action', 'Test drive the game')
+ const exportBtn = el('button', 'action', 'Download manifest.json')
+ const clearBtn = el('button', 'action danger', 'Clear all my swaps')
+ bigActions.append(saveBtn, testBtn, exportBtn, clearBtn)
+ left.append(bigActions)
+
+ const statusLine = el('p', 'status', 'Nothing swapped yet — this is the game as it ships.')
+ left.append(statusLine)
+ const setStatus = (text: string, tone: 'ok' | 'warn' = 'ok'): void => {
+ statusLine.textContent = text
+ statusLine.dataset.tone = tone
+ }
+
+ // ---- centre panel overlay ------------------------------------------------
+ const stageHint = el('div', 'stage-hint',
+ 'Drag to spin the view · scroll to zoom · right-drag to slide')
+ centre.append(stageHint)
+ const focusBtn = el('button', 'focus-btn', 'Show me the selected thing')
+ focusBtn.addEventListener('click', () => { if (selected) stage.focus(selected) })
+ centre.append(focusBtn)
+
+ // ---- right panel ---------------------------------------------------------
+ const fitTitle = el('h2', 'fit-title', 'Nothing selected')
+ const fitHint = el('p', 'fit-hint', 'Pick something from the list on the left.')
+ const dropZone = el('div', 'dropzone')
+ const dropLabel = el('div', 'dropzone-label', 'Drop a .glb file here')
+ const dropSub = el('div', 'dropzone-sub', 'or click to pick one from your computer')
+ dropZone.append(dropLabel, dropSub)
+ const filePicker = el('input')
+ filePicker.type = 'file'
+ filePicker.accept = '.glb,model/gltf-binary'
+ filePicker.style.display = 'none'
+
+ const paintCard = el('div', 'paint-card')
+ const fitControls = el('div', 'fit-controls')
+ const resetBtn = el('button', 'action', 'Put the original back')
+ const helpCard = el('details', 'help-card')
+ right.append(fitTitle, fitHint, dropZone, filePicker, paintCard, fitControls, resetBtn, helpCard)
+
+ helpCard.append(el('summary', '', 'How to make models that fit (and how to ship them)'))
+ const helpBody = el('div', 'help-body')
+ helpBody.innerHTML = `
+ In Blender, before you export
+
+ - Work in metres, Y up, facing −Z. The blob is about 1 metre wide.
+ - Put the origin in the centre for the blob, and at the ground contact point for props.
+ - One material per model if you can. Textures no bigger than 2048×2048.
+ - Export as .glb (embedded), not .gltf + files.
+
+ If it's the blob body
+
+ - It has to be UV unwrapped, all in one 0–1 square, no overlaps and no mirroring —
+ that's where the paint goes.
+ - Rigged is fine: one armature, the looping animation named
idle, keep it under ~40 bones.
+
+ Ship it for real
+
+ - Press Save to this browser, then Test drive to play with it.
+ - When you're happy, press Download manifest.json.
+ - Copy your .glb files into
assets/live/ in the repo and put the
+ downloaded manifest.json next to them as assets/manifest.json.
+ - Change every url that starts with
idb: to assets/live/yourfile.glb.
+ - Redeploy. Everyone sees it.
+
+ Saved here stays here. Swaps you save live in this browser only —
+ nobody else sees them until you do the steps above.
`
+ helpCard.append(helpBody)
+
+ // ---- selection state -----------------------------------------------------
+ let selected: string | null = null
+
+ const refreshSlotStates = (): void => {
+ for (const [id, button] of slotButtons) {
+ const spec = slotById(id)!
+ const state = button.querySelector('.slot-state') as HTMLElement
+ if (spec.derivedFrom) {
+ state.textContent = 'copies the blob'
+ button.dataset.state = 'derived'
+ } else if (workshop.hasCustom(id)) {
+ state.textContent = 'yours'
+ button.dataset.state = 'custom'
+ } else {
+ state.textContent = 'original'
+ button.dataset.state = 'plain'
+ }
+ button.dataset.selected = String(id === selected)
+ }
+ }
+
+ const renderPaintCard = (spec: SlotSpec): void => {
+ paintCard.textContent = ''
+ if (!spec.paintable) {
+ paintCard.style.display = 'none'
+ return
+ }
+ paintCard.style.display = 'block'
+ const report = workshop.paintReport(spec.id)
+ if (!report) {
+ paintCard.dataset.tone = 'idle'
+ paintCard.append(el('div', 'paint-head', 'Paint check'))
+ paintCard.append(el('div', 'paint-note',
+ 'Drop a blob model and I will check the paint sticks to it.'))
+ return
+ }
+ paintCard.dataset.tone = report.uvOk && report.problems.length === 0 ? 'ok' : 'bad'
+ paintCard.append(el('div', 'paint-head',
+ report.uvOk && report.problems.length === 0
+ ? 'Paint check passed'
+ : 'Paint problem'))
+ const facts = el('div', 'paint-facts')
+ facts.append(el('span', '', `${report.triCount.toLocaleString()} triangles`))
+ facts.append(el('span', '', `${report.materialCount} material${report.materialCount === 1 ? '' : 's'}`))
+ facts.append(el('span', '', report.uvOk ? 'UV map looks right' : 'UV map is wrong'))
+ paintCard.append(facts)
+ for (const problem of report.problems) paintCard.append(el('div', 'paint-note', problem))
+ }
+
+ const renderFitControls = (): void => {
+ fitControls.textContent = ''
+ const entry = selected ? workshop.entry(selected) : undefined
+ if (!selected || !entry) {
+ fitControls.style.display = 'none'
+ resetBtn.style.display = 'none'
+ return
+ }
+ fitControls.style.display = 'block'
+ resetBtn.style.display = 'block'
+ for (const row of FIT_ROWS) {
+ const wrap = el('label', 'fit-row')
+ wrap.append(el('span', 'fit-label', row.label))
+ const slider = el('input')
+ slider.type = 'range'
+ slider.min = String(row.min)
+ slider.max = String(row.max)
+ slider.step = String(row.step)
+ const number = el('input')
+ number.type = 'number'
+ number.step = String(row.step)
+ const current = row.get(entry)
+ slider.value = String(current)
+ number.value = String(Number(current.toFixed(3)))
+ const push = (raw: string): void => {
+ const value = Number(raw)
+ if (!Number.isFinite(value) || !selected) return
+ const live = workshop.entry(selected)
+ if (!live) return
+ workshop.setFit(selected, row.set(live, value))
+ slider.value = String(value)
+ number.value = String(Number(value.toFixed(3)))
+ setStatus('Nudged. Press "Save to this browser" when it looks right.')
+ }
+ slider.addEventListener('input', () => push(slider.value))
+ number.addEventListener('change', () => push(number.value))
+ wrap.append(slider, number)
+ fitControls.append(wrap)
+ }
+ }
+
+ const select = (slot: string): void => {
+ selected = slot
+ const spec = slotById(slot)
+ if (!spec) return
+ fitTitle.textContent = spec.label
+ fitHint.textContent = spec.derivedFrom
+ ? `${spec.hint} There is nothing to drop here.`
+ : spec.hint
+ dropZone.style.display = spec.derivedFrom ? 'none' : 'flex'
+ stage.highlight(slot)
+ stage.focus(slot)
+ renderPaintCard(spec)
+ renderFitControls()
+ refreshSlotStates()
+ }
+
+ // ---- drop plumbing -------------------------------------------------------
+ const handleFile = async (file: File): Promise => {
+ if (!selected) {
+ setStatus('Pick something on the left first, then drop your file.', 'warn')
+ return
+ }
+ setStatus(`Reading ${file.name}…`)
+ const bytes = await file.arrayBuffer()
+ const result = await workshop.dropFile(selected, file.name, bytes)
+ setStatus(result.message, result.rejected ? 'warn' : 'ok')
+ const spec = slotById(selected)
+ if (spec) renderPaintCard(spec)
+ renderFitControls()
+ refreshSlotStates()
+ }
+
+ const stop = (e: Event): void => { e.preventDefault(); e.stopPropagation() }
+ for (const type of ['dragenter', 'dragover'] as const) {
+ dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'true' })
+ }
+ for (const type of ['dragleave', 'drop'] as const) {
+ dropZone.addEventListener(type, (e) => { stop(e); dropZone.dataset.hot = 'false' })
+ }
+ dropZone.addEventListener('drop', (e) => {
+ const file = (e as DragEvent).dataTransfer?.files?.[0]
+ if (file) void handleFile(file)
+ })
+ dropZone.addEventListener('click', () => filePicker.click())
+ filePicker.addEventListener('change', () => {
+ const file = filePicker.files?.[0]
+ if (file) void handleFile(file)
+ filePicker.value = ''
+ })
+ // Dropping anywhere else must not make the browser navigate to the file.
+ addEventListener('dragover', (e) => e.preventDefault())
+ addEventListener('drop', (e) => e.preventDefault())
+
+ // ---- buttons -------------------------------------------------------------
+ resetBtn.addEventListener('click', () => {
+ if (!selected) return
+ workshop.reset(selected)
+ const spec = slotById(selected)
+ if (spec) renderPaintCard(spec)
+ renderFitControls()
+ refreshSlotStates()
+ setStatus('Back to the original. Save if you want that to stick.')
+ })
+
+ const save = async (): Promise => {
+ if (!idbAvailable()) {
+ setStatus("This browser won't let me save anything locally.", 'warn')
+ return
+ }
+ const count = await workshop.saveLocally()
+ setStatus(count === 0
+ ? 'Saved — no swaps, so the game looks like it always did.'
+ : `Saved ${count} swap${count === 1 ? '' : 's'}. Press "Test drive" to play with them.`)
+ }
+ saveBtn.addEventListener('click', () => void save())
+
+ testBtn.addEventListener('click', () => {
+ void save().then(() => {
+ setStatus('Opened the game in a new tab — your swaps are already in it.')
+ open(import.meta.env.BASE_URL, '_blank')
+ })
+ })
+
+ const exportManifest = (): string => workshop.exportText()
+ exportBtn.addEventListener('click', () => {
+ const text = exportManifest()
+ const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
+ const a = el('a')
+ a.href = url
+ a.download = 'manifest.json'
+ a.click()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+ setStatus('Downloaded manifest.json — open the help below to see where it goes.')
+ })
+
+ const clearAll = async (): Promise => {
+ await workshop.clearEverything()
+ refreshSlotStates()
+ renderFitControls()
+ if (selected) {
+ const spec = slotById(selected)
+ if (spec) renderPaintCard(spec)
+ }
+ setStatus('All swaps cleared. The game is back to how it ships.')
+ }
+ clearBtn.addEventListener('click', () => {
+ if (confirm('Throw away every model you dropped in? This cannot be undone.')) void clearAll()
+ })
+
+ // ---- restore a previous session -----------------------------------------
+ refreshSlotStates()
+ renderFitControls()
+ if (idbAvailable()) {
+ const restored = await workshop.restore()
+ if (restored.length) {
+ refreshSlotStates()
+ setStatus(`Brought back ${restored.length} swap${restored.length === 1 ? '' : 's'} from last time.`)
+ }
+ }
+
+ return {
+ stage,
+ workshop,
+ dropFile: (slot, name, bytes) => workshop.dropFile(slot, name, bytes),
+ select,
+ save,
+ exportManifest,
+ clearAll,
+ status: () => statusLine.textContent ?? '',
+ }
+}
+
+// Auto-boot when loaded as the editor page. The demo page marks its host
+// `data-manual` and calls bootEditor itself, so it never gets two editors.
+const host = document.getElementById('workshop')
+if (host && !host.hasAttribute('data-manual')) {
+ void bootEditor(host).catch((err) => {
+ console.error(err)
+ host.textContent = 'The workshop could not start: ' + String(err)
+ })
+}
diff --git a/src/editor/manifest-io.test.ts b/src/editor/manifest-io.test.ts
new file mode 100644
index 0000000..f39860f
--- /dev/null
+++ b/src/editor/manifest-io.test.ts
@@ -0,0 +1,166 @@
+/**
+ * Unit checks for the editor's pure core. No test runner / no deps — run directly:
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/manifest-io.test.ts
+ * (from the repo root; the --import hook teaches node the repo's extension-less
+ * imports, which its ESM resolver rejects on its own). Same shape as
+ * paint/coverage-math.test.ts: console + throw only, never bundled.
+ */
+import {
+ IDB_README, SHIPPABLE_README,
+ cleanManifest, composeFit, idbKey, identityTransform, isIdbUrl, isNeutralFit,
+ manifestsEqual, parseEntry, parseManifest, serializeManifest,
+} from './manifest-io'
+import type { Manifest } from './manifest-io'
+import { buildTinyGlb, isGlb } from './tiny-glb'
+import { SLOTS, SLOT_IDS, slotById } from './slots'
+
+// node:fs, reached through a computed specifier so tsc (whose lib is DOM-only,
+// and whose config this lane may not edit) never tries to resolve it. Only this
+// file — which never ships — touches it.
+const { readFileSync } = await import('node' + ':fs')
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+function eq(a: unknown, b: unknown, msg: string): void {
+ ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`)
+}
+
+// ---- every catalogued slot is actually wired to something on the stage -----
+// Static source check: the stage builds its objects inside WebGL-only code that
+// can't run here, but the registration calls are greppable, and a slot in the UI
+// with nothing behind it would silently do nothing when clicked.
+{
+ const stageSource = readFileSync(new URL('./stage.ts', import.meta.url), 'utf8')
+ const registered = new Set(
+ [...stageSource.matchAll(/register\('([^']+)'/g)].map((m) => m[1]!))
+ for (const id of SLOT_IDS) {
+ ok(registered.has(id), `stage.ts registers a real object for the "${id}" slot`)
+ }
+ for (const id of registered) {
+ ok(SLOT_IDS.includes(id), `stage.ts registers "${id}", which the slot list also knows about`)
+ }
+}
+
+// ---- slot catalogue --------------------------------------------------------
+{
+ ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'slot ids are unique')
+ ok(SLOTS.every((s) => s.label.length > 0 && s.hint.length > 0), 'every slot is labelled')
+ ok(SLOTS.every((s) => !/[._]/.test(s.label)), 'no slot id leaks into an on-screen label')
+ const derived = SLOTS.filter((s) => s.derivedFrom)
+ ok(derived.length > 0, 'at least one derived slot exists (the ghost)')
+ ok(derived.every((s) => slotById(s.derivedFrom!) !== undefined), 'derived slots point at real slots')
+ ok(SLOTS.filter((s) => s.paintable).length === 1, 'exactly one paintable slot (the body)')
+}
+
+// ---- entry parsing ---------------------------------------------------------
+{
+ ok(parseEntry(null) === null, 'null is not an entry')
+ ok(parseEntry({}) === null, 'an entry without a url is dropped')
+ ok(parseEntry({ url: '' }) === null, 'an empty url is dropped')
+ eq(parseEntry({ url: 'a.glb' }), { url: 'a.glb' }, 'a bare url survives')
+ eq(parseEntry({ url: 'a.glb', offset: [1, 2] }), { url: 'a.glb' }, 'a short offset is dropped')
+ eq(parseEntry({ url: 'a.glb', offset: [1, 'x', 3] }), { url: 'a.glb' }, 'a non-numeric offset is dropped')
+ eq(parseEntry({ url: 'a.glb', scale: 2 }), { url: 'a.glb', scale: 2 }, 'a uniform scale survives')
+ eq(parseEntry({ url: 'a.glb', scale: [1, 2, 3] }), { url: 'a.glb', scale: [1, 2, 3] }, 'a triple scale survives')
+ eq(parseEntry({ url: 'a.glb', idleClip: 'idle' }), { url: 'a.glb', idleClip: 'idle' }, 'idleClip survives')
+}
+
+// ---- manifest parsing is total --------------------------------------------
+{
+ eq(parseManifest('not json at all'), {}, 'bad JSON parses to an empty manifest')
+ eq(parseManifest('[1,2,3]'), {}, 'an array parses to an empty manifest')
+ eq(parseManifest('null'), {}, 'null parses to an empty manifest')
+ eq(parseManifest('{}'), {}, 'an empty object parses to an empty manifest')
+ eq(parseManifest('{"machine.boot":{"url":"a.glb"},"bad":7}'),
+ { 'machine.boot': { url: 'a.glb' } }, 'malformed slots are skipped, good ones kept')
+ eq(parseManifest(`{"_readme":"hi","machine.boot":{"url":"a.glb"}}`),
+ { 'machine.boot': { url: 'a.glb' } }, '_readme is metadata, not a slot')
+}
+
+// ---- round trip ------------------------------------------------------------
+{
+ const m: Manifest = {
+ 'machine.boot': { url: 'idb:machine.boot-prop-spring-boot.glb', offset: [0, 0.2, 0], rotationDeg: [0, 90, 0], scale: 1.4 },
+ 'blob.body': { url: 'assets/live/blobbo.glb', scale: [1, 1.2, 1], idleClip: 'idle' },
+ }
+ const text = serializeManifest(m)
+ const back = parseManifest(text)
+ ok(manifestsEqual(m, back), 'serialize → parse round-trips exactly')
+ eq(back, cleanManifest(m), 'the round trip deep-equals the cleaned manifest')
+ ok(JSON.parse(text)._readme === IDB_README, 'an export holding idb: urls carries the commit-it note')
+ ok(Object.keys(JSON.parse(text)).indexOf('_readme') === 0, '_readme comes first so it reads like a header')
+ ok(text.endsWith('\n'), 'the export ends with a newline (diff-friendly)')
+
+ const shippable: Manifest = { 'machine.boot': { url: 'assets/live/boot.glb' } }
+ const shippableDoc = JSON.parse(serializeManifest(shippable))
+ ok(shippableDoc._readme === SHIPPABLE_README, 'a shippable export gets the ship-it note')
+ ok(shippableDoc._readme !== IDB_README, 'a shippable export does NOT nag about idb urls')
+
+ // key order must not change equality
+ const reordered: Manifest = { 'blob.body': m['blob.body']!, 'machine.boot': m['machine.boot']! }
+ ok(manifestsEqual(m, reordered), 'slot order does not affect manifest equality')
+ ok(Object.keys(cleanManifest(reordered))[0] === 'blob.body', 'exports are sorted by slot')
+}
+
+// ---- idb urls --------------------------------------------------------------
+{
+ ok(isIdbUrl('idb:x'), 'idb: prefix detected')
+ ok(!isIdbUrl('assets/live/x.glb'), 'a normal path is not an idb url')
+ ok(idbKey('idb:machine.boot-a.glb') === 'machine.boot-a.glb', 'the key is everything after idb:')
+ ok(idbKey('assets/live/x.glb') === '', 'a non-idb url has no key')
+}
+
+// ---- fit maths -------------------------------------------------------------
+{
+ const base = { position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [1, 1, 1] } as const
+ const neutral = composeFit({ ...base, position: [...base.position], rotationDeg: [...base.rotationDeg], scale: [...base.scale] } as never, { url: 'a.glb' })
+ eq(neutral.position, [1, 2, 3], 'an empty fit leaves the asset exactly on the procedural pose')
+ eq(neutral.rotationDeg, [0, 45, 0], 'an empty fit keeps the procedural rotation')
+ eq(neutral.scale, [1, 1, 1], 'an empty fit keeps the procedural scale')
+
+ const fitted = composeFit(
+ { position: [1, 2, 3], rotationDeg: [0, 45, 0], scale: [2, 2, 2] },
+ { url: 'a.glb', offset: [0, 0.5, -1], rotationDeg: [0, 45, 10], scale: 3 })
+ eq(fitted.position, [1, 2.5, 2], 'offsets add to the base position')
+ eq(fitted.rotationDeg, [0, 90, 10], 'rotations add to the base rotation')
+ eq(fitted.scale, [6, 6, 6], 'scale multiplies the base scale')
+
+ const nonUniform = composeFit(identityTransform(), { url: 'a.glb', scale: [1, 2, 3] })
+ eq(nonUniform.scale, [1, 2, 3], 'a triple scale is applied per axis')
+
+ ok(isNeutralFit({ url: 'a.glb' }), 'a bare entry is a neutral fit')
+ ok(isNeutralFit({ url: 'a.glb', offset: [0, 0, 0], rotationDeg: [0, 0, 0], scale: 1 }), 'explicit zeros are neutral')
+ ok(!isNeutralFit({ url: 'a.glb', offset: [0, 0.1, 0] }), 'any offset is not neutral')
+ ok(!isNeutralFit({ url: 'a.glb', scale: 1.0001 }), 'any scale change is not neutral')
+}
+
+// ---- tiny glb --------------------------------------------------------------
+{
+ const glb = buildTinyGlb()
+ ok(isGlb(glb), 'the generated buffer has a valid GLB header and length')
+ ok(glb.byteLength % 4 === 0, 'the GLB total length is 4-byte aligned')
+ const view = new DataView(glb)
+ const jsonLen = view.getUint32(12, true)
+ ok(jsonLen % 4 === 0, 'the JSON chunk is padded to 4 bytes')
+ const json = JSON.parse(new TextDecoder().decode(new Uint8Array(glb, 20, jsonLen)).trim())
+ ok(json.asset.version === '2.0', 'the glTF asset version is 2.0')
+ ok(json.meshes[0].primitives[0].attributes.TEXCOORD_0 !== undefined, 'the quad ships UVs by default')
+ ok(json.materials.length === 1, 'the quad has exactly one material')
+ const binStart = 20 + jsonLen
+ ok(view.getUint32(binStart + 4, true) === 0x004e4942, 'the second chunk is the BIN chunk')
+ ok(binStart + 8 + view.getUint32(binStart, true) === glb.byteLength, 'the BIN chunk fills the rest of the file')
+
+ const noUv = buildTinyGlb({ withUv: false })
+ ok(isGlb(noUv), 'the UV-less variant is still a valid GLB')
+ const noUvLen = new DataView(noUv).getUint32(12, true)
+ const noUvJson = JSON.parse(new TextDecoder().decode(new Uint8Array(noUv, 20, noUvLen)).trim())
+ ok(noUvJson.meshes[0].primitives[0].attributes.TEXCOORD_0 === undefined, 'the UV-less variant really has no UVs')
+
+ ok(!isGlb(new ArrayBuffer(8)), 'a stub buffer is not a GLB')
+}
+
+console.log(`editor manifest-io.test: ${passed} assertions passed ✓`)
diff --git a/src/editor/manifest-io.ts b/src/editor/manifest-io.ts
new file mode 100644
index 0000000..e943be4
--- /dev/null
+++ b/src/editor/manifest-io.ts
@@ -0,0 +1,164 @@
+/**
+ * Lane J — the THREE-free, DOM-free core of the workshop editor.
+ *
+ * Everything here is pure so it can be exercised by `manifest-io.test.ts` under
+ * plain node (see coverage-math.test.ts for the house pattern). The types mirror
+ * Lane I's `src/assets/manifest.ts` spec verbatim (lanes/LANE-I §Deliverables 1)
+ * because Lane I lives in a parallel worktree — integration re-points these
+ * imports at the real module and deletes the duplicates.
+ */
+
+/** Mirrors Lane I `SlotEntry`. */
+export interface SlotEntry {
+ url: string
+ offset?: [number, number, number]
+ rotationDeg?: [number, number, number]
+ scale?: number | [number, number, number]
+ idleClip?: string
+}
+
+/** Mirrors Lane I `Manifest` (slot id → entry). */
+export type Manifest = Record
+
+/** The note stamped into an exported manifest.json that still holds idb: urls. */
+export const IDB_README =
+ 'Some urls start with "idb:" — those GLB files live only in this browser. ' +
+ 'To make them permanent: copy the .glb files into assets/live/ in the repo, ' +
+ 'change each "idb:..." url to "assets/live/.glb", then redeploy.'
+
+/** The note stamped into an exported manifest.json whose urls are all shippable. */
+export const SHIPPABLE_README =
+ 'Drop this file in the repo as assets/manifest.json, put the .glb files in ' +
+ 'assets/live/, then redeploy. No code changes needed.'
+
+export const isIdbUrl = (url: string): boolean => url.startsWith('idb:')
+
+/** The IndexedDB blob key behind an `idb:` url ('' if not an idb url). */
+export const idbKey = (url: string): string => (isIdbUrl(url) ? url.slice(4) : '')
+
+// ---- fit maths -------------------------------------------------------------
+
+/** A slot object's resting pose, in the units the manifest speaks. */
+export interface BaseTransform {
+ position: [number, number, number]
+ rotationDeg: [number, number, number]
+ scale: [number, number, number]
+}
+
+export const identityTransform = (): BaseTransform => ({
+ position: [0, 0, 0], rotationDeg: [0, 0, 0], scale: [1, 1, 1],
+})
+
+const asTriple = (s: number | [number, number, number]): [number, number, number] =>
+ typeof s === 'number' ? [s, s, s] : [s[0], s[1], s[2]]
+
+/**
+ * Where a dropped asset actually sits: the slot's procedural pose plus the
+ * manifest's offset/rotation/scale. Offsets ADD to the base position and
+ * rotations ADD (degrees, per axis) to the base rotation, so a manifest of all
+ * zeros/one lands the asset exactly where the procedural part stood — which is
+ * what makes "reset to procedural" and "empty manifest = today's game" agree.
+ */
+export function composeFit(base: BaseTransform, entry: SlotEntry): BaseTransform {
+ const off = entry.offset ?? [0, 0, 0]
+ const rot = entry.rotationDeg ?? [0, 0, 0]
+ const sc = asTriple(entry.scale ?? 1)
+ return {
+ position: [base.position[0] + off[0], base.position[1] + off[1], base.position[2] + off[2]],
+ rotationDeg: [
+ base.rotationDeg[0] + rot[0],
+ base.rotationDeg[1] + rot[1],
+ base.rotationDeg[2] + rot[2],
+ ],
+ scale: [base.scale[0] * sc[0], base.scale[1] * sc[1], base.scale[2] * sc[2]],
+ }
+}
+
+/** True when an entry carries no fit at all (asset sits exactly on the base pose). */
+export function isNeutralFit(entry: SlotEntry): boolean {
+ const off = entry.offset ?? [0, 0, 0]
+ const rot = entry.rotationDeg ?? [0, 0, 0]
+ const sc = asTriple(entry.scale ?? 1)
+ return off.every((v) => v === 0) && rot.every((v) => v === 0) && sc.every((v) => v === 1)
+}
+
+// ---- serialisation ---------------------------------------------------------
+
+const num3 = (v: unknown): [number, number, number] | undefined => {
+ if (!Array.isArray(v) || v.length !== 3) return undefined
+ const out = v.map((n) => (typeof n === 'number' && Number.isFinite(n) ? n : NaN))
+ return out.some(Number.isNaN) ? undefined : (out as [number, number, number])
+}
+
+/**
+ * Tolerant single-entry parse. Anything malformed is dropped rather than
+ * thrown — a half-typed manifest must never take the editor (or the game) down.
+ */
+export function parseEntry(raw: unknown): SlotEntry | null {
+ if (!raw || typeof raw !== 'object') return null
+ const o = raw as Record
+ if (typeof o.url !== 'string' || o.url === '') return null
+ const entry: SlotEntry = { url: o.url }
+ const offset = num3(o.offset)
+ if (offset) entry.offset = offset
+ const rotationDeg = num3(o.rotationDeg)
+ if (rotationDeg) entry.rotationDeg = rotationDeg
+ if (typeof o.scale === 'number' && Number.isFinite(o.scale)) entry.scale = o.scale
+ else {
+ const s3 = num3(o.scale)
+ if (s3) entry.scale = s3
+ }
+ if (typeof o.idleClip === 'string' && o.idleClip !== '') entry.idleClip = o.idleClip
+ return entry
+}
+
+/** Keys that are documentation, not slots — stripped on the way back in. */
+const META_KEYS = new Set(['_readme', '_generatedBy'])
+
+/** Tolerant whole-manifest parse. Bad JSON or a bad shape yields `{}`. */
+export function parseManifest(text: string): Manifest {
+ let raw: unknown
+ try {
+ raw = JSON.parse(text)
+ } catch {
+ return {}
+ }
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
+ const out: Manifest = {}
+ for (const [slot, value] of Object.entries(raw as Record)) {
+ if (META_KEYS.has(slot)) continue
+ const entry = parseEntry(value)
+ if (entry) out[slot] = entry
+ }
+ return out
+}
+
+/** Drop keys the editor never wrote so exports stay diff-friendly. */
+export function cleanManifest(manifest: Manifest): Manifest {
+ const out: Manifest = {}
+ for (const slot of Object.keys(manifest).sort()) {
+ const entry = parseEntry(manifest[slot])
+ if (entry) out[slot] = entry
+ }
+ return out
+}
+
+/**
+ * The exact bytes the download button hands over: sorted slots plus a plain-
+ * language `_readme`. `_readme` is metadata, and `parseManifest` strips it, so
+ * `parseManifest(serializeManifest(m))` deep-equals `cleanManifest(m)`.
+ */
+export function serializeManifest(manifest: Manifest): string {
+ const clean = cleanManifest(manifest)
+ const anyIdb = Object.values(clean).some((e) => isIdbUrl(e.url))
+ const doc: Record = {
+ _readme: anyIdb ? IDB_README : SHIPPABLE_README,
+ ...clean,
+ }
+ return JSON.stringify(doc, null, 2) + '\n'
+}
+
+/** Structural equality for manifests (used by the demo's round-trip assertion). */
+export function manifestsEqual(a: Manifest, b: Manifest): boolean {
+ return JSON.stringify(cleanManifest(a)) === JSON.stringify(cleanManifest(b))
+}
diff --git a/src/editor/node-ts-resolve.mjs b/src/editor/node-ts-resolve.mjs
new file mode 100644
index 0000000..ebc0257
--- /dev/null
+++ b/src/editor/node-ts-resolve.mjs
@@ -0,0 +1,33 @@
+/**
+ * Lane J — lets node run the repo's extension-less TS imports directly.
+ *
+ * The house test style (see paint/coverage-math.test.ts) imports './thing' with
+ * no extension, which is what tsc's "bundler" resolution and vite both want but
+ * which node's ESM resolver rejects outright. This registers a resolve hook that
+ * retries a bare relative specifier as `.ts`, so:
+ *
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/manifest-io.test.ts
+ *
+ * Plain .mjs on purpose: tsconfig only compiles .ts, and nothing imports this
+ * file, so it never reaches tsc or the vite bundle.
+ */
+import { registerHooks } from 'node:module'
+
+registerHooks({
+ resolve(specifier, context, next) {
+ try {
+ return next(specifier, context)
+ } catch (err) {
+ if (specifier.startsWith('.') && !/\.[a-z]+$/i.test(specifier)) {
+ try {
+ return next(specifier + '.ts', context)
+ } catch {
+ // '../machine' style directory imports resolve to their index.ts
+ return next(specifier + '/index.ts', context)
+ }
+ }
+ throw err
+ }
+ },
+})
diff --git a/src/editor/paintability.test.ts b/src/editor/paintability.test.ts
new file mode 100644
index 0000000..e4da01d
--- /dev/null
+++ b/src/editor/paintability.test.ts
@@ -0,0 +1,82 @@
+/**
+ * Headless check of the paint report, run against REAL parsed GLB data rather
+ * than a hand-built THREE object — GLTFLoader.parse needs no DOM for an
+ * untextured mesh, so the whole "bytes in, verdict out" path is testable:
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/paintability.test.ts
+ */
+import * as THREE from 'three'
+import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
+import { buildTinyGlb } from './tiny-glb'
+import { firstMesh, inspectPaintability } from './paintability'
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+
+const parse = (bytes: ArrayBuffer): Promise =>
+ new Promise((resolve, reject) => {
+ new GLTFLoader().parse(bytes, '', (gltf) => resolve(gltf.scene), reject)
+ })
+
+// ---- a well-formed asset passes -------------------------------------------
+{
+ const scene = await parse(buildTinyGlb())
+ const mesh = firstMesh(scene)
+ ok(mesh !== null, 'the parsed GLB yields a mesh')
+ ok((mesh as THREE.Mesh).isMesh === true, 'what PaintSkin would bind to really is a Mesh')
+
+ const report = inspectPaintability(scene)
+ ok(report.hasMesh, 'the report found a mesh')
+ ok(report.uvOk, 'a 0-1 UV map passes the paint check')
+ ok(report.triCount === 2, `the quad reports 2 triangles (got ${report.triCount})`)
+ ok(report.materialCount === 1, 'a single-material asset reports 1 material')
+ ok(report.problems.length === 0, `a good asset raises no problems (got ${JSON.stringify(report.problems)})`)
+}
+
+// ---- no UVs is rejected with a plain-language reason ------------------------
+{
+ const scene = await parse(buildTinyGlb({ withUv: false }))
+ const report = inspectPaintability(scene)
+ ok(report.hasMesh, 'the UV-less asset still parses to a mesh')
+ ok(!report.uvOk, 'an asset with no UV map fails the paint check')
+ ok(report.problems.length > 0, 'the failure comes with a reason')
+ ok(/unwrap/i.test(report.problems[0]!), 'the reason tells you to unwrap it in Blender')
+ ok(!/uv attribute|BufferAttribute|undefined/i.test(report.problems[0]!),
+ 'the reason is written for an artist, not a programmer')
+}
+
+// ---- an empty group is reported, not crashed on ----------------------------
+{
+ const report = inspectPaintability(new THREE.Group())
+ ok(!report.hasMesh, 'an empty asset reports no mesh')
+ ok(!report.uvOk, 'an empty asset cannot be painted')
+ ok(report.problems.length === 1, 'an empty asset gets exactly one complaint')
+}
+
+// ---- UVs outside 0..1 are caught (the silent wrap-around bug) --------------
+{
+ const scene = await parse(buildTinyGlb())
+ const mesh = firstMesh(scene)!
+ const uv = mesh.geometry.getAttribute('uv') as THREE.BufferAttribute
+ uv.setXY(2, 2.5, 1) // push one corner into the next UV tile
+ uv.needsUpdate = true
+ const report = inspectPaintability(scene)
+ ok(!report.uvOk, 'UVs running past 1 fail the paint check')
+ ok(/0.{0,3}1|square/i.test(report.problems[0]!), 'the reason names the 0-1 square')
+}
+
+// ---- more than one material is caught --------------------------------------
+{
+ const scene = await parse(buildTinyGlb())
+ const mesh = firstMesh(scene)!
+ mesh.material = [mesh.material as THREE.Material, new THREE.MeshStandardMaterial()]
+ const report = inspectPaintability(scene)
+ ok(report.materialCount === 2, 'a material array is counted')
+ ok(report.problems.some((p) => /single material/i.test(p)),
+ 'multi-material assets are told to join down to one')
+}
+
+console.log(`editor paintability.test: ${passed} assertions passed ✓`)
diff --git a/src/editor/paintability.ts b/src/editor/paintability.ts
new file mode 100644
index 0000000..74bb114
--- /dev/null
+++ b/src/editor/paintability.ts
@@ -0,0 +1,88 @@
+/**
+ * Lane J — "can paint stick to this?" report for the blob body slot.
+ *
+ * Mirrors Lane I's `paintableInfo` (lanes/LANE-I §Deliverables 2) so integration
+ * can collapse the two. The checks exist because PaintSkin stamps into UV space
+ * through a raycast: no UV attribute means no stamps at all, UVs outside 0..1
+ * wrap into unrelated parts of the texture, and more than one material means
+ * PaintSkin's `mat.map = tex` lands on only one of them (the blob then reads as
+ * unpainted while coverage still climbs — a silent, maddening divergence).
+ */
+import * as THREE from 'three'
+
+export interface PaintableInfo {
+ /** A UV attribute exists and stays inside 0..1 (with a hair of tolerance). */
+ uvOk: boolean
+ triCount: number
+ materialCount: number
+ /** False when nothing mesh-like was found at all. */
+ hasMesh: boolean
+ /** Plain-language reasons the report is unhappy; empty when all good. */
+ problems: string[]
+}
+
+const UV_TOLERANCE = 0.001
+
+/** The mesh PaintSkin would end up bound to: the first Mesh in the subtree. */
+export function firstMesh(object: THREE.Object3D): THREE.Mesh | null {
+ let found: THREE.Mesh | null = null
+ object.traverse((o) => {
+ if (!found && (o as THREE.Mesh).isMesh) found = o as THREE.Mesh
+ })
+ return found
+}
+
+export function inspectPaintability(object: THREE.Object3D): PaintableInfo {
+ const mesh = firstMesh(object)
+ if (!mesh) {
+ return {
+ uvOk: false, triCount: 0, materialCount: 0, hasMesh: false,
+ problems: ['This file has no model in it that paint could stick to.'],
+ }
+ }
+
+ const geo = mesh.geometry
+ const problems: string[] = []
+ const uv = geo.getAttribute('uv') as THREE.BufferAttribute | undefined
+
+ let uvOk = false
+ if (!uv) {
+ problems.push('No UV map — paint has nowhere to land. Unwrap the model in Blender.')
+ } else {
+ let min = Infinity
+ let max = -Infinity
+ for (let i = 0; i < uv.count; i++) {
+ const u = uv.getX(i)
+ const v = uv.getY(i)
+ if (u < min) min = u
+ if (v < min) min = v
+ if (u > max) max = u
+ if (v > max) max = v
+ }
+ uvOk = min >= -UV_TOLERANCE && max <= 1 + UV_TOLERANCE
+ if (!uvOk) {
+ problems.push(
+ `UV map runs outside the square (${min.toFixed(2)} to ${max.toFixed(2)}) — ` +
+ 'splats will show up in the wrong places. Pack the UVs into 0–1.',
+ )
+ }
+ }
+
+ const index = geo.getIndex()
+ const position = geo.getAttribute('position') as THREE.BufferAttribute | undefined
+ const triCount = Math.floor((index ? index.count : (position?.count ?? 0)) / 3)
+
+ const materialCount = Array.isArray(mesh.material) ? mesh.material.length : 1
+ if (materialCount > 1) {
+ problems.push(
+ `${materialCount} materials on the body — paint only ever shows on one. ` +
+ 'Join it down to a single material in Blender.',
+ )
+ }
+ const single = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material
+ if (single && !(single as THREE.MeshStandardMaterial).isMeshStandardMaterial) {
+ problems.push('The body material is an unusual type — export it as a Principled BSDF.')
+ }
+
+ return { uvOk, triCount, materialCount, hasMesh: true, problems }
+}
diff --git a/src/editor/slot-swap.ts b/src/editor/slot-swap.ts
new file mode 100644
index 0000000..2a3d0a8
--- /dev/null
+++ b/src/editor/slot-swap.ts
@@ -0,0 +1,119 @@
+/**
+ * Lane J — the swap itself, as plain scene-graph surgery.
+ *
+ * Split out of stage.ts so it can be driven headlessly (no WebGL, no canvas):
+ * everything here is THREE object maths. The rule it encodes is the one the
+ * runtime has to follow too — a custom asset never replaces the procedural node,
+ * it goes into a NEW sibling "fit" node and the procedural node is hidden. That
+ * keeps reset instant, keeps every reference other systems captured alive, and
+ * keeps the manifest's offset/rotation/scale on a node nobody else writes to
+ * (feel.ts, telegraph.ts and the machine parts all own transforms of their own).
+ */
+import * as THREE from 'three'
+import { composeFit } from './manifest-io'
+import type { BaseTransform, SlotEntry } from './manifest-io'
+
+export const DEG = Math.PI / 180
+
+export interface SlotStageEntry {
+ id: string
+ /** What the game builds today. Hidden — never removed — while a swap is in. */
+ procedural: THREE.Object3D[]
+ /** Parent the fit node hangs off: the procedural object's own parent. */
+ parent: THREE.Object3D
+ /** Resting pose of the procedural object, in the units the manifest speaks. */
+ base: BaseTransform
+ /** Wrapper holding the current custom asset, or null when procedural. */
+ fitNode: THREE.Group | null
+}
+
+/** Read an object's pose in manifest units (degrees, not radians). */
+export function readBase(object: THREE.Object3D): BaseTransform {
+ const e = new THREE.Euler().setFromQuaternion(object.quaternion, 'XYZ')
+ return {
+ position: [object.position.x, object.position.y, object.position.z],
+ rotationDeg: [e.x / DEG, e.y / DEG, e.z / DEG],
+ scale: [object.scale.x, object.scale.y, object.scale.z],
+ }
+}
+
+/**
+ * Farm and Blender exports arrive with shadow flags off, which reads as a
+ * floating prop rather than a missing checkbox — so turn them on for everything
+ * that lands in a slot.
+ */
+export function dressAsset(object: THREE.Object3D): void {
+ object.traverse((o) => {
+ const m = o as THREE.Mesh
+ if (!m.isMesh) return
+ m.castShadow = true
+ m.receiveShadow = true
+ })
+}
+
+export class SlotSwap {
+ readonly slots = new Map()
+
+ /** Called once per slot as the stage builds. First object defines the pose. */
+ register(id: string, objects: THREE.Object3D[], fallbackParent: THREE.Object3D): void {
+ const first = objects[0]
+ if (!first) return
+ this.slots.set(id, {
+ id,
+ procedural: objects,
+ parent: first.parent ?? fallbackParent,
+ base: readBase(first),
+ fitNode: null,
+ })
+ }
+
+ has(id: string): boolean {
+ return this.slots.has(id)
+ }
+
+ setCustom(id: string, asset: THREE.Object3D, entry: SlotEntry): void {
+ const s = this.slots.get(id)
+ if (!s) return
+ this.clearCustom(id)
+ const fitNode = new THREE.Group()
+ fitNode.name = `fit:${id}`
+ dressAsset(asset)
+ fitNode.add(asset)
+ s.parent.add(fitNode)
+ s.fitNode = fitNode
+ for (const p of s.procedural) p.visible = false
+ this.applyFit(id, entry)
+ }
+
+ clearCustom(id: string): void {
+ const s = this.slots.get(id)
+ if (!s) return
+ if (s.fitNode) {
+ s.parent.remove(s.fitNode)
+ s.fitNode = null
+ }
+ for (const p of s.procedural) p.visible = true
+ }
+
+ applyFit(id: string, entry: SlotEntry): void {
+ const s = this.slots.get(id)
+ if (!s?.fitNode) return
+ const fit = composeFit(s.base, entry)
+ s.fitNode.position.set(fit.position[0], fit.position[1], fit.position[2])
+ s.fitNode.rotation.set(
+ fit.rotationDeg[0] * DEG, fit.rotationDeg[1] * DEG, fit.rotationDeg[2] * DEG)
+ s.fitNode.scale.set(fit.scale[0], fit.scale[1], fit.scale[2])
+ }
+
+ /** What the fit panel is acting on: the custom asset if any, else the original. */
+ currentObject(id: string): THREE.Object3D | null {
+ const s = this.slots.get(id)
+ if (!s) return null
+ return s.fitNode ?? s.procedural[0] ?? null
+ }
+
+ /** True when this slot is showing a custom asset. */
+ isCustom(id: string): boolean {
+ return this.slots.get(id)?.fitNode != null
+ }
+}
diff --git a/src/editor/slots.ts b/src/editor/slots.ts
new file mode 100644
index 0000000..bdc1c37
--- /dev/null
+++ b/src/editor/slots.ts
@@ -0,0 +1,156 @@
+/**
+ * Lane J — the slot catalogue the editor's left panel renders.
+ *
+ * Ids are docs/WORKSHOP-PLAN.md's table verbatim (minus the UI rows, which are
+ * out of scope for v1); Lane I's registry answers to the same strings, so this
+ * list is the contract between the two lanes. `label`/`hint` are written for
+ * John, not for a programmer — no slot ids appear on screen.
+ *
+ * THREE-free on purpose: the stage maps ids to objects, this file only names
+ * them, so the catalogue is testable and cheap to import anywhere.
+ */
+
+export type SlotGroupName = 'The blob' | 'Machines' | 'The course' | 'Effects'
+
+export interface SlotSpec {
+ id: string
+ group: SlotGroupName
+ /** Plain-language name shown in the list. */
+ label: string
+ /** One sentence under the name. */
+ hint: string
+ /** Where the camera should sit / look when you press "Show me". */
+ focus: { target: [number, number, number]; distance: number }
+ /** True for the paintable body — unlocks the paintability report. */
+ paintable?: boolean
+ /**
+ * Derived slots have no drop zone of their own: they copy another slot.
+ * (The ghost is a see-through copy of the blob, per the plan.)
+ */
+ derivedFrom?: string
+}
+
+export const SLOTS: SlotSpec[] = [
+ {
+ id: 'blob.body',
+ group: 'The blob',
+ label: 'Blob body',
+ hint: 'The thing you play as. Paint sticks to this one, so it needs tidy UVs.',
+ focus: { target: [0, 3.5, 30], distance: 6 },
+ paintable: true,
+ },
+ {
+ id: 'blob.face',
+ group: 'The blob',
+ label: 'Blob face',
+ hint: 'Just the eyes. Kept separate so paint never covers them.',
+ focus: { target: [0, 3.7, 30], distance: 4 },
+ },
+ {
+ id: 'ghost.body',
+ group: 'The blob',
+ label: 'Ghost racer',
+ hint: 'Your best-run ghost. It copies the blob body automatically.',
+ focus: { target: [3, 3.5, 30], distance: 6 },
+ derivedFrom: 'blob.body',
+ },
+ {
+ id: 'cannon.base',
+ group: 'Machines',
+ label: 'Paint cannon stand',
+ hint: 'The chunky foot the cannon sits on.',
+ focus: { target: [-9, 2.5, 14], distance: 7 },
+ },
+ {
+ id: 'cannon.barrel',
+ group: 'Machines',
+ label: 'Paint cannon barrel',
+ hint: 'The swivelling tube. It gets tinted the colour it shoots.',
+ focus: { target: [-9, 3, 14], distance: 6 },
+ },
+ {
+ id: 'machine.plate',
+ group: 'Machines',
+ label: 'Pressure plate',
+ hint: 'The pad a heavy blob stands on to set the boot off.',
+ focus: { target: [-6, 0.5, -47], distance: 9 },
+ },
+ {
+ id: 'machine.boot',
+ group: 'Machines',
+ label: 'Spring boot',
+ hint: 'Kicks you down the course. A ready-made boot model is in assets/meshes.',
+ focus: { target: [-6, 1, -49.5], distance: 9 },
+ },
+ {
+ id: 'machine.bucket',
+ group: 'Machines',
+ label: 'Paint bucket',
+ hint: 'Tips a colour over you. Its model must pivot at the lip and pour toward +X.',
+ focus: { target: [-1.4, 6, -50], distance: 10 },
+ },
+ {
+ id: 'machine.arch',
+ group: 'Machines',
+ label: 'Bubble wash',
+ hint: 'Walk through it and paint comes off.',
+ focus: { target: [-4.5, 2, -56], distance: 9 },
+ },
+ {
+ id: 'machine.belt',
+ group: 'Machines',
+ label: 'Conveyor belt',
+ hint: 'Slow-carries you along. The moving stripes stay animated.',
+ focus: { target: [0, 0.5, -50], distance: 12 },
+ },
+ {
+ id: 'course.scenery.cereal',
+ group: 'The course',
+ label: 'Giant cereal box',
+ hint: 'Big silly prop next to the start. Nothing depends on it — go wild.',
+ focus: { target: [-20, 7, 26], distance: 22 },
+ },
+ {
+ id: 'course.scenery.block',
+ group: 'The course',
+ label: 'Purple block',
+ hint: 'The other roadside prop. Also purely decorative.',
+ focus: { target: [20, 5, 12], distance: 20 },
+ },
+ {
+ id: 'course.tramp',
+ group: 'The course',
+ label: 'Gap launcher',
+ hint: 'The orange pad in the jump gap that throws you back up.',
+ focus: { target: [0, 0.25, 0], distance: 14 },
+ },
+ {
+ id: 'course.tunnel',
+ group: 'The course',
+ label: 'Low tunnel',
+ hint: 'The pink shortcut only a shrunken blob fits under. Keep the underside flat.',
+ focus: { target: [6.5, 1.3, -51], distance: 16 },
+ },
+ {
+ id: 'course.finish',
+ group: 'The course',
+ label: 'Finish podium',
+ hint: 'The pink pad at the end of the run.',
+ focus: { target: [0, 0.6, -64], distance: 14 },
+ },
+ {
+ id: 'fx.puddle',
+ group: 'Effects',
+ label: 'Paint puddle',
+ hint: 'The glossy colour patches on the ground. Swaps all of them at once.',
+ focus: { target: [0, 1.1, 12], distance: 10 },
+ },
+]
+
+export const SLOT_IDS: string[] = SLOTS.map((s) => s.id)
+
+export const slotById = (id: string): SlotSpec | undefined =>
+ SLOTS.find((s) => s.id === id)
+
+export const SLOT_GROUPS: SlotGroupName[] =
+ ['The blob', 'Machines', 'The course', 'Effects']
diff --git a/src/editor/stage.ts b/src/editor/stage.ts
new file mode 100644
index 0000000..4a8c222
--- /dev/null
+++ b/src/editor/stage.ts
@@ -0,0 +1,314 @@
+/**
+ * Lane J — the editor stage: the REAL course, built from the game's own builders.
+ *
+ * Fits are only truthful if you fit against the actual thing, so this imports
+ * buildGreybox / createBlob / installZones / the machine factories rather than
+ * re-modelling anything. Physics is created (the builders need it) but never
+ * stepped: `world.tick()` is never called and no system is registered, so the
+ * stage is a frozen tableau. Rendering is a plain rAF calling `world.renderOnce()`
+ * — deliberately NOT `world.start()`, which would advance the sim.
+ *
+ * Slot objects are captured two ways: handles where a builder returns one, and a
+ * scene-children diff where it doesn't (installZones and PaintCannon both just
+ * `scene.add` internally). The diff is why capture() exists.
+ */
+import * as THREE from 'three'
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
+import { cloneAsset } from './glb'
+import { createWorld } from '../world'
+import { buildGreybox } from '../course/greybox'
+import { createBlob } from '../blob/createBlob'
+import { installZones } from '../course/zones'
+import { PaintSkin } from '../paint/skin'
+import { PaintCannon } from '../paint/cannon'
+import { createBucketDump, createBubbleArch, createConveyorBelt } from '../machine/index'
+import type { World } from '../contracts'
+import { identityTransform } from './manifest-io'
+import type { SlotEntry } from './manifest-io'
+import { SlotSwap } from './slot-swap'
+import type { SlotStageEntry } from './slot-swap'
+import { SLOTS } from './slots'
+
+export type { SlotStageEntry } from './slot-swap'
+
+export interface EditorStage {
+ world: World
+ controls: OrbitControls
+ slots: Map
+ /** Swap a slot's visual for a loaded GLB scene (takes ownership of the object). */
+ setCustom(slot: string, asset: THREE.Object3D, entry: SlotEntry): void
+ /** Drop the custom asset and show today's procedural build again. */
+ clearCustom(slot: string): void
+ /** Re-apply offset/rotation/scale without reloading the asset. */
+ applyFit(slot: string, entry: SlotEntry): void
+ /** The object the fit panel is currently acting on (custom asset or procedural). */
+ currentObject(slot: string): THREE.Object3D | null
+ highlight(slot: string | null): void
+ focus(slot: string): void
+ dispose(): void
+}
+
+export { cloneAsset, loadGlb } from './glb'
+
+export async function createEditorStage(container: HTMLElement): Promise {
+ const world = await createWorld(container)
+ world.scene.fog = null // an editor wants to see the far end of the course
+
+ const swap = new SlotSwap()
+ const slots = swap.slots
+ const register = (id: string, objects: THREE.Object3D[]): void =>
+ swap.register(id, objects, world.scene)
+
+ /** Everything a builder adds straight to the scene, recovered by diffing. */
+ const capture = (fn: () => T): { result: T; added: THREE.Object3D[] } => {
+ const before = new Set(world.scene.children)
+ const result = fn()
+ return { result, added: world.scene.children.filter((c) => !before.has(c)) }
+ }
+
+ // ---- the course ----------------------------------------------------------
+ const course = buildGreybox(world)
+ const meshAt = (x: number, y: number, z: number): THREE.Mesh | undefined =>
+ course.meshes.find((m) =>
+ Math.abs(m.position.x - x) < 0.01 &&
+ Math.abs(m.position.y - y) < 0.01 &&
+ Math.abs(m.position.z - z) < 0.01)
+
+ const cereal = meshAt(-20, 7, 26)
+ if (cereal) register('course.scenery.cereal', [cereal])
+ const block = meshAt(20, 5, 12)
+ if (block) register('course.scenery.block', [block])
+ const finish = meshAt(0, 0.6, -64)
+ if (finish) register('course.finish', [finish])
+
+ // ---- blob ----------------------------------------------------------------
+ const blob = createBlob(world, { position: course.spawn })
+ const skin = new PaintSkin(blob.mesh, { size: 64 }) // small: nothing paints here
+ blob.paint = skin
+ world.blob = blob
+ register('blob.body', [blob.mesh])
+ register('blob.face', [blob.face])
+
+ // Ghost preview. The shipped GhostPlayer only builds itself when a saved run
+ // exists, so the editor shows the same thing it would derive: a see-through
+ // copy of whatever is in the body slot, parked beside the blob.
+ const ghostRoot = new THREE.Group()
+ ghostRoot.position.set(course.spawn.x + 2.2, course.spawn.y, course.spawn.z)
+ world.scene.add(ghostRoot)
+ const rebuildGhost = (source: THREE.Object3D): void => {
+ ghostRoot.clear()
+ const copy = cloneAsset(source)
+ copy.position.set(0, 0, 0)
+ copy.rotation.set(0, 0, 0)
+ copy.traverse((o) => {
+ const m = o as THREE.Mesh
+ if (!m.isMesh) return
+ // Clone the material: the source may share it with the real blob (or with
+ // other instances via the asset cache) and we must not make those sheer.
+ const src = Array.isArray(m.material) ? m.material[0] : m.material
+ const ghostMat = (src as THREE.Material).clone() as THREE.MeshStandardMaterial
+ ghostMat.transparent = true
+ ghostMat.opacity = 0.35
+ ghostMat.depthWrite = false
+ m.material = ghostMat
+ })
+ copy.renderOrder = 2
+ ghostRoot.add(copy)
+ }
+ rebuildGhost(blob.mesh)
+ register('ghost.body', [ghostRoot.children[0]!])
+
+ // ---- zones: puddles, the purple fork's plate + boot, the MINI tunnel ------
+ // installZones registers systems, but nothing ever ticks them here.
+ const zoned = capture(() => installZones(world, blob, skin))
+ const zones = zoned.result
+ // The plate and boot are the only Groups zones adds; the tunnel's roof/bar/
+ // posts arrive as loose Meshes. Order is stable (plate, then boot, then tunnel).
+ const zoneGroups = zoned.added.filter((o) => o instanceof THREE.Group)
+ if (zoneGroups[0]) register('machine.plate', [zoneGroups[0]])
+ if (zoneGroups[1]) register('machine.boot', [zoneGroups[1]])
+ if (zones.puddles.length) register('fx.puddle', zones.puddles.map((p) => p.mesh))
+ // The tunnel is whatever loose Meshes are left once the puddles are accounted
+ // for — installPuddles runs FIRST and also adds bare Meshes to the scene, so
+ // filtering on "not a Group" alone would swallow all ten puddles.
+ const puddleMeshes = new Set(zones.puddles.map((p) => p.mesh))
+ const tunnelParts = zoned.added.filter(
+ (o) => !(o instanceof THREE.Group) && !puddleMeshes.has(o))
+ if (tunnelParts.length) register('course.tunnel', tunnelParts)
+
+ // ---- centre-lane machines (same configs as the shipped game) -------------
+ const bucket = createBucketDump(world, {
+ id: 'bucket-1', position: [-1.4, 6, -50], color: 'purple', radius: 0.8,
+ })
+ register('machine.bucket', [bucket.group])
+ const belt = createConveyorBelt(world, {
+ id: 'belt-1', position: [0, 0.0, -50], size: [3, 0.5, 10], velocity: [0, 0, -3],
+ })
+ register('machine.belt', [belt.group])
+ const arch = createBubbleArch(world, {
+ id: 'arch-1', position: [-4.5, 0.12, -56], fraction: 0.6,
+ })
+ register('machine.arch', [arch.group])
+
+ // ---- cannons: built for their look only; never added as systems ----------
+ const cannonCaptures = zones.cannonConfigs.map((spec) =>
+ capture(() => new PaintCannon({
+ world,
+ position: new THREE.Vector3(...spec.position),
+ color: spec.color,
+ target: blob.mesh,
+ paint: skin,
+ targetRadius: blob.radius,
+ })))
+ const barrels = cannonCaptures.flatMap((c) => c.added)
+ // A barrel group is [base mesh, 'pivot' group] — the plan splits those into
+ // two slots, and PaintCannon.aimBarrel looks the pivot up BY NAME, so the
+ // pivot node must survive any swap (a custom barrel goes inside it).
+ const bases = barrels.map((b) => b.children[0]).filter(Boolean) as THREE.Object3D[]
+ const pivots = barrels
+ .map((b) => b.children.find((c) => c.name === 'pivot'))
+ .filter(Boolean) as THREE.Object3D[]
+ if (bases.length) register('cannon.base', bases)
+ if (pivots.length) register('cannon.barrel', pivots)
+
+ // ---- gap launcher --------------------------------------------------------
+ // game.ts builds this inline and game.ts is frozen, so the stage mirrors its
+ // geometry here. Reported as friction: the editor can preview a fit for this
+ // slot but nothing reads it until game.ts asks the registry.
+ const tramp = new THREE.Mesh(
+ new THREE.BoxGeometry(12, 0.5, 4),
+ new THREE.MeshStandardMaterial({ color: '#FF9500', roughness: 0.6 }))
+ tramp.position.set(0, 0.25, 0)
+ tramp.receiveShadow = true
+ world.scene.add(tramp)
+ register('course.tramp', [tramp])
+
+ // ---- camera + orbit ------------------------------------------------------
+ world.camera.position.set(14, 12, 34)
+ const controls = new OrbitControls(world.camera, world.renderer.domElement)
+ controls.target.set(0, 2, 22)
+ controls.enableDamping = true
+ controls.maxPolarAngle = Math.PI * 0.49 // never orbit under the floor
+ controls.update()
+
+ // ---- selection highlight -------------------------------------------------
+ const marker = new THREE.Mesh(
+ new THREE.SphereGeometry(1, 20, 14),
+ new THREE.MeshBasicMaterial({
+ color: '#FFD60A', wireframe: true, transparent: true, opacity: 0.85,
+ }))
+ marker.visible = false
+ marker.renderOrder = 3
+ world.scene.add(marker)
+ const markerBox = new THREE.Box3()
+ const markerCenter = new THREE.Vector3()
+ const markerSize = new THREE.Vector3()
+ let markerPulse = 0
+
+ const highlight = (slot: string | null): void => {
+ const entry = slot ? slots.get(slot) : undefined
+ const object = entry ? (entry.fitNode ?? entry.procedural[0]) : undefined
+ if (!object) {
+ marker.visible = false
+ return
+ }
+ object.updateWorldMatrix(true, true)
+ markerBox.setFromObject(object)
+ if (markerBox.isEmpty()) {
+ marker.visible = false
+ return
+ }
+ markerBox.getCenter(markerCenter)
+ markerBox.getSize(markerSize)
+ marker.position.copy(markerCenter)
+ marker.scale.setScalar(Math.max(0.6, markerSize.length() * 0.55))
+ marker.visible = true
+ }
+
+ let selected: string | null = null
+
+ // ---- render loop: frame hooks + render only. Physics never advances. -----
+ let raf = 0
+ let disposed = false
+ const loop = (): void => {
+ if (disposed) return
+ raf = requestAnimationFrame(loop)
+ controls.update()
+ markerPulse += 0.03
+ if (marker.visible) {
+ const s = 1 + Math.sin(markerPulse) * 0.04
+ marker.scale.multiplyScalar(s / (marker.userData.lastPulse ?? 1))
+ marker.userData.lastPulse = s
+ }
+ world.renderOnce()
+ }
+ raf = requestAnimationFrame(loop)
+
+ const stage: EditorStage = {
+ world,
+ controls,
+ slots,
+
+ setCustom(slot, asset, entry) {
+ swap.setCustom(slot, asset, entry)
+ if (slot === 'blob.body') rebuildGhost(asset)
+ if (selected === slot) highlight(slot)
+ },
+
+ clearCustom(slot) {
+ swap.clearCustom(slot)
+ if (slot === 'blob.body') rebuildGhost(blob.mesh)
+ if (selected === slot) highlight(slot)
+ },
+
+ applyFit(slot, entry) {
+ swap.applyFit(slot, entry)
+ if (selected === slot) highlight(slot)
+ },
+
+ currentObject(slot) {
+ return swap.currentObject(slot)
+ },
+
+ highlight(slot) {
+ selected = slot
+ highlight(slot)
+ },
+
+ focus(slot) {
+ const spec = SLOTS.find((x) => x.id === slot)
+ const s = slots.get(slot)
+ const target = new THREE.Vector3()
+ if (s) {
+ const object = s.fitNode ?? s.procedural[0]!
+ object.updateWorldMatrix(true, true)
+ const box = new THREE.Box3().setFromObject(object)
+ if (!box.isEmpty()) box.getCenter(target)
+ else object.getWorldPosition(target)
+ } else if (spec) {
+ target.set(...spec.focus.target)
+ }
+ const distance = spec?.focus.distance ?? 10
+ controls.target.copy(target)
+ world.camera.position.set(
+ target.x + distance * 0.55, target.y + distance * 0.5, target.z + distance * 0.8)
+ controls.update()
+ },
+
+ dispose() {
+ disposed = true
+ cancelAnimationFrame(raf)
+ controls.dispose()
+ world.renderer.dispose()
+ world.renderer.domElement.remove()
+ },
+ }
+
+ // Nothing is selected on boot; keep the marker off so an untouched session is
+ // visually identical to the game's own course.
+ return stage
+}
+
+export const stageSlotIds = (stage: EditorStage): string[] => [...stage.slots.keys()]
+
+export { identityTransform }
diff --git a/src/editor/store.ts b/src/editor/store.ts
new file mode 100644
index 0000000..ea40fe9
--- /dev/null
+++ b/src/editor/store.ts
@@ -0,0 +1,68 @@
+/**
+ * Lane J — working-manifest persistence on top of idb.ts.
+ *
+ * Two things live here: the manifest the game reads on boot, and the raw GLB
+ * bytes it references via `idb:` urls. Everything is tolerant — a corrupt
+ * record reads back as an empty manifest rather than blocking the editor.
+ */
+import {
+ BLOB_STORE, MANIFEST_KEY, MANIFEST_STORE,
+ idbClear, idbDelete, idbGet, idbKeys, idbPut,
+} from './idb'
+import { cleanManifest, idbKey, isIdbUrl, parseManifest } from './manifest-io'
+import type { Manifest } from './manifest-io'
+
+/** What the `blobs` store holds per key (name kept so exports can suggest a path). */
+export interface StoredGlb {
+ name: string
+ bytes: ArrayBuffer
+ savedAt: number
+}
+
+export async function loadOverrideManifest(): Promise {
+ const raw = await idbGet(MANIFEST_STORE, MANIFEST_KEY)
+ if (typeof raw === 'string') return parseManifest(raw)
+ if (raw && typeof raw === 'object') return parseManifest(JSON.stringify(raw))
+ return {}
+}
+
+/** Stored as an object (not a string) so Lane I's loader can read it either way. */
+export const saveOverrideManifest = (manifest: Manifest): Promise =>
+ idbPut(MANIFEST_STORE, MANIFEST_KEY, cleanManifest(manifest))
+
+/** A stable, human-readable blob key: `-`. */
+export const blobKeyFor = (slot: string, fileName: string): string =>
+ `${slot}-${fileName}`.replace(/[^a-zA-Z0-9._-]/g, '_')
+
+export async function putGlb(key: string, name: string, bytes: ArrayBuffer): Promise {
+ const record: StoredGlb = { name, bytes, savedAt: Date.now() }
+ await idbPut(BLOB_STORE, key, record)
+ return `idb:${key}`
+}
+
+export async function getGlb(key: string): Promise {
+ const raw = await idbGet(BLOB_STORE, key)
+ if (!raw || !(raw.bytes instanceof ArrayBuffer)) return undefined
+ return raw
+}
+
+/** Every override gone: manifest record and all stored GLB bytes. */
+export async function clearOverrides(): Promise {
+ await idbDelete(MANIFEST_STORE, MANIFEST_KEY)
+ await idbClear(BLOB_STORE)
+}
+
+/** Blob keys with nothing pointing at them any more (drop-then-reset leftovers). */
+export async function orphanBlobKeys(manifest: Manifest): Promise {
+ const live = new Set(
+ Object.values(manifest).filter((e) => isIdbUrl(e.url)).map((e) => idbKey(e.url)),
+ )
+ const keys = await idbKeys(BLOB_STORE)
+ return keys.map(String).filter((k) => !live.has(k))
+}
+
+export async function pruneOrphans(manifest: Manifest): Promise {
+ const orphans = await orphanBlobKeys(manifest)
+ for (const k of orphans) await idbDelete(BLOB_STORE, k)
+ return orphans.length
+}
diff --git a/src/editor/tiny-glb.ts b/src/editor/tiny-glb.ts
new file mode 100644
index 0000000..e4149a1
--- /dev/null
+++ b/src/editor/tiny-glb.ts
@@ -0,0 +1,132 @@
+/**
+ * Lane J — a valid, tiny GLB built in memory.
+ *
+ * Exists so the drop pipeline can be exercised without a 13 MB farm asset:
+ * `assets/` is NOT part of the vite build graph today (nothing copies it into
+ * dist/), so the demo can't count on a real .glb being fetchable. This produces
+ * a two-triangle, single-material, 0–1-UV quad — the shape a paintability check
+ * should be happy with — as raw GLB bytes.
+ *
+ * Pure ArrayBuffer maths: no THREE, no DOM, runs under node.
+ */
+
+const MAGIC = 0x46546c67 // 'glTF'
+const CHUNK_JSON = 0x4e4f534a // 'JSON'
+const CHUNK_BIN = 0x004e4942 // 'BIN\0'
+
+const pad4 = (n: number): number => (n + 3) & ~3
+
+export interface TinyGlbOptions {
+ /** Quad half-size in metres. Default 0.5 (a 1u-wide plate). */
+ half?: number
+ /** Material base colour as [r,g,b,a] in 0..1. Default a workshop orange. */
+ color?: [number, number, number, number]
+ /** Omit UVs to produce a deliberately unpaintable asset (for testing warnings). */
+ withUv?: boolean
+}
+
+export function buildTinyGlb(opts: TinyGlbOptions = {}): ArrayBuffer {
+ const h = opts.half ?? 0.5
+ const color = opts.color ?? [1, 0.58, 0, 1]
+ const withUv = opts.withUv !== false
+
+ const positions = new Float32Array([
+ -h, 0, -h,
+ h, 0, -h,
+ h, 0, h,
+ -h, 0, h,
+ ])
+ const uvs = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1])
+ const indices = new Uint16Array([0, 1, 2, 0, 2, 3])
+
+ const posBytes = positions.byteLength // 48
+ const uvBytes = withUv ? uvs.byteLength : 0 // 32
+ const idxBytes = indices.byteLength // 12
+
+ const posOffset = 0
+ const uvOffset = posOffset + posBytes
+ const idxOffset = pad4(uvOffset + uvBytes)
+ const binLength = idxOffset + idxBytes
+
+ const bin = new ArrayBuffer(pad4(binLength))
+ new Uint8Array(bin, posOffset, posBytes).set(new Uint8Array(positions.buffer))
+ if (withUv) new Uint8Array(bin, uvOffset, uvBytes).set(new Uint8Array(uvs.buffer))
+ new Uint8Array(bin, idxOffset, idxBytes).set(new Uint8Array(indices.buffer))
+
+ const bufferViews: unknown[] = [
+ { buffer: 0, byteOffset: posOffset, byteLength: posBytes, target: 34962 },
+ ]
+ const accessors: unknown[] = [
+ {
+ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3',
+ min: [-h, 0, -h], max: [h, 0, h],
+ },
+ ]
+ const attributes: Record = { POSITION: 0 }
+ if (withUv) {
+ bufferViews.push({ buffer: 0, byteOffset: uvOffset, byteLength: uvBytes, target: 34962 })
+ accessors.push({
+ bufferView: bufferViews.length - 1, componentType: 5126, count: 4, type: 'VEC2',
+ min: [0, 0], max: [1, 1],
+ })
+ attributes.TEXCOORD_0 = accessors.length - 1
+ }
+ bufferViews.push({ buffer: 0, byteOffset: idxOffset, byteLength: idxBytes, target: 34963 })
+ accessors.push({
+ bufferView: bufferViews.length - 1, componentType: 5123, count: 6, type: 'SCALAR',
+ min: [0], max: [3],
+ })
+ const indexAccessor = accessors.length - 1
+
+ const gltf = {
+ asset: { version: '2.0', generator: 'BLOBBO workshop tiny-glb' },
+ scene: 0,
+ scenes: [{ nodes: [0] }],
+ nodes: [{ mesh: 0, name: 'TinyQuad' }],
+ meshes: [{
+ name: 'TinyQuad',
+ primitives: [{ attributes, indices: indexAccessor, material: 0, mode: 4 }],
+ }],
+ materials: [{
+ name: 'TinyMat',
+ pbrMetallicRoughness: { baseColorFactor: color, metallicFactor: 0, roughnessFactor: 0.6 },
+ }],
+ accessors,
+ bufferViews,
+ buffers: [{ byteLength: bin.byteLength }],
+ }
+
+ const jsonText = JSON.stringify(gltf)
+ const jsonBytes = new TextEncoder().encode(jsonText)
+ const jsonPadded = pad4(jsonBytes.length)
+
+ const total = 12 + 8 + jsonPadded + 8 + bin.byteLength
+ const out = new ArrayBuffer(total)
+ const view = new DataView(out)
+ const bytes = new Uint8Array(out)
+
+ view.setUint32(0, MAGIC, true)
+ view.setUint32(4, 2, true)
+ view.setUint32(8, total, true)
+
+ view.setUint32(12, jsonPadded, true)
+ view.setUint32(16, CHUNK_JSON, true)
+ bytes.set(jsonBytes, 20)
+ // glTF requires JSON chunk padding to be spaces, BIN padding to be zeroes.
+ for (let i = jsonBytes.length; i < jsonPadded; i++) bytes[20 + i] = 0x20
+
+ const binChunkStart = 20 + jsonPadded
+ view.setUint32(binChunkStart, bin.byteLength, true)
+ view.setUint32(binChunkStart + 4, CHUNK_BIN, true)
+ bytes.set(new Uint8Array(bin), binChunkStart + 8)
+
+ return out
+}
+
+/** Cheap structural validation — the header checks GLTFLoader does first. */
+export function isGlb(buffer: ArrayBuffer): boolean {
+ if (buffer.byteLength < 20) return false
+ const view = new DataView(buffer)
+ return view.getUint32(0, true) === MAGIC &&
+ view.getUint32(8, true) === buffer.byteLength
+}
diff --git a/src/editor/workshop.test.ts b/src/editor/workshop.test.ts
new file mode 100644
index 0000000..ff61345
--- /dev/null
+++ b/src/editor/workshop.test.ts
@@ -0,0 +1,196 @@
+/**
+ * Headless end-to-end check of the drop → store → swap → export → restore path.
+ *
+ * The real SlotSwap does the scene surgery (it is pure THREE, no WebGL), the
+ * real Workshop drives it, and a small in-memory stand-in plays IndexedDB so the
+ * persistence path is exercised rather than mocked away. Only the WebGL stage
+ * shell and the DOM panels are absent — those are the parts this cannot reach
+ * without a browser.
+ *
+ * node --experimental-strip-types --import ./src/editor/node-ts-resolve.mjs \
+ * src/editor/workshop.test.ts
+ */
+import * as THREE from 'three'
+
+// ---- in-memory IndexedDB (installed before anything imports the store) -----
+type Store = Map
+const dbs = new Map>()
+
+function fireLater(req: { onsuccess: (() => void) | null; result: T }): void {
+ queueMicrotask(() => req.onsuccess?.())
+}
+
+const fakeIndexedDb = {
+ open(name: string) {
+ const req: Record = { result: null, onsuccess: null, onerror: null, onupgradeneeded: null }
+ const stores = dbs.get(name) ?? new Map()
+ dbs.set(name, stores)
+ const db = {
+ objectStoreNames: { contains: (s: string) => stores.has(s) },
+ createObjectStore: (s: string) => void stores.set(s, new Map()),
+ transaction: (s: string) => ({
+ objectStore: (_n: string) => {
+ const store = stores.get(s)!
+ const wrap = (result: T): Record => {
+ const r: Record = { result, onsuccess: null, onerror: null }
+ fireLater(r as never)
+ return r
+ }
+ return {
+ get: (k: string) => wrap(store.get(k)),
+ put: (v: unknown, k: string) => { store.set(k, v); return wrap(undefined) },
+ delete: (k: string) => { store.delete(k); return wrap(undefined) },
+ getAllKeys: () => wrap([...store.keys()]),
+ clear: () => { store.clear(); return wrap(undefined) },
+ }
+ },
+ }),
+ }
+ req.result = db
+ queueMicrotask(() => {
+ ;(req.onupgradeneeded as (() => void) | null)?.()
+ ;(req.onsuccess as (() => void) | null)?.()
+ })
+ return req
+ },
+}
+;(globalThis as unknown as { indexedDB: unknown }).indexedDB = fakeIndexedDb
+
+const { Workshop } = await import('./workshop')
+const { SlotSwap } = await import('./slot-swap')
+const { buildTinyGlb } = await import('./tiny-glb')
+const { manifestsEqual, parseManifest, isIdbUrl, idbKey } = await import('./manifest-io')
+const { getGlb, loadOverrideManifest } = await import('./store')
+type EditorStage = import('./stage').EditorStage
+
+let passed = 0
+function ok(cond: boolean, msg: string): void {
+ if (!cond) throw new Error('FAIL: ' + msg)
+ passed++
+}
+
+// ---- a stage stand-in whose swap logic is the REAL SlotSwap ----------------
+const scene = new THREE.Scene()
+const swap = new SlotSwap()
+
+const makeProcedural = (name: string, pos: [number, number, number]): THREE.Mesh => {
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
+ mesh.name = name
+ mesh.position.set(...pos)
+ scene.add(mesh)
+ return mesh
+}
+swap.register('machine.boot', [makeProcedural('boot', [-6, 0.2, -49.5])], scene)
+swap.register('blob.body', [makeProcedural('body', [0, 3.5, 30])], scene)
+
+type Entry = import('./manifest-io').SlotEntry
+const stage = {
+ slots: swap.slots,
+ setCustom: (s: string, a: THREE.Object3D, e: Entry) => swap.setCustom(s, a, e),
+ clearCustom: (s: string) => swap.clearCustom(s),
+ applyFit: (s: string, e: Entry) => swap.applyFit(s, e),
+ currentObject: (s: string) => swap.currentObject(s),
+ highlight: () => {},
+ focus: () => {},
+ dispose: () => {},
+} as unknown as EditorStage
+
+const workshop = new Workshop(stage)
+const BOOT = 'machine.boot'
+const bootEntry = swap.slots.get(BOOT)!
+
+// ---- clean session ---------------------------------------------------------
+ok(!workshop.hasCustom(BOOT), 'nothing is swapped on a fresh session')
+ok(bootEntry.fitNode === null, 'no fit wrapper exists yet')
+ok(JSON.stringify(workshop.getManifest()) === '{}', 'a fresh session exports an empty manifest')
+
+// ---- the drop --------------------------------------------------------------
+const bytes = buildTinyGlb({ half: 1.1 })
+const dropped = await workshop.dropFile(BOOT, 'prop-spring-boot.glb', bytes)
+ok(!dropped.rejected, `the drop was accepted (${dropped.message})`)
+ok(bootEntry.fitNode !== null, 'the swap created a fit wrapper')
+ok(bootEntry.fitNode!.children.length === 1, 'the dropped model is inside the wrapper')
+ok(bootEntry.procedural.every((p) => !p.visible), 'the procedural boot was hidden')
+ok(bootEntry.procedural[0]!.parent === scene, 'the procedural boot is still in the scene, just hidden')
+ok(isIdbUrl(dropped.entry.url), 'the manifest entry uses an idb: url')
+const stored = await getGlb(idbKey(dropped.entry.url))
+ok(stored !== undefined && stored.bytes.byteLength === bytes.byteLength,
+ 'the GLB bytes reached storage intact')
+ok(workshop.hasCustom(BOOT), 'the slot now reports a custom asset')
+
+// shadows: a farm export arrives with them off and must not float
+let anyShadow = false
+bootEntry.fitNode!.traverse((o) => { if ((o as THREE.Mesh).isMesh && o.castShadow) anyShadow = true })
+ok(anyShadow, 'the dropped model casts a shadow (not left floating)')
+
+// the swap lands exactly on the procedural pose with no fit applied
+ok(bootEntry.fitNode!.position.distanceTo(bootEntry.procedural[0]!.position) < 1e-9,
+ 'with no nudging, the custom model sits exactly where the original stood')
+
+// ---- fit -------------------------------------------------------------------
+workshop.setFit(BOOT, { offset: [0, 1.5, 0], rotationDeg: [0, 90, 0], scale: 2 })
+ok(Math.abs(bootEntry.fitNode!.position.y - (0.2 + 1.5)) < 1e-9, 'raising it moves it exactly that far')
+ok(Math.abs(bootEntry.fitNode!.rotation.y - Math.PI / 2) < 1e-9, 'turning it rotates it')
+ok(Math.abs(bootEntry.fitNode!.scale.x - 2) < 1e-9, 'resizing it scales it')
+
+// ---- export round-trip -----------------------------------------------------
+const text = workshop.exportText()
+const back = parseManifest(text)
+ok(manifestsEqual(workshop.getManifest(), back), 'the exported manifest round-trips exactly')
+ok(back[BOOT]!.offset?.[1] === 1.5, 'the fit survives export and re-import')
+
+// ---- values survive a slot switch -----------------------------------------
+await workshop.dropFile('blob.body', 'tiny.glb', buildTinyGlb())
+ok(workshop.entry(BOOT)?.offset?.[1] === 1.5, "another slot's drop does not disturb the boot's fit")
+ok(workshop.hasCustom('blob.body'), 'the second slot swapped too')
+
+// ---- a body that paint cannot stick to is refused --------------------------
+workshop.reset('blob.body')
+const bad = await workshop.dropFile('blob.body', 'no-uvs.glb', buildTinyGlb({ withUv: false }))
+ok(bad.rejected, 'a body model with no UV map is refused')
+ok(!workshop.hasCustom('blob.body'), 'the refused model is not written into the manifest')
+ok(swap.slots.get('blob.body')!.procedural.every((p) => p.visible),
+ 'the plain blob is still on screen after a refusal')
+ok(bad.paint !== undefined && !bad.paint.uvOk, 'the refusal carries the paint report')
+ok(/paint would not stick/i.test(bad.message), 'the refusal is explained in plain words')
+
+// ---- junk bytes are refused, not thrown ------------------------------------
+const junk = await workshop.dropFile(BOOT, 'notes.txt', new TextEncoder().encode('hello').buffer)
+ok(junk.rejected, 'a non-GLB file is refused')
+ok(bootEntry.fitNode !== null, 'a refused drop leaves the previous good swap alone')
+ok(workshop.entry(BOOT)?.url === dropped.entry.url, 'a refused drop does not overwrite the manifest entry')
+
+// ---- derived slots refuse drops with an explanation ------------------------
+swap.register('ghost.body', [makeProcedural('ghost', [2, 3.5, 30])], scene)
+const ghost = await workshop.dropFile('ghost.body', 'x.glb', buildTinyGlb())
+ok(ghost.rejected, 'the ghost slot refuses its own drop')
+ok(/blob body/i.test(ghost.message), 'it points you at the blob body slot instead')
+
+// ---- save + restore --------------------------------------------------------
+const savedCount = await workshop.saveLocally()
+ok(savedCount === 1, `saving wrote the one live swap (got ${savedCount})`)
+const persisted = await loadOverrideManifest()
+ok(manifestsEqual(workshop.getManifest(), persisted),
+ 'what was saved is exactly what the live game reads back')
+
+const reopened = new Workshop(stage)
+swap.clearCustom(BOOT)
+const restored = await reopened.restore()
+ok(restored.includes(BOOT), 'reopening the editor brings the swap back')
+ok(bootEntry.fitNode !== null, 'the restored swap is on the stage again')
+ok(Math.abs(bootEntry.fitNode!.position.y - 1.7) < 1e-9, 'the restored swap keeps its fit')
+
+// ---- reset -----------------------------------------------------------------
+reopened.reset(BOOT)
+ok(bootEntry.fitNode === null, 'reset removes the custom model')
+ok(bootEntry.procedural.every((p) => p.visible), 'reset shows the original again')
+ok(!reopened.hasCustom(BOOT), 'reset clears the manifest entry')
+
+// ---- clear everything ------------------------------------------------------
+await workshop.clearEverything()
+const afterClear = await loadOverrideManifest()
+ok(Object.keys(afterClear).length === 0, 'Clear wipes the saved manifest')
+ok(JSON.stringify(workshop.getManifest()) === '{}', 'Clear empties the working manifest')
+ok(swap.slots.get(BOOT)!.procedural.every((p) => p.visible), 'Clear puts every original back on screen')
+
+console.log(`editor workshop.test: ${passed} assertions passed ✓`)
diff --git a/src/editor/workshop.ts b/src/editor/workshop.ts
new file mode 100644
index 0000000..b9ed438
--- /dev/null
+++ b/src/editor/workshop.ts
@@ -0,0 +1,211 @@
+/**
+ * Lane J — the editor's brain, with no DOM in it.
+ *
+ * Holds the working manifest, owns the drop → store → swap pipeline, and is the
+ * single code path both the real drag-drop and the demo's scripted drop go
+ * through (so a green demo means the real button works).
+ */
+import * as THREE from 'three'
+import type { EditorStage } from './stage'
+import { cloneAsset, loadGlb } from './glb'
+import { inspectPaintability } from './paintability'
+import type { PaintableInfo } from './paintability'
+import {
+ cleanManifest, idbKey, isIdbUrl, serializeManifest,
+} from './manifest-io'
+import type { Manifest, SlotEntry } from './manifest-io'
+import {
+ blobKeyFor, clearOverrides, getGlb, loadOverrideManifest, pruneOrphans,
+ putGlb, saveOverrideManifest,
+} from './store'
+import { slotById } from './slots'
+
+export interface DropResult {
+ slot: string
+ entry: SlotEntry
+ /** Only produced for the paintable body slot. */
+ paint?: PaintableInfo
+ /** True when the asset was rejected and the procedural build was kept. */
+ rejected: boolean
+ /** Plain-language outcome for the status line. */
+ message: string
+}
+
+export class Workshop {
+ private manifest: Manifest = {}
+ /** Parsed GLB scenes by url, so a slot switch never re-parses. */
+ private readonly cache = new Map()
+ private readonly paintReports = new Map()
+ private readonly stage: EditorStage
+
+ // Plain assignment rather than a parameter property: node's type-stripping
+ // mode (how the .test.ts files run) rejects parameter properties outright.
+ constructor(stage: EditorStage) {
+ this.stage = stage
+ }
+
+ getManifest(): Manifest {
+ return cleanManifest(this.manifest)
+ }
+
+ entry(slot: string): SlotEntry | undefined {
+ return this.manifest[slot]
+ }
+
+ paintReport(slot: string): PaintableInfo | undefined {
+ return this.paintReports.get(slot)
+ }
+
+ hasCustom(slot: string): boolean {
+ return this.manifest[slot] !== undefined
+ }
+
+ /** Re-hydrate everything the browser remembered from a previous session. */
+ async restore(): Promise {
+ const stored = await loadOverrideManifest()
+ const restored: string[] = []
+ for (const [slot, entry] of Object.entries(stored)) {
+ if (!this.stage.slots.has(slot)) continue
+ const ok = await this.applyEntry(slot, entry)
+ if (ok) {
+ this.manifest[slot] = entry
+ restored.push(slot)
+ }
+ }
+ return restored
+ }
+
+ /**
+ * The one drop path. Bytes in, stage swapped, manifest updated.
+ * A body asset that paint can't stick to is REJECTED and the procedural
+ * sphere stays: broken paint is worse than a missing model.
+ */
+ async dropFile(slot: string, fileName: string, bytes: ArrayBuffer): Promise {
+ const spec = slotById(slot)
+ if (!spec || !this.stage.slots.has(slot)) {
+ return { slot, entry: { url: '' }, rejected: true, message: `No such slot: ${slot}` }
+ }
+ if (spec.derivedFrom) {
+ return {
+ slot, entry: { url: '' }, rejected: true,
+ message: `${spec.label} copies "${slotById(spec.derivedFrom)?.label}" — drop your model there instead.`,
+ }
+ }
+
+ let scene: THREE.Object3D
+ try {
+ scene = await loadGlb(bytes)
+ } catch (err) {
+ console.warn(`[workshop] ${fileName} could not be read as a .glb`, err)
+ return {
+ slot, entry: { url: '' }, rejected: true,
+ message: `${fileName} isn't a .glb file this browser can read. Try exporting again from Blender.`,
+ }
+ }
+
+ let paint: PaintableInfo | undefined
+ if (spec.paintable) {
+ paint = inspectPaintability(scene)
+ this.paintReports.set(slot, paint)
+ if (!paint.uvOk) {
+ return {
+ slot, entry: { url: '' }, rejected: true, paint,
+ message: 'Paint would not stick to this model, so the plain blob is still in. ' +
+ (paint.problems[0] ?? ''),
+ }
+ }
+ }
+
+ const key = blobKeyFor(slot, fileName)
+ const url = await putGlb(key, fileName, bytes)
+ this.cache.set(url, scene)
+
+ const previous = this.manifest[slot]
+ const entry: SlotEntry = {
+ url,
+ offset: previous?.offset ?? [0, 0, 0],
+ rotationDeg: previous?.rotationDeg ?? [0, 0, 0],
+ scale: previous?.scale ?? 1,
+ }
+ this.manifest[slot] = entry
+ this.stage.setCustom(slot, cloneAsset(scene), entry)
+ return {
+ slot, entry, paint, rejected: false,
+ message: `${fileName} is in the ${spec.label.toLowerCase()} slot.`,
+ }
+ }
+
+ /** Nudge the fit. Re-applies to the stage immediately; nothing is re-parsed. */
+ setFit(slot: string, patch: Partial): SlotEntry | undefined {
+ const current = this.manifest[slot]
+ if (!current) return undefined
+ const next: SlotEntry = { ...current, ...patch }
+ this.manifest[slot] = next
+ this.stage.applyFit(slot, next)
+ return next
+ }
+
+ /** Back to what the game builds today. */
+ reset(slot: string): void {
+ delete this.manifest[slot]
+ this.paintReports.delete(slot)
+ this.stage.clearCustom(slot)
+ }
+
+ /** Write the working manifest where the live game will find it. */
+ async saveLocally(): Promise {
+ const clean = cleanManifest(this.manifest)
+ await saveOverrideManifest(clean)
+ await pruneOrphans(clean)
+ return Object.keys(clean).length
+ }
+
+ /** Wipe every override — the game goes back to its shipped look. */
+ async clearEverything(): Promise {
+ for (const slot of Object.keys(this.manifest)) this.stage.clearCustom(slot)
+ this.manifest = {}
+ this.paintReports.clear()
+ this.cache.clear()
+ await clearOverrides()
+ }
+
+ /** The exact text the download button hands over. */
+ exportText(): string {
+ return serializeManifest(this.manifest)
+ }
+
+ /** Re-apply a stored entry to the stage (used by restore and by the demo). */
+ private async applyEntry(slot: string, entry: SlotEntry): Promise {
+ let scene = this.cache.get(entry.url)
+ if (!scene) {
+ const bytes = await this.readBytes(entry.url)
+ if (!bytes) return false
+ try {
+ scene = await loadGlb(bytes)
+ } catch (err) {
+ console.warn(`[workshop] stored asset for ${slot} could not be read`, err)
+ return false
+ }
+ this.cache.set(entry.url, scene)
+ const spec = slotById(slot)
+ if (spec?.paintable) this.paintReports.set(slot, inspectPaintability(scene))
+ }
+ this.stage.setCustom(slot, cloneAsset(scene), entry)
+ return true
+ }
+
+ private async readBytes(url: string): Promise {
+ if (isIdbUrl(url)) {
+ const record = await getGlb(idbKey(url))
+ return record?.bytes ?? null
+ }
+ try {
+ const res = await fetch(url)
+ if (!res.ok) return null
+ return await res.arrayBuffer()
+ } catch (err) {
+ console.warn(`[workshop] could not fetch ${url}`, err)
+ return null
+ }
+ }
+}