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 | <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>chat_cad — gesture control (rotate · zoom · explode · select)</title> | |
| <style> | |
| html,body{margin:0;height:100%;background:#0e1116;color:#cbd5e1;font:13px/1.4 system-ui,sans-serif;overflow:hidden} | |
| #scene{position:fixed;inset:0} | |
| #cam{position:fixed;right:12px;bottom:12px;width:260px;height:195px;border:1px solid #1f2937;border-radius:8px;transform:scaleX(-1);z-index:5;background:#000} | |
| #overlay{position:fixed;right:12px;bottom:12px;width:260px;height:195px;transform:scaleX(-1);z-index:6;pointer-events:none} | |
| #hud{position:fixed;left:12px;top:12px;z-index:7;background:rgba(15,18,22,.82);padding:10px 14px;border-radius:8px;border:1px solid #1f2937;min-width:250px} | |
| #hud b{color:#e2e8f0} | |
| .mode{display:inline-block;padding:2px 9px;border-radius:5px;font-weight:700;letter-spacing:.5px} | |
| .ROTATE{background:#1d4ed8;color:#fff}.ZOOM{background:#b91c1c;color:#fff}.EXPLODE{background:#7c3aed;color:#fff}.SELECT{background:#15803d;color:#fff}.IDLE{background:#334155;color:#94a3b8} | |
| #hint{position:fixed;left:12px;bottom:12px;z-index:7;background:rgba(15,18,22,.82);padding:10px 14px;border-radius:8px;border:1px solid #1f2937;max-width:330px;color:#94a3b8} | |
| #start{position:fixed;inset:0;display:flex;flex-direction:column;gap:14px;align-items:center;justify-content:center;z-index:20;background:rgba(8,10,14,.93)} | |
| #start button{font:600 16px system-ui;padding:14px 26px;border-radius:10px;border:0;background:#2563eb;color:#fff;cursor:pointer} | |
| #sel{color:#4ade80;font-weight:700} | |
| kbd{background:#1f2937;border-radius:4px;padding:1px 6px;color:#cbd5e1} | |
| .dwell{position:fixed;z-index:8;width:42px;height:42px;border-radius:50%;border:3px solid #15803d;pointer-events:none;display:none} | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="scene"></canvas> | |
| <video id="cam" autoplay playsinline muted></video> | |
| <canvas id="overlay" width="260" height="195"></canvas> | |
| <div id="dwell" class="dwell"></div> | |
| <div id="hud"> | |
| <div>gesture: <span id="mode" class="mode IDLE">IDLE</span></div> | |
| <div style="margin-top:6px">selected: <span id="sel">none</span></div> | |
| <div style="margin-top:6px">hands: <b id="nh">0</b> · render <b id="fps">--</b>fps · track <b id="tfps">--</b>fps</div> | |
| <div style="margin-top:6px;font-size:12px;color:#94a3b8">pinch <span id="pinch">--</span> · spread <span id="spread">--</span> · explode <span id="exf">0.00</span></div> | |
| </div> | |
| <div id="hint"> | |
| <b>One open hand</b>, move → <b>rotate</b><br/> | |
| <b>Two hands</b>, apart/together → <b>explode / reassemble</b><br/> | |
| <b>Pinch</b> + move up/down → <b>zoom</b>; <b>pinch + hold still</b> on a part → <b>select</b><br/> | |
| <span style="font-size:11px">One Euro smoothing · debounced state machine · <kbd>R</kbd> reset · <kbd>X</kbd> toggle explode</span> | |
| </div> | |
| <div id="start"> | |
| <div style="font-size:18px;font-weight:700;color:#e2e8f0">Gesture-controlled car design</div> | |
| <button id="go">▶ Start camera & gesture control</button> | |
| <div style="color:#64748b;font-size:12px;max-width:380px;text-align:center">Loads your generated chat_cad car split into its real parts. Allow webcam access.</div> | |
| </div> | |
| <script type="importmap"> | |
| { "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| }} | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; | |
| import { HandLandmarker, FilesetResolver } from 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14'; | |
| // ---------- three.js scene ---------- | |
| const canvas=document.getElementById('scene'); | |
| const renderer=new THREE.WebGLRenderer({canvas,antialias:true}); | |
| renderer.setSize(innerWidth,innerHeight); renderer.setPixelRatio(Math.min(devicePixelRatio,2)); | |
| const scene=new THREE.Scene(); scene.background=new THREE.Color(0x0e1116); | |
| const camera=new THREE.PerspectiveCamera(45,innerWidth/innerHeight,0.01,200); camera.position.set(0,0.5,4); | |
| const controls=new OrbitControls(camera,canvas); controls.enableDamping=true; | |
| scene.add(new THREE.HemisphereLight(0xffffff,0x223344,1.1)); | |
| const key=new THREE.DirectionalLight(0xffffff,1.4); key.position.set(3,5,2); scene.add(key); | |
| scene.add(new THREE.GridHelper(12,24,0x334155,0x1f2937)); | |
| const model=new THREE.Group(); scene.add(model); // rotates as a whole | |
| const carParts=new THREE.Group(); model.add(carParts); // individual part meshes | |
| let partMeshes=[]; // {mesh, dir, name, baseMat} | |
| let assemblyRadius=1; | |
| function matFor(name,c){ | |
| const col=new THREE.Color(c[0],c[1],c[2]); const DS=THREE.DoubleSide; | |
| if(name==='glass') return new THREE.MeshPhysicalMaterial({color:col,metalness:.1,roughness:.06,transmission:.35,transparent:true,opacity:.72,ior:1.5,side:DS}); | |
| if(name==='wheel_tyre') return new THREE.MeshStandardMaterial({color:0x0a0a0c,roughness:.88,side:DS}); | |
| if(name==='wheel_rim'||name==='grille') return new THREE.MeshStandardMaterial({color:col,metalness:.95,roughness:.26,side:DS}); | |
| if(name==='taillight') return new THREE.MeshStandardMaterial({color:col,emissive:0x550000,roughness:.1,side:DS}); | |
| return new THREE.MeshStandardMaterial({color:col,metalness:.6,roughness:.32,side:DS}); | |
| } | |
| async function loadParts(){ | |
| const r=await fetch('/car/parts.bin?ts='+Date.now()); | |
| if(!r.ok) throw new Error('parts.bin '+r.status); | |
| const palette=JSON.parse(r.headers.get('X-Parts-Palette')); | |
| const idName={},idCol={}; palette.forEach(p=>{idName[p.id]=p.name; idCol[p.id]=p.color;}); | |
| const buf=await r.arrayBuffer(); const dv=new DataView(buf); | |
| let off=4; const nv=dv.getUint32(off,true); off+=4; const nf=dv.getUint32(off,true); off+=4; | |
| const base=new Float32Array(buf,off,nv*3); off+=nv*12; | |
| const faces=new Uint32Array(buf,off,nf*3); off+=nf*12; | |
| const pid=new Uint8Array(buf,off,nv); | |
| // face -> part by majority vote (no boundary bleed) | |
| const byPart={}; | |
| for(let k=0;k<faces.length;k+=3){ const a=pid[faces[k]],b=pid[faces[k+1]],c=pid[faces[k+2]]; | |
| let w=a; if(b===c)w=b; else if(a===c)w=a; else if(a===b)w=a; | |
| (byPart[w]||(byPart[w]=[])).push(faces[k],faces[k+1],faces[k+2]); } | |
| // assembly centre (of all verts) | |
| const ctr=new THREE.Vector3(); for(let i=0;i<nv;i++) ctr.add(new THREE.Vector3(base[i*3],base[i*3+1],base[i*3+2])); ctr.multiplyScalar(1/nv); | |
| for(const idStr in byPart){ const id=+idStr,idx=byPart[idStr],nm=idName[id]||'body'; | |
| const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.BufferAttribute(base,3)); g.setIndex(idx); g.computeVertexNormals(); | |
| const mat=matFor(nm,idCol[id]||[.6,.6,.6]); const m=new THREE.Mesh(g,mat); m.name=nm; m.userData.partId=id; | |
| // part centroid -> explode direction (away from assembly centre) | |
| const pc=new THREE.Vector3(); let n=0; const seen=new Set(); | |
| idx.forEach(vi=>{ if(!seen.has(vi)){ seen.add(vi); pc.add(new THREE.Vector3(base[vi*3],base[vi*3+1],base[vi*3+2])); n++; } }); | |
| pc.multiplyScalar(1/Math.max(n,1)); | |
| const dir=pc.clone().sub(ctr); if(dir.length()<1e-4) dir.set(pc.x-ctr.x,0.1,pc.z-ctr.z); dir.normalize(); | |
| carParts.add(m); partMeshes.push({mesh:m,dir,name:nm,baseMat:mat}); | |
| } | |
| // fit + scale assembly to ~2.4 units, recentre | |
| const box=new THREE.Box3().setFromObject(carParts); const size=box.getSize(new THREE.Vector3()); const s=2.4/size.length(); | |
| carParts.scale.setScalar(s); const c2=box.getCenter(new THREE.Vector3()).multiplyScalar(s); carParts.position.sub(c2); | |
| assemblyRadius=size.length()*s*0.5; | |
| } | |
| function placeholder(){ | |
| const m=new THREE.Mesh(new THREE.TorusKnotGeometry(0.7,0.22,160,24),new THREE.MeshStandardMaterial({color:0x3b82f6,metalness:.5,roughness:.35})); | |
| carParts.add(m); partMeshes.push({mesh:m,dir:new THREE.Vector3(1,0,0),name:'demo',baseMat:m.material}); assemblyRadius=1; | |
| } | |
| loadParts().catch(()=>{ // fall back to merged OBJ, then placeholder | |
| new OBJLoader().load('/car/realistic_solid.obj?color=silver&ts='+Date.now(), | |
| o=>{ o.traverse(c=>{if(c.isMesh)c.material=new THREE.MeshStandardMaterial({color:0xcfd3d8,metalness:.6,roughness:.4});}); | |
| const b=new THREE.Box3().setFromObject(o),s=2.4/b.getSize(new THREE.Vector3()).length(); o.scale.setScalar(s); | |
| o.position.sub(b.getCenter(new THREE.Vector3()).multiplyScalar(s)); carParts.add(o); assemblyRadius=1.2; }, | |
| undefined, ()=>placeholder()); }); | |
| // ---------- explode ---------- | |
| let explode=0, explodeTarget=0; | |
| function applyExplode(f){ partMeshes.forEach(p=>{ p.mesh.position.copy(p.dir).multiplyScalar(f*assemblyRadius*1.8); }); } | |
| // ---------- selection highlight ---------- | |
| let selected=null; | |
| const raycaster=new THREE.Raycaster(); | |
| function selectPart(mesh){ | |
| if(selected===mesh) return; | |
| partMeshes.forEach(p=>{ p.mesh.material=p.baseMat; }); // restore | |
| selected=mesh; | |
| if(mesh){ const hi=mesh.material.clone(); hi.emissive=new THREE.Color(0x2563eb); hi.emissiveIntensity=0.6; mesh.material=hi; | |
| document.getElementById('sel').textContent=mesh.name; } | |
| else document.getElementById('sel').textContent='none'; | |
| } | |
| const home={pos:camera.position.clone(),tgt:controls.target.clone()}; | |
| addEventListener('keydown',e=>{ if(e.key==='r'||e.key==='R'){camera.position.copy(home.pos);controls.target.copy(home.tgt);} | |
| if(e.key==='x'||e.key==='X'){ explodeTarget = explodeTarget>0.05?0:1; } }); | |
| addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);}); | |
| // ---------- 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)); }} | |
| const filt=[...Array(2)].map(()=>[...Array(21)].map(()=>({x:new OneEuro(),y:new OneEuro()}))); | |
| // ---------- MediaPipe ---------- | |
| let landmarker=null; const video=document.getElementById('cam'); | |
| const octx=document.getElementById('overlay').getContext('2d'); | |
| const HC=[[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]]; | |
| async function initHands(){ const v=await FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm'); | |
| landmarker=await HandLandmarker.createFromOptions(v,{baseOptions:{modelAssetPath:'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',delegate:'GPU'},runningMode:'VIDEO',numHands:2}); } | |
| async function initCam(){ const s=await navigator.mediaDevices.getUserMedia({video:{width:640,height:480},audio:false}); video.srcObject=s; await video.play(); } | |
| // ---------- gesture state machine ---------- | |
| const D=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y); | |
| const openness=lm=>{const w=lm[0];return ([8,12,16,20].reduce((s,i)=>s+D(lm[i],w),0))/4;}; | |
| let mode='IDLE'; const setMode=m=>{if(m!==mode){mode=m;const e=document.getElementById('mode');e.className='mode '+m;e.textContent=m;}}; | |
| let rotPrev=null, zoomPrev=null, expPrev=null; | |
| let dwell={part:null,since:0,x:0,y:0}; | |
| const dwellEl=document.getElementById('dwell'); | |
| let lastT=performance.now(),lastTr=performance.now(),fr=0,tfr=0; | |
| function loop(){ | |
| requestAnimationFrame(loop); | |
| if(landmarker && video.readyState>=2){ | |
| const now=performance.now(); const res=landmarker.detectForVideo(video,now); | |
| tfr++; if(now-lastTr>500){document.getElementById('tfps').textContent=Math.round(tfr*1000/(now-lastTr));tfr=0;lastTr=now;} | |
| const hands=res.landmarks||[]; document.getElementById('nh').textContent=hands.length; | |
| const sm=hands.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)}))); | |
| // overlay | |
| octx.clearRect(0,0,260,195); | |
| sm.forEach(lm=>{ octx.strokeStyle='#22d3ee88'; octx.lineWidth=2; HC.forEach(([a,b])=>{octx.beginPath();octx.moveTo(lm[a].x*260,lm[a].y*195);octx.lineTo(lm[b].x*260,lm[b].y*195);octx.stroke();}); | |
| octx.fillStyle='#22d3ee'; lm.forEach(p=>{octx.beginPath();octx.arc(p.x*260,p.y*195,2.5,0,7);octx.fill();}); }); | |
| let pinch='--',spread='--'; | |
| if(sm.length>=2){ // TWO HANDS -> EXPLODE | |
| const d=D(sm[0][0],sm[1][0]); spread=d.toFixed(3); | |
| explodeTarget=THREE.MathUtils.clamp((d-0.18)/0.45,0,1.4); | |
| setMode('EXPLODE'); rotPrev=zoomPrev=null; dwell.part=null; dwellEl.style.display='none'; | |
| } else if(sm.length===1){ | |
| const lm=sm[0], w=lm[0], op=openness(lm), pg=D(lm[4],lm[8]); pinch=pg.toFixed(3); | |
| if(op>0.30 && pg>0.08){ // OPEN PALM -> ROTATE | |
| if(rotPrev){ model.rotation.y+=(w.x-rotPrev.x)*6.0; model.rotation.x+=THREE.MathUtils.clamp((w.y-rotPrev.y)*4.0,-0.3,0.3); } | |
| rotPrev={x:w.x,y:w.y}; setMode('ROTATE'); zoomPrev=null; dwell.part=null; dwellEl.style.display='none'; | |
| } else if(pg<0.06){ // PINCH -> zoom (move) OR dwell-select (still) | |
| // raycast from index fingertip (mirror x to match scene) | |
| const fx=(1-lm[8].x)*2-1, fy=-(lm[8].y*2-1); | |
| raycaster.setFromCamera({x:fx,y:fy},camera); | |
| const hit=raycaster.intersectObjects(carParts.children,true)[0]; | |
| const moving = zoomPrev!==null && Math.abs(w.y-zoomPrev)>0.012; | |
| if(moving){ // pinch + vertical move -> ZOOM | |
| const dir=new THREE.Vector3().subVectors(camera.position,controls.target); const dist=dir.length(); | |
| const nd=THREE.MathUtils.clamp(dist*(1+(w.y-zoomPrev)*3.0),0.8,16); dir.setLength(nd); camera.position.copy(controls.target).add(dir); | |
| setMode('ZOOM'); dwell.part=null; dwellEl.style.display='none'; | |
| } else { // pinch + still -> dwell SELECT | |
| setMode('SELECT'); | |
| const part=hit?hit.object:null; | |
| if(part && part===dwell.part){ if(now-dwell.since>600){ selectPart(part); } } | |
| else { dwell={part,since:now}; } | |
| // dwell ring on screen | |
| const sx=(1-lm[8].x)*innerWidth, sy=lm[8].y*innerHeight; | |
| dwellEl.style.display=part?'block':'none'; dwellEl.style.left=(sx-21)+'px'; dwellEl.style.top=(sy-21)+'px'; | |
| dwellEl.style.borderColor = (part&&now-dwell.since>600)?'#4ade80':'#15803d44'; | |
| } | |
| zoomPrev=w.y; rotPrev=null; | |
| } else { setMode('IDLE'); rotPrev=zoomPrev=null; dwell.part=null; dwellEl.style.display='none'; } | |
| } else { setMode('IDLE'); rotPrev=zoomPrev=null; dwell.part=null; dwellEl.style.display='none'; } | |
| document.getElementById('pinch').textContent=pinch; document.getElementById('spread').textContent=spread; | |
| } | |
| // smooth explode toward target | |
| explode+=(explodeTarget-explode)*0.18; applyExplode(explode); document.getElementById('exf').textContent=explode.toFixed(2); | |
| controls.update(); renderer.render(scene,camera); | |
| fr++; const t=performance.now(); if(t-lastT>500){document.getElementById('fps').textContent=Math.round(fr*1000/(t-lastT));fr=0;lastT=t;} | |
| } | |
| document.getElementById('go').onclick=async()=>{ const b=document.getElementById('go'); b.textContent='loading model…'; | |
| try{ await initHands(); await initCam(); document.getElementById('start').remove(); } | |
| catch(e){ b.textContent='error: '+e.message; console.error(e); } }; | |
| loop(); | |
| </script> | |
| </body> | |
| </html> | |