Spaces:
Sleeping
Sleeping
Commercial overhaul: clean glass+taillight boundaries (conforming split), model-locked AI render (ControlNet img2img + paint tint), gesture control, workshop disassembly, part info cards, pro UI + new How-it-works
9032518 verified | // 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; }; | |
| })(); | |