Lane J: workshop editor — drop-in GLB fitting on the real course
Three-panel editor page (editor.html + src/editor/): slot list, the actual greybox course built from the game's own builders, and fit controls. Drop a .glb on a slot, nudge offset/rotation/scale, save to IndexedDB so the live game picks it up with no deploy, export manifest.json to commit it. Design notes worth keeping: - A custom asset never replaces the procedural node; it goes into a new sibling 'fit' node and the original is hidden. Reset is instant, references other systems captured stay alive, and the manifest transform lives on a node nobody else writes to (feel.ts, telegraph.ts and the machine parts all own transforms of their own). - The stage creates physics but never steps it: no systems, no world.start(), just renderOnce() on a rAF. Nothing here affects gameplay. - A blob body whose UVs paint cannot stick to is refused outright rather than swapped in — silently broken paint is worse than a missing model. Headless verification: 148 assertions across three node test files (manifest round-trip, GLB paintability against real parsed glTF, and the whole drop/save/restore path over an in-memory IndexedDB). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
cabc60def8
commit
367e700934
90
demos/lane-j.html
Normal file
90
demos/lane-j.html
Normal file
@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8" /><title>BLOBBO lane j demo — workshop editor</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#0a2540; --ink-soft:#4a627d; --paper:#f4f7fb; --card:#ffffff;
|
||||
--line:#d7e2ee; --hot:#FF9500; --good:#34C759; --bad:#FF3B30; --pick:#0A84FF;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;overflow:hidden}
|
||||
button,input{font:inherit;color:inherit}
|
||||
|
||||
.workshop{display:grid;grid-template-columns:280px 1fr 330px;height:100%}
|
||||
.panel{overflow-y:auto;padding:16px}
|
||||
.panel-slots{background:var(--card);border-right:1px solid var(--line)}
|
||||
.panel-fit{background:var(--card);border-left:1px solid var(--line)}
|
||||
.panel-stage{position:relative;padding:0;overflow:hidden;background:#bfe3ff}
|
||||
.stage-host{position:absolute;inset:0}
|
||||
.stage-host canvas{display:block;width:100%;height:100%}
|
||||
|
||||
.brand{margin:0 0 4px;font-size:19px}
|
||||
.lede{margin:0 0 18px;color:var(--ink-soft);font-size:13px}
|
||||
.group{margin:16px 0 6px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--ink-soft)}
|
||||
.slot-list{display:flex;flex-direction:column;gap:4px}
|
||||
.slot{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;
|
||||
padding:9px 11px;border:1px solid var(--line);border-radius:10px;background:#fff;
|
||||
cursor:pointer;text-align:left}
|
||||
.slot[data-selected="true"]{border-color:var(--pick);background:#e7f2ff;box-shadow:inset 3px 0 0 var(--pick)}
|
||||
.slot-name{font-weight:600}
|
||||
.slot-state{font-size:11px;padding:2px 7px;border-radius:20px;background:#eef3f8;color:var(--ink-soft)}
|
||||
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
|
||||
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
|
||||
|
||||
.big-actions{display:flex;flex-direction:column;gap:8px;margin:22px 0 12px}
|
||||
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;cursor:pointer;font-weight:600}
|
||||
.action.primary{background:var(--pick);border-color:var(--pick);color:#fff}
|
||||
.action.danger{color:var(--bad);border-color:#f6d2cf}
|
||||
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;font-size:13px;color:var(--ink-soft)}
|
||||
.status[data-tone="warn"]{background:#fff3e2;color:#8a5100}
|
||||
|
||||
.stage-hint{position:absolute;left:14px;bottom:14px;padding:7px 11px;border-radius:9px;
|
||||
background:rgba(10,37,64,.72);color:#fff;font-size:12px;pointer-events:none}
|
||||
.focus-btn{position:absolute;right:14px;bottom:14px;padding:10px 14px;border:0;border-radius:10px;
|
||||
background:rgba(10,37,64,.86);color:#fff;font-weight:600;cursor:pointer}
|
||||
|
||||
.fit-title{margin:0 0 4px;font-size:18px}
|
||||
.fit-hint{margin:0 0 14px;color:var(--ink-soft);font-size:13px}
|
||||
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
|
||||
min-height:132px;border:2.5px dashed var(--line);border-radius:14px;background:#fbfdff;
|
||||
cursor:pointer;text-align:center;padding:14px}
|
||||
.dropzone[data-hot="true"]{border-color:var(--hot);background:#fff6ea}
|
||||
.dropzone-label{font-size:17px;font-weight:700}
|
||||
.dropzone-sub{font-size:12px;color:var(--ink-soft)}
|
||||
.paint-card{display:none;margin-top:14px;padding:12px;border-radius:12px;border:1px solid var(--line);background:#fbfdff}
|
||||
.paint-card[data-tone="ok"]{border-color:#bde5c8;background:#f2fcf5}
|
||||
.paint-card[data-tone="bad"]{border-color:#f3b9b4;background:#fff3f2}
|
||||
.paint-head{font-weight:700;margin-bottom:6px}
|
||||
.paint-facts{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
|
||||
.paint-facts span{font-size:11px;padding:3px 8px;border-radius:20px;background:#fff;border:1px solid var(--line)}
|
||||
.paint-note{font-size:12.5px;color:var(--ink-soft);margin-top:4px}
|
||||
.fit-controls{display:none;margin-top:16px}
|
||||
.fit-row{display:grid;gap:4px;margin-bottom:11px}
|
||||
.fit-label{font-size:12px;font-weight:600;color:var(--ink-soft)}
|
||||
.fit-row input[type=range]{width:100%}
|
||||
.fit-row input[type=number]{width:96px;padding:5px 7px;border:1px solid var(--line);border-radius:7px;background:#fff}
|
||||
.help-card{margin-top:20px;border:1px solid var(--line);border-radius:12px;background:#fbfdff;padding:10px 12px}
|
||||
.help-card summary{cursor:pointer;font-weight:600}
|
||||
.help-body{font-size:13px;color:var(--ink-soft)}
|
||||
.help-body h3{margin:14px 0 4px;font-size:13px;color:var(--ink)}
|
||||
.help-body code{background:#eef3f8;padding:1px 5px;border-radius:5px;font-size:12px}
|
||||
|
||||
/* demo report overlay */
|
||||
#results{position:fixed;top:10px;left:50%;transform:translateX(-50%);z-index:50;
|
||||
max-height:46vh;overflow:auto;min-width:520px;max-width:74vw;
|
||||
background:rgba(12,20,34,.92);color:#eaf2ff;padding:12px 15px;border-radius:11px;
|
||||
font:12.5px/1.55 ui-monospace,Menlo,monospace;box-shadow:0 10px 30px rgba(0,0,0,.28)}
|
||||
#results .pass{color:#8ef0a8}
|
||||
#results .fail{color:#ff9b93}
|
||||
#results .note{color:#ffd98a}
|
||||
#results .summary{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.18);
|
||||
font-weight:700;font-size:14px}
|
||||
#results h1{margin:0 0 8px;font-size:13px;color:#ffd60a;letter-spacing:.05em}
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="workshop" data-manual></div>
|
||||
<div id="results"><h1>LANE J — SCRIPTED DROP ONTO THE SPRING BOOT SLOT</h1></div>
|
||||
<script type="module" src="/src/demo/lane-j.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
102
editor.html
Normal file
102
editor.html
Normal file
@ -0,0 +1,102 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>BLOBBO workshop</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#0a2540; --ink-soft:#4a627d; --paper:#f4f7fb; --card:#ffffff;
|
||||
--line:#d7e2ee; --hot:#FF9500; --good:#34C759; --bad:#FF3B30; --pick:#0A84FF;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;overflow:hidden}
|
||||
button,input{font:inherit;color:inherit}
|
||||
|
||||
.workshop{display:grid;grid-template-columns:280px 1fr 330px;height:100%}
|
||||
.panel{overflow-y:auto;padding:16px}
|
||||
.panel-slots{background:var(--card);border-right:1px solid var(--line)}
|
||||
.panel-fit{background:var(--card);border-left:1px solid var(--line)}
|
||||
.panel-stage{position:relative;padding:0;overflow:hidden;background:#bfe3ff}
|
||||
.stage-host{position:absolute;inset:0}
|
||||
.stage-host canvas{display:block;width:100%;height:100%}
|
||||
|
||||
.brand{margin:0 0 4px;font-size:19px;letter-spacing:.02em}
|
||||
.lede{margin:0 0 18px;color:var(--ink-soft);font-size:13px}
|
||||
.group{margin:16px 0 6px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;
|
||||
color:var(--ink-soft)}
|
||||
.slot-list{display:flex;flex-direction:column;gap:4px}
|
||||
.slot{display:flex;justify-content:space-between;align-items:center;gap:8px;
|
||||
width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:10px;
|
||||
background:#fff;cursor:pointer;text-align:left;transition:.12s}
|
||||
.slot:hover{border-color:var(--pick);background:#f2f8ff}
|
||||
.slot[data-selected="true"]{border-color:var(--pick);background:#e7f2ff;
|
||||
box-shadow:inset 3px 0 0 var(--pick)}
|
||||
.slot-name{font-weight:600}
|
||||
.slot-state{font-size:11px;padding:2px 7px;border-radius:20px;background:#eef3f8;
|
||||
color:var(--ink-soft);white-space:nowrap}
|
||||
.slot[data-state="custom"] .slot-state{background:#e6f8ea;color:#1c7a35}
|
||||
.slot[data-state="derived"] .slot-state{background:#f3ecff;color:#6b3fa0}
|
||||
|
||||
.big-actions{display:flex;flex-direction:column;gap:8px;margin:22px 0 12px}
|
||||
.action{padding:12px;border:1px solid var(--line);border-radius:11px;background:#fff;
|
||||
cursor:pointer;font-weight:600}
|
||||
.action:hover{border-color:var(--pick)}
|
||||
.action.primary{background:var(--pick);border-color:var(--pick);color:#fff}
|
||||
.action.danger{color:var(--bad);border-color:#f6d2cf}
|
||||
.status{margin:0;padding:10px 12px;border-radius:10px;background:#eef3f8;
|
||||
font-size:13px;color:var(--ink-soft)}
|
||||
.status[data-tone="warn"]{background:#fff3e2;color:#8a5100}
|
||||
|
||||
.stage-hint{position:absolute;left:14px;bottom:14px;padding:7px 11px;border-radius:9px;
|
||||
background:rgba(10,37,64,.72);color:#fff;font-size:12px;pointer-events:none}
|
||||
.focus-btn{position:absolute;right:14px;bottom:14px;padding:10px 14px;border:0;
|
||||
border-radius:10px;background:rgba(10,37,64,.86);color:#fff;font-weight:600;cursor:pointer}
|
||||
|
||||
.fit-title{margin:0 0 4px;font-size:18px}
|
||||
.fit-hint{margin:0 0 14px;color:var(--ink-soft);font-size:13px}
|
||||
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:5px;min-height:132px;border:2.5px dashed var(--line);border-radius:14px;
|
||||
background:#fbfdff;cursor:pointer;text-align:center;padding:14px}
|
||||
.dropzone:hover,.dropzone[data-hot="true"]{border-color:var(--hot);background:#fff6ea}
|
||||
.dropzone-label{font-size:17px;font-weight:700}
|
||||
.dropzone-sub{font-size:12px;color:var(--ink-soft)}
|
||||
|
||||
.paint-card{display:none;margin-top:14px;padding:12px;border-radius:12px;
|
||||
border:1px solid var(--line);background:#fbfdff}
|
||||
.paint-card[data-tone="ok"]{border-color:#bde5c8;background:#f2fcf5}
|
||||
.paint-card[data-tone="bad"]{border-color:#f3b9b4;background:#fff3f2}
|
||||
.paint-head{font-weight:700;margin-bottom:6px}
|
||||
.paint-facts{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
|
||||
.paint-facts span{font-size:11px;padding:3px 8px;border-radius:20px;background:#fff;
|
||||
border:1px solid var(--line)}
|
||||
.paint-note{font-size:12.5px;color:var(--ink-soft);margin-top:4px}
|
||||
|
||||
.fit-controls{display:none;margin-top:16px}
|
||||
.fit-row{display:grid;grid-template-columns:1fr;gap:4px;margin-bottom:11px}
|
||||
.fit-label{font-size:12px;font-weight:600;color:var(--ink-soft)}
|
||||
.fit-row input[type=range]{width:100%}
|
||||
.fit-row input[type=number]{width:96px;padding:5px 7px;border:1px solid var(--line);
|
||||
border-radius:7px;background:#fff}
|
||||
|
||||
.help-card{margin-top:20px;border:1px solid var(--line);border-radius:12px;
|
||||
background:#fbfdff;padding:10px 12px}
|
||||
.help-card summary{cursor:pointer;font-weight:600}
|
||||
.help-body{font-size:13px;color:var(--ink-soft)}
|
||||
.help-body h3{margin:14px 0 4px;font-size:13px;color:var(--ink)}
|
||||
.help-body ul,.help-body ol{margin:0;padding-left:18px}
|
||||
.help-body code{background:#eef3f8;padding:1px 5px;border-radius:5px;font-size:12px}
|
||||
|
||||
@media (max-width:1100px){
|
||||
html,body{overflow:auto}
|
||||
.workshop{grid-template-columns:1fr;height:auto}
|
||||
.panel-stage{height:60vh}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="workshop"></div>
|
||||
<script type="module" src="/src/editor/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
156
src/demo/lane-j.ts
Normal file
156
src/demo/lane-j.ts
Normal file
@ -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<void> {
|
||||
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))
|
||||
})
|
||||
28
src/editor/glb.ts
Normal file
28
src/editor/glb.ts
Normal file
@ -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<THREE.Group> {
|
||||
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)
|
||||
}
|
||||
71
src/editor/idb.ts
Normal file
71
src/editor/idb.ts
Normal file
@ -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:<key>` 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<IDBDatabase> | null = null
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
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<T>(
|
||||
store: string,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (s: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T>((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 = <T>(store: string, key: string): Promise<T | undefined> =>
|
||||
run<T | undefined>(store, 'readonly', (s) => s.get(key) as IDBRequest<T | undefined>)
|
||||
|
||||
export const idbPut = (store: string, key: string, value: unknown): Promise<unknown> =>
|
||||
run(store, 'readwrite', (s) => s.put(value, key) as IDBRequest<unknown>)
|
||||
|
||||
export const idbDelete = (store: string, key: string): Promise<unknown> =>
|
||||
run(store, 'readwrite', (s) => s.delete(key) as IDBRequest<unknown>)
|
||||
|
||||
export const idbKeys = (store: string): Promise<IDBValidKey[]> =>
|
||||
run<IDBValidKey[]>(store, 'readonly', (s) => s.getAllKeys())
|
||||
|
||||
export const idbClear = (store: string): Promise<unknown> =>
|
||||
run(store, 'readwrite', (s) => s.clear() as IDBRequest<unknown>)
|
||||
|
||||
/** True when this browser can hold overrides at all (private modes sometimes can't). */
|
||||
export const idbAvailable = (): boolean =>
|
||||
typeof indexedDB !== 'undefined' && indexedDB !== null
|
||||
421
src/editor/main.ts
Normal file
421
src/editor/main.ts
Normal file
@ -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 = <K extends keyof HTMLElementTagNameMap>(
|
||||
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<SlotEntry>
|
||||
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<DropResult>
|
||||
select: (slot: string) => void
|
||||
save: () => Promise<void>
|
||||
exportManifest: () => string
|
||||
clearAll: () => Promise<void>
|
||||
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<string, HTMLButtonElement>()
|
||||
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 = `
|
||||
<h3>In Blender, before you export</h3>
|
||||
<ul>
|
||||
<li>Work in <b>metres</b>, Y up, facing −Z. The blob is about <b>1 metre wide</b>.</li>
|
||||
<li>Put the origin in the <b>centre</b> for the blob, and at the <b>ground contact point</b> for props.</li>
|
||||
<li>One material per model if you can. Textures no bigger than 2048×2048.</li>
|
||||
<li>Export as <b>.glb</b> (embedded), not .gltf + files.</li>
|
||||
</ul>
|
||||
<h3>If it's the blob body</h3>
|
||||
<ul>
|
||||
<li>It has to be UV unwrapped, all in one 0–1 square, <b>no overlaps and no mirroring</b> —
|
||||
that's where the paint goes.</li>
|
||||
<li>Rigged is fine: one armature, the looping animation named <code>idle</code>, keep it under ~40 bones.</li>
|
||||
</ul>
|
||||
<h3>Ship it for real</h3>
|
||||
<ol>
|
||||
<li>Press <b>Save to this browser</b>, then <b>Test drive</b> to play with it.</li>
|
||||
<li>When you're happy, press <b>Download manifest.json</b>.</li>
|
||||
<li>Copy your .glb files into <code>assets/live/</code> in the repo and put the
|
||||
downloaded <code>manifest.json</code> next to them as <code>assets/manifest.json</code>.</li>
|
||||
<li>Change every url that starts with <code>idb:</code> to <code>assets/live/yourfile.glb</code>.</li>
|
||||
<li>Redeploy. Everyone sees it.</li>
|
||||
</ol>
|
||||
<p><b>Saved here stays here.</b> Swaps you save live in this browser only —
|
||||
nobody else sees them until you do the steps above.</p>`
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
166
src/editor/manifest-io.test.ts
Normal file
166
src/editor/manifest-io.test.ts
Normal file
@ -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 ✓`)
|
||||
164
src/editor/manifest-io.ts
Normal file
164
src/editor/manifest-io.ts
Normal file
@ -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<string, SlotEntry>
|
||||
|
||||
/** 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/<filename>.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:<key>` 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<string, unknown>
|
||||
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<string, unknown>)) {
|
||||
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<string, unknown> = {
|
||||
_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))
|
||||
}
|
||||
33
src/editor/node-ts-resolve.mjs
Normal file
33
src/editor/node-ts-resolve.mjs
Normal file
@ -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 `<specifier>.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
|
||||
}
|
||||
},
|
||||
})
|
||||
82
src/editor/paintability.test.ts
Normal file
82
src/editor/paintability.test.ts
Normal file
@ -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<THREE.Group> =>
|
||||
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 ✓`)
|
||||
88
src/editor/paintability.ts
Normal file
88
src/editor/paintability.ts
Normal file
@ -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 }
|
||||
}
|
||||
119
src/editor/slot-swap.ts
Normal file
119
src/editor/slot-swap.ts
Normal file
@ -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<string, SlotStageEntry>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
156
src/editor/slots.ts
Normal file
156
src/editor/slots.ts
Normal file
@ -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']
|
||||
314
src/editor/stage.ts
Normal file
314
src/editor/stage.ts
Normal file
@ -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<string, SlotStageEntry>
|
||||
/** 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<EditorStage> {
|
||||
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 = <T>(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<THREE.Object3D>(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 }
|
||||
68
src/editor/store.ts
Normal file
68
src/editor/store.ts
Normal file
@ -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:<key>` 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<Manifest> {
|
||||
const raw = await idbGet<unknown>(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<unknown> =>
|
||||
idbPut(MANIFEST_STORE, MANIFEST_KEY, cleanManifest(manifest))
|
||||
|
||||
/** A stable, human-readable blob key: `<slot>-<filename>`. */
|
||||
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<string> {
|
||||
const record: StoredGlb = { name, bytes, savedAt: Date.now() }
|
||||
await idbPut(BLOB_STORE, key, record)
|
||||
return `idb:${key}`
|
||||
}
|
||||
|
||||
export async function getGlb(key: string): Promise<StoredGlb | undefined> {
|
||||
const raw = await idbGet<StoredGlb>(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<void> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
const orphans = await orphanBlobKeys(manifest)
|
||||
for (const k of orphans) await idbDelete(BLOB_STORE, k)
|
||||
return orphans.length
|
||||
}
|
||||
132
src/editor/tiny-glb.ts
Normal file
132
src/editor/tiny-glb.ts
Normal file
@ -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<string, number> = { 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
|
||||
}
|
||||
196
src/editor/workshop.test.ts
Normal file
196
src/editor/workshop.test.ts
Normal file
@ -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<string, unknown>
|
||||
const dbs = new Map<string, Map<string, Store>>()
|
||||
|
||||
function fireLater<T>(req: { onsuccess: (() => void) | null; result: T }): void {
|
||||
queueMicrotask(() => req.onsuccess?.())
|
||||
}
|
||||
|
||||
const fakeIndexedDb = {
|
||||
open(name: string) {
|
||||
const req: Record<string, unknown> = { result: null, onsuccess: null, onerror: null, onupgradeneeded: null }
|
||||
const stores = dbs.get(name) ?? new Map<string, Store>()
|
||||
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 = <T>(result: T): Record<string, unknown> => {
|
||||
const r: Record<string, unknown> = { 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 ✓`)
|
||||
211
src/editor/workshop.ts
Normal file
211
src/editor/workshop.ts
Normal file
@ -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<string, THREE.Object3D>()
|
||||
private readonly paintReports = new Map<string, PaintableInfo>()
|
||||
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<string[]> {
|
||||
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<DropResult> {
|
||||
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>): 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<number> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<ArrayBuffer | null> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user