| <!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8" /> |
| <style> |
| html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; } |
| body { background: #d8e0ea; color: #d9edf7; font: 13px/1.4 Inter, sans-serif; } |
| #app { width: 100%; height: 100%; position: relative; } |
| #debug-status { |
| display: none; |
| position: absolute; |
| left: 12px; |
| top: 12px; |
| z-index: 20; |
| max-width: 420px; |
| padding: 8px 10px; |
| border-radius: 10px; |
| background: rgba(240, 245, 250, 0.84); |
| color: #334155; |
| font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| white-space: pre-wrap; |
| pointer-events: none; |
| } |
| canvas { display: block; } |
| </style> |
| </head> |
| <body> |
| <div id="app"></div> |
| <div id="debug-status">boot: script tag loaded</div> |
| <script type="module"> |
| let activeLoadToken = 0; |
| const debugStatus = document.getElementById('debug-status'); |
| const initialParams = new URLSearchParams(window.location.search); |
| const viewerNonce = initialParams.get('viewerNonce') || ''; |
| const assetVersion = initialParams.get('v') || ''; |
| |
| function setDebugStatus(message) { |
| if (debugStatus) { |
| debugStatus.textContent = message; |
| } |
| } |
| |
| function post(type, payload = {}) { |
| window.parent.postMessage({ source: 'robot-motion-viewer', viewerNonce, type, ...payload }, '*'); |
| } |
| |
| function withAssetVersion(url) { |
| const raw = String(url || '').trim(); |
| if (!raw || !assetVersion) return raw; |
| try { |
| const resolved = new URL(raw, window.location.origin); |
| resolved.searchParams.set('v', assetVersion); |
| if (resolved.origin === window.location.origin) { |
| return resolved.pathname + resolved.search + resolved.hash; |
| } |
| return resolved.toString(); |
| } catch { |
| return raw; |
| } |
| } |
| |
| window.addEventListener('message', (event) => { |
| const payload = event.data || {}; |
| if (payload.viewerNonce && payload.viewerNonce !== viewerNonce) { |
| return; |
| } |
| if (payload.type === 'robot-ping') { |
| setDebugStatus('boot: ping received'); |
| post('boot'); |
| } |
| }); |
| |
| window.addEventListener('error', (event) => { |
| const message = event?.error?.message || event?.message || 'Unknown robot viewer error'; |
| setDebugStatus('error: ' + message); |
| post('error', { message }); |
| }); |
| |
| window.addEventListener('unhandledrejection', (event) => { |
| const reason = event?.reason; |
| const message = |
| reason instanceof Error |
| ? reason.message |
| : typeof reason === 'string' |
| ? reason |
| : JSON.stringify(reason || 'Unknown rejection'); |
| setDebugStatus('rejection: ' + message); |
| post('error', { message }); |
| }); |
| |
| setDebugStatus('boot: importing three'); |
| const THREE = await import('/hf-assets/vendor/three/three.module.js'); |
| setDebugStatus('boot: importing orbit controls'); |
| const { OrbitControls } = await import('/hf-assets/vendor/three/controls/OrbitControls.js'); |
| setDebugStatus('boot: importing STL loader'); |
| const { STLLoader } = await import('/hf-assets/vendor/three/loaders/STLLoader.js'); |
| setDebugStatus('boot: importing mujoco'); |
| const { default: loadMujoco } = await import('/hf-assets/vendor/mujoco/mujoco.js'); |
| setDebugStatus('boot: runtime imports ready'); |
| |
| const root = document.getElementById('app'); |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); |
| renderer.setPixelRatio(window.devicePixelRatio || 1); |
| renderer.setSize(root.clientWidth, root.clientHeight); |
| renderer.outputColorSpace = THREE.SRGBColorSpace; |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; |
| renderer.toneMappingExposure = 0.92; |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| renderer.domElement.style.opacity = '0'; |
| renderer.domElement.style.transition = 'opacity 180ms ease'; |
| root.appendChild(renderer.domElement); |
| |
| const scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0xd8e0ea); |
| scene.fog = new THREE.Fog(0xcfd7e2, 9, 24); |
| |
| const camera = new THREE.PerspectiveCamera(34, root.clientWidth / root.clientHeight, 0.05, 1000); |
| camera.position.set(2.4, 1.5, 3.8); |
| |
| const controls = new OrbitControls(camera, renderer.domElement); |
| controls.enableDamping = true; |
| controls.dampingFactor = 0.07; |
| controls.target.set(0, 0.9, 0); |
| controls.minDistance = 0.7; |
| controls.maxDistance = 12; |
| controls.maxPolarAngle = Math.PI - 0.08; |
| controls.minPolarAngle = 0.08; |
| |
| scene.add(new THREE.AmbientLight(0xffffff, 0.54)); |
| const hemi = new THREE.HemisphereLight(0xe8eff7, 0xb3bfce, 0.66); |
| scene.add(hemi); |
| |
| const key = new THREE.DirectionalLight(0xe7edf5, 1.15); |
| key.position.set(3.8, 5.8, 4.6); |
| key.castShadow = true; |
| key.shadow.mapSize.set(2048, 2048); |
| key.shadow.camera.near = 0.5; |
| key.shadow.camera.far = 30; |
| key.shadow.bias = -0.00015; |
| scene.add(key); |
| |
| const fill = new THREE.DirectionalLight(0x8cb4d9, 0.34); |
| fill.position.set(-3.4, 2.8, 4.2); |
| scene.add(fill); |
| |
| const rim = new THREE.DirectionalLight(0xc7d6eb, 0.2); |
| rim.position.set(-2.8, 4.2, -4.8); |
| scene.add(rim); |
| |
| const top = new THREE.PointLight(0xffffff, 0.14, 12); |
| top.position.set(0, 2.9, 1.2); |
| scene.add(top); |
| |
| const grid = new THREE.GridHelper(16, 16, 0x96aac0, 0xc2ccd8); |
| grid.material.opacity = 0.28; |
| grid.material.transparent = true; |
| scene.add(grid); |
| |
| const floor = new THREE.Mesh( |
| new THREE.CircleGeometry(5.4, 64), |
| new THREE.MeshStandardMaterial({ color: 0xe4e9f0, roughness: 0.95, metalness: 0.02 }) |
| ); |
| floor.rotation.x = -Math.PI / 2; |
| floor.position.y = -0.01; |
| floor.receiveShadow = true; |
| scene.add(floor); |
| |
| const shadowCatcher = new THREE.Mesh( |
| new THREE.CircleGeometry(1.32, 48), |
| new THREE.MeshBasicMaterial({ color: 0x465a72, transparent: true, opacity: 0.035, depthWrite: false }) |
| ); |
| shadowCatcher.rotation.x = -Math.PI / 2; |
| shadowCatcher.position.y = 0.002; |
| scene.add(shadowCatcher); |
| |
| const backdrop = new THREE.Mesh( |
| new THREE.PlaneGeometry(24, 16), |
| new THREE.MeshBasicMaterial({ color: 0xe1e8f1 }) |
| ); |
| backdrop.position.set(0, 4, -8.5); |
| scene.add(backdrop); |
| |
| const mujocoRoot = new THREE.Group(); |
| mujocoRoot.rotation.x = -Math.PI / 2; |
| scene.add(mujocoRoot); |
| |
| const loader = new STLLoader(); |
| const geometryCache = new Map(); |
| const materialCache = new Map(); |
| const jsonCache = new Map(); |
| const textCache = new Map(); |
| const bufferCache = new Map(); |
| const modelPackageCache = new Map(); |
| const motionCache = new Map(); |
| const renderables = []; |
| const tempMatrix = new THREE.Matrix4(); |
| const tempQuaternion = new THREE.Quaternion(); |
| const tempPosition = new THREE.Vector3(); |
| const tempBox = new THREE.Box3(); |
| const tempSize = new THREE.Vector3(); |
| const tempCenter = new THREE.Vector3(); |
| let mujoco = null; |
| let model = null; |
| let data = null; |
| let vfs = null; |
| let motion = null; |
| let robotMeta = null; |
| let visualMeta = null; |
| let videoPlane = null; |
| let videoElement = null; |
| let videoTexture = null; |
| let frameCount = 0; |
| let fps = 30; |
| let syncedTime = 0; |
| let builtFrame = -1; |
| let qposLayout = { rootPosStart: 0, rootQuatStart: 3, dofStart: 7 }; |
| let rootQuatOrder = 'xyzw'; |
| let playbackAnchorTime = 0; |
| let playbackAnchorPerf = 0; |
| let currentPlaybackTime = 0; |
| let externalPlaying = false; |
| let forcedRenderSize = null; |
| let currentModelPackageKey = ''; |
| const videoPlaneBottomY = 0.02; |
| let videoPlaneDepth = -2.25; |
| let videoPlaneHeight = 1.72; |
| let videoPlaneAspect = 16 / 9; |
| |
| function clamp(value, min, max) { |
| return Math.max(min, Math.min(max, value)); |
| } |
| |
| function getTerminalPlaybackTime(duration) { |
| if (!Number.isFinite(duration) || duration <= 0) { |
| return 0; |
| } |
| return Math.max(duration - 1 / Math.max(fps, 1), 0); |
| } |
| |
| function getNormalizedPlaybackTime(time, duration) { |
| const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0; |
| if (!Number.isFinite(duration) || duration <= 0) { |
| return safeTime; |
| } |
| return Math.min(safeTime, getTerminalPlaybackTime(duration)); |
| } |
| |
| function quatAngleRadians(a, b) { |
| const ax = a[0] || 0; |
| const ay = a[1] || 0; |
| const az = a[2] || 0; |
| const aw = a[3] || 1; |
| const bx = b[0] || 0; |
| const by = b[1] || 0; |
| const bz = b[2] || 0; |
| const bw = b[3] || 1; |
| const dot = Math.abs(ax * bx + ay * by + az * bz + aw * bw); |
| return 2 * Math.acos(clamp(dot, -1, 1)); |
| } |
| |
| function stabilizeLeadingFrames(nextMotion) { |
| if (!nextMotion) return nextMotion; |
| const rootRot = Array.isArray(nextMotion.root_rot_xyzw) ? nextMotion.root_rot_xyzw : []; |
| const rootPos = Array.isArray(nextMotion.root_pos) ? nextMotion.root_pos : []; |
| const dofPos = Array.isArray(nextMotion.dof_pos) ? nextMotion.dof_pos : []; |
| const frameCount = Math.min(rootRot.length, rootPos.length, dofPos.length); |
| if (frameCount < 3) return nextMotion; |
| |
| const inspectCount = Math.min(6, frameCount - 1); |
| let stableIndex = -1; |
| for (let index = 1; index < inspectCount; index += 1) { |
| const delta = quatAngleRadians(rootRot[index], rootRot[index + 1]); |
| if (delta < THREE.MathUtils.degToRad(10)) { |
| stableIndex = index; |
| break; |
| } |
| } |
| if (stableIndex < 1) return nextMotion; |
| |
| const leadDelta = quatAngleRadians(rootRot[0], rootRot[stableIndex]); |
| const stableDelta = quatAngleRadians(rootRot[stableIndex], rootRot[Math.min(stableIndex + 1, frameCount - 1)]); |
| if (leadDelta < THREE.MathUtils.degToRad(25) || stableDelta > THREE.MathUtils.degToRad(12)) { |
| return nextMotion; |
| } |
| |
| const stableRootRot = [...rootRot[stableIndex]]; |
| const stableRootPos = [...rootPos[stableIndex]]; |
| const stableDofPos = [...dofPos[stableIndex]]; |
| for (let index = 0; index < stableIndex; index += 1) { |
| rootRot[index] = [...stableRootRot]; |
| rootPos[index] = [...stableRootPos]; |
| dofPos[index] = [...stableDofPos]; |
| } |
| return nextMotion; |
| } |
| |
| function setStatus(phase, message) { |
| setDebugStatus(phase + ': ' + message); |
| if (phase === 'ready') { |
| renderer.domElement.style.opacity = '1'; |
| } else { |
| renderer.domElement.style.opacity = '0'; |
| } |
| post('status', { phase, message }); |
| } |
| |
| function setError(error) { |
| const message = error instanceof Error ? error.message : String(error || 'Unknown error'); |
| post('error', { message }); |
| } |
| |
| function resizeRenderer() { |
| const width = forcedRenderSize?.width || root.clientWidth; |
| const height = forcedRenderSize?.height || root.clientHeight; |
| camera.aspect = width / height; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(width, height, false); |
| } |
| |
| function getMaterial(color) { |
| const key = color || '#c2d4e8'; |
| if (!materialCache.has(key)) { |
| materialCache.set( |
| key, |
| new THREE.MeshStandardMaterial({ |
| color: new THREE.Color(key), |
| metalness: 0.22, |
| roughness: 0.58, |
| }) |
| ); |
| } |
| return materialCache.get(key); |
| } |
| |
| function toArrayBuffer(bytes) { |
| if (bytes instanceof ArrayBuffer) { |
| return bytes; |
| } |
| if (ArrayBuffer.isView(bytes)) { |
| return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); |
| } |
| return bytes; |
| } |
| |
| function nextFrameYield() { |
| return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); |
| } |
| |
| function decodeBase64ToArrayBuffer(text) { |
| const normalized = String(text || '').replace(/\s+/g, ''); |
| const binary = window.atob(normalized); |
| const bytes = new Uint8Array(binary.length); |
| for (let index = 0; index < binary.length; index += 1) { |
| bytes[index] = binary.charCodeAt(index); |
| } |
| return bytes.buffer; |
| } |
| |
| function normalizeMeshAssetFileName(rawName, fallbackMeshName = '') { |
| const candidate = String(rawName || '').trim(); |
| const stripped = candidate.replace(/\.b64\.txt$/i, ''); |
| if (/\.stl$/i.test(stripped)) { |
| return stripped; |
| } |
| const meshName = String(fallbackMeshName || '').trim(); |
| if (meshName) { |
| return meshName + '.STL'; |
| } |
| return stripped; |
| } |
| |
| async function loadGeometry(src, rawBuffer = null) { |
| if (geometryCache.has(src)) return geometryCache.get(src); |
| const geometryPromise = (async () => { |
| const parsedBuffer = rawBuffer ? toArrayBuffer(rawBuffer) : null; |
| if (parsedBuffer) { |
| const geometry = loader.parse(parsedBuffer); |
| geometry.computeVertexNormals(); |
| return geometry; |
| } |
| if (/\.stl\.b64\.txt(?:[?#].*)?$/i.test(String(src || ''))) { |
| const encoded = await fetchText(src, 'force-cache'); |
| const geometry = loader.parse(decodeBase64ToArrayBuffer(encoded)); |
| geometry.computeVertexNormals(); |
| return geometry; |
| } |
| if (/\.stl(?:[?#].*)?$/i.test(String(src || ''))) { |
| const response = await fetch(src, { cache: 'force-cache' }); |
| if (!response.ok) { |
| throw new Error('Failed to fetch ' + src + ' (' + response.status + ')'); |
| } |
| const geometry = loader.parse(await response.arrayBuffer()); |
| geometry.computeVertexNormals(); |
| return geometry; |
| } |
| return await new Promise((resolve, reject) => { |
| reject(new Error('Unsupported geometry format: ' + src)); |
| }); |
| })(); |
| geometryCache.set(src, geometryPromise); |
| try { |
| return await geometryPromise; |
| } catch (error) { |
| geometryCache.delete(src); |
| throw error; |
| } |
| } |
| |
| async function fetchBuffer(url, cacheMode = 'force-cache') { |
| if (bufferCache.has(url)) return bufferCache.get(url); |
| const bufferPromise = (async () => { |
| if (/\.stl\.b64\.txt(?:[?#].*)?$/i.test(String(url || ''))) { |
| const encoded = await fetchText(url, cacheMode); |
| return new Uint8Array(decodeBase64ToArrayBuffer(encoded)); |
| } |
| const response = await fetch(url, { cache: cacheMode }); |
| if (!response.ok) { |
| throw new Error('Failed to fetch ' + url + ' (' + response.status + ')'); |
| } |
| return new Uint8Array(await response.arrayBuffer()); |
| })(); |
| bufferCache.set(url, bufferPromise); |
| try { |
| return await bufferPromise; |
| } catch (error) { |
| bufferCache.delete(url); |
| throw error; |
| } |
| } |
| |
| function clearSceneMeshes() { |
| while (mujocoRoot.children.length) { |
| mujocoRoot.remove(mujocoRoot.children[0]); |
| } |
| renderables.length = 0; |
| builtFrame = -1; |
| } |
| |
| function clearVideoPlane() { |
| if (videoPlane) { |
| scene.remove(videoPlane); |
| videoPlane.geometry?.dispose?.(); |
| videoPlane.material?.dispose?.(); |
| videoPlane = null; |
| } |
| if (videoTexture) { |
| videoTexture.dispose?.(); |
| videoTexture = null; |
| } |
| if (videoElement) { |
| try { |
| videoElement.pause(); |
| } catch {} |
| videoElement.src = ''; |
| videoElement.load?.(); |
| videoElement = null; |
| } |
| } |
| |
| function applyVideoPlaneLayout() { |
| if (!videoPlane) return; |
| videoPlane.geometry.dispose?.(); |
| videoPlane.geometry = new THREE.PlaneGeometry(videoPlaneHeight * videoPlaneAspect, videoPlaneHeight); |
| videoPlane.position.set(0, videoPlaneBottomY + videoPlaneHeight / 2, videoPlaneDepth); |
| } |
| |
| function ensureVideoPlane(src) { |
| clearVideoPlane(); |
| if (!src) return; |
| videoElement = document.createElement('video'); |
| videoElement.src = src; |
| videoElement.crossOrigin = 'anonymous'; |
| videoElement.muted = true; |
| videoElement.loop = false; |
| videoElement.playsInline = true; |
| videoElement.preload = 'auto'; |
| videoTexture = new THREE.VideoTexture(videoElement); |
| videoTexture.colorSpace = THREE.SRGBColorSpace; |
| videoTexture.minFilter = THREE.LinearFilter; |
| videoTexture.magFilter = THREE.LinearFilter; |
| videoTexture.generateMipmaps = false; |
| |
| videoPlane = new THREE.Mesh( |
| new THREE.PlaneGeometry(videoPlaneHeight * videoPlaneAspect, videoPlaneHeight), |
| new THREE.MeshBasicMaterial({ |
| map: videoTexture, |
| transparent: false, |
| opacity: 1, |
| toneMapped: false, |
| side: THREE.DoubleSide, |
| }) |
| ); |
| applyVideoPlaneLayout(); |
| scene.add(videoPlane); |
| |
| videoElement.addEventListener( |
| 'loadedmetadata', |
| () => { |
| if (!videoPlane || !videoElement || !videoElement.videoWidth || !videoElement.videoHeight) return; |
| videoPlaneAspect = videoElement.videoWidth / videoElement.videoHeight; |
| applyVideoPlaneLayout(); |
| }, |
| { once: true } |
| ); |
| } |
| |
| function fitPresentationToRobot() { |
| if (!renderables.length) return; |
| tempBox.setFromObject(mujocoRoot); |
| if (tempBox.isEmpty()) return; |
| tempBox.getSize(tempSize); |
| tempBox.getCenter(tempCenter); |
| |
| const robotHeight = Math.max(tempSize.y, 0.9); |
| const robotWidth = Math.max(tempSize.x, 0.45); |
| const robotDepth = Math.max(tempSize.z, 0.35); |
| const eyeLine = tempBox.min.y + robotHeight * 0.48; |
| const targetZ = tempCenter.z - robotDepth * 0.1; |
| controls.target.set(tempCenter.x, eyeLine, targetZ); |
| |
| const baseDistance = Math.max( |
| Number(robotMeta?.cameraDistance || 2.1) * 1.15, |
| robotHeight * 1.75, |
| robotWidth * 2.8, |
| robotDepth * 3.6 |
| ); |
| camera.position.set( |
| controls.target.x + baseDistance * 0.12, |
| controls.target.y + robotHeight * 0.46, |
| controls.target.z + baseDistance * 1.32 |
| ); |
| camera.near = 0.05; |
| camera.far = 80; |
| camera.updateProjectionMatrix(); |
| camera.lookAt(controls.target); |
| |
| videoPlaneHeight = THREE.MathUtils.clamp(robotHeight * 1.22, 1.58, 1.96); |
| videoPlaneDepth = tempCenter.z - THREE.MathUtils.clamp(robotDepth * 1.9 + 1.15, 1.65, 2.45); |
| applyVideoPlaneLayout(); |
| } |
| |
| function disposeRuntime() { |
| try { data?.delete?.(); } catch {} |
| try { model?.delete?.(); } catch {} |
| try { vfs?.delete?.(); } catch {} |
| data = null; |
| model = null; |
| vfs = null; |
| } |
| |
| async function ensureMujoco() { |
| if (mujoco) return mujoco; |
| setStatus('runtime', 'Loading robot runtime...'); |
| mujoco = await loadMujoco({ |
| locateFile(path) { |
| return '/hf-assets/vendor/mujoco/' + path; |
| }, |
| }); |
| return mujoco; |
| } |
| |
| async function fetchJson(url, cacheMode = 'force-cache') { |
| if (jsonCache.has(url)) return jsonCache.get(url); |
| const response = await fetch(url, { cache: cacheMode }); |
| if (!response.ok) { |
| throw new Error('Failed to fetch ' + url + ' (' + response.status + ')'); |
| } |
| const value = await response.json(); |
| jsonCache.set(url, value); |
| return value; |
| } |
| |
| async function fetchText(url, cacheMode = 'force-cache') { |
| if (textCache.has(url)) return textCache.get(url); |
| const response = await fetch(url, { cache: cacheMode }); |
| if (!response.ok) { |
| throw new Error('Failed to fetch ' + url + ' (' + response.status + ')'); |
| } |
| const value = await response.text(); |
| textCache.set(url, value); |
| return value; |
| } |
| |
| async function prewarmModelPackage(metaSrc) { |
| if (!metaSrc) return null; |
| if (modelPackageCache.has(metaSrc)) return modelPackageCache.get(metaSrc); |
| const packagePromise = (async () => { |
| const nextRobotMeta = await fetchJson(withAssetVersion(metaSrc), 'force-cache'); |
| const packageUrl = withAssetVersion(nextRobotMeta.packageUrl); |
| if (packageUrl) { |
| try { |
| const bundledPackage = await fetchJson(packageUrl, 'force-cache'); |
| const xmlText = typeof bundledPackage?.xmlText === 'string' ? bundledPackage.xmlText : ''; |
| if (!xmlText) { |
| throw new Error('Robot model package is missing xmlText.'); |
| } |
| const visualGeoms = Array.isArray(bundledPackage?.visualGeoms) |
| ? bundledPackage.visualGeoms.map((entry) => ({ |
| ...entry, |
| rawSrc: String(entry?.src || '').trim(), |
| src: withAssetVersion(entry?.src), |
| })) |
| : []; |
| const meshBase64BySrc = {}; |
| if (bundledPackage && typeof bundledPackage.meshBase64BySrc === 'object' && bundledPackage.meshBase64BySrc) { |
| Object.assign(meshBase64BySrc, bundledPackage.meshBase64BySrc); |
| } |
| const meshChunkUrls = Array.isArray(bundledPackage?.meshChunkUrls) |
| ? bundledPackage.meshChunkUrls.map((src) => withAssetVersion(src)).filter(Boolean) |
| : []; |
| for (const chunkUrl of meshChunkUrls) { |
| const chunkPayload = await fetchJson(chunkUrl, 'force-cache'); |
| if (chunkPayload && typeof chunkPayload.meshBase64BySrc === 'object' && chunkPayload.meshBase64BySrc) { |
| Object.assign(meshBase64BySrc, chunkPayload.meshBase64BySrc); |
| } |
| } |
| const uniqueMeshUrls = [...new Set(visualGeoms.map((entry) => entry.src).filter(Boolean))]; |
| const meshBuffers = new Map(); |
| for (let index = 0; index < uniqueMeshUrls.length; index += 1) { |
| const meshUrl = uniqueMeshUrls[index]; |
| const visualEntry = visualGeoms.find((entry) => entry.src === meshUrl); |
| const rawSrc = String(visualEntry?.rawSrc || '').trim(); |
| const encodedMesh = typeof meshBase64BySrc[rawSrc] === 'string' ? meshBase64BySrc[rawSrc] : ''; |
| if (!encodedMesh) { |
| throw new Error('Robot model package is missing mesh payload for ' + rawSrc); |
| } |
| const meshBuffer = new Uint8Array(decodeBase64ToArrayBuffer(encodedMesh)); |
| meshBuffers.set(meshUrl, meshBuffer); |
| await loadGeometry(meshUrl, meshBuffer); |
| if ((index + 1) % 2 === 0) { |
| await nextFrameYield(); |
| } |
| } |
| |
| return { |
| key: metaSrc, |
| robotMeta: nextRobotMeta, |
| visualMeta: { |
| version: bundledPackage?.version || nextRobotMeta?.version || 1, |
| visualGeoms, |
| }, |
| xmlText, |
| visualGeoms, |
| meshBuffers, |
| }; |
| } catch (error) { |
| console.warn('[robot-viewer] Falling back to legacy G1 model chain.', error); |
| } |
| } |
| |
| const xmlUrl = withAssetVersion(nextRobotMeta.xmlUrl); |
| if (!xmlUrl) throw new Error('Robot meta is missing xmlUrl.'); |
| const xmlText = await fetchText(xmlUrl, 'force-cache'); |
| |
| let nextVisualMeta = nextRobotMeta; |
| if (!Array.isArray(nextRobotMeta.visualGeoms)) { |
| const visualMetaUrl = nextRobotMeta.modelMetaUrl; |
| if (!visualMetaUrl) throw new Error('Robot meta is missing modelMetaUrl.'); |
| nextVisualMeta = await fetchJson(withAssetVersion(visualMetaUrl), 'force-cache'); |
| } |
| |
| const visualGeoms = Array.isArray(nextVisualMeta.visualGeoms) |
| ? nextVisualMeta.visualGeoms.map((entry) => ({ |
| ...entry, |
| src: withAssetVersion(entry.src), |
| })) |
| : []; |
| const uniqueMeshUrls = [...new Set(visualGeoms.map((entry) => entry.src).filter(Boolean))]; |
| const meshBuffers = new Map(); |
| for (let index = 0; index < uniqueMeshUrls.length; index += 1) { |
| const meshUrl = uniqueMeshUrls[index]; |
| const meshBuffer = await fetchBuffer(meshUrl, 'force-cache'); |
| meshBuffers.set(meshUrl, meshBuffer); |
| await loadGeometry(meshUrl, meshBuffer); |
| if ((index + 1) % 2 === 0) { |
| await nextFrameYield(); |
| } |
| } |
| |
| return { |
| key: metaSrc, |
| robotMeta: nextRobotMeta, |
| visualMeta: nextVisualMeta, |
| xmlText, |
| visualGeoms, |
| meshBuffers, |
| }; |
| })(); |
| modelPackageCache.set(metaSrc, packagePromise); |
| try { |
| return await packagePromise; |
| } catch (error) { |
| modelPackageCache.delete(metaSrc); |
| throw error; |
| } |
| } |
| |
| async function loadMotionData(motionSrc) { |
| if (!motionSrc) throw new Error('Robot motion data is missing.'); |
| if (motionCache.has(motionSrc)) return motionCache.get(motionSrc); |
| const motionPromise = fetchJson(motionSrc, 'no-store').then((value) => stabilizeLeadingFrames(value)); |
| motionCache.set(motionSrc, motionPromise); |
| try { |
| return await motionPromise; |
| } catch (error) { |
| motionCache.delete(motionSrc); |
| throw error; |
| } |
| } |
| |
| async function ensureModelRuntime(metaSrc, packageData, loadToken) { |
| const runtime = await ensureMujoco(); |
| if (loadToken !== activeLoadToken) return; |
| if (currentModelPackageKey === metaSrc && model && data && renderables.length > 0) { |
| robotMeta = packageData.robotMeta; |
| visualMeta = packageData.visualMeta; |
| return; |
| } |
| |
| disposeRuntime(); |
| clearSceneMeshes(); |
| if (loadToken !== activeLoadToken) return; |
| |
| vfs = new runtime.MjVFS(); |
| for (const entry of packageData.visualGeoms) { |
| const meshUrl = String(entry.src || '').trim(); |
| if (!meshUrl) continue; |
| const meshBuffer = packageData.meshBuffers.get(meshUrl); |
| if (!meshBuffer) continue; |
| let meshFileName = ''; |
| try { |
| const meshUrlObject = new URL(meshUrl, window.location.origin); |
| meshFileName = meshUrlObject.pathname.split('/').pop() || ''; |
| } catch { |
| meshFileName = meshUrl.split('/').pop() || ''; |
| } |
| meshFileName = normalizeMeshAssetFileName(meshFileName, entry.meshName); |
| if (!meshFileName) continue; |
| vfs.addBuffer('meshes/' + meshFileName, meshBuffer); |
| } |
| if (loadToken !== activeLoadToken) return; |
| |
| setStatus('motion', 'Preparing robot model...'); |
| model = runtime.MjModel.from_xml_string(packageData.xmlText, vfs); |
| data = new runtime.MjData(model); |
| robotMeta = packageData.robotMeta; |
| visualMeta = packageData.visualMeta; |
| |
| for (let index = 0; index < packageData.visualGeoms.length; index += 1) { |
| const entry = packageData.visualGeoms[index]; |
| const geometry = await loadGeometry(entry.src, packageData.meshBuffers.get(entry.src) || null); |
| if (loadToken !== activeLoadToken) return; |
| const group = new THREE.Group(); |
| const mesh = new THREE.Mesh(geometry, getMaterial(entry.color)); |
| mesh.castShadow = true; |
| mesh.receiveShadow = true; |
| const meshOffsetQuat = new THREE.Quaternion(...(entry.meshOffsetQuatXyzw || [0, 0, 0, 1])).invert(); |
| const meshOffsetPos = new THREE.Vector3(...(entry.meshOffsetPos || [0, 0, 0])) |
| .applyQuaternion(meshOffsetQuat) |
| .multiplyScalar(-1); |
| mesh.position.copy(meshOffsetPos); |
| mesh.quaternion.copy(meshOffsetQuat); |
| mesh.scale.set(...(entry.scale || [1, 1, 1])); |
| group.add(mesh); |
| mujocoRoot.add(group); |
| renderables.push({ |
| geomIndex: Number(entry.geomIndex), |
| group, |
| }); |
| if ((index + 1) % 4 === 0) { |
| await nextFrameYield(); |
| if (loadToken !== activeLoadToken) return; |
| } |
| } |
| currentModelPackageKey = metaSrc; |
| } |
| |
| async function prepareModel(metaSrc, motionSrc, videoSrc, loadToken) { |
| setStatus('assets', 'Loading robot assets...'); |
| const [packageData, nextMotion] = await Promise.all([ |
| prewarmModelPackage(metaSrc), |
| loadMotionData(motionSrc), |
| ]); |
| if (loadToken !== activeLoadToken || !packageData) return; |
| motion = nextMotion; |
| frameCount = Number(motion.frameCount || 0); |
| fps = Number(motion.fps || 30); |
| rootQuatOrder = motion.rootQuatOrder || 'xyzw'; |
| qposLayout = { |
| rootPosStart: Number(motion.qposLayout?.rootPosStart ?? 0), |
| rootQuatStart: Number(motion.qposLayout?.rootQuatStart ?? 3), |
| dofStart: Number(motion.qposLayout?.dofStart ?? 7), |
| }; |
| |
| await ensureModelRuntime(metaSrc, packageData, loadToken); |
| if (loadToken !== activeLoadToken) return; |
| ensureVideoPlane(videoSrc); |
| |
| applyFrame(0, true); |
| fitPresentationToRobot(); |
| setStatus('ready', 'Robot preview is ready.'); |
| post('ready'); |
| } |
| |
| function setRootQuat(qpos, rootQuat) { |
| if (rootQuatOrder === 'xyzw') { |
| qpos[qposLayout.rootQuatStart + 0] = rootQuat[3]; |
| qpos[qposLayout.rootQuatStart + 1] = rootQuat[0]; |
| qpos[qposLayout.rootQuatStart + 2] = rootQuat[1]; |
| qpos[qposLayout.rootQuatStart + 3] = rootQuat[2]; |
| } else { |
| qpos[qposLayout.rootQuatStart + 0] = rootQuat[0]; |
| qpos[qposLayout.rootQuatStart + 1] = rootQuat[1]; |
| qpos[qposLayout.rootQuatStart + 2] = rootQuat[2]; |
| qpos[qposLayout.rootQuatStart + 3] = rootQuat[3]; |
| } |
| } |
| |
| function writeFrameToQpos(frameFloat) { |
| if (!data || !motion || !Array.isArray(motion.root_pos) || !Array.isArray(motion.root_rot_xyzw) || !Array.isArray(motion.dof_pos)) { |
| return false; |
| } |
| const safeFrame = Math.max(0, Math.min(frameCount - 1, frameFloat)); |
| const frame0 = Math.floor(safeFrame); |
| const frame1 = Math.min(frame0 + 1, frameCount - 1); |
| const alpha = Math.max(0, Math.min(1, safeFrame - frame0)); |
| const rootPos0 = motion.root_pos[frame0]; |
| const rootPos1 = motion.root_pos[frame1] || rootPos0; |
| const rootQuat0 = motion.root_rot_xyzw[frame0]; |
| const rootQuat1 = motion.root_rot_xyzw[frame1] || rootQuat0; |
| const dofPos0 = motion.dof_pos[frame0]; |
| const dofPos1 = motion.dof_pos[frame1] || dofPos0; |
| if (!rootPos0 || !rootQuat0 || !dofPos0) return false; |
| const qpos = data.qpos; |
| |
| qpos[qposLayout.rootPosStart + 0] = rootPos0[0] + (rootPos1[0] - rootPos0[0]) * alpha; |
| qpos[qposLayout.rootPosStart + 1] = rootPos0[1] + (rootPos1[1] - rootPos0[1]) * alpha; |
| qpos[qposLayout.rootPosStart + 2] = rootPos0[2] + (rootPos1[2] - rootPos0[2]) * alpha; |
| |
| tempQuaternion.set(rootQuat0[0], rootQuat0[1], rootQuat0[2], rootQuat0[3]); |
| const targetQuat = new THREE.Quaternion(rootQuat1[0], rootQuat1[1], rootQuat1[2], rootQuat1[3]); |
| tempQuaternion.slerp(targetQuat, alpha); |
| setRootQuat(qpos, [tempQuaternion.x, tempQuaternion.y, tempQuaternion.z, tempQuaternion.w]); |
| |
| for (let i = 0; i < dofPos0.length; i += 1) { |
| const nextValue = dofPos0[i] + ((dofPos1[i] ?? dofPos0[i]) - dofPos0[i]) * alpha; |
| qpos[qposLayout.dofStart + i] = nextValue; |
| } |
| mujoco.mj_forward(model, data); |
| return true; |
| } |
| |
| function applyFrame(frameFloat, force = false) { |
| if (!data || !model || !motion || !frameCount) return; |
| if (!force && Math.abs(frameFloat - builtFrame) < 0.001) return; |
| if (!writeFrameToQpos(frameFloat)) return; |
| |
| const geomXpos = data.geom_xpos; |
| const geomXmat = data.geom_xmat; |
| for (const entry of renderables) { |
| const posOffset = entry.geomIndex * 3; |
| const matOffset = entry.geomIndex * 9; |
| tempMatrix.set( |
| geomXmat[matOffset + 0], geomXmat[matOffset + 1], geomXmat[matOffset + 2], 0, |
| geomXmat[matOffset + 3], geomXmat[matOffset + 4], geomXmat[matOffset + 5], 0, |
| geomXmat[matOffset + 6], geomXmat[matOffset + 7], geomXmat[matOffset + 8], 0, |
| 0, 0, 0, 1 |
| ); |
| tempQuaternion.setFromRotationMatrix(tempMatrix); |
| entry.group.position.set( |
| geomXpos[posOffset + 0], |
| geomXpos[posOffset + 1], |
| geomXpos[posOffset + 2] |
| ); |
| entry.group.quaternion.copy(tempQuaternion); |
| } |
| |
| builtFrame = frameFloat; |
| } |
| |
| function syncPlayback(time, isPlaying) { |
| const duration = frameCount > 0 ? frameCount / Math.max(fps, 1) : 0; |
| const nextTime = getNormalizedPlaybackTime(Number(time || 0), duration); |
| const nextPlaying = !!isPlaying; |
| const drift = Math.abs(nextTime - currentPlaybackTime); |
| if (nextPlaying === externalPlaying) { |
| if (nextPlaying && drift < 0.08) { |
| return; |
| } |
| if (!nextPlaying && drift < 0.002) { |
| return; |
| } |
| } |
| currentPlaybackTime = nextTime; |
| playbackAnchorTime = nextTime; |
| playbackAnchorPerf = performance.now(); |
| externalPlaying = nextPlaying; |
| if (videoElement) { |
| const drift = Math.abs((videoElement.currentTime || 0) - nextTime); |
| if (drift > 0.08) { |
| try { |
| videoElement.currentTime = nextTime; |
| } catch {} |
| } |
| if (nextPlaying) { |
| const playPromise = videoElement.play?.(); |
| if (playPromise?.catch) playPromise.catch(() => {}); |
| } else { |
| try { |
| videoElement.pause?.(); |
| } catch {} |
| } |
| } |
| } |
| |
| async function handleLoadRequest(motionSrc, metaSrc, videoSrc) { |
| activeLoadToken += 1; |
| await prepareModel(metaSrc, motionSrc, videoSrc, activeLoadToken); |
| } |
| |
| async function handlePrewarmRequest(modelSrcs) { |
| const uniqueModelSrcs = [...new Set((Array.isArray(modelSrcs) ? modelSrcs : []).filter(Boolean))]; |
| if (uniqueModelSrcs.length === 0) return; |
| await Promise.allSettled(uniqueModelSrcs.map((modelSrc) => prewarmModelPackage(modelSrc))); |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| if (data && frameCount > 0) { |
| const duration = frameCount / Math.max(fps, 1); |
| if (externalPlaying) { |
| currentPlaybackTime = playbackAnchorTime + (performance.now() - playbackAnchorPerf) / 1000; |
| } |
| if (externalPlaying && duration > 0 && currentPlaybackTime >= duration) { |
| currentPlaybackTime = getTerminalPlaybackTime(duration); |
| } |
| const nextFrame = currentPlaybackTime * fps; |
| applyFrame(nextFrame); |
| } |
| controls.update(); |
| renderer.render(scene, camera); |
| } |
| animate(); |
| |
| window.addEventListener('message', async (event) => { |
| const payload = event.data || {}; |
| if (payload.viewerNonce && payload.viewerNonce !== viewerNonce) { |
| return; |
| } |
| try { |
| if (payload.type === 'robot-load' && payload.src && payload.modelSrc) { |
| await handleLoadRequest(payload.src, payload.modelSrc, payload.videoSrc || ''); |
| } else if (payload.type === 'robot-prewarm' && Array.isArray(payload.modelSrcs)) { |
| await handlePrewarmRequest(payload.modelSrcs); |
| } else if (payload.type === 'robot-sync') { |
| syncedTime = Number(payload.time || 0); |
| syncPlayback(syncedTime, !!payload.playing); |
| } else if (payload.type === 'robot-recording-config') { |
| if (Number.isFinite(payload.width) && Number.isFinite(payload.height) && payload.width > 0 && payload.height > 0) { |
| forcedRenderSize = { width: Number(payload.width), height: Number(payload.height) }; |
| } else { |
| forcedRenderSize = null; |
| } |
| resizeRenderer(); |
| } |
| } catch (error) { |
| console.error(error); |
| setError(error); |
| } |
| }); |
| |
| window.addEventListener('resize', resizeRenderer); |
| |
| post('boot'); |
| |
| const params = new URLSearchParams(window.location.search); |
| const directMotionSrc = params.get('src'); |
| const directModelSrc = params.get('modelSrc'); |
| const directVideoSrc = params.get('videoSrc') || ''; |
| const directTime = Number(params.get('time') || 0); |
| const directPlaying = params.get('playing') === '1' || params.get('playing') === 'true'; |
| if (directMotionSrc && directModelSrc) { |
| syncPlayback(directTime, directPlaying); |
| try { |
| await handleLoadRequest(directMotionSrc, directModelSrc, directVideoSrc); |
| } catch (error) { |
| console.error(error); |
| setError(error); |
| } |
| } |
| </script> |
| </body> |
| </html> |
|
|