File size: 25,037 Bytes
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
c3bf349
 
 
 
 
 
 
 
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
cf0a8d5
 
 
c3bf349
cf0a8d5
c3bf349
cf0a8d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3bf349
 
cf0a8d5
 
 
c3bf349
 
 
 
 
 
 
 
 
cf0a8d5
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
import * as THREE from './vendor/three.module.min.js';
import { OrbitControls } from './vendor/OrbitControls.js';

const $ = id => document.getElementById(id);
const names = ['Thumb', 'Index', 'Middle', 'Ring', 'Little', 'Palm'];
const colors = ['#f87171','#fbbf24','#34d399','#22d3ee','#818cf8','#d8b4fe'];
const state = { samples: [], filtered: [], index: 0, sample: null, selectedSensor: -1, layer: 'depth', angle: 0, playing: false, loadToken: 0, drawToken: 0, images: new Map(), canonical: false };
const stage = $('pointcloud');
let renderer, scene, camera, controls, cloud, model, specimen, sensorMeshes = [], sensorAxes = [], labelElements = [], rawPoints, center;
let manifest;
const fmt = n => Number(n).toLocaleString('en-US');
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const capitalize = s => s.charAt(0).toUpperCase() + s.slice(1);
const safePath = s => s && !/^(?:[a-z]+:|\/\/)/i.test(s) ? s : null;

function init3D() {
  scene = new THREE.Scene();
  scene.background = new THREE.Color('#132338');
  renderer = new THREE.WebGLRenderer({antialias:true,alpha:false,powerPreference:'low-power'});
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  stage.prepend(renderer.domElement);
  camera = new THREE.PerspectiveCamera(33, 1, .1, 3000);
  camera.position.set(105, 70, 305);
  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = .09;
  controls.minDistance = 45;
  controls.maxDistance = 1100;
  controls.enablePan = true;
  controls.maxPolarAngle = Math.PI;
  specimen = new THREE.Group();
  model = new THREE.Group();
  specimen.add(model);
  scene.add(specimen);
  const grid = new THREE.GridHelper(360, 18, '#3d526c', '#223750');
  grid.rotation.x = Math.PI / 2;
  grid.position.z = -95;
  grid.material.transparent = true;
  grid.material.opacity = .4;
  scene.add(grid);
  const dots = [];
  for(let x=-180;x<=180;x+=20) for(let y=-180;y<=180;y+=20) dots.push(x,y,-94.9);
  const dotsGeometry = new THREE.BufferGeometry();
  dotsGeometry.setAttribute('position',new THREE.Float32BufferAttribute(dots,3));
  scene.add(new THREE.Points(dotsGeometry,new THREE.PointsMaterial({color:'#566d89',size:1.2,sizeAttenuation:false,transparent:true,opacity:.35})));
  new ResizeObserver(() => { const {width,height}=stage.getBoundingClientRect(); renderer.setSize(width,height);camera.aspect=width/Math.max(height,1);camera.updateProjectionMatrix(); }).observe(stage);
  const tick=()=> { requestAnimationFrame(tick);controls.update();renderer.render(scene,camera); updateLabels(); };
  tick();
}

function disposeModel() {
  if(!model) return;
  for(const obj of [...model.children]) { model.remove(obj); obj.traverse(child=> {child.geometry?.dispose(); if(Array.isArray(child.material))child.material.forEach(m=>m.dispose());else child.material?.dispose();}); }
  sensorMeshes=[];sensorAxes=[];
  $('sceneLabels').replaceChildren();labelElements=[];
}

