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

+
    +
  1. Press Save to this browser, then Test drive to play with it.
  2. +
  3. When you're happy, press Download manifest.json.
  4. +
  5. Copy your .glb files into assets/live/ in the repo and put the + downloaded manifest.json next to them as assets/manifest.json.
  6. +
  7. Change every url that starts with idb: to assets/live/yourfile.glb.
  8. +
  9. Redeploy. Everyone sees it.
  10. +
+

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 + } + } +}