File size: 11,479 Bytes
9032518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// gesture_control.js — IN-VIEWER webcam gesture control for the main chat_cad scene.
// Drives the live scene exposed by window.__cadGesture (Z-up, mm units):
//   open hand move      -> orbit-rotate the camera (Z-up spherical math)
//   pinch + move        -> dolly zoom
//   pinch + hold still  -> dwell-select the part under the fingertip (raycast)
//   two hands apart     -> explode / reassemble the part group
//
// HandLandmarker runs on the MAIN thread via dynamic ESM import. (A module Web
// Worker cannot host MediaPipe: FilesetResolver calls importScripts(), which is
// illegal in a {type:'module'} worker — so we detect on the main thread, which
// is comfortably real-time at 640x480.)
// Smoothing = One Euro filter per landmark; modes debounced via a state machine.

(function () {
  const HUD_ID = '__gctrl';
  const CDN = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14';
  const MODEL = 'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task';
  let on = false, landmarker = null, video = null, octx = null, raf = 0, lastTs = 0;
  let H = null, THREE = null;

  // ---- One Euro Filter ----
  class LP { constructor(){this.y=null;} f(x,a){this.y=this.y===null?x:a*x+(1-a)*this.y;return this.y;} }
  class OneEuro { constructor(mc=1.2,b=0.03,dc=1.0){this.mc=mc;this.b=b;this.dc=dc;this.xf=new LP();this.df=new LP();this.p=null;this.t=null;}
    a(c,dt){const r=2*Math.PI*c*dt;return r/(r+1);}
    f(x,ts){ if(this.t===null){this.t=ts;this.p=x;return x;} let dt=(ts-this.t)/1000; if(dt<=0)dt=1/60; this.t=ts;
      const dx=(x-this.p)/dt, edx=this.df.f(dx,this.a(this.dc,dt)), cut=this.mc+this.b*Math.abs(edx); this.p=x; return this.xf.f(x,this.a(cut,dt)); } }
  let filt = [];
  function ensureFilt(){ if(filt.length) return; filt=[0,1].map(()=>Array.from({length:21},()=>({x:new OneEuro(),y:new OneEuro()}))); }

  // ---- gesture/scene state ----
  const D=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
  const openness=lm=>([8,12,16,20].reduce((s,i)=>s+D(lm[i],lm[0]),0))/4;
  let mode='IDLE', rotPrev=null, zoomPrev=null;
  let dwell={part:null,since:0};
  let explode=0, explodeTarget=0, explodeBase=null, partCenter=null, assemblySize=1;
  let selected=null, selectedBaseMat=null;

  function parts(){ try { return H.getParts ? H.getParts() : []; } catch(e){ return []; } }

  function prepExplode(){
    const ps=parts(); if(!ps.length){ explodeBase=null; return; }
    const box=new THREE.Box3(); ps.forEach(m=>box.expandByObject(m));
    partCenter=box.getCenter(new THREE.Vector3()); assemblySize=box.getSize(new THREE.Vector3()).length()||1;
    explodeBase=ps.map(m=>{ const b=new THREE.Box3().setFromObject(m); const c=b.getCenter(new THREE.Vector3());
      let dir=c.clone().sub(partCenter); if(dir.length()<1e-4) dir.set(0,0,1); dir.normalize();
      return {mesh:m, base:m.position.clone(), dir}; });
  }
  function applyExplode(f){ if(!explodeBase) return; const amp=assemblySize*0.55*f;
    explodeBase.forEach(p=>{ p.mesh.position.set(p.base.x+p.dir.x*amp, p.base.y+p.dir.y*amp, p.base.z+p.dir.z*amp); }); }

  const WUP=()=>new THREE.Vector3(0,0,1);
  function rotateCam(dAz, dEl){
    const cam=H.camera, tgt=H.controls.target;
    const off=cam.position.clone().sub(tgt);
    off.applyAxisAngle(WUP(), dAz);
    const viewDir=off.clone().negate().normalize();
    let right=new THREE.Vector3().crossVectors(viewDir, WUP());
    if(right.length()<1e-4) right.set(1,0,0); right.normalize();
    const cur=Math.asin(THREE.MathUtils.clamp(off.clone().normalize().z,-1,1));
    let nEl=THREE.MathUtils.clamp(cur+dEl, -1.45, 1.45); const applyEl=nEl-cur;
    off.applyAxisAngle(right, applyEl);
    cam.position.copy(tgt).add(off); H.controls.update();
  }
  function zoomCam(scale){ const cam=H.camera, tgt=H.controls.target; const off=cam.position.clone().sub(tgt);
    const d=THREE.MathUtils.clamp(off.length()*scale, assemblySize*0.15+1, assemblySize*8+50);
    off.setLength(d); cam.position.copy(tgt).add(off); H.controls.update(); }

  function highlight(mesh){ if(selected===mesh) return;
    if(selected && selectedBaseMat){ selected.material=selectedBaseMat; }
    selected=mesh; selectedBaseMat = mesh?mesh.material:null;
    if(mesh){ const hi=mesh.material.clone(); hi.emissive=new THREE.Color(0x2563eb); hi.emissiveIntensity=0.6; mesh.material=hi; }
    setSel(mesh?mesh.name:'none'); }

  // ---- UI ----
  function buildUI(){
    if(document.getElementById(HUD_ID)) return;
    const wrap=document.createElement('div'); wrap.id=HUD_ID;
    wrap.innerHTML=`
      <style>
        #${HUD_ID}{position:fixed;right:10px;bottom:10px;z-index:9999;font:12px system-ui;color:#cbd5e1}
        #${HUD_ID} video{width:220px;height:165px;border:1px solid #1f2937;border-radius:8px;transform:scaleX(-1);background:#000;display:block}
        #${HUD_ID} canvas{position:absolute;right:0;bottom:0;width:220px;height:165px;transform:scaleX(-1);pointer-events:none}
        #${HUD_ID} .bar{margin-top:4px;background:rgba(15,18,22,.85);border:1px solid #1f2937;border-radius:6px;padding:5px 8px;display:flex;gap:8px;align-items:center}
        #${HUD_ID} .m{padding:1px 7px;border-radius:4px;font-weight:700}
        .gROTATE{background:#1d4ed8;color:#fff}.gZOOM{background:#b91c1c;color:#fff}.gEXPLODE{background:#7c3aed;color:#fff}.gSELECT{background:#15803d;color:#fff}.gIDLE{background:#334155}
        #${HUD_ID}_dw{position:fixed;z-index:9998;width:40px;height:40px;border-radius:50%;border:3px solid #15803d;pointer-events:none;display:none}
      </style>
      <canvas id="${HUD_ID}_ov" width="220" height="165"></canvas>
      <video id="${HUD_ID}_v" autoplay playsinline muted></video>
      <div class="bar"><span class="m gIDLE" id="${HUD_ID}_m">IDLE</span>
        <span id="${HUD_ID}_s">none</span><span id="${HUD_ID}_f" style="margin-left:auto;color:#64748b">init…</span></div>`;
    document.body.appendChild(wrap);
    const dw=document.createElement('div'); dw.id=HUD_ID+'_dw'; document.body.appendChild(dw);
    video=document.getElementById(HUD_ID+'_v'); octx=document.getElementById(HUD_ID+'_ov').getContext('2d');
  }
  const setMode=m=>{ if(m!==mode){mode=m; const e=document.getElementById(HUD_ID+'_m'); if(e){e.className='m g'+m; e.textContent=m;}} };
  const setSel=s=>{ const e=document.getElementById(HUD_ID+'_s'); if(e) e.textContent=s; };
  const setFps=s=>{ const e=document.getElementById(HUD_ID+'_f'); if(e) e.textContent=s; };
  function showUI(v){ const w=document.getElementById(HUD_ID); if(w) w.style.display=v?'block':'none';
    const d=document.getElementById(HUD_ID+'_dw'); if(d&&!v) d.style.display='none'; }

  const HCON=[[0,1],[1,2],[2,3],[3,4],[0,5],[5,6],[6,7],[7,8],[5,9],[9,10],[10,11],[11,12],[9,13],[13,14],[14,15],[15,16],[13,17],[17,18],[18,19],[19,20],[0,17]];
  let tf=0,lt=performance.now();

  function process(hands){
    ensureFilt();
    const now=performance.now();
    const sm=hands.slice(0,2).map((lm,hi)=>lm.map((p,i)=>({x:filt[hi][i].x.f(p.x,now),y:filt[hi][i].y.f(p.y,now)})));
    if(octx){ octx.clearRect(0,0,220,165);
      sm.forEach(lm=>{ octx.strokeStyle='#22d3ee88'; octx.lineWidth=2; HCON.forEach(([a,b])=>{octx.beginPath();octx.moveTo(lm[a].x*220,lm[a].y*165);octx.lineTo(lm[b].x*220,lm[b].y*165);octx.stroke();});
        octx.fillStyle='#22d3ee'; lm.forEach(p=>{octx.beginPath();octx.arc(p.x*220,p.y*165,2.2,0,7);octx.fill();}); }); }
    const dwEl=document.getElementById(HUD_ID+'_dw');

    if(sm.length>=2){
      const d=D(sm[0][0],sm[1][0]); explodeTarget=THREE.MathUtils.clamp((d-0.18)/0.45,0,1.3);
      if(!explodeBase) prepExplode(); setMode('EXPLODE'); rotPrev=zoomPrev=null; dwell.part=null; if(dwEl)dwEl.style.display='none';
    } else if(sm.length===1){
      const lm=sm[0], w=lm[0], op=openness(lm), pg=D(lm[4],lm[8]);
      if(op>0.30 && pg>0.08){
        if(rotPrev) rotateCam(-(w.x-rotPrev.x)*3.0, (w.y-rotPrev.y)*2.4);
        rotPrev={x:w.x,y:w.y}; setMode('ROTATE'); zoomPrev=null; dwell.part=null; if(dwEl)dwEl.style.display='none';
      } else if(pg<0.06){
        const moving = zoomPrev!==null && Math.abs(w.y-zoomPrev)>0.012;
        if(moving){ zoomCam(1+(w.y-zoomPrev)*2.5); setMode('ZOOM'); dwell.part=null; if(dwEl)dwEl.style.display='none'; }
        else {
          setMode('SELECT');
          const fx=(1-lm[8].x)*2-1, fy=-(lm[8].y*2-1); const rc=new THREE.Raycaster(); rc.setFromCamera({x:fx,y:fy},H.camera);
          const hit=rc.intersectObjects(parts(),true)[0]; const part=hit?hit.object:null;
          if(part && part===dwell.part){ if(now-dwell.since>600) highlight(part); }
          else dwell={part,since:now};
          if(dwEl){ const sx=(1-lm[8].x)*innerWidth, sy=lm[8].y*innerHeight; dwEl.style.display=part?'block':'none';
            dwEl.style.left=(sx-20)+'px'; dwEl.style.top=(sy-20)+'px'; dwEl.style.borderColor=(part&&now-dwell.since>600)?'#4ade80':'#15803d66'; }
        }
        zoomPrev=w.y; rotPrev=null;
      } else { setMode('IDLE'); rotPrev=zoomPrev=null; if(dwEl)dwEl.style.display='none'; }
    } else { setMode('IDLE'); rotPrev=zoomPrev=null; if(dwEl)dwEl.style.display='none'; }
    tf++; if(now-lt>500){ setFps(Math.round(tf*1000/(now-lt))+' fps'); tf=0; lt=now; }
  }

  function pump(){ if(!on) return; raf=requestAnimationFrame(pump);
    explode+=(explodeTarget-explode)*0.18;
    // only drive positions while an explode is actually in progress, so the
    // assembly-sequence animation (assembly_seq.js) is never overridden
    if(explode>0.002 || explodeTarget>0.002) applyExplode(explode);
    if(!landmarker || !video || video.readyState<2) return;
    let ts=performance.now(); if(ts<=lastTs) ts=lastTs+1; lastTs=ts;
    let res=null; try{ res=landmarker.detectForVideo(video, ts); }catch(e){ return; }
    process(res && res.landmarks ? res.landmarks : []);
  }

  async function start(){
    H=window.__cadGesture; if(!H){ alert('viewer not ready — generate a car first'); return; } THREE=H.THREE;
    buildUI(); showUI(true); setFps('loading model…');
    try{
      const M = await import(CDN);
      const fs = await M.FilesetResolver.forVisionTasks(CDN + '/wasm');
      try {
        landmarker = await M.HandLandmarker.createFromOptions(fs, {
          baseOptions:{ modelAssetPath: MODEL, delegate:'GPU' }, runningMode:'VIDEO', numHands:2 });
      } catch(e){
        landmarker = await M.HandLandmarker.createFromOptions(fs, {
          baseOptions:{ modelAssetPath: MODEL, delegate:'CPU' }, runningMode:'VIDEO', numHands:2 });
      }
      const stream=await navigator.mediaDevices.getUserMedia({video:{width:640,height:480},audio:false});
      video.srcObject=stream; await video.play();
      on=true; prepExplode(); setFps('tracking'); pump();
    }catch(e){ setFps('error'); alert('Gesture start failed: '+(e&&e.message||e)+'\n(Make sure you allowed camera access.)'); console.error('[gesture]',e); stop(); }
  }
  function stop(){ on=false; if(raf)cancelAnimationFrame(raf);
    if(landmarker && landmarker.close){ try{landmarker.close();}catch(e){} } landmarker=null;
    if(video&&video.srcObject){ video.srcObject.getTracks().forEach(t=>t.stop()); video.srcObject=null; }
    if(explodeBase){ explodeBase.forEach(p=>p.mesh.position.copy(p.base)); explodeBase=null; }
    explode=explodeTarget=0; if(selected&&selectedBaseMat){selected.material=selectedBaseMat;selected=null;}
    showUI(false); }

  window.__toggleGesture=function(){ if(on) stop(); else start(); return on; };
})();