function buildCloud(points, sample) {
  if(!renderer) return;
  disposeModel();rawPoints=points;
  const transformed = new Float32Array(points.length);
  const bounds = new THREE.Box3();
  for(let i=0;i<points.length;i+=3){transformed[i]=points[i];transformed[i+1]=-points[i+1];transformed[i+2]=-points[i+2];bounds.expandByPoint(new THREE.Vector3(transformed[i],transformed[i+1],transformed[i+2]));}
  for(const s of sample.sensors || []) bounds.expandByPoint(new THREE.Vector3(s.x,-s.y,-s.z));
  center=bounds.getCenter(new THREE.Vector3());
  const geometry = new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.BufferAttribute(transformed,3));
  cloud=new THREE.Points(geometry,new THREE.PointsMaterial({size:Number($('pointSize').value),vertexColors:true,sizeAttenuation:false,transparent:true,opacity:.95}));
  model.add(cloud);colorCloud();
  (sample.sensors || []).forEach((sensor,i)=> {
    const mesh = new THREE.Mesh(new THREE.SphereGeometry(2.1,14,10),new THREE.MeshBasicMaterial({color:colors[i]}));
    mesh.position.set(sensor.x,-sensor.y,-sensor.z);model.add(mesh);sensorMeshes.push(mesh);
    const halo = new THREE.Mesh(new THREE.SphereGeometry(3.2,14,10),new THREE.MeshBasicMaterial({color:colors[i],transparent:true,opacity:.13,wireframe:true}));halo.position.copy(mesh.position);model.add(halo);
    const axisGroup = new THREE.Group();axisGroup.position.copy(mesh.position);
    const r=sensor.rotation;
    if(r?.length===9) for(let a=0;a<3;a++) {
      const direction = new THREE.Vector3(r[a],-r[3+a],-r[6+a]).normalize();
      const arrow = new THREE.ArrowHelper(direction,new THREE.Vector3(),13,[0xe98971,0x9cc67e,0x74a7dd][a],3,1.7);axisGroup.add(arrow);
    }
    axisGroup.visible=$('showAxes').checked;model.add(axisGroup);sensorAxes.push(axisGroup);
    const el=document.createElement('span');el.className='scene-label';el.style.setProperty('--sensor-color',colors[i]);el.textContent=sensor.name || names[i];$('sceneLabels').append(el);labelElements.push(el);
  });
  applyRotation();resetView(bounds.getSize(new THREE.Vector3()));updateSensorSelection();
}

function colorCloud() {
  if(!cloud || !rawPoints) return;
  const mode=$('colorMode').value, c=new THREE.Color(), out=new Float32Array(rawPoints.length);
  const values=[];for(let i=0;i<rawPoints.length;i+=3)values.push(mode==='height'?-rawPoints[i+1]:rawPoints[i+2]);
  const min=Math.min(...values),max=Math.max(...values),range=max-min||1;
  const stops=mode==='height'?['#bcdef0','#70baa3','#d4e7a8','#f4c993']:['#f8c9a0','#d4d99d','#8ec29c','#4c9f8b','#2d6763'];
  const palette=stops.map(s=>new THREE.Color(s));
  for(let j=0;j<values.length;j++){
    if(mode==='mono')c.set('#d0ddca');else{const t=Math.max(0,Math.min(.999,(values[j]-min)/range))*(palette.length-1),idx=Math.floor(t);c.copy(palette[idx]).lerp(palette[Math.min(idx+1,palette.length-1)],t-idx);}
    out[j*3]=c.r;out[j*3+1]=c.g;out[j*3+2]=c.b;
  }
  cloud.geometry.setAttribute('color',new THREE.BufferAttribute(out,3));
}

function resetView(size) {
  if(!controls) return;
  const extent=size?Math.max(size.x,size.y,size.z,130):180;
  camera.position.set(extent*.42,extent*.25,extent*2.05);
  controls.target.set(0,0,0);controls.update();
}

function updateLabels() {
  if(!renderer || !camera || !sensorMeshes.length)return;
  const w=stage.clientWidth,h=stage.clientHeight;
  sensorMeshes.forEach((mesh,i)=>{const p=mesh.getWorldPosition(new THREE.Vector3()).project(camera),el=labelElements[i];el.style.left=`${(p.x*.5+.5)*w}px`;el.style.top=`${(-p.y*.5+.5)*h}px`;el.style.display=p.z>1||p.z<0||Math.abs(p.x)>1.1||Math.abs(p.y)>1.1?'none':'';});
}

