karate-wiener / _check_srcdoc.js
polats's picture
Deploy Karate Wiener (kimodo kata maker)
d446228 verified
Raw
History Blame Contribute Delete
77.2 kB
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 ? '&#128274;' : '&#128275;';
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);