[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>
This commit is contained in:
type-two 2026-07-18 18:06:22 +10:00
parent b20354c53f
commit de3416869f
7 changed files with 856 additions and 0 deletions

View File

@ -9,3 +9,50 @@ Format per session:
NEXT: …
(no sessions yet)
## 2026-07-18 session 1
DONE: M1 shipped and verified end-to-end in a browser.
- `web/room3d.js` — ported the rigroom retarget as a pure ES module (no DOM/globals).
Exports parseAny/canon/boneMap/captureRest/bakeRetarget/disposeRoot/stats + `_selftest`.
bakeRetarget is now `(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts?)` — target is a
parameter instead of the closed-over `char`. Algorithm kept byte-for-byte (30fps world-delta
bake, hips-height ratio, quat sign continuity, dup-hierarchy dedupe). Added `.bvh` to parseAny.
- `web/stage.js``class Stage` per PLAN §4.2. IBL (RoomEnvironment+PMREM), grid+circle floor,
OrbitControls director cam, TransformControls gizmo (W/E/R), raycast select, shadow-casting.
addEntity handles character/prop/backdrop(plane|corner|dome)/camera/light; wrapper-Group holds
all transforms, loaded root normalized+grounded underneath. prepareClip caches parse-per-path and
bake-per-(entity,path,index). captureState/applyState round-trip entities.
- `web/dock.js` — asset dock (Characters/Props/Backdrops/Animations tabs from `/assets/tree`,
offline→file-drop) + inspector (transform/params editors, ⏺key dispatches `scenegod:capturekey`,
delete). Local drop: image→backdrop, mesh→character, clip-onto-selected-character→bake+play.
- `web/index.html` + `web/style.css` — CSS-grid layout (dock/view/inspector/timeline), rigroom
importmap (three 0.160.0), `?selftest=1` badge. Timeline loaded via guarded dynamic import so the
page never breaks when Lane B's file is absent.
VERIFICATION (browser, in-app):
- `?selftest=1`: PASS — two 3-bone skeletons, different rests+namespaces, baked 1s clip,
world-delta match maxErr=6.4e-12, srcMoved=0.224rad (guard against a silently-frozen source).
- Real end-to-end (Female_mixamo.fbx char + "Running.fbx" clip + a jpg backdrop, served from a
throwaway scratchpad harness): char loaded (65 bones, 18.6k tris, normalized), gizmo-moved,
clip retargeted (66 tracks) and animating live. Screenshot: `logs/shots/m1-lady-running.png`.
- Page boots with zero console errors with BOTH timeline.js and the server absent.
DECISIONS:
- selftest uses underscore namespaces (`mixamorig1_`/`mixamorig4_`), not colon — a colon in a bone
name breaks three's PropertyBinding so the source clip wouldn't bind (silent pass). canon() still
collapses both to the same key, which is the thing under test.
- Added `Stage.prepareClipUpload(id, buf, name, idx)` (bake from in-memory bytes) so the offline
file-drop path can retarget without a server. `Stage.setMixerAuto(false)` lets Lane B take over
mixer stepping at SYNC 1 (default true so dropped clips preview standalone now).
- backdrop param edits (mode/image/width) don't rebuild the mesh yet — marked M2. fov/light
color/intensity are live.
BLOCKED/REQUESTS:
- @Lane C: please confirm the item shape inside `/assets/tree` arrays. dock.js tolerates both a
bare path string and `{path,name,files:[{path,ext}]}` (rigroom-style) for now — tell me the real
one and I'll drop the fallback. Also confirm the page is served such that absolute `/web/*.js`
imports resolve (I used `/web/...` to be URL-independent).
NEXT (M2, do not start until orchestrator flips the milestone line):
- multiple cameras + PiP overlay via renderActiveCamera, light entity param plumbing polish,
backdrop rebuild-on-param-change, drag-from-dock onto viewport (currently click-to-add).

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

187
scenegod/web/dock.js Normal file
View File

@ -0,0 +1,187 @@
// 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);
}
}

57
scenegod/web/index.html Normal file
View File