function applyRotation() {
  const rad=-state.angle*Math.PI/180;
  if(model && center){model.rotation.z=rad;const centered=center.clone().applyAxisAngle(new THREE.Vector3(0,0,1),rad);specimen.position.copy(centered).multiplyScalar(-1);}
  $('rotation').value=state.angle;$('rotationValue').textContent=`${state.angle>0?'+':''}${Math.round(state.angle)}°`;
  $('rotationDisclaimer').textContent=state.canonical?`Ground-truth demonstration: ${state.angle.toFixed(1)}° from the measured palm frame, following the original derotate.m convention. No learned prediction is run.`:'This geometric demonstration uses measured palm orientation. It does not run the paper’s learned network.';
  drawCamera();drawMethodPreview();updateSensorReadout();
}

async function drawMethodPreview(){
  const sample=state.sample;if(!sample)return;
  const angle=state.angle,img=await imageFor(sample.depth.path);
  if(!img||sample!==state.sample||angle!==state.angle)return;
  const K=manifest.dataset.cameraIntrinsics||{},w=sample.depth.width,h=sample.depth.height;
  ['rotationOriginal','rotationCurrent'].forEach((id,index)=>{const canvas=$(id),ctx=canvas.getContext('2d');canvas.width=w;canvas.height=h;ctx.fillStyle='#152a29';ctx.fillRect(0,0,w,h);ctx.save();if(index){ctx.translate(K.cx??w/2,K.cy??h/2);ctx.rotate(angle*Math.PI/180);ctx.translate(-(K.cx??w/2),-(K.cy??h/2));}ctx.imageSmoothingEnabled=false;ctx.drawImage(img,0,0,w,h);ctx.restore();});
}

function imageFor(path) {
  if(!safePath(path))return Promise.resolve(null);
  if(state.images.has(path))return state.images.get(path);
  const task=new Promise(resolve=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>resolve(null);img.src=path;});state.images.set(path,task);return task;
}

async function drawCamera() {
  const s=state.sample;if(!s)return;
  const token=++state.drawToken;
  const selected=state.selectedSensor<0?0:state.selectedSensor;
  const path=state.layer==='heatmap'?s.heatmaps?.[selected]?.path:s[state.layer]?.path;
  const img=await imageFor(path);
  if(s!==state.sample||token!==state.drawToken)return;
  const canvas=$('depthCanvas'),ctx=canvas.getContext('2d');
  const w=s.depth?.width||320,h=s.depth?.height||240,scale=2;canvas.width=w*scale;canvas.height=h*scale;
  ctx.fillStyle='#080d16';ctx.fillRect(0,0,canvas.width,canvas.height);$('depthEmpty').hidden=Boolean(img);
  if(!img)return;
  const K=manifest.dataset.cameraIntrinsics||{},cx=(K.cx??w/2)*scale,cy=(K.cy??h/2)*scale;
  ctx.save();ctx.translate(cx,cy);ctx.rotate(state.angle*Math.PI/180);ctx.translate(-cx,-cy);ctx.imageSmoothingEnabled=false;ctx.drawImage(img,0,0,w*scale,h*scale);
  if($('projectedLabels').checked){
    (s.sensors || []).forEach((sensor,i)=>{
      if(state.selectedSensor!==-1&&i!==state.selectedSensor)return;
      const u=sensor.u??sensor.projectedU,v=sensor.v??sensor.projectedV;
      if(!Number.isFinite(u)||!Number.isFinite(v))return;
      ctx.beginPath();ctx.arc(u*scale,v*scale,6,0,Math.PI*2);ctx.fillStyle=colors[i];ctx.fill();ctx.lineWidth=2;ctx.strokeStyle='#142a24';ctx.stroke();
      ctx.save();ctx.translate(u*scale+9,v*scale-8);ctx.rotate(-state.angle*Math.PI/180);ctx.font='bold 12px Arial';ctx.lineWidth=3;ctx.strokeStyle='#152a29';ctx.strokeText(names[i],0,0);ctx.fillStyle=colors[i];ctx.fillText(names[i],0,0);ctx.restore();
    });
  }
  ctx.restore();
  $('depthNear').textContent=state.layer==='depth'?`${Math.round(s.depth.rangeMin)} mm`:'LOW';
  $('depthFar').textContent=state.layer==='depth'?`${Math.round(s.depth.rangeMax)} mm`:'HIGH';
  const gradient=document.querySelector('.depth-gradient');
  gradient.style.background=state.layer==='depth'?'linear-gradient(90deg,#f7f4b7,#8ee7bc,#1cb4b5,#337ec2,#5643b6,#351966)':state.layer==='ir'?'linear-gradient(90deg,#080d16,#fff)':state.layer==='labels'?'linear-gradient(90deg,#080d16 12.5%,#f87171 12.5% 25%,#fbbf24 25% 37.5%,#34d399 37.5% 50%,#22d3ee 50% 62.5%,#818cf8 62.5% 75%,#d8b4fe 75% 87.5%,#f472b6 87.5%)':'linear-gradient(90deg,#000,#fff)';
  if(state.layer==='labels'){$('depthNear').textContent='LABEL ID';$('depthFar').textContent='';}
  if(state.layer==='heatmap'){$('depthNear').textContent='0';$('depthFar').textContent='1';}
  const descriptions={depth:'Distance from the camera, colored per frame. Markers show the annotation heatmap peaks; 3D spheres show measured sensor positions.',ir:'The original infrared image. IR supplies visual context and was not used for the paper’s fingertip classification.',labels:'Original per-pixel part labels, generated by raycasting the oriented sensor ellipsoids. These are sparse annotations, not full hand segmentation.',heatmap:`Original ${names[selected].toLowerCase()} annotation heatmap, normalized on the source 0–1 scale. Select a sensor below to change channels.`};
  $('layerDescription').textContent=descriptions[state.layer];
}

