- dock: /assets/tree {name,path,formats,thumb}; +camera/+key/+ambient toolbar;
clip apply dispatches scenegod:clipdrop when timeline present (else preview);
drag-from-dock places onto the viewport (screenToFloor / entityAt)
- stage: PiP overlay from active camera; renderActiveCamera hardened as M3 target
+ pause()/resume(); backdrop rebuild-on-param; fire onChange on add+remove
(fixes tlui empty-names + a gizmo mouseUp id-vs-entity bug); set-active-camera
- verified in the integrated app (:8020, Lane B timeline mounted), zero console
errors. Found + logged the entity-mirror integration gap (timeline model isn't
seeded from live stage adds) for the orchestrator.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
17 KiB
JavaScript
374 lines
17 KiB
JavaScript
// 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);
|
|
this._paused = false; // M3 final render freezes the director loop, then drives renderActiveCamera
|
|
|
|
// PiP overlay — the active camera's view, shown top-right when a camera is active
|
|
const pip = this._pip = document.createElement('canvas');
|
|
pip.width = 256; pip.height = 144; pip.className = 'pip'; pip.style.display = 'none';
|
|
view.appendChild(pip);
|
|
|
|
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.getEntity(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);
|
|
const dt = this.clock.getDelta();
|
|
if(this._paused) return; // M3: caller drives rendering deterministically
|
|
ctr.update();
|
|
if(this._mixerAuto) for(const en of this._entities.values()) en.mixer && en.mixer.update(dt);
|
|
// PiP first (it scribbles the active cam onto the main canvas), then the director view last
|
|
if(this._activeCamId && pip.style.display !== 'none') this.renderActiveCamera(pip);
|
|
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);
|
|
this._fireChange(en); // tlui/dock build a row per entity off onChange (incl. applyState loads)
|
|
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();
|
|
this._fireChange(en); // trigger a row resync so the deleted entity drops out
|
|
}
|
|
|
|
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);
|
|
if(key === 'castShadow' && en.light.isDirectionalLight) en.light.castShadow = !!value;
|
|
}
|
|
if(en.kind === 'backdrop' && (key === 'mode' || key === 'image' || key === 'width'))
|
|
this._rebuildBackdrop(en); // geometry depends on these — rebuild the mesh
|
|
}
|
|
async _rebuildBackdrop(en){
|
|
const old = en.root;
|
|
en.root = await this._buildBackdrop(en);
|
|
en.wrapper.add(en.root);
|
|
if(old){ en.wrapper.remove(old); disposeRoot(old); }
|
|
}
|
|
|
|
// ---- 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;
|
|
this._pip.style.display = (id && this._entities.get(id)?.cam) ? 'block' : 'none';
|
|
}
|
|
_activeCam(){ const en = this._activeCamId && this._entities.get(this._activeCamId);
|
|
return en && en.cam ? en.cam : this._dirCam; }
|
|
// PiP: pass the small overlay canvas. Final render (M3): pass null after pause() — the active cam
|
|
// lands on the main WebGL canvas at its current size for readPixels/toBlob. Hard contract, keep it.
|
|
renderActiveCamera(canvas){
|
|
const cam = this._activeCam();
|
|
const aspect = canvas ? canvas.width/canvas.height
|
|
: this.renderer.domElement.width/this.renderer.domElement.height;
|
|
if(cam.isPerspectiveCamera){ cam.aspect = aspect; 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); }
|
|
}
|
|
pause(){ this._paused = true; }
|
|
resume(){ this._paused = false; this.clock.getDelta(); } // swallow the paused gap so mixers don't jump
|
|
|
|
// ---- pointer→world helpers (drag-from-dock placement) ----
|
|
screenToFloor(clientX, clientY){
|
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
|
|
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
|
|
const hit = new THREE.Vector3();
|
|
return rc.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0,1,0), 0), hit) ? [hit.x, 0, hit.z] : [0,0,0];
|
|
}
|
|
entityAt(clientX, clientY, kind){
|
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
const ndc = new THREE.Vector2(((clientX-rect.left)/rect.width)*2-1, -((clientY-rect.top)/rect.height)*2+1);
|
|
const rc = new THREE.Raycaster(); rc.setFromCamera(ndc, this._dirCam);
|
|
const hits = rc.intersectObjects(this.entities().map(e=>e.wrapper), true);
|
|
for(const h of hits){ let o=h.object; while(o && o.userData.entityId==null) o=o.parent;
|
|
if(o){ const en=this._entities.get(o.userData.entityId); if(en && (!kind || en.kind===kind)) return en.id; } }
|
|
return null;
|
|
}
|
|
|
|
// ---- 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); }
|
|
}
|
|
}
|
|
}
|