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 */