function updateSensorSelection(){
  document.querySelectorAll('#sensorLegend button').forEach((el,i)=>el.setAttribute('aria-pressed',String(i-1===state.selectedSensor)));
  sensorMeshes.forEach((mesh,i)=>{mesh.material.opacity=state.selectedSensor===-1||i===state.selectedSensor?1:.2;mesh.material.transparent=true;mesh.scale.setScalar(i===state.selectedSensor?1.5:1);});
  labelElements.forEach((el,i)=>el.classList.toggle('dimmed',state.selectedSensor!==-1&&i!==state.selectedSensor));
  document.querySelectorAll('#measurementTable tr').forEach((row,i)=>row.classList.toggle('selected',i===state.selectedSensor));
  sensorAxes.forEach((axis,i)=>axis.visible=$('showAxes').checked&&(state.selectedSensor===-1||i===state.selectedSensor));
  updateSensorReadout();drawCamera();
}

function updateSensorReadout(){
  if(state.selectedSensor<0){$('sensorReadoutName').textContent='ALL SIX SENSORS';$('sensorReadoutValue').textContent='Select a fingertip below to inspect its coordinates.';return;}
  const s=state.sample?.sensors[state.selectedSensor];if(!s)return;
  const theta=state.angle*Math.PI/180,x=s.x*Math.cos(theta)-s.y*Math.sin(theta),y=s.x*Math.sin(theta)+s.y*Math.cos(theta);
  $('sensorReadoutName').textContent=`${s.name.toUpperCase()} · ${state.angle?'ROTATED':'SOURCE'} CAMERA XYZ`;
  $('sensorReadoutValue').textContent=`X ${x.toFixed(1)}  Y ${y.toFixed(1)}  Z ${s.z.toFixed(1)} mm`;
}

function renderMeasurements(sample){
  const table=$('measurementTable');
  if(table)table.innerHTML=(sample.sensors||[]).map((s,i)=>`<tr class="${i===state.selectedSensor?'selected':''}"><td><button data-sensor="${i}" aria-label="Select ${esc(s.name)}"><span class="sensor-dot" style="--sensor-color:${colors[i]}"></span>${esc(s.name)}</button></td><td>${Number(s.x).toFixed(1)}</td><td>${Number(s.y).toFixed(1)}</td><td>${Number(s.z).toFixed(1)}</td><td>${esc(s.u??'—')}</td><td>${esc(s.v??'—')}</td></tr>`).join('');
  const source=sample.source||{};
  const rows=[['Archive',source.archive],['Member',source.member],['Session',source.session],['Indices',`idx ${source.idx??'—'} · gidx ${source.gidx??'—'}`],['Variant',`${capitalize(sample.variant)}${sample.variant!=='original'?` · ${Number(sample.rotationDegrees).toFixed(2)}° recorded rotation`:''}`],['MAT SHA-256',source.matSha256]];
  if($('provenanceFields'))$('provenanceFields').innerHTML=rows.map(([name,value])=>`<div class="${name==='MAT SHA-256'?'hash-row':''}"><dt>${esc(name)}</dt><dd>${esc(value??'—')}</dd></div>`).join('');
}

