[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:
parent
b20354c53f
commit
de3416869f
@ -9,3 +9,50 @@ Format per session:
|
|||||||
NEXT: …
|
NEXT: …
|
||||||
|
|
||||||
(no sessions yet)
|
(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).
|
||||||
|
|||||||
BIN
logs/shots/m1-lady-running.png
Normal file
BIN
logs/shots/m1-lady-running.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
187
scenegod/web/dock.js
Normal file
187
scenegod/web/dock.js
Normal 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
57
scenegod/web/index.html
Normal 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
171
scenegod/web/room3d.js
Normal 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
327
scenegod/web/stage.js
Normal 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
67
scenegod/web/style.css
Normal 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 */
|
||||||
Loading…
Reference in New Issue
Block a user