@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SCENEGOD — machinima stage</title>
<link rel="stylesheet" href="/web/style.css">
</head>
<body>
<header>
<h1>🎬 SCENEGOD</h1><span class="tag">machinima stage</span>
<span id="selftest"></span>
</header>
<div id="app">
<aside id="dock"></aside>
<div id="view"></div>
<aside id="inspector"><div class="empty">nothing selected</div></aside>
<div id="timeline"><div class="empty">timeline — Lane B mounts here</div></div>
</div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import { Stage } from '/web/stage.js';
import { Dock } from '/web/dock.js';
// retarget self-check (?selftest=1) — proves the crown-jewel bake before anything else
if(new URLSearchParams(location.search).has('selftest')){
const { _selftest } = await import('/web/room3d.js');
const r = _selftest();
const el = document.getElementById('selftest');
el.textContent = `selftest: ${r.ok ? 'PASS' : 'FAIL'} (maxErr=${r.maxErr.toExponential(2)})`;
el.className = r.ok ? 'ok' : 'bad';
console.log('[room3d selftest]', r);
}
const stage = new Stage(document.getElementById('view'));
const dock = new Dock({
stage,
dockEl: document.getElementById('dock'),
inspectorEl: document.getElementById('inspector'),
viewEl: document.getElementById('view'),
});
// Timeline is optional — the page must never break when Lane B's file is absent
try {
const { Timeline } = await import('/web/timeline.js');
window.timeline = new Timeline(stage);
} catch(e){ /* timeline.js not present yet */ }
window.stage = stage; window.dock = dock;
</script>
</body>
</html>

171
scenegod/web/room3d.js Normal file
View File