function rebuildFilter(keepId) {
  const value=$('splitSelect').value;
  state.filtered=state.samples.filter(s=>value==='all'||`${s.split}|${s.variant}`===value);
  $('sampleSelect').innerHTML=state.filtered.map(s=>`<option value="${esc(s.id)}">${esc(s.title||s.id)}</option>`).join('');
  const index=state.filtered.findIndex(s=>s.id===keepId);state.index=Math.max(0,index);loadSample(state.index);
}

async function loadSample(index) {
  if(!state.filtered.length)return;
  state.index=(index+state.filtered.length)%state.filtered.length;
  const s=state.filtered[state.index],token=++state.loadToken;state.sample=s;state.angle=0;state.canonical=false;
  $('sampleSelect').value=s.id;$('sampleCounter').textContent=`${state.index+1} / ${state.filtered.length}`;
  $('loadingState').hidden=false;$('loadingState').querySelector('strong').textContent='Reading depth & geometry';$('loadingState').querySelector('span:last-child').textContent=s.title||s.id;
  $('pointCount').textContent=`${fmt(s.pointCount)} POINTS · 6 SENSORS`;
  $('depthDimensions').textContent=`${s.depth.width} × ${s.depth.height}`;
  $('frameSource').textContent=s.source?.member||s.id;
  $('frameDescription').textContent=`${s.source?.session||'Session'} · ${capitalize(s.variant)}${s.variant!=='original'?` · archive rotation ${Number(s.rotationDegrees).toFixed(1)}°`:''}`;
  renderMeasurements(s);
  $('frameFileLink').href=`https://huggingface.co/datasets/twerdster/HandNet/blob/main/archives/${encodeURIComponent(s.source.archive)}`;
  const paired=state.samples.filter(p=>p.pairKey===s.pairKey&&p.id!==s.id);const button=$('comparePair');button.hidden=!paired.length;button.textContent=paired.length?`Compare ${capitalize(paired[0].variant)} ⇄`:'';button.dataset.target=paired[0]?.id||'';
  $('applyCanonical').disabled=!s.sensors?.[5]?.rotation;
  document.querySelector('[data-layer="heatmap"]').hidden=!s.heatmaps?.length;
  if(state.layer==='heatmap'&&!s.heatmaps?.length)selectLayer('depth');
  renderGallery();applyRotation();
  try{
    const response=await fetch(s.points);if(!response.ok)throw new Error(`Geometry unavailable (${response.status})`);
    const buffer=await response.arrayBuffer();if(token!==state.loadToken)return;
    buildCloud(new Float32Array(buffer),s);
    if(renderer){$('loadingState').hidden=true;}else{
      $('loadingState').hidden=false;$('loadingState').querySelector('.loader').hidden=true;
      $('loadingState').querySelector('strong').textContent='3D is unavailable in this browser';
      $('loadingState').querySelector('span:last-child').textContent='The original camera images and sensor coordinates remain available. Enable WebGL to inspect the surface.';
    }
  }catch(error){if(token!==state.loadToken)return;$('loadingState').querySelector('strong').textContent='Geometry could not load';$('loadingState').querySelector('span:last-child').textContent=error.message;$('loadingState').querySelector('.loader').hidden=true;}
}

function renderGallery(){
  const list=state.filtered, count=Math.min(8,list.length);let start=Math.max(0,Math.min(state.index-3,list.length-count));
  $('sampleGallery').innerHTML=list.slice(start,start+count).map(s=>`<button class="sample-tile ${s.id===state.sample.id?'active':''}" data-sample="${esc(s.id)}" aria-label="Open ${esc(s.title)}"><img src="${esc(s.depth.path)}" alt="Depth image: ${esc(s.title)}" loading="lazy"><span>${esc(s.title)}</span></button>`).join('');
}

