Spaces:
Paused
Paused
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| // Spark (gaussian-splat renderer) is loaded lazily via ensureSpark() — only the | |
| // dojo-scene splat path needs it, so its unpkg module stays off the boot path. | |
| window.__viewerBooted = true; // core ESM imports resolved; the head watchdog stands down | |
| const SMPLX_HEIGHT = 1.7; | |
| const data = JSON.parse(document.getElementById('motion-data').textContent); | |
| const characters = JSON.parse(document.getElementById('char-data').textContent); | |
| const cfg = JSON.parse(document.getElementById('viewer-cfg').textContent); | |
| // The id of the clip currently on screen (kept current on every switch) so | |
| // the on-screen label can show it. Hoisted here (above setCreatorLabel) to | |
| // avoid a temporal-dead-zone throw when the boot label renders. | |
| let curId = (cfg.currentId && cfg.currentId.indexOf('kata:') !== 0 ? cfg.currentId : '') | |
| || (data ? data.id : '') || ''; | |
| function decodeFloat32(b64) { | |
| const bin = atob(b64); | |
| const bytes = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); | |
| return new Float32Array(bytes.buffer); | |
| } | |
| function decodeBytes(b64) { | |
| const bin = atob(b64); | |
| const bytes = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); | |
| return bytes; | |
| } | |
| const app = document.getElementById('app'); | |
| const scene = new THREE.Scene(); | |
| const defaultBackground = new THREE.Color(0x151719); | |
| scene.background = defaultBackground.clone(); | |
| let dojoTexture = null; | |
| let dojoEnv = null; | |
| function makePanoramaFloorMaterial(tex) { | |
| return new THREE.ShaderMaterial({ | |
| uniforms: { | |
| pano: { value: tex }, | |
| origin: { value: new THREE.Vector3(0.0, 1.35, 0.0) }, | |
| }, | |
| vertexShader: ` | |
| varying vec3 vWorldPos; | |
| void main() { | |
| vec4 world = modelMatrix * vec4(position, 1.0); | |
| vWorldPos = world.xyz; | |
| gl_Position = projectionMatrix * viewMatrix * world; | |
| } | |
| `, | |
| fragmentShader: ` | |
| precision highp float; | |
| uniform sampler2D pano; | |
| uniform vec3 origin; | |
| varying vec3 vWorldPos; | |
| const float PI = 3.141592653589793; | |
| void main() { | |
| vec3 dir = normalize(vWorldPos - origin); | |
| float u = atan(dir.z, dir.x) / (2.0 * PI) + 0.5; | |
| float v = asin(clamp(dir.y, -1.0, 1.0)) / PI + 0.5; | |
| vec3 color = texture2D(pano, vec2(u, v)).rgb; | |
| gl_FragColor = vec4(color * 1.08, 1.0); | |
| } | |
| `, | |
| side: THREE.DoubleSide, | |
| }); | |
| } | |
| function clearDojoScene() { | |
| if (dojoEnv) { | |
| scene.remove(dojoEnv); | |
| dojoEnv.traverse((o) => { | |
| if (o.geometry) o.geometry.dispose(); | |
| if (o.material) { | |
| if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose()); | |
| else o.material.dispose(); | |
| } | |
| }); | |
| dojoEnv = null; | |
| } | |
| if (dojoTexture) { | |
| dojoTexture.dispose(); | |
| dojoTexture = null; | |
| } | |
| scene.background = defaultBackground.clone(); | |
| if (typeof floor !== 'undefined') floor.visible = true; | |
| } | |
| function applyDojoScene(src, settings) { | |
| if (!src) { clearDojoScene(); return; } | |
| applyDojoRoomSettings(settings); | |
| new THREE.TextureLoader().load(src, (tex) => { | |
| clearDojoScene(); | |
| applyDojoRoomSettings(settings); | |
| tex.colorSpace = THREE.SRGBColorSpace; | |
| dojoTexture = tex; | |
| scene.background = defaultBackground.clone(); | |
| if (typeof floor !== 'undefined') floor.visible = false; | |
| dojoEnv = new THREE.Group(); | |
| dojoEnv.name = 'dojo-environment'; | |
| // Finite world-space panorama shell. Unlike scene.background, this is real | |
| // geometry, so camera translation/dolly creates visible scale/parallax. | |
| const shellGeo = new THREE.SphereGeometry(5.8, 64, 32); | |
| shellGeo.scale(-1, 1, 1); | |
| const shellMat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.FrontSide, depthWrite: false }); | |
| const shell = new THREE.Mesh(shellGeo, shellMat); | |
| shell.position.set(0, 1.35, 0); | |
| shell.renderOrder = -10; | |
| dojoEnv.add(shell); | |
| const floorGeo = new THREE.PlaneGeometry(8.5, 8.5, 24, 24); | |
| const floorMat = makePanoramaFloorMaterial(tex); | |
| const roomFloor = new THREE.Mesh(floorGeo, floorMat); | |
| roomFloor.rotation.x = -Math.PI / 2; | |
| roomFloor.position.y = -0.012; | |
| dojoEnv.add(roomFloor); | |
| const wallMat = new THREE.MeshStandardMaterial({ | |
| color: 0x3b3028, | |
| roughness: 0.9, | |
| metalness: 0.0, | |
| transparent: true, | |
| opacity: 0.42, | |
| side: THREE.DoubleSide, | |
| }); | |
| const backWall = new THREE.Mesh(new THREE.PlaneGeometry(8.5, 3.7), wallMat.clone()); | |
| backWall.position.set(0, 1.85, -4.15); | |
| dojoEnv.add(backWall); | |
| const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(8.2, 3.7), wallMat.clone()); | |
| leftWall.position.set(-4.15, 1.85, 0); | |
| leftWall.rotation.y = Math.PI / 2; | |
| dojoEnv.add(leftWall); | |
| const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(8.2, 3.7), wallMat.clone()); | |
| rightWall.position.set(4.15, 1.85, 0); | |
| rightWall.rotation.y = -Math.PI / 2; | |
| dojoEnv.add(rightWall); | |
| wallMat.dispose(); | |
| scene.add(dojoEnv); | |
| applyDojoRoomSettings(settings); | |
| }, undefined, (err) => { | |
| console.error('dojo scene load failed', err); | |
| }); | |
| } | |
| let disposed = false; // set on teardown to stop the loop + free the GL context | |
| const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| app.appendChild(renderer.domElement); | |
| // Spark renderer is created on first splat use (dojo scenes only) — see ensureSpark(). | |
| // This keeps the heavy unpkg spark module off the viewer's critical boot path. | |
| let spark = null; | |
| async function ensureSpark() { | |
| const mod = await import('@sparkjsdev/spark'); | |
| if (!spark) { spark = new mod.SparkRenderer({ renderer }); scene.add(spark); } | |
| return mod; | |
| } | |
| let dojoSplatRoot = null; | |
| let dojoSplatHelper = null; | |
| let dojoSplatObjectUrl = null; | |
| // Room size is the dojo FLOOR FOOTPRINT in world metres — the same units as the | |
| // ~1.7m character (SMPLX_HEIGHT). fitDojoSplat normalises every splat to this span, | |
| // so the character/room proportion is fixed: 6.5m ≈ 3.8 character-heights across | |
| // (the character spans ~26% of the floor — a believable small dojo). | |
| let dojoRoomSize = 6.5; | |
| let dojoRoomHeight = 3.2; | |
| let dojoSplatBaseBounds = null; | |
| const dojoFloorSurfaceY = 0.0; | |
| const dojoFloorThickness = 0.025; | |
| function postDojoStatus(text) { | |
| try { parent.postMessage({ kind: 'dojo-status', text }, '*'); } catch (e) {} | |
| } | |
| function applyDojoRoomSettings(settings) { | |
| settings = settings || {}; | |
| dojoRoomSize = Math.max(1.0, Number(settings.room_size || settings.roomSize || dojoRoomSize) || dojoRoomSize); | |
| dojoRoomHeight = Math.max(1.0, Number(settings.room_height || settings.roomHeight || dojoRoomHeight) || dojoRoomHeight); | |
| if (dojoSplatRoot) fitDojoSplat(); | |
| if (dojoEnv) dojoEnv.scale.set(dojoRoomSize / 8.5, dojoRoomHeight / 3.2, dojoRoomSize / 8.5); | |
| if (!dojoSplatRoot) resizeFloorGrid(dojoRoomSize); | |
| } | |
| function percentile(values, q) { | |
| if (!values || !values.length) return 0; | |
| const sorted = values.slice().sort((a, b) => a - b); | |
| const idx = Math.min(sorted.length - 1, Math.max(0, (sorted.length - 1) * q)); | |
| const lo = Math.floor(idx); | |
| const hi = Math.ceil(idx); | |
| const t = idx - lo; | |
| return sorted[lo] * (1 - t) + sorted[hi] * t; | |
| } | |
| function resizeFloorGrid(size) { | |
| if (typeof floor === 'undefined') return; | |
| const gridSize = Math.max(1.0, Number(size) || dojoRoomSize); | |
| floor.scale.set(gridSize, 1, gridSize); | |
| } | |
| function estimateSplatFloor(localX, localY, localZ, robust) { | |
| if (!localY || !localY.length || !robust) return percentile(localY || [0], 0.02); | |
| const yLow = percentile(localY, 0.02); | |
| const yHigh = percentile(localY, 0.55); | |
| const span = Math.max(0.001, yHigh - yLow); | |
| const bins = 56; | |
| const stats = Array.from({ length: bins }, () => ({ | |
| count: 0, | |
| minX: Infinity, | |
| maxX: -Infinity, | |
| minZ: Infinity, | |
| maxZ: -Infinity, | |
| sumY: 0, | |
| })); | |
| const padX = robust.sizeX * 0.15; | |
| const padZ = robust.sizeZ * 0.15; | |
| for (let i = 0; i < localY.length; i++) { | |
| const y = localY[i]; | |
| const x = localX[i]; | |
| const z = localZ[i]; | |
| if (y < yLow || y > yHigh) continue; | |
| if (x < robust.minX - padX || x > robust.maxX + padX || z < robust.minZ - padZ || z > robust.maxZ + padZ) continue; | |
| const idx = Math.min(bins - 1, Math.max(0, Math.floor(((y - yLow) / span) * bins))); | |
| const s = stats[idx]; | |
| s.count += 1; | |
| s.sumY += y; | |
| s.minX = Math.min(s.minX, x); | |
| s.maxX = Math.max(s.maxX, x); | |
| s.minZ = Math.min(s.minZ, z); | |
| s.maxZ = Math.max(s.maxZ, z); | |
| } | |
| let best = null; | |
| let bestScore = -Infinity; | |
| for (let i = 0; i < stats.length; i++) { | |
| const s = stats[i]; | |
| if (s.count < 12) continue; | |
| const coverX = Math.min(1, Math.max(0, (s.maxX - s.minX) / robust.sizeX)); | |
| const coverZ = Math.min(1, Math.max(0, (s.maxZ - s.minZ) / robust.sizeZ)); | |
| const coverage = Math.sqrt(Math.max(0.001, coverX * coverZ)); | |
| // Prefer dense, broad, lower horizontal bands. This rejects small outlier | |
| // clusters while avoiding upper wall/ceiling bands with similar density. | |
| const lowerBias = 1.0 - (i / bins) * 0.35; | |
| const score = s.count * coverage * lowerBias; | |
| if (score > bestScore) { | |
| bestScore = score; | |
| best = { y: s.sumY / s.count, count: s.count, coverage, index: i }; | |
| } | |
| } | |
| if (!best) return percentile(localY, 0.08); | |
| return best.y; | |
| } | |
| async function splatPayloadBuffer(value) { | |
| if (!value) return ''; | |
| const response = await fetch(value); | |
| if (!response.ok) throw new Error('PLY fetch failed: ' + response.status + ' ' + response.statusText); | |
| return await response.arrayBuffer(); | |
| } | |
| function parsePlyBounds(buffer) { | |
| const bytes = new Uint8Array(buffer); | |
| const scanLimit = Math.min(bytes.length, 16384); | |
| let headerEnd = -1; | |
| for (let i = 0; i < scanLimit - 10; i++) { | |
| if (bytes[i] === 10 && bytes[i + 1] === 101 && bytes[i + 2] === 110 && bytes[i + 3] === 100 && bytes[i + 4] === 95 && bytes[i + 5] === 104 && bytes[i + 6] === 101 && bytes[i + 7] === 97 && bytes[i + 8] === 100 && bytes[i + 9] === 101 && bytes[i + 10] === 114) { | |
| headerEnd = i + 11; | |
| if (bytes[headerEnd] === 13 && bytes[headerEnd + 1] === 10) headerEnd += 2; | |
| else if (bytes[headerEnd] === 10) headerEnd += 1; | |
| break; | |
| } | |
| } | |
| if (headerEnd < 0) return null; | |
| const header = new TextDecoder('ascii').decode(bytes.slice(0, headerEnd)); | |
| const lines = header.split(/\r?\n/); | |
| const formatLine = lines.find((line) => line.startsWith('format ')) || ''; | |
| const isAscii = formatLine.indexOf('ascii') >= 0; | |
| const isBinaryLittle = formatLine.indexOf('binary_little_endian') >= 0; | |
| let vertexCount = 0; | |
| const props = []; | |
| let inVertex = false; | |
| for (const line of lines) { | |
| const parts = line.trim().split(/\s+/); | |
| if (parts[0] === 'element') { | |
| inVertex = parts[1] === 'vertex'; | |
| if (inVertex) vertexCount = Number(parts[2]) || 0; | |
| } else if (inVertex && parts[0] === 'property' && parts.length >= 3) { | |
| props.push({ type: parts[1], name: parts[2] }); | |
| } | |
| } | |
| const xIndex = props.findIndex((p) => p.name === 'x'); | |
| const yIndex = props.findIndex((p) => p.name === 'y'); | |
| const zIndex = props.findIndex((p) => p.name === 'z'); | |
| if (!vertexCount || xIndex < 0 || yIndex < 0 || zIndex < 0) return null; | |
| const min = [Infinity, Infinity, Infinity]; | |
| const max = [-Infinity, -Infinity, -Infinity]; | |
| const localX = []; | |
| const localY = []; | |
| const localZ = []; | |
| const add = (x, y, z) => { | |
| if (![x, y, z].every(Number.isFinite)) return; | |
| min[0] = Math.min(min[0], x); min[1] = Math.min(min[1], y); min[2] = Math.min(min[2], z); | |
| max[0] = Math.max(max[0], x); max[1] = Math.max(max[1], y); max[2] = Math.max(max[2], z); | |
| // Match the viewer transforms below: splat yaw Y=PI/2, root X=PI. | |
| localX.push(z); | |
| localY.push(-y); | |
| localZ.push(x); | |
| }; | |
| if (isAscii) { | |
| const body = new TextDecoder('utf-8').decode(bytes.slice(headerEnd)); | |
| const rows = body.split(/\r?\n/); | |
| for (let i = 0; i < Math.min(vertexCount, rows.length); i++) { | |
| const vals = rows[i].trim().split(/\s+/).map(Number); | |
| add(vals[xIndex], vals[yIndex], vals[zIndex]); | |
| } | |
| } else if (isBinaryLittle) { | |
| const sizeOf = (type) => (type === 'double' || type === 'float64' ? 8 : type === 'uchar' || type === 'uint8' || type === 'char' || type === 'int8' ? 1 : type === 'ushort' || type === 'uint16' || type === 'short' || type === 'int16' ? 2 : 4); | |
| const readers = { | |
| float: (dv, o) => dv.getFloat32(o, true), | |
| float32: (dv, o) => dv.getFloat32(o, true), | |
| double: (dv, o) => dv.getFloat64(o, true), | |
| float64: (dv, o) => dv.getFloat64(o, true), | |
| uchar: (dv, o) => dv.getUint8(o), | |
| uint8: (dv, o) => dv.getUint8(o), | |
| char: (dv, o) => dv.getInt8(o), | |
| int8: (dv, o) => dv.getInt8(o), | |
| ushort: (dv, o) => dv.getUint16(o, true), | |
| uint16: (dv, o) => dv.getUint16(o, true), | |
| short: (dv, o) => dv.getInt16(o, true), | |
| int16: (dv, o) => dv.getInt16(o, true), | |
| uint: (dv, o) => dv.getUint32(o, true), | |
| uint32: (dv, o) => dv.getUint32(o, true), | |
| int: (dv, o) => dv.getInt32(o, true), | |
| int32: (dv, o) => dv.getInt32(o, true), | |
| }; | |
| const stride = props.reduce((n, p) => n + sizeOf(p.type), 0); | |
| const offsets = []; | |
| let off = 0; | |
| for (const p of props) { offsets.push(off); off += sizeOf(p.type); } | |
| const dv = new DataView(buffer); | |
| for (let i = 0; i < vertexCount; i++) { | |
| const base = headerEnd + i * stride; | |
| if (base + stride > buffer.byteLength) break; | |
| const read = (idx) => (readers[props[idx].type] || readers.float)(dv, base + offsets[idx]); | |
| add(read(xIndex), read(yIndex), read(zIndex)); | |
| } | |
| } else { | |
| return null; | |
| } | |
| if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) return null; | |
| const size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]]; | |
| const robust = { | |
| minX: percentile(localX, 0.05), | |
| maxX: percentile(localX, 0.95), | |
| minZ: percentile(localZ, 0.05), | |
| maxZ: percentile(localZ, 0.95), | |
| }; | |
| robust.sizeX = Math.max(0.001, robust.maxX - robust.minX); | |
| robust.sizeZ = Math.max(0.001, robust.maxZ - robust.minZ); | |
| robust.footprint = Math.max(robust.sizeX, robust.sizeZ); | |
| robust.floorY = estimateSplatFloor(localX, localY, localZ, robust); | |
| robust.floorP02 = percentile(localY, 0.02); | |
| return { min, max, size, maxDim: Math.max(size[0], size[1], size[2]), robust }; | |
| } | |
| function clearDojoSplat() { | |
| if (dojoSplatHelper) { | |
| scene.remove(dojoSplatHelper); | |
| if (dojoSplatHelper.geometry) dojoSplatHelper.geometry.dispose(); | |
| if (dojoSplatHelper.material) dojoSplatHelper.material.dispose(); | |
| dojoSplatHelper = null; | |
| } | |
| if (dojoSplatRoot) { | |
| scene.remove(dojoSplatRoot); | |
| _dojoSplatGrow = null; | |
| dojoSplatRoot.traverse((o) => { | |
| // SplatMesh.dispose() frees its packed splats so the Spark renderer stops | |
| // drawing it (removing it from the scene graph alone doesn't — Spark caches). | |
| if (typeof o.dispose === 'function') o.dispose(); | |
| if (o.geometry) o.geometry.dispose(); | |
| if (o.material) { | |
| if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose()); | |
| else o.material.dispose(); | |
| } | |
| }); | |
| dojoSplatRoot = null; | |
| } | |
| if (dojoSplatObjectUrl) { | |
| URL.revokeObjectURL(dojoSplatObjectUrl); | |
| dojoSplatObjectUrl = null; | |
| } | |
| dojoSplatBaseBounds = null; | |
| // Tear the Spark renderer down so it stops drawing the removed splat (it caches | |
| // accumulated splats independently of the scene graph). ensureSpark() rebuilds it | |
| // for the next dojo splat. | |
| if (spark) { | |
| try { scene.remove(spark); if (typeof spark.dispose === 'function') spark.dispose(); } catch (e) {} | |
| spark = null; | |
| } | |
| } | |
| // Animate the splat scaling up from ~0 to its fit scale on first load, so it grows | |
| // in instead of popping. Ticked each frame by _dojoSplatGrowTick. | |
| let _dojoSplatGrow = null; | |
| function _dojoSplatGrowTick(dt) { | |
| if (!_dojoSplatGrow || !dojoSplatRoot) return; | |
| _dojoSplatGrow.t += dt; | |
| const k = Math.min(1, _dojoSplatGrow.t / _dojoSplatGrow.dur); | |
| const e = 1 - Math.pow(1 - k, 3); // easeOutCubic | |
| dojoSplatRoot.scale.setScalar(Math.max(0.0001, _dojoSplatGrow.to * e)); | |
| if (k >= 1) _dojoSplatGrow = null; | |
| } | |
| function fitDojoSplat(attempt = 0, grow = false) { | |
| if (!dojoSplatRoot) return; | |
| const robust = dojoSplatBaseBounds && dojoSplatBaseBounds.robust ? dojoSplatBaseBounds.robust : null; | |
| const rawMax = robust && robust.footprint > 0 ? robust.footprint : (dojoSplatBaseBounds && dojoSplatBaseBounds.maxDim > 0 ? dojoSplatBaseBounds.maxDim : 1.0); | |
| // Normalise the splat footprint to `dojoRoomSize` metres so it stays proportional | |
| // to the ~SMPLX_HEIGHT-tall character (room span ≈ dojoRoomSize / 1.7 body-heights). | |
| const target = Math.max(SMPLX_HEIGHT * 1.5, dojoRoomSize); | |
| const scale = target / rawMax; | |
| const localFloorY = robust ? robust.floorY : 0; | |
| const localCenterX = robust ? (robust.minX + robust.maxX) * 0.5 : 0; | |
| const localCenterZ = robust ? (robust.minZ + robust.maxZ) * 0.5 : 0; | |
| const floorY = (dojoFloorSurfaceY - dojoFloorThickness) - localFloorY * scale; | |
| if (grow) { _dojoSplatGrow = { t: 0, dur: 0.7, to: scale }; dojoSplatRoot.scale.setScalar(0.0001); } | |
| else { _dojoSplatGrow = null; dojoSplatRoot.scale.setScalar(scale); } | |
| dojoSplatRoot.position.set(-localCenterX * scale, floorY, -localCenterZ * scale); | |
| dojoSplatRoot.updateMatrixWorld(true); | |
| resizeFloorGrid(rawMax * scale); | |
| const raw = dojoSplatBaseBounds && dojoSplatBaseBounds.size ? dojoSplatBaseBounds.size : [rawMax, rawMax, rawMax]; | |
| const footprint = robust ? (robust.sizeX.toFixed(2) + ' x ' + robust.sizeZ.toFixed(2)) : rawMax.toFixed(2); | |
| const floorP02 = robust && robust.floorP02 != null ? robust.floorP02.toFixed(2) : 'n/a'; | |
| postDojoStatus('Splat loaded in viewer. Raw bounds ' + raw.map((v) => v.toFixed(2)).join(' x ') + 'm, robust footprint ' + footprint + 'm, floor band ' + localFloorY.toFixed(2) + 'm (p02 ' + floorP02 + 'm), scale ' + scale.toFixed(2) + 'x.'); | |
| } | |
| async function applyDojoSplat(url, settings) { | |
| if (!url) { clearDojoSplat(); postDojoStatus('Splat cleared.'); return; } | |
| clearDojoSplat(); | |
| applyDojoRoomSettings(settings); | |
| postDojoStatus('Loading splat in viewer...'); | |
| try { | |
| const buffer = await splatPayloadBuffer(url); | |
| dojoSplatBaseBounds = parsePlyBounds(buffer); | |
| if (dojoSplatObjectUrl) URL.revokeObjectURL(dojoSplatObjectUrl); | |
| dojoSplatObjectUrl = URL.createObjectURL(new Blob([buffer], { type: 'application/octet-stream' })); | |
| const loadUrl = dojoSplatObjectUrl; | |
| const sparkMod = await ensureSpark(); | |
| const packedSplats = await new sparkMod.SplatLoader().loadAsync(loadUrl); | |
| const splat = new sparkMod.SplatMesh({ packedSplats }); | |
| dojoSplatRoot = new THREE.Group(); | |
| dojoSplatRoot.name = 'dojo-splat'; | |
| dojoSplatRoot.add(splat); | |
| splat.rotation.y = Math.PI / 2; | |
| dojoSplatRoot.rotation.x = Math.PI; | |
| dojoSplatRoot.scale.setScalar(1.0); | |
| dojoSplatRoot.position.set(0.95, 0.05, -0.85); | |
| scene.add(dojoSplatRoot); | |
| fitDojoSplat(0, true); // grow in from ~0 so the splat doesn't pop | |
| } catch (err) { | |
| console.error('dojo splat load failed', err); | |
| postDojoStatus('Splat load failed: ' + (err && err.message ? err.message : err)); | |
| } | |
| } | |
| const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 200); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| // --- Move placement: overlay a move's END pose as a draggable figure. Tap the | |
| // ground to move it (sets the 2D root), swipe across it to rotate. Reports | |
| // {kind:'place-update', tx, tz, rot} so the composer updates the move. --- | |
| let placeGroup = null, placePosed = null, placeParents = null, placeTx = 0, placeTz = 0, placeRot = 0, placeHitMesh = null, placeMoveHit = null, placeRotHit = null; | |
| let placeBaseTx = 0, placeBaseTz = 0, placeBaseHeading = 0; // world transform of the move's START | |
| let placeStartPosed = null, placeStartGroup = null, placeStartVis = false, placeEndVis = true, placeEndSkel = null; | |
| const _placeRay = new THREE.Raycaster(); | |
| const _placePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); | |
| const _placeNDC = new THREE.Vector2(), _placeHit = new THREE.Vector3(); | |
| let _placeDrag = null; | |
| function placeApply() { | |
| if (!placeGroup) return; | |
| // Compose the move's local (tx,tz,rot) onto the START-of-move world transform, so | |
| // the overlay sits where the move ends in the kata (not at the origin). | |
| const bh = placeBaseHeading, c = Math.cos(bh), s = Math.sin(bh); | |
| placeGroup.position.set(placeTx * c + placeTz * s + placeBaseTx, 0, -placeTx * s + placeTz * c + placeBaseTz); | |
| placeGroup.rotation.y = bh + placeRot * Math.PI / 180; | |
| } | |
| // Build a colored skeleton (lines + joint dots) group from a posed-joint array. | |
| function _placeSkel(posed, color) { | |
| const grp = new THREE.Group(); | |
| const segs = []; | |
| for (let i = 0; i < posed.length; i++) { const p = placeParents[i]; if (p < 0) continue; segs.push(posed[p], posed[i]); } | |
| const arr = new Float32Array(segs.length * 3); | |
| for (let i = 0; i < segs.length; i++) { arr[i*3] = segs[i][0]; arr[i*3+1] = segs[i][1]; arr[i*3+2] = segs[i][2]; } | |
| const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(arr, 3)); | |
| const lines = new THREE.LineSegments(g, new THREE.LineBasicMaterial({ color, depthTest: false, transparent: true, opacity: 0.96 })); lines.renderOrder = 999; grp.add(lines); | |
| const dotG = new THREE.SphereGeometry(0.022, 6, 6), dotM = new THREE.MeshBasicMaterial({ color, depthTest: false }); | |
| for (const j of posed) { const d = new THREE.Mesh(dotG, dotM); d.position.set(j[0], j[1], j[2]); d.renderOrder = 999; grp.add(d); } | |
| return grp; | |
| } | |
| // Animate a group's scale toward show/hide so skeletons grow/shrink (no pop). | |
| const _placeAnims = new Map(); // obj -> target scale | |
| function placeSetVisible(obj, show) { if (!obj) return; if (show) obj.visible = true; _placeAnims.set(obj, { target: show ? 1 : 0 }); } | |
| function _placeAnimTick() { | |
| if (!_placeAnims.size) return; | |
| for (const [obj, st] of _placeAnims) { | |
| if (!obj.parent) { _placeAnims.delete(obj); continue; } | |
| const cur = obj.scale.x, next = cur + (st.target - cur) * 0.28; | |
| if (Math.abs(next - st.target) < 0.012) { obj.scale.setScalar(st.target || 0.0001); if (st.target === 0) obj.visible = false; _placeAnims.delete(obj); } | |
| else obj.scale.setScalar(next); | |
| } | |
| } | |
| function placeBuild() { | |
| if (placeGroup) { scene.remove(placeGroup); placeGroup = null; } | |
| if (placeStartGroup) { scene.remove(placeStartGroup); placeStartGroup = null; } | |
| _placeAnims.clear(); | |
| // Container holding the END skeleton + the gizmos. The container stays visible so | |
| // the pose CONTROLS (ring/knob) remain even when the skeleton is hidden. | |
| placeGroup = new THREE.Group(); | |
| placeEndSkel = _placeSkel(placePosed, 0xe8736a); // RED end-pose skeleton (toggle) | |
| placeEndSkel.scale.setScalar(0.0001); placeEndSkel.visible = placeEndVis; placeGroup.add(placeEndSkel); | |
| // Ground gizmos: green ring at the feet = drag to MOVE; red knob out front = ROTATE. | |
| const moveDisc = new THREE.Mesh(new THREE.CircleGeometry(0.30, 36), new THREE.MeshBasicMaterial({ color: 0x5fb98c, side: THREE.DoubleSide, depthTest: false, transparent: true, opacity: 0.38 })); | |
| moveDisc.rotation.x = -Math.PI / 2; moveDisc.position.y = 0.012; moveDisc.renderOrder = 997; placeGroup.add(moveDisc); placeMoveHit = moveDisc; | |
| const moveRing = new THREE.Mesh(new THREE.RingGeometry(0.30, 0.35, 36), new THREE.MeshBasicMaterial({ color: 0x9fe6c0, side: THREE.DoubleSide, depthTest: false, transparent: true, opacity: 0.95 })); | |
| moveRing.rotation.x = -Math.PI / 2; moveRing.position.y = 0.013; moveRing.renderOrder = 998; placeGroup.add(moveRing); | |
| const stemG = new THREE.BufferGeometry(); stemG.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0, 0.013, 0, 0, 0.013, 0.52]), 3)); | |
| placeGroup.add(new THREE.LineSegments(stemG, new THREE.LineBasicMaterial({ color: 0xe8736a, depthTest: false, transparent: true, opacity: 0.85 }))); | |
| const rotKnob = new THREE.Mesh(new THREE.SphereGeometry(0.09, 16, 12), new THREE.MeshBasicMaterial({ color: 0xe8736a, depthTest: false })); | |
| rotKnob.position.set(0, 0.013, 0.52); rotKnob.renderOrder = 999; placeGroup.add(rotKnob); placeRotHit = rotKnob; // y on the stem line so it hits the sphere center | |
| scene.add(placeGroup); | |
| placeSetVisible(placeEndSkel, placeEndVis); // grow the skeleton in | |
| // START pose skeleton (GREEN) at the move's start (the base transform), hidden by default. | |
| if (placeStartPosed && placeStartPosed.length) { | |
| placeStartGroup = _placeSkel(placeStartPosed, 0x5fb98c); | |
| placeStartGroup.position.set(placeBaseTx, 0, placeBaseTz); | |
| placeStartGroup.rotation.y = placeBaseHeading; | |
| placeStartGroup.scale.setScalar(0.0001); placeStartGroup.visible = placeStartVis; | |
| scene.add(placeStartGroup); | |
| placeSetVisible(placeStartGroup, placeStartVis); | |
| } | |
| placeApply(); | |
| } | |
| function placeShow(posed, startPosed, parents, tx, tz, rot, base, startVis, endVis) { | |
| placePosed = posed; placeStartPosed = startPosed || null; placeParents = parents; placeTx = tx; placeTz = tz; placeRot = rot; | |
| placeBaseTx = (base && base.x) || 0; placeBaseTz = (base && base.z) || 0; placeBaseHeading = (base && base.heading) || 0; | |
| placeStartVis = !!startVis; placeEndVis = (endVis !== false); | |
| placeBuild(); | |
| } | |
| function placeHide() { | |
| if (placeGroup) { scene.remove(placeGroup); placeGroup = null; } | |
| if (placeStartGroup) { scene.remove(placeStartGroup); placeStartGroup = null; } | |
| placePosed = null; placeStartPosed = null; placeHitMesh = null; placeMoveHit = null; placeRotHit = null; controls.enabled = true; | |
| } | |
| function _placeNDCfrom(cx, cy) { const r = renderer.domElement.getBoundingClientRect(); _placeNDC.x = ((cx - r.left) / r.width) * 2 - 1; _placeNDC.y = -((cy - r.top) / r.height) * 2 + 1; _placeRay.setFromCamera(_placeNDC, camera); } | |
| function placeOverFigure(cx, cy) { if (!placeHitMesh) return false; _placeNDCfrom(cx, cy); return _placeRay.intersectObject(placeHitMesh, false).length > 0; } | |
| function placeGround(cx, cy) { _placeNDCfrom(cx, cy); return _placeRay.ray.intersectPlane(_placePlane, _placeHit) ? _placeHit : null; } | |
| // Place gizmos: pointerdown on the MOVE ring → drag to translate; on the ROTATE knob | |
| // → drag around to turn. Anywhere else the camera orbits (the figure body is inert). | |
| renderer.domElement.addEventListener('pointerdown', (e) => { | |
| if (!placeGroup) return; | |
| _placeNDCfrom(e.clientX, e.clientY); | |
| const onRot = placeRotHit && _placeRay.intersectObject(placeRotHit, false).length > 0; | |
| const onMove = !onRot && placeMoveHit && _placeRay.intersectObject(placeMoveHit, false).length > 0; | |
| if (onRot || onMove) { | |
| const g = placeGround(e.clientX, e.clientY); const wp = placeGroup.position; | |
| _placeDrag = { mode: onRot ? 'rot' : 'move', ox: g ? (wp.x - g.x) : 0, oz: g ? (wp.z - g.z) : 0 }; // world offset | |
| controls.enabled = false; // dragging a gizmo, not the camera | |
| } else { _placeDrag = null; } | |
| }); | |
| renderer.domElement.addEventListener('pointermove', (e) => { | |
| if (!placeGroup || !_placeDrag) return; | |
| const hit = placeGround(e.clientX, e.clientY); if (!hit) return; | |
| const bh = placeBaseHeading, c = Math.cos(bh), s = Math.sin(bh); | |
| if (_placeDrag.mode === 'move') { | |
| const dx = (hit.x + _placeDrag.ox) - placeBaseTx, dz = (hit.z + _placeDrag.oz) - placeBaseTz; // desired world → local | |
| placeTx = dx * c - dz * s; placeTz = dx * s + dz * c; | |
| } else { | |
| const cx = placeGroup.position.x, cz = placeGroup.position.z; // face the figure toward the pointer | |
| placeRot = (Math.atan2(hit.x - cx, hit.z - cz) - bh) * 180 / Math.PI; | |
| } | |
| placeApply(); postParent({ kind: 'place-update', tx: placeTx, tz: placeTz, rot: placeRot }); | |
| }); | |
| function _placeEnd() { controls.enabled = true; _placeDrag = null; } | |
| renderer.domElement.addEventListener('pointerup', _placeEnd); | |
| renderer.domElement.addEventListener('pointercancel', _placeEnd); | |
| // Brighter, lighter-bottomed hemisphere so downward-facing surfaces (a shadowed | |
| // face in a guard pose) aren't near-black; key + a front fill so faces read. | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0x6a7078, 2.7)); | |
| const dir = new THREE.DirectionalLight(0xffffff, 2.1); | |
| dir.position.set(3, 5, 2); | |
| scene.add(dir); | |
| const fill = new THREE.DirectionalLight(0xffffff, 0.85); // front fill toward the camera | |
| fill.position.set(-2, 3, 5); | |
| scene.add(fill); | |
| const floor = new THREE.GridHelper(1, 20, 0x42f6ff, 0x1aa6b8); | |
| floor.position.y = dojoFloorSurfaceY; | |
| floor.scale.set(dojoRoomSize, 1, dojoRoomSize); | |
| scene.add(floor); | |
| // Motion arrays are swapped in only for clips with this viewer's joint count. | |
| let jointData = decodeFloat32(data.joint_data_b64); | |
| let quatData = data.quat_data_b64 ? decodeFloat32(data.quat_data_b64) : null; | |
| let rootData = data.root_data_b64 ? decodeFloat32(data.root_data_b64) : null; | |
| let nFrames = data.preview_frames || 0; | |
| let fps = data.preview_fps || data.fps || 30; | |
| const parents = data.parents; | |
| const boneNames = data.bone_names || []; | |
| const nJoints = data.num_joints || 0; | |
| // --- Procedural skeleton (joints + bones + root trail), grouped for toggling. | |
| const skel = new THREE.Group(); | |
| scene.add(skel); | |
| // Camera framing + root-trail geometry, recomputed from the CURRENT clip | |
| // (frame count and extents change between clips). | |
| const rootPathGeo = new THREE.BufferGeometry(); | |
| skel.add(new THREE.Line(rootPathGeo, new THREE.LineBasicMaterial({ color: 0x7dd3fc }))); | |
| function frameCamera() { | |
| let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; | |
| for (let i = 0; i < jointData.length; i += 3) { | |
| const x = jointData[i], y = jointData[i + 1], z = jointData[i + 2]; | |
| if (x < minX) minX = x; if (x > maxX) maxX = x; | |
| if (y < minY) minY = y; if (y > maxY) maxY = y; | |
| if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; | |
| } | |
| const box = new THREE.Box3(new THREE.Vector3(minX, minY, minZ), new THREE.Vector3(maxX, maxY, maxZ)); | |
| const center = new THREE.Vector3(), size = new THREE.Vector3(); | |
| box.getCenter(center); box.getSize(size); | |
| const radius = Math.max(size.x, size.y, size.z, 1); | |
| controls.target.copy(center); | |
| camera.position.set(center.x + radius * 0.9, center.y + radius * 0.55, center.z + radius * 1.8); | |
| camera.near = Math.max(radius / 1000, 0.01); | |
| camera.far = radius * 20; | |
| camera.updateProjectionMatrix(); | |
| const rp = new Float32Array(nFrames * 3); | |
| for (let f = 0; f < nFrames; f++) { | |
| const src = f * nJoints * 3; | |
| rp[f * 3] = jointData[src]; rp[f * 3 + 1] = 0.02; rp[f * 3 + 2] = jointData[src + 2]; | |
| } | |
| rootPathGeo.setAttribute('position', new THREE.BufferAttribute(rp, 3)); | |
| rootPathGeo.computeBoundingSphere(); | |
| } | |
| const cameraLockBtn = document.getElementById('camera-lock'); | |
| let cameraLocked = true; | |
| const cameraFocus = new THREE.Vector3(); | |
| const previousCameraFocus = new THREE.Vector3(); | |
| const cameraFocusDelta = new THREE.Vector3(); | |
| const lockedCameraOffset = new THREE.Vector3(1.55, 0.72, 2.85); | |
| function frameFocus(f, out) { | |
| const safeFrame = Math.max(0, Math.min(nFrames - 1, f || 0)); | |
| const o = safeFrame * nJoints * 3; | |
| out.set(jointData[o] || 0, jointData[o + 1] || 0.9, jointData[o + 2] || 0); | |
| return out; | |
| } | |
| function setLockedCameraView() { | |
| frameFocus(frame, cameraFocus); | |
| controls.target.copy(cameraFocus); | |
| camera.position.copy(cameraFocus).add(lockedCameraOffset); | |
| previousCameraFocus.copy(cameraFocus); | |
| controls.update(); | |
| } | |
| function syncCameraLock(jump = false) { | |
| if (!cameraLocked || !nFrames || !nJoints) return; | |
| if (_camAnim) { frameFocus(frame, previousCameraFocus); return; } // a fly is in progress — don't fight it | |
| frameFocus(frame, cameraFocus); | |
| if (jump) { | |
| setLockedCameraView(); | |
| return; | |
| } | |
| cameraFocusDelta.copy(cameraFocus).sub(previousCameraFocus); | |
| camera.position.add(cameraFocusDelta); | |
| controls.target.add(cameraFocusDelta); | |
| previousCameraFocus.copy(cameraFocus); | |
| } | |
| function updateCameraLockButton() { | |
| cameraLockBtn.innerHTML = cameraLocked ? '🔒' : '🔓'; | |
| cameraLockBtn.title = cameraLocked ? 'Camera locked to character' : 'Camera unlocked'; | |
| cameraLockBtn.setAttribute('aria-label', cameraLockBtn.title); | |
| } | |
| cameraLockBtn.onclick = () => { | |
| cameraLocked = !cameraLocked; | |
| if (cameraLocked) setLockedCameraView(); | |
| updateCameraLockButton(); | |
| }; | |
| updateCameraLockButton(); | |
| // Center + frame the FULL body (head→feet) at the current frame. The open sidebar is | |
| // accounted for by the existing view offset (applyViewOffset), so the body is centered | |
| // in the still-visible area. Driven by the parent's center/lock button. | |
| // Eased camera fly so loading a stance/move GLIDES into frame instead of popping. | |
| let _camAnim = null; | |
| function flyCameraTo(pos, look, dur) { _camAnim = { p0: camera.position.clone(), p1: pos.clone(), t0: controls.target.clone(), t1: look.clone(), t: 0, dur: dur || 0.5 }; } | |
| function _camAnimTick(dt) { | |
| if (!_camAnim) return; | |
| _camAnim.t += dt; | |
| const k = Math.min(1, _camAnim.t / _camAnim.dur); | |
| const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; // easeInOutQuad | |
| camera.position.lerpVectors(_camAnim.p0, _camAnim.p1, e); | |
| controls.target.lerpVectors(_camAnim.t0, _camAnim.t1, e); | |
| if (k >= 1) _camAnim = null; | |
| } | |
| const _frameDir = new THREE.Vector3(0.04, 0.22, 2.9).normalize(); // near-frontal, camera further left + lower | |
| // Frame the WHOLE body (head→feet) with a roomy margin, centered in the visible area | |
| // (the open drawer is accounted for by applyViewOffset). smooth=true glides there. | |
| function centerFullBody(smooth) { | |
| if (!nFrames || !nJoints) return; | |
| let minY = Infinity, maxY = -Infinity; | |
| const base = Math.max(0, Math.min(nFrames - 1, frame)) * nJoints * 3; | |
| for (let i = 0; i < nJoints; i++) { const y = jointData[base + i * 3 + 1]; if (y < minY) minY = y; if (y > maxY) maxY = y; } | |
| const bh = Math.max((maxY - minY) || 1.6, 1.2); | |
| frameFocus(frame, previousCameraFocus); // pelvis → smooth follow deltas | |
| const look = previousCameraFocus.clone(); look.y = (minY + maxY) / 2; // focus = body center | |
| const fov = (camera.fov || 45) * Math.PI / 180; | |
| const dist = (bh / (2 * Math.tan(fov / 2))) * 2.9 + 1.0; // fit full height + extra margin (zoomed out more) | |
| const pos = look.clone().addScaledVector(_frameDir, dist); | |
| if (smooth) flyCameraTo(pos, look, 0.5); | |
| else { camera.position.copy(pos); controls.target.copy(look); controls.update(); } | |
| applyViewOffset(); | |
| } | |
| const childCounts = parents.map(() => 0); | |
| parents.forEach(p => { if (p >= 0) childCounts[p] += 1; }); | |
| const jointGeo = new THREE.SphereGeometry(0.018, 10, 6); | |
| const jointMat = new THREE.MeshStandardMaterial({ color: 0xd7dde5, roughness: 0.55 }); | |
| const jointMesh = new THREE.InstancedMesh(jointGeo, jointMat, nJoints); | |
| jointMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); | |
| jointMesh.frustumCulled = false; | |
| for (let i = 0; i < nJoints; i++) { | |
| const color = i === 0 ? 0x7dd3fc : (childCounts[i] === 0 ? 0xfacc15 : 0xd7dde5); | |
| jointMesh.setColorAt(i, new THREE.Color(color)); | |
| } | |
| if (jointMesh.instanceColor) jointMesh.instanceColor.needsUpdate = true; | |
| skel.add(jointMesh); | |
| const edges = []; | |
| for (let i = 0; i < nJoints; i++) { | |
| if (parents[i] < 0) continue; | |
| edges.push([i, parents[i]]); | |
| } | |
| const edgePositions = new Float32Array(edges.length * 2 * 3); | |
| const edgeGeo = new THREE.BufferGeometry(); | |
| edgeGeo.setAttribute('position', new THREE.BufferAttribute(edgePositions, 3)); | |
| edgeGeo.attributes.position.setUsage(THREE.DynamicDrawUsage); | |
| const edgeLines = new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({ color: 0xaeb7c2 })); | |
| edgeLines.frustumCulled = false; | |
| skel.add(edgeLines); | |
| const dummy = new THREE.Object3D(); | |
| // Skeleton OVERLAY: draw the true joint bones on top of the mesh character | |
| // (x-ray) so the actual pose is readable as it plays. Toggled in the menu. | |
| let skelOverlay = false; | |
| function applySkelOverlayStyle() { | |
| // Bright + depth-tested-off so bones show THROUGH the character when overlaying. | |
| const xray = skelOverlay && mode !== 'skeleton'; | |
| edgeLines.material.color.set(xray ? 0x39ff14 : 0xaeb7c2); | |
| edgeLines.material.depthTest = !xray; | |
| jointMat.depthTest = !xray; | |
| jointMat.color.set(xray ? 0x39ff14 : 0xd7dde5); | |
| edgeLines.renderOrder = jointMesh.renderOrder = xray ? 999 : 0; | |
| jointMat.needsUpdate = true; | |
| } | |
| // RED reference-pose skeleton (viser-style constraint skeleton): the STATIC | |
| // target pose a seeded clip was generated to start from. Drawn alongside the | |
| // green overlay so you can see the generated frame 0 land on it (and peel away). | |
| const refEdgePositions = new Float32Array(edges.length * 2 * 3); | |
| const refEdgeGeo = new THREE.BufferGeometry(); | |
| refEdgeGeo.setAttribute('position', new THREE.BufferAttribute(refEdgePositions, 3)); | |
| const refEdges = new THREE.LineSegments(refEdgeGeo, new THREE.LineBasicMaterial({ | |
| color: 0xff3b3b, depthTest: false, transparent: true, opacity: 0.9 })); | |
| refEdges.renderOrder = 998; refEdges.frustumCulled = false; refEdges.visible = false; | |
| scene.add(refEdges); | |
| let hasReference = false; | |
| function setReferencePose(ref) { | |
| // ref: flat [nJoints*3] array (re-rooted to XZ origin, same frame as clip f0). | |
| hasReference = Array.isArray(ref) && ref.length === nJoints * 3; | |
| if (hasReference) { | |
| let k = 0; | |
| for (const [i, p] of edges) { | |
| refEdgePositions[k++] = ref[p*3]; refEdgePositions[k++] = ref[p*3+1]; refEdgePositions[k++] = ref[p*3+2]; | |
| refEdgePositions[k++] = ref[i*3]; refEdgePositions[k++] = ref[i*3+1]; refEdgePositions[k++] = ref[i*3+2]; | |
| } | |
| refEdgeGeo.attributes.position.needsUpdate = true; | |
| } | |
| refEdges.visible = hasReference && skelOverlay; | |
| } | |
| setReferencePose((typeof data !== 'undefined' && data && data.reference_pose) || null); | |
| // RED NEXT-TARGET skeleton for a KATA: a single static red pose at the NEXT | |
| // upcoming checkpoint (the most-expressive frame of the upcoming move). As the | |
| // green skeleton advances past a checkpoint, the red jumps to the next one — | |
| // less noisy than showing every target at once. | |
| let targetFramesArr = []; | |
| const targetPos = new Float32Array(edges.length * 2 * 3); | |
| const targetGeo = new THREE.BufferGeometry(); | |
| targetGeo.setAttribute('position', new THREE.BufferAttribute(targetPos, 3)); | |
| const targetEdges = new THREE.LineSegments(targetGeo, new THREE.LineBasicMaterial({ | |
| color: 0xff3b3b, depthTest: false, transparent: true, opacity: 0.75 })); | |
| targetEdges.renderOrder = 997; targetEdges.frustumCulled = false; targetEdges.visible = false; | |
| scene.add(targetEdges); | |
| function setTargetPoses(frames) { | |
| targetFramesArr = (Array.isArray(frames) ? frames.filter((f) => f >= 0 && f < nFrames) : []).sort((a, b) => a - b); | |
| updateNextTarget(frame); | |
| } | |
| function updateNextTarget(curFrame) { | |
| if (!targetFramesArr.length || !skelOverlay || !jointData) { targetEdges.visible = false; return; } | |
| // next checkpoint strictly ahead; wrap to the first when past the last (loop). | |
| let f = targetFramesArr.find((t) => t > curFrame); | |
| if (f === undefined) f = targetFramesArr[0]; | |
| const base = f * nJoints * 3; let k = 0; | |
| for (const [i, p] of edges) { | |
| targetPos[k++] = jointData[base + p*3]; targetPos[k++] = jointData[base + p*3+1]; targetPos[k++] = jointData[base + p*3+2]; | |
| targetPos[k++] = jointData[base + i*3]; targetPos[k++] = jointData[base + i*3+1]; targetPos[k++] = jointData[base + i*3+2]; | |
| } | |
| targetGeo.attributes.position.needsUpdate = true; | |
| targetEdges.visible = true; | |
| } | |
| const skelOverlayBox = document.getElementById('skel-overlay'); | |
| if (skelOverlayBox) skelOverlayBox.addEventListener('change', () => { | |
| skelOverlay = skelOverlayBox.checked; | |
| skel.visible = skelOverlay || mode === 'skeleton'; | |
| refEdges.visible = hasReference && skelOverlay; | |
| updateNextTarget(frame); | |
| applySkelOverlayStyle(); | |
| setSkeletonFrame(frame); // populate bone positions immediately | |
| }); | |
| // --- Rest-mode retargeter: drive a skinned rig from the clip's world quats. | |
| // Ported from kimodo-motion-api web/src/animator.js (alignMode='rest'): | |
| // Q_target_world = Q_kimodo_world . Q_bone_rest_world | |
| // Q_target_local = Q_parent_world^-1 . Q_target_world | |
| class Animator { | |
| constructor(skinned, mapping, opts) { | |
| this.mapping = mapping; | |
| this.scale = opts.scale; | |
| this.groundOffsetY = opts.groundOffsetY; | |
| this.norm = (n) => n.replace(/[.:]/g, ''); | |
| this.bonesByName = {}; | |
| for (const b of skinned.skeleton.bones) this.bonesByName[this.norm(b.name)] = b; | |
| skinned.updateMatrixWorld(true); | |
| this.restQ = new Map(); | |
| const p = new THREE.Vector3(), q = new THREE.Quaternion(), s = new THREE.Vector3(); | |
| for (const b of skinned.skeleton.bones) { b.matrixWorld.decompose(p, q, s); this.restQ.set(b.name, q.clone()); } | |
| this.pelvis = this.bonesByName[this.norm(mapping.pelvis || '')] || null; | |
| this.pairs = []; | |
| this._qk = new THREE.Quaternion(); this._qw = new THREE.Quaternion(); | |
| this._qp = new THREE.Quaternion(); this._tp = new THREE.Vector3(); this._ts = new THREE.Vector3(); | |
| } | |
| resolve(names) { | |
| this.pairs = []; | |
| for (let i = 0; i < names.length; i++) { | |
| const tName = this.mapping[names[i]]; | |
| if (!tName) continue; | |
| const bone = this.bonesByName[this.norm(tName)]; | |
| if (!bone) continue; | |
| this.pairs.push({ idx: i, bone, restQ: this.restQ.get(bone.name) }); | |
| } | |
| console.log('[retarget] resolved ' + this.pairs.length + ' of ' + names.length + ' joints'); | |
| } | |
| applyFrame(quats, root, f) { | |
| if (!quats) return; | |
| for (const { idx, bone, restQ } of this.pairs) { | |
| const o = f * nJoints * 4 + idx * 4; | |
| this._qk.set(quats[o], quats[o + 1], quats[o + 2], quats[o + 3]); | |
| this._qw.copy(this._qk).multiply(restQ); | |
| if (bone.parent) { | |
| bone.parent.matrixWorld.decompose(this._tp, this._qp, this._ts); | |
| this._qp.invert(); | |
| bone.quaternion.copy(this._qp).multiply(this._qw); | |
| } else { | |
| bone.quaternion.copy(this._qw); | |
| } | |
| bone.updateMatrixWorld(true); | |
| } | |
| if (this.pelvis && root) { | |
| const s = this.scale; | |
| this.pelvis.position.set(root[f * 3] * s, root[f * 3 + 1] * s - this.groundOffsetY, root[f * 3 + 2] * s); | |
| } | |
| } | |
| } | |
| const charCache = {}; | |
| let character = null; // { root, anim } | |
| let mode = 'skeleton'; | |
| // Map a skin atlas (e.g. the decompiled s&box citizen skin) onto a character's | |
| // body materials. One shared UV atlas covers the whole body; skip eye/mouth | |
| // sub-materials (their own UVs). flipY=false = glTF UV convention. | |
| // The material color multiplies the texture — a warm pinkish "wiener" tint pushes | |
| // the pale skin toward sausage. Set to 0xffffff to disable the tint. | |
| const SAUSAGE_TINT = 0x8f6549; | |
| const _charTexLoader = new THREE.TextureLoader(); | |
| _charTexLoader.setCrossOrigin('anonymous'); | |
| function applyCharSkin(root, url) { | |
| const tex = _charTexLoader.load(url); | |
| tex.colorSpace = THREE.SRGBColorSpace; | |
| tex.flipY = false; | |
| tex.anisotropy = renderer.capabilities.getMaxAnisotropy(); | |
| root.traverse(o => { | |
| if (!o.isMesh && !o.isSkinnedMesh) return; | |
| const mats = Array.isArray(o.material) ? o.material : [o.material]; | |
| for (const m of mats) { | |
| if (!m) continue; | |
| const name = (m.name || o.name || '').toLowerCase(); | |
| if (/eye|mouth|teeth|tongue|brow|lash|cornea/.test(name)) continue; | |
| m.map = tex; | |
| if (m.color) m.color.setHex(SAUSAGE_TINT); // warm sausage tint multiplies the skin atlas | |
| if (m.metalness !== undefined) m.metalness = 0; // skin is non-metal; metallic PBR w/o envMap renders black | |
| if (m.roughness !== undefined) m.roughness = Math.max(m.roughness, 0.7); | |
| m.needsUpdate = true; | |
| } | |
| }); | |
| } | |
| // Network loads on the boot/reveal path must never HANG: a stalled CDN request | |
| // (socket opens, no response) never rejects, so a bare `await loadAsync` would leave | |
| // the viewer stuck on the spinner with NO character. Bound each GLB fetch with a | |
| // timeout (+ optional retry) so a stall fails fast and we fall back gracefully | |
| // (skeleton for the body, skip-the-garment for clothing) instead of blanking forever. | |
| function loadGltfTimed(loader, url, ms, tries) { | |
| ms = ms || 12000; tries = Math.max(1, tries || 1); | |
| const once = () => new Promise((resolve, reject) => { | |
| let settled = false; | |
| const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error('gltf timeout: ' + url)); } }, ms); | |
| loader.loadAsync(url).then( | |
| (g) => { if (!settled) { settled = true; clearTimeout(timer); resolve(g); } }, | |
| (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e); } } | |
| ); | |
| }); | |
| let p = once(); | |
| for (let i = 1; i < tries; i++) p = p.catch(() => once()); // retry a transient stall/error | |
| return p; | |
| } | |
| async function buildCharacter(entry) { | |
| const loader = new GLTFLoader(); | |
| // Prefer the hosted GLB URL — the browser caches it across iframe rebuilds, so | |
| // re-rendering the viewer doesn't re-download or re-ship the mesh. Fall back to an | |
| // inline base64 blob if a character still embeds one. Timed + retried so a stalled | |
| // CDN fetch fails fast (-> skeleton) rather than hanging the viewer blank. | |
| const gltf = entry.glb_url | |
| ? await loadGltfTimed(loader, entry.glb_url, 12000, 2) | |
| : await loader.parseAsync(decodeBytes(entry.glb_b64).buffer, ''); | |
| const root = gltf.scene; | |
| root.scale.setScalar(entry.scale || 1.0); | |
| let skinned = null; | |
| root.traverse(o => { if (o.isSkinnedMesh) { if (!skinned) skinned = o; o.frustumCulled = false; } }); | |
| if (!skinned) throw new Error('GLB has no skinned mesh'); | |
| if (entry.texture) applyCharSkin(root, entry.texture); // map the s&box skin atlas onto the body | |
| root.visible = false; | |
| scene.add(root); | |
| root.updateMatrixWorld(true); | |
| const bb = new THREE.Box3().setFromObject(root); | |
| const groundOffsetY = -bb.min.y; | |
| root.position.y += groundOffsetY; | |
| const charHeight = (bb.max.y - bb.min.y) || 1; | |
| const anim = new Animator(skinned, entry.mapping, { scale: charHeight / SMPLX_HEIGHT, groundOffsetY }); | |
| anim.resolve(boneNames); | |
| return { root, anim, skinned, dressed: false }; | |
| } | |
| // --- Clothing: garments are skinned GLBs rigged to the SAME citizen bone_N | |
| // skeleton. Wear one by name-matching its bones to the citizen's and copying | |
| // the citizen's bone transforms onto them each frame (syncClothing), so the | |
| // garment deforms with the body. Ported from kata.js attach/detach/sync. | |
| const clothLoader = new GLTFLoader(); | |
| const worn = new Map(); // slot -> garment id (intent, persists) | |
| const wornAttached = new Map(); // slot -> { scene, pairs } | |
| const slotSelects = {}; | |
| for (const [slot, id] of Object.entries(cfg.defaultClothing || {})) worn.set(slot, id); | |
| function detachGarment(slot) { | |
| const a = wornAttached.get(slot); | |
| if (!a) return; | |
| if (character) character.root.remove(a.scene); | |
| a.scene.traverse(o => { if (o.isMesh && o.geometry) o.geometry.dispose(); }); | |
| wornAttached.delete(slot); | |
| } | |
| async function attachGarment(slot, item) { | |
| detachGarment(slot); | |
| if (!character || !character.skinned) return; | |
| const sc = (await loadGltfTimed(clothLoader, item.url, 9000, 1)).scene; | |
| let mesh = null; | |
| sc.traverse(o => { if (o.isSkinnedMesh) { if (!mesh) mesh = o; o.frustumCulled = false; } }); | |
| if (!mesh) return; | |
| mesh.renderOrder = item.layer || 1; | |
| const cit = new Map(character.skinned.skeleton.bones.map(b => [b.name, b])); | |
| const pairs = mesh.skeleton.bones.map(jb => ({ j: jb, c: cit.get(jb.name) })).filter(p => p.c); | |
| character.root.add(sc); | |
| wornAttached.set(slot, { scene: sc, pairs }); | |
| } | |
| // Returns true only if EVERY worn garment attached. A stalled/failed garment | |
| // returns false so the caller can decline to reveal a half-dressed character. | |
| async function applyWorn() { | |
| for (const s of [...wornAttached.keys()]) detachGarment(s); | |
| let allOk = true; | |
| for (const [slot, id] of worn) { | |
| const item = cfg.clothing.find(c => c.id === id); | |
| if (!item) continue; | |
| try { await attachGarment(slot, item); } | |
| catch (e) { console.warn('clothing load failed', id, e); allOk = false; } | |
| } | |
| return allOk; | |
| } | |
| function syncClothing() { | |
| for (const { pairs } of wornAttached.values()) | |
| for (const { j, c } of pairs) { j.quaternion.copy(c.quaternion); j.position.copy(c.position); j.scale.copy(c.scale); } | |
| } | |
| const playBtn = document.getElementById('play'); | |
| const scrub = document.getElementById('scrub'); | |
| const frameLabel = document.getElementById('frame'); | |
| const speed = document.getElementById('speed'); | |
| const characterSel = document.getElementById('character'); | |
| const menuBtn = document.getElementById('menu-btn'); | |
| const menu = document.getElementById('menu'); | |
| menuBtn.onclick = () => menu.classList.toggle('open'); | |
| const creatorLabel = document.getElementById('creator-label'); | |
| function truncateText(value, maxLen) { | |
| const text = String(value || '').replace(/\s+/g, ' ').trim(); | |
| return text.length > maxLen ? text.slice(0, Math.max(0, maxLen - 1)).trimEnd() + '...' : text; | |
| } | |
| function appendSpan(parent, cls, text) { | |
| const span = document.createElement('span'); | |
| span.className = cls; | |
| span.textContent = text; | |
| parent.appendChild(span); | |
| } | |
| // Always reflect the clip currently on screen. Do NOT fall back to cfg.* | |
| // (the initially-rendered clip's creator) -- on a picker switch that would | |
| // leave a named author stuck on a subsequently-loaded anonymous clip. | |
| function setCreatorLabel(source) { | |
| const username = (source && source.created_by) || ''; | |
| const name = (source && source.created_by_name) || username; | |
| const prompt = truncateText((source && source.prompt) || '', 72); | |
| // The clip id lets you point precisely at the animation on screen. Prefer | |
| // the payload's own id, else the canonically-tracked curId. | |
| const cid = (source && source.id) || (typeof curId !== 'undefined' ? curId : '') || ''; | |
| if (!username && !prompt && !cid) { | |
| creatorLabel.style.display = 'none'; | |
| creatorLabel.textContent = ''; | |
| return; | |
| } | |
| creatorLabel.textContent = ''; | |
| if (cid) { | |
| const idSpan = document.createElement('span'); | |
| idSpan.className = 'creator-id'; | |
| idSpan.textContent = cid; | |
| idSpan.title = 'Click to copy clip id'; | |
| idSpan.style.cursor = 'pointer'; | |
| idSpan.onclick = () => { try { navigator.clipboard.writeText(cid); idSpan.classList.add('copied'); setTimeout(() => idSpan.classList.remove('copied'), 900); } catch (e) {} }; | |
| creatorLabel.appendChild(idSpan); | |
| } | |
| if (username) { | |
| if (cid) appendSpan(creatorLabel, 'creator-sep', '/'); | |
| appendSpan(creatorLabel, 'creator-by', 'made by '); | |
| appendSpan(creatorLabel, 'creator-name', name || username); | |
| } | |
| if (prompt) { | |
| if (username || cid) appendSpan(creatorLabel, 'creator-sep', '/'); | |
| appendSpan(creatorLabel, 'creator-prompt', prompt); | |
| } | |
| creatorLabel.title = `${cid ? `[${cid}] ` : ''}${username ? `Made by @${username}` : 'Anonymous animation'}${prompt ? `: ${prompt}` : ''}`; | |
| creatorLabel.style.display = 'block'; | |
| } | |
| setCreatorLabel(data); | |
| const loadingEl = document.getElementById('loading'); | |
| const showLoading = () => loadingEl.classList.add('on'); | |
| const hideLoading = () => loadingEl.classList.remove('on'); | |
| const toastEl = document.getElementById('viewer-toast'); | |
| let _toastTimer = null; | |
| function showToast(msg, ms) { | |
| if (!toastEl) return; | |
| toastEl.textContent = msg; | |
| toastEl.classList.add('on'); | |
| if (_toastTimer) clearTimeout(_toastTimer); | |
| _toastTimer = setTimeout(() => toastEl.classList.remove('on'), ms || 7000); | |
| } | |
| // Status text was removed from the UI; this sink keeps existing status | |
| // assignments harmless (still visible in the console). | |
| const info = { set textContent(v) { try { console.log('[viewer]', v); } catch (e) {} } }; | |
| scrub.max = Math.max(0, nFrames - 1); | |
| // Populate the display-model picker. The Citizen mapping is SMPL-X-22 only; | |
| // SOMA-77 clips still render as the procedural skeleton so finger chains are visible. | |
| for (const c of characters) { | |
| if (c.id !== 'skeleton' && (!quatData || nJoints !== 22)) continue; | |
| const opt = document.createElement('option'); | |
| opt.value = c.id; | |
| opt.textContent = c.label; | |
| characterSel.appendChild(opt); | |
| } | |
| characterSel.value = 'skeleton'; | |
| let frame = 0; | |
| let playing = true; | |
| let elapsedFrames = 0; | |
| let last = performance.now(); | |
| function setSkeletonFrame(f) { | |
| const base = f * nJoints * 3; | |
| for (let i = 0; i < nJoints; i++) { | |
| const o = base + i * 3; | |
| dummy.position.set(jointData[o], jointData[o + 1], jointData[o + 2]); | |
| dummy.updateMatrix(); | |
| jointMesh.setMatrixAt(i, dummy.matrix); | |
| } | |
| jointMesh.instanceMatrix.needsUpdate = true; | |
| let k = 0; | |
| for (const [i, p] of edges) { | |
| const po = base + p * 3; | |
| const io = base + i * 3; | |
| edgePositions[k++] = jointData[po]; | |
| edgePositions[k++] = jointData[po + 1]; | |
| edgePositions[k++] = jointData[po + 2]; | |
| edgePositions[k++] = jointData[io]; | |
| edgePositions[k++] = jointData[io + 1]; | |
| edgePositions[k++] = jointData[io + 2]; | |
| } | |
| edgeGeo.attributes.position.needsUpdate = true; | |
| } | |
| function showFrame(idx) { | |
| frame = ((idx % nFrames) + nFrames) % nFrames; | |
| if (mode === 'skeleton' || skelOverlay) setSkeletonFrame(frame); | |
| if (skelOverlay) updateNextTarget(frame); // advance the red next-target skeleton | |
| if (mode !== 'skeleton' && character) { | |
| character.anim.applyFrame(quatData, rootData, frame); | |
| syncClothing(); | |
| } | |
| syncCameraLock(); | |
| scrub.value = String(frame); | |
| frameLabel.textContent = `${frame + 1} / ${nFrames}`; | |
| } | |
| const clothingRows = document.getElementById('clothing-rows'); | |
| characterSel.onchange = async () => { | |
| const id = characterSel.value; | |
| if (id === 'skeleton') { | |
| mode = 'skeleton'; | |
| skel.visible = true; | |
| applySkelOverlayStyle(); | |
| if (character) character.root.visible = false; | |
| clothingRows.style.display = 'none'; | |
| showFrame(frame); | |
| return; | |
| } | |
| const entry = characters.find(c => c.id === id); | |
| if (!entry) { | |
| characterSel.value = 'skeleton'; | |
| mode = 'skeleton'; | |
| skel.visible = true; | |
| if (character) character.root.visible = false; | |
| clothingRows.style.display = 'none'; | |
| showFrame(frame); | |
| return; | |
| } | |
| info.textContent = `Loading ${entry.label}...`; | |
| if (character) character.root.visible = false; | |
| skel.visible = skelOverlay; | |
| showLoading(); | |
| try { | |
| if (!charCache[id]) charCache[id] = await buildCharacter(entry); | |
| character = charCache[id]; | |
| mode = id; | |
| showFrame(frame); // pose body + clothing while still HIDDEN | |
| // Dress FULLY before revealing — we never show a half-dressed citizen. If the | |
| // mesh or any garment stalls/fails (buildCharacter throws, or applyWorn returns | |
| // false), fall through to the catch: keep the skeleton and toast the user that a | |
| // refresh may be needed, rather than revealing an undressed/broken character. | |
| const dressed = character.dressed || await applyWorn(); | |
| if (!dressed) throw new Error('clothing did not finish loading'); | |
| character.dressed = true; | |
| character.root.visible = true; // reveal the finished, fully-dressed character | |
| skel.visible = skelOverlay; | |
| applySkelOverlayStyle(); // x-ray the overlay bones over the mesh | |
| clothingRows.style.display = Object.keys(slotSelects).length ? '' : 'none'; | |
| info.textContent = `${entry.label} - ${character.anim.pairs.length} joints retargeted`; | |
| } catch (e) { | |
| console.error(e); | |
| // Don't show the partially-loaded character — fall back to the skeleton (motion | |
| // still plays) and notify that a refresh may be needed to load the character. | |
| if (character) character.root.visible = false; | |
| info.textContent = `Could not load ${entry.label} — refresh to retry`; | |
| characterSel.value = 'skeleton'; | |
| mode = 'skeleton'; | |
| skel.visible = true; | |
| clothingRows.style.display = 'none'; | |
| showFrame(frame); | |
| showToast('Couldn’t fully load the character — refresh the page to try again.'); | |
| } finally { | |
| hideLoading(); | |
| } | |
| }; | |
| // Wardrobe dropdowns: one per clothing slot (None + the garments in that slot). | |
| // Changing one attaches/detaches live when a character is shown. | |
| async function onWardrobeChange(slot, id) { | |
| if (id) worn.set(slot, id); else worn.delete(slot); | |
| if (mode === 'skeleton' || !character) return; | |
| const item = id ? cfg.clothing.find(c => c.id === id) : null; | |
| if (item) await attachGarment(slot, item); else detachGarment(slot); | |
| } | |
| (function buildWardrobe() { | |
| const bySlot = {}; | |
| for (const g of (cfg.clothing || [])) (bySlot[g.slot] = bySlot[g.slot] || []).push(g); | |
| let any = false; | |
| for (const [slot, label] of Object.entries(cfg.slots || {})) { | |
| const items = bySlot[slot]; | |
| if (!items || !items.length) continue; | |
| if (!any) { | |
| const hdr = document.createElement('div'); | |
| hdr.className = 'mhdr'; | |
| hdr.textContent = 'Clothing'; | |
| clothingRows.appendChild(hdr); | |
| any = true; | |
| } | |
| const row = document.createElement('div'); | |
| row.className = 'mrow'; | |
| const lab = document.createElement('span'); | |
| lab.className = 'mlab'; | |
| lab.textContent = label; | |
| const sel = document.createElement('select'); | |
| const none = document.createElement('option'); | |
| none.value = ''; none.textContent = 'None'; | |
| sel.appendChild(none); | |
| for (const g of items) { | |
| const o = document.createElement('option'); | |
| o.value = g.id; o.textContent = g.label; | |
| sel.appendChild(o); | |
| } | |
| sel.value = worn.get(slot) || ''; | |
| sel.onchange = () => onWardrobeChange(slot, sel.value); | |
| slotSelects[slot] = sel; | |
| row.appendChild(lab); row.appendChild(sel); | |
| clothingRows.appendChild(row); | |
| } | |
| })(); | |
| clothingRows.style.display = 'none'; | |
| // --- In-viewer animation picker: fetch any clip's compact preview by URL and | |
| // swap the motion arrays without reloading the page when the joint count matches. | |
| const animSel = document.getElementById('animation'); | |
| const animLabels = new Map((cfg.animations || []).map((a) => [a.id, a.label])); | |
| // currentId may be a 'kata:<root>' selector (auto-play a kata); curId (hoisted | |
| // above) stays a real clip id (or '' for a composite kata) for messages/label. | |
| function postParent(payload) { try { parent.postMessage(payload, '*'); } catch (e) {} } | |
| // The camera is framed ONCE per editing session (the first motion that loads), then | |
| // left alone as the user browses stances / edits moves. `keepCamera` payloads ask to | |
| // preserve the current view; a `camera-reframe` message re-arms the one-shot centering. | |
| let _didInitialFrame = false; | |
| function loadMotion(p) { | |
| // Skip clips with a different joint count rather than render them garbled. | |
| if ((p.num_joints || 0) !== nJoints) { | |
| console.warn('skipping clip: ' + (p.num_joints || 0) + ' joints, viewer expects ' + nJoints); | |
| return; | |
| } | |
| jointData = decodeFloat32(p.joint_data_b64); | |
| quatData = p.quat_data_b64 ? decodeFloat32(p.quat_data_b64) : null; | |
| rootData = p.root_data_b64 ? decodeFloat32(p.root_data_b64) : null; | |
| nFrames = p.preview_frames || 0; | |
| fps = p.preview_fps || p.fps || 30; | |
| scrub.max = Math.max(0, nFrames - 1); | |
| frame = 0; elapsedFrames = 0; last = performance.now(); | |
| const _wasPos = camera.position.clone(), _wasTgt = controls.target.clone(); // previous view | |
| frameCamera(); // near/far + root-path geometry | |
| camera.position.copy(_wasPos); controls.target.copy(_wasTgt); // restore the prior view | |
| if (p.keepCamera && _didInitialFrame) { | |
| // Keep the existing (already-framed) camera — just re-apply the drawer offset. | |
| controls.update(); applyViewOffset(); | |
| } else { | |
| centerFullBody(true); // one-shot full-body framing (accounts for the drawer) | |
| _didInitialFrame = true; | |
| } | |
| showFrame(0); | |
| setCreatorLabel(p); | |
| setReferencePose(p.reference_pose || null); // red target-pose skeleton (seeded clips) | |
| setTargetPoses(p.target_frames || null); // red target skeletons at each kata seam | |
| } | |
| // Katas first (play the whole stitched sequence), then single moves. Kata | |
| // option values are prefixed 'kata:' and carry their spine path of clip ids. | |
| const kataIds = new Map(); | |
| for (const k of (cfg.katas || [])) kataIds.set(k.root, k.ids || []); | |
| // (Re)build the picker options, optionally filtered by a query that matches | |
| // either the clip id or the visible label (case-insensitive). Native <select> | |
| // can't hide <option>s reliably, so we rebuild the list on each keystroke. | |
| function renderAnimOptions(q) { | |
| const query = (q || '').trim().toLowerCase(); | |
| const match = (id, label) => !query | |
| || (id || '').toLowerCase().includes(query) | |
| || (label || '').toLowerCase().includes(query); | |
| const prev = animSel.value; | |
| animSel.textContent = ''; | |
| let shown = 0; | |
| const katas = (cfg.katas || []).filter(k => match('kata:' + k.root, k.label)); | |
| if (katas.length) { | |
| const kg = document.createElement('optgroup'); | |
| kg.label = '▶ Katas (full sequence)'; | |
| for (const k of katas) { | |
| const opt = document.createElement('option'); | |
| opt.value = 'kata:' + k.root; | |
| opt.textContent = k.label + ' · ' + k.count + ' moves'; | |
| kg.appendChild(opt); shown++; | |
| } | |
| animSel.appendChild(kg); | |
| } | |
| const anims = (cfg.animations || []).filter(a => match(a.id, a.label)); | |
| if (anims.length) { | |
| const ag = document.createElement('optgroup'); | |
| ag.label = 'Single moves'; | |
| for (const a of anims) { | |
| const opt = document.createElement('option'); | |
| // Lead with a short id chunk (the 6 hex chars after 'kmd_') so a picker | |
| // entry can be matched to a specific clip id; full id shows in the label. | |
| const shortId = (a.id || '').replace(/^kmd_/, '').slice(0, 6); | |
| opt.value = a.id; opt.textContent = (shortId ? shortId + ' · ' : '') + a.label; | |
| ag.appendChild(opt); shown++; | |
| } | |
| animSel.appendChild(ag); | |
| } | |
| if (!shown) { | |
| const opt = document.createElement('option'); | |
| opt.value = ''; opt.disabled = true; opt.textContent = 'no matches'; | |
| animSel.appendChild(opt); | |
| } | |
| // Keep the current selection if it's still visible after filtering. | |
| if (prev && [...animSel.options].some(o => o.value === prev)) animSel.value = prev; | |
| } | |
| renderAnimOptions(''); | |
| const animFilter = document.getElementById('anim-filter'); | |
| if (animFilter) animFilter.addEventListener('input', () => renderAnimOptions(animFilter.value)); | |
| if (cfg.currentId) animSel.value = cfg.currentId; | |
| // Default-to-kata: if the picker is pointed at a kata, auto-play the whole | |
| // stitched sequence once the viewer has booted. | |
| if (animSel.value && animSel.value.indexOf('kata:') === 0) { | |
| setTimeout(() => { if (animSel.value.indexOf('kata:') === 0) animSel.onchange(); }, 60); | |
| } | |
| animSel.onchange = async () => { | |
| const id = animSel.value; | |
| if (!id) return; | |
| if (id.startsWith('kata:')) { | |
| // Whole kata: the parent drawer stitches the path client-side and posts | |
| // the result back as a 'load-payload'. Just request it here. | |
| const root = id.slice(5); | |
| const ids = kataIds.get(root) || []; | |
| if (ids.length) { showLoading(); postParent({ kind: 'play-kata', ids, label: animSel.options[animSel.selectedIndex].textContent }); } | |
| return; | |
| } | |
| if (!cfg.previewBase) return; | |
| showLoading(); | |
| try { | |
| const r = await fetch(`${cfg.previewBase}/${encodeURIComponent(id)}.json`); | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| const payload = await r.json(); | |
| if (!payload.prompt) payload.prompt = animLabels.get(id) || ''; | |
| // Set the id BEFORE rendering so the on-screen label shows this clip's id | |
| // (not the previously-loaded one). | |
| payload.id = payload.id || id; | |
| curId = id; | |
| loadMotion(payload); | |
| // In-place clip switch (same iframe): only the highlight needs updating. | |
| // NOT 'kimodo-preview-ready' -- that would re-trigger clothing/actions work. | |
| postParent({ kind: 'kimodo-clip-changed', id: curId }); | |
| } catch (err) { | |
| console.error('animation load failed', err); | |
| } finally { | |
| hideLoading(); | |
| } | |
| }; | |
| // Model + clothing are chosen in the page-level controls, which post | |
| // {kind:'model'|'clothing', ...} to drive the hidden controls' apply logic. | |
| window.addEventListener('message', (e) => { | |
| const m = e.data || {}; | |
| if (m.kind === 'model') { | |
| const nextModel = [...characterSel.options].some(o => o.value === m.value) ? m.value : 'skeleton'; | |
| characterSel.value = nextModel || 'skeleton'; | |
| characterSel.onchange(); | |
| } else if (m.kind === 'clothing') { | |
| const sel = slotSelects[m.slot]; | |
| if (sel) sel.value = m.value || ''; | |
| onWardrobeChange(m.slot, m.value || ''); | |
| } else if (m.kind === 'load-animation' && m.id) { | |
| // Actions drawer asked to play a specific clip: reuse the picker path. | |
| animSel.value = m.id; | |
| animSel.onchange(); | |
| } else if (m.kind === 'load-payload' && m.payload) { | |
| // Kata drawer / picker stitched a path client-side: play the arrays directly. | |
| curId = m.payload.id || ''; | |
| loadMotion(m.payload); | |
| hideLoading(); | |
| postParent({ kind: 'kimodo-clip-changed', id: curId }); | |
| } else if (m.kind === 'viewport-inset') { | |
| // A left drawer is covering the left `left` px of the canvas — shift the | |
| // rendered scene right so the character stays centered in the visible area. | |
| viewportInsetLeft = Math.max(0, Number(m.left) || 0); | |
| applyViewOffset(); | |
| } else if (m.kind === 'dojo-scene') { | |
| const scenePayload = m.scene || {}; | |
| const img = m.image || scenePayload.image || scenePayload.panorama_image || ''; | |
| const splat = m.splat || scenePayload.splat || scenePayload.splat_url || ''; | |
| const settings = { room_size: scenePayload.room_size, room_height: scenePayload.room_height }; | |
| const renderMode = scenePayload.render_mode || scenePayload.mode || (splat ? 'splat' : 'panorama'); | |
| if (renderMode === 'splat' && splat) { | |
| if (scenePayload.use_panorama_skybox && img) applyDojoScene(img, settings); | |
| else clearDojoScene(); | |
| applyDojoSplat(splat, settings); | |
| } else if (img) { | |
| clearDojoSplat(); | |
| applyDojoScene(img, settings); | |
| } | |
| } else if (m.kind === 'dojo-room-settings') { | |
| applyDojoRoomSettings(m.settings || {}); | |
| } else if (m.kind === 'dojo-clear') { | |
| clearDojoScene(); | |
| clearDojoSplat(); | |
| } else if (m.kind === 'place-pose') { | |
| placeShow(m.posed, m.startPosed, m.parents, m.tx || 0, m.tz || 0, m.rot || 0, m.base, m.startVis, m.endVis); | |
| } else if (m.kind === 'place-vis') { | |
| // Grow/shrink the SKELETONS only; the gizmos (pose controls) stay put. | |
| if (typeof m.start === 'boolean') { placeStartVis = m.start; placeSetVisible(placeStartGroup, m.start); } | |
| if (typeof m.end === 'boolean') { placeEndVis = m.end; placeSetVisible(placeEndSkel, m.end); } | |
| } else if (m.kind === 'place-clear') { | |
| placeHide(); | |
| } else if (m.kind === 'transport') { | |
| // Action card play-row drives the viewer's playback. | |
| if (m.action === 'play' || (m.action === 'toggle' && !playing)) { | |
| playing = true; playBtn.textContent = 'Pause'; elapsedFrames = frame; last = performance.now(); | |
| } else if (m.action === 'pause' || (m.action === 'toggle' && playing)) { | |
| playing = false; playBtn.textContent = 'Play'; | |
| } else if (m.action === 'seek' && typeof m.frame === 'number') { | |
| playing = false; playBtn.textContent = 'Play'; elapsedFrames = m.frame; showFrame(m.frame); | |
| } | |
| } else if (m.kind === 'camera-lock') { | |
| // Parent center/lock button: center + frame the full body and follow, or free. | |
| cameraLocked = !!m.on; | |
| if (cameraLocked) centerFullBody(true); | |
| updateCameraLockButton(); | |
| } else if (m.kind === 'camera-reframe') { | |
| // Re-arm the one-shot framing so the NEXT motion load re-centers (new session). | |
| _didInitialFrame = false; | |
| } | |
| }); | |
| playBtn.onclick = () => { | |
| playing = !playing; | |
| playBtn.textContent = playing ? 'Pause' : 'Play'; | |
| elapsedFrames = frame; | |
| last = performance.now(); | |
| }; | |
| scrub.oninput = () => { | |
| playing = false; | |
| playBtn.textContent = 'Play'; | |
| elapsedFrames = Number(scrub.value); | |
| showFrame(elapsedFrames); | |
| }; | |
| // When a left drawer covers `viewportInsetLeft` px, offset the projection so the | |
| // world center maps to the middle of the still-visible region (right of the | |
| // drawer) — the character appears centered again. 0 = no drawer = clear offset. | |
| let viewportInsetLeft = 0; | |
| function applyViewOffset() { | |
| // Do NOT compensate for the sidebar — just center the character in the full canvas | |
| // (looks fine with the bar shown or hidden, and avoids the off-center artifacts a | |
| // projection shift introduced). | |
| camera.clearViewOffset(); | |
| camera.updateProjectionMatrix(); | |
| } | |
| function resize() { | |
| const w = app.clientWidth || 1; | |
| const h = app.clientHeight || 1; | |
| renderer.setSize(w, h, false); | |
| camera.aspect = w / h; | |
| applyViewOffset(); // re-applies the drawer offset (or clears) + updateProjectionMatrix | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| frameCamera(); | |
| centerFullBody(false); // initial full-body framing (immediate, no fly) | |
| // Decide the starting model BEFORE the first render so the skeleton never | |
| // flashes when the citizen is the default: hide the skeleton up front and | |
| // let the citizen appear once it's built (clothing streams in after). | |
| const startCharacter = (quatData && nJoints === 22 && cfg.defaultCharacter && characters.some(c => c.id === cfg.defaultCharacter)) | |
| ? cfg.defaultCharacter : 'skeleton'; | |
| if (startCharacter !== 'skeleton') { | |
| skel.visible = false; | |
| characterSel.value = startCharacter; | |
| characterSel.onchange(); | |
| } else { | |
| showFrame(0); | |
| } | |
| // Tell the parent the viewer is ready (with the clip id) so the Actions | |
| // drawer can refresh + know which clip is showing. | |
| postParent({ kind: 'kimodo-preview-ready', id: curId }); | |
| let lastPost = 0; | |
| function tick(now) { | |
| if (disposed) return; | |
| const dt = Math.min(0.1, (now - last) / 1000); | |
| last = now; | |
| if (playing && nFrames > 1) { | |
| elapsedFrames += dt * fps * Number(speed.value); | |
| const target = Math.floor(elapsedFrames) % nFrames; | |
| if (target !== frame) showFrame(target); | |
| } | |
| // Stream playback state to the parent so a card's play-row can track it. | |
| if (now - lastPost > 120) { | |
| lastPost = now; | |
| postParent({ kind: 'kimodo-frame', id: curId, frame, playing, num_frames: nFrames }); | |
| } | |
| _camAnimTick(dt); // glide the camera into a new framing | |
| _placeAnimTick(); // grow/shrink place skeletons | |
| _dojoSplatGrowTick(dt); // grow the dojo splat in on first load | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(tick); | |
| } | |
| requestAnimationFrame(tick); | |
| // Gradio replaces this whole iframe on each (re)render. Without releasing the | |
| // WebGL context the browser accumulates them and eventually blocks new ones | |
| // ("Web page caused context loss and was blocked"). Stop the loop and free the | |
| // GPU context the moment this iframe is torn down. | |
| function teardownViewer() { | |
| if (disposed) return; | |
| disposed = true; | |
| try { controls.dispose(); } catch (e) {} | |
| try { renderer.forceContextLoss(); } catch (e) {} | |
| try { renderer.dispose(); } catch (e) {} | |
| } | |
| window.addEventListener('pagehide', teardownViewer); | |
| window.addEventListener('beforeunload', teardownViewer); | |