File size: 22,803 Bytes
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36b8b48
 
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e3eeaa2
f94d993
 
 
36b8b48
f94d993
 
 
 
36b8b48
 
 
9c299e7
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1b4bc33
 
 
 
cc218ce
 
 
36b8b48
 
 
 
 
cc218ce
36b8b48
 
 
cc218ce
36b8b48
 
 
 
 
cc218ce
1b4bc33
 
36b8b48
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc218ce
 
 
 
 
 
 
 
 
f94d993
 
cc218ce
 
 
 
 
 
 
 
 
 
f94d993
1b4bc33
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc218ce
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc218ce
36b8b48
 
 
 
 
 
cc218ce
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cc218ce
 
 
f94d993
cc218ce
 
 
 
 
 
 
 
 
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36b8b48
 
f94d993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36b8b48
 
 
 
 
f94d993
cc218ce
f94d993
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
<!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>