chatcad / static /gesture_demo.html
Samarjithbiswas's picture
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
Raw
History Blame Contribute Delete
15.9 kB
<!DOCTYPE html>
<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 &amp; 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>