/** * 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). * * TWO INVARIANTS, both learned the hard way: * * 1. NOTHING LEAVES THIS CLASS AS A REJECTED PROMISE. Every caller is a DOM * handler that can only `void` the result, so a rejection used to vanish * into an unhandled-rejection with the status line frozen mid-sentence. * Storage failures come back as a `rejected` result carrying a sentence a * person can read, and the slot stays procedural. * * 2. A SAVED SWAP IS NEVER DESTROYED BY THE EDITOR. An entry whose file will * not re-load stays in the manifest, flagged unloadable, so the next save * cannot prune its bytes away. Losing a file the user cannot regenerate is * worse than showing them a broken row. */ import * as THREE from 'three' import type { EditorStage } from './stage' import { loadGlb } from './glb' import { firstMesh, 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 interface SaveResult { /** How many swaps are now saved. */ saved: number /** False when the browser refused to keep them. */ ok: boolean /** Plain-language outcome for the status line. */ message: string } export interface RestoreResult { /** Slots whose model came back and is on the stage. */ restored: string[] /** Slots still in the manifest whose file could not be read back. */ unloadable: string[] } /** What the user is told when the browser will not keep a file. */ const NO_ROOM = "There wasn't room in this browser to keep that file, so the original is still there. " + 'Try a smaller model, or clear some swaps you no longer want.' const NO_MESH = "There's no model inside this file — the original is still there." 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() /** Slots whose stored file would not load: kept, shown, never pruned. */ private readonly unloadable = new Set() 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 } /** True when this slot's saved file is missing or unreadable. */ isUnloadable(slot: string): boolean { return this.unloadable.has(slot) } unloadableSlots(): string[] { return [...this.unloadable] } /** * Re-hydrate everything the browser remembered from a previous session. * Never throws: no storage is a normal state, not a reason to lose the editor. */ async restore(): Promise { let stored: Manifest = {} try { stored = await loadOverrideManifest() } catch (err) { console.warn('[workshop] could not read what was saved last time', err) return { restored: [], unloadable: [] } } const restored: string[] = [] const unloadable: string[] = [] for (const [slot, entry] of Object.entries(stored)) { if (!this.stage.slots.has(slot)) continue let ok = false try { ok = await this.applyEntry(slot, entry) } catch (err) { console.warn(`[workshop] stored asset for ${slot} could not be restored`, err) } // The entry is kept either way. Dropping it here is what used to make the // next save delete the only copy of the file. this.manifest[slot] = entry if (ok) { this.unloadable.delete(slot) restored.push(slot) } else { this.unloadable.add(slot) unloadable.push(slot) } } return { restored, unloadable } } /** * 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) { return { slot, entry: { url: '' }, rejected: true, message: "That isn't something this game can swap out.", } } if (!this.stage.slots.has(slot)) { return { slot, entry: { url: '' }, rejected: true, message: `There isn't a ${spec.label.toLowerCase()} anywhere in the course at the ` + 'moment, so there is nothing here to replace.', } } 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.`, } } // EVERY slot, not just the paintable one: a file with no mesh in it used to // be accepted, hide the original and put nothing in its place — a cheerful // success message for an object that had just vanished from the course. if (!firstMesh(scene)) { return { slot, entry: { url: '' }, rejected: true, message: NO_MESH } } 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] ?? ''), } } } // Storage first: a swap the browser cannot keep must not appear on the // stage, or Save would silently drop it and the preview would be a lie. const key = blobKeyFor(slot, fileName) let url: string try { url = await putGlb(key, fileName, bytes) } catch (err) { console.warn(`[workshop] could not store ${fileName}`, err) this.paintReports.delete(slot) return { slot, entry: { url: '' }, rejected: true, paint, message: NO_ROOM } } 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.unloadable.delete(slot) this.stage.setCustom(slot, scene, entry) const count = this.stage.slots.get(slot)?.procedural.length ?? 1 return { slot, entry, paint, rejected: false, message: count > 1 ? `${fileName} is now all ${count} of the ${spec.label.toLowerCase()}s.` : `${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.unloadable.delete(slot) this.stage.clearCustom(slot) } /** * Write the working manifest where the live game will find it. * Pruning happens only after the write succeeds, and only against a manifest * that still holds every entry — including the ones that would not load. */ async saveLocally(): Promise { const clean = cleanManifest(this.manifest) const saved = Object.keys(clean).length try { await saveOverrideManifest(clean) } catch (err) { console.warn('[workshop] could not save the manifest', err) return { saved: 0, ok: false, message: NO_ROOM } } try { await pruneOrphans(clean) } catch (err) { // Leftover bytes waste space but break nothing — never fail a save for it. console.warn('[workshop] could not tidy up unused files', err) } return { saved, ok: true, message: saved === 0 ? 'Saved — no swaps, so the game looks like it always did.' : `Saved ${saved} swap${saved === 1 ? '' : 's'}. Press "Test drive" to play with them.`, } } /** 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.unloadable.clear() this.cache.clear() try { await clearOverrides() return true } catch (err) { console.warn('[workshop] could not clear stored swaps', err) return false } } /** 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 } if (!firstMesh(scene)) { console.warn(`[workshop] stored asset for ${slot} has no model in it`) 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, scene, entry) return true } private async readBytes(url: string): Promise { if (isIdbUrl(url)) { try { const record = await getGlb(idbKey(url)) return record?.bytes ?? null } catch (err) { console.warn(`[workshop] could not read the stored file for ${url}`, err) return null } } try { const res = await fetch(url) if (!res.ok) { console.warn(`[workshop] ${url} answered ${res.status} — keeping the original.`) return null } return await res.arrayBuffer() } catch (err) { console.warn(`[workshop] could not fetch ${url}`, err) return null } } }