@ -0,0 +1,171 @@
// room3d.js — loaders + Mixamo retarget, ported from MESHGOD rigroom.html.
// Pure module: no DOM, no globals, no toast — throw Errors, callers handle UI.
// The bake algorithm is byte-for-byte rigroom's; only the target is now a
// parameter instead of a closed-over global.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
import { BVHLoader } from 'three/addons/loaders/BVHLoader.js';
export async function parseAny(buf, name){
name = (name||'').toLowerCase();
// extensionless routes (gallery/<id>/glb) + magic-byte fallback: gltf binary starts "glTF"
if(!/\.(glb|gltf|fbx|obj|bvh)$/.test(name)){
const head = new TextDecoder().decode(new Uint8Array(buf, 0, Math.min(16, buf.byteLength)));
if(head.startsWith('glTF')) name += '.glb';
else if(head.includes('Kaydara')) name += '.fbx';
else if(head.includes('HIERARCHY')) name += '.bvh';
else name += '.glb'; // worst case the loader throws a clear error
}
if(name.endsWith('.glb') || name.endsWith('.gltf')){
const g = await new GLTFLoader().parseAsync(buf, ''); return {root:g.scene, anims:g.animations||[]}; }
if(name.endsWith('.fbx')){ const r = new FBXLoader().parse(buf, ''); return {root:r, anims:r.animations||[]}; }
if(name.endsWith('.obj')){ const r = new OBJLoader().parse(new TextDecoder().decode(buf));
r.traverse(o=>{ if(o.isMesh && !o.material.map) o.material = new THREE.MeshStandardMaterial({color:0x9aa4af, roughness:.7}); });
return {root:r, anims:[]}; }
if(name.endsWith('.bvh')){ const r = new BVHLoader().parse(new TextDecoder().decode(buf));
const root = new THREE.Object3D(); root.add(r.skeleton.bones[0]);
return {root, anims:[r.clip]}; }
throw new Error('unsupported type: '+name);
}
export function stats(root){
let tris=0, meshes=0, bones=0;
root.traverse(o=>{ if(o.isMesh){ meshes++; const g=o.geometry;
tris += (g.index ? g.index.count : g.attributes.position.count)/3; } if(o.isBone) bones++; });
return {tris:Math.round(tris), meshes, bones};
}
export function disposeRoot(root){
root.traverse(o=>{ if(o.geometry) o.geometry.dispose();
const m=o.material; if(m)(Array.isArray(m)?m:[m]).forEach(mt=>{ for(const k in mt){ const v=mt[k];
if(v && v.isTexture) v.dispose(); } mt.dispose(); }); });
}
// canonical bone key: "mixamorig:LeftUpLeg" / "mixamorig_LeftUpLeg" / "mixamorig4LeftUpLeg" → "leftupleg"
// (mixamo numbers the namespace per-download — strip digits too). Encodes real Mixamo quirks; keep as-is.
export const canon = n => n.replace(/^.*?mixamorig\d*[:_]?/i,'').replace(/[^a-z0-9]/gi,'').toLowerCase();
export function boneMap(root){
const m={}; root.traverse(o=>{ if(o.isBone && !(canon(o.name) in m)) m[canon(o.name)] = o; }); return m;
}
const hipsOf = map => map['hips'] || null;
// bind-pose snapshot — retargets are world-space deltas against THIS. Capture BEFORE any clip poses it.
export function captureRest(root){
root.updateMatrixWorld(true);
const rest = new Map(), order = [];
root.traverse(o=>{ if(o.isBone){ order.push(o);
rest.set(o, { wq:o.getWorldQuaternion(new THREE.Quaternion()),
lq:o.quaternion.clone(), lp:o.position.clone() }); } });
return {rest, order};
}
// WORLD-SPACE rotation-delta transfer, baked at 30fps. Raw quaternion copying only works when both
// rests share bone axes (fbx→fbx). Blender-exported GLB rigs orient bones differently, so we sample
// the SOURCE through the clip and move each target bone by the source bone's world-space delta.
export function bakeRetarget(clip, srcRoot, srcRest, tgtRoot, tgtRest, opts={}){
const tgt = boneMap(tgtRoot), src = boneMap(srcRoot);
const tHips = hipsOf(tgt), sHips = hipsOf(src);
if(!tHips || !sHips) throw new Error('retarget: missing hips (mixamorig skeleton?)');
const sRest = srcRest.rest, tRest = tgtRest.rest, tOrder = tgtRest.order;
const pairs = new Map(); // target bone → source bone. Dedupe by canon key: fbx files often
const used = new Set(); // carry a DUPLICATE hierarchy — bind only the first (what boneMap picks)
for(const tb of tOrder){ const k=canon(tb.name), sb=src[k];
if(sb && sRest.has(sb) && !used.has(k)){ pairs.set(tb, sb); used.add(k); } }
if(!pairs.size) throw new Error('retarget: no matching bones');
let ratio=1; // hips travel: local hip-height ratio (cm fbx vs m glb sorts itself out)
if(sHips.position.y > 1e-6) ratio = tHips.position.y / sHips.position.y;
const fps=30, n=Math.max(2, Math.ceil(clip.duration*fps)+1);
const times = new Float32Array(n);
const qData = new Map([...pairs.keys()].map(tb=>[tb, new Float32Array(n*4)]));
const pData = new Float32Array(n*3);
const mixerS = new THREE.AnimationMixer(srcRoot);
mixerS.clipAction(clip).play();
const q1=new THREE.Quaternion(), q2=new THREE.Quaternion(), q3=new THREE.Quaternion();
for(let i=0;i<n;i++){
const t = Math.min(clip.duration, i/fps); times[i]=t;
mixerS.setTime(t); srcRoot.updateMatrixWorld(true);
const world = new Map(); // this frame's target world quats, parent-first (traverse order)
for(const tb of tOrder){
const parent = tb.parent;
const pw = world.get(parent) || parent.getWorldQuaternion(q3); // non-bone parents are static
let lq;
const sb = pairs.get(tb);
if(sb){
const sw = sb.getWorldQuaternion(q1); // source now
const delta = sw.multiply(q2.copy(sRest.get(sb).wq).invert()); // world delta from source rest
const tw = delta.multiply(tRest.get(tb).wq); // applied to target rest
lq = q2.copy(pw).invert().multiply(tw); // back to local
// hemisphere continuity: keep each bone on the same side as its last frame, else slerp garbage
const qd = qData.get(tb);
if(i && qd[(i-1)*4]*lq.x + qd[(i-1)*4+1]*lq.y + qd[(i-1)*4+2]*lq.z + qd[(i-1)*4+3]*lq.w < 0)
lq.set(-lq.x,-lq.y,-lq.z,-lq.w);
qd.set([lq.x,lq.y,lq.z,lq.w], i*4);
lq = new THREE.Quaternion(lq.x,lq.y,lq.z,lq.w);
} else lq = tRest.get(tb).lq;
world.set(tb, new THREE.Quaternion().multiplyQuaternions(pw, lq));
}
const hl = tRest.get(tHips).lp, sl = sRest.get(sHips).lp;
pData.set([ hl.x + (sHips.position.x - sl.x)*ratio, hl.y + (sHips.position.y - sl.y)*ratio,
hl.z + (sHips.position.z - sl.z)*ratio ], i*3);
}
mixerS.stopAllAction();
for(const [sb,r] of sRest){ sb.quaternion.copy(r.lq); sb.position.copy(r.lp); } // un-pose the source
if(opts.inPlace) for(let i=3;i<pData.length;i+=3){ pData[i]=pData[0]; pData[i+2]=pData[2]; }
const tracks = [...pairs.keys()].map(tb =>
new THREE.QuaternionKeyframeTrack(`${tb.name}.quaternion`, times, qData.get(tb)));
tracks.push(new THREE.VectorKeyframeTrack(`${tHips.name}.position`, times, pData));
return new THREE.AnimationClip(clip.name, clip.duration, tracks);
}
// ---- self-check: two 3-bone skeletons with DIFFERENT rests, bake a 1s clip across, assert the
// world-space rotation DELTA the target reproduces matches the source's, within 1e-3. --------------
function _mkSkel(prefix, restEuler){
// hips -> spine -> head, each 1 unit up its parent. restEuler tilts each bone's rest differently.
const names = ['Hips','Spine','Head'];
let parent=null, root=null; const bones=[];
names.forEach((nm,i)=>{
const b = new THREE.Bone(); b.name = prefix+nm;
b.position.set(0, i===0 ? 1.0 : 1.0, 0); // hips at y=1 so ratio is well-defined
b.rotation.set(restEuler[i][0], restEuler[i][1], restEuler[i][2]);
if(parent) parent.add(b); else root=b;
parent=b; bones.push(b);
});
const wrap = new THREE.Object3D(); wrap.add(root); wrap.updateMatrixWorld(true);
return {wrap, bones};
}
export function _selftest(){
// underscore namespaces (not colon) so three's PropertyBinding binds the clips; canon() still
// strips "mixamorig1_" / "mixamorig4_" to the same key, which is what we're actually testing.
const src = _mkSkel('mixamorig1_', [[0,0,0],[0.1,0,0.2],[0,0.15,0]]);
const tgt = _mkSkel('mixamorig4_', [[0,0.3,0],[-0.2,0,0.1],[0.05,0,-0.1]]); // different namespace + rests
const srcRest = captureRest(src.wrap), tgtRest = captureRest(tgt.wrap);
// source clip: rotate the spine over 1s
const times = new Float32Array([0, 0.5, 1.0]);
const q0=new THREE.Quaternion(), q1=new THREE.Quaternion().setFromEuler(new THREE.Euler(0.6,0,0.3));
const qm=q0.clone().slerp(q1,0.5);
const vals=new Float32Array([q0.x,q0.y,q0.z,q0.w, qm.x,qm.y,qm.z,qm.w, q1.x,q1.y,q1.z,q1.w]);
const clip = new THREE.AnimationClip('t', 1.0,
[new THREE.QuaternionKeyframeTrack(src.bones[1].name+'.quaternion', times, vals)]);
const baked = bakeRetarget(clip, src.wrap, srcRest, tgt.wrap, tgtRest);
const srcMix = new THREE.AnimationMixer(src.wrap); srcMix.clipAction(clip).play();
const tgtMix = new THREE.AnimationMixer(tgt.wrap); tgtMix.clipAction(baked).play();
const delta=(bone,rest)=>bone.getWorldQuaternion(new THREE.Quaternion())
.multiply(rest.wq.clone().invert());
let maxErr=0, srcMoved=0;
for(const t of [0, 0.5, 1.0]){
srcMix.setTime(t); src.wrap.updateMatrixWorld(true);
tgtMix.setTime(t); tgt.wrap.updateMatrixWorld(true);
for(let i=0;i<3;i++){
const ds=delta(src.bones[i], srcRest.rest.get(src.bones[i]));
const dt=delta(tgt.bones[i], tgtRest.rest.get(tgt.bones[i]));
let d = Math.abs(ds.x*dt.x + ds.y*dt.y + ds.z*dt.z + ds.w*dt.w); // |dot|→1 when equal (mod sign)
maxErr = Math.max(maxErr, 1 - d);
srcMoved = Math.max(srcMoved, 2*Math.acos(Math.min(1, Math.abs(ds.w)))); // world-delta angle (rad)
}
}
const ok = maxErr < 1e-3 && srcMoved > 0.05; // guard: a non-posing source (e.g. unbound clip) would pass trivially
return {ok, maxErr, srcMoved};
}

