PHONE_CASE_GENERATOR

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/4293fd92fd5b3595/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>CASE GEN — Voronoi phone hull</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{--line:#e8e8ec;--txt:#1b1b1f;--mut:#8b8b94;--acc:#1a66ff;--soft:#f5f7fb}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--txt);font:13px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,Roboto,sans-serif}
#app{display:grid;grid-template-columns:350px 1fr;height:100%}
#side{border-right:1px solid var(--line);overflow:auto;position:relative;background:#fff;padding-bottom:190px}
#side header{padding:16px 16px 10px}
#side header b{font-size:15px;letter-spacing:.08em}
.mut{color:var(--mut)}
section{border-top:1px solid var(--line);padding:10px 0 8px}
section h3{margin:0 16px 6px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--mut);font-weight:600}
.row{display:flex;align-items:center;justify-content:space-between;padding:2px 16px}
.row label{color:#333}
input[type=number],select{width:84px;border:1px solid var(--line);border-radius:7px;padding:4px 6px;font:inherit;background:#fff;color:var(--txt)}
select{width:auto}
input[type=number]:focus,select:focus{outline:none;border-color:var(--acc)}
.btn{border:1px solid var(--line);background:#fff;color:var(--txt);border-radius:8px;padding:6px 10px;font:inherit;cursor:pointer}
.btn:hover{background:var(--soft)}
.btn.on{border-color:var(--acc);color:var(--acc);background:#f2f6ff}
.btn.primary{background:var(--acc);border-color:var(--acc);color:#fff;font-weight:600}
.btn.primary:hover{background:#0f56e0}
.btn[disabled]{opacity:.4;cursor:not-allowed}
.btns{display:flex;gap:6px;flex-wrap:wrap;padding:4px 16px}
.hint{padding:2px 16px 4px;color:var(--mut);font-size:12px}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;vertical-align:middle}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.seg .btn{border:none;border-radius:0;padding:6px 12px}
.seg .btn+.btn{border-left:1px solid var(--line)}
.seg .btn.on{background:var(--acc);color:#fff}
.sep{width:1px;align-self:stretch;background:var(--line);margin:0 2px}
.cut{margin:6px 16px;border:1px solid var(--line);border-radius:10px;padding:8px}
.cuthead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.cuthead label{display:flex;gap:6px;align-items:center;font-weight:600}
.cuthead button{border:none;background:none;color:var(--mut);cursor:pointer;font-size:14px}
.cut .nm{border:none;border-bottom:1px dashed var(--line);font:inherit;font-weight:600;width:190px;padding:1px 0;background:transparent;color:var(--txt)}.cut .nm:focus{outline:none;border-bottom-color:var(--acc)}.cut details{margin-top:6px;color:var(--mut);font-size:11px}.grid3 label.edge input{border-color:#bfe5c9;background:#f6fcf8}.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
.grid3 label{display:flex;flex-direction:column;font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
.grid3 input,.grid3 select{width:100%;margin-top:2px}
#selPanel{margin:6px 16px;border:1px solid var(--acc);border-radius:10px;padding:8px;display:none;background:#fafcff}
#selPanel .hd{font-weight:600;margin-bottom:6px}
#actions{background:#fff;border-top:1px solid var(--line);padding:10px 16px 12px;position:fixed;width:350px;left:0;bottom:0}
#chip{display:inline-block;font-size:11px;padding:2px 8px;border-radius:99px;border:1px solid var(--line);color:var(--mut)}
#chip.dirty{border-color:#ffb020;color:#b36b00;background:#fff8ea}
#chip.prev{border-color:#c9d6ff;color:#2a56c6;background:#f4f7ff}
#chip.ok{border-color:#bfe5c9;color:#1f8a43;background:#f2fbf5}
#stats{font-size:11px;color:var(--mut);margin:6px 0 8px;min-height:30px}
#actions .btns{padding:0}
#vp{position:relative;overflow:hidden;background:#fff}
#cv{width:100%;height:100%;display:block;touch-action:none}
.float{position:absolute;background:rgba(255,255,255,.94);border:1px solid var(--line);border-radius:12px;padding:6px;display:flex;gap:6px;align-items:center}
#tools{left:14px;top:14px}
#views{right:14px;top:14px}
#bottombar{left:14px;bottom:14px;gap:14px;padding:8px 12px;flex-wrap:wrap;max-width:calc(100% - 28px)}
#bottombar label{display:flex;align-items:center;gap:6px;color:#444}
#bottombar input[type=range]{width:100px}
#tip{position:absolute;left:50%;transform:translateX(-50%);bottom:70px;color:var(--mut);font-size:12px;background:rgba(255,255,255,.9);padding:3px 10px;border-radius:99px;pointer-events:none;white-space:nowrap}
#busy{position:absolute;inset:0;background:rgba(255,255,255,.82);display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;z-index:5}
#busy .bar{width:260px;height:4px;background:#eceef3;border-radius:4px;overflow:hidden}
#busy .bar i{display:block;height:100%;width:0;background:var(--acc)}
#msg{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:var(--mut)}
@media(max-width:820px){#app{grid-template-columns:1fr;grid-template-rows:55% 45%}#side{order:2;padding-bottom:20px}#actions{position:static;width:auto}#tip{display:none}}
</style>
</head>
<body>
<div id="app">
  <aside id="side">
    <header><b>CASE GEN</b><div class="mut">Voronoi phone hull · base tray + clip-in cover · Samsung Galaxy A15</div></header>
    <div id="groups"></div>
    <section>
      <h3>Cutouts</h3>
      <div class="hint"><b>Measure on the phone, type it in – it is saved in this browser automatically</b> (separate from the project file). Use the <b>edge distances</b> (green fields): distance from the phone's <b>top edge</b> (camera end) to the nearest end of the opening, from its <b>left edge</b> (seen from the front, button side = right), and from its <b>back face</b>. The sizes are the full opening dimensions: X = across the width, Y = through the thickness, Z = along the length. “Through” = the direction the opening passes through the wall; the radius rounds the corners (half of the smaller side = fully round). The red boxes in the 3D view show where they land; the extra clearance (Voronoi structure) is added on every side.</div>
      <div id="cuts"></div>
      <div class="btns"><button class="btn" id="addCut">+ Add cutout</button><button class="btn" id="resetCuts">Reset to estimates</button></div>
    </section>
    <section>
      <h3>Voronoi points</h3>
      <div class="btns">
        <span class="seg"><button class="btn on" data-part="A">Design: Base</button><button class="btn" data-part="B">Cover</button></span>
      </div>
      <div class="btns">
        <button class="btn on" data-tool="pt" title="A"><span class="dot" style="background:#1a66ff"></span>Point</button>
        <button class="btn" data-tool="out" title="O"><span class="dot" style="background:#e2399c"></span>Blob</button>
        <button class="btn" data-tool="zone" title="Z"><span class="dot" style="background:#0fb5ae"></span>Mesh zone</button>
        <button class="btn" data-tool="move" title="M">Move</button>
        <button class="btn" data-tool="del" title="D">Delete</button>
        <button class="btn" id="undo">Undo</button>
      </div>
      <div id="selPanel">
        <div class="hd" id="selInfo"></div>
        <div class="grid3">
          <label>X<input type="number" step="0.5" id="seX"></label>
          <label>Y<input type="number" step="0.5" id="seY"></label>
          <label>Z<input type="number" step="0.5" id="seZ"></label>
          <label id="seRL">Radius<input type="number" step="0.5" min="1" id="seR"></label>
        </div>
        <div class="btns" style="padding:8px 0 0"><button class="btn" id="selSnap">Snap to surface</button><button class="btn" id="selDel">Delete point</button></div>
      </div>
      <div class="row"><label>Strut width</label><span><input type="range" data-strut min="1" max="8" step="0.1" style="width:110px;vertical-align:middle"> <b data-strutv>3.2</b> mm</span></div>
      <div class="row"><label><input type="checkbox" id="mirror"> Mirror left/right (X)</label><span class="mut" id="seedCount">0 points</span></div>
      <div class="row"><label>Scatter</label>
        <span><input type="number" id="scN" value="40" min="2" max="300" style="width:60px">
        <select id="scR"><option value="back">back</option><option value="all">everywhere</option></select>
        <button class="btn" id="scatter">Go</button></span></div>
      <div class="btns"><button class="btn" id="clearSeeds">Clear points of this part</button></div>
      <div class="hint"><b>Point</b> = one hole; struts run along the borders between neighbouring points. <b>Blob</b> = inflates the shell outward: the inside against the phone (and the clip) stays untouched, the added volume is divided into 3D Voronoi cells automatically (“Blob cell size” – open pockets on the surface plus cells in depth), and normal points you place on a blob replace the automatic cells around them. A blob does not grow below the back of the case (adjustable); blobs exist on the base only. The cover stays flat; use <b>Mesh zone</b> (cover only) to cover parts of the display on purpose: a zone is a circle with a radius, only the points inside that radius are connected into a mesh there (points outside any zone are ignored and shown grey), and the frame around the screen is never touched. Only closed membranes that lie completely inside the radius are drawn – no half membranes cut by the radius (cells open into each other where a wall would be cut). A zone has to overlap the frame, otherwise the mesh would float – the stats line warns about loose parts. Click a point to select it: arrows appear in the 3D view and XYZ fields in the sidebar · Del removes it · Alt-click removes too · Ctrl+C / Ctrl+V copies a point or blob and pastes it at the cursor with the same settings. “Design: Base / Cover” switches which part you edit; the other one is shown faded.</div>
    </section>
    <section>
      <h3>Project</h3>
      <div class="btns">
        <button class="btn" id="saveP">Save project</button>
        <button class="btn" id="loadP">Load project</button>
        <button class="btn" id="resetP">Reset dimensions</button>
      </div>
      <input type="file" id="fileP" accept=".json" style="display:none">
    </section>
    <div id="actions">
      <div class="row" style="padding:0 0 4px"><label><input type="checkbox" id="autoPrev" checked> Live rough preview</label></div>
      <div class="row" style="padding:0 0 6px"><label>Detail resolution</label>
        <select id="res"><option value="0.8">Draft 0.8 mm</option><option value="0.5" selected>Normal 0.5 mm</option><option value="0.35">Fine 0.35 mm</option></select></div>
      <button class="btn primary" id="regen" style="width:100%;padding:9px">Build detailed mesh</button>
      <div style="margin-top:8px"><span id="chip" class="dirty">not generated</span></div>
      <div id="stats"></div>
      <div class="btns">
        <button class="btn" id="expA" disabled>Base .stl</button>
        <button class="btn" id="expB" disabled>Cover .stl</button>
        <button class="btn" id="expAll" disabled>Print plate .stl</button>
        <button class="btn" id="expTest" title="A 26 mm stretch of the clip (base + cover, one hook) – print this first (about 10 min) to check the fit">Fit-test .stl</button>
      </div>
    </div>
  </aside>
  <main id="vp">
    <canvas id="cv"></canvas>
    <div class="float" id="tools">
      <span class="seg"><button class="btn on" data-part="A">Base</button><button class="btn" data-part="B">Cover</button></span>
      <span class="sep"></span>
      <button class="btn on" data-tool="pt" title="A"><span class="dot" style="background:#1a66ff"></span>Point</button>
      <button class="btn" data-tool="out" title="O"><span class="dot" style="background:#e2399c"></span>Blob</button>
      <button class="btn" data-tool="zone" title="Z"><span class="dot" style="background:#0fb5ae"></span>Mesh zone</button>
      <button class="btn" data-tool="move" title="M">Move</button>
      <button class="btn" data-tool="del" title="D">Delete</button>
    </div>
    <div class="float" id="views">
      <button class="btn" data-view="persp">3D</button>
      <button class="btn" data-view="back">Back</button>
      <button class="btn" data-view="front">Front</button>
      <button class="btn" data-view="side">Side</button>
    </div>
    <div id="tip"></div>
    <div class="float" id="bottombar">
      <label>Explode <input type="range" id="explode" min="0" max="40" value="0" step="1"></label>
      <label>Struts <input type="range" data-strut min="1" max="8" step="0.1"></label>
      <label>Grain <input type="range" id="grain" min="0" max="1" value="0.3" step="0.02"></label>
      <label><input type="checkbox" id="secOn"> Section <input type="range" id="secX" min="-60" max="60" value="0" step="0.5"></label>
      <label><input type="checkbox" id="showSeeds" checked> Points</label>
      <label><input type="checkbox" id="showCuts" checked> Cutouts</label>
      <label><input type="checkbox" id="showPhone"> Phone</label>
    </div>
    <div id="busy"><div id="busyTxt">Generating…</div><div class="bar"><i id="busyBar"></i></div></div>
    <div id="msg"></div>
  </main>
</div>

<!-- ============ GEOMETRY CORE (no DOM / no three.js) ============ -->
<script id="core">
const Core=(()=>{
  const sq=Math.sqrt;
  const smax=(a,b,k)=>{const h=Math.max(k-Math.abs(a-b),0)/k;return Math.max(a,b)+h*h*k*0.25;};
  const smin=(a,b,k)=>{const h=Math.max(k-Math.abs(a-b),0)/k;return Math.min(a,b)-h*h*k*0.25;};
  const tick=()=>new Promise(r=>setTimeout(r,0));

  function dims(P){
    const c=P.clr, hx=P.W/2+c, hy=P.T/2+c, hz=P.L/2+c;
    let re=Math.max(0.2,Math.min(P.Re+c,hy-0.01));
    const rp=Math.max(P.Rc+c,re); re=Math.min(re,rp);
    const reO=Math.max(0.2,Math.min(P.reO==null?1.5:P.reO,re+P.wall));           // outer back-edge radius (independent of the cavity)
    const cB=Math.max(0.6,P.wall-(P.rimT==null?1.4:P.rimT)), cS=cB-P.gap;        // cB: inner face of the base rim, cS: outer face of cover plate / skirt
    return {hx,hy,hz,re,rp,reO,cB,cS};
  }
  // yP0: underside of the cover plate (= top of the cavity), yP1: flat top of case
  function levels(P){const D=dims(P);return {yP0:D.hy,yP1:D.hy+P.cover};}
  // signed distance to a rounded box (plan radius rp, edge radius re on the back side); above the mid-plane the walls continue straight up (open top)
  function makeRB(hx,hy,hz,re,rp){
    const cx=hx-re, cz=hz-re, cr=rp-re, cy=hy-re;
    return (x,y,z)=>{
      const qx=Math.abs(x)-cx+cr, qz=Math.abs(z)-cz+cr;
      const ax=qx>0?qx:0, az=qz>0?qz:0;
      const d2=sq(ax*ax+az*az)+Math.min(Math.max(qx,qz),0)-cr;
      const yy=y>0?0:y;
      const qy=Math.abs(yy)-cy;
      const d2p=d2>0?d2:0, qyp=qy>0?qy:0;
      return sq(d2p*d2p+qyp*qyp)+Math.min(Math.max(d2,qy),0)-re;
    };
  }
  function makeC(P){const D=dims(P);return makeRB(D.hx,D.hy,D.hz,D.re,D.rp);}                 // phone cavity
  function makeO(P){const D=dims(P),t=P.wall;return makeRB(D.hx+t,D.hy+t,D.hz+t,D.reO,D.rp+t);}  // outer skin of the base tray
  function makeOpen(P){
    const lipR=Math.max(P.Rc-P.lip,1.0), cx=(P.W/2-P.lip)-lipR, cz=(P.L/2-P.lip)-lipR;
    return (x,z)=>{
      const qx=Math.abs(x)-cx,qz=Math.abs(z)-cz;
      const ax=qx>0?qx:0,az=qz>0?qz:0;
      return sq(ax*ax+az*az)+Math.min(Math.max(qx,qz),0)-lipR;
    };
  }
  // blobs: [{x,y,z,r}] smooth-unioned spheres (3D) or discs (xz only)
  function blobFns(blobs,kb){
    const n=blobs.length;
    return {
      n,
      d3:(x,y,z)=>{let d=Infinity;for(let i=0;i<n;i++){const b=blobs[i],dx=x-b.x,dy=y-b.y,dz=z-b.z;const t=sq(dx*dx+dy*dy+dz*dz)-b.r;d=i===0?t:smin(d,t,kb);}return d;},
      d2:(x,z)=>{let d=Infinity;for(let i=0;i<n;i++){const b=blobs[i],dx=x-b.x,dz=z-b.z;const t=sq(dx*dx+dz*dz)-b.r;d=i===0?t:smin(d,t,kb);}return d;}
    };
  }
  // outer solid of a part (used for picking / placing points): negative inside.
  // base: hull offset by wall, merged with blob spheres.  cover: plate slab, merged with blob discs.
  function makeShape(P,blobs,part){
    const C=makeC(P),O=makeO(P),Lv=levels(P),tw=P.wall,Dm=dims(P),mid=Dm.cB-P.gap/2,kb=Math.max(P.blobK,0.5),bf=blobFns(blobs||[],kb);
    const yF=-(dims(P).hy+tw)-(P.blobDown||0);
    if(part==='A')return {S:(x,y,z)=>{let d=O(x,y,z);if(bf.n)d=Math.max(smin(d,smax(bf.d3(x,y,z),yF-y,0.8),kb),yF-y);return d;}};
    const lim=Dm.cS, gv=P.gapV;
    return {S:(x,y,z)=>{
      const c=C(x,y,z);
      const slab=Math.max(Lv.yP0-y,y-Lv.yP1);
      let d=Math.max(c-mid,slab);
      if(bf.n)d=smin(d,Math.max(bf.d3(x,y,z),Lv.yP0-y,Math.min(c-lim,Lv.yP1+gv-y,tw+gv-c)),kb);   // dome: inflates upward / outward, never below the plate underside
      return d;}};
  }
  // automatic 3D cell centres inside the volume a blob adds: one layer just under the blob surface (so the cells open outward
  // as visible pockets) plus a jittered lattice deeper inside (so the bulge is divided in depth, not one thin shell)
  function volSeeds(P,part,blobs,S){
    const cell=P.blobCell; if(!(cell>0.5)||!blobs||!blobs.length)return [];
    const D=dims(P),C=makeC(P),Lv=levels(P),tw=P.wall,lim=D.cS,gv=P.gapV,yF=-(D.hy+tw)-(P.blobDown||0);
    const out=[];
    const ok=(x,y,z)=>{const c=C(x,y,z);
      if(part==='A')return c>1.0&&y<Lv.yP1-1.0&&y>yF+0.5;
      return y>=Lv.yP0+0.8&&(c<lim-0.5||y>Lv.yP1+gv+0.8||c>tw+gv+0.8);};
    const near=(x,y,z,r2)=>{
      for(let i=0;i<S.length;i++){const q=S[i],dx=q[0]-x,dy=q[1]-y,dz=q[2]-z;if(dx*dx+dy*dy+dz*dz<r2)return true;}
      for(let i=0;i<out.length;i++){const q=out[i],dx=q[0]-x,dy=q[1]-y,dz=q[2]-z;if(dx*dx+dy*dy+dz*dz<r2)return true;}
      return false;};
    const hash=(a,b,c,d)=>{const t=Math.sin(a*127.1+b*311.7+c*74.7+d*19.3)*43758.5453;return t-Math.floor(t);};
    blobs.forEach((b,bi)=>{
      const depth=Math.min(cell*0.45,b.r*0.45), R=b.r-depth;
      const N=Math.max(6,Math.round(4*Math.PI*R*R/(cell*cell*0.85)));
      const ga=Math.PI*(3-Math.sqrt(5)), rs=cell*0.55;
      for(let i=0;i<N;i++){
        const yy=1-(i+0.5)/N*2, rr=sq(1-yy*yy), th=ga*i+bi*1.7;
        const x=b.x+R*rr*Math.cos(th), y=b.y+R*yy, z=b.z+R*rr*Math.sin(th);
        if(!ok(x,y,z)||near(x,y,z,rs*rs))continue;
        out.push([x,y,z]);
      }
      const n=Math.ceil(b.r/cell), ri=0.7*cell;
      for(let i=-n;i<=n;i++)for(let j=-n;j<=n;j++)for(let k=-n;k<=n;k++){
        const x=b.x+(i+(hash(i,j,k,bi)-.5)*.7)*cell, y=b.y+(j+(hash(j,k,i,bi+7)-.5)*.7)*cell, z=b.z+(k+(hash(k,i,j,bi+13)-.5)*.7)*cell;
        const dx=x-b.x,dy=y-b.y,dz=z-b.z; if(sq(dx*dx+dy*dy+dz*dz)>R-cell*0.35)continue;
        if(!ok(x,y,z)||near(x,y,z,ri*ri))continue;
        out.push([x,y,z]);
      }
    });
    return out;
  }
  function bounds(P,part,blobs){
    const D=dims(P), L=levels(P), tw=P.wall, m=1.6;
    let y0,y1;
    if(part==='A'){y0=-D.hy-tw-(P.blobDown||0);y1=L.yP1;}else{y0=L.yP0-P.joint;y1=L.yP1;}
    let x0=-(D.hx+tw),x1=D.hx+tw,z0=-(D.hz+tw),z1=D.hz+tw;
    const e=Math.max(P.blobK,0.5)/2+m;
    for(const b of (blobs||[])){
      x0=Math.min(x0,b.x-b.r-e);x1=Math.max(x1,b.x+b.r+e);z0=Math.min(z0,b.z-b.r-e);z1=Math.max(z1,b.z+b.r+e);
      if(part!=='A')y1=Math.max(y1,b.y+b.r+e);
    }
    return {x0:x0-m,x1:x1+m,y0:y0-m,y1:y1+m,z0:z0-m,z1:z1+m};
  }

  // rounded box signed distance (negative inside), centre/half-size per axis, corner radius rad
  function rbx(p,q,r,pc,ph,qc,qh,rc,rh,rad){
    const a=Math.abs(p-pc)-ph+rad,b=Math.abs(q-qc)-qh+rad,d=Math.abs(r-rc)-rh+rad;
    const A=a>0?a:0,B=b>0?b:0,Dd=d>0?d:0;
    return sq(A*A+B*B+Dd*Dd)+Math.min(Math.max(a,b,d),0)-rad;
  }
  // positions of the clip hooks along the straight parts of the outline. kind 0: on the long (x) sides, tangent = z; kind 1: on the short (z) sides, tangent = x.
  // c0 = tangent coordinate of the arm root, dir = direction from root to free end, ctr = centre of the hook span
  function planHooks(P){
    const D=dims(P),out=[],E=P.hookL+P.slitW+0.6;
    const add=(kind,sgn,n,S)=>{
      const R=S-E/2; if(n<1||R<0)return;
      for(let k=0;k<n;k++){
        const ctr=n===1?0:-R+k*(2*R)/(n-1);
        out.push({kind,sgn,ctr,dir:1,c0:ctr-E/2+0.3});
      }
    };
    for(const sgn of [-1,1]){
      add(0,sgn,Math.round(P.hookN||0),D.hz-D.rp-1.5);
      add(1,sgn,Math.round(P.hookNs||0),D.hx-D.rp-1.5);
    }
    return out;
  }

  // S: voronoi points of this part [[x,y,z],...]; blobs: [{x,y,z,r}] of this part
  function buildEvaluator(P,S,CUTS,part,blobs,keeps){
    const userN=S.length, S2=S.concat(volSeeds(P,part,blobs||[],S)); S=S2;
    const D=dims(P), Lv=levels(P), C=makeC(P), O=makeO(P), open=makeOpen(P);
    const tw=P.wall, g=P.gap, gv=P.gapV, J=P.joint, cB=D.cB, cS=D.cS;
    const yP0=Lv.yP0, yP1=Lv.yP1;
    const kTop=Math.max(P.topR*2,0.01);
    const half=P.strut/2, kc=Math.max(P.corner,0.05), kE=Math.max(P.round*2,0.01), kk=0.8;
    const kEB=Math.min(kE,P.cover*0.9);
    const yLim=yP0-J-P.seamMargin, rim=P.rim, rimB=P.rimB;
    const ox=D.hx-D.rp, oz=D.hz-D.rp, orad=D.rp+tw;
    const outD=(x,z)=>{
      const qx=Math.abs(x)-ox,qz=Math.abs(z)-oz;
      const ax=qx>0?qx:0,az=qz>0?qz:0;
      return sq(ax*ax+az*az)+Math.min(Math.max(qx,qz),0)-orad;
    };
    const wRing=Math.max(0.8,P.strut);
    const kb=Math.max(P.blobK,0.5), bf=blobFns(blobs||[],kb), nb=bf.n, yFloor=-(D.hy+tw)-(P.blobDown||0);

    // cutouts: rounded slot outline in the plane perpendicular to the "through" axis
    const cuts=(CUTS||[]).filter(c=>c.on).map(c=>{
      const ax=c.ax||'y'; let ha,hb,ht,ia,ib,it;
      if(ax==='y'){ha=c.sx/2;hb=c.sz/2;ht=c.sy/2;ia=0;ib=2;it=1;}
      else if(ax==='x'){ha=c.sy/2;hb=c.sz/2;ht=c.sx/2;ia=1;ib=2;it=0;}
      else{ha=c.sx/2;hb=c.sy/2;ht=c.sz/2;ia=0;ib=1;it=2;}
      const mg=P.cutMargin||0; ha+=mg;hb+=mg;ht+=mg;                     // extra clearance on every side
      const r=Math.max(0,Math.min((c.r==null?1.5:c.r)+mg,ha,hb));
      return {c:[c.x,c.y,c.z],ha:ha-r,hb:hb-r,ht,r,ia,ib,it};
    });
    const pp=[0,0,0];
    function cutDist(q,x,y,z){
      pp[0]=x-q.c[0];pp[1]=y-q.c[1];pp[2]=z-q.c[2];
      const qa=Math.abs(pp[q.ia])-q.ha,qb=Math.abs(pp[q.ib])-q.hb;
      const a=qa>0?qa:0,b=qb>0?qb:0;
      const d2=sq(a*a+b*b)+Math.min(Math.max(qa,qb),0)-q.r;
      const dt=Math.abs(pp[q.it])-q.ht;
      const e2=d2>0?d2:0,et=dt>0?dt:0;
      return sq(e2*e2+et*et)+Math.min(Math.max(d2,dt),0);
    }
    const cRim=P.cutRim==null?3:P.cutRim;
    function cutNear(x,y,z){let d=1e9;for(let i=0;i<cuts.length;i++){const t=cutDist(cuts[i],x,y,z);if(t<d)d=t;}return d;}
    function applyCuts(v,x,y,z){
      for(let i=0;i<cuts.length;i++)v=smax(v,-cutDist(cuts[i],x,y,z),0.5);
      return v;
    }

    // ---------- clip hooks.  Spring arms are cut into the cover plate (they flex in the print plane = strongest direction for FDM);
    // each arm's free end tucks under a lip on the base rim.  Hooks that would collide with a cutout are dropped.
    const Lh=P.hookL, sw=P.slitW, fW=P.fingerW, E=Lh+sw+0.6, ws=Math.max(1.1,(cS-(cB-P.lipD))+0.4), rail=1.0;   // ws: width of the slot the arm springs into; a rail of the frame runs past it so the frame stays one piece
    const hooks=planHooks(P).filter(h=>{
      for(const u of [-0.3,E/2-0.3,E-0.6]){
        const t=h.c0+h.dir*u, x=h.kind===0?h.sgn*(D.hx+cB):t, z=h.kind===0?t:h.sgn*(D.hz+cB);
        if(cutNear(x,yP1-1,z)<2.5||cutNear(x,yP0-2,z)<2.5)return false;
      }return true;});
    function hu(h,x,z){                                                      // tangent coordinate from the arm root (NaN = not beside this hook)
      if(h.kind===0){if(h.sgn*x<D.hx-4)return NaN;return (z-h.c0)*h.dir;}
      if(h.sgn*z<D.hz-4)return NaN;return (x-h.c0)*h.dir;
    }
    // the lip's depth follows the arm's own bending curve (an arm barely moves near its root), so every part of the lip needs about the same strain to pass
    const lipSl=P.catchH/Math.max(P.lipD,0.2), nrm=Math.sqrt(1+lipSl*lipSl);
    const lu1=Lh-1.4, lu0=Lh*0.45, gT=0.3, chP=1.0, chS=Math.min(0.7,J*0.2), yBot=yP0-J+gv;
    const gf=t=>(3*t*t-t*t*t)/2, gMax=gf(lu1/Lh);
    const dep=u=>P.lipD*Math.max(0,Math.min(1,gf(u/Lh)/gMax));
    function lipBase(c,y,u){                                                  // base lip, negative inside; underside slopes down toward the outside (catch ramp)
      const d=dep(u),ct=cB-d,chT=Math.min(0.8,P.lipH*0.6,0.6*d);
      const a=ct-c,b=c-(cB+0.4),t=y-yP1,chm=(chT-((c-ct)+(yP1-y)))*0.70711,un=(yP1-P.lipH-lipSl*(c-ct)-y)/nrm;
      return Math.max(a,b,t,chm,un,lu0-u,u-lu1);
    }
    function notchD(c,y,u){                                                    // recess in the cover plate (inside negative)
      const ct=cB-dep(u);
      return Math.max(ct-g-c,y-(yP1+1),(yP1-P.lipH-lipSl*(c-ct)-gv-y)/nrm,lu0-gT-u,u-(lu1+gT),c-(cB+1));
    }
    function lipUnion(x,y,z,c){
      let d=1e9;
      for(let i=0;i<hooks.length;i++){const u=hu(hooks[i],x,z);if(u!==u||u<lu0-1||u>lu1+1)continue;const q=lipBase(c,y,u);if(q<d)d=q;}
      return d;
    }
    function hookPad(x,y,z,c){                                                // frame rail: runs past each arm slot, padding the screen opening a little
      let d=1e9;const L2=Lh+sw,cOut=cS-fW-ws-rail,cIn=cS-fW-ws+0.4;
      if(cOut>=-(P.lip+P.clr))return d;
      for(let i=0;i<hooks.length;i++){const u=hu(hooks[i],x,z);if(u!==u||u<-1.6||u>L2+1.6)continue;
        const q=rbx(u,c,y,L2/2,L2/2+1.6,(cOut+cIn)/2,(cIn-cOut)/2,(yP0+yP1)/2,(yP1-yP0)/2,0.5);if(q<d)d=q;}
      return d;
    }
    function hookRemove(x,y,z,c){                                             // cover: >0 = remove material here
      let r=-1e9;
      for(let i=0;i<hooks.length;i++){
        const u=hu(hooks[i],x,z);if(u!==u||u<-0.7||u>E)continue;
        const L2=Lh+sw;
        const slot=rbx(u,c,y,L2/2,L2/2,cS-fW-ws/2,ws/2,(yP0+yP1)/2,(yP1-yP0)/2+1,0.5);                    // slot the arm springs into
        const endS=rbx(u,c,y,Lh+sw/2,sw/2,(cS-fW-ws+0.3+cS+4)/2,(cS+4-(cS-fW-ws+0.3))/2,(yP0-J-1+yP1+1)/2,(yP1+1-(yP0-J-1))/2,0.4);   // slit at the free end
        const skirt=rbx(u,c,y,L2/2,L2/2+0.3,(cS+2-1)/2,(cS+2+1)/2,(yP0-J-2+yP0)/2,(J+2)/2,0.0);          // no skirt under the arm
        const ins=Math.min(u,L2-u);
        const cham=Math.min((chP-((cS-c)+(y-yP0)))*0.70711,ins);                                          // lead-in chamfer under the arm tip
        r=Math.max(r,-slot,-endS,-skirt,cham);
        if(u>=lu0-gT-1&&u<=lu1+gT+1)r=Math.max(r,-notchD(c,y,u));   // recess for the lip
      }
      return r;
    }

    // ---- voronoi: exact distance to the cell borders via bisector planes (factory: one lattice per seed list)
    function makeStrut(list){
      const NS=list.length; if(NS<2)return null;
      const sxA=new Float32Array(NS),syA=new Float32Array(NS),szA=new Float32Array(NS);
      list.forEach((q,i)=>{sxA[i]=q[0];syA[i]=q[1];szA[i]=q[2];});
      const PL=[];
      for(let i=0;i<NS;i++){
        const arr=new Float32Array(4*(NS-1));let k=0;
        for(let j=0;j<NS;j++){ if(j===i)continue;
          const dx=sxA[j]-sxA[i],dy=syA[j]-syA[i],dz=szA[j]-szA[i];
          const len=sq(dx*dx+dy*dy+dz*dz); if(len<1e-4)continue;
          const ux=dx/len,uy=dy/len,uz=dz/len;
          const mx=(sxA[i]+sxA[j])/2,my=(syA[i]+syA[j])/2,mz=(szA[i]+szA[j])/2;
          arr[k]=ux;arr[k+1]=uy;arr[k+2]=uz;arr[k+3]=ux*mx+uy*my+uz*mz;k+=4;
        }
        PL.push(arr.subarray(0,k));
      }
      const scratch=new Float64Array(NS);
      return function(x,y,z){
        let bi=0,bd=Infinity;
        for(let i=0;i<NS;i++){const dx=x-sxA[i],dy=y-syA[i],dz=z-szA[i];const d=dx*dx+dy*dy+dz*dz;if(d<bd){bd=d;bi=i;}}
        const pl=PL[bi],M=pl.length; if(M===0)return 1e3;
        let mn=Infinity,n=0;
        for(let k=0;k<M;k+=4){const d=pl[k+3]-(pl[k]*x+pl[k+1]*y+pl[k+2]*z);scratch[n++]=d;if(d<mn)mn=d;}
        let sum=0;const cut=kc*7;
        for(let j=0;j<n;j++){const e=scratch[j]-mn;if(e<cut)sum+=Math.exp(-e/kc);}
        return mn-kc*Math.log(sum);
      };
    }
    const PL=part==='A'?makeStrut(S):null, strut=PL;

    if(part==='A'){
      // ---------- base tray: back + walls (+ blob bulges), open top; thin outer rim on top holds the cover
      const ox2=D.hx-D.rp, oz2=D.hz-D.rp, eK=P.edgeKeep||0, cK=P.cornerKeep||0, keepOn=eK>0||cK>0;
      // solid support zone: band along all inner edges + corner posts – never carved by struts or blob cells
      function protect(x,y,z){
        const u=outD(x,z)+tw, w=-(y+D.hy);                                // u: plan distance to the cavity wall (neg inside), w: depth below the back plane
        let p=Math.min(u+eK,w+eK);
        if(cK>0){const qx=Math.abs(x)-ox2,qz=Math.abs(z)-oz2;p=Math.max(p,Math.min(Math.min(qx,qz)+cK,u+Math.max(eK,1)));}
        return p;
      }
      return function fA(x,y,z){
        const c=C(x,y,z);
        let Sx=O(x,y,z),bd=1e9;
        if(nb){bd=smax(bf.d3(x,y,z),yFloor-y,0.8);Sx=Math.max(smin(Sx,bd,kb),yFloor-y);}   // blob never spreads below the back (hard limit, also against the fillet)
        const s=Math.max(smax(Sx,y-yP1,kTop),-c);            // solid shell + inflated volume; inner face stays exactly at the phone cavity
        if(s>1.0)return s;
        let v=Math.max(s,Math.min(y-(yP0-J),cB-c));                        // above the step only the outer rim (c >= cB) remains: the cover skirt sits inside
        if(PL){
          let m=y-yLim;
          if(keepOn)m=Math.max(m,protect(x,y,z));
          if(rim>0&&!(nb&&bd<2)){m=Math.max(m,outD(x,z)+rim);}
          if(userN<2)m=Math.max(m,bd+0.5);                            // no user points: only the blob volume is subdivided
          if(cRim>0&&cuts.length)m=Math.max(m,cRim-cutNear(x,y,z));   // solid frame around every cutout
          if(m<3){
            const b=strut(x,y,z);
            v=smax(v,smin(b-half,-m,kk),kE);
          }
        }
        if(hooks.length&&y>yP1-P.lipH-P.catchH-1){const q=lipUnion(x,y,z,c);if(q<v)v=q;}
        return applyCuts(v,x,y,z);
      };
    }
    // ---------- cover: flat frame over the screen (+ blob lobes) + skirt that slides into the tray wall
    // ---------- cover: flat frame around the screen opening (ALWAYS intact) + skirt; mesh zones add a voronoi mesh over the display
    // explicit 2D membranes of a zone: a Voronoi edge is kept only if it is closed (finite) AND lies completely inside the zone radius –
    // no half membranes cut by the radius, no open rays
    function makeZoneStrut(list,zn){
      const n=list.length; if(n<3)return null;
      const X=list.map(q=>q[0]),Zc=list.map(q=>q[2]),segs=[];
      for(let i=0;i<n;i++)for(let j=i+1;j<n;j++){
        const ex=X[j]-X[i],ez=Zc[j]-Zc[i],L=sq(ex*ex+ez*ez); if(L<1e-4)continue;
        const mx=(X[i]+X[j])/2,mz=(Zc[i]+Zc[j])/2,dx=-ez/L,dz=ex/L;
        let t0=-Infinity,t1=Infinity,empty=false;
        for(let k=0;k<n&&!empty;k++){ if(k===i||k===j)continue;
          const fx=X[k]-X[i],fz=Zc[k]-Zc[i],c=(X[k]*X[k]+Zc[k]*Zc[k])-(X[i]*X[i]+Zc[i]*Zc[i]);
          const a=2*(dx*fx+dz*fz),b=c-2*(mx*fx+mz*fz);          // a*t <= b
          if(Math.abs(a)<1e-9){if(b<0)empty=true;}
          else if(a>0){t1=Math.min(t1,b/a);} else {t0=Math.max(t0,b/a);}
        }
        if(empty||!isFinite(t0)||!isFinite(t1)||t1-t0<1e-4)continue;
        const px=mx+t0*dx,pz=mz+t0*dz,qx=mx+t1*dx,qz=mz+t1*dz;
        if(Math.hypot(px-zn.x,pz-zn.z)>zn.r||Math.hypot(qx-zn.x,qz-zn.z)>zn.r)continue;   // would be cut by the radius → no membrane at all
        segs.push(px,pz,qx,qz);
      }
      const m=segs.length/4; if(!m)return null;
      const sc=new Float64Array(m);
      return function(x,y,z){
        let mn=Infinity;
        for(let k=0;k<m;k++){const o=4*k,ax=segs[o],az=segs[o+1],bx=segs[o+2]-ax,bz=segs[o+3]-az;
          let t=((x-ax)*bx+(z-az)*bz)/(bx*bx+bz*bz);t=t<0?0:t>1?1:t;
          const d=Math.hypot(x-ax-bx*t,z-az-bz*t);sc[k]=d;if(d<mn)mn=d;}
        let sum=0;const cut=kc*7;
        for(let k=0;k<m;k++){const e=sc[k]-mn;if(e<cut)sum+=Math.exp(-e/kc);}
        return mn-kc*Math.log(sum);
      };
    }
    const Z=(keeps||[]).map(zn=>({x:zn.x,z:zn.z,r:zn.r,st:makeZoneStrut(S.filter(q=>Math.hypot(q[0]-zn.x,q[2]-zn.z)<=zn.r),zn)}));
    return function fB(x,y,z){
      const c=C(x,y,z);
      const lim=cS;
      let bs=Math.max(c-lim,(yP0-J+gv)-y,y-yP1,Math.min(yP0-y,-c));
      bs=smax(bs,-Math.max(open(x,z),(yP0-1)-y),0.6);                 // frame with the screen opening – never carved
      let v=bs;
      for(let i=0;i<Z.length;i++){
        const zn=Z[i],dx=x-zn.x,dz=z-zn.z,dk=sq(dx*dx+dz*dz)-zn.r;   // dk<0 inside the zone radius
        if(dk>half+kc+1)continue;
        const Pl=Math.max(c-lim,yP0-y,y-yP1);                         // plate layer, inside the outline
        let sol=1e3;
        if(zn.st)sol=smax(Pl,zn.st(x,y,z)-half,kEB);                  // cell borders of the points inside the radius only
        v=smin(v,sol,0.8);                                            // only struts of those points – no ring along the zone edge
      }
      if(hooks.length){const q=hookPad(x,y,z,c);if(q<v)v=q;}
      if(v>1.0)return v;
      v=Math.max(v,(chS-(cS-c)-(y-yBot))*0.70711);                    // lead-in chamfer at the skirt's lower outer edge
      if(hooks.length)v=Math.max(v,hookRemove(x,y,z,c));              // spring arms, slots, recesses
      return applyCuts(v,x,y,z);
    };
  }

  async function meshPart(f,B,h,passes,prog){
    const nx=Math.ceil((B.x1-B.x0)/h)+1, ny=Math.ceil((B.y1-B.y0)/h)+1, nz=Math.ceil((B.z1-B.z0)/h)+1;
    const N=nx*ny*nz, F=new Float32Array(N);
    let i=0,t0=performance.now();
    for(let z=0;z<nz;z++){
      const wz=B.z0+z*h;
      for(let y=0;y<ny;y++){const wy=B.y0+y*h;for(let x=0;x<nx;x++){F[i++]=f(B.x0+x*h,wy,wz);}}
      if(performance.now()-t0>40){prog&&prog(0.7*z/nz);await tick();t0=performance.now();}
    }
    // ---- naive surface nets
    const cell=new Int32Array(N).fill(-1);
    const pos=[];
    const sx=1,sy=nx,sz=nx*ny, st=[sx,sy,sz];
    const offs=[0,sx,sy,sx+sy,sz,sx+sz,sy+sz,sx+sy+sz];
    const E=[[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]];
    const v=new Float32Array(8);
    t0=performance.now();
    for(let z=0;z<nz-1;z++){
      for(let y=0;y<ny-1;y++){
        for(let x=0;x<nx-1;x++){
          const i0=x+nx*(y+ny*z);
          let mask=0;
          for(let k=0;k<8;k++){const val=F[i0+offs[k]];v[k]=val;if(val<0)mask|=(1<<k);}
          if(mask===0||mask===255)continue;
          let cx=0,cy=0,cz=0,cnt=0;
          for(let e=0;e<12;e++){
            const a=E[e][0],b=E[e][1];
            if(((mask>>a)&1)!==((mask>>b)&1)){
              const t=v[a]/(v[a]-v[b]);
              cx+=(a&1)+t*((b&1)-(a&1));
              cy+=((a>>1)&1)+t*(((b>>1)&1)-((a>>1)&1));
              cz+=((a>>2)&1)+t*(((b>>2)&1)-((a>>2)&1));
              cnt++;
            }
          }
          cell[i0]=pos.length/3;
          pos.push(B.x0+(x+cx/cnt)*h,B.y0+(y+cy/cnt)*h,B.z0+(z+cz/cnt)*h);
        }
      }
      if(performance.now()-t0>40){prog&&prog(0.7+0.15*z/nz);await tick();t0=performance.now();}
    }
    const idx=[];
    for(let z=1;z<nz-1;z++)for(let y=1;y<ny-1;y++)for(let x=1;x<nx-1;x++){
      const i0=x+nx*(y+ny*z), a0=F[i0]<0;
      for(let ax=0;ax<3;ax++){
        if(a0===(F[i0+st[ax]]<0))continue;
        const u=st[(ax+1)%3],w=st[(ax+2)%3];
        const c0=cell[i0],c1=cell[i0-u],c2=cell[i0-u-w],c3=cell[i0-w];
        if(a0)idx.push(c0,c1,c2,c0,c2,c3);else idx.push(c0,c2,c1,c0,c3,c2);
      }
    }
    prog&&prog(0.88);await tick();
    // drop tiny floating specks (a few triangles) left where smooth blends graze the iso-surface
    {const nV=pos.length/3,par=new Int32Array(nV);for(let i=0;i<nV;i++)par[i]=i;
     const fd=q=>{while(par[q]!==q){par[q]=par[par[q]];q=par[q];}return q;};
     for(let t=0;t<idx.length;t+=3){const a=fd(idx[t]);par[fd(idx[t+1])]=a;par[fd(idx[t+2])]=a;}
     const cnt=new Map();for(let t=0;t<idx.length;t+=3){const k=fd(idx[t]);cnt.set(k,(cnt.get(k)||0)+1);}
     let big=0;for(const c of cnt.values())if(c>big)big=c;
     if(cnt.size>1){const keep=[];for(let t=0;t<idx.length;t+=3){if(cnt.get(fd(idx[t]))>=Math.min(60,big))keep.push(idx[t],idx[t+1],idx[t+2]);}idx.length=0;for(const v of keep)idx.push(v);}}
    const P3=new Float32Array(pos), I3=new Uint32Array(idx);
    let vol=volume(P3,I3);
    if(vol<0){for(let t=0;t<I3.length;t+=3){const q=I3[t+1];I3[t+1]=I3[t+2];I3[t+2]=q;}vol=-vol;}
    if(passes>0)taubin(P3,I3,passes);
    prog&&prog(1);
    return {pos:P3,idx:I3,volume:volume(P3,I3),nTri:I3.length/3,nVert:P3.length/3};
  }
  function volume(p,I){
    let v=0;
    for(let t=0;t<I.length;t+=3){
      const a=3*I[t],b=3*I[t+1],c=3*I[t+2];
      v+=p[a]*(p[b+1]*p[c+2]-p[b+2]*p[c+1])-p[a+1]*(p[b]*p[c+2]-p[b+2]*p[c])+p[a+2]*(p[b]*p[c+1]-p[b+1]*p[c]);
    }
    return v/6;
  }
  function taubin(p,I,passes){
    const n=p.length/3, acc=new Float32Array(n*3), cnt=new Uint16Array(n);
    for(let t=0;t<I.length;t++)cnt[I[t]]+=2;
    for(let it=0;it<passes;it++){
      for(const step of [0.5,-0.53]){
        acc.fill(0);
        for(let t=0;t<I.length;t+=3){
          const a=I[t]*3,b=I[t+1]*3,c=I[t+2]*3;
          acc[a]+=p[b]+p[c];acc[a+1]+=p[b+1]+p[c+1];acc[a+2]+=p[b+2]+p[c+2];
          acc[b]+=p[a]+p[c];acc[b+1]+=p[a+1]+p[c+1];acc[b+2]+=p[a+2]+p[c+2];
          acc[c]+=p[a]+p[b];acc[c+1]+=p[a+1]+p[b+1];acc[c+2]+=p[a+2]+p[b+2];
        }
        for(let v=0;v<n;v++){
          const k=cnt[v]; if(!k)continue; const o=v*3;
          p[o]+=step*(acc[o]/k-p[o]);p[o+1]+=step*(acc[o+1]/k-p[o+1]);p[o+2]+=step*(acc[o+2]/k-p[o+2]);
        }
      }
    }
  }
  function bbox(p,xf){
    const mn=[1e9,1e9,1e9],mx=[-1e9,-1e9,-1e9],o=[0,0,0];
    for(let i=0;i<p.length;i+=3){
      if(xf)xf(p[i],p[i+1],p[i+2],o);else{o[0]=p[i];o[1]=p[i+1];o[2]=p[i+2];}
      for(let k=0;k<3;k++){if(o[k]<mn[k])mn[k]=o[k];if(o[k]>mx[k])mx[k]=o[k];}
    }
    return {mn,mx,size:[mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2]]};
  }
  // print orientation (Z up). Base: back down, open top up. Cover: flipped, flat outer face on the bed.
  function printXf(P,part,layout,domes){
    const D=dims(P), L=levels(P), tw=P.wall;
    const yminA=-D.hy-tw-0, ymaxB=L.yP1, wOut=2*(D.hx+tw), gap=6;
    const dx=layout?(part==='A'?-(wOut/2+gap/2):(wOut/2+gap/2)):0;
    if(part==='A')return (x,y,z,o)=>{o[0]=x+dx;o[1]=-z;o[2]=y-yminA;};
    if(domes){const y0=L.yP0-P.joint;return (x,y,z,o)=>{o[0]=x+dx;o[1]=-z;o[2]=y-y0;};}   // cover with domes prints as assembled (skirt on the bed)
    return (x,y,z,o)=>{o[0]=x+dx;o[1]=z;o[2]=ymaxB-y;};
  }
  function toSTL(items){
    let nT=0;items.forEach(it=>nT+=it.idx.length/3);
    const buf=new ArrayBuffer(84+nT*50),dv=new DataView(buf);
    const hdr='CASE GEN voronoi phone case';for(let i=0;i<hdr.length;i++)dv.setUint8(i,hdr.charCodeAt(i));
    dv.setUint32(80,nT,true);
    let o=84;const a=[0,0,0],b=[0,0,0],c=[0,0,0];
    for(const it of items){
      const {pos,idx,xf}=it;
      for(let t=0;t<idx.length;t+=3){
        xf(pos[3*idx[t]],pos[3*idx[t]+1],pos[3*idx[t]+2],a);
        xf(pos[3*idx[t+1]],pos[3*idx[t+1]+1],pos[3*idx[t+1]+2],b);
        xf(pos[3*idx[t+2]],pos[3*idx[t+2]+1],pos[3*idx[t+2]+2],c);
        const ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2],vx=c[0]-a[0],vy=c[1]-a[1],vz=c[2]-a[2];
        let nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx;const l=sq(nx*nx+ny*ny+nz*nz)||1;
        dv.setFloat32(o,nx/l,true);dv.setFloat32(o+4,ny/l,true);dv.setFloat32(o+8,nz/l,true);
        dv.setFloat32(o+12,a[0],true);dv.setFloat32(o+16,a[1],true);dv.setFloat32(o+20,a[2],true);
        dv.setFloat32(o+24,b[0],true);dv.setFloat32(o+28,b[1],true);dv.setFloat32(o+32,b[2],true);
        dv.setFloat32(o+36,c[0],true);dv.setFloat32(o+40,c[1],true);dv.setFloat32(o+44,c[2],true);
        dv.setUint16(o+48,0,true);o+=50;
      }
    }
    return buf;
  }
  // small two-part joint specimen: a short stretch of the long side with one hook (no struts / blobs / cutouts) – print it first to check the fit
  function fitTest(P){
    const D=dims(P),Lv=levels(P),tw=P.wall,hk=planHooks(P).filter(h=>h.kind===0&&h.sgn>0);
    const E=P.hookL+P.slitW+0.6;
    let zc=0;if(hk.length)zc=hk.reduce((a,b)=>Math.abs(b.ctr)<Math.abs(a.ctr)?b:a).ctr;
    const half=E/2+6,x0=D.hx-8,x1=D.hx+tw+3,z0=zc-half,z1=zc+half,m=1.6;
    const box=(x,z)=>Math.max(x0-x,x-x1,z0-z,z-z1);
    const fA0=buildEvaluator(P,[],[],'A',[],[]),fB0=buildEvaluator(P,[],[],'B',[],[]);
    const fA=(x,y,z)=>Math.max(fA0(x,y,z),box(x,z)),fB=(x,y,z)=>Math.max(fB0(x,y,z),box(x,z));
    const bA=bounds(P,'A',[]),bB=bounds(P,'B',[]);
    const mkB=b=>({x0:x0-m,x1:x1+m,y0:b.y0,y1:b.y1,z0:z0-m,z1:z1+m});
    const yminA=-(D.hy+tw);
    return {fA,fB,BA:mkB(bA),BB:mkB(bB),
      xfA:(x,y,z,o)=>{o[0]=x-x0;o[1]=-(z-zc);o[2]=y-yminA;},
      xfB:(x,y,z,o)=>{o[0]=x-x0+(x1-x0)+8;o[1]=z-zc;o[2]=Lv.yP1-y;}};
  }
  return {dims,levels,makeC,makeO,makeOpen,makeShape,bounds,buildEvaluator,meshPart,bbox,printXf,toSTL,volume,planHooks,fitTest};
})();
</script>

<!-- ============ APP ============ -->
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const DEFAULT_P={W:76.8,L:160.1,T:8.4,Rc:11.5,Re:0.8,clr:0.3,wall:4,reO:1.5,lip:1.6,topR:0.8,edgeKeep:3,cornerKeep:1.5,
  cover:3.2,joint:6,gap:0.2,gapV:0.1,rimT:2.2,hookN:5,hookNs:2,hookL:18,fingerW:2.4,lipD:1.2,lipH:1.4,catchH:0.25,slitW:1.4,zoneR:15,
  strut:3.2,corner:1.5,round:0.9,rim:4,rimB:2,cutRim:3,cutMargin:0.3,seamMargin:0.8,blobK:8,blobCell:8,blobDown:0,outRadius:10,outOffset:2,smooth:1};
const DEFAULT_CUTS=[
  {name:'Camera (back)',on:true,x:25.5,y:-5.5,z:-52,sx:16,sy:5,sz:38,r:4,ax:'y'},
  {name:'Bottom: USB-C / speaker / jack',on:true,x:0,y:0,z:81.5,sx:52,sy:6.5,sz:8,r:3.25,ax:'z'},
  {name:'Side buttons (right)',on:true,x:39.9,y:0.5,z:-38,sx:8,sy:5,sz:40,r:2.5,ax:'x'}
];
function Core_validate(P){
  const D=Core.dims(P),w=[],skirt=D.cS,lowPlate=P.cover-P.lipH-P.catchH-P.gapV;
  if(skirt<0.9)w.push(`cover skirt only ${skirt.toFixed(2)} mm thick – raise wall thickness or lower the base rim thickness`);
  if(P.rimT<1.0)w.push('base rim thinner than 1.0 mm');
  if(lowPlate<0.9)w.push(`cover edge under the lip only ${lowPlate.toFixed(2)} mm – raise cover thickness`);
  if(P.lipD>=D.cB-0.2)w.push('catch depth reaches the cavity – lower it');
  if(P.joint<3)w.push('skirt depth below 3 mm – weak guide');
  if(Core.planHooks(P).length===0)w.push('no hooks – the cover is not held');
  return w;
}
const GROUPS=[
 ['Phone (Galaxy A15 default)',[['W','Width',.1],['L','Length',.1],['T','Thickness',.1],['Rc','Corner radius (plan)',.1],['Re','Edge radius of the phone back (cavity)',.1]]],
 ['Hull',[['clr','Clearance to phone',.05],['wall','Wall thickness',.1],['reO','Outer back-edge radius',.1],['edgeKeep','Solid band along inner edges',.5],['cornerKeep','Solid corner posts (0 = off)',.5],['lip','Screen lip width (cover)',.1],['topR','Top edge rounding',.1]]],
 ['Cover & clip',[['cover','Cover thickness',.1],['joint','Skirt depth (stop + guide)',.1],['gap','Fit gap, radial',.05],['gapV','Fit gap, vertical',.05],['rimT','Base rim thickness (outer wall at the clip)',.1],['hookN','Hooks per long side',1],['hookNs','Hooks per short side',1],['hookL','Hook arm length',.5],['fingerW','Hook arm width',.1],['lipD','Catch depth',.05],['lipH','Lip thickness',.1],['catchH','Catch slope (0 = permanent)',.05],['slitW','Slit at arm tip',.1],['zoneR','Mesh zone radius (new)',.5]]],
 ['Voronoi structure',[['strut','Strut width',.1],['corner','Hole corner smoothing',.1],['round','Hole edge rounding',.1],['rim','Solid rim – base (0 = side walls open)',.5],['rimB','Solid rim – cover',.5],['cutRim','Solid frame around cutouts',.5],['cutMargin','Extra clearance around cutouts',.1],['seamMargin','Solid band below clip',.1]]],
 ['Blobs',[['blobK','Blob smoothing (merge into hull)',.5],['blobCell','Blob cell size (0 = solid)',.5],['blobDown','Blob: allowed drop below the back (mm)',.5],['outRadius','Radius of new blobs',.5],['outOffset','Centre offset of new blobs',.5]]],
 ['Mesh',[['smooth','Smoothing passes',1]]]
];
const TOOL_TIPS={
  pt:'Click the case to place a point · click a point to select it – drag its arrows to move it · Del removes · Ctrl+C / V copies',
  out:'Blob: click the case to inflate it outward there (radius / position in the point panel, arrows in the view)',
  zone:'Mesh zone (cover): click to place a zone with a radius – only the points inside the radius are connected into a mesh there; the frame around the screen stays untouched. A zone must touch the frame.',
  move:'Drag a point · click empty space to deselect',
  del:'Click a point to delete it'};
let P={...DEFAULT_P}, CUTS=JSON.parse(JSON.stringify(DEFAULT_CUTS)), SEEDS=[], nextId=1;
let tool='pt', part='A', sel=null, dirty=true, busy=false, undoStack=[];
let result={A:null,B:null}, shapes={A:null,B:null};

function normCuts(list){
  return (list||[]).map(c=>{
    const o={...c};
    if(!o.ax)o.ax=/side/i.test(o.name||'')?'x':/bottom/i.test(o.name||'')?'z':'y';
    if(o.r==null){const ha=o.ax==='y'?o.sx:o.ax==='x'?o.sy:o.sx, hb=o.ax==='y'?o.sz:o.ax==='x'?o.sz:o.sy;o.r=Math.min(ha,hb)/2;}
    return o;
  });
}
function normSeeds(list){return (list||[]).filter(s=>!(s.t==='o'&&s.p==='B')&&!((s.t==='z'||s.t==='k')&&s.p==='A')).map(s=>({...s,t:(s.t==='o')?'o':(s.t==='k'||s.t==='z')?'z':'s',p:s.p||'A',r:(s.t==='o'||s.t==='k'||s.t==='z')?(s.r||(s.t==='o'?10:15)):undefined,free:!!s.free}));}
function applyProject(j){
  P={...DEFAULT_P};if(j.v>=12)Object.keys(DEFAULT_P).forEach(k=>{if(j.P&&typeof j.P[k]==='number')P[k]=j.P[k];});   // older files: keep points + cutouts, take the new clip / hull defaults
  CUTS=normCuts(j.CUTS&&j.CUTS.length?j.CUTS:DEFAULT_CUTS);SEEDS=normSeeds(j.SEEDS);nextId=j.nextId||(SEEDS.reduce((a,s)=>Math.max(a,s.id||0),0)+1);
  if(j.res)$('#res').value=j.res;sel=null;updateShapes();
}
const pxf=(pt,lay)=>{                      // print orientation, dropped so the lowest point sits on the bed (blobs can reach below the back)
  const xf=Core.printXf(P,pt,lay,pt==='B'&&SEEDS.some(q=>q.p==='B'&&q.t==='o'));
  const r=result[pt]; if(!r)return xf;
  const z0=Core.bbox(r.pos,xf).mn[2];
  return (x,y,z,o)=>{xf(x,y,z,o);o[2]-=z0;};
};
const zonesOf=p=>SEEDS.filter(s=>s.p===p&&s.t==='z').map(s=>({x:s.x,z:s.z,r:s.r||P.zoneR}));
const blobsOf=p=>SEEDS.filter(s=>s.p===p&&s.t==='o').map(s=>({x:s.x,y:s.y,z:s.z,r:s.r||P.outRadius}));
let shapes0={A:null,B:null};   // hull without blobs: stable target while a blob is being dragged
function updateShapes(){shapes.A=Core.makeShape(P,blobsOf('A'),'A');shapes.B=Core.makeShape(P,blobsOf('B'),'B');shapes0.A=Core.makeShape(P,[],'A');shapes0.B=Core.makeShape(P,[],'B');}
const midB=()=>{const L=Core.levels(P);return (L.yP0+L.yP1)/2;};
updateShapes();

// ---------- param UI
(function buildParams(){
  const host=$('#groups');
  GROUPS.forEach(([title,items])=>{
    const s=document.createElement('section');s.innerHTML=`<h3>${title}</h3>`;
    items.forEach(([k,label,step])=>{
      const r=document.createElement('div');r.className='row';
      r.innerHTML=`<label>${label}</label><input type="number" step="${step}" data-k="${k}">`;
      s.appendChild(r);
    });
    host.appendChild(s);
  });
  host.addEventListener('input',e=>{
    const k=e.target.dataset.k;if(!k)return;
    const v=parseFloat(e.target.value);if(isNaN(v))return;
    P[k]=v;onParamsChanged();
  });
})();
function syncParamInputs(){$$('[data-k]').forEach(i=>i.value=P[i.dataset.k]);$$('[data-strut]').forEach(i=>i.value=P.strut);$$('[data-strutv]').forEach(i=>i.textContent=(+P.strut).toFixed(1));}
document.addEventListener('input',e=>{if(e.target.dataset&&e.target.dataset.strut!==undefined){P.strut=parseFloat(e.target.value);syncParamInputs();onParamsChanged();}});

// ---------- cutouts UI
const edgeOf=(c,f)=>f==='top'?c.z+P.L/2-c.sz/2:f==='left'?c.x+P.W/2-c.sx/2:c.y+P.T/2-c.sy/2;
const r2=v=>Math.round(v*100)/100;
function renderCuts(){
  const host=$('#cuts');host.innerHTML='';
  CUTS.forEach((c,i)=>{
    const d=document.createElement('div');d.className='cut';
    const num=(f,l,st,cls)=>`<label class="${cls||''}">${l}<input type="number" step="${st||0.5}" value="${r2(f in c?c[f]:edgeOf(c,f))}" data-c="${i}" data-f="${f}"></label>`;
    d.innerHTML=`<div class="cuthead"><label><input type="checkbox" ${c.on?'checked':''} data-c="${i}" data-f="on"> <input type="text" class="nm" value="${(c.name||'').replace(/"/g,'&quot;')}" data-c="${i}" data-f="name"></label><button data-del="${i}" title="Remove">✕</button></div>
    <div class="grid3">${num('top','From top edge','0.1','edge')}${num('left','From left edge','0.1','edge')}${num('back','From back face','0.1','edge')}
    ${num('sx','Size X','0.1')}${num('sy','Size Y','0.1')}${num('sz','Size Z','0.1')}
    ${num('r','Corner radius','0.25')}
    <label>Through<select data-c="${i}" data-f="ax">${['x','y','z'].map(a=>`<option value="${a}" ${c.ax===a?'selected':''}>${a.toUpperCase()}</option>`).join('')}</select></label></div>
    <details><summary>Centre position (advanced)</summary><div class="grid3" style="margin-top:6px">${num('x','X','0.1')}${num('y','Y','0.1')}${num('z','Z','0.1')}</div></details>`;
    host.appendChild(d);
  });
}
function refreshCutInputs(i,except){
  $$('#cuts [data-c="'+i+'"]').forEach(inp=>{const f=inp.dataset.f;if(inp===except||f==='on'||f==='ax'||f==='name')return;inp.value=r2(f in CUTS[i]?CUTS[i][f]:edgeOf(CUTS[i],f));});
}
function onCutInput(e){
  const i=e.target.dataset.c;if(i===undefined)return;const f=e.target.dataset.f,c=CUTS[i];
  if(f==='on')c.on=e.target.checked;
  else if(f==='ax')c.ax=e.target.value;
  else if(f==='name')c.name=e.target.value;
  else{const v=parseFloat(e.target.value);if(isNaN(v))return;
    if(f==='top')c.z=v-P.L/2+c.sz/2; else if(f==='left')c.x=v-P.W/2+c.sx/2; else if(f==='back')c.y=v-P.T/2+c.sy/2;
    else{
      // sizes keep the near edge (not the centre) fixed, so typing the opening length does not shift its start
      if(f==='sx'){const e0=edgeOf(c,'left');c.sx=v;c.x=e0-P.W/2+v/2;}
      else if(f==='sy'){const e0=edgeOf(c,'back');c.sy=v;c.y=e0-P.T/2+v/2;}
      else if(f==='sz'){const e0=edgeOf(c,'top');c.sz=v;c.z=e0-P.L/2+v/2;}
      else c[f]=v;
    }
    refreshCutInputs(i,e.target);}
  if(f!=='name'){rebuildCutHelpers();setDirty();}save();
}
$('#cuts').addEventListener('input',onCutInput);
$('#cuts').addEventListener('change',e=>{if(e.target.dataset.f==='ax')onCutInput(e);});
$('#cuts').addEventListener('click',e=>{const i=e.target.dataset.del;if(i===undefined)return;CUTS.splice(+i,1);renderCuts();rebuildCutHelpers();setDirty();save();});
$('#addCut').onclick=()=>{CUTS.push({name:'Cutout',on:true,x:0,y:0,z:0,sx:10,sy:6,sz:10,r:2,ax:'y'});renderCuts();rebuildCutHelpers();setDirty();save();};

// ---------- state helpers
let quality='none', ver=0, schedulePreview=()=>{}, interacting=false;
function setChip(cls,txt){const c=$('#chip');c.className=cls;c.textContent=txt;}
function setDirty(){dirty=true;ver++;setChip('dirty',$('#autoPrev').checked?'updating preview…':'preview outdated');updateExportButtons();if(!interacting)schedulePreview();}
function updateExportButtons(){['expA','expB','expAll'].forEach(id=>$('#'+id).disabled=dirty||quality!=='detail'||!result.A||!result.B);}
let saveT=null;
function save(){clearTimeout(saveT);saveT=setTimeout(()=>{try{localStorage.setItem('casegen.v4',JSON.stringify({v:12,P,CUTS,SEEDS,nextId,res:$('#res').value}));localStorage.setItem('casegen.cuts.v1',JSON.stringify(CUTS));}catch(e){}},400);}
function pushUndo(){undoStack.push(JSON.stringify(SEEDS));if(undoStack.length>60)undoStack.shift();}
function grad(F,x,y,z){const e=0.02;let gx=F(x+e,y,z)-F(x-e,y,z),gy=F(x,y+e,z)-F(x,y-e,z),gz=F(x,y,z+e)-F(x,y,z-e);const l=Math.hypot(gx,gy,gz)||1;return [gx/l,gy/l,gz/l];}
function projectMid(pt,x,y,z){
  if(pt==='B'&&y<=Core.levels(P).yP1+0.05)return {x,y:midB(),z};
  const F=shapes[pt].S;let p=[x,y,z];const t=-P.wall/2;
  for(let k=0;k<10;k++){const g=grad(F,p[0],p[1],p[2]);const d=F(p[0],p[1],p[2])-t;p[0]-=g[0]*d;p[1]-=g[1]*d;p[2]-=g[2]*d;}
  return {x:p[0],y:p[1],z:p[2]};
}
function reprojectAll(){SEEDS.forEach(s=>{if(s.t==='s'&&!s.free)Object.assign(s,projectMid(s.p,s.x,s.y,s.z));});}
function onParamsChanged(){updateShapes();reprojectAll();updateMarkers();updateGrid();if(window.rebuildPhone)rebuildPhone();if(window.rebuildCutHelpers)rebuildCutHelpers();setDirty();save();need();}

// ---------- three loader
function loadScript(src){return new Promise((res,rej)=>{const s=document.createElement('script');s.src=src;s.onload=res;s.onerror=rej;document.head.appendChild(s);});}
async function ensureThree(){
  for(const s of ['https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js','https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js','https://unpkg.com/three@0.128.0/build/three.min.js']){
    try{await loadScript(s);if(window.THREE)return true;}catch(e){}
  }return false;
}

let need=()=>{};
(async function boot(){
  // restore (v3, or migrate v2 / v1)
  try{
    const j=JSON.parse(localStorage.getItem('casegen.v4')||localStorage.getItem('casegen.v3')||localStorage.getItem('casegen.v2')||localStorage.getItem('casegen.v1')||'null');
    if(j)applyProject(j);
    const cj=JSON.parse(localStorage.getItem('casegen.cuts.v1')||'null');if(cj&&cj.length)CUTS=normCuts(cj);          // measured cutouts live in their own slot
  }catch(e){}
  syncParamInputs();renderCuts();updateSeedCount();
  if(!(await ensureThree())){$('#msg').textContent='three.js could not be loaded (offline?).';return;}

  const cv=$('#cv'),vp=$('#vp');
  const renderer=new THREE.WebGLRenderer({canvas:cv,antialias:true});
  renderer.setPixelRatio(Math.min(window.devicePixelRatio,2));renderer.setClearColor(0xffffff,1);renderer.localClippingEnabled=true;
  const scene=new THREE.Scene();
  const cam=new THREE.PerspectiveCamera(30,1,1,4000);
  scene.add(new THREE.HemisphereLight(0xffffff,0xb9bcc6,0.85));
  const sun=new THREE.DirectionalLight(0xffffff,0.8);sun.position.set(80,200,120);scene.add(sun);
  const fill=new THREE.DirectionalLight(0xffffff,0.45);fill.position.set(-120,-150,-80);scene.add(fill);
  const grid=new THREE.GridHelper(400,40,0xe0e0e6,0xf0f0f4);scene.add(grid);
  window.updateGrid=()=>{grid.position.y=-(P.T/2+P.clr+P.wall)-0.5;};updateGrid();

  // ---- grey material with fine procedural grain (noisy normal perturbation, no UVs needed)
  const grainU={value:0.3}, grainS={value:3.0};
  function addGrain(mat){
    mat.onBeforeCompile=(sh)=>{
      sh.uniforms.uGrain=grainU;sh.uniforms.uGrainScale=grainS;
      sh.vertexShader=sh.vertexShader.replace('#include <common>','#include <common>\nvarying vec3 vOP;').replace('#include <begin_vertex>','#include <begin_vertex>\nvOP=position;');
      sh.fragmentShader=sh.fragmentShader.replace('#include <common>',`#include <common>
varying vec3 vOP; uniform float uGrain; uniform float uGrainScale;
float h31(vec3 p){p=fract(p*0.1031);p+=dot(p,p.yzx+33.33);return fract((p.x+p.y)*p.z);}
float vn(vec3 p){vec3 i=floor(p),f=fract(p);f=f*f*(3.0-2.0*f);
 return mix(mix(mix(h31(i),h31(i+vec3(1,0,0)),f.x),mix(h31(i+vec3(0,1,0)),h31(i+vec3(1,1,0)),f.x),f.y),
            mix(mix(h31(i+vec3(0,0,1)),h31(i+vec3(1,0,1)),f.x),mix(h31(i+vec3(0,1,1)),h31(i+vec3(1,1,1)),f.x),f.y),f.z);}
float fbm(vec3 p){return vn(p)*0.6+vn(p*2.13)*0.3+vn(p*4.37)*0.1;}`)
      .replace('#include <normal_fragment_maps>',`#include <normal_fragment_maps>
{ vec3 gp=vOP*uGrainScale; float e=0.35; float n0=fbm(gp);
  vec3 g=vec3(fbm(gp+vec3(e,0.,0.))-n0,fbm(gp+vec3(0.,e,0.))-n0,fbm(gp+vec3(0.,0.,e))-n0)/e;
  vec3 gv=(viewMatrix*vec4(g,0.0)).xyz; gv-=dot(gv,normal)*normal;
  normal=normalize(normal-uGrain*gv); }`);
    };
    mat.customProgramCacheKey=()=>'casegrain';
  }
  const clip=new THREE.Plane(new THREE.Vector3(-1,0,0),0);
  const mats={A:new THREE.MeshStandardMaterial({color:0x8e8f95,roughness:.62,metalness:0,side:THREE.DoubleSide}),
              B:new THREE.MeshStandardMaterial({color:0xa2a3a9,roughness:.62,metalness:0,side:THREE.DoubleSide})};
  addGrain(mats.A);addGrain(mats.B);
  const meshes={A:null,B:null};
  const caseGroup=new THREE.Group();scene.add(caseGroup);
  const cutGroup=new THREE.Group();scene.add(cutGroup);
  function applyPartVis(){
    for(const k of ['A','B']){const on=k===part;mats[k].transparent=!on;mats[k].opacity=on?1:0.2;mats[k].depthWrite=on;mats[k].needsUpdate=true;}
    need();
  }

  window.rebuildCutHelpers=()=>{
    while(cutGroup.children.length){const o=cutGroup.children.pop();o.geometry.dispose();}
    CUTS.forEach(c=>{if(!c.on)return;
      const g=new THREE.BoxGeometry(c.sx,c.sy,c.sz);
      const m=new THREE.Mesh(g,new THREE.MeshBasicMaterial({color:0xff3b30,transparent:true,opacity:.2,depthWrite:false}));
      m.position.set(c.x,c.y,c.z);
      m.add(new THREE.LineSegments(new THREE.EdgesGeometry(g),new THREE.LineBasicMaterial({color:0xff3b30})));
      cutGroup.add(m);});
    need();
  };
  rebuildCutHelpers();
  // phone outline (true dimensions, no clearance) to check cutout positions against
  const phoneG=new THREE.Group();phoneG.visible=false;scene.add(phoneG);
  window.rebuildPhone=()=>{
    while(phoneG.children.length){const o=phoneG.children.pop();o.geometry.dispose();}
    const g=new THREE.BoxGeometry(P.W,P.T,P.L);
    const m=new THREE.Mesh(g,new THREE.MeshBasicMaterial({color:0x1a66ff,transparent:true,opacity:.07,depthWrite:false}));
    m.add(new THREE.LineSegments(new THREE.EdgesGeometry(g),new THREE.LineBasicMaterial({color:0x1a66ff,transparent:true,opacity:.6})));
    phoneG.add(m);need();
  };
  rebuildPhone();

  // markers: blue = point, green = blob centre, orange = selected
  const MAXS=600;
  const mk=new THREE.InstancedMesh(new THREE.SphereGeometry(1,16,12),new THREE.MeshBasicMaterial({color:0xffffff}),MAXS);
  mk.instanceColor=new THREE.InstancedBufferAttribute(new Float32Array(MAXS*3).fill(1),3);   // allocate up front so the shader compiles with per-instance colour
  mk.frustumCulled=false;scene.add(mk);
  const blobG=new THREE.Group();scene.add(blobG);
  const ghost=new THREE.Mesh(new THREE.SphereGeometry(1.3,16,12),new THREE.MeshBasicMaterial({color:0x1a66ff,transparent:true,opacity:.4,depthTest:false}));
  ghost.visible=false;scene.add(ghost);
  // ---- 3D move gizmo for the selected point
  const giz=new THREE.Group();giz.visible=false;scene.add(giz);
  const AXES=[{k:'x',v:new THREE.Vector3(1,0,0),c:0xe5484d},{k:'y',v:new THREE.Vector3(0,1,0),c:0x30a46c},{k:'z',v:new THREE.Vector3(0,0,1),c:0x3b82f6}];
  AXES.forEach(a=>{
    const m=new THREE.MeshBasicMaterial({color:a.c,depthTest:false,transparent:true});
    const shaft=new THREE.Mesh(new THREE.CylinderGeometry(0.03,0.03,0.8,10),m);shaft.position.y=0.4;
    const head=new THREE.Mesh(new THREE.ConeGeometry(0.1,0.3,16),m);head.position.y=0.95;
    shaft.renderOrder=head.renderOrder=999;
    const g=new THREE.Group();g.add(shaft,head);g.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),a.v);
    giz.add(g);a.g=g;
  });
  const gizDot=new THREE.Mesh(new THREE.SphereGeometry(0.07,12,8),new THREE.MeshBasicMaterial({color:0xffffff,depthTest:false}));gizDot.renderOrder=1000;giz.add(gizDot);
  let gizLen=1;
  function updateGizmo(){
    const s=SEEDS.find(q=>q.id===sel);
    if(!s||s.p!==part||!mk.visible){giz.visible=false;need();return;}
    gizLen=view.r*0.07;giz.visible=true;giz.position.set(s.x,s.y+exOf(),s.z);giz.scale.setScalar(gizLen);
    AXES[1].g.visible=part!=='B'||s.t==='o'||s.y>Core.levels(P).yP1+0.05;   // flat cover points live in the plate plane: no Y arrow
    need();
  }
  function gizHit(e){
    if(!giz.visible)return null;
    const r=cv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
    const scr=v=>{const p=v.clone().project(cam);return [(p.x*.5+.5)*r.width,(-p.y*.5+.5)*r.height];};
    const o=scr(giz.position);let best=null,bd=10;
    for(const a of AXES){
      if(!a.g.visible)continue;
      const t=scr(giz.position.clone().addScaledVector(a.v,gizLen*1.1));
      const dx=t[0]-o[0],dy=t[1]-o[1],L2=dx*dx+dy*dy||1;
      let u=((mx-o[0])*dx+(my-o[1])*dy)/L2;u=Math.max(0,Math.min(1,u));
      const d=Math.hypot(mx-(o[0]+dx*u),my-(o[1]+dy*u));
      if(d<bd){bd=d;best=a;}
    }
    return best;
  }
  function axisParam(e,a,O){                            // position along the axis line closest to the mouse ray (O = fixed origin while dragging)
    ray.setFromCamera(ndc(e),cam);
    const Rd=ray.ray.direction,w0=(O||giz.position).clone().sub(ray.ray.origin);
    const b=a.v.dot(Rd),d=a.v.dot(w0),ee=Rd.dot(w0),den=1-b*b;
    if(Math.abs(den)<1e-4)return null;return (b*ee-d)/den;
  }
  const DIM=new THREE.Color(0xb4b8c2),COL={s:new THREE.Color(0x1a66ff),o:new THREE.Color(0xe2399c),z:new THREE.Color(0x0fb5ae)},SELC=new THREE.Color(0xff9f0a),HOVC=new THREE.Color(0xffc266);
  const SIZE={s:1.3,o:1.7,z:1.6};
  const exOf=()=>part==='B'?+$('#explode').value:0;
  let vis=[],markerW=[],markerN=[],hoverId=null;
  window.updateMarkers=()=>{
    const m4=new THREE.Matrix4(),q=new THREE.Quaternion(),sc=new THREE.Vector3();
    const ex=exOf(),Lv=Core.levels(P);
    vis=SEEDS.filter(s=>s.p===part).slice(0,MAXS);markerW=[];markerN=[];
    while(blobG.children.length){const o=blobG.children.pop();o.geometry.dispose();o.material.dispose();}
    vis.forEach((s,i)=>{
      let p,n;
      if(s.t==='o'){p=new THREE.Vector3(s.x,s.y+ex,s.z);n=new THREE.Vector3(0,0,0);
        const geo=new THREE.SphereGeometry(s.r,28,18);
        const bm=new THREE.Mesh(geo,new THREE.MeshBasicMaterial({color:0xe2399c,transparent:true,opacity:.10,depthWrite:false}));
        bm.position.copy(p);
        bm.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo,30),new THREE.LineBasicMaterial({color:0xe2399c,transparent:true,opacity:.35})));
        blobG.add(bm);}
      else if(s.t==='z'){p=new THREE.Vector3(s.x,Lv.yP1+0.3+ex,s.z);n=new THREE.Vector3(0,1,0);
        const geo=new THREE.CylinderGeometry(s.r,s.r,0.3,48);
        const bm=new THREE.Mesh(geo,new THREE.MeshBasicMaterial({color:0x0fb5ae,transparent:true,opacity:.16,depthWrite:false}));
        bm.position.set(s.x,Lv.yP1+0.2+ex,s.z);
        bm.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo,30),new THREE.LineBasicMaterial({color:0x0fb5ae,transparent:true,opacity:.6})));
        blobG.add(bm);}
      else if(part==='A'||s.y>Lv.yP1+0.05){const g=grad(shapes[part].S,s.x,s.y,s.z),off=P.wall/2+0.3;p=new THREE.Vector3(s.x+g[0]*off,s.y+g[1]*off+ex,s.z+g[2]*off);n=new THREE.Vector3(g[0],g[1],g[2]);}
      else{p=new THREE.Vector3(s.x,Lv.yP1+0.3+ex,s.z);n=new THREE.Vector3(0,1,0);}
      markerW.push(p);markerN.push(n);
      const k=(SIZE[s.t]||1.3)*(s.id===sel?1.35:1);sc.set(k,k,k);
      m4.compose(p,q,sc);mk.setMatrixAt(i,m4);
      const live=!(part==='B'&&s.t==='s')||zonesOf('B').some(q=>Math.hypot(s.x-q.x,s.z-q.z)<=q.r);
      mk.setColorAt(i,s.id===sel?SELC:(s.id===hoverId?HOVC:(live?COL[s.t]:DIM)));
    });
    mk.count=vis.length;mk.instanceMatrix.needsUpdate=true;if(mk.instanceColor)mk.instanceColor.needsUpdate=true;
    updateSeedCount();updateGizmo();need();
  };
  updateMarkers();

  // camera (custom orbit)
  const view={target:new THREE.Vector3(),r:380,az:0.7,el:0.75};
  function updateCam(){
    const cE=Math.cos(view.el);
    cam.position.set(view.target.x+view.r*cE*Math.sin(view.az),view.target.y+view.r*Math.sin(view.el),view.target.z+view.r*cE*Math.cos(view.az));
    cam.up.set(0,1,0);cam.lookAt(view.target);grid.visible=view.el>0;updateGizmo();need();
  }
  const MAXEL=1.5608;
  function setView(n){
    view.target.set(0,0,0);
    if(n==='persp'){view.az=.7;view.el=.75;view.r=380;}
    if(n==='back'){view.az=Math.PI;view.el=-MAXEL;view.r=430;}
    if(n==='front'){view.az=0;view.el=MAXEL;view.r=430;}
    if(n==='side'){view.az=Math.PI/2;view.el=0;view.r=430;}
    updateCam();
  }
  $$('[data-view]').forEach(b=>b.onclick=()=>setView(b.dataset.view));

  function resize(){const w=vp.clientWidth,h=vp.clientHeight;renderer.setSize(w,h,false);cam.aspect=w/h;cam.updateProjectionMatrix();need();}
  new ResizeObserver(resize).observe(vp);resize();updateCam();

  let needsRender=true;need=()=>{needsRender=true;};
  (function loop(){requestAnimationFrame(loop);if(needsRender){needsRender=false;renderer.render(scene,cam);}})();

  // ---------- picking
  const ray=new THREE.Raycaster();
  function ndc(e){const r=cv.getBoundingClientRect();return new THREE.Vector2(((e.clientX-r.left)/r.width)*2-1,-((e.clientY-r.top)/r.height)*2+1);}
  function pickSurface(e,base){
    ray.setFromCamera(ndc(e),cam);
    const o=ray.ray.origin.clone(),d=ray.ray.direction;o.y-=exOf();
    const Lv=Core.levels(P),open=Core.makeOpen(P),Sf=(base?shapes0:shapes)[part].S;
    const F=part==='A'?(x,y,z)=>Math.max(Sf(x,y,z),y-Lv.yP1):Sf;
    let t=0;
    for(let i=0;i<500;i++){
      const x=o.x+d.x*t,y=o.y+d.y*t,z=o.z+d.z*t;const dd=F(x,y,z);
      if(dd<0.02){
        if(part==='A'&&y>Lv.yP0-1.5&&open(x,z)<0)return null;   // screen opening: nothing to place on
        return {x,y,z,n:grad(F,x,y,z)};
      }
      t+=Math.max(dd*0.9,0.02);if(t>3000)break;
    }return null;
  }
  function pickOutside(e,base){
    ray.setFromCamera(ndc(e),cam);
    if(part==='B'){
      const pl=new THREE.Plane(new THREE.Vector3(0,1,0),-(Core.levels(P).yP0+exOf()));const v=new THREE.Vector3();
      if(ray.ray.intersectPlane(pl,v))return {x:v.x,y:Core.levels(P).yP0,z:v.z};return null;
    }
    const h=pickSurface(e,base);
    if(h){const o=P.outOffset;return {x:h.x+h.n[0]*o,y:h.y+h.n[1]*o,z:h.z+h.n[2]*o};}
    const dir=new THREE.Vector3();cam.getWorldDirection(dir);
    const pl=new THREE.Plane().setFromNormalAndCoplanarPoint(dir,view.target);const v=new THREE.Vector3();
    if(ray.ray.intersectPlane(pl,v))return {x:v.x,y:v.y,z:v.z};
    return null;
  }
  function pickSeed(e){
    const r=cv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;let best=null,bd=14;
    for(let i=0;i<markerW.length;i++){
      if(markerN[i].lengthSq()>0&&markerN[i].dot(cam.position.clone().sub(markerW[i]))<=0)continue;
      const p=markerW[i].clone().project(cam);const sx=(p.x*.5+.5)*r.width,sy=(-p.y*.5+.5)*r.height;
      const d=Math.hypot(sx-mx,sy-my);if(d<bd){bd=d;best=vis[i];}
    }return best;
  }

  // ---------- seed ops
  const mirrorOn=()=>$('#mirror').checked;
  const byId=id=>SEEDS.find(s=>s.id===id)||null;
  const selSeed=()=>byId(sel);
  function partner(s){return s.pair?byId(s.pair):null;}
  function place(s,p){return s.t==='o'?{x:p.x,y:s.p==='B'?s.y:p.y,z:p.z}:projectMid(s.p,p.x,p.y,p.z);}
  function addSeedAt(p,t,rad){
    pushUndo();
    const s={id:nextId++,x:0,y:t==='o'&&part==='B'?Core.levels(P).yP0:0,z:0,pair:null,t,p:part,r:t==='o'?(rad||P.outRadius):t==='z'?(rad||P.zoneR):undefined,free:false};
    Object.assign(s,place(s,p));SEEDS.push(s);
    if(mirrorOn()&&Math.abs(s.x)>1.5){const s2={...s,id:nextId++,pair:s.id};Object.assign(s2,place(s2,{x:-s.x,y:s.y,z:s.z}));s.pair=s2.id;SEEDS.push(s2);}
    sel=s.id;afterSeeds();return s;
  }
  function moveSeed(s,p){
    Object.assign(s,place(s,p));s.free=false;
    const o=partner(s);if(o&&mirrorOn()){Object.assign(o,place(o,{x:-s.x,y:s.y,z:s.z}));o.free=false;}
    if(s.t==='o')updateShapes();
    syncSel();updateMarkers();setDirty();save();
  }
  function delSeed(s){
    if(!s)return;pushUndo();const o=partner(s);
    SEEDS=SEEDS.filter(a=>a!==s&&a!==o);SEEDS.forEach(a=>{if(a.pair&&!byId(a.pair))a.pair=null;});
    if(sel===s.id||(o&&sel===o.id))sel=null;afterSeeds();
  }
  function select(s){sel=s?s.id:null;syncSel();updateMarkers();}
  function afterSeeds(){hoverId=null;updateShapes();syncSel();updateMarkers();setDirty();save();}
  function updateSeedCount(){
    const mine=SEEDS.filter(s=>s.p===part),o=mine.filter(s=>s.t==='o').length,k=mine.filter(s=>s.t==='z').length;
    $('#seedCount').textContent=`${mine.length-o-k} points`+(o?` · ${o} blobs`:'')+(k?` · ${k} mesh zones`:'')+` (${part==='A'?'base':'cover'})`;
  }
  // ---- selected-point panel
  function syncSel(){
    const s=selSeed(),pn=$('#selPanel');
    if(!s||s.p!==part){pn.style.display='none';return;}
    pn.style.display='block';
    $('#selInfo').textContent=(s.t==='o'?'Blob':s.t==='z'?'Mesh zone':'Point')+' · '+(s.p==='A'?'Base':'Cover');
    $('#seX').value=(+s.x).toFixed(2);$('#seY').value=(+s.y).toFixed(2);$('#seZ').value=(+s.z).toFixed(2);
    $('#seRL').style.display=(s.t==='o'||s.t==='z')?'flex':'none';if(s.t==='o'||s.t==='z')$('#seR').value=(+s.r).toFixed(1);
    $('#selSnap').style.display=s.t==='s'?'inline-block':'none';
  }
  ['seX','seY','seZ','seR'].forEach(id=>{
    const el=$('#'+id);
    el.addEventListener('focus',()=>pushUndo());
    el.addEventListener('input',()=>{
      const s=selSeed();if(!s)return;const v=parseFloat(el.value);if(isNaN(v))return;
      const o=partner(s);
      if(id==='seR'){s.r=Math.max(1,v);if(o&&mirrorOn())o.r=s.r;}
      else{const f={seX:'x',seY:'y',seZ:'z'}[id];s[f]=v;s.free=true;
        if(o&&mirrorOn()){o.x=-s.x;o.y=s.y;o.z=s.z;o.free=true;}}
      updateShapes();updateMarkers();setDirty();save();
    });
  });
  $('#selSnap').onclick=()=>{const s=selSeed();if(!s||s.t!=='s')return;pushUndo();Object.assign(s,projectMid(s.p,s.x,s.y,s.z));s.free=false;syncSel();updateMarkers();setDirty();save();};
  $('#selDel').onclick=()=>delSeed(selSeed());

  $('#undo').onclick=doUndo;
  function doUndo(){const j=undoStack.pop();if(!j)return;SEEDS=JSON.parse(j);if(!selSeed())sel=null;afterSeeds();}
  $('#clearSeeds').onclick=()=>{if(!SEEDS.some(s=>s.p===part))return;pushUndo();SEEDS=SEEDS.filter(s=>s.p!==part);sel=null;afterSeeds();};
  $('#scatter').onclick=()=>{
    const n=Math.max(2,Math.min(300,parseInt($('#scN').value)||40)),region=$('#scR').value;
    pushUndo();const D=Core.dims(P),Lv=Core.levels(P),open=Core.makeOpen(P),C0=Core.makeC(P);
    const pts=SEEDS.filter(s=>s.p===part&&s.t==='s').map(s=>[s.x,s.y,s.z]);let added=0,minD;
    if(part==='B'){
      const zs=zonesOf('B');const ok=(x,z)=>zs.some(q=>Math.hypot(x-q.x,z-q.z)<q.r-0.8)&&C0(x,Lv.yP1,z)<P.wall/2-0.8;
      let hit=0;const T=6000;for(let i=0;i<T;i++)if(ok((Math.random()*2-1)*D.hx,(Math.random()*2-1)*D.hz))hit++;
      const area=Math.max(hit/T*4*D.hx*D.hz,50);minD=Math.sqrt(area/n)*0.78;
      for(let tries=0;tries<n*120&&added<n;tries++){
        const x=(Math.random()*2-1)*D.hx,z=(Math.random()*2-1)*D.hz;if(!ok(x,z))continue;
        if(pts.some(p=>Math.hypot(p[0]-x,p[2]-z)<minD))continue;
        pts.push([x,midB(),z]);SEEDS.push({id:nextId++,x,y:midB(),z,pair:null,t:'s',p:'B',free:false});added++;
      }
    }else{
      const area=region==='all'?2*P.W*P.L+2*(P.W+P.L)*P.T:P.W*P.L;minD=Math.sqrt(area/n)*0.78;
      for(let tries=0;tries<n*80&&added<n;tries++){
        let x,y,z;
        if(region==='back'){x=(Math.random()*2-1)*(D.hx-4);z=(Math.random()*2-1)*(D.hz-4);y=-(D.hy+P.wall+1);}
        else{x=(Math.random()*2-1)*(D.hx+3);y=(Math.random()*2-1)*(D.hy+3);z=(Math.random()*2-1)*(D.hz+3);}
        const q=projectMid('A',x,y,z);
        if(region==='back'&&grad(shapes.A.S,q.x,q.y,q.z)[1]>-0.6)continue;
        if(region==='all'&&q.y>Lv.yP0-P.joint-P.seamMargin)continue;
        if(pts.some(p=>Math.hypot(p[0]-q.x,p[1]-q.y,p[2]-q.z)<minD))continue;
        pts.push([q.x,q.y,q.z]);SEEDS.push({id:nextId++,...q,pair:null,t:'s',p:'A',free:false});added++;
      }
    }
    afterSeeds();
  };

  function pasteSeed(){                                   // paste the copied point / blob at the last cursor position on the case, same settings
    if(!copied)return;
    if((copied.t==='o'&&part==='B')||(copied.t==='z'&&part==='A'))return;
    const h=lastHit&&lastHit.part===part?lastHit:null;
    let p;
    if(h)p=copied.t==='o'?{x:h.x+h.n[0]*P.outOffset,y:h.y+h.n[1]*P.outOffset,z:h.z+h.n[2]*P.outOffset}:h;
    else{const o=selSeed()||{x:0,y:0,z:0};p={x:o.x+8,y:o.y,z:o.z+8};}
    addSeedAt(p,copied.t,copied.r);
  }
  // ---------- part switch + tools
  function setPart(p){
    part=p;$$('[data-part]').forEach(b=>b.classList.toggle('on',b.dataset.part===p));
    $$('[data-tool="out"]').forEach(b=>b.disabled=p==='B');$$('[data-tool="zone"]').forEach(b=>b.disabled=p!=='B');
    if((p==='B'&&tool==='out')||(p==='A'&&tool==='zone'))setTool('pt');
    const s=selSeed();if(s&&s.p!==p)sel=null;
    hoverId=null;ghost.visible=false;applyPartVis();syncSel();updateMarkers();
  }
  $$('[data-part]').forEach(b=>b.onclick=()=>setPart(b.dataset.part));
  const GHOST={pt:0x1a66ff,out:0xe2399c,zone:0x0fb5ae};
  function setTool(t){tool=t;$$('[data-tool]').forEach(b=>b.classList.toggle('on',b.dataset.tool===t));ghost.visible=false;
    if(GHOST[t])ghost.material.color.setHex(GHOST[t]);$('#tip').textContent=TOOL_TIPS[t]||'';need();}
  $$('[data-tool]').forEach(b=>b.onclick=()=>setTool(b.dataset.tool));
  window.addEventListener('keydown',e=>{
    if(/INPUT|SELECT|TEXTAREA/.test(document.activeElement.tagName))return;
    if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();doUndo();return;}
    if((e.ctrlKey||e.metaKey)&&(e.key==='c'||e.key==='C')){const s=selSeed();if(s){copied={t:s.t,r:s.r};$('#tip').textContent=(s.t==='o'?'Blob':s.t==='z'?'Mesh zone':'Point')+' copied – Ctrl+V pastes it at the cursor';}return;}
    if((e.ctrlKey||e.metaKey)&&(e.key==='v'||e.key==='V')){e.preventDefault();pasteSeed();return;}
    if(e.key==='Delete'||e.key==='Backspace'){const s=selSeed();if(s){e.preventDefault();delSeed(s);}return;}
    if(e.key==='Escape'){select(null);return;}
    const k={a:'pt',o:'out',z:'zone',m:'move',d:'del'}[e.key];if(k&&!(k==='out'&&part==='B')&&!(k==='zone'&&part==='A'))setTool(k);
  });
  setTool('pt');

  // ---------- pointer
  let drag=null,lastHit=null,copied=null;
  cv.addEventListener('contextmenu',e=>e.preventDefault());
  cv.addEventListener('pointerdown',e=>{
    cv.setPointerCapture(e.pointerId);
    drag={sx:e.clientX,sy:e.clientY,lx:e.clientX,ly:e.clientY,btn:e.button,moved:false,mode:null,seed:null,alt:e.altKey};
    if(e.button===0&&!e.shiftKey){const a=gizHit(e),s=selSeed();
      if(a&&s){const O=giz.position.clone(),t=axisParam(e,a,O);if(t!==null){pushUndo();drag.mode='gizmo';drag.ax=a;drag.s=s;drag.t0=t;drag.v0=s[a.k];drag.O=O;interacting=true;}}}
    if(!drag.mode&&e.button===0&&!e.shiftKey&&tool==='move'){const s=pickSeed(e);if(s){drag.mode='seed';drag.seed=s;interacting=true;pushUndo();select(s);}}
    if(!drag.mode)drag.mode=(e.button===2||e.button===1||e.shiftKey)?'pan':'orbit';
  });
  cv.addEventListener('pointermove',e=>{
    if(!drag){
      const ex=exOf();
      const hit=pickSurface(e);if(hit)lastHit={...hit,part};
      if(tool==='pt'||tool==='zone'){if(hit){ghost.position.set(hit.x+hit.n[0]*.3,hit.y+hit.n[1]*.3+ex,hit.z+hit.n[2]*.3);ghost.visible=true;}else ghost.visible=false;}
      else if(tool==='out'){const h=pickOutside(e);if(h){ghost.position.set(h.x,h.y+ex,h.z);ghost.visible=true;}else ghost.visible=false;}
      const gh=gizHit(e);
      const s=(tool==='move'||tool==='del')?pickSeed(e):null;const id=s?s.id:null;
      if(id!==hoverId){hoverId=id;updateMarkers();}
      cv.style.cursor=gh?'grab':s?(tool==='move'?'grab':'pointer'):((tool==='pt'||tool==='out')?'crosshair':'default');
      need();return;
    }
    const dx=e.clientX-drag.lx,dy=e.clientY-drag.ly;drag.lx=e.clientX;drag.ly=e.clientY;
    if(Math.hypot(e.clientX-drag.sx,e.clientY-drag.sy)>4)drag.moved=true;
    if(drag.mode==='gizmo'){
      const t=axisParam(e,drag.ax,drag.O);if(t===null)return;
      const s=drag.s,o=partner(s);s[drag.ax.k]=drag.v0+(t-drag.t0);s.free=true;
      if(o&&mirrorOn()){o.x=-s.x;o.y=s.y;o.z=s.z;o.free=true;}
      if(s.t==='o')updateShapes();
      syncSel();updateMarkers();setDirty();save();return;
    }
    if(drag.mode==='seed'){if(drag.moved){const h=drag.seed.t==='o'?pickOutside(e,true):pickSurface(e);if(h)moveSeed(drag.seed,h);}}
    else if(drag.mode==='orbit'){view.az-=dx*.006;view.el=Math.max(-MAXEL,Math.min(MAXEL,view.el+dy*.006));updateCam();ghost.visible=false;}
    else{const k=view.r*Math.tan(cam.fov*Math.PI/360)*2/cv.clientHeight;
      const right=new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld,0),up=new THREE.Vector3().setFromMatrixColumn(cam.matrixWorld,1);
      view.target.addScaledVector(right,-dx*k).addScaledVector(up,dy*k);updateCam();}
  });
  cv.addEventListener('pointerup',e=>{
    const d=drag;drag=null;if(!d)return;
    if(interacting){interacting=false;schedulePreview(120);}
    if(d.mode==='gizmo'){return;}
    if(d.mode==='seed'){updateMarkers();return;}
    if(!d.moved&&d.btn===0&&d.mode==='orbit'){
      const s=pickSeed(e);
      if(d.alt){if(s)delSeed(s);return;}
      if(tool==='del'){if(s)delSeed(s);return;}
      if(s){select(s);return;}                                   // click on a point selects it
      if(tool==='pt'){const h=pickSurface(e);if(h)addSeedAt(h,'s');}
      else if(tool==='zone'){const h=pickSurface(e);if(h)addSeedAt(h,'z');}
      else if(tool==='out'){const h=pickOutside(e);if(h)addSeedAt(h,'o');}
      else select(null);
    }
  });
  cv.addEventListener('pointercancel',()=>{drag=null;if(interacting){interacting=false;schedulePreview(120);}});
  cv.addEventListener('pointerleave',()=>{ghost.visible=false;need();});
  cv.addEventListener('wheel',e=>{e.preventDefault();view.r=Math.max(60,Math.min(1500,view.r*Math.exp(e.deltaY*.0012)));updateCam();},{passive:false});

  // ---------- display toggles
  function applyExplode(){if(meshes.B)meshes.B.position.y=+$('#explode').value;need();}
  $('#explode').oninput=()=>{applyExplode();updateMarkers();};
  $('#grain').oninput=e=>{grainU.value=+e.target.value;need();};
  grainU.value=+$('#grain').value;
  function applySection(){
    const on=$('#secOn').checked;clip.constant=+$('#secX').value;
    mats.A.clippingPlanes=mats.B.clippingPlanes=on?[clip]:null;mats.A.needsUpdate=mats.B.needsUpdate=true;need();
  }
  $('#secOn').onchange=applySection;$('#secX').oninput=applySection;
  $('#showSeeds').onchange=e=>{mk.visible=e.target.checked;blobG.visible=e.target.checked;need();};
  $('#showCuts').onchange=e=>{cutGroup.visible=e.target.checked;need();};
  $('#showPhone').onchange=e=>{phoneG.visible=e.target.checked;need();};

  // ---------- generate
  function setGeo(pt,r){
    const g=new THREE.BufferGeometry();
    g.setAttribute('position',new THREE.BufferAttribute(r.pos,3));g.setIndex(new THREE.BufferAttribute(r.idx,1));
    g.computeVertexNormals();
    if(meshes[pt]){caseGroup.remove(meshes[pt]);meshes[pt].geometry.dispose();}
    meshes[pt]=new THREE.Mesh(g,mats[pt]);caseGroup.add(meshes[pt]);
  }
  function countParts(r){const n=r.nVert,p=new Int32Array(n);for(let i=0;i<n;i++)p[i]=i;const f=q=>{while(p[q]!==q){p[q]=p[p[q]];q=p[q];}return q;};
    for(let t=0;t<r.idx.length;t+=3){const a=f(r.idx[t]);p[f(r.idx[t+1])]=a;p[f(r.idx[t+2])]=a;}
    const u=new Set();for(let t=0;t<r.idx.length;t++)u.add(f(r.idx[t]));return u.size;}
  // how closely the printed inner faces follow the phone cavity (measured on the mesh: back plate + straight side walls)
  function fitReport(r){
    const D=Core.dims(P),Lv=Core.levels(P),p=r.pos;let sp=0,np=0,sw=0,nw=0,mx=0;
    for(let i=0;i<p.length;i+=3){const x=p[i],y=p[i+1],z=p[i+2];
      if(Math.abs(y+D.hy)<0.6&&Math.abs(x)<D.hx-8&&Math.abs(z)<D.hz-14){sp+=y+D.hy;np++;mx=Math.max(mx,Math.abs(y+D.hy));}
      else if(Math.abs(Math.abs(x)-D.hx)<0.6&&Math.abs(z)<D.hz-30&&y>-D.hy+1.6&&y<Lv.yP0-P.joint-0.9){const d=Math.abs(x)-D.hx;sw+=d;nw++;mx=Math.max(mx,Math.abs(d));}}
    if(!np&&!nw)return '';
    const f=v=>(v>=0?'+':'')+v.toFixed(2);
    return `Inner faces vs. cavity: back plate ${np?f(sp/np):'–'} mm · side walls ${nw?f(sw/nw):'–'} mm (worst ${mx.toFixed(2)})`;
  }
  let wantDetail=false,prevT=null;
  async function build(detail){
    if(busy){if(detail)wantDetail=true;return;}
    busy=true;const v0=ver;
    if(detail){$('#busy').style.display='flex';$('#regen').disabled=true;}
    else setChip('dirty','updating preview…');
    const t0=performance.now();
    try{
      const h=detail?parseFloat($('#res').value):1.6, passes=detail?Math.round(P.smooth):1;
      const names={A:'base tray',B:'cover'};
      for(const [k,pt] of ['A','B'].entries()){
        const S=SEEDS.filter(s=>s.p===pt&&s.t==='s').map(s=>[s.x,s.y,s.z]);
        const bl=blobsOf(pt);
        const f=Core.buildEvaluator(P,S,CUTS,pt,bl,zonesOf(pt)),B=Core.bounds(P,pt,bl);
        const r=await Core.meshPart(f,B,detail?h:(pt==='B'?0.7:1.6),passes,detail?(p=>{$('#busyTxt').textContent=`Building detailed ${names[pt]}…`;$('#busyBar').style.width=((k+p)/2*100)+'%';}):null);
        result[pt]=r;setGeo(pt,r);
      }
      applyExplode();applySection();applyPartVis();
      const stale=ver!==v0;
      dirty=stale;quality=stale?'preview':(detail?'detail':'preview');
      const sec=((performance.now()-t0)/1000).toFixed(1);
      if(stale)setChip('dirty','updating preview…');
      else if(detail)setChip('ok',`detailed mesh · ${h} mm · ${sec} s – ready for export`);
      else setChip('prev',`rough preview · ${sec} s – build the detailed mesh for export`);
      const a=Core.bbox(result.A.pos,pxf('A',false)),b=Core.bbox(result.B.pos,pxf('B',false));
      const fm=n=>n>=1000?(n/1000).toFixed(0)+'k':n;
      $('#stats').innerHTML=`Base: ${a.size.map(v=>v.toFixed(1)).join(' × ')} mm · ${fm(result.A.nTri)} tris · ${(result.A.volume/1000).toFixed(1)} cm³<br>Cover: ${b.size.map(v=>v.toFixed(1)).join(' × ')} mm · ${fm(result.B.nTri)} tris · ${(result.B.volume/1000).toFixed(1)} cm³`+(quality==='detail'?'':'<br>(rough preview – volumes approximate)');
      const warn=Core_validate(P);if(warn.length)$('#stats').innerHTML+='<br><b style="color:#b36b00">⚠ '+warn.join('<br>⚠ ')+'</b>';
      if(detail){try{$('#stats').innerHTML+='<br>'+fitReport(result.A);}catch(e){}}
      const nc=countParts(result.B);if(nc>1)$('#stats').innerHTML+=`<br><b style="color:#b36b00">⚠ cover has ${nc} loose parts – a mesh zone must touch the frame</b>`;
      updateExportButtons();
    }catch(err){console.error(err);$('#stats').textContent='Error: '+err.message;}
    $('#busy').style.display='none';$('#regen').disabled=false;busy=false;need();
    if(wantDetail){wantDetail=false;build(true);}
    else if(dirty&&$('#autoPrev').checked)schedulePreview(30);
  }
  schedulePreview=(delay)=>{clearTimeout(prevT);if(!$('#autoPrev').checked)return;prevT=setTimeout(()=>build(false),delay==null?350:delay);};
  $('#autoPrev').onchange=()=>{if($('#autoPrev').checked&&dirty)schedulePreview(0);};
  $('#regen').onclick=()=>build(true);
  $('#res').onchange=()=>{if(quality==='detail'){quality='preview';setChip('prev','resolution changed – build the detailed mesh again');updateExportButtons();}save();};

  // ---------- export
  function saveFile(name,buf){const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([buf],{type:'model/stl'}));a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),2000);}
  $('#expA').onclick=()=>saveFile('case_base.stl',Core.toSTL([{...result.A,xf:pxf('A',false)}]));
  $('#expB').onclick=()=>saveFile('case_cover.stl',Core.toSTL([{...result.B,xf:pxf('B',false)}]));
  $('#expAll').onclick=()=>saveFile('case_print_plate.stl',Core.toSTL([{...result.A,xf:pxf('A',true)},{...result.B,xf:pxf('B',true)}]));

  $('#expTest').onclick=async()=>{
    $('#busy').style.display='flex';$('#busyTxt').textContent='Building fit-test parts…';$('#busyBar').style.width='10%';
    try{
      const T=Core.fitTest(P);
      const rA=await Core.meshPart(T.fA,T.BA,0.35,1,null);$('#busyBar').style.width='55%';
      const rB=await Core.meshPart(T.fB,T.BB,0.35,1,null);
      saveFile('case_fit_test.stl',Core.toSTL([{...rA,xf:T.xfA},{...rB,xf:T.xfB}]));
    }catch(err){console.error(err);alert('Fit test failed: '+err.message);}
    $('#busy').style.display='none';
  };

  // ---------- project io
  const afterLoad=()=>{syncParamInputs();renderCuts();rebuildCutHelpers();rebuildPhone();updateGrid();syncSel();updateMarkers();setDirty();save();};
  $('#saveP').onclick=()=>{const j=JSON.stringify({v:12,P,CUTS,SEEDS,nextId,res:$('#res').value},null,1);const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([j],{type:'application/json'}));a.download='case_project.json';a.click();};
  $('#loadP').onclick=()=>$('#fileP').click();
  $('#fileP').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{applyProject(JSON.parse(await f.text()));afterLoad();}catch(err){alert('Could not read project file.');}e.target.value='';};
  $('#resetP').onclick=()=>{if(!confirm('Reset all dimensions to the defaults? (points and your cutouts stay)'))return;P={...DEFAULT_P};updateShapes();reprojectAll();afterLoad();};
  $('#resetCuts').onclick=()=>{if(!confirm('Replace all cutouts by the rough default estimates?'))return;CUTS=JSON.parse(JSON.stringify(DEFAULT_CUTS));afterLoad();};

  setView('persp');applyPartVis();
  build(false);
})();
</script>
</body>
</html>