<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>JST Connector Studio</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0d1117;--surface:#161b22;--line:#222b36;--accent:#00b8b1;--accent2:#00e6dc;--text:#e6edf3;--dim:#9aa7b4}
*{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:'Familjen Grotesk',Satoshi,system-ui,sans-serif;overflow:hidden}
#view{position:fixed;inset:0;top:52px} canvas{width:100%;height:100%;display:block;outline:none}
.topbar{position:fixed;top:0;left:0;right:0;height:48px;display:flex;align-items:center;gap:14px;padding:0 16px;background:linear-gradient(180deg,rgba(13,17,23,.95),rgba(13,17,23,.6));z-index:20}
.brand{font-weight:700;letter-spacing:.02em} .brand b{color:var(--accent2)}
select,button,input{font-family:inherit}
select{background:var(--surface);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:13px}
.hud{position:fixed;top:60px;left:16px;width:300px;background:rgba(22,27,34,.92);backdrop-filter:blur(8px);border:1px solid var(--line);border-radius:12px;z-index:30;box-shadow:0 10px 30px rgba(0,0,0,.45)}
.hud header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;cursor:move;border-bottom:1px solid var(--line)}
.hud header h3{margin:0;font-size:13px;font-weight:700} .hud .body{padding:10px 12px;max-height:74vh;overflow:auto}
.hud.collapsed .body{display:none}
.iconbtn{background:none;border:0;color:var(--dim);cursor:pointer;font-size:14px}
h4{margin:12px 0 5px;font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim)}
.seg{display:flex;flex-wrap:wrap;gap:4px} .seg button{background:#0c121b;color:#cdd7e1;border:1px solid var(--line);border-radius:6px;padding:5px 7px;cursor:pointer;font-size:11px}
.seg button.on{border-color:var(--accent);color:var(--accent2);box-shadow:0 0 0 1px var(--accent) inset}
.row{display:flex;align-items:center;gap:8px;margin:5px 0;font-size:12px} .row label{flex:1;color:#cdd7e1}
input[type=range]{flex:1} input[type=checkbox]{accent-color:var(--accent)}
.pick{display:flex;gap:6px;flex-wrap:wrap;margin:4px 0}
.pchip{background:#0c121b;border:1px solid var(--line);border-radius:999px;padding:4px 9px;font-size:11px;cursor:pointer;color:#cdd7e1}
.pchip.on{border-color:var(--accent);color:var(--accent2)}
.tinybtn{background:var(--accent);color:#04201f;border:0;border-radius:7px;padding:6px 10px;font-weight:700;cursor:pointer;font-size:12px}
.legend{font-size:10px;color:var(--dim);margin-top:6px} .legend b.x{color:#ff6b6b}.legend b.y{color:#5bff8d}.legend b.z{color:#6b9bff}
#tt{position:fixed;z-index:99999;background:#0b0f15;border:1px solid var(--line);color:var(--text);font-size:11px;padding:6px 8px;border-radius:6px;max-width:240px;pointer-events:none;display:none;box-shadow:0 6px 18px rgba(0,0,0,.5)}
#status{position:fixed;bottom:10px;left:16px;font:11px JetBrains Mono,monospace;color:var(--dim);z-index:20}
.sel-label{font-size:11px;color:var(--accent2)}
</style></head>
<body>
<div id="view"></div>
<div class="topbar">
  <span class="brand">JST Connector <b>Studio</b></span>
  <select id="sysSel" data-tooltip="Load a JST connector system (housing + wire + footprint, KiCad + manufacturer)"></select>
  <span class="sel-label" id="selname"></span>
  <span id="led" title="AI live link" style="margin-left:auto;display:flex;align-items:center;gap:6px;font-size:11px;color:var(--dim)"><span id="leddot" style="width:9px;height:9px;border-radius:50%;background:#c0392b;box-shadow:0 0 6px #c0392b"></span>AI link</span>
</div>
<div class="hud" id="hud">
  <header><h3>Controls</h3><button class="iconbtn" id="collapse" data-tooltip="Collapse / expand this panel">_</button></header>
  <div class="body" id="hudbody"></div>
</div>
<div id="tt"></div>
<div id="status">loading...</div>
<script type="module">
const statusEl=document.getElementById('status'); const setStatus=t=>statusEl.textContent=t;
let V,B,scene,viewer; const els={};
// ---- tooltips (body-appended fixed div, per human-ui-patterns) ----
const tt=document.getElementById('tt');
document.addEventListener('mouseover',e=>{const t=e.target.closest('[data-tooltip]');if(!t)return;tt.textContent=t.getAttribute('data-tooltip');tt.style.display='block';});
document.addEventListener('mousemove',e=>{if(tt.style.display==='block'){tt.style.left=Math.min(e.clientX+12,innerWidth-250)+'px';tt.style.top=(e.clientY+14)+'px';}});
document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tooltip]'))tt.style.display='none';});
// ---- draggable + collapsible HUD ----
(function(){const hud=document.getElementById('hud'),h=hud.querySelector('header');let dx,dy,drag=false;
 h.addEventListener('mousedown',e=>{if(e.target.id==='collapse')return;drag=true;dx=e.clientX-hud.offsetLeft;dy=e.clientY-hud.offsetTop;});
 addEventListener('mousemove',e=>{if(drag){hud.style.left=(e.clientX-dx)+'px';hud.style.top=(e.clientY-dy)+'px';hud.style.right='auto';}});
 addEventListener('mouseup',()=>drag=false);
 document.getElementById('collapse').onclick=()=>hud.classList.toggle('collapsed');})();

const AXES=["asis","zdown","yup","ydown","xup","xdown"];
const AXLBL={asis:"Z up",zdown:"Z down",yup:"Y up",ydown:"Y down",xup:"X up",xdown:"X down"};
function axisQuat(ax){const X=new B.Vector3(1,0,0),Y=new B.Vector3(0,1,0);
  const m={asis:[X,0],zdown:[X,Math.PI],yup:[X,Math.PI/2],ydown:[X,-Math.PI/2],xup:[Y,-Math.PI/2],xdown:[Y,Math.PI/2]}[ax];
  return B.Quaternion.RotationAxis(m[0],m[1]);}

let CFG={}, cur=null, parts={}, gizmo=null, selected=null, animT=0, showBBox=false, showFront=true, showGizmo=true;
const cache={};
async function loadGLB(u){ if(!cache[u]){const r=await fetch(u);const blob=new Blob([await r.arrayBuffer()]);const o=URL.createObjectURL(blob);
  const res=await B.SceneLoader.ImportMeshAsync("",o,"",scene,null,".glb");const root=new B.TransformNode("src",scene);res.meshes.forEach(m=>{if(!m.parent)m.parent=root;});root.setEnabled(false);cache[u]=root;}
  const c=cache[u].clone("c",null);c.scaling=new B.Vector3(SCALE,SCALE,SCALE);c.setEnabled(true);c.getChildMeshes().forEach(m=>m.setEnabled(true));c.computeWorldMatrix(true);const _b=c.getHierarchyBoundingVectors(true);c.position=c.position.subtract(_b.min.add(_b.max).scale(0.5));return c;}
function bvec(node){node.computeWorldMatrix(true);return node.getHierarchyBoundingVectors(true);}
function tealMat(c){const m=new B.StandardMaterial("m",scene);const col=B.Color3.FromHexString(c);m.diffuseColor=col.scale(0.45);m.emissiveColor=col.scale(0.65);m.specularColor=new B.Color3(0,0,0);return m;}
const SCALE=120;
const MMU=0.001*SCALE;
window.__undo=window.__undo||[];
const COPPER="#c8902f",PCB="#1f8a44",SILK="#eef3f6",CRT="#e0b23a";

function footprintGroup(fp){ // returns TransformNode with pads+silk+courtyard at kicad mm coords (scene units = mm/1000)
  const g=new B.TransformNode("fp",scene); const MM=0.001*SCALE;
  // PCB slab
  const xs=[...fp.pads.map(p=>p.x),...fp.silk.flatMap(s=>[s[0],s[2]])], ys=[...fp.pads.map(p=>-p.y),...fp.silk.flatMap(s=>[-s[1],-s[3]])];
  const minx=Math.min(...xs),maxx=Math.max(...xs),miny=Math.min(...ys),maxy=Math.max(...ys);
  const w=(maxx-minx+2.5)*MM,h=(maxy-miny+2.5)*MM,cx=(minx+maxx)/2*MM,cy=(miny+maxy)/2*MM;
  const slab=B.MeshBuilder.CreateBox("slab",{width:w,height:h,depth:1.6*MM},scene);slab.position.set(cx,cy,0.8*MM);const pm=new B.StandardMaterial("pcbm",scene);pm.emissiveColor=new B.Color3(0.09,0.50,0.25);pm.diffuseColor=new B.Color3(0.05,0.28,0.14);pm.specularColor=new B.Color3(0,0,0);pm.disableLighting=true;slab.material=pm;slab.parent=g;
  for(const p of fp.pads){ const r=Math.max(p.w,p.h)/2*MM;
    const pad=B.MeshBuilder.CreateCylinder("pad",{diameter:r*2,height:0.18*MM},scene);pad.rotation.x=Math.PI/2;pad.position.set(p.x*MM,-p.y*MM,1.65*MM);pad.material=tealMat(COPPER);pad.parent=g;
    if(p.drill>0){const d=B.MeshBuilder.CreateCylinder("dr",{diameter:p.drill*MM,height:0.22*MM},scene);d.rotation.x=Math.PI/2;d.position.set(p.x*MM,-p.y*MM,1.68*MM);d.material=tealMat("#0a0a0c");d.parent=g;}
  }
  const lineGrp=(segs,col,zz)=>{ for(const s of segs){ const a=new B.Vector3(s[0]*MM,-s[1]*MM,zz),b2=new B.Vector3(s[2]*MM,-s[3]*MM,zz);
    const len=B.Vector3.Distance(a,b2); if(len<1e-6)continue; const bx=B.MeshBuilder.CreateBox("ln",{width:len,height:0.14*MM,depth:0.12*MM},scene);
    const mid=a.add(b2).scale(.5);bx.position=mid;bx.rotation.z=Math.atan2(-(s[3]-s[1]),(s[2]-s[0]));bx.material=tealMat(col);bx.parent=g;} };
  lineGrp(fp.silk,SILK,1.7*MM); lineGrp(fp.courtyard,CRT,1.7*MM);
  return g;
}
function frontDisc(){ const d=B.MeshBuilder.CreateDisc("front",{radius:0.0028*SCALE,tessellation:32},scene);
  const m=new B.StandardMaterial("fm",scene);m.diffuseColor=B.Color3.FromHexString("#ff3b3b");m.emissiveColor=B.Color3.FromHexString("#ff3b3b");m.alpha=0.55;m.backFaceCulling=false;d.material=m;return d;}

function disposeScene(){ Object.values(parts).forEach(o=>{try{o.node&&o.node.dispose();o.fp&&o.fp.dispose();o.front&&o.front.dispose();o.bbox&&o.bbox.dispose();}catch{}});parts={};if(gizmo){gizmo.dispose();gizmo=null;}selected=null;}

function seatColumn(boardNode,colx){ let bb=bvec(boardNode); const dz=1.8*MMU-bb.min.z; const cx=-(bb.min.x+bb.max.x)/2+colx, cy=-(bb.min.y+bb.max.y)/2;
  boardNode.position.addInPlace(new B.Vector3(cx,cy,dz)); }

async function loadSystem(key){ if(window.__loading)return; window.__loading=true; setTimeout(()=>{window.__loading=false;},6000); setStatus("loading "+key+"...");
  const sysSel=document.getElementById('sysSel'); sysSel.value=key; cur=key; const sc=CFG[key];
  document.getElementById('selname').textContent=sc.label;
  disposeScene();
  const st=getState(key);
  const colGap=0.0072*SCALE;
  // JST board
  const jb=await loadGLB(sc.parts.jstBoard.url); const jbw=new B.TransformNode("jbw",scene); jb.parent=jbw; jbw.rotationQuaternion=axisQuat(st.jstBoard); jbw.computeWorldMatrix(true); seatColumn(jbw,colGap);
  parts.jstBoard={node:jbw,kind:"housing",col:"jst",seatPos:jbw.position.clone(),hh:bbHeight(jbw)};
  // JST footprint
  const jfp=footprintGroup(sc.footprint); jfp.rotationQuaternion=axisQuat(st.jstFp||"asis"); let jbb=bvec(jbw); jfp.position.set((jbb.min.x+jbb.max.x)/2,(jbb.min.y+jbb.max.y)/2,0); parts.jstFp={node:jfp,kind:"footprint",col:"jst"};
  // JST wire
  const jw=await loadGLB(sc.parts.jstWire.url); const jww=new B.TransformNode("jww",scene); jw.parent=jww; jww.rotationQuaternion=axisQuat(st.jstWire); parts.jstWire={node:jww,kind:"connector",col:"jst",mate:st.mate,insert:st.insert};
  // KiCad column
  if(sc.parts.kicadBoard){ const kb=await loadGLB(sc.parts.kicadBoard.url); const kbw=new B.TransformNode("kbw",scene); kb.parent=kbw;
     const q=axisQuat(st.kicadBoard); if(st.kflip){ kbw.rotationQuaternion=B.Quaternion.RotationAxis(new B.Vector3(0,0,1),Math.PI).multiply(q);} else kbw.rotationQuaternion=q;
     kbw.computeWorldMatrix(true); seatColumn(kbw,-colGap); parts.kicadBoard={node:kbw,kind:"housing",col:"kicad",seatPos:kbw.position.clone(),hh:bbHeight(kbw)};
     const kfp=footprintGroup(sc.footprint); kfp.rotationQuaternion=(st.kflip?B.Quaternion.RotationAxis(new B.Vector3(0,0,1),Math.PI):B.Quaternion.Identity()); let kbb=bvec(kbw); kfp.position.set((kbb.min.x+kbb.max.x)/2,(kbb.min.y+kbb.max.y)/2,0); parts.kicadFp={node:kfp,kind:"footprint",col:"kicad"};
  }
  // front markers + bboxes
  for(const k of ["jstBoard","kicadBoard"]){ if(!parts[k])continue; const f=frontDisc(); parts[k].front=f; }
  layoutMate(); buildHUD(); refreshDebug(); selectPart("jstBoard");
  frameScene(); window.__loading=false; setStatus(key+" ready");
}
function frameScene(){ let mn=null,mx=null; for(const k in parts){const p=parts[k];if(!p.node)continue;const b=bvec(p.node);mn=mn?B.Vector3.Minimize(mn,b.min):b.min.clone();mx=mx?B.Vector3.Maximize(mx,b.max):b.max.clone();}
  if(!mn)return; const c=mn.add(mx).scale(.5),sz=Math.max(mx.subtract(mn).length(),0.01); const cam=viewer.getCamera();
  cam.setTarget(c); cam.radius=sz*1.7; cam.alpha=Math.PI/2; cam.beta=1.15; cam.lowerRadiusLimit=sz*0.2; cam.upperRadiusLimit=sz*8; cam.wheelPrecision=1/(sz*0.02); cam.minZ=sz*0.01; }
function layoutMate(){ // position wire mated into board per mate axis + insertion
  const b=parts.jstBoard, w=parts.jstWire; if(!b||!w)return; const bb=bvec(b.node); const wn=w.node;
  wn.position.set(0,0,0); wn.computeWorldMatrix(true); let wb=bvec(wn);
  const cx=(bb.min.x+bb.max.x)/2,cy=(bb.min.y+bb.max.y)/2; const ins=(w.insert!=null?w.insert:80)/100;
  if(w.mate==="y"){ const dep=bb.max.y-bb.min.y; const tgt=bb.max.y-ins*dep; wn.position.set(cx-(wb.min.x+wb.max.x)/2, tgt-wb.min.y, (bb.min.z+bb.max.z)/2-(wb.min.z+wb.max.z)/2);}
  else { const tgt=bb.max.z-ins*(bb.max.z-bb.min.z); wn.position.set(cx-(wb.min.x+wb.max.x)/2, cy-(wb.min.y+wb.max.y)/2, tgt-wb.min.z);}
  w.seatPos=wn.position.clone(); w.mateAxis=w.mate;
  // front discs on housings (front = +mate side, on ground)
  for(const k of ["jstBoard","kicadBoard"]){ const p=parts[k]; if(!p||!p.front)continue; const pb=bvec(p.node); p.front.position.set((pb.min.x+pb.max.x)/2,(p.col==="jst"&&w.mate==="y")?pb.max.y:(pb.min.y+pb.max.y)/2,0.0006*SCALE); p.front.rotation.x=-Math.PI/2; p.front.setEnabled(showFront);}
}
function refreshDebug(){ for(const k in parts){const p=parts[k]; if(p.bbox){p.bbox.dispose();p.bbox=null;} if(showBBox&&p.node){const bb=bvec(p.node);const sz=bb.max.subtract(bb.min);const box=B.MeshBuilder.CreateBox("bb",{width:sz.x,height:sz.y,depth:sz.z},scene);box.position=bb.min.add(sz.scale(.5));const mm=new B.StandardMaterial("bm",scene);mm.wireframe=true;mm.emissiveColor=B.Color3.FromHexString(p.col==="jst"?"#00e6dc":"#ffae57");mm.disableLighting=true;box.material=mm;box.isPickable=false;p.bbox=box;}}
  if(parts.jstBoard&&parts.jstBoard.front)for(const k of["jstBoard","kicadBoard"])parts[k]&&parts[k].front&&parts[k].front.setEnabled(showFront);
}
// ---- animation: loop the mate ----
let animOn=true, animDir=1;
function tickAnim(){ let bU,wU;
  if(!animOn){ bU=0; wU=0; }                     // animation off -> fully seated + mated (static truth pose)
  else { animT+=scene.getEngine().getDeltaTime()/1000*0.28*animDir; const t=((animT%1)+1)%1;
    bU=t<0.40?(1-t/0.40):0;                       // boards: lifted at t=0, seated by 0.40
    wU=t<0.50?1:(t<0.92?(1-(t-0.50)/0.42):0); }   // wire: waits, then descends by 0.92
  for(const k of ["jstBoard","kicadBoard"]){const p=parts[k]; if(p&&p.seatPos){const h=(p.hh||0)*0.9+2*MMU; p.node.position=p.seatPos.add(new B.Vector3(0,0,bU*h));}}
  const w=parts.jstWire; if(w&&w.seatPos){const hh=bbHeight(w.node,w.mate)*0.9+3*MMU; w.node.position=(w.mate==="y")?w.seatPos.add(new B.Vector3(0,wU*hh,0)):w.seatPos.add(new B.Vector3(0,0,wU*hh));}
}
function bbHeight(n,ax){const b=bvec(n);return ax==="y"?(b.max.y-b.min.y):(b.max.z-b.min.z);}

// ---- selection + 90deg-snap rotation gizmo ----
function selectPart(key){ selected=key; if(!gizmo){gizmo=new B.GizmoManager(scene);gizmo.rotationGizmoEnabled=true;gizmo.positionGizmoEnabled=false;gizmo.scaleGizmoEnabled=false;gizmo.usePointerToAttachGizmos=false;
   const rg=gizmo.gizmos.rotationGizmo; rg.snapDistance=Math.PI/2; rg.onDragStartObservable.add(()=>{const nd=parts[selected]&&parts[selected].node; if(nd)window.__undo.push({n:nd,q:(nd.rotationQuaternion||B.Quaternion.Identity()).clone()});}); rg.onDragEndObservable.add(()=>{snapNode(parts[selected].node);pushChange();}); if(!window.__undoKey){window.__undoKey=1;addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){const u=window.__undo.pop();if(u&&u.n)u.n.rotationQuaternion=u.q;e.preventDefault();}});}}
  if(parts[key]) gizmo.attachToNode(parts[key].node);
  document.querySelectorAll('.partbtn').forEach(b=>b.classList.toggle('on',b.dataset.k===key));
}
function snapNode(n){ if(!n.rotationQuaternion)return; const e=n.rotationQuaternion.toEulerAngles(); const s=v=>Math.round(v/(Math.PI/2))*(Math.PI/2);
  n.rotationQuaternion=B.Quaternion.FromEulerAngles(s(e.x),s(e.y),s(e.z)); }

// ---- state (per-system overrides; mirrors /api/state) ----
const STATE={};
function defState(key){const sc=CFG[key];return {jstBoard:sc.parts.jstBoard.axis,jstWire:sc.parts.jstWire.axis,jstFp:"asis",kicadBoard:sc.parts.kicadBoard?sc.parts.kicadBoard.axis:"asis",kflip:sc.parts.kicadBoard?!!sc.parts.kicadBoard.flip180:false,mate:sc.mate,insert:80,tht:sc.tht};}
function getState(key){ if(!STATE[key])STATE[key]=defState(key); return STATE[key]; }
function pushChange(){ exportState(); }
function exportState(){ const out=JSON.stringify(STATE,null,1); if(els.exp)els.exp.value=out; }

// ---- HUD build ----
function seg(parent,items,get,set){const d=document.createElement('div');d.className='seg';items.forEach(it=>{const b=document.createElement('button');b.textContent=it.l;b.setAttribute('data-tooltip',it.t||it.l);if(it.v===get())b.classList.add('on');b.onclick=()=>{set(it.v);applyState();};d.appendChild(b);});parent.appendChild(d);return d;}
function buildHUD(){ const body=document.getElementById('hudbody'); body.innerHTML=''; const st=getState(cur);
  const mk=(t)=>{const h=document.createElement('h4');h.textContent=t;body.appendChild(h);};
  // selectable parts
  mk("Select part (then drag the gizmo - snaps 90°)");
  const pr=document.createElement('div');pr.className='pick';[["jstBoard","JST housing"],["jstWire","JST wire"],["jstFp","JST footprint"],["kicadBoard","KiCad housing"],["kicadFp","KiCad footprint"]].forEach(([k,l])=>{if(!parts[k])return;const c=document.createElement('button');c.className='pchip partbtn';c.dataset.k=k;c.textContent=l;c.setAttribute('data-tooltip','Select '+l+' to rotate it with the gizmo');c.onclick=()=>selectPart(k);pr.appendChild(c);});body.appendChild(pr);
  mk("JST housing up-axis"); seg(body,AXES.map(a=>({l:AXLBL[a],v:a,t:"Orient JST housing: "+AXLBL[a]})),()=>getState(cur).jstBoard,v=>getState(cur).jstBoard=v);
  mk("JST wire up-axis"); seg(body,AXES.map(a=>({l:AXLBL[a],v:a})),()=>getState(cur).jstWire,v=>getState(cur).jstWire=v);
  if(parts.kicadBoard){ mk("KiCad housing up-axis"); seg(body,AXES.map(a=>({l:AXLBL[a],v:a})),()=>getState(cur).kicadBoard,v=>getState(cur).kicadBoard=v);
    const r=document.createElement('div');r.className='row';r.innerHTML='<label data-tooltip="Rotate KiCad housing 180° about Z to match the JST manufacturer part">KiCad flip 180° (match JST)</label>';const cb=document.createElement('input');cb.type='checkbox';cb.checked=st.kflip;cb.onchange=()=>{getState(cur).kflip=cb.checked;applyState();};r.appendChild(cb);body.appendChild(r);}
  mk("Mate / connect axis"); seg(body,[{l:"Z top",v:"z",t:"Wire drops straight down (top entry)"},{l:"Y side",v:"y",t:"Wire slides in from the front (side entry)"}],()=>getState(cur).mate,v=>getState(cur).mate=v);
  // insertion
  const ir=document.createElement('div');ir.className='row';ir.innerHTML='<label data-tooltip="How deep the wire seats into the housing">Insertion <span id="insv">'+st.insert+'</span>%</label>';const sl=document.createElement('input');sl.type='range';sl.min=0;sl.max=100;sl.value=st.insert;sl.setAttribute('data-tooltip','Drag to set insertion depth');sl.oninput=()=>{getState(cur).insert=+sl.value;document.getElementById('insv').textContent=sl.value;layoutMate();exportState();};ir.appendChild(sl);body.appendChild(ir);
  // toggles
  mk("View");
  const tog=(lbl,get,set,tip)=>{const r=document.createElement('div');r.className='row';r.innerHTML='<label data-tooltip="'+tip+'">'+lbl+'</label>';const cb=document.createElement('input');cb.type='checkbox';cb.checked=get();cb.onchange=()=>{set(cb.checked);};r.appendChild(cb);body.appendChild(r);};
  tog("Animate mate",()=>animOn,v=>animOn=v,"Loop the wire mating into the housing");
  tog("Debug bounding boxes",()=>showBBox,v=>{showBBox=v;refreshDebug();},"Show a wireframe bbox around every part");
  tog("Front marker (red disc)",()=>showFront,v=>{showFront=v;refreshDebug();},"Red translucent disc marks the housing front");
  tog("Through-hole (off = SMD)",()=>getState(cur).tht,v=>{getState(cur).tht=v;exportState();},"THT pins through the PCB vs SMD pads on top");
  const r2=document.createElement('div');r2.className='row';r2.innerHTML='<label data-tooltip="Open the Babylon v9 Inspector (scene graph debug)">Inspector</label>';const ib=document.createElement('button');ib.className='tinybtn';ib.textContent='toggle';ib.onclick=()=>viewer.toggleDebugLayer&&viewer.toggleDebugLayer();r2.appendChild(ib);body.appendChild(r2);
  // anim direction
  const r3=document.createElement('div');r3.className='row';r3.innerHTML='<label data-tooltip="Reverse the mate animation direction">Anim direction</label>';const db=document.createElement('button');db.className='tinybtn';db.textContent='flip';db.onclick=()=>animDir*=-1;r3.appendChild(db);body.appendChild(r3);
  // export
  mk("Config (feeds the GLB build)"); const ta=document.createElement('textarea');ta.readOnly=true;ta.style.cssText='width:100%;height:90px;background:#0c121b;color:var(--accent2);border:1px solid var(--line);border-radius:8px;font:11px JetBrains Mono,monospace;padding:6px';els.exp=ta;body.appendChild(ta);
  const cp=document.createElement('button');cp.className='tinybtn';cp.textContent='Copy config';cp.style.marginTop='6px';cp.setAttribute('data-tooltip','Copy the full tuned config to paste back to Claude');cp.onclick=()=>navigator.clipboard.writeText(JSON.stringify(STATE,null,1));body.appendChild(cp);
  const lg=document.createElement('div');lg.className='legend';lg.innerHTML='Gizmo axes: <b class="x">X</b> <b class="y">Y</b> <b class="z">Z up</b>';body.appendChild(lg);
  exportState();
}
function applyState(){ loadSystem(cur); } // rebuild from state

// ---- AI control surface ----
window.JSTStudio={
  load:loadSystem, getState:()=>STATE, setVar:(sys,k,v)=>{getState(sys)[k]=v;if(sys===cur)applyState();},
  systems:()=>Object.keys(CFG), select:selectPart, exportConfig:()=>JSON.stringify(STATE,null,1)
};
async function pollExternal(){ try{const s=await (await fetch('/api/state')).json(); for(const sys in s){STATE[sys]=Object.assign(getState(sys),s[sys]);} }catch{} }

// ---- boot ----
(async function(){ setStatus("loading Babylon 9.5 viewer...");
  await import('./vendor/adom-3d-viewer-babylon9.esm.js');
  V=window.Adom3DViewerBabylon9; B=V.BABYLON;
  viewer=V.init(document.getElementById('view'),{zUp:true});
  scene=viewer.getScene(); scene.clearColor=new B.Color4(0.04,0.05,0.07,1);
  CFG=await (await fetch('config.json')).json();
  const sel=document.getElementById('sysSel'); Object.keys(CFG).forEach(k=>{const o=document.createElement('option');o.value=k;o.textContent=CFG[k].label;sel.appendChild(o);}); sel.onchange=()=>loadSystem(sel.value);
  scene.onBeforeRenderObservable.add(tickAnim);
  await loadSystem(Object.keys(CFG)[0]);
  (function(){const dot=document.getElementById('leddot');const setled=c=>{if(dot){dot.style.background=c;dot.style.boxShadow='0 0 6px '+c;}};const seen={};async function poll(){try{const s=await (await fetch('api/state',{cache:'no-store'})).json();setled('#27ae60');for(const sys in s){const j=JSON.stringify(s[sys]);if(j!==seen[sys]){seen[sys]=j;Object.assign(getState(sys),s[sys]);if(sys===cur&&!window.__loading)applyState();}}}catch(e){setled('#c0392b');}setTimeout(poll,1200);}poll();})();
})().catch(e=>{setStatus("ERROR: "+e);console.error(e);});
</script></body></html>