327
scenegod/web/stage.js Normal file
View File

@ -0,0 +1,327 @@
// stage.js — the 3D stage: renderer, IBL scene, entities on wrappers, gizmo, retarget cache.
// Timeline (Lane B) talks to the stage ONLY through this API (PLAN §4.2).
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseAny, captureRest, disposeRoot, stats, bakeRetarget } from './room3d.js';
const ASSET_URL = p => '/assets/file?path=' + encodeURIComponent(p);
export class Stage {
constructor(viewEl){
this.view = viewEl;
this._entities = new Map();
this._selCbs = []; this._changeCbs = [];
this._selected = null;
this._activeCamId = null;
this._mixerAuto = true; // Stage advances mixers each frame until Timeline takes over
this._idc = 0;
this._clipSrc = new Map(); // path -> {root, rest, anims} (parse+rest cached)
this._baked = new Map(); // id|path|index -> AnimationClip
const r = this.renderer = new THREE.WebGLRenderer({antialias:true, preserveDrawingBuffer:true});
r.setPixelRatio(devicePixelRatio);
r.shadowMap.enabled = true;
view.appendChild(r.domElement);
const scene = this.scene = new THREE.Scene();
const cam = this._dirCam = new THREE.PerspectiveCamera(45, 1, 0.01, 3000);
cam.position.set(0, 1.6, 5);
const ctr = this.controls = new OrbitControls(cam, r.domElement);
ctr.enableDamping = true; ctr.target.set(0, 1.0, 0);
const pmrem = new THREE.PMREMGenerator(r);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; pmrem.dispose();
// default lights — replaced/dimmed once the scene carries light entities
this._defHemi = new THREE.HemisphereLight(0xffffff, 0x555a66, 2.2); scene.add(this._defHemi);
this._defKey = new THREE.DirectionalLight(0xffffff, 1.8); this._defKey.position.set(3, 5, 2); scene.add(this._defKey);
const floor = new THREE.Mesh(new THREE.CircleGeometry(30, 64).rotateX(-Math.PI/2),
new THREE.MeshStandardMaterial({color:0x141a20, roughness:.97}));
floor.receiveShadow = true; scene.add(floor);
scene.add(new THREE.GridHelper(60, 60, 0x2a323b, 0x1c232b));
// gizmo
const tc = this._tc = new TransformControls(cam, r.domElement);
tc.addEventListener('dragging-changed', e => { ctr.enabled = !e.value; });
tc.addEventListener('mouseUp', () => { if(this._selected) this._fireChange(this._selected); });
scene.add(tc);
addEventListener('keydown', e => {
if(!this._selected) return;
if(e.key==='w') tc.setMode('translate');
else if(e.key==='e') tc.setMode('rotate');
else if(e.key==='r') tc.setMode('scale');
});
r.domElement.addEventListener('click', e => this._pick(e));
this.clock = new THREE.Clock();
this._resize(); addEventListener('resize', () => this._resize());
const loop = () => { requestAnimationFrame(loop);
ctr.update();
const dt = this.clock.getDelta();
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
r.render(scene, this._dirCam);
};
loop();
}
_resize(){ const w=this.view.clientWidth, h=this.view.clientHeight;
this.renderer.setSize(w, h, false); this._dirCam.aspect = w/h; this._dirCam.updateProjectionMatrix(); }
// ---- callbacks ----
onSelect(cb){ this._selCbs.push(cb); }
onChange(cb){ this._changeCbs.push(cb); }
_fireChange(en){ for(const cb of this._changeCbs) cb(en); }
// ---- entity lifecycle ----
entities(){ return [...this._entities.values()]; }
getEntity(id){ return this._entities.get(id); }
async addEntity(desc){
const id = desc.id || ('e' + (++this._idc));
if(this._idc <= (parseInt(String(id).slice(1)) || 0)) this._idc = parseInt(String(id).slice(1)) || this._idc;
const wrapper = new THREE.Group();
wrapper.userData.entityId = id;
const en = { id, kind: desc.kind, label: desc.label || desc.kind,
source: desc.source || null, params: {...(desc.params||{})},
wrapper, root:null, mixer:null, rest:null, cam:null, light:null };
if(desc.kind === 'character' || desc.kind === 'prop'){
const { root } = await this._loadAsset(desc);
this._normalize(root, desc.kind === 'character');
root.traverse(o=>{ if(o.isMesh){ o.castShadow = true; o.receiveShadow = true; } if(o.isSkinnedMesh) o.frustumCulled=false; });
wrapper.add(root); en.root = root;
if(desc.kind === 'character'){ en.rest = captureRest(root); en.mixer = new THREE.AnimationMixer(root); }
} else if(desc.kind === 'backdrop'){
en.root = await this._buildBackdrop(en); wrapper.add(en.root);
} else if(desc.kind === 'camera'){
const fov = en.params.fov || 45;
en.cam = new THREE.PerspectiveCamera(fov, 16/9, 0.05, 3000);
wrapper.add(en.cam);
const proxy = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.35, 4).rotateX(-Math.PI/2),
new THREE.MeshBasicMaterial({color:0x43c0cf, wireframe:true}));
proxy.position.z = 0.18; wrapper.add(proxy);
} else if(desc.kind === 'light'){
this._buildLight(en);
} else throw new Error('unknown kind: ' + desc.kind);
this.scene.add(wrapper);
this._entities.set(id, en);
if(desc.transform) this.setTransform(id, desc.transform);
return en;
}
removeEntity(id){
const en = this._entities.get(id); if(!en) return;
if(this._selected === id) this.select(null);
if(en.root) disposeRoot(en.root);
if(en.light) en.light.parent && en.light.parent.remove(en.light);
this.scene.remove(en.wrapper);
this._entities.delete(id);
for(const k of [...this._baked.keys()]) if(k.startsWith(id+'|')) this._baked.delete(k);
if(en.kind === 'light') this._refreshDefaults();
}
async _loadAsset(desc){
let buf, name;
if(desc.source && desc.source.type === 'upload'){
if(!desc._buf) throw new Error('upload entity has no buffer');
buf = desc._buf; name = desc.source.path;
} else {
const path = desc.source.path;
const res = await fetch(ASSET_URL(path));
if(!res.ok) throw new Error('asset fetch ' + res.status + ': ' + path);
buf = await res.arrayBuffer(); name = path;
}
return parseAny(buf, name);
}
_normalize(root, isChar){
root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(root);
if(box.isEmpty() || !isFinite(box.min.x)) return;
if(isChar){
const size = box.getSize(new THREE.Vector3());
const s = 1.7 / (Math.max(size.x, size.y, size.z) || 1);
root.scale.setScalar(s); root.updateMatrixWorld(true);
}
const b = new THREE.Box3().setFromObject(root);
root.position.x -= (b.min.x + b.max.x)/2;
root.position.z -= (b.min.z + b.max.z)/2;
root.position.y -= b.min.y; // feet on the floor
}
async _buildBackdrop(en){
const p = en.params;
const url = en.source
? (en.source.type === 'upload' ? en.source._url : ASSET_URL(p.image || en.source.path))
: ASSET_URL(p.image);
const tex = await new THREE.TextureLoader().loadAsync(url);
tex.colorSpace = THREE.SRGBColorSpace;
const aspect = (tex.image.width / tex.image.height) || 1;
const w = p.width || 10, h = w / aspect;
const mat = new THREE.MeshStandardMaterial({map:tex, roughness:1, metalness:0});
const grp = new THREE.Group();
if(p.mode === 'dome'){
const dome = new THREE.Mesh(new THREE.SphereGeometry(40, 40, 24),
new THREE.MeshBasicMaterial({map:tex, side:THREE.BackSide}));
grp.add(dome);
} else {
const wall = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
wall.position.y = h/2; wall.receiveShadow = true; grp.add(wall);
if(p.mode === 'corner'){ // cheap "L": duplicate lower third laid flat as ground
const gh = h/3;
const gm = new THREE.Mesh(new THREE.PlaneGeometry(w, gh), mat.clone());
gm.rotation.x = -Math.PI/2; gm.position.z = gh/2; gm.receiveShadow = true; grp.add(gm);
wall.position.z = -gh/2;
}
}
return grp;
}
_buildLight(en){
const p = en.params;
if(p.type === 'ambient'){
en.light = new THREE.HemisphereLight(new THREE.Color(p.color||'#ffffff'), 0x444450, p.intensity ?? 1.0);
en.wrapper.add(en.light);
} else {
const dir = en.light = new THREE.DirectionalLight(new THREE.Color(p.color||'#ffffff'), p.intensity ?? 1.5);
dir.castShadow = p.castShadow ?? true;
dir.shadow.mapSize.set(1024,1024); dir.shadow.camera.far = 60;
dir.position.set(0,0,0);
const tgt = new THREE.Object3D(); tgt.position.set(0,0,-1); en.wrapper.add(tgt); dir.target = tgt;
en.wrapper.add(dir);
const proxy = new THREE.Mesh(new THREE.SphereGeometry(0.15, 12, 8),
new THREE.MeshBasicMaterial({color:0xffe08a}));
en.wrapper.add(proxy);
}
this._refreshDefaults();
}
// dim built-in lights once scene lights exist
_refreshDefaults(){
const hasKey = this.entities().some(e => e.kind==='light' && e.params.type!=='ambient');
const hasAmb = this.entities().some(e => e.kind==='light' && e.params.type==='ambient');
this._defKey.visible = !hasKey;
this._defHemi.intensity = hasAmb ? 0 : 2.2;
}
// ---- selection + gizmo ----
select(id){
this._selected = id;
if(id == null){ this._tc.detach(); }
else { const en = this._entities.get(id); if(en) this._tc.attach(en.wrapper); }
for(const cb of this._selCbs) cb(id ? this._entities.get(id) : null);
}
_pick(e){
if(this._tc.dragging) return;
const rect = this.renderer.domElement.getBoundingClientRect();
const ndc = new THREE.Vector2(
((e.clientX - rect.left)/rect.width)*2 - 1,
-((e.clientY - rect.top)/rect.height)*2 + 1);
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
const wraps = this.entities().map(en => en.wrapper);
const hits = rc.intersectObjects(wraps, true);
for(const h of hits){
let o = h.object; while(o && o.userData.entityId == null) o = o.parent;
if(o){ this.select(o.userData.entityId); return; }
}
this.select(null);
}
// ---- transforms / params (Timeline drives these every tick) ----
entityTransform(id){
const w = this._entities.get(id)?.wrapper; if(!w) return null;
return { pos:[w.position.x, w.position.y, w.position.z],
rot:[w.rotation.x, w.rotation.y, w.rotation.z],
scale: w.scale.x };
}
setTransform(id, {pos, rot, scale}){
const w = this._entities.get(id)?.wrapper; if(!w) return;
if(pos) w.position.set(pos[0], pos[1], pos[2]);
if(rot) w.rotation.set(rot[0], rot[1], rot[2]);
if(scale != null) w.scale.setScalar(scale);
}
setParam(id, key, value){
const en = this._entities.get(id); if(!en) return;
en.params[key] = value;
if(en.cam && key === 'fov'){ en.cam.fov = value; en.cam.updateProjectionMatrix(); }
if(en.light){
if(key === 'intensity') en.light.intensity = value;
if(key === 'color') en.light.color.set(value);
}
// backdrop mode/image/width changes rebuild lazily — M2; ponytail: rebuild-on-change, add when keyable.
}
// ---- clips (bake is expensive — cache per path parse and per (entity,path,index) bake) ----
async prepareClip(id, path, clipIndex=0){
const en = this._entities.get(id);
if(!en || en.kind !== 'character') throw new Error('prepareClip: not a character: ' + id);
const bkey = `${id}|${path}|${clipIndex}`;
if(this._baked.has(bkey)) return this._baked.get(bkey);
let src = this._clipSrc.get(path);
if(!src){
const res = await fetch(ASSET_URL(path));
if(!res.ok) throw new Error('clip fetch ' + res.status + ': ' + path);
const { root, anims } = await parseAny(await res.arrayBuffer(), path);
src = { root, anims, rest: captureRest(root) };
this._clipSrc.set(path, src);
}
const clip = src.anims[clipIndex];
if(!clip) throw new Error('no clip index ' + clipIndex + ' in ' + path);
const baked = bakeRetarget(clip, src.root, src.rest, en.root, en.rest);
this._baked.set(bkey, baked);
return baked;
}
// same bake, from in-memory bytes (dropped clip file) instead of a server path
async prepareClipUpload(id, buf, name, clipIndex=0){
const en = this._entities.get(id);
if(!en || en.kind !== 'character') throw new Error('prepareClipUpload: not a character: ' + id);
const { root, anims } = await parseAny(buf, name);
const clip = anims[clipIndex];
if(!clip) throw new Error('no animation tracks in ' + name);
return bakeRetarget(clip, root, captureRest(root), en.root, en.rest);
}
entityMixer(id){ return this._entities.get(id)?.mixer || null; }
// M1 temp helper: play a prepared clip on a character (Timeline replaces this in M2).
playClip(id, clip, {loop=true} = {}){
const en = this._entities.get(id); if(!en || !en.mixer) return;
en.mixer.stopAllAction();
const a = en.mixer.clipAction(clip);
a.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
a.clampWhenFinished = !loop; a.reset().play();
}
setMixerAuto(on){ this._mixerAuto = on; } // Timeline calls setMixerAuto(false) at integration
// ---- cameras ----
setActiveCamera(id){ this._activeCamId = id; }
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
return en && en.cam ? en.cam : this._dirCam; }
renderActiveCamera(canvas){
const cam = this._activeCam();
if(cam.isPerspectiveCamera && canvas){ cam.aspect = canvas.width/canvas.height; cam.updateProjectionMatrix(); }
this.renderer.render(this.scene, cam);
if(canvas){ const ctx = canvas.getContext('2d');
ctx.drawImage(this.renderer.domElement, 0, 0, canvas.width, canvas.height); }
}
// ---- scene JSON round-trip (entities only; Timeline owns tracks/duration/cuts) ----
captureState(){
return this.entities().map(en => ({
id: en.id, kind: en.kind, label: en.label,
source: en.source, params: {...en.params},
transform: this.entityTransform(en.id),
upload: en.source && en.source.type === 'upload' || undefined,
}));
}
async applyState(sceneJson){
for(const en of this.entities()) this.removeEntity(en.id);
for(const d of (sceneJson.entities || [])){
if(d.source && d.source.type === 'upload'){ console.warn('skipping upload entity (no bytes):', d.id); continue; }
try { await this.addEntity(d); } catch(err){ console.error('addEntity failed', d.id, err); }
}
}
}

