File size: 39,233 Bytes
c8f934b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e86b3c
c8f934b
 
 
 
 
 
 
 
 
 
 
 
 
d01ec1a
 
 
 
 
 
 
 
 
 
c8f934b
 
d01ec1a
3e86b3c
c8f934b
d01ec1a
 
 
 
c22fc81
d01ec1a
 
 
 
 
 
 
 
 
c22fc81
d01ec1a
 
 
 
 
 
c22fc81
c8f934b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d01ec1a
c8f934b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c22fc81
 
 
 
c8f934b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edf5d89
c22fc81
edf5d89
d01ec1a
edf5d89
 
 
 
d01ec1a
c22fc81
 
c8f934b
 
 
 
 
 
 
edf5d89
c22fc81
c8f934b
c22fc81
c8f934b
 
 
 
 
c22fc81
c8f934b
 
 
 
 
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
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Faber U Teleop Viewer</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:#07090d;color:#e6e8ee;font:14px/1.5 -apple-system,"Helvetica Neue",Arial,sans-serif}
a{color:#6f92c8}
.main{height:100vh;overflow-y:auto}
header{padding:14px 20px 10px}
header h1{margin:0 0 3px;font-size:19px}
.sub{color:#8890a0;font-size:12.5px}.sub code{color:#c7ccd6;background:#12151c;padding:1px 5px;border-radius:4px}
.badges{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}
.bdg{background:#12151c;border:1px solid #1c2028;border-radius:999px;padding:2px 9px;font-size:11px;color:#9aa1ad}
.bdg b{color:#e6e8ee}
.bdg.tag{color:#07090d;background:#6f9fe0;border-color:#6f9fe0;font-weight:700}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:14px;padding:0 20px}
@media(max-width:1360px){.layout{grid-template-columns:1fr}}
.panel{background:#0e1118;border:1px solid #1c2028;border-radius:8px}
.vgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;padding:8px}
@media(max-width:700px){.vgrid{grid-template-columns:1fr}}
.vp{position:relative;background:#000;border-radius:4px;overflow:hidden;aspect-ratio:5/4;min-width:0}
.vp.big{grid-column:span 2;grid-row:span 2}
.vp video{width:100%;height:100%;object-fit:contain;display:block}
.vp .lbl{position:absolute;top:5px;left:6px;font:10.5px ui-monospace,monospace;color:#bcd2e6;background:rgba(0,0,0,.5);padding:1px 5px;border-radius:3px;pointer-events:none}
.vp .rd{position:absolute;bottom:5px;right:6px;font:11px ui-monospace,monospace;color:#fff;background:rgba(0,0,0,.6);padding:1px 6px;border-radius:3px;pointer-events:none}
.three{position:relative;height:clamp(260px,38vh,420px);margin:0 8px 8px;background:#0b0d12;border:1px solid #1c2028;border-radius:6px;overflow:hidden}
.three canvas{display:block}
.three .lbl{position:absolute;top:6px;left:9px;font:12px ui-monospace,monospace;color:#9aa1ad;pointer-events:none;z-index:1}
.three .hint{position:absolute;bottom:6px;right:9px;font-size:10px;color:#556;pointer-events:none;z-index:1}
.chk{display:flex;flex-wrap:wrap;gap:4px 14px;padding:0 12px 8px;font-size:11.5px;color:#9aa1ad}
.chk label{cursor:pointer;display:flex;gap:4px;align-items:center}
.spans-head{padding:12px 14px 8px;font-size:12px;letter-spacing:.04em;color:#9aa1ad;text-transform:uppercase}
.spans-head b{color:#6f9fe0}
.tl{height:9px;border-radius:4px;margin:0 14px 6px;display:flex;overflow:hidden;cursor:pointer;background:#12151c}
.tl i{height:100%}
.chips{display:flex;flex-wrap:wrap;gap:5px;padding:0 14px 10px}
.chip{min-width:24px;height:21px;padding:0 5px;display:flex;align-items:center;justify-content:center;border-radius:5px;font-size:10.5px;font-weight:700;cursor:pointer;color:#07090d}
.chip.on{outline:2px solid #fff}
.slist{max-height:560px;overflow-y:auto}
.si{border-left:3px solid transparent;border-radius:4px;padding:7px 10px;margin:0 10px 5px;cursor:pointer;background:#12151c}
.si:hover{background:#171b24}.si.on{background:#182339;border-left-color:#6f9fe0}
.si .n{display:inline-flex;min-width:17px;height:17px;padding:0 3px;border-radius:9px;color:#07090d;font-size:10px;font-weight:700;align-items:center;justify-content:center;margin-right:6px}
.si .k{font-size:13px}.si .m{font-size:10.5px;color:#6f92c8;font-family:ui-monospace,monospace;margin-top:2px}
.si .lb{font-size:11px;color:#8890a0}
.legend{display:flex;flex-wrap:wrap;gap:6px 12px;padding:10px 20px 0;color:#8890a0;font-size:11.5px}
.legend .it{display:flex;align-items:center;gap:5px}
.sw{width:11px;height:11px;border-radius:3px;display:inline-block}
.controls{display:flex;align-items:center;gap:12px;padding:9px 16px;margin:14px 20px 0}
.controls button{background:#1b2740;color:#cfe0ff;border:1px solid #2b3654;border-radius:6px;padding:5px 13px;cursor:pointer;font-size:15px}
.controls input[type=range]{flex:1;min-width:0}
.controls .t{font-variant-numeric:tabular-nums;color:#c7ccd6;font-size:13px;white-space:nowrap}
.controls select{background:#12151c;color:#c7ccd6;border:1px solid #2b3654;border-radius:5px;font-size:12px;padding:3px 4px}
.note{margin:10px 20px 0;padding:9px 14px;font-size:12px;color:#9aa1ad;border-left:3px solid #b8860b;background:#14130c;border-radius:4px}
.note b{color:#d9c48a}
.readout{margin:14px 20px 0;padding:13px 18px}
.rt{font-size:11px;letter-spacing:.04em;color:#9aa1ad;text-transform:uppercase;margin-bottom:9px}
.rg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px 26px}
@media(max-width:900px){.rg{grid-template-columns:1fr 1fr}}
.ro{display:flex;justify-content:space-between;border-bottom:1px dashed #1c2028;padding-bottom:4px;gap:8px}
.ro .k{color:#9aa1ad}.ro .v{font-variant-numeric:tabular-nums;text-align:right}.ro .v.b{color:#7fb2ff}.ro .v.hi{color:#6fe08a}
.charts{margin:12px 20px 0;display:grid;grid-template-columns:1fr 1fr;gap:8px}
@media(max-width:1100px){.charts{grid-template-columns:1fr}}
.chp{padding:6px 14px 8px}
.chp .l{font-size:11px;color:#9aa1ad}.chp .u{color:#5a6373;font-size:10px;margin-left:6px}
.chp canvas{width:100%;height:84px;display:block;cursor:pointer}
.leg{display:inline-flex;flex-wrap:wrap;gap:4px 9px;margin-left:8px;font-size:10.5px}
.leg i{width:9px;height:9px;border-radius:2px;display:inline-block;margin-right:3px;vertical-align:-1px}
.files{display:flex;flex-wrap:wrap;gap:7px}
.fc{background:#12151c;border:1px solid #1c2028;border-radius:5px;padding:4px 9px;font:11.5px ui-monospace,monospace;color:#c7ccd6;text-decoration:none;display:inline-block}
.fc.dl{color:#7fe0a0;border-color:#24402f}.fc.dl:hover{background:#16211a;border-color:#3d9970}
.cpy{background:#12151c;border:1px solid #2b3654;color:#9fc0ff;border-radius:5px;padding:4px 9px;font:11.5px ui-monospace,monospace;cursor:pointer}
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}</script>
</head>
<body>
<div class="main" id="main"></div>
<link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"><link rel="modulepreload" href="https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js">
<script id="D" type="application/json">{"eps": [{"slug": "restock_20260916_ep000", "dataset_dir": "faberu_restock_20260916_ep000", "task": "restock_20260916", "leaf": "Restocking a retail shelf", "leaf_instr": "Fetch a bottle from the table and restock the shelf", "mode": "VR teleoperation, mobile manipulation", "perspective": "Robot egocentric", "planned": true, "rationale": "We can collect data from robots deployed in real-world working scenes. This episode is included in Ego Sample to showcase that sensor capability: a deployed robot captures rich, synchronized multi-sensor data (stereo, fisheye and color cameras, metric depth, dual lidar, IMU, odometry, joint and teleop streams). Robot-egocentric capture is a planned data line.", "ep": 0, "task_name": "Retail shelf restocking (mobile manipulation)", "instruction": "Drive from the shelf to the table, pick up the bottle with the right gripper, carry it back and place it on the shelf", "duration": 80.4, "nframes": 2412, "fps": 30, "hw": "Faber U", "vendor": "FF", "region": "US", "tags": ["US"], "taxo": {"environment_l1": "Retail and Consumer Goods", "environment_l2": "Grocery Store", "environment_l3": "Shelf Aisle", "naics_primary_code": "445110", "naics_primary_title": "Supermarkets and Other Grocery Retailers", "taxonomy_source": "OpenAI Robotics Data Requirements 08-21-2026 (Environment/Skill Group/Jobs taxonomies)", "collection_setup": "staged retail shelf in a lab space", "skill_group": "Inventory & Stock Management", "job_family": "Stock clerk", "task_difficulty": "Medium"}, "n_segments": 22, "skills": {"Navigate": 7, "Wait": 3, "Rotate": 3, "Open": 2, "Retract": 2, "Close": 1, "Pick": 1, "Lift": 1, "Reach": 1, "Place": 1}, "skill_list": ["Close", "Lift", "Navigate", "Open", "Pick", "Place", "Reach", "Retract", "Rotate", "Wait"], "qc": {"status": "pass", "total": 10, "passed": 8, "warnings": 2, "failures": 0}, "state_dim": 27, "action_dim": 19, "depth_valid": 0.847, "lidar_scans": [810, 810], "segments": [{"skill": "Wait", "label": "The robot stands in front of the retail shelf while the teleoperator gets ready", "ts": 0.0, "te": 13.0, "mistake": false}, {"skill": "Close", "label": "The right gripper closes to its travel position", "ts": 13.0, "te": 14.25, "mistake": false}, {"skill": "Navigate", "label": "The robot backs away from the shelf", "ts": 14.25, "te": 16.75, "mistake": false}, {"skill": "Rotate", "label": "The robot turns about 95 degrees right to face the work table", "ts": 16.75, "te": 20.0, "mistake": false}, {"skill": "Wait", "label": "The robot pauses facing the table", "ts": 20.0, "te": 21.25, "mistake": false}, {"skill": "Navigate", "label": "The robot drives forward toward the table", "ts": 21.25, "te": 23.25, "mistake": false}, {"skill": "Rotate", "label": "The robot corrects its heading toward the table", "ts": 23.25, "te": 25.25, "mistake": false}, {"skill": "Navigate", "label": "The robot drives up to the table edge and stops", "ts": 25.25, "te": 32.0, "mistake": false}, {"skill": "Open", "label": "The right gripper opens in front of the bottle on the table", "ts": 32.0, "te": 34.0, "mistake": false}, {"skill": "Pick", "label": "The right arm reaches the bottle on the table and the gripper closes on it", "ts": 34.0, "te": 40.25, "mistake": false}, {"skill": "Lift", "label": "The right arm lifts the bottle and retracts it close to the body", "ts": 40.25, "te": 44.25, "mistake": false}, {"skill": "Navigate", "label": "The robot drives back toward the shelf while holding the bottle", "ts": 44.25, "te": 48.5, "mistake": false}, {"skill": "Rotate", "label": "The robot turns about 95 degrees left to face the shelf", "ts": 48.5, "te": 52.0, "mistake": false}, {"skill": "Navigate", "label": "The robot aligns itself with the shelf", "ts": 52.0, "te": 54.75, "mistake": false}, {"skill": "Reach", "label": "The right arm raises the bottle toward the middle shelf", "ts": 54.75, "te": 61.0, "mistake": false}, {"skill": "Navigate", "label": "The robot repositions sideways along the shelf to reach a free slot", "ts": 61.0, "te": 65.75, "mistake": false}, {"skill": "Place", "label": "The right arm moves the bottle onto the middle shelf", "ts": 65.75, "te": 70.25, "mistake": false}, {"skill": "Open", "label": "The right gripper opens to release the bottle on the shelf", "ts": 70.25, "te": 71.5, "mistake": false}, {"skill": "Retract", "label": "The right arm withdraws from the shelf", "ts": 71.5, "te": 74.0, "mistake": false}, {"skill": "Navigate", "label": "The robot backs away from the shelf and straightens up", "ts": 74.0, "te": 76.75, "mistake": false}, {"skill": "Retract", "label": "Both arms return to the home pose", "ts": 76.75, "te": 78.5, "mistake": false}, {"skill": "Wait", "label": "The robot stops; episode ends", "ts": 78.5, "te": 80.4, "mistake": false}]}], "skc": {"Clip": "#4f83cc", "Close": "#e0913a", "CloseBox": "#5aa469", "Cut": "#c0504d", "Dip": "#8064a2", "Drop": "#4bacc6", "Flip": "#d98594", "Fold": "#9b8f2e", "HandOver": "#7f8fa6", "Hang": "#674ea7", "Insert": "#3d9970", "Knead": "#b8860b", "Open": "#a0522d", "Paint": "#2e8b8b", "Pick": "#b5651d", "Place": "#6a5acd", "Plug": "#556b2f", "Pour": "#8b3a62", "Press": "#487eb0", "PressButton": "#cc8e35", "Pull": "#6b8e23", "Rinse": "#9370db", "Rotate": "#20b2aa", "Scan": "#cd853f", "Scoop": "#4f83cc", "Screw (tighten)": "#e0913a", "Smear": "#5aa469", "Stack": "#c0504d", "Stir": "#8064a2", "Straighten": "#4bacc6", "Takedown": "#d98594", "Takeout (remove)": "#9b8f2e", "Tear": "#7f8fa6", "Wipe": "#674ea7", "dip": "#3d9970", "plug": "#b8860b", "Wait": "#5d6574", "Navigate": "#3fa7d6", "Lift": "#e07a5f", "Reach": "#81b29a", "Retract": "#b388eb"}, "hf": "https://huggingface.co/datasets/faradayfuture/egoSample"}</script>
<script>
const D=JSON.parse(document.getElementById('D').textContent);
const EPS=D.eps, SKC=D.skc, HF=D.hf;
const CAMS=['head_stereo_left','head_color','head_stereo_right','head_left_fisheye','head_depth','head_right_fisheye'];
const AX={x:'#e0616f',y:'#41c76b',z:'#4f8ff7'};
const PAL=['#e0616f','#41c76b','#4f8ff7','#e0a33a','#b57be0','#3fc1c9','#d98594'];
let EP=null, SIG=null, vids=[], master=null, playing=false, raf=0, syncT=0, _lastCi=-2, _toggle=null, setCloudT=null;
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const fmt=t=>{t=Math.max(0,t);const m=Math.floor(t/60),s=t%60;return m+':'+s.toFixed(2).padStart(5,'0');};
const skc=s=>SKC[s]||'#666';

function select(i){ EP=EPS[i]; SIG=null; cancelAnimationFrame(raf); clearInterval(syncT); playing=false; render(); loadSig(EP.slug); }
function loadSig(slug){ window.EPDATA=window.EPDATA||{};
  // start every download at once; core first-priority, map next, scans next, depth-hover grid last
  const P=n=>'epdata/'+slug+'.'+n;
  const mapP=fetchBin(P('map'),'map'), scansP=fetchBin(P('scans'),'scans');
  const merge=(k,fn)=>b=>{ if(!b||!SIG) return; fn(parseFUP(b)); if(window._addClouds) window._addClouds(); };
  const done=()=>{ SIG=window.EPDATA[slug]; if(!SIG) return; sizeCharts(); drawCharts(); init3D();
    mapP.then(merge('map',F=>{ SIG.map=decPts(F,0,F.hdr.n); }));
    scansP.then(merge('scans',F=>{ let c=0; SIG.clouds={}; Object.entries(F.hdr.clouds).forEach(([k,fr])=>{ SIG.clouds[k]=fr.map(f=>Object.assign({t:f.t},decPts(F,c++,f.n))); }); }))
      .then(()=>fetchBin(P('dgrid'),null,'low')).then(b=>{ if(!b||!SIG) return; const F=parseFUP(b), n=F.hdr.w*F.hdr.h; SIG.depthGrid=F.hdr.offs.map((o,k)=>new Uint16Array(F.buf.buffer,F.base+o,n)); });
    updateUI(master.currentTime||0);
    const go=()=>{ if(_toggle&&!playing) _toggle(); }; if(master.readyState>=3) go(); else master.addEventListener('canplay',go,{once:true}); };
  if(window.EPDATA[slug]){ done(); return; }
  fetchBin(P('core'),null).then(b=>{ window.EPDATA[slug]=JSON.parse(new TextDecoder().decode(b)); done(); })
    .catch(()=>{ const s=document.createElement('script'); s.src='epdata/'+slug+'.js'; s.onload=done; document.body.appendChild(s); }); }

const _prog={};
function showProg(){ const el=document.getElementById('cloudStat'); if(!el) return;
  const a=Object.entries(_prog).filter(([k,v])=>!v.done); el.textContent=a.length?'loading '+a.map(([k,v])=>`${k} ${(v.got/1e6).toFixed(1)}${v.total?'/'+(v.total/1e6).toFixed(1):''} MB`).join(' · '):''; }
async function fetchBin(url,label,prio){
  try{
    const r=await fetch(url,{priority:prio||'high'}); if(!r.ok) throw new Error('HTTP '+r.status);
    let b;
    if(label){ const total=+r.headers.get('content-length')||0, rd=r.body.getReader(), parts=[]; let got=0; _prog[label]={got:0,total};
      for(;;){ const {done,value}=await rd.read(); if(done) break; parts.push(value); got+=value.length; _prog[label].got=got; showProg(); }
      b=new Uint8Array(got); let o=0; for(const p of parts){ b.set(p,o); o+=p.length; } _prog[label].done=1; showProg(); }
    else b=new Uint8Array(await r.arrayBuffer());
    if(b[0]===0x1f&&b[1]===0x8b) b=new Uint8Array(await new Response(new Blob([b]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
    return b;
  }catch(e){ if(label){ const el=document.getElementById('cloudStat'); if(el) el.textContent=label+' failed to load: '+e.message; } return null; }
}
function parseFUP(buf){ const dv=new DataView(buf.buffer,buf.byteOffset); if(String.fromCharCode(...buf.slice(0,4))!=='FUP2') throw new Error('bad pack');
  const hl=dv.getUint32(4,true), hdr=JSON.parse(new TextDecoder().decode(buf.slice(8,8+hl))); let base=8+hl; base+=(4-base%4)%4; return {buf,hdr,base}; }
/* planar per-axis deltas (int16 cm) + intensity -> interleaved Int16 xyz (cm) */
function decPts(F,k,n){ const o=F.base+F.hdr.offs[k], d=new Int16Array(F.buf.buffer,F.buf.byteOffset+o,n*3), q=new Int16Array(n*3);
  for(let a=0;a<3;a++){ let acc=0; for(let m=0;m<n;m++){ acc+=d[a*n+m]; q[3*m+a]=acc; } }
  return {q, I:new Uint8Array(F.buf.buffer,F.buf.byteOffset+o+n*6,n)}; }

function render(){
  const e=EP, dur=e.duration, segs=e.segments, T=e.taxo;
  const tile=(c,big)=>`<div class="vp${big?' big':''}" ><video data-c="${c}" src="media/${e.slug}/${c}.mp4" muted playsinline preload="auto"></video><span class="lbl">${c}${c==='head_depth'?' · filtered preview · raw 16-bit mm in dataset':''}</span>${c==='head_depth'?'<span class="rd" id="drd">hover for depth</span>':''}</div>`;
  const chips=segs.map((s,i)=>`<div class="chip" data-i="${i}" style="background:${skc(s.skill)}" title="${esc(s.skill)} — ${esc(s.label)}">${i+1}</div>`).join('');
  const slist=segs.map((s,i)=>`<div class="si" data-i="${i}"><div><span class="n" style="background:${skc(s.skill)}">${i+1}</span><span class="k">${esc(s.skill)}</span><span class="lb"> · ${s.ts.toFixed(2)}–${s.te.toFixed(2)}s</span></div><div class="m">${esc(s.label)}</div></div>`).join('');
  const legend=Object.keys(e.skills).sort().map(s=>`<span class="it"><span class="sw" style="background:${skc(s)}"></span>${esc(s)} <b style="color:#e6e8ee">${e.skills[s]}</b></span>`).join('');
  const HFDIR=HF+'/tree/main/'+e.dataset_dir, raw=f=>HF+'/resolve/main/'+e.dataset_dir+'/'+f;
  const camDl=CAMS.map(c=>`<a class="fc dl" href="media/${e.slug}/${c}.mp4" download="${e.slug}_${c}.mp4">${c}.mp4</a>`).join('');
  const refs=['data/file-000.parquet','meta/info.json','meta/ff/segments.parquet','meta/ff/episode_meta.parquet','meta/ff/quality.json','meta/ff/camera_params.json','depth/head_depth/file-000.mkv','pointclouds/lidar_front/file-000.parquet','pointclouds/lidar_back/file-000.parquet','sensors/imu_chassis.parquet','sensors/odom.parquet','sensors/vr_teleop_device.parquet']
    .map(f=>`<a class="fc dl" href="${raw(f)}" target="_blank" rel="noopener">${f}</a>`).join('');
  document.getElementById('main').innerHTML=`
  <header>
    <h1>${esc(e.task_name)}</h1>
    <div class="sub"><code>${esc(e.task)}</code> · ${esc(e.vendor)} ${esc(e.hw)} · <b style="color:#e0a33a">robot egocentric</b> · ${esc(e.mode)} · episode ${e.ep} · ${e.duration}s @ ${e.fps}fps</div>
    <div class="badges">${e.tags.map(t=>`<span class="bdg tag">${esc(t)}</span>`).join('')}<span class="bdg tag" style="background:#e0a33a;border-color:#e0a33a">Robot egocentric</span>${e.planned?'<span class="bdg tag" style="background:#b388eb;border-color:#b388eb">Planned</span>':''}<span class="bdg">instruction: <b>${esc(e.instruction)}</b></span></div>
    <div class="badges"><span class="bdg">environment <b>${esc(T.environment_l1)} › ${esc(T.environment_l2)} › ${esc(T.environment_l3)}</b></span><span class="bdg">skill group <b>${esc(T.skill_group)}</b></span><span class="bdg">job <b>${esc(T.job_family)}</b></span><span class="bdg">difficulty <b>${esc(T.task_difficulty)}</b></span><span class="bdg">NAICS <b>${esc(T.naics_primary_code)}</b></span></div>
  </header>
  <div class="layout">
    <div class="panel">
      <div class="vgrid">${CAMS.map(c=>tile(c)).join('')}</div>
      <div class="chk" id="chk"></div>
      <div class="three" id="three"><span class="lbl">world (odom) · robot links · map (all scans, dim) · current scan front <span style="color:#ff8a70">■</span> back <span style="color:#6ea8ff">■</span> · base path · ceiling (&gt;2.3 m) and &gt;10 m hidden</span><span class="hint">drag to orbit · scroll to zoom</span><span id="cloudStat" style="position:absolute;top:26px;left:9px;font:11px ui-monospace,monospace;color:#e0a33a;z-index:1">loading map…</span></div>
    </div>
    <div class="panel">
      <div class="spans-head">temporal spans (${e.n_segments}) · <b>${esc(e.task_name)}</b></div>
      <div class="tl" id="tl"></div><div class="chips" id="chips">${chips}</div><div class="slist" id="slist">${slist}</div>
    </div>
  </div>
  <div class="legend">${legend}</div>
  <div class="panel controls"><button id="play">&#9654;</button><input type="range" id="seek" min="0" max="10000" value="0"><span class="t" id="tlab">0:00.00 / ${fmt(dur)}</span>
    <select id="rate"><option value="1">1×</option><option value="2">2×</option><option value="4">4×</option></select></div>
  <div class="badges" style="padding:0 20px;margin-top:8px">
    <span class="bdg"><b>${e.nframes}</b> frames</span><span class="bdg"><b>${e.n_segments}</b> skill spans</span><span class="bdg">state dim <b>${e.state_dim}</b> · action dim <b>${e.action_dim}</b></span>
    <span class="bdg">5 RGB cams + depth</span><span class="bdg">2 lidars · ${e.lidar_scans[0]}+${e.lidar_scans[1]} scans</span><span class="bdg">IMU 200 Hz</span>
    <span class="bdg">depth valid <b>${(e.depth_valid*100).toFixed(0)}%</b></span>
    <span class="bdg">QC <b style="color:${e.qc.failures?'#ff5c5c':'#6fe08a'}">${e.qc.passed}/${e.qc.total}</b>${e.qc.warnings?` · ${e.qc.warnings} warn`:''}</span></div>
  <div class="note"><b>Why a robot episode in Ego Sample.</b> ${esc(e.rationale||'')}</div>
  <div class="panel readout"><div class="rt">current-time readout</div><div class="rg">
    <div class="ro"><span class="k">time</span><span class="v" id="roT">–</span></div>
    <div class="ro"><span class="k">current skill</span><span class="v b" id="roSk">–</span></div>
    <div class="ro"><span class="k">span</span><span class="v" id="roSp">–</span></div>
    <div class="ro"><span class="k">base x / y / yaw</span><span class="v" id="roB">–</span></div>
    <div class="ro"><span class="k">base speed (odom)</span><span class="v" id="roV">–</span></div>
    <div class="ro"><span class="k">gripper L / R (rad)</span><span class="v" id="roG">–</span></div>
    <div class="ro"><span class="k">teleop active</span><span class="v hi" id="roA">–</span></div>
    <div class="ro"><span class="k">right end xyz (base_link)</span><span class="v" id="roE">–</span></div>
    <div class="ro"><span class="k">max |target − measured| arm</span><span class="v" id="roTr">–</span></div></div></div>
  <div class="charts">
    <div class="panel chp"><div class="l">left arm joints <span class="u">rad · solid measured, dim target</span></div><canvas id="chL"></canvas></div>
    <div class="panel chp"><div class="l">right arm joints <span class="u">rad · solid measured, dim target</span></div><canvas id="chR"></canvas></div>
    <div class="panel chp"><div class="l">grippers <span class="u">rad</span><span class="leg"><span><i style="background:#e0616f"></i>L pos</span><span><i style="background:#41c76b"></i>R pos</span><span><i style="background:#7a3a42"></i>L cmd</span><span><i style="background:#2a6b3e"></i>R cmd</span></span></div><canvas id="chG"></canvas></div>
    <div class="panel chp"><div class="l">base velocity <span class="u">m/s, rad/s · solid odom, dim teleop cmd</span><span class="leg"><span><i style="background:${AX.x}"></i>vx</span><span><i style="background:${AX.y}"></i>vy</span><span><i style="background:${AX.z}"></i>wz</span></span></div><canvas id="chV"></canvas></div>
    <div class="panel chp"><div class="l">chassis IMU · gyro <span class="u">rad/s</span><span class="leg"><span><i style="background:${AX.x}"></i>x</span><span><i style="background:${AX.y}"></i>y</span><span><i style="background:${AX.z}"></i>z</span></span></div><canvas id="chI"></canvas></div>
    <div class="panel chp"><div class="l">end-effector wrench · force <span class="u">N</span><span class="leg"><span><i style="background:#e0616f"></i>L fx fy fz</span><span><i style="background:#41c76b"></i>R fx fy fz</span></span></div><canvas id="chW"></canvas></div>
  </div>
  <div class="panel" style="margin:14px 20px 30px;padding:11px 16px">
    <div class="rt">episode clips &mdash; click to download (preview encodes)</div><div class="files" style="padding:0 0 6px">${camDl}</div>
    <div class="rt" style="margin-top:8px">dataset files &middot; <a href="${HFDIR}" target="_blank" rel="noopener" style="text-transform:none;color:#7fe0a0">full episode on HuggingFace &rarr;</a>
      <button class="cpy" id="cpy" style="margin-left:8px">📋 copy: hf download (all files)</button></div>
    <div class="files">${refs}</div></div>`;
  wire();
}

function wire(){
  const e=EP, dur=e.duration, segs=e.segments;
  vids=[...document.querySelectorAll('.vp video')]; master=vids[0];
  const playBtn=document.getElementById('play'), seek=document.getElementById('seek'), rate=document.getElementById('rate');
  function toggle(){ playing=!playing; playBtn.innerHTML=playing?'&#10073;&#10073;':'&#9654;';
    if(playing){ vids.forEach(v=>{ v.playbackRate=+rate.value; v.play().catch(()=>{}); }); tick(); clearInterval(syncT); syncT=setInterval(()=>syncSlaves(false),250); }
    else { vids.forEach(v=>v.pause()); clearInterval(syncT); syncSlaves(true); } }
  playBtn.onclick=toggle; _toggle=toggle;
  rate.onchange=()=>vids.forEach(v=>v.playbackRate=+rate.value);
  seek.oninput=()=>setTime(seek.value/10000*dur);
  const tl=document.getElementById('tl'); tl.innerHTML='';
  segs.forEach(s=>{ const b=document.createElement('i'); b.style.width=((s.te-s.ts)/dur*100)+'%'; b.style.background=skc(s.skill); b.title=`${s.skill} — ${s.label}`;
    b.onclick=ev=>{ ev.stopPropagation(); setTime(s.ts+0.01); }; tl.appendChild(b); });
  document.querySelectorAll('#chips .chip,#slist .si').forEach(c=>c.onclick=()=>setTime(segs[+c.dataset.i].ts+0.01));
  ['chL','chR','chG','chV','chI','chW'].forEach(id=>{ const c=document.getElementById(id); c.onclick=ev=>{ const r=c.getBoundingClientRect(); setTime((ev.clientX-r.left)/r.width*dur); }; });
  master.addEventListener('seeked',()=>syncSlaves(true));
  document.getElementById('cpy').onclick=()=>{ const cmd=`hf download ${HF.replace('https://huggingface.co/datasets/','')} --repo-type dataset --include "${e.dataset_dir}/*" --local-dir .`;
    navigator.clipboard.writeText(cmd).then(()=>{ document.getElementById('cpy').textContent='✓ copied'; setTimeout(()=>document.getElementById('cpy').textContent='📋 copy: hf download (all files)',1500); }); };
  document.onkeydown=ev=>{ if(ev.target.tagName==='SELECT') return;
    if(ev.code==='Space'){ ev.preventDefault(); toggle(); }
    else if(ev.code==='ArrowRight'){ ev.preventDefault(); const n=segs.find(s=>s.ts>master.currentTime+0.05); if(n) setTime(n.ts+0.01); }
    else if(ev.code==='ArrowLeft'){ ev.preventDefault(); const p=[...segs].reverse().find(s=>s.ts<master.currentTime-0.3); setTime(p?p.ts+0.01:0); } };
  // depth hover readout
  const dv=vids.find(v=>v.dataset.c==='head_depth'), rd=document.getElementById('drd'), cache={};
  dv.parentElement.addEventListener('mousemove',ev=>{ if(!SIG) return; const r=dv.getBoundingClientRect(), ar=640/400; let w=r.width,h=w/ar; if(h>r.height){h=r.height;w=h*ar;}
    const x=(ev.clientX-r.left-(r.width-w)/2)/w, y=(ev.clientY-r.top-(r.height-h)/2)/h; if(x<0||x>1||y<0||y>1){ rd.textContent='—'; return; }
    if(!SIG.depthGrid){ rd.textContent='loading…'; return; }
    const [gw,gh]=SIG.depth_grid_wh, fi=Math.min(SIG.depthGrid.length-1,Math.round(dv.currentTime*30/SIG.depth_grid_every));
    const g=SIG.depthGrid[fi]; const v=g[Math.floor(y*gh)*gw+Math.floor(x*gw)];
    rd.textContent=v?`${(v/100).toFixed(2)} m`:'invalid'; });
  dv.parentElement.addEventListener('mouseleave',()=>rd.textContent='hover for depth');
  setTime(0);
}
function inBuf(v,t){ const b=v.buffered; for(let i=0;i<b.length;i++) if(t>=b.start(i)-0.2&&t<=b.end(i)+0.2) return true; return false; }
function syncSlaves(hard){ const t=master.currentTime, base=+document.getElementById('rate').value;
  for(const v of vids){ if(v===master||v.readyState<1) continue;
    if(hard){ v.playbackRate=base; if(!v.seeking&&Math.abs(v.currentTime-t)>0.04){ try{v.currentTime=t;}catch(e){} } continue; }
    if(!playing) continue; if(v.paused&&!v.ended) v.play().catch(()=>{});
    const d=v.currentTime-t, ad=Math.abs(d);
    if(ad>0.75){ if(!v.seeking&&inBuf(v,t)){ try{v.currentTime=t;}catch(e){} } v.playbackRate=base; }
    else v.playbackRate=ad>0.035?base*Math.max(.5,Math.min(1.6,1-d*3)):base; } }
function setTime(t){ t=Math.max(0,Math.min(EP.duration-0.01,t)); try{ if(Math.abs(master.currentTime-t)>0.03) master.currentTime=t; }catch(e){} syncSlaves(true); updateUI(t); }
function tick(){ if(!playing) return; const t=master.currentTime; updateUI(t);
  if(t>=EP.duration-0.05){ try{master.currentTime=0;}catch(e){} syncSlaves(true); vids.forEach(v=>v.play().catch(()=>{})); }
  raf=requestAnimationFrame(tick); }
function curSpan(t){ const s=EP.segments; for(let i=0;i<s.length;i++) if(t>=s[i].ts&&t<s[i].te) return i; return -1; }
function updateUI(t){
  const dur=EP.duration, segs=EP.segments;
  document.getElementById('seek').value=t/dur*10000; document.getElementById('tlab').textContent=fmt(t)+' / '+fmt(dur);
  document.getElementById('roT').textContent=t.toFixed(2)+'s';
  const ci=curSpan(t); document.getElementById('roSk').textContent=ci>=0?segs[ci].skill:'–'; document.getElementById('roSp').textContent=ci>=0?`${ci+1} / ${segs.length}`:'–';
  if(ci!==_lastCi){ const sl=document.querySelectorAll('#slist .si'); sl.forEach((el,k)=>el.classList.toggle('on',k===ci));
    document.querySelectorAll('#chips .chip').forEach((el,k)=>el.classList.toggle('on',k===ci));
    if(ci>=0){ const box=document.getElementById('slist'), el=sl[ci], top=el.offsetTop-box.offsetTop; if(top<box.scrollTop||top+el.offsetHeight>box.scrollTop+box.clientHeight) box.scrollTop=top-box.clientHeight/2; }
    _lastCi=ci; }
  if(!SIG) return;
  const j=Math.min(SIG.n-1,Math.round(t*SIG.fps)), b=SIG.base[j], bv=SIG.base_vel[j], g=SIG.grip[j], ee=SIG.ee[j];
  document.getElementById('roB').textContent=`${b[0].toFixed(2)} / ${b[1].toFixed(2)} m / ${(b[2]*180/Math.PI).toFixed(0)}°`;
  document.getElementById('roV').textContent=`${Math.hypot(bv[0],bv[1]).toFixed(2)} m/s · ${(bv[2]*180/Math.PI).toFixed(0)}°/s`;
  document.getElementById('roG').textContent=`${g[0].toFixed(2)} / ${g[1].toFixed(2)}`;
  document.getElementById('roA').textContent=SIG.active[j]?'yes':'no';
  document.getElementById('roE').textContent=ee.slice(7,10).map(v=>v.toFixed(2)).join(', ');
  document.getElementById('roTr').textContent=Math.max(...SIG.arm[j].map((v,k)=>Math.abs(v-SIG.arm_target[j][k]))).toFixed(3)+' rad';
  drawPlayheads(t); if(setCloudT) setCloudT(t);
}

/* ---------- charts ---------- */
const CH={};
function sizeCharts(){ document.querySelectorAll('.chp canvas').forEach(c=>{ c.width=c.clientWidth*devicePixelRatio; c.height=84*devicePixelRatio; }); }
function plot(id,series){ const c=document.getElementById(id); if(!c) return; const g=c.getContext('2d'), w=c.width, h=c.height, dpr=devicePixelRatio;
  g.fillStyle='#0b0e13'; g.fillRect(0,0,w,h); let lo=Infinity,hi=-Infinity; series.forEach(s=>s.d.forEach(v=>{ if(v<lo)lo=v; if(v>hi)hi=v; })); if(lo===hi){lo-=1;hi+=1;}
  const n=series[0].d.length, Y=v=>h-4*dpr-(v-lo)/(hi-lo)*(h-8*dpr);
  if(lo<0&&hi>0){ g.strokeStyle='#1c2028'; g.beginPath(); g.moveTo(0,Y(0)); g.lineTo(w,Y(0)); g.stroke(); }
  series.forEach(s=>{ g.strokeStyle=s.c; g.globalAlpha=s.a||1; g.lineWidth=dpr; g.beginPath(); s.d.forEach((v,i)=>{ const x=i/(n-1)*w; i?g.lineTo(x,Y(v)):g.moveTo(x,Y(v)); }); g.stroke(); });
  g.globalAlpha=1; g.fillStyle='#5a6373'; g.font=`${10*dpr}px ui-monospace`; g.fillText(hi.toFixed(2),4*dpr,11*dpr); g.fillText(lo.toFixed(2),4*dpr,h-3*dpr);
  CH[id]=g.getImageData(0,0,w,h); }
const colOf=(a,k)=>a.map(r=>r[k]);
function drawCharts(){ if(!SIG) return; sizeCharts();
  const arm=(off)=>{ const s=[]; for(let k=0;k<7;k++){ s.push({d:colOf(SIG.arm_target,off+k),c:PAL[k],a:.3}); s.push({d:colOf(SIG.arm,off+k),c:PAL[k]}); } return s; };
  plot('chL',arm(0)); plot('chR',arm(7));
  plot('chG',[{d:colOf(SIG.grip_cmd,0),c:'#7a3a42'},{d:colOf(SIG.grip_cmd,1),c:'#2a6b3e'},{d:colOf(SIG.grip,0),c:'#e0616f'},{d:colOf(SIG.grip,1),c:'#41c76b'}]);
  plot('chV',[{d:colOf(SIG.base_cmd,0),c:AX.x,a:.35},{d:colOf(SIG.base_cmd,1),c:AX.y,a:.35},{d:colOf(SIG.base_cmd,2),c:AX.z,a:.35},{d:colOf(SIG.base_vel,0),c:AX.x},{d:colOf(SIG.base_vel,1),c:AX.y},{d:colOf(SIG.base_vel,2),c:AX.z}]);
  plot('chI',[3,4,5].map((k,i)=>({d:colOf(SIG.imu,k),c:[AX.x,AX.y,AX.z][i]})));
  plot('chW',[0,1,2].map(k=>({d:colOf(SIG.wrench,k),c:'#e0616f',a:.5+k*.25})).concat([6,7,8].map(k=>({d:colOf(SIG.wrench,k),c:'#41c76b',a:.5+(k-6)*.25}))));
  drawPlayheads(master?master.currentTime:0); }
function drawPlayheads(t){ const f=t/EP.duration; Object.entries(CH).forEach(([id,img])=>{ const c=document.getElementById(id); if(!c) return; const g=c.getContext('2d'); g.putImageData(img,0,0);
  g.strokeStyle='rgba(255,255,255,.6)'; g.lineWidth=devicePixelRatio; g.beginPath(); g.moveTo(f*c.width,0); g.lineTo(f*c.width,c.height); g.stroke(); }); }
window.addEventListener('resize',()=>{ clearTimeout(window._rz); window._rz=setTimeout(drawCharts,150); });

/* ---------- 3D world view ---------- */
async function init3D(){
  const THREE=await import('three'); const {OrbitControls}=await import('three/addons/controls/OrbitControls.js');
  const el=document.getElementById('three'); el.querySelectorAll('canvas').forEach(c=>c.remove());
  const R=new THREE.WebGLRenderer({antialias:true}); R.setPixelRatio(devicePixelRatio); el.appendChild(R.domElement);
  const scene=new THREE.Scene(); scene.background=new THREE.Color(0x0b0d12);
  const cam=new THREE.PerspectiveCamera(50,1,.05,200); cam.up.set(0,0,1);
  const B0=SIG.base; let cx=0,cy=0; B0.forEach(b=>{cx+=b[0];cy+=b[1];}); cx/=B0.length; cy/=B0.length;
  let x0=1e9,x1=-1e9,y0=1e9,y1=-1e9; B0.forEach(b=>{x0=Math.min(x0,b[0]);x1=Math.max(x1,b[0]);y0=Math.min(y0,b[1]);y1=Math.max(y1,b[1]);}); const span=Math.max(x1-x0,y1-y0,2)+3;
  cam.position.set(cx-span*.75,cy-span*.75,span*.7); const ctl=new OrbitControls(cam,R.domElement); ctl.target.set(cx,cy,.6); ctl.update();
  const grid=new THREE.GridHelper(16,32,0x2a3a5a,0x1a2030); grid.rotation.x=Math.PI/2; grid.position.set(cx,cy,0); scene.add(grid);
  // base path
  const path=new THREE.Line(new THREE.BufferGeometry().setFromPoints(B0.map(b=>new THREE.Vector3(b[0],b[1],.01))),new THREE.LineBasicMaterial({color:0x6ea8ff})); scene.add(path);
  const robot=new THREE.Group(); scene.add(robot);
  const body=new THREE.LineSegments(new THREE.BufferGeometry(),new THREE.LineBasicMaterial({color:0xd6dbe6})); robot.add(body);
  const joints=new THREE.Points(new THREE.BufferGeometry(),new THREE.PointsMaterial({color:0xffc857,size:.045})); robot.add(joints);
  const foot=new THREE.Mesh(new THREE.BoxGeometry(.62,.52,.25),new THREE.MeshBasicMaterial({color:0x4a5570,wireframe:true})); foot.position.set(.1,0,.14); robot.add(foot);
  const ax=new THREE.AxesHelper(.35); robot.add(ax);
  const L=SIG.links, idx=n=>L.indexOf(n);
  const bones=[['chassis_link','body_link1'],['body_link1','body_link2'],['body_link2','body_link3'],['body_link3','body_link5'],['body_link5','head_link3'],['body_link5','arm_base_link']];
  ['l','r'].forEach(s=>{ bones.push(['arm_base_link',`arm_${s}_link1`]); for(let i=1;i<7;i++) bones.push([`arm_${s}_link${i}`,`arm_${s}_link${i+1}`]); bones.push([`arm_${s}_link7`,`arm_${s}_end_link`],[`arm_${s}_end_link`,`gripper_${s}_center_link`]); });
  const clouds={}; const CC={lidar_front:[1,.54,.44],lidar_back:[.43,.66,1]};
  let follow=false;
  const buildChk=()=>{ document.getElementById('chk').innerHTML=Object.keys(clouds).map(k=>`<label><input type="checkbox" data-k="${k}" checked>${k==='__map'?'environment map (all scans)':k+' (current scan)'}</label>`).join('')+`<label><input type="checkbox" data-k="__path" checked>base path</label><label><input type="checkbox" data-k="__follow">camera follows robot</label>`;
    document.querySelectorAll('#chk input').forEach(i=>i.onchange=()=>{ const k=i.dataset.k; if(k==='__path') path.visible=i.checked; else if(k==='__follow') follow=i.checked; else clouds[k].p.visible=i.checked; }); };
  let mapPts=null;
  window._addClouds=()=>{
    if(SIG.map&&!mapPts){ const q=SIG.map.q, I=SIG.map.I, n=I.length, pos=new Float32Array(n*3), col=new Float32Array(n*3);
      for(let m=0;m<n;m++){ pos[3*m]=q[3*m]/100; pos[3*m+1]=q[3*m+1]/100; const z=q[3*m+2]/100; pos[3*m+2]=z;
        const h=Math.max(0,Math.min(1,z/2.3)), s=.30+.35*I[m]/255; col[3*m]=(.35+.25*h)*s*1.6; col[3*m+1]=(.45+.2*h)*s*1.6; col[3*m+2]=(.6+.15*h)*s*1.6; }
      const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.BufferAttribute(pos,3)); g.setAttribute('color',new THREE.BufferAttribute(col,3));
      mapPts=new THREE.Points(g,new THREE.PointsMaterial({size:.03,vertexColors:true,transparent:true,opacity:.35})); scene.add(mapPts); clouds.__map={p:mapPts,cur:0,static:true}; } if(SIG.clouds) Object.keys(SIG.clouds).forEach(k=>{ if(clouds[k]) return; const p=new THREE.Points(new THREE.BufferGeometry(),new THREE.PointsMaterial({size:.05,vertexColors:true})); scene.add(p); clouds[k]={p,cur:-1}; });
    buildChk(); if(setCloudT) setCloudT(master.currentTime||0); };
  buildChk();
  let last=null;
  setCloudT=t=>{ const j=Math.min(SIG.n-1,Math.round(t*SIG.fps)), b=SIG.base[j];
    robot.position.set(b[0],b[1],0); robot.rotation.set(0,0,b[2]);
    if(follow&&last){ const dx=b[0]-last[0], dy=b[1]-last[1]; cam.position.x+=dx; cam.position.y+=dy; ctl.target.x+=dx; ctl.target.y+=dy; } last=b;
    const lt=SIG.link_t; let li=0; while(li<lt.length-1&&lt[li+1]<=t) li++; const P=SIG.link_p[li];
    const seg=[]; bones.forEach(([a,c])=>{ const ia=idx(a), ic=idx(c); if(ia>=0&&ic>=0) seg.push(...P[ia],...P[ic]); });
    body.geometry.setAttribute('position',new THREE.Float32BufferAttribute(seg,3)); joints.geometry.setAttribute('position',new THREE.Float32BufferAttribute(P.flat(),3));
    Object.entries(clouds).forEach(([k,C])=>{ if(C.static) return; const fr=SIG.clouds&&SIG.clouds[k]; if(!fr) return; let i=0; while(i<fr.length-1&&fr[i+1].t<=t) i++; if(i===C.cur) return; C.cur=i;
      const f=fr[i], q=f.q, I=f.I, n=I.length, bj=Math.max(0,Math.min(SIG.n-1,Math.round(f.t*SIG.fps))), bb=SIG.base[bj], cs=Math.cos(bb[2]), sn=Math.sin(bb[2]);
      const pos=new Float32Array(n*3), col=new Float32Array(n*3), base=CC[k]||[.8,.8,.8];
      for(let m=0;m<n;m++){ const x=q[3*m]/100,y=q[3*m+1]/100,z=q[3*m+2]/100; pos[3*m]=bb[0]+cs*x-sn*y; pos[3*m+1]=bb[1]+sn*x+cs*y; pos[3*m+2]=z;
        const s=.35+.65*I[m]/255; col[3*m]=base[0]*s; col[3*m+1]=base[1]*s; col[3*m+2]=base[2]*s; }
      C.p.geometry.setAttribute('position',new THREE.BufferAttribute(pos,3)); C.p.geometry.setAttribute('color',new THREE.BufferAttribute(col,3)); C.p.geometry.computeBoundingSphere(); }); };
  const resize=()=>{ const w=el.clientWidth,h=el.clientHeight; if(!w) return; R.setSize(w,h); cam.aspect=w/h; cam.updateProjectionMatrix(); };
  new ResizeObserver(resize).observe(el); resize();
  (function loop(){ requestAnimationFrame(loop); ctl.update(); R.render(scene,cam); })();
  setCloudT(master.currentTime||0); window._addClouds();
}
function fromHash(){ const s=decodeURIComponent(location.hash.slice(1)); const i=EPS.findIndex(e=>e.slug===s); select(i>=0?i:0); }
window.addEventListener('hashchange',fromHash); fromHash();
</script>
</body></html>