|
|
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; |
| |
| |
| window.__viewerBooted = true; |
|
|
| 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); |
| |
| |
| |
| 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'; |
|
|
| |
| |
| 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; |
| 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); |
| |
| |
| 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; |
| |
| |
| |
| |
| 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)); |
| |
| |
| 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); |
| |
| 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) => { |
| |
| |
| 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; |
| |
| |
| |
| if (spark) { |
| try { scene.remove(spark); if (typeof spark.dispose === 'function') spark.dispose(); } catch (e) {} |
| spark = null; |
| } |
| } |
| |
| |
| 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); |
| 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); |
| |
| |
| 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); |
| } 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; |
|
|
| |
| |
| |
| 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; |
| 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; |
| |
| |
| 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; |
| } |
| |
| 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; |
| } |
| |
| const _placeAnims = new Map(); |
| 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(); |
| |
| |
| placeGroup = new THREE.Group(); |
| placeEndSkel = _placeSkel(placePosed, 0xe8736a); |
| placeEndSkel.scale.setScalar(0.0001); placeEndSkel.visible = placeEndVis; placeGroup.add(placeEndSkel); |
| |
| 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; |
| scene.add(placeGroup); |
| placeSetVisible(placeEndSkel, placeEndVis); |
| |
| 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; } |
| |
| |
| 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 }; |
| controls.enabled = false; |
| } 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; |
| placeTx = dx * c - dz * s; placeTz = dx * s + dz * c; |
| } else { |
| const cx = placeGroup.position.x, cz = placeGroup.position.z; |
| 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); |
|
|
| |
| |
| 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); |
| 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); |
|
|
| |
| 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; |
|
|
| |
| const skel = new THREE.Group(); |
| scene.add(skel); |
|
|
| |
| |
| 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; } |
| 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(); |
| |
| |
| |
| |
| 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; |
| 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(); |
| |
| |
| 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); |
| const look = previousCameraFocus.clone(); look.y = (minY + maxY) / 2; |
| const fov = (camera.fov || 45) * Math.PI / 180; |
| const dist = (bh / (2 * Math.tan(fov / 2))) * 2.9 + 1.0; |
| 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(); |
|
|
| |
| |
| let skelOverlay = false; |
| function applySkelOverlayStyle() { |
| |
| 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; |
| } |
| |
| |
| |
| 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) { |
| |
| 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); |
|
|
| |
| |
| |
| |
| 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; } |
| |
| 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); |
| }); |
|
|
| |
| |
| |
| |
| 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; |
| let mode = 'skeleton'; |
|
|
| |
| |
| |
| |
| |
| 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); |
| if (m.metalness !== undefined) m.metalness = 0; |
| if (m.roughness !== undefined) m.roughness = Math.max(m.roughness, 0.7); |
| m.needsUpdate = true; |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| 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()); |
| return p; |
| } |
|
|
| async function buildCharacter(entry) { |
| const loader = new GLTFLoader(); |
| |
| |
| |
| |
| 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); |
| 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 }; |
| } |
|
|
| |
| |
| |
| |
| const clothLoader = new GLTFLoader(); |
| const worn = new Map(); |
| const wornAttached = new Map(); |
| 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 }); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| |
| function setCreatorLabel(source) { |
| const username = (source && source.created_by) || ''; |
| const name = (source && source.created_by_name) || username; |
| const prompt = truncateText((source && source.prompt) || '', 72); |
| |
| |
| 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); |
| } |
| |
| |
| const info = { set textContent(v) { try { console.log('[viewer]', v); } catch (e) {} } }; |
| scrub.max = Math.max(0, nFrames - 1); |
|
|
| |
| |
| 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); |
| 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); |
| |
| |
| |
| |
| const dressed = character.dressed || await applyWorn(); |
| if (!dressed) throw new Error('clothing did not finish loading'); |
| character.dressed = true; |
| character.root.visible = true; |
| skel.visible = skelOverlay; |
| applySkelOverlayStyle(); |
| clothingRows.style.display = Object.keys(slotSelects).length ? '' : 'none'; |
| info.textContent = `${entry.label} - ${character.anim.pairs.length} joints retargeted`; |
| } catch (e) { |
| console.error(e); |
| |
| |
| 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(); |
| } |
| }; |
|
|
| |
| |
| 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'; |
|
|
| |
| |
| const animSel = document.getElementById('animation'); |
| const animLabels = new Map((cfg.animations || []).map((a) => [a.id, a.label])); |
| |
| |
| function postParent(payload) { try { parent.postMessage(payload, '*'); } catch (e) {} } |
| |
| |
| |
| let _didInitialFrame = false; |
| function loadMotion(p) { |
| |
| 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(); |
| frameCamera(); |
| camera.position.copy(_wasPos); controls.target.copy(_wasTgt); |
| if (p.keepCamera && _didInitialFrame) { |
| |
| controls.update(); applyViewOffset(); |
| } else { |
| centerFullBody(true); |
| _didInitialFrame = true; |
| } |
| showFrame(0); |
| setCreatorLabel(p); |
| setReferencePose(p.reference_pose || null); |
| setTargetPoses(p.target_frames || null); |
| } |
| |
| |
| const kataIds = new Map(); |
| for (const k of (cfg.katas || [])) kataIds.set(k.root, k.ids || []); |
| |
| |
| |
| 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'); |
| |
| |
| 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); |
| } |
| |
| 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; |
| |
| |
| 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:')) { |
| |
| |
| 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) || ''; |
| |
| |
| payload.id = payload.id || id; |
| curId = id; |
| loadMotion(payload); |
| |
| |
| postParent({ kind: 'kimodo-clip-changed', id: curId }); |
| } catch (err) { |
| console.error('animation load failed', err); |
| } finally { |
| hideLoading(); |
| } |
| }; |
|
|
| |
| |
| 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) { |
| |
| animSel.value = m.id; |
| animSel.onchange(); |
| } else if (m.kind === 'load-payload' && m.payload) { |
| |
| curId = m.payload.id || ''; |
| loadMotion(m.payload); |
| hideLoading(); |
| postParent({ kind: 'kimodo-clip-changed', id: curId }); |
| } else if (m.kind === 'viewport-inset') { |
| |
| |
| 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') { |
| |
| 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') { |
| |
| 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') { |
| |
| cameraLocked = !!m.on; |
| if (cameraLocked) centerFullBody(true); |
| updateCameraLockButton(); |
| } else if (m.kind === 'camera-reframe') { |
| |
| _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); |
| }; |
|
|
| |
| |
| |
| let viewportInsetLeft = 0; |
| function applyViewOffset() { |
| |
| |
| |
| 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(); |
| } |
| window.addEventListener('resize', resize); |
| resize(); |
| frameCamera(); |
| centerFullBody(false); |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| 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); |
| } |
| |
| if (now - lastPost > 120) { |
| lastPost = now; |
| postParent({ kind: 'kimodo-frame', id: curId, frame, playing, num_frames: nFrames }); |
| } |
| _camAnimTick(dt); |
| _placeAnimTick(); |
| _dojoSplatGrowTick(dt); |
| controls.update(); |
| renderer.render(scene, camera); |
| requestAnimationFrame(tick); |
| } |
| requestAnimationFrame(tick); |
|
|
| |
| |
| |
| |
| 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); |
| |