SampleViewer / motionSample /index.html
Darren Zhang
Motion Sample: drop 'not dynamically validated' from the footer
e3eeaa2
Raw History Blame
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>