SCENEGOD/scenegod/web/dock.js
type-two de3416869f [laneA] M1: Stage viewport, retarget port, dock+inspector
- room3d.js: pure-module port of rigroom retarget, target parameterized;
  _selftest bakes across two mismatched-rest skeletons (maxErr 6.4e-12)
- stage.js: Stage class (IBL, gizmo, entities, clip bake cache) per PLAN §4.2
- dock.js: asset dock + inspector, offline file-drop + clip retarget
- index.html/style.css: grid layout, guarded timeline import (page survives B absent)

Verified in-browser end-to-end: Mixamo char loaded+normalized, backdrop plane,
gizmo move, running clip retargeted (66 tracks) and animating live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:06:22 +10:00

188 lines
9.2 KiB
JavaScript

// 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._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 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);
}
// tolerate string paths or {path,name,files:[{path,ext}]} until Lane C nails the shape (logged REQUEST)
_path(it){ return typeof it === 'string' ? it
: (it.path || (it.files && it.files[0] && it.files[0].path) || it.file || ''); }
_name(it){ return typeof it === 'string' ? it.split('/').pop()
: (it.name || this._path(it).split('/').pop()); }
renderBrowser(){
this.list.innerHTML = '';
const items = this.tree[this.tab] || [];
if(!items.length){
this.list.innerHTML = `<div class="empty">${this._offline
? 'server offline — drag a .glb / .fbx / image onto the view'
: 'nothing here — drop banks into assets/' + this.tab + '/'}</div>`;
return;
}
const icon = { characters:'🕺', props:'🪑', backdrops:'🖼', animations:'🏃' }[this.tab];
for(const it of items){
const c = document.createElement('div'); c.className = 'card';
c.innerHTML = `<div class="th">${icon}</div><div class="nm">${this._name(it)}</div>`;
c.onclick = () => this._addFromLibrary(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');
this._toast('baking clip…');
const clip = await this.stage.prepareClip(en.id, path, 0);
this.stage.playClip(en.id, clip);
this._toast('▶ ' + this._name(it));
return;
}
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)); }
}
// ---- inspector ----
renderInspector(en){
this._selId = en ? en.id : null;
const el = this.insp; el.innerHTML = '';
if(!en){ el.innerHTML = '<div class="empty">nothing selected</div>'; return; }
const t = this.stage.entityTransform(en.id);
const h = document.createElement('div'); h.className = 'ihd';
h.innerHTML = `<b>${en.label}</b><span class="kind">${en.kind}${
en.source && en.source.type === 'upload' ? ' · <span class="warn">upload</span>' : ''}</span>`;
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)));
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))); // rebuild is M2
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 = `<span>${label}</span>`; 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 = `<span>${label}</span>`;
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){
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);
}
}