Spaces:
Running
Running
Download motionSample/index.html from faradayfuture/SampleViewer: direct link, hf CLI and curl.
- Browser
- Download file 22.8 kB
-
https://huggingface.co/spaces/faradayfuture/SampleViewer/resolve/main/motionSample/index.html
- Command line
-
hf download hf://spaces/faradayfuture/SampleViewer/motionSample/index.html
-
curl -L -o index.html https://huggingface.co/spaces/faradayfuture/SampleViewer/resolve/main/motionSample/index.html
22.8 kB
| <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">← 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 — PHC-style pickle · 2 files · 2759 frames · 120 Hz · 31 DoF · 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> | |