diff --git a/logs/laneA.md b/logs/laneA.md
index 2937d7a..07ba5f4 100644
--- a/logs/laneA.md
+++ b/logs/laneA.md
@@ -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).
diff --git a/logs/shots/m1-lady-running.png b/logs/shots/m1-lady-running.png
new file mode 100644
index 0000000..b1cf5ac
Binary files /dev/null and b/logs/shots/m1-lady-running.png differ
diff --git a/scenegod/web/dock.js b/scenegod/web/dock.js
new file mode 100644
index 0000000..c036984
--- /dev/null
+++ b/scenegod/web/dock.js
@@ -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 = `
${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.innerHTML = `${icon}
${this._name(it)}
`;
+ 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 = '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)));
+ 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 = `${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){
+ 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);
+ }
+}
diff --git a/scenegod/web/index.html b/scenegod/web/index.html
new file mode 100644
index 0000000..f56f3f7
--- /dev/null
+++ b/scenegod/web/index.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+SCENEGOD — machinima stage
+
+
+
+
+ 🎬 SCENEGOD
machinima stage
+
+
+
+
+
+
+
timeline — Lane B mounts here
+
+
+
+
+
diff --git a/scenegod/web/room3d.js b/scenegod/web/room3d.js
new file mode 100644
index 0000000..152527f
--- /dev/null
+++ b/scenegod/web/room3d.js
@@ -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//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
+ 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};
+}
diff --git a/scenegod/web/stage.js b/scenegod/web/stage.js
new file mode 100644
index 0000000..5d6a07e
--- /dev/null
+++ b/scenegod/web/stage.js
@@ -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); }
+ }
+ }
+}
diff --git a/scenegod/web/style.css b/scenegod/web/style.css
new file mode 100644
index 0000000..11726d8
--- /dev/null
+++ b/scenegod/web/style.css
@@ -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 */