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:' 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