function selectLayer(layer){state.layer=layer;document.querySelectorAll('[data-layer]').forEach(b=>b.setAttribute('aria-selected',String(b.dataset.layer===layer)));drawCamera();}
function setPlaying(playing){state.playing=playing;$('play').textContent=playing?'Ⅱ':'▶';$('play').setAttribute('aria-label',playing?'Pause samples':'Play samples');$('play').title=playing?'Pause samples':'Play samples';clearInterval(state.timer);if(playing)state.timer=setInterval(()=>loadSample(state.index+1),2200);}
function canonicalAngle(){
  const r=state.sample?.sensors?.[5]?.rotation;if(!r?.length)return null;
  const abs=[Math.abs(r[6]),Math.abs(r[7]),Math.abs(r[8])],align=abs.indexOf(Math.max(...abs));
  let alpha=align===1?Math.atan2(r[2],r[5])*180/Math.PI+90+(r[7]<0?180:0):Math.atan2(r[1],r[4])*180/Math.PI+90;
  return ((alpha+180)%360+360)%360-180;
}

function bindEvents(){
  $('splitSelect').addEventListener('change',()=>{setPlaying(false);rebuildFilter();});
  $('sampleSelect').addEventListener('change',e=>loadSample(state.filtered.findIndex(s=>s.id===e.target.value)));
  $('previous').onclick=()=>loadSample(state.index-1);$('next').onclick=()=>loadSample(state.index+1);$('shuffle').onclick=()=>loadSample(Math.floor(Math.random()*state.filtered.length));$('play').onclick=()=>setPlaying(!state.playing);
  $('resetView').onclick=()=>resetView();$('colorMode').onchange=colorCloud;$('pointSize').oninput=e=>{if(cloud)cloud.material.size=Number(e.target.value);};$('showAxes').onchange=updateSensorSelection;
  $('projectedLabels').onchange=drawCamera;
  document.querySelectorAll('[data-layer]').forEach(b=>b.onclick=()=>selectLayer(b.dataset.layer));
  $('sensorLegend').innerHTML=`<button aria-pressed="true" data-sensor="-1" style="--sensor-color:#486e52">All</button>`+names.map((name,i)=>`<button aria-pressed="false" data-sensor="${i}" style="--sensor-color:${colors[i]}">${name}</button>`).join('');
  $('sensorLegend').onclick=e=>{const b=e.target.closest('[data-sensor]');if(b){state.selectedSensor=Number(b.dataset.sensor);updateSensorSelection();}};
  if($('measurementTable'))$('measurementTable').onclick=e=>{const b=e.target.closest('[data-sensor]');if(b){state.selectedSensor=Number(b.dataset.sensor);updateSensorSelection();}};
  $('sampleGallery').onclick=e=>{const b=e.target.closest('[data-sample]');if(b)loadSample(state.filtered.findIndex(s=>s.id===b.dataset.sample));};
  $('rotation').oninput=e=>{state.angle=Number(e.target.value);state.canonical=false;applyRotation();};
  $('resetRotation').onclick=()=>{state.angle=0;state.canonical=false;applyRotation();};
  $('applyCanonical').onclick=()=>{const a=canonicalAngle();if(a===null)return;state.angle=a;state.canonical=true;applyRotation();};
  $('comparePair').onclick=e=>{const target=state.samples.find(s=>s.id===e.currentTarget.dataset.target);if(!target)return;setPlaying(false);$('splitSelect').value='all';rebuildFilter(target.id);};
  $('copyCitation').onclick=async()=>{const text='@inproceedings{wetzler2015rule,\n  title={Rule of Thumb: Deep Derotation for Improved Fingertip Detection},\n  author={Wetzler, Aaron and Slossberg, Ron and Kimmel, Ron},\n  booktitle={British Machine Vision Conference},\n  year={2015}\n}';try{await navigator.clipboard.writeText(text);$('copyCitation').textContent='Copied ✓';setTimeout(()=>$('copyCitation').innerHTML='Copy BibTeX <span>⧉</span>',2000);}catch{$('copyCitation').textContent='Select BibTeX text alongside';}};
  document.addEventListener('keydown',e=>{if(/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)||e.metaKey||e.ctrlKey||e.altKey)return;if(e.key==='ArrowRight'){e.preventDefault();loadSample(state.index+1);}if(e.key==='ArrowLeft'){e.preventDefault();loadSample(state.index-1);}if(e.key===' '){e.preventDefault();setPlaying(!state.playing);}if(e.key.toLowerCase()==='r')resetView();});
  document.addEventListener('visibilitychange',()=>{if(document.hidden)setPlaying(false);});
}

