// dock.js — asset dock (left) + inspector (right). Talks to Stage's public API only. // M1: click a card to add; click an animation to bake onto the selected character; drop local // files onto the view for offline/upload work. M2: Lane B takes over clip placement. import { stats } from './room3d.js'; const TABS = ['characters', 'props', 'backdrops', 'animations']; export class Dock { constructor({ stage, dockEl, inspectorEl, viewEl }){ this.stage = stage; this.dock = dockEl; this.insp = inspectorEl; this.view = viewEl; this.tree = { characters:[], props:[], backdrops:[], animations:[] }; this.tab = 'characters'; stage.onSelect(en => this.renderInspector(en)); stage.onChange(en => { if(en && en.id === this._selId) this.renderInspector(en); }); this._selId = null; this._buildTabs(); this._viewDrop(); this._dropZone(); this.load(); } async load(){ try { const r = await fetch('/assets/tree'); if(!r.ok) throw new Error(r.status); this.tree = { characters:[], props:[], backdrops:[], animations:[], ...(await r.json()) }; } catch(e){ this._offline = true; this.tree = { characters:[], props:[], backdrops:[], animations:[] }; } this.renderBrowser(); } _buildTabs(){ const add = document.createElement('div'); add.className = 'addbar'; for(const [txt, what] of [['+ camera','camera'], ['+ key light','keylight'], ['+ ambient','ambient']]){ const b = document.createElement('button'); b.textContent = txt; b.onclick = () => this._addRig(what).catch(e => this._toast('failed: ' + String(e).slice(0,80))); add.appendChild(b); } this.dock.appendChild(add); const bar = document.createElement('div'); bar.className = 'tabs'; for(const t of TABS){ const b = document.createElement('button'); b.textContent = t.toUpperCase(); b.className = t === this.tab ? 'on' : ''; b.onclick = () => { this.tab = t; [...bar.children].forEach(c => c.classList.toggle('on', c === b)); this.renderBrowser(); }; bar.appendChild(b); } this.dock.appendChild(bar); this.list = document.createElement('div'); this.list.className = 'cards'; this.dock.appendChild(this.list); } // drag a dock card onto the viewport → place at the drop point (chars/props) or bake onto the // character under the cursor (animations). Library items only; local files use the window drop. _viewDrop(){ this.view.addEventListener('dragover', e => { if([...e.dataTransfer.types].includes('application/scenegod')) e.preventDefault(); }); this.view.addEventListener('drop', async e => { const raw = e.dataTransfer.getData('application/scenegod'); if(!raw) return; // a local file drop — let the window handler take it e.preventDefault(); e.stopPropagation(); const { tab, path, name } = JSON.parse(raw); try { if(tab === 'animations'){ const id = this.stage.entityAt(e.clientX, e.clientY, 'character'); if(!id) return this._toast('drop the clip onto a character'); return this._applyClip(id, path, 0); } const kind = tab === 'characters' ? 'character' : tab === 'props' ? 'prop' : 'backdrop'; const desc = { kind, label: name, source:{ type:'assets', path } }; if(kind === 'backdrop') desc.params = { mode:'plane', image:path, width:10 }; else desc.transform = { pos: this.stage.screenToFloor(e.clientX, e.clientY), rot:[0,0,0], scale:1 }; const en = await this.stage.addEntity(desc); this.stage.select(en.id); } catch(err){ this._toast('drop failed: ' + String(err).slice(0,90)); } }); } // /assets/tree item shape (confirmed by Lane C): {name, path, formats:[...], thumb: relpath|null} _path(it){ return it.path; } _name(it){ return it.name || (it.path || '').split('/').pop(); } renderBrowser(){ this.list.innerHTML = ''; const items = this.tree[this.tab] || []; if(!items.length){ this.list.innerHTML = `
${this._offline ? 'server offline — drag a .glb / .fbx / image onto the view' : 'nothing here — drop banks into assets/' + this.tab + '/'}
`; return; } const icon = { characters:'🕺', props:'🪑', backdrops:'🖼', animations:'🏃' }[this.tab]; for(const it of items){ const c = document.createElement('div'); c.className = 'card'; c.draggable = true; const th = it.thumb ? `` : `
${icon}
`; c.innerHTML = `${th}
${this._name(it)}
`; c.onclick = () => this._addFromLibrary(it); c.ondragstart = ev => ev.dataTransfer.setData('application/scenegod', JSON.stringify({ tab: this.tab, path: this._path(it), name: this._name(it) })); this.list.appendChild(c); } } async _addFromLibrary(it){ const path = this._path(it); try { if(this.tab === 'animations'){ const en = this.stage.getEntity(this._selId); if(!en || en.kind !== 'character') return this._toast('select a character first'); return this._applyClip(en.id, path, 0); } const kind = this.tab === 'characters' ? 'character' : this.tab === 'props' ? 'prop' : 'backdrop'; const desc = { kind, label: this._name(it), source: { type:'assets', path } }; if(kind === 'backdrop') desc.params = { mode:'plane', image:path, width:10 }; this._toast('loading ' + this._name(it) + '…'); const en = await this.stage.addEntity(desc); this.stage.select(en.id); this._toast(''); } catch(e){ this._toast('failed: ' + String(e).slice(0, 100)); } } // clips go to the timeline once it's mounted; standalone preview only when it isn't async _applyClip(id, path, clipIndex){ if(window.timeline){ dispatchEvent(new CustomEvent('scenegod:clipdrop', { detail:{ id, path, clipIndex } })); this._toast('+ ' + path.split('/').pop() + ' → timeline'); return; } this._toast('baking clip…'); const clip = await this.stage.prepareClip(id, path, clipIndex); this.stage.playClip(id, clip); this._toast('▶ ' + path.split('/').pop()); } // cameras and lights have no asset file — spawn them from the toolbar async _addRig(what){ const D = { camera: { kind:'camera', label:'camera', params:{ fov:45 }, transform:{ pos:[0,1.6,4], rot:[0,0,0], scale:1 } }, keylight: { kind:'light', label:'key light', params:{ type:'key', color:'#ffffff', intensity:2.2, castShadow:true }, transform:{ pos:[3,5,2], rot:[-0.9,0.5,0], scale:1 } }, ambient: { kind:'light', label:'ambient', params:{ type:'ambient', color:'#ffffff', intensity:1.0 } }, }[what]; const en = await this.stage.addEntity(D); this.stage.select(en.id); if(en.kind === 'camera') this.stage.setActiveCamera(en.id); // preview through the new cam } // ---- inspector ---- renderInspector(en){ this._selId = en ? en.id : null; const el = this.insp; el.innerHTML = ''; if(!en){ el.innerHTML = '
nothing selected
'; return; } const t = this.stage.entityTransform(en.id); const h = document.createElement('div'); h.className = 'ihd'; h.innerHTML = `${en.label}${en.kind}${ en.source && en.source.type === 'upload' ? ' · upload' : ''}`; el.appendChild(h); el.appendChild(this._vec3('pos', t.pos, v => this.stage.setTransform(en.id, { pos:v }))); el.appendChild(this._vec3('rot', t.rot, v => this.stage.setTransform(en.id, { rot:v }))); el.appendChild(this._num('scale', t.scale, v => this.stage.setTransform(en.id, { scale:v }))); if(en.kind === 'camera'){ el.appendChild(this._num('fov', en.params.fov ?? 45, v => this.stage.setParam(en.id, 'fov', v))); const act = document.createElement('button'); act.textContent = '◉ set active camera'; act.className = 'wide'; act.onclick = () => this.stage.setActiveCamera(en.id); el.appendChild(act); } if(en.kind === 'light'){ el.appendChild(this._num('intensity', en.params.intensity ?? 1.5, v => this.stage.setParam(en.id, 'intensity', v))); el.appendChild(this._color('color', en.params.color || '#ffffff', v => this.stage.setParam(en.id, 'color', v))); } if(en.kind === 'backdrop') el.appendChild(this._sel('mode', ['plane','corner','dome'], en.params.mode || 'plane', v => this.stage.setParam(en.id, 'mode', v))); // stage rebuilds the mesh on change const row = document.createElement('div'); row.className = 'ibtns'; const key = document.createElement('button'); key.textContent = '⏺ key'; key.title = 'capture keyframe'; key.onclick = () => dispatchEvent(new CustomEvent('scenegod:capturekey', { detail:{ id: en.id } })); const del = document.createElement('button'); del.textContent = '🗑 delete'; del.className = 'danger'; del.onclick = () => { this.stage.removeEntity(en.id); this.renderInspector(null); }; row.append(key, del); el.appendChild(row); } _field(label, inputEl){ const w = document.createElement('label'); w.className = 'fld'; w.innerHTML = `${label}`; w.appendChild(inputEl); return w; } _num(label, val, cb){ const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=val; i.oninput = () => cb(parseFloat(i.value) || 0); return this._field(label, i); } _color(label, val, cb){ const i = document.createElement('input'); i.type='color'; i.value=val; i.oninput = () => cb(i.value); return this._field(label, i); } _sel(label, opts, val, cb){ const s = document.createElement('select'); for(const o of opts){ const op = document.createElement('option'); op.value=op.textContent=o; if(o===val) op.selected=true; s.appendChild(op); } s.onchange = () => cb(s.value); return this._field(label, s); } _vec3(label, arr, cb){ const w = document.createElement('div'); w.className = 'fld vec'; w.innerHTML = `${label}`; const cur = [...arr]; ['x','y','z'].forEach((_, k) => { const i = document.createElement('input'); i.type='number'; i.step='0.1'; i.value=+arr[k].toFixed(3); i.oninput = () => { cur[k] = parseFloat(i.value) || 0; cb([...cur]); }; w.appendChild(i); }); return w; } // ---- local file drop (upload / offline path) ---- _dropZone(){ addEventListener('dragover', e => { e.preventDefault(); document.body.classList.add('hot'); }); addEventListener('dragleave', e => { if(e.target === document.body) document.body.classList.remove('hot'); }); addEventListener('drop', async e => { e.preventDefault(); document.body.classList.remove('hot'); for(const f of e.dataTransfer.files){ const nm = f.name.toLowerCase(); try { if(/\.(png|jpe?g|webp)$/.test(nm)){ const url = URL.createObjectURL(f); const en = await this.stage.addEntity({ kind:'backdrop', label:f.name, source:{ type:'upload', path:f.name, _url:url }, params:{ mode:'plane' } }); this.stage.select(en.id); } else if(/\.(glb|gltf|fbx|obj|bvh)$/.test(nm)){ const buf = await f.arrayBuffer(); // clip drop onto selected character → bake; else load as a character const sel = this.stage.getEntity(this._selId); const probe = await this._probe(buf, f.name); if(sel && sel.kind === 'character' && probe.anims && !probe.meshes){ // uploaded clip has no server path → the timeline can't place a block for it if(window.timeline){ this._toast('add clips from the dock, not local files, once the timeline is up'); } else { this._toast('baking ' + f.name + '…'); const clip = await this.stage.prepareClipUpload(sel.id, buf, f.name, 0); this.stage.playClip(sel.id, clip); this._toast('▶ ' + f.name); } } else { const en = await this.stage.addEntity({ kind:'character', label:f.name, source:{ type:'upload', path:f.name }, _buf:buf }); this.stage.select(en.id); this._toast(''); } } } catch(err){ this._toast('drop failed: ' + String(err).slice(0, 100)); } } }); } // cheap probe: is this file animation-only (clip) or a mesh (character)? async _probe(buf, name){ const { parseAny } = await import('./room3d.js'); const { root, anims } = await parseAny(buf.slice(0), name); const st = stats(root); return { anims: anims.length > 0, meshes: st.meshes }; } _toast(m){ let t = document.getElementById('toast'); if(!t){ t = document.createElement('div'); t.id = 'toast'; document.body.appendChild(t); } t.textContent = m; t.style.display = m ? 'block' : 'none'; clearTimeout(this._th); if(m) this._th = setTimeout(() => t.style.display = 'none', 3500); } }