- 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>
188 lines
9.2 KiB
JavaScript
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);
|
|
}
|
|
}
|