async function archiveList(){
  try{
    const response=await fetch('data/archive-manifest.json');if(!response.ok)return;
    const release=await response.json();
    const notes={'TrainData.rar':'Training · 202,198 frames','TestData.zip':'Test · 10,000 frames','ValidationData.zip':'Validation · 2,773 frames','TrainData_DerotGT.zip':'Training · ground-truth derotation','ValidationData_DerotGT.zip':'Validation · ground-truth derotation','TestData_Perturbed.zip':'Test · rotation perturbed','ValidationData_Perturbed.zip':'Validation · rotation perturbed'};
    $('archiveList').innerHTML=release.archives.map(a=>{const size=a.bytes>=1073741824?`${(a.bytes/1073741824).toFixed(2)} GiB`:`${(a.bytes/1048576).toFixed(1)} MiB`;return `<a href="https://huggingface.co/datasets/twerdster/HandNet/resolve/main/${esc(a.path)}?download=true" target="_blank" rel="noreferrer" title="${esc(a.bytes)} bytes · SHA-256 ${esc(a.sha256)}"><span class="archive-name">${esc(a.filename)}<small>${esc(notes[a.filename]||'Original archive')}</small></span><span class="archive-size">${size}</span><span class="arrow">↓</span></a>`;}).join('');
  }catch(error){console.warn('Archive metadata unavailable:',error);}
}

async function init(){
  bindEvents();archiveList();
  try{init3D();}catch(error){console.warn('3D renderer unavailable:',error);document.body.classList.add('webgl-unavailable');$('loadingState').querySelector('strong').textContent='3D is unavailable in this browser';$('loadingState').querySelector('span:last-child').textContent='The original camera images and sensor coordinates remain available. Enable WebGL to inspect the surface.';}
  try{
    const response=await fetch('data/samples.json');if(!response.ok)throw new Error(`Sample manifest unavailable (${response.status})`);manifest=await response.json();state.samples=manifest.samples;if(!state.samples?.length)throw new Error('No samples found in the manifest.');
    const groups=[...new Set(state.samples.map(s=>`${s.split}|${s.variant}`))];$('splitSelect').innerHTML='<option value="all">All samples</option>'+groups.map(g=>`<option value="${g}">${g.split('|').map(capitalize).join(' · ')}</option>`).join('');
    $('sampleTotal').textContent=`${state.samples.length} stored source frames`;
    const K=manifest.dataset.cameraIntrinsics;if(K&&$('calibrationValues'))$('calibrationValues').textContent=`fₓ = ${K.fx} · fᵧ = ${K.fy}\ncₓ = ${K.cx} · cᵧ = ${K.cy}`;
    rebuildFilter('test-original-0008694');
  }catch(error){$('loadingState').hidden=false;$('loadingState').querySelector('strong').textContent='Samples could not be loaded';$('loadingState').querySelector('span:last-child').textContent=`${error.message}. The complete original archives remain available below.`;$('loadingState').querySelector('.loader').hidden=true;}
}
function initEmbeddedSizing(){
  if(document.documentElement.dataset.embedded!=='true'||window.parent===window)return;
  let previous=0,scheduled=false;
  const report=()=>{scheduled=false;const explorer=$('explore');if(!explorer)return;const height=Math.ceil(explorer.getBoundingClientRect().bottom+window.scrollY);if(Math.abs(height-previous)>1){previous=height;window.parent.postMessage({type:'handnet-explorer-height',height},window.location.origin);}};
  const schedule=()=>{if(!scheduled){scheduled=true;requestAnimationFrame(report);}};
  new ResizeObserver(schedule).observe(document.querySelector('main'));
  window.addEventListener('load',schedule);window.addEventListener('resize',schedule);schedule();
}
initEmbeddedSizing();
init();