67
scenegod/web/style.css Normal file
View File

@ -0,0 +1,67 @@
/* SCENEGOD — dark director UI, visual language borrowed from MESHGOD rigroom */
:root{
--bg:#101317; --bg2:#161b21; --panel:#1a2027; --line:#2a323b; --line2:#39434e;
--ink:#e8ecf1; --mut:#8a95a2; --teal:#43c0cf; --teal-d:#2a8b98; --bad:#e0604a; --good:#5bc98b;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);height:100vh;
display:flex;flex-direction:column;overflow:hidden}
header{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex:0 0 auto}
header h1{margin:0;font-size:16px;letter-spacing:-.01em}
header .tag{font-family:var(--mono);font-size:10px;color:var(--teal);letter-spacing:.12em;text-transform:uppercase}
#selftest{margin-left:auto;font-family:var(--mono);font-size:11px}
#selftest.ok{color:var(--good)} #selftest.bad{color:var(--bad)}
#app{flex:1;min-height:0;display:grid;
grid-template-columns:280px 1fr 260px;
grid-template-rows:1fr 260px;
grid-template-areas:"dock view inspector" "dock timeline inspector";}
#dock{grid-area:dock;border-right:1px solid var(--line);overflow-y:auto;padding:10px}
#view{grid-area:view;position:relative;min-width:0;
background:radial-gradient(120% 100% at 50% 0%,#20272f,#0d1013)}
#view canvas{display:block;width:100%;height:100%}
#inspector{grid-area:inspector;border-left:1px solid var(--line);overflow-y:auto;padding:12px}
#timeline{grid-area:timeline;border-top:1px solid var(--line);background:var(--bg2);overflow:auto}
.empty{color:var(--mut);font-family:var(--mono);font-size:11.5px;padding:14px;line-height:1.6}
/* dock */
.tabs{display:flex;background:var(--bg2);border:1px solid var(--line2);border-radius:7px;overflow:hidden;margin-bottom:10px}
.tabs button{flex:1;background:transparent;border:0;color:var(--mut);padding:7px 6px;font-family:var(--mono);
font-size:10.5px;cursor:pointer;letter-spacing:.04em}
.tabs button.on{background:var(--teal);color:#0c0f12;font-weight:700}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(112px,1fr));gap:8px}
.card{background:var(--bg2);border:1px solid var(--line);border-radius:9px;overflow:hidden;cursor:pointer}
.card:hover{border-color:var(--teal)}
.card .th{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:30px;
background:radial-gradient(120% 100% at 50% 0%,#20272f,#0d1013);color:var(--mut)}
.card .nm{padding:5px 7px;font-size:10.5px;line-height:1.3;color:var(--ink);
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:30px}
/* inspector */
.ihd{margin-bottom:10px} .ihd b{font-size:14px}
.ihd .kind{display:block;font-family:var(--mono);font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
.ihd .warn{color:var(--bad)}
.fld{display:flex;align-items:center;gap:8px;margin:7px 0;font-family:var(--mono);font-size:11px;color:var(--mut)}
.fld>span{width:52px;flex:0 0 auto}
.fld input,.fld select{flex:1;min-width:0;background:var(--panel);border:1px solid var(--line2);color:var(--ink);
border-radius:6px;padding:5px 7px;font-family:var(--mono);font-size:11px}
.fld input[type=color]{padding:2px;height:26px}
.fld.vec input{width:100%}
.fld.vec{align-items:center}
.ibtns{display:flex;gap:8px;margin-top:14px}
.ibtns button{flex:1;background:var(--panel);border:1px solid var(--line2);color:var(--ink);border-radius:7px;
padding:8px;font-family:var(--mono);font-size:12px;cursor:pointer}
.ibtns button:hover{border-color:var(--teal)}
.ibtns button.danger:hover{border-color:var(--bad);color:var(--bad)}
/* drop + toast */
body.hot::after{content:'drop to load';position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
font-size:24px;color:var(--teal);background:rgba(16,19,23,.85);pointer-events:none;z-index:99}
#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--panel);
border:1px solid var(--teal);border-radius:9px;padding:9px 16px;font-size:13px;display:none;z-index:50;max-width:80vw}
/* === LANE B === */
/* Lane B appends timeline styling below this marker */