SampleViewer / motionSample /index.html
Darren Zhang
Motion Sample: drop 'not dynamically validated' from the footer
e3eeaa2
Raw History Blame Contribute Delete
22.8 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Master Motion Sample</title>
<style>
/* palette matches faradayfuture/SampleViewer */
:root{color-scheme:dark;
--bg:#0b0d12;--panel:#12151c;--line:#232733;--fg:#e6e8ee;--dim:#9aa1ad;
--chip:#171b24;--btn:#1b2740;--btnfg:#cfe0ff;--btnline:#2b3654;--lbl:#9fe0ff}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);overflow:hidden;
font:14px/1.5 -apple-system,"Helvetica Neue",Arial,sans-serif}
#top{height:56px;display:flex;align-items:center;gap:16px;padding:0 22px;
border-bottom:1px solid var(--line)}
#top h1{font-size:18px;margin:0;white-space:nowrap}
#top .back{color:#6f92c8;text-decoration:none;font-size:13px;white-space:nowrap}
#top .back:hover{color:#9fc4ff}
#top .sub{color:var(--dim);font-size:12px;white-space:nowrap}
#main{display:flex;height:calc(100% - 56px - 126px)}
#view{flex:1 1 56%;position:relative;min-width:0;
background:radial-gradient(120% 100% at 22% 18%,#1b2430 0%,#141a24 42%,#0b0d12 100%)}
#right{flex:1 1 44%;display:flex;min-width:0;border-left:1px solid var(--line)}
#plotWrap{flex:1;position:relative;min-width:0}
#plot{display:block;width:100%;height:100%;cursor:crosshair}
#legend{width:212px;flex:0 0 212px;overflow:auto;padding:8px 10px;
border-left:1px solid var(--line);background:var(--panel)}
.lg{display:flex;align-items:center;gap:7px;font:11px ui-monospace,monospace;
color:var(--dim);padding:1px 0;cursor:pointer;white-space:nowrap}
.lg:hover{color:var(--fg)}
.lg.off{opacity:.3}
.lg.fixed{opacity:.35}
.lg .tag{color:#6f92c8;font-size:10px;margin-left:4px}
.lg i{width:9px;height:9px;border-radius:50%;flex:0 0 9px}
#bot{height:126px;padding:10px 22px;border-top:1px solid var(--line);
display:flex;flex-direction:column;gap:8px;justify-content:center}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
button{background:var(--btn);color:var(--btnfg);border:1px solid var(--btnline);
border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer}
button:hover{border-color:#3a4a6a}
button.on{background:#24406e;border-color:#4f8ff7;color:#fff}
select{background:var(--chip);color:var(--fg);border:1px solid var(--line);
border-radius:5px;padding:4px 6px;font-size:12.5px}
#scrub{flex:1;min-width:160px}
.hint,.stat{color:var(--dim);font-size:12px}
#dl a{color:#7fc4ff;font-size:12px;text-decoration:none;
border:1px solid var(--btnline);border-radius:5px;padding:3px 9px}
#dl a:hover{background:var(--btn);border-color:#4f8ff7}
.stat b{color:var(--fg);font-weight:600;font-family:ui-monospace,monospace}
#hud{position:absolute;left:16px;top:14px;font:11px ui-monospace,monospace;
color:var(--lbl);text-shadow:0 1px 2px #000;pointer-events:none;line-height:1.7;
white-space:pre}
#tip{position:absolute;left:16px;bottom:12px;color:var(--dim);font-size:12px;pointer-events:none}
#load{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);color:var(--dim);
font:13px/1.6 ui-monospace,monospace;pointer-events:none;z-index:5}
#err{position:fixed;inset:0;display:none;place-content:center;padding:40px;background:var(--bg);
font-family:ui-monospace,monospace;color:#ff8f8f;white-space:pre-wrap;z-index:9}
</style>
</head>
<body>
<div id="top">
<a class="back" href="../index.html">&larr; all datasets</a>
<h1>Master Motion Sample</h1>
<span class="sub">FF Master · 31 DoF · joint-space motion reference</span>
<span style="margin-left:auto"><span id="clips"></span></span>
</div>
<div id="main">
<div id="view"><div id="hud"></div><div id="tip">drag to orbit · scroll to zoom · right-drag to pan</div></div>
<div id="right">
<div id="plotWrap"><canvas id="plot"></canvas></div>
<div id="legend"></div>
</div>
</div>
<div id="bot">
<div class="row">
<button id="play">⏸ Pause</button>
<span class="hint">speed</span>
<select id="speed"><option>0.25</option><option>0.5</option><option selected>1</option><option>2</option></select>
<input type="range" id="scrub" min="0" max="100" value="0">
<span class="stat" id="tinfo"></span>
</div>
<div class="row">
<button id="bMesh" class="on">Mesh</button>
<button id="bAxes" class="on">Joint frames</button>
<button id="bStatic">Show fixed joints</button>
<button id="bGrid" class="on">Ground</button>
<button id="bTrail" class="on">Path</button>
<button id="bFollow" class="on">Follow</button>
<button id="bClamp" class="on">Ground clamp</button>
<button id="bReset">Reset view</button>
<span class="stat" id="stat"></span>
</div>
<div class="row" id="dl">
<span class="hint">Dataset &mdash; PHC-style pickle &middot; 2 files &middot; 2759 frames &middot; 120 Hz &middot; 31 DoF &middot; kinematic reference</span>
</div>
</div>
<div id="err"></div>
<div id="load">Loading…</div>
<script src="three.min.js"></script>
<script src="OrbitControls.js"></script>
<script>
// 注意: <div id="load"> 会在 window 上建一个同名全局, 所以这个 helper 必须真的定义,
// 否则 load(...) 拿到的是那个 DOM 元素, 报 "load is not a function"。
const load=m=>{const e=document.getElementById('load');if(e)e.textContent=m};
// 数据下载, 指向 HF 上的 motionSample 数据集。
const DL_BASE='https://huggingface.co/datasets/faradayfuture/motionSample/resolve/main/';
const DL_REPO='https://huggingface.co/datasets/faradayfuture/motionSample';
for(const[label,href]of[
['Dataset home', DL_REPO],
['jogging.pkl', DL_BASE+'jogging.pkl'],
['highjump.pkl', DL_BASE+'highjump.pkl'],
['README', DL_BASE+'README.md'],
['validate.py', DL_BASE+'validate.py']]){
const a=document.createElement('a');
a.href=href; a.textContent=label; a.target='_blank'; a.rel='noopener';
document.getElementById('dl').appendChild(a);
}
const fail=m=>{const e=document.getElementById('err');e.style.display='grid';e.textContent=m;console.error(m)};
(async function(){
try{
const scene_=await (await fetch('scene.json')).json();
// 网格是最大的一块(未压缩 12.6MB), gzip 预压缩后 4.4MB —— HF static Space 不给二进制
// 自动 gzip, 所以自己压。扩展名不用 .gz: 那样有些服务器会加 Content-Encoding: gzip,
// 浏览器已经解过一遍, 这里再解就双重解压; 所以还按 gzip magic (1f 8b) 兜底判断。
// 曾经做过"先低模再后台换高模"的渐进加载, 但开头那几秒面数明显不足很难看, 已去掉。
async function getPack(url, onProgress){
const r=await fetch(url);
if(!r.ok) throw new Error(url+' '+r.status);
const total=+(r.headers.get('content-length')||0);
const rd=r.body.getReader(); const parts=[]; let got=0;
for(;;){
const {done,value}=await rd.read(); if(done)break;
parts.push(value); got+=value.byteLength;
if(onProgress)onProgress(got,total);
}
const buf=new Uint8Array(got); let o=0;
for(const p of parts){buf.set(p,o); o+=p.byteLength;}
if(!(buf[0]===0x1f && buf[1]===0x8b)) return buf.buffer;
if(typeof DecompressionStream==='undefined')
throw new Error('This browser lacks DecompressionStream (needs Chrome 80+ / Safari 16.4+ / Firefox 113+).');
const ds=new DecompressionStream('gzip');
const w=ds.writable.getWriter(); w.write(buf); w.close();
return new Response(ds.readable).arrayBuffer();
}
const mbuf=await getPack('meshes.pack',(g,t)=>
load(`Loading mesh… ${(g/1048576).toFixed(1)}${t?' / '+(t/1048576).toFixed(1):''} MB`));
load('Building geometry…');
const abuf=await (await fetch('motion.bin')).arrayBuffer();
const Q=32767;
// ── 3D scene ─────────────────────────────────────────────────────────
const view=document.getElementById('view');
const renderer=new THREE.WebGLRenderer({antialias:true,alpha:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
view.appendChild(renderer.domElement);
const scene=new THREE.Scene(); // 背景交给 CSS 渐变(见 #view)
const camera=new THREE.PerspectiveCamera(38,1,0.01,100);
camera.up.set(0,0,1); camera.position.set(1.55,-1.95,1.15);
const ctrl=new THREE.OrbitControls(camera,renderer.domElement);
ctrl.target.set(0,0,0.62); ctrl.enableDamping=true; ctrl.dampingFactor=0.08;
ctrl.minDistance=0.4; ctrl.maxDistance=12;
scene.add(new THREE.HemisphereLight(0xbcd4ff,0x20242c,0.85));
// 基准方向是按 jogging(朝向 ~0deg) 调的。灯在世界坐标固定, 所以朝向不同的段明暗会
// 不一样(highjump 朝 +y, 之前偏白)。换段时把灯整体绕 z 转到该段朝向。
const L1=[2,-3,4], L2=[-3,2,1];
const d1=new THREE.DirectionalLight(0xffffff,0.75); d1.position.set(L1[0],L1[1],L1[2]); scene.add(d1);
const d2=new THREE.DirectionalLight(0x88aaff,0.35); d2.position.set(L2[0],L2[1],L2[2]); scene.add(d2);
function orientLights(psi){
const c=Math.cos(psi),s2=Math.sin(psi);
for(const[l,b]of[[d1,L1],[d2,L2]]) l.position.set(b[0]*c-b[1]*s2,b[0]*s2+b[1]*c,b[2]);
}
const grid=new THREE.GridHelper(8,32,0x2a3140,0x1c2230); grid.rotation.x=Math.PI/2; scene.add(grid);
function buildGeo(buf,index){
const out={};
for(const[name,mi]of Object.entries(index)){
const q=new Int16Array(buf,mi.off,mi.n*3), pos=new Float32Array(mi.n*3);
for(let i=0;i<mi.n;i++)for(let k=0;k<3;k++)pos[i*3+k]=mi.lo[k]+(q[i*3+k]+Q)*mi.sc[k];
const g=new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(pos,3));
g.computeVertexNormals(); out[name]=g;
}
return out;
}
const meshGeo=buildGeo(mbuf, scene_.meshes);
const meshGroup=new THREE.Group(); scene.add(meshGroup);
const objs={}, jointNodes={}, axesList=[], axesByJoint={};
const mk=n=>objs[n]||(objs[n]=new THREE.Group());
const rootObj=mk(scene_.root); meshGroup.add(rootObj);
for(const j of scene_.joints){
const h=new THREE.Group();
h.position.set(...j.xyz); h.rotation.set(j.rpy[0],j.rpy[1],j.rpy[2],'XYZ');
mk(j.parent).add(h); h.add(mk(j.child));
if(j.type==='revolute'||j.type==='continuous'){
jointNodes[j.name]={obj:objs[j.child],axis:new THREE.Vector3(...j.axis).normalize()};
// RGB triad per joint (like the reference viewer); rides the FK transform
const AX=[[1,0,0,0xff4d4d],[0,1,0,0x4dff88],[0,0,1,0x4d8bff]];
const grp=new THREE.Group();
for(const[x,y,z,col]of AX){
const a=new THREE.ArrowHelper(new THREE.Vector3(x,y,z),new THREE.Vector3(),
0.062,col,0.022,0.013);
// 与参考一致: 保留深度测试, 指向机身内部的箭头会被遮挡, 画面不会糊成一团
grp.add(a);
}
objs[j.child].add(grp); axesList.push(grp); axesByJoint[j.name]=grp;
}
}
for(const[ln,L]of Object.entries(scene_.links)){
const o=mk(ln);
for(const m of L.meshes){
if(!meshGeo[m.mesh])continue;
// URDF 自带 visual 材质色: 机身白 / 髋踝 pitch 深灰 / 脚掌橙
const rgba=m.rgba||[0.87,0.90,0.93,1];
const me=new THREE.Mesh(meshGeo[m.mesh],
new THREE.MeshStandardMaterial({
color:new THREE.Color(rgba[0],rgba[1],rgba[2]),
metalness:.25,roughness:.6}));
me.position.set(...m.xyz); me.rotation.set(m.rpy[0],m.rpy[1],m.rpy[2],'XYZ');
if(m.scale)me.scale.set(...m.scale);
o.add(me);
}
}
const trailPos=new Float32Array(3*4000); let trailN=0;
const trailGeo=new THREE.BufferGeometry();
trailGeo.setAttribute('position',new THREE.BufferAttribute(trailPos,3));
const trail=new THREE.Line(trailGeo,new THREE.LineBasicMaterial({color:0x39506e}));
scene.add(trail);
// ── joint colours + legend ───────────────────────────────────────────
const DOF=scene_.dof;
const colOf=i=>`hsl(${(i*137.508)%360} 72% 62%)`; // golden-angle spread
const shown=new Set(DOF);
const lg=document.getElementById('legend');
DOF.forEach((n,i)=>{
const d=document.createElement('div'); d.className='lg'; d.dataset.n=n;
d.innerHTML=`<i style="background:${colOf(i)}"></i>${n.replace(/_joint$/,'')}<span class="tag"></span>`;
d.onclick=()=>{ shown.has(n)?shown.delete(n):shown.add(n);
d.classList.toggle('off',!shown.has(n)); drawPlot(); };
lg.appendChild(d);
});
let hideStatic=true;
function applyStatic(){
const st=n=>hideStatic&&cur&&cur.static.has(n);
for(const[n,g]of Object.entries(axesByJoint))
g.visible=showAxes && !st(n);
document.querySelectorAll('#legend .lg').forEach(d=>{
const n=d.dataset.n, st=cur&&cur.static.has(n);
d.classList.toggle('fixed',!!st);
const tag=d.querySelector('.tag');
if(tag) tag.textContent = st?' fixed':'';
if(st) shown.delete(n);
});
drawPlot();
}
// ── motion state ─────────────────────────────────────────────────────
const clips=scene_.clips;
let cur=null,frame=0,playing=true,speed=1,follow=true,acc=0,last=performance.now();
let clampGround=true, showAxes=true, lastPel=null;
function loadClip(k){
const c=clips[k];
cur={key:k,...c,
ang:new Int16Array(abuf,c.angOff,c.n*c.ndof),
root:new Float32Array(abuf,c.rootOff,c.n*7),
minz:new Float32Array(abuf,c.minzOff,c.n)};
// 本段里幅度 <0.01 rad 的关节视为恒定, 默认不画坐标轴也不画曲线;
// 本段压根没有的关节(onboard log 没有 head_*)同样算 fixed
cur.static=new Set();
for(let k=0;k<c.ndof;k++){
let lo=1e9,hi=-1e9;
for(let i=0;i<c.n;i++){const v=cur.ang[i*c.ndof+k]; if(v<lo)lo=v; if(v>hi)hi=v;}
if((hi-lo)/Q*Math.PI<0.01) cur.static.add(c.dof[k]);
}
// 图例/曲线的配色按全局 DOF 下标取, 各段 dof 长度不同时不能直接用段内下标
cur.colIdx=c.dof.map(n=>DOF.indexOf(n));
// 本段没有的关节: 归零并当 fixed, 否则会留着上一段的角度
const has=new Set(c.dof);
for(const[n,j]of Object.entries(jointNodes))
if(!has.has(n)){ j.obj.quaternion.identity(); cur.static.add(n); }
applyStatic();
orientLights(c.heading||0);
// 每段有自己的默认机位(朝向的右前方), 换段时重置, 否则 highjump 会从后背看
const co=c.camOff||[1.55,-1.95,1.15];
apply(0);
const pv=new THREE.Vector3(); (objs['pelvis']||rootObj).getWorldPosition(pv);
ctrl.target.set(pv.x,pv.y,0.6);
camera.position.set(pv.x+co[0],pv.y+co[1],co[2]);
frame=0; trailN=0; lastPel=null;
document.getElementById('scrub').max=c.n-1;
document.querySelectorAll('#clips button').forEach(b=>b.classList.toggle('on',b.dataset.k===k));
layoutPlot(); drawPlot();
}
function apply(i){
const c=cur; i=Math.max(0,Math.min(c.n-1,i|0));
for(let k=0;k<c.ndof;k++){
const j=jointNodes[c.dof[k]]; if(!j)continue;
j.obj.quaternion.setFromAxisAngle(j.axis,c.ang[i*c.ndof+k]/Q*Math.PI);
}
const r=c.root,o=i*7, raw=c.minz[i], fix=clampGround?Math.max(0,-raw):0;
rootObj.position.set(r[o],r[o+1],r[o+2]+fix);
rootObj.quaternion.set(r[o+3],r[o+4],r[o+5],r[o+6]);
rootObj.updateMatrixWorld(true);
const v=new THREE.Vector3();
const pel=objs['pelvis']||rootObj; pel.getWorldPosition(v);
if(trailN<4000){trailPos[trailN*3]=v.x;trailPos[trailN*3+1]=v.y;trailPos[trailN*3+2]=0;trailN++;}
trailGeo.setDrawRange(0,trailN);
const pa=trailGeo.attributes.position; // 只上传新增那一个点, 别每帧重传 4000*3 个 float
pa.updateRange.offset=Math.max(0,(trailN-1)*3); pa.updateRange.count=3; pa.needsUpdate=true;
if(follow){
// 按骨盆逐帧位移整体平移机位。原来是朝目标 lerp, 会和 OrbitControls 的阻尼互相
// 拉扯, jogging 以 ~2m/s 平移时表现为持续抖动。
if(lastPel){
const dx=v.x-lastPel.x, dy=v.y-lastPel.y;
ctrl.target.x+=dx; ctrl.target.y+=dy;
camera.position.x+=dx; camera.position.y+=dy;
}
lastPel={x:v.x,y:v.y};
} else lastPel=null;
document.getElementById('hud').textContent=
`${cur.title}\nframe ${i} / ${c.n-1} t = ${(i/c.fps).toFixed(2)} s\n`+
`${c.ndof-cur.static.size} / ${c.ndof} joints active`;
document.getElementById('tinfo').innerHTML=
`<b>${(i/c.fps).toFixed(2)}</b> / ${((c.n-1)/c.fps).toFixed(2)} s · source ${c.src_frames} frames @ ${c.src_fps} Hz`;
document.getElementById('stat').innerHTML=
`pelvis <b>${v.z.toFixed(3)} m</b> · lowest surface <b>${raw.toFixed(3)} m</b>`+
(fix>0?` · clamp <b>+${fix.toFixed(3)} m</b>`:'');
document.getElementById('scrub').value=i;
drawCursor();
}
// ── joint-angle plot ─────────────────────────────────────────────────
const cvs=document.getElementById('plot'), ctx=cvs.getContext('2d');
let PW=0,PH=0,base=null,yLo=-1,yHi=1;
const PAD={l:48,r:12,t:14,b:26};
function layoutPlot(){
const w=cvs.parentElement.clientWidth,h=cvs.parentElement.clientHeight;
const dpr=Math.min(devicePixelRatio,2);
cvs.width=Math.max(1,w*dpr); cvs.height=Math.max(1,h*dpr); PW=w; PH=h;
ctx.setTransform(dpr,0,0,dpr,0,0);
}
function niceStep(range){
const raw=range/6, p=Math.pow(10,Math.floor(Math.log10(raw||1))), m=(raw||1)/p;
return (m<1.5?1:m<3?2:m<7?5:10)*p;
}
function drawPlot(){
if(!cur||PW<10||PH<10)return;
const c=cur,n=c.n,x0=PAD.l,x1=PW-PAD.r,y0=PAD.t,y1=PH-PAD.b;
let lo=1e9,hi=-1e9;
for(let k=0;k<c.ndof;k++){ if(!shown.has(c.dof[k]))continue;
for(let i=0;i<n;i+=2){const v=c.ang[i*c.ndof+k]/Q*Math.PI; if(v<lo)lo=v; if(v>hi)hi=v;} }
if(lo>hi){lo=-1;hi=1;}
const pad=(hi-lo)*0.06||0.1; yLo=lo-pad; yHi=hi+pad;
const Y=v=>y1-(v-yLo)/(yHi-yLo)*(y1-y0), X=i=>x0+i/(n-1)*(x1-x0);
ctx.clearRect(0,0,PW,PH);
ctx.lineWidth=1; ctx.font='10px ui-monospace,monospace';
ctx.textAlign='right'; ctx.textBaseline='middle';
const step=niceStep(yHi-yLo), dec=Math.max(0,-Math.floor(Math.log10(step)));
for(let v=Math.ceil(yLo/step)*step;v<=yHi;v+=step){
const y=Y(v); ctx.strokeStyle='#1c2230';
ctx.beginPath(); ctx.moveTo(x0,y); ctx.lineTo(x1,y); ctx.stroke();
ctx.fillStyle='#9aa1ad'; ctx.fillText(v.toFixed(dec),x0-6,y);
}
ctx.textAlign='center'; ctx.textBaseline='top';
const fstep=Math.max(50,Math.round(n/6/50)*50);
for(let i=0;i<n;i+=fstep){
const x=X(i); ctx.strokeStyle='#161c27';
ctx.beginPath(); ctx.moveTo(x,y0); ctx.lineTo(x,y1); ctx.stroke();
ctx.fillStyle='#9aa1ad'; ctx.fillText('#'+i,x,y1+5);
}
ctx.save(); ctx.translate(13,(y0+y1)/2); ctx.rotate(-Math.PI/2);
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#9aa1ad';
ctx.fillText('joint angle (rad)',0,0); ctx.restore();
const stride=Math.max(1,Math.ceil(n/Math.max(1,(x1-x0))/1.2));
for(let k=0;k<c.ndof;k++){
if(!shown.has(c.dof[k]))continue;
ctx.strokeStyle=colOf(cur.colIdx[k]); ctx.beginPath();
let first=true;
for(let i=0;i<n;i+=stride){
const x=X(i),y=Y(c.ang[i*c.ndof+k]/Q*Math.PI);
first?(ctx.moveTo(x,y),first=false):ctx.lineTo(x,y);
}
ctx.stroke();
}
base=ctx.getImageData(0,0,cvs.width,cvs.height);
drawCursor();
}
function drawCursor(){
if(!base||!cur)return;
ctx.putImageData(base,0,0);
const x0=PAD.l,x1=PW-PAD.r,y0=PAD.t,y1=PH-PAD.b;
const x=x0+frame/(cur.n-1)*(x1-x0);
ctx.strokeStyle='#9fe0ff'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(x,y0); ctx.lineTo(x,y1); ctx.stroke();
}
// ── UI ───────────────────────────────────────────────────────────────
const cd=document.getElementById('clips');
for(const k in clips){
const b=document.createElement('button'); b.dataset.k=k; b.textContent=clips[k].title;
b.style.marginLeft='6px'; b.onclick=()=>{loadClip(k);apply(0)}; cd.appendChild(b);
}
document.getElementById('play').onclick=e=>{playing=!playing;
e.target.textContent=playing?'⏸ Pause':'▶ Play'};
document.getElementById('speed').onchange=e=>speed=parseFloat(e.target.value);
document.getElementById('scrub').oninput=e=>{playing=false;
document.getElementById('play').textContent='▶ Play';frame=+e.target.value;apply(frame)};
const tg=(id,fn)=>document.getElementById(id).onclick=e=>{e.target.classList.toggle('on');
fn(e.target.classList.contains('on'))};
tg('bMesh',v=>meshGroup.visible=v);
tg('bAxes',v=>{showAxes=v;applyStatic();});
tg('bGrid',v=>grid.visible=v);
tg('bTrail',v=>trail.visible=v);
tg('bFollow',v=>follow=v);
tg('bClamp',v=>{clampGround=v;apply(frame)});
tg('bStatic',v=>{hideStatic=!v;applyStatic();});
document.getElementById('bReset').onclick=()=>{
const v=new THREE.Vector3(); (objs['pelvis']||rootObj).getWorldPosition(v);
const co=(cur&&cur.camOff)||[1.55,-1.95,1.15];
ctrl.target.set(v.x,v.y,0.6); camera.position.set(v.x+co[0],v.y+co[1],co[2]);
};
cvs.addEventListener('pointerdown',e=>{
if(!cur)return;
const r=cvs.getBoundingClientRect(),x0=PAD.l,x1=PW-PAD.r;
const f=Math.round((e.clientX-r.left-x0)/(x1-x0)*(cur.n-1));
playing=false; document.getElementById('play').textContent='▶ Play';
frame=Math.max(0,Math.min(cur.n-1,f)); apply(frame);
});
function resize(){
const w=view.clientWidth,h=view.clientHeight;
renderer.setSize(w,h); camera.aspect=w/h; camera.updateProjectionMatrix();
layoutPlot(); drawPlot();
}
addEventListener('resize',resize);
loadClip('jogging'); resize(); apply(0);
(function loop(){
requestAnimationFrame(loop);
const now=performance.now(),dt=(now-last)/1000; last=now;
if(playing&&cur){acc+=dt*speed*cur.fps;
if(acc>=1){frame=(frame+Math.floor(acc))%cur.n;acc%=1;apply(frame);}}
ctrl.update(); renderer.render(scene,camera);
})();
window.__dbg={axesByJoint,jointNodes,cur:()=>cur,
setCam:(x,y,z)=>{const v=new THREE.Vector3();(objs['pelvis']||rootObj).getWorldPosition(v);
ctrl.target.set(v.x,v.y,0.6); camera.position.set(v.x+x,v.y+y,z); ctrl.update();},
pause:()=>{playing=false; document.getElementById('play').textContent='\u25b6 Play';}};
document.getElementById('load').remove();
window.__ready=true;
}catch(e){fail('load failed: '+(e&&e.stack||e));}
})();
</script>
</body>
</html>