- stage: detectVisemes() maps CC/ARKit/plain morph names -> canonical
A/E/I/O/U/MBP/FV/L at character load; visemeTargets(id) + setMorph(id,name,w)
for the timeline to drive lip sync; visemeSelfCheck() asserts the matcher
- dock: inspector 🗣 badge (greyed when a rig has no mouth morphs)
- stage: camera/light default source:{type:'none'} (PLAN §4.1); applyState
skips only upload mesh/backdrop bytes -> rig survives save/load (SYNC4)
Verified live at :8020: selfcheck ok=[A,FV,L,MBP,U]; save/load keeps cam+light.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
13 KiB
JavaScript
263 lines
13 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._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 = `<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.draggable = true;
|
|
const th = it.thumb
|
|
? `<img class="th" loading="lazy" src="/assets/file?path=${encodeURIComponent(it.thumb)}">`
|
|
: `<div class="th">${icon}</div>`;
|
|
c.innerHTML = `${th}<div class="nm">${this._name(it)}</div>`;
|
|
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 = '<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 === 'character'){
|
|
const vis = this.stage.visemeTargets ? this.stage.visemeTargets(en.id) : [];
|
|
const badge = document.createElement('div');
|
|
badge.className = 'viseme' + (vis.length ? '' : ' off');
|
|
badge.textContent = vis.length ? `🗣 visemes: ${vis.join(' ')}` : '🗣 no visemes';
|
|
badge.title = vis.length ? 'lip-sync targets detected' : 'no mouth morph targets on this rig';
|
|
el.appendChild(badge);
|
|
}
|
|
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 = `<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){
|
|
// 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);
|
|
}
|
|
}
|