INFLATE v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/ccf1bcfca4aa7fe0/" sandbox="allow-scripts allow-forms allow-modals" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>INFLATE v1.0</title>
<style>
:root{
  color-scheme: light;
  --bg:#fff; --fg:#111; --mute:#8c8c8c; --line:#ebebeb; --acc:#ff2d95;
  box-sizing:border-box;
  padding-top:env(safe-area-inset-top,0px);
  padding-bottom:env(safe-area-inset-bottom,0px);
}
html{scroll-padding-top:env(safe-area-inset-top,0px)}
html,body{height:100%;margin:0;background:#fff;color:var(--fg);
  font:12px/1.45 "Helvetica Neue",Helvetica,Arial,system-ui,sans-serif;-webkit-text-size-adjust:100%}
*{box-sizing:border-box}
#app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto}
header{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:10px 14px;border-bottom:1px solid var(--line)}
header .name{font-weight:600;margin-right:10px;letter-spacing:.01em}
header .sp,.lay .sp{width:1px;height:16px;background:var(--line);margin:0 6px}
button{font:inherit;color:var(--fg);background:#fff;border:1px solid #d9d9d9;border-radius:3px;padding:4px 10px;cursor:pointer}
button:hover{border-color:#111}
button.on{border-color:#111;background:#f3f3f3}
button:focus-visible,input:focus-visible{outline:2px solid var(--acc);outline-offset:1px}
main{display:grid;grid-template-columns:1fr 1fr 250px;min-height:0}
section{position:relative;min-height:0;min-width:0;overflow:hidden}
section+section{border-left:1px solid var(--line)}
canvas{display:block;touch-action:none}
#c2{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}
#c3{position:absolute;inset:0}
#c3 canvas{width:100%;height:100%;cursor:grab}
.tag{position:absolute;left:14px;top:10px;color:var(--mute);pointer-events:none}
.hint{position:absolute;left:14px;bottom:10px;right:110px;color:var(--mute);pointer-events:none}
.lay{position:absolute;right:12px;top:6px;display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;max-width:78%}
footer{display:flex;flex-wrap:wrap;align-items:center;gap:8px 22px;padding:10px 14px;border-top:1px solid var(--line)}
#st{color:#444;flex:1 1 100%;min-height:17px}
#msg{color:var(--mute);margin-left:6px}
#msg.err{color:var(--acc)}
aside{border-left:1px solid var(--line);padding:14px;overflow:auto;min-height:0}
aside h2{font-size:12px;font-weight:600;margin:0 0 12px}
aside h2:not(:first-child){margin-top:22px;padding-top:16px;border-top:1px solid var(--line)}
.f{margin-bottom:14px}
.f .lab{display:flex;justify-content:space-between;margin-bottom:4px}
.f .lab b{font-weight:600}
.f input[type=range]{display:block;width:100%;margin:0;accent-color:#111}
.f.inline{display:flex;justify-content:space-between;align-items:center}
.f input[type=color]{width:34px;height:22px;padding:0;border:1px solid #d9d9d9;border-radius:3px;background:#fff;cursor:pointer}
.chk{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.note{color:var(--mute);margin:0}
.tag.b{top:auto;bottom:10px}
button.dark{position:absolute;left:14px;top:10px;z-index:2;background:#111;color:#fff;border-color:#111;display:flex;align-items:center}
button.dark:hover{background:#333}
button.dark .dot{display:none;width:7px;height:7px;border-radius:50%;background:var(--acc);margin-left:8px}
button.dark.dirty .dot{display:inline-block}
@media (max-width:760px){
  main{grid-template-columns:1fr;grid-template-rows:1fr 1fr auto}
  section+section{border-left:0;border-top:1px solid var(--line)}
  aside{border-left:0;border-top:1px solid var(--line)}
}
</style>
</head>
<body>
<div id="app">
  <header>
    <span class="name">INFLATE v1.0</span>
    <button id="tSpline" class="on" title="Punkte setzen">Spline</button>
    <button id="tFree" title="Freihand zeichnen">Freihand</button>
    <span class="sp"></span>
    <button data-p="kreis">Kreis</button>
    <button data-p="viereck">Viereck</button>
    <button data-p="kapsel">Kapsel</button>
    <span class="sp"></span>
    <button id="bUndo">Zurück</button>
    <button id="bNew">Leeren</button>
    <span class="sp"></span>
    <button id="bSvg">SVG exportieren</button>
    <span id="msg"></span>
  </header>
  <main>
    <section id="l">
      <canvas id="c2"></canvas>
      <div class="tag" id="tag2"></div>
      <div class="lay">
        <button id="lyT" class="on" title="Obere Lage bearbeiten">Oben</button>
        <button id="lyB" title="Untere Lage bearbeiten">Unten</button>
        <span class="sp"></span>
        <button id="cpl" class="on" title="Gekoppelt: Ein angefasster Knoten zieht seinen Partner auf der anderen Lage deckungsgleich mit. Entkoppelt: Nur die gewählte Lage bewegt sich.">Gekoppelt</button>
        <button id="alg" title="Die andere Lage deckungsgleich mit der gewählten machen">Angleichen</button>
        <button id="lnb" title="Segmentlängen und Abweichung zur anderen Lage anzeigen">Längen</button>
      </div>
      <div class="hint" id="hint"></div>
    </section>
    <section id="r">
      <div id="c3"></div>
      <button id="bRe" class="dark" title="Simulation mit dem aktuellen Zuschnitt neu starten">Neu aufpumpen<span class="dot"></span></button>
      <div class="tag b">Ziehen zum Drehen, Scrollen zum Zoomen</div>
    </section>
    <aside>
      <h2>Aussehen</h2>
      <div class="f inline"><span>Farbe</span><input type="color" id="col" value="#33383d"></div>
      <div class="f"><div class="lab"><span>Glanz</span><b id="glv"></b></div><input type="range" id="gl" min="0" max="100" step="1" value="55"></div>
      <div class="f"><div class="lab"><span>Metall</span><b id="mev"></b></div><input type="range" id="me" min="0" max="100" step="1" value="0"></div>
      <label class="chk"><input type="checkbox" id="ck1"> Dehnung zeigen</label>
      <h2>Verhalten</h2>
      <div class="f"><div class="lab"><span>Druck</span><b id="prv"></b></div><input type="range" id="pr" min="0" max="15" step="0.1" value="4"></div>
      <div class="f"><div class="lab"><span>Steifigkeit</span><b id="etv"></b></div><input type="range" id="et" min="0" max="100" step="1" value="26"></div>
      <p class="note">Steifigkeit ist E·t des Materials. Folie liegt bei wenigen N/mm, Gewebe bei einigen hundert.</p>
      <h2>Netz</h2>
      <div class="f"><div class="lab"><span>Auflösung</span><b id="rsv"></b></div><input type="range" id="rs" min="0" max="100" step="1" value="53"></div>
      <label class="chk"><input type="checkbox" id="ck2"> Netz zeigen</label>
      <p class="note">Feineres Netz rechnet genauer, aber langsamer. Gilt ab dem nächsten Aufpumpen.</p>
    </aside>
  </main>
  <footer><div id="st"></div></footer>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/delaunator@5.0.1/delaunator.min.js"></script>
<script>
/* ============================ CORE ============================ */
// Einheiten: mm, N, N/mm (Membransteifigkeit E*t), Druck in N/mm² (1 kPa = 0.001)
// Jede Form hat zwei Lagen mit gleicher Knotenzahl: pts (oben) und pb (unten). Knoten i oben gehört zu Knoten i unten.
const DT = 0.02, SUB = 20, DAMP = 0.996;

function cr(p0, p1, p2, p3, t){
  const t2 = t*t, t3 = t2*t;
  return [
    0.5*(2*p1.x + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
    0.5*(2*p1.y + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3)
  ];
}
function splinePoly(P, sub){            // geschlossener Catmull-Rom
  const n = P.length, out = [];
  for (let i = 0; i < n; i++)
    for (let k = 0; k < sub; k++) out.push(cr(P[(i-1+n)%n], P[i], P[(i+1)%n], P[(i+2)%n], k/sub));
  return out;
}
function splinePolyOpen(P, sub){        // offener Catmull-Rom, Enden geklemmt
  const n = P.length;
  if (n < 2) return P.map(p => [p.x, p.y]);
  const out = [];
  for (let i = 0; i < n-1; i++)
    for (let k = 0; k < sub; k++) out.push(cr(P[Math.max(i-1,0)], P[i], P[i+1], P[Math.min(i+2,n-1)], k/sub));
  out.push([P[n-1].x, P[n-1].y]);
  return out;
}
function ptAt(P, closed, i){ const n = P.length; return closed ? P[((i%n)+n)%n] : P[Math.max(0, Math.min(n-1, i))]; }
function segCount(P, closed){ return closed ? P.length : Math.max(0, P.length-1); }
function segPoly(P, closed, k, sub){    // Polylinie des Segments k zwischen Knoten k und k+1, beide Enden inklusive
  const p0 = ptAt(P,closed,k-1), p1 = ptAt(P,closed,k), p2 = ptAt(P,closed,k+1), p3 = ptAt(P,closed,k+2), out = [];
  for (let j = 0; j <= sub; j++) out.push(cr(p0,p1,p2,p3,j/sub));
  return out;
}
function plLen(pl){ let L = 0; for (let i = 1; i < pl.length; i++) L += Math.hypot(pl[i][0]-pl[i-1][0], pl[i][1]-pl[i-1][1]); return L; }
function plAt(pl, L, f){                // Punkt bei Bogenlängenanteil f
  const t = f*L; let acc = 0;
  for (let i = 1; i < pl.length; i++){
    const d = Math.hypot(pl[i][0]-pl[i-1][0], pl[i][1]-pl[i-1][1]);
    if (acc + d >= t || i === pl.length-1){ const u = d > 0 ? Math.max(0, Math.min(1, (t-acc)/d)) : 0; return [pl[i-1][0]+(pl[i][0]-pl[i-1][0])*u, pl[i-1][1]+(pl[i][1]-pl[i-1][1])*u]; }
    acc += d;
  }
  return pl[pl.length-1];
}
// Tastet beide Lagen segmentweise ab, mit gleich vielen Punkten je Segment. So bleibt die Zuordnung Knoten zu Knoten erhalten.
function sampleShape(PT, PB, closed, h){
  const nS = segCount(PT, closed), T = [], B = [];
  for (let k = 0; k < nS; k++){
    const a = segPoly(PT,closed,k,24), b = segPoly(PB,closed,k,24), la = plLen(a), lb = plLen(b);
    const n = Math.max(1, Math.round((la+lb)/2/h));
    for (let j = 0; j < n; j++){ T.push(plAt(a,la,j/n)); B.push(plAt(b,lb,j/n)); }
  }
  if (!closed){ const e = PT.length-1; T.push([PT[e].x, PT[e].y]); B.push([PB[e].x, PB[e].y]); }
  const kt = [], kb = [], min = 0.45*h, d2 = (p,q) => Math.hypot(p[0]-q[0], p[1]-q[1]);
  for (let i = 0; i < T.length; i++){
    if (kt.length){
      const j = kt.length-1;
      if (d2(T[i],kt[j]) < min || d2(B[i],kb[j]) < min){
        if (!closed && i === T.length-1){ kt.pop(); kb.pop(); } else continue;
      }
    }
    kt.push(T[i]); kb.push(B[i]);
  }
  if (closed) while (kt.length > 6){
    const l = kt.length-1;
    if (d2(kt[l],kt[0]) < min || d2(kb[l],kb[0]) < min){ kt.pop(); kb.pop(); } else break;
  }
  return {t: kt, b: kb};
}
function perim(p){let L=0;for(let i=0;i<p.length;i++){const q=p[(i+1)%p.length];L+=Math.hypot(q[0]-p[i][0],q[1]-p[i][1])}return L}
function polyLen(p){let L=0;for(let i=1;i<p.length;i++)L+=Math.hypot(p[i][0]-p[i-1][0],p[i][1]-p[i-1][1]);return L}
function resampleN(poly, M){            // geschlossen
  const n = poly.length, seg = []; let L = 0;
  for (let i = 0; i < n; i++){ const q = poly[(i+1)%n]; const d = Math.hypot(q[0]-poly[i][0], q[1]-poly[i][1]); seg.push(d); L += d; }
  const step = L/M, out = []; let i = 0, acc = 0;
  for (let m = 0; m < M; m++){
    const target = m*step;
    while (acc + seg[i] < target && i < n-1){ acc += seg[i]; i++; }
    const f = seg[i] > 0 ? (target-acc)/seg[i] : 0, q = poly[(i+1)%n];
    out.push([poly[i][0] + (q[0]-poly[i][0])*f, poly[i][1] + (q[1]-poly[i][1])*f]);
  }
  return out;
}
function resampleOpen(poly, M){         // offen, beide Enden bleiben
  const n = poly.length, cum = [0];
  for (let i = 1; i < n; i++) cum.push(cum[i-1] + Math.hypot(poly[i][0]-poly[i-1][0], poly[i][1]-poly[i-1][1]));
  const L = cum[n-1], out = []; let i = 0;
  for (let m = 0; m < M; m++){
    const t = L*m/(M-1);
    while (i < n-2 && cum[i+1] < t) i++;
    const f = cum[i+1] > cum[i] ? (t-cum[i])/(cum[i+1]-cum[i]) : 0;
    out.push([poly[i][0] + (poly[i+1][0]-poly[i][0])*f, poly[i][1] + (poly[i+1][1]-poly[i][1])*f]);
  }
  return out;
}
function polyArea(p){let a=0;for(let i=0;i<p.length;i++){const q=p[(i+1)%p.length];a+=p[i][0]*q[1]-q[0]*p[i][1]}return a/2}
function inPoly(x,y,p){let c=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const a=p[i],b=p[j];if((a[1]>y)!==(b[1]>y)&&x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])c=!c}return c}
function segDist(px,py,a,b){const dx=b[0]-a[0],dy=b[1]-a[1];let t=((px-a[0])*dx+(py-a[1])*dy)/(dx*dx+dy*dy||1);t=Math.max(0,Math.min(1,t));return Math.hypot(a[0]+t*dx-px,a[1]+t*dy-py)}
function segInt(a,b,c,d){const o=(p,q,r)=>(q[0]-p[0])*(r[1]-p[1])-(q[1]-p[1])*(r[0]-p[0]);return (o(a,b,c)>0)!==(o(a,b,d)>0)&&(o(c,d,a)>0)!==(o(c,d,b)>0)}
function selfIntersects(p){const n=p.length;for(let i=0;i<n;i++){const a=p[i],b=p[(i+1)%n];for(let j=i+2;j<n;j++){if(i===0&&j===n-1)continue;if(segInt(a,b,p[j],p[(j+1)%n]))return true}}return false}
function loopsIntersect(A,B){for(let i=0;i<A.length;i++){const a=A[i],b=A[(i+1)%A.length];for(let j=0;j<B.length;j++)if(segInt(a,b,B[j],B[(j+1)%B.length]))return true}return false}

// G = {B: {t,b}, H: [{t,b}], W: [{t,b}]}. t und b sind Punktlisten gleicher Länge (oben und unten).
// Das Netz wird auf dem oberen Layout trianguliert. Die untere Lage bekommt dieselbe Topologie, ihre
// Ruhelängen kommen aus dem unteren Layout (Verschiebungsfeld: Nahtpunkte bekannt, Innenpunkte geglättet).
// Punkte auf Kontur, Ausschnittrand und Naht gehören beiden Lagen (verschweißt).
function buildModel(G, h){
  const Bt = G.B.t, Bb = G.B.b, Ht = G.H.map(x => x.t), Hb = G.H.map(x => x.b), M0 = Bt.length;
  let x0=1e9, x1=-1e9, y0=1e9, y1=-1e9;
  for (const p of Bt){ x0=Math.min(x0,p[0]); x1=Math.max(x1,p[0]); y0=Math.min(y0,p[1]); y1=Math.max(y1,p[1]); }
  const pts = Bt.slice(), ptsB = Bb.slice(), chains = [{s: 0, n: M0, closed: true}], segs = [];
  const addSegs = (s,n,closed) => { for (let i = 0; i < n-1; i++) segs.push([s+i, s+i+1]); if (closed) segs.push([s+n-1, s]); };
  addSegs(0, M0, true);
  const inHole = (x,y) => { for (const hp of Ht) if (inPoly(x,y,hp)) return true; return false; };
  for (let i = 0; i < Ht.length; i++){
    const s = pts.length;
    for (let j = 0; j < Ht[i].length; j++){ pts.push(Ht[i][j]); ptsB.push(Hb[i][j]); }
    chains.push({s, n: Ht[i].length, closed: true}); addSegs(s, Ht[i].length, true);
  }
  const loops = [Bt, ...Ht];
  const dLoops = (x,y) => { let d = 1e9; for (const L of loops) for (let i = 0; i < L.length; i++){ const dd = segDist(x,y,L[i],L[(i+1)%L.length]); if (dd < d) d = dd; } return d; };
  for (const w of G.W){
    const s = pts.length;
    for (let j = 0; j < w.t.length; j++){
      const p = w.t[j];
      if (!inPoly(p[0],p[1],Bt) || inHole(p[0],p[1])) continue;
      if (dLoops(p[0],p[1]) < 0.5*h) continue;
      let ok = true;
      for (let i = 0; i < s; i++) if (Math.hypot(pts[i][0]-p[0], pts[i][1]-p[1]) < 0.45*h){ ok = false; break; }
      if (ok){ pts.push(p); ptsB.push(w.b[j]); }
    }
    const cnt = pts.length - s;
    if (cnt < 2){ pts.length = s; ptsB.length = s; continue; }
    chains.push({s, n: cnt, closed: false}); addSegs(s, cnt, false);
  }
  const Ms = pts.length, dB = new Array(Ms).fill(0), rowH = h*0.8660254;
  const sp = segs.map(([a,b]) => [pts[a], pts[b]]);
  const dAll = (x,y,stop) => { let d = 1e9; for (const [a,b] of sp){ const dd = segDist(x,y,a,b); if (dd < d){ d = dd; if (d < stop) return d; } } return d; };
  let r = 0;
  for (let y = y0 + rowH*0.5; y < y1; y += rowH, r++){
    for (let x = x0 + h*0.25 + ((r&1) ? h*0.5 : 0); x < x1; x += h){
      if (!inPoly(x,y,Bt) || inHole(x,y)) continue;
      const d = dAll(x,y,0.62*h);
      if (d < 0.62*h) continue;
      pts.push([x,y]); dB.push(d);
    }
  }
  const K = pts.length, nI = K - Ms, n = K + nI;
  const co = new Float64Array(2*K);
  for (let i = 0; i < K; i++){ co[2*i] = pts[i][0]; co[2*i+1] = pts[i][1]; }
  const tr = new Delaunator(co).triangles;
  const adj = new Set();
  for (const [a,b] of segs) adj.add(a < b ? a*K+b : b*K+a);
  const okE = (a,b) => adj.has(a < b ? a*K+b : b*K+a) ||
    (inPoly((pts[a][0]+pts[b][0])/2, (pts[a][1]+pts[b][1])/2, Bt) && !inHole((pts[a][0]+pts[b][0])/2, (pts[a][1]+pts[b][1])/2));
  const T = [], es = new Set();
  for (let i = 0; i < tr.length; i += 3){
    let a = tr[i], b = tr[i+1], c = tr[i+2];
    const gx = (pts[a][0]+pts[b][0]+pts[c][0])/3, gy = (pts[a][1]+pts[b][1]+pts[c][1])/3;
    if (!inPoly(gx,gy,Bt) || inHole(gx,gy)) continue;
    if (!okE(a,b) || !okE(b,c) || !okE(c,a)) continue;
    const ny = (pts[b][1]-pts[a][1])*(pts[c][0]-pts[a][0]) - (pts[b][0]-pts[a][0])*(pts[c][1]-pts[a][1]);
    if (Math.abs(ny) < 1e-6) continue;
    if (ny < 0){ const t = b; b = c; c = t; }
    T.push(a,b,c);
    for (const [u,v] of [[a,b],[b,c],[c,a]]) es.add(u < v ? u*K+v : v*K+u);
  }
  // Unteres Layout: Verschiebung an den Nahtpunkten bekannt, innen per Gewichtung und Glättung über das Netz
  const dx = new Float64Array(K), dy = new Float64Array(K);
  let maxd = 0;
  for (let k = 0; k < Ms; k++){ dx[k] = ptsB[k][0]-pts[k][0]; dy[k] = ptsB[k][1]-pts[k][1]; maxd = Math.max(maxd, Math.abs(dx[k]), Math.abs(dy[k])); }
  if (maxd > 1e-6 && K > Ms){
    for (let k = Ms; k < K; k++){
      let sw = 0, sx = 0, sy = 0;
      for (let j = 0; j < Ms; j++){ const ex = pts[j][0]-pts[k][0], ey = pts[j][1]-pts[k][1], w = 1/(ex*ex+ey*ey+1e-6); sw += w; sx += w*dx[j]; sy += w*dy[j]; }
      dx[k] = sx/sw; dy[k] = sy/sw;
    }
    const nbr = Array.from({length: K}, () => []);
    for (const key of es){ const a = Math.floor(key/K), b = key % K; nbr[a].push(b); nbr[b].push(a); }
    for (let sweep = 0; sweep < 60; sweep++)
      for (let k = Ms; k < K; k++){
        const L = nbr[k]; if (!L.length) continue;
        let sx = 0, sy = 0; for (const j of L){ sx += dx[j]; sy += dy[j]; }
        dx[k] = sx/L.length; dy[k] = sy/L.length;
      }
  }
  const pbf = new Array(K);
  for (let k = 0; k < K; k++) pbf[k] = k < Ms ? ptsB[k] : [pts[k][0]+dx[k], pts[k][1]+dy[k]];
  // Startlage: Mittel beider Layouts, um den Schwerpunkt der Außenkontur zentriert
  let cx = 0, cy = 0;
  for (let k = 0; k < M0; k++){ cx += (pts[k][0]+pbf[k][0])/2; cy += (pts[k][1]+pbf[k][1])/2; }
  cx /= M0; cy /= M0;
  let ax0=1e9, ax1=-1e9, ay0=1e9, ay1=-1e9;
  for (let k = 0; k < M0; k++){
    const X = (pts[k][0]+pbf[k][0])/2, Y = (pts[k][1]+pbf[k][1])/2;
    ax0=Math.min(ax0,X); ax1=Math.max(ax1,X); ay0=Math.min(ay0,Y); ay1=Math.max(ay1,Y);
  }
  const bi = k => k < Ms ? k : k + nI;
  const pos = new Float64Array(3*n);
  for (let k = 0; k < K; k++){
    const X = (pts[k][0]+pbf[k][0])/2-cx, Z = (pts[k][1]+pbf[k][1])/2-cy;
    pos[3*k] = X; pos[3*k+2] = Z;
    if (k >= Ms){ const t = 0.15*Math.min(dB[k],30); pos[3*k+1] = t; pos[3*bi(k)] = X; pos[3*bi(k)+2] = Z; pos[3*bi(k)+1] = -t; }
  }
  const ea = [], eb = [], rs = [];
  for (const key of es){
    const a = Math.floor(key/K), b = key % K;
    ea.push(a, bi(a)); eb.push(b, bi(b));
    rs.push(Math.hypot(pts[a][0]-pts[b][0], pts[a][1]-pts[b][1]), Math.hypot(pbf[a][0]-pbf[b][0], pbf[a][1]-pbf[b][1]));
  }
  const tri = [];
  for (let i = 0; i < T.length; i += 3){
    const a = T[i], b = T[i+1], c = T[i+2];
    tri.push(a,b,c, bi(a), bi(c), bi(b));
  }
  const E = rs.length, scnt = new Float32Array(n);
  for (let e = 0; e < E; e++){ scnt[ea[e]]++; scnt[eb[e]]++; }
  let At = Math.abs(polyArea(Bt)), Ab = Math.abs(polyArea(Bb));
  for (const hp of Ht) At -= Math.abs(polyArea(hp));
  for (const hp of Hb) Ab -= Math.abs(polyArea(hp));
  const seamIdx = []; for (const [a,b] of segs) seamIdx.push(a,b);
  return {
    h, M0, Ms, K, n, cx, cy, W0: ax1-ax0, D0: ay1-ay0, At, Ab, chains, seamIdx: Uint32Array.from(seamIdx),
    pos, vel: new Float64Array(3*n), prev: new Float64Array(3*n), frc: new Float64Array(3*n),
    ea: Int32Array.from(ea), eb: Int32Array.from(eb), rest: Float64Array.from(rs),
    tri: Int32Array.from(tri), sacc: new Float32Array(n), scnt
  };
}

function stepSim(m, P, Et){
  const {n, pos, vel, prev, frc, ea, eb, rest, tri} = m, E = rest.length, T = tri.length/3;
  const at = (1/(0.866*Et))/(DT*DT), inv = DAMP/DT, kP = P/6, n3 = 3*n;
  for (let s = 0; s < SUB; s++){
    frc.fill(0);
    if (P > 0) for (let t = 0; t < T; t++){
      const a = tri[3*t]*3, b = tri[3*t+1]*3, c = tri[3*t+2]*3;
      const ux = pos[b]-pos[a], uy = pos[b+1]-pos[a+1], uz = pos[b+2]-pos[a+2];
      const vx = pos[c]-pos[a], vy = pos[c+1]-pos[a+1], vz = pos[c+2]-pos[a+2];
      const nx = (uy*vz-uz*vy)*kP, ny = (uz*vx-ux*vz)*kP, nz = (ux*vy-uy*vx)*kP;
      frc[a]+=nx; frc[a+1]+=ny; frc[a+2]+=nz;
      frc[b]+=nx; frc[b+1]+=ny; frc[b+2]+=nz;
      frc[c]+=nx; frc[c+1]+=ny; frc[c+2]+=nz;
    }
    for (let i = 0; i < n3; i++){ vel[i] += DT*frc[i]; prev[i] = pos[i]; pos[i] += DT*vel[i]; }
    for (let e = 0; e < E; e++){
      const a = ea[e]*3, b = eb[e]*3;
      let dx = pos[a]-pos[b], dy = pos[a+1]-pos[b+1], dz = pos[a+2]-pos[b+2];
      const L = Math.sqrt(dx*dx+dy*dy+dz*dz) || 1e-9;
      const k = -(L-rest[e])/(2+at)/L;
      dx*=k; dy*=k; dz*=k;
      pos[a]+=dx; pos[a+1]+=dy; pos[a+2]+=dz;
      pos[b]-=dx; pos[b+1]-=dy; pos[b+2]-=dz;
    }
    for (let i = 0; i < n3; i++) vel[i] = (pos[i]-prev[i])*inv;
  }
}

function analyse(m){
  const {pos, tri, ea, eb, rest, M0, sacc} = m, E = rest.length;
  let V = 0, ymin = 1e9, ymax = -1e9;
  for (let t = 0; t < tri.length; t += 3){
    const a = tri[t]*3, b = tri[t+1]*3, c = tri[t+2]*3;
    V += (pos[a]*(pos[b+1]*pos[c+2]-pos[b+2]*pos[c+1]) + pos[a+1]*(pos[b+2]*pos[c]-pos[b]*pos[c+2]) + pos[a+2]*(pos[b]*pos[c+1]-pos[b+1]*pos[c]))/6;
    for (const q of [a,b,c]){ if (pos[q+1] < ymin) ymin = pos[q+1]; if (pos[q+1] > ymax) ymax = pos[q+1]; }
  }
  let bx0=1e9, bx1=-1e9, bz0=1e9, bz1=-1e9;
  for (let i = 0; i < M0; i++){ bx0=Math.min(bx0,pos[3*i]); bx1=Math.max(bx1,pos[3*i]); bz0=Math.min(bz0,pos[3*i+2]); bz1=Math.max(bz1,pos[3*i+2]); }
  sacc.fill(0);
  let sum = 0, mx = 0;
  for (let e = 0; e < E; e++){
    const a = ea[e]*3, b = eb[e]*3;
    const L = Math.hypot(pos[a]-pos[b], pos[a+1]-pos[b+1], pos[a+2]-pos[b+2]);
    const s = (L-rest[e])/rest[e];
    sum += Math.abs(s); if (s > mx) mx = s;
    sacc[ea[e]] += Math.abs(s); sacc[eb[e]] += Math.abs(s);
  }
  return {vol: V, h: ymax-ymin, w: bx1-bx0, d: bz1-bz0, mean: sum/E, max: mx};
}

// Bereitet die gezeichneten Formen auf (Validierung inklusive). Gibt {G, h, error} zurück.
function prepare(shapes, N){
  const o = shapes[0];
  if (!o || !o.closed || o.pts.length < 3) return {error: 'Zuerst eine geschlossene Außenkontur zeichnen.'};
  const inner = shapes.slice(1).filter(sh => sh.done);
  const holesS = inner.filter(sh => sh.closed && sh.pts.length >= 3);
  const weldsS = inner.filter(sh => !sh.closed && sh.pts.length >= 2);
  const area = key => { let a = Math.abs(polyArea(splinePoly(o[key], 12))); for (const sh of holesS) a -= Math.abs(polyArea(splinePoly(sh[key], 12))); return a; };
  const aT = area('pts'), aB = area('pb');
  if (Math.min(aT, aB) < 3000) return {error: 'Die Form ist zu klein.'};
  const h = Math.min(40, Math.max(2.5, Math.sqrt(((aT+aB)/2)/((N || 1100)*0.866))));
  const B = sampleShape(o.pts, o.pb, true, h);
  if (B.t.length < 12) return {error: 'Die Form ist zu klein.'};
  const H = holesS.map(sh => sampleShape(sh.pts, sh.pb, true, h));
  const W = weldsS.map(sh => sampleShape(sh.pts, sh.pb, false, h));
  for (const lay of ['t', 'b']){
    const pre = lay === 'b' ? 'Unten: ' : '', Bl = B[lay];
    if (selfIntersects(Bl)) return {error: pre + 'Die Außenkontur überschneidet sich. Bitte einen Punkt verschieben.'};
    for (let i = 0; i < H.length; i++){
      const Hi = H[i][lay];
      if (Hi.length < 8 || Math.abs(polyArea(Hi)) < 6*h*h) return {error: pre + 'Ein Ausschnitt ist zu klein.'};
      if (selfIntersects(Hi)) return {error: pre + 'Ein Ausschnitt überschneidet sich selbst.'};
      if (Hi.some(p => !inPoly(p[0],p[1],Bl)) || loopsIntersect(Bl, Hi)) return {error: pre + 'Ein Ausschnitt liegt nicht vollständig innerhalb der Außenkontur.'};
      for (let j = 0; j < i; j++){
        const Hj = H[j][lay];
        if (loopsIntersect(Hi, Hj) || inPoly(Hi[0][0],Hi[0][1],Hj) || inPoly(Hj[0][0],Hj[0][1],Hi)) return {error: pre + 'Ausschnitte überlappen sich.'};
      }
    }
  }
  return {G: {B, H, W}, h};
}
/* ============================ /CORE ============================ */

/* ============================ UI ============================ */
const $ = s => document.querySelector(s);
const S = {shapes: [], tool: 'spline', layer: 'top', coupled: true, lens: false, pressure: 4, Et: 6, strain: false, wire: false, N: 1100};
let model = null, Pcur = 0, fitted = false;
const history = [];
let sel = [];
const isSel = (si,pi) => sel.some(k => k.si === si && k.pi === pi);
const LK = () => S.layer === 'top' ? 'pts' : 'pb';      // aktive Lage
const OK = () => S.layer === 'top' ? 'pb' : 'pts';      // andere Lage
const clone = p => ({x: p.x, y: p.y});
const mkShape = (pts, closed, done) => ({pts, pb: pts.map(clone), closed, done});

if (typeof THREE === 'undefined' || typeof Delaunator === 'undefined'){
  $('#msg').textContent = 'Bibliotheken (three.js, delaunator) konnten nicht geladen werden.'; $('#msg').className = 'err';
}
function msg(t, err){ const m = $('#msg'); m.textContent = t || ''; m.className = err ? 'err' : ''; }

/* ---------- Verlauf ---------- */
function snap(){ history.push(JSON.stringify(S.shapes)); if (history.length > 60) history.shift(); }
function undo(){
  if (!history.length) return;
  S.shapes = JSON.parse(history.pop()); drag = null; freeRaw = null; sel = [];
  msg(''); commit();
}
let dirty = false;
function setDirty(v){ dirty = v; $('#bRe').classList.toggle('dirty', v); }
function commit(auto){
  sel = sel.filter(k => S.shapes[k.si] && S.shapes[k.si].pts[k.pi]);
  updateHint();
  if (!model || auto === true) scheduleBuild(20); else setDirty(true);
}
function deleteSelected(){
  sel = sel.filter(k => S.shapes[k.si] && S.shapes[k.si].pts[k.pi]);
  if (!sel.length) return;
  snap();
  const by = {};
  sel.forEach(k => { (by[k.si] = by[k.si] || []).push(k.pi); });
  for (const si of Object.keys(by).map(Number).sort((a,b) => b-a)){
    const sh = S.shapes[si];
    for (const pi of by[si].sort((a,b) => b-a)){ sh.pts.splice(pi, 1); sh.pb.splice(pi, 1); }   // beide Lagen
    if (sh.pts.length <= 1) S.shapes.splice(si, 1);
    else if (sh.closed && sh.pts.length < 3) sh.closed = false;
  }
  sel = []; msg(''); commit();
}
const active = () => { const l = S.shapes[S.shapes.length-1]; return l && !l.done ? l : null; };
function finishActive(){
  const a = active(); if (!a) return;
  const idx = S.shapes.indexOf(a);
  snap();
  if (idx === 0){ if (a.pts.length >= 3){ a.closed = true; a.done = true; } else S.shapes.pop(); }
  else { if (a.pts.length >= 2) a.done = true; else S.shapes.pop(); }
  commit();
}
function cancelActive(){ const a = active(); if (!a) return; snap(); S.shapes.pop(); commit(); }
const shapePoly = (sh, key, sub) => sh.closed ? splinePoly(sh[key], sub) : splinePolyOpen(sh[key], sub);
const layersDiffer = () => S.shapes.some(sh => sh.pts.some((p,i) => Math.hypot(p.x-sh.pb[i].x, p.y-sh.pb[i].y) > 0.05));

/* ---------- 2D ---------- */
const c2 = $('#c2'), ctx = c2.getContext('2d');
let cw = 1, ch = 1, dpr = 1, drag = null, dragSnapped = false, freeRaw = null, cursor = null;
const vs = () => Math.min(cw/640, ch/480);
const w2s = (x,y) => [cw/2 + x*vs(), ch/2 + y*vs()];
function evW(e){ const r = c2.getBoundingClientRect(), s = vs(); return {x: (e.clientX-r.left-cw/2)/s, y: (e.clientY-r.top-ch/2)/s}; }
function evS(e){ const r = c2.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; }

function hitPoint(e){
  const [sx,sy] = evS(e);
  for (let si = S.shapes.length-1; si >= 0; si--)
    for (let pi = 0; pi < S.shapes[si][LK()].length; pi++){
      const p = S.shapes[si][LK()][pi], [px,py] = w2s(p.x, p.y);
      if (Math.hypot(px-sx, py-sy) < 10) return {si, pi};
    }
  return null;
}
// nächster Punkt auf einer Kurve der aktiven Lage: Formindex, Segment, Parameter t im Segment
function nearCurve(e){
  const [sx,sy] = evS(e); let best = 9, res = null;
  S.shapes.forEach((sh, si) => {
    const P = sh[LK()]; if (P.length < 2) return;
    for (let k = 0; k < segCount(P, sh.closed); k++){
      const pl = segPoly(P, sh.closed, k, 10).map(p => w2s(p[0],p[1]));
      for (let j = 0; j < 10; j++){
        const a = pl[j], b = pl[j+1], dx = b[0]-a[0], dy = b[1]-a[1];
        const u = Math.max(0, Math.min(1, ((sx-a[0])*dx + (sy-a[1])*dy)/(dx*dx+dy*dy || 1)));
        const d = Math.hypot(a[0]+u*dx-sx, a[1]+u*dy-sy);
        if (d < best){ best = d; res = {si, seg: k, t: (j+u)/10}; }
      }
    }
  });
  return res;
}
function pointOnLayer(sh, key, seg, t){
  const P = sh[key], c = sh.closed, q = cr(ptAt(P,c,seg-1), ptAt(P,c,seg), ptAt(P,c,seg+1), ptAt(P,c,seg+2), t);
  return {x: q[0], y: q[1]};
}

c2.addEventListener('pointerdown', e => {
  c2.setPointerCapture(e.pointerId);
  const p = evW(e), hit = hitPoint(e), act = active();
  if (hit){
    const sh = S.shapes[hit.si];
    if (sh === act && hit.pi === 0 && sh.pts.length >= 3){ snap(); sh.closed = true; sh.done = true; sel = []; commit(); return; }
    if (e.shiftKey){
      if (isSel(hit.si, hit.pi)) sel = sel.filter(k => !(k.si === hit.si && k.pi === hit.pi)); else sel.push(hit);
      return;
    }
    if (!isSel(hit.si, hit.pi)) sel = [hit];
    drag = hit; dragSnapped = false; return;
  }
  if (e.shiftKey){
    const nc = nearCurve(e);
    if (nc){
      snap();
      const sh = S.shapes[nc.si], a = pointOnLayer(sh, LK(), nc.seg, nc.t);
      const b = S.coupled ? clone(a) : pointOnLayer(sh, OK(), nc.seg, nc.t);   // Partnerknoten sitzt am selben Kurvenparameter
      sh[LK()].splice(nc.seg+1, 0, a); sh[OK()].splice(nc.seg+1, 0, b);
      sel = []; commit(); return;
    }
  }
  sel = [];
  if (S.tool === 'free'){ freeRaw = [p]; return; }
  snap();
  if (act){ act.pts.push(p); act.pb.push(clone(p)); }
  else S.shapes.push(mkShape([p], false, false));
  updateHint();
});
c2.addEventListener('pointermove', e => {
  cursor = evW(e);
  if (drag){
    if (!dragSnapped){ snap(); dragSnapped = true; }
    const q = evW(e), sh = S.shapes[drag.si];
    sh[LK()][drag.pi] = q;
    if (S.coupled) sh[OK()][drag.pi] = clone(q);     // gekoppelt: Partnerknoten wird deckungsgleich
  } else if (freeRaw){
    const p = evW(e), q = freeRaw[freeRaw.length-1];
    if (Math.hypot(p.x-q.x, p.y-q.y)*vs() > 3) freeRaw.push(p);
  }
});
c2.addEventListener('pointerup', () => {
  if (drag){ const moved = dragSnapped; drag = null; if (moved) commit(); }
  if (freeRaw){ const r = freeRaw; freeRaw = null; finishFree(r); }
});
c2.addEventListener('pointerleave', () => { cursor = null; });
c2.addEventListener('dblclick', e => {
  const hit = hitPoint(e), act = active();
  if (!hit) return;
  const sh = S.shapes[hit.si];
  if (sh === act && hit.pi === sh.pts.length-1){ finishActive(); return; }
  sel = [hit]; deleteSelected();
});
window.addEventListener('keydown', e => {
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'){ e.preventDefault(); undo(); return; }
  if (e.key === 'Delete' || e.key === 'Backspace'){ if (sel.length){ e.preventDefault(); deleteSelected(); } return; }
  if (e.key === 'Enter') finishActive();
  if (e.key === 'Escape') cancelActive();
});

function finishFree(raw){
  let L = 0; for (let i = 1; i < raw.length; i++) L += Math.hypot(raw[i].x-raw[i-1].x, raw[i].y-raw[i-1].y);
  if (L*vs() < 60 || raw.length < 5) return;
  let p = raw.map(q => [q.x, q.y]);
  for (let pass = 0; pass < 3; pass++){
    p = p.map((q,i) => { let sx=0, sy=0, c=0; for (let k = -2; k <= 2; k++){ const r = p[i+k]; if (r){ sx+=r[0]; sy+=r[1]; c++; } } return [sx/c, sy/c]; });
  }
  const sc = vs(), first = S.shapes.length === 0;
  const endD = Math.hypot(p[0][0]-p[p.length-1][0], p[0][1]-p[p.length-1][1])*sc;
  const closed = first || endD < 30;
  snap();
  let pts;
  if (closed) pts = resampleN(p, Math.max(6, Math.min(40, Math.round(perim(p)*sc/34))));
  else pts = resampleOpen(p, Math.max(2, Math.min(40, Math.round(polyLen(p)*sc/34)+1)));
  S.shapes.push(mkShape(pts.map(q => ({x: q[0], y: q[1]})), closed, true));
  commit();
}

function draw2(){
  const c = ctx; c.setTransform(dpr,0,0,dpr,0,0); c.clearRect(0,0,cw,ch);
  const s = vs(), lk = LK(), ok = OK(), differ = layersDiffer();
  c.lineWidth = 1;
  for (let x = -400; x <= 400; x += 50){ c.strokeStyle = x % 100 === 0 ? '#eeeeee' : '#f6f6f6'; const X = cw/2 + x*s; c.beginPath(); c.moveTo(X,0); c.lineTo(X,ch); c.stroke(); }
  for (let y = -400; y <= 400; y += 50){ c.strokeStyle = y % 100 === 0 ? '#eeeeee' : '#f6f6f6'; const Y = ch/2 + y*s; c.beginPath(); c.moveTo(0,Y); c.lineTo(cw,Y); c.stroke(); }
  const path = (poly, closed) => { c.beginPath(); poly.forEach((p,i) => { const q = w2s(p[0],p[1]); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); }); if (closed) c.closePath(); };
  const o = S.shapes[0];
  // aktive Lage: Flächen
  if (o && o.closed && o[lk].length >= 3){ path(splinePoly(o[lk], 10), true); c.fillStyle = S.layer === 'top' ? '#f8f8f8' : '#f1f1f1'; c.fill(); }
  S.shapes.forEach((sh, i) => { if (i > 0 && sh.closed && sh[lk].length >= 3){ path(splinePoly(sh[lk], 10), true); c.fillStyle = '#fff'; c.fill(); } });
  // andere Lage als leicht transparenter Schatten
  if (differ){
    if (o && o.closed && o[ok].length >= 3){ path(splinePoly(o[ok], 10), true); c.fillStyle = 'rgba(255,45,149,0.06)'; c.fill(); }
    c.strokeStyle = 'rgba(17,17,17,0.28)'; c.lineWidth = 1.3;
    S.shapes.forEach(sh => { if (sh.done && sh[ok].length >= 2){ path(shapePoly(sh, ok, 10), sh.closed); c.stroke(); } });
  }
  // aktive Lage: Linien
  c.strokeStyle = '#111'; c.lineWidth = 1.5;
  S.shapes.forEach(sh => {
    if (sh.closed && sh[lk].length >= 3){ path(splinePoly(sh[lk], 10), true); c.stroke(); }
    else if (sh.done && sh[lk].length >= 2){ c.setLineDash([7,4]); path(splinePolyOpen(sh[lk], 10), false); c.stroke(); c.setLineDash([]); }
  });
  if (model && !dirty){   // Endkontur nach dem Aufpumpen (Einlaufen sichtbar)
    c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.setLineDash([5,4]);
    for (const ch_ of model.chains){
      c.beginPath();
      for (let i = 0; i < ch_.n; i++){ const k = ch_.s + i, q = w2s(model.pos[3*k]+model.cx, model.pos[3*k+2]+model.cy); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); }
      if (ch_.closed) c.closePath();
      c.stroke();
    }
    c.setLineDash([]);
  }
  const act = active();
  if (act && act[lk].length >= 1){
    const idx = S.shapes.indexOf(act);
    if (act[lk].length >= 3){
      c.strokeStyle = '#cfcfcf'; c.lineWidth = 1; c.setLineDash([4,4]);
      path(idx === 0 ? splinePoly(act[lk], 10) : splinePolyOpen(act[lk], 10), idx === 0); c.stroke(); c.setLineDash([]);
    }
    c.strokeStyle = '#111'; c.lineWidth = 1.2; c.beginPath();
    act[lk].forEach((p,i) => { const q = w2s(p.x,p.y); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); });
    if (cursor && S.tool === 'spline'){ const q = w2s(cursor.x,cursor.y); c.lineTo(q[0],q[1]); }
    c.stroke();
  }
  // Partnerknoten der anderen Lage und ihre Verbindungslinien
  if (differ){
    S.shapes.forEach(sh => sh[lk].forEach((p,i) => {
      const r = sh[ok][i]; if (!r || Math.hypot(p.x-r.x, p.y-r.y) < 0.3) return;
      const a = w2s(p.x,p.y), b = w2s(r.x,r.y);
      c.strokeStyle = 'rgba(255,45,149,0.55)'; c.lineWidth = 1; c.beginPath(); c.moveTo(a[0],a[1]); c.lineTo(b[0],b[1]); c.stroke();
      c.fillStyle = 'rgba(17,17,17,0.35)'; c.beginPath(); c.arc(b[0], b[1], 2.5, 0, Math.PI*2); c.fill();
    }));
  }
  // Segmentlängen
  if (S.lens){
    c.font = '10px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.lineJoin = 'round';
    S.shapes.forEach(sh => {
      if (!sh.done || sh[lk].length < 2) return;
      for (let k = 0; k < segCount(sh[lk], sh.closed); k++){
        const pa = segPoly(sh[lk], sh.closed, k, 10), pb = segPoly(sh[ok], sh.closed, k, 10);
        const La = plLen(pa), Lb = plLen(pb), d = Lb > 0 ? (La-Lb)/Lb : 0, bad = Math.abs(d) >= 0.005;
        const m = pa[5], t0 = pa[4], t1 = pa[6];
        let nx = -(t1[1]-t0[1]), ny = t1[0]-t0[0]; const nl = Math.hypot(nx,ny) || 1; nx /= nl; ny /= nl;
        const q = w2s(m[0], m[1]), X = q[0]+nx*10, Y = q[1]+ny*10;
        const txt = Math.round(La) + (bad ? ' (' + (d > 0 ? '+' : '−') + fmt(Math.abs(d)*100, 1) + ' %)' : '');
        c.lineWidth = 3; c.strokeStyle = '#fff'; c.strokeText(txt, X, Y);
        c.fillStyle = bad ? '#ff2d95' : '#8c8c8c'; c.fillText(txt, X, Y);
      }
    });
    c.textBaseline = 'alphabetic';
  }
  S.shapes.forEach((sh, si) => sh[lk].forEach((p,i) => {
    const q = w2s(p.x,p.y), on = isSel(si, i);
    if (sh === act && i === 0 && sh[lk].length >= 3){ c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.beginPath(); c.arc(q[0], q[1], 9, 0, Math.PI*2); c.stroke(); }
    c.fillStyle = on ? '#ff2d95' : '#fff'; c.strokeStyle = on ? '#ff2d95' : '#111'; c.lineWidth = 1;
    const r = on ? 4.5 : 3.5;
    c.beginPath(); c.rect(q[0]-r, q[1]-r, 2*r, 2*r); c.fill(); c.stroke();
  }));
  if (freeRaw && freeRaw.length > 1){
    c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.beginPath();
    freeRaw.forEach((p,i) => { const q = w2s(p.x,p.y); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); }); c.stroke();
  }
  const bx = cw - 14, by = ch - 16; c.strokeStyle = '#8c8c8c'; c.fillStyle = '#8c8c8c'; c.lineWidth = 1;
  c.beginPath(); c.moveTo(bx-100*s, by); c.lineTo(bx, by); c.moveTo(bx-100*s, by-4); c.lineTo(bx-100*s, by+4); c.moveTo(bx, by-4); c.lineTo(bx, by+4); c.stroke();
  c.font = '11px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'right'; c.fillText('100 mm', bx, by-8);
}

/* ---------- Formen ---------- */
const sq = (n, a, b, p) => Array.from({length: n}, (_,i) => { const t = i/n*Math.PI*2, c = Math.cos(t), s = Math.sin(t); return {x: a*Math.sign(c)*Math.pow(Math.abs(c), p), y: b*Math.sign(s)*Math.pow(Math.abs(s), p)}; });
const PRESETS = {
  kreis: () => Array.from({length: 12}, (_,i) => { const t = i/12*Math.PI*2; return {x: 150*Math.cos(t), y: 150*Math.sin(t)}; }),
  viereck: () => sq(24, 140, 140, 0.25),
  kapsel: () => sq(20, 200, 65, 0.5)
};
document.querySelectorAll('[data-p]').forEach(b => b.addEventListener('click', () => {
  snap(); S.shapes = [mkShape(PRESETS[b.dataset.p](), true, true)]; sel = []; fitted = false; msg(''); commit(true);
}));
function setTool(t){
  finishActive();
  S.tool = t; $('#tSpline').classList.toggle('on', t === 'spline'); $('#tFree').classList.toggle('on', t === 'free'); updateHint();
}
$('#tSpline').onclick = () => setTool('spline');
$('#tFree').onclick = () => setTool('free');
$('#bUndo').onclick = undo;
$('#bNew').onclick = () => { snap(); S.shapes = []; sel = []; clearModel(); msg(''); updateHint(); };
$('#bRe').onclick = () => build(true);

/* ---------- Lagen ---------- */
function setLayer(l){
  S.layer = l; sel = []; drag = null;
  $('#lyT').classList.toggle('on', l === 'top'); $('#lyB').classList.toggle('on', l === 'bottom');
  updateHint();
}
function syncLens(){ $('#lnb').classList.toggle('on', S.lens); }
function setCoupled(v){
  S.coupled = v;
  $('#cpl').classList.toggle('on', v); $('#cpl').textContent = v ? 'Gekoppelt' : 'Entkoppelt';
  if (!v){ S.lens = true; syncLens(); }
  updateHint();
}
$('#lyT').onclick = () => setLayer('top');
$('#lyB').onclick = () => setLayer('bottom');
$('#cpl').onclick = () => setCoupled(!S.coupled);
$('#lnb').onclick = () => { S.lens = !S.lens; syncLens(); };
$('#alg').onclick = () => { snap(); S.shapes.forEach(sh => { sh[OK()] = sh[LK()].map(clone); }); commit(); };

function updateHint(){
  const o = S.shapes[0], act = active(); let t;
  if (!o) t = S.tool === 'free' ? 'Außenkontur freihand ziehen, sie schließt sich von selbst.' : 'Außenkontur zeichnen: Klicken setzt Punkte, ein Klick auf den ersten Punkt schließt die Form.';
  else if (act && S.shapes.indexOf(act) === 0) t = 'Ersten Punkt anklicken oder Enter drücken, um die Form zu schließen.';
  else if (act) t = 'Enter oder Doppelklick auf den letzten Punkt beendet die Linie als Naht. Ein Klick auf den ersten Punkt schließt sie als Ausschnitt.';
  else if (S.tool === 'free') t = 'Innen freihand ziehen: endet der Strich nahe am Start, wird es ein Ausschnitt, sonst eine Naht. Punkt anklicken und Entf drücken löscht ihn.';
  else t = 'Innen zeichnen: eine offene Linie ist eine Naht, eine geschlossene Form ein Ausschnitt. Punkt anklicken (Shift für mehrere) und Entf löscht ihn. Shift+Klick auf eine Kurve fügt einen Punkt ein.';
  if (o && !S.coupled) t = 'Entkoppelt: Es bewegt sich nur die Lage ' + (S.layer === 'top' ? 'oben' : 'unten') + '. ' + t;
  $('#hint').textContent = t;
  $('#tag2').textContent = 'Zuschnitt ' + (S.layer === 'top' ? 'oben' : 'unten') + ' in mm' + (layersDiffer() ? ', die andere Lage als Schatten' : '');
}

/* ---------- SVG-Export ---------- */
function bez(pts, closed){
  const n = pts.length, f = v => v.toFixed(2);
  const g = i => closed ? pts[(i+n)%n] : pts[Math.max(0, Math.min(n-1, i))];
  let d = 'M' + f(pts[0].x) + ' ' + f(pts[0].y);
  for (let i = 0; i < (closed ? n : n-1); i++){
    const p0 = g(i-1), p1 = g(i), p2 = g(i+1), p3 = g(i+2);
    d += 'C' + f(p1.x+(p2.x-p0.x)/6) + ' ' + f(p1.y+(p2.y-p0.y)/6) + ' ' + f(p2.x-(p3.x-p1.x)/6) + ' ' + f(p2.y-(p3.y-p1.y)/6) + ' ' + f(p2.x) + ' ' + f(p2.y);
  }
  return d + (closed ? 'Z' : '');
}
function buildSVG(){
  const o = S.shapes[0];
  if (!o || !o.closed || o.pts.length < 3) return null;
  const list = S.shapes.filter(sh => sh.done && sh.pts.length >= (sh.closed ? 3 : 2));
  const differ = layersDiffer();
  const layers = differ ? [['oben', 'pts'], ['unten', 'pb']] : [['', 'pts']];
  const m = 5, gap = 20, f = v => v.toFixed(2);
  let curX = m, maxH = 0, body = '';
  for (const [name, key] of layers){
    let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
    for (const sh of list) for (const p of shapePoly(sh, key, 16)){ x0 = Math.min(x0,p[0]); x1 = Math.max(x1,p[0]); y0 = Math.min(y0,p[1]); y1 = Math.max(y1,p[1]); }
    const tx = curX - x0, ty = m - y0;
    const tp = sh => sh[key].map(p => ({x: p.x+tx, y: p.y+ty}));
    const cuts = list.filter(sh => sh.closed).map(sh => '      <path d="' + bez(tp(sh), true) + '"/>').join('\n');
    const welds = list.filter(sh => !sh.closed).map(sh => '      <path d="' + bez(tp(sh), false) + '"/>').join('\n');
    const pre = name ? name + '-' : '';
    body += (name ? '  <g id="' + name + '">\n' : '  <g>\n') +
      '    <g id="' + pre + 'schnitt" fill="none" stroke="#000000" stroke-width="0.3">\n' + cuts + '\n    </g>\n' +
      (welds ? '    <g id="' + pre + 'naht" fill="none" stroke="#0000ff" stroke-width="0.3">\n' + welds + '\n    </g>\n' : '') +
      '  </g>\n';
    curX += (x1-x0) + gap; maxH = Math.max(maxH, y1-y0);
  }
  const W = curX - gap + m, H = maxH + 2*m;
  const note = differ ? '  <!-- oben und unten: beide in Ansicht von oben gezeichnet. Zum Zuschneiden das untere Teil bei Bedarf spiegeln. -->\n' : '  <!-- Beide Lagen haben dasselbe Muster: zweimal zuschneiden. -->\n';
  return '<?xml version="1.0" encoding="UTF-8"?>\n' +
    '<svg xmlns="http://www.w3.org/2000/svg" width="' + f(W) + 'mm" height="' + f(H) + 'mm" viewBox="0 0 ' + f(W) + ' ' + f(H) + '">\n' + note + body + '</svg>\n';
}
async function saveFile(name, text, mime){
  try {
    const dl = (window.claude && typeof window.claude.use === 'function') ? await window.claude.use('downloads') : null;
    if (dl){ await dl.save({filename: name, data: text}); return 'saved'; }
  } catch (err) { if (err && err.code === 'declined') return 'declined'; }
  const a = document.createElement('a');
  a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'})); a.download = name;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  return 'anchor';
}
$('#bSvg').onclick = () => {
  const svg = buildSVG();
  if (!svg){ msg('Erst eine geschlossene Außenkontur zeichnen.', true); return; }
  msg(''); saveFile('inflate-zuschnitt.svg', svg, 'image/svg+xml').then(r => { if (r === 'saved') msg('SVG gespeichert.'); });
};

/* ---------- 3D ---------- */
let renderer, scene, camera, mesh, wire, seam, floor, geo, colArr, mat;
let az = 0.6, el = 0.5, dist = 700;
function makeEnv(){
  const c = document.createElement('canvas'); c.width = 512; c.height = 256;
  const g = c.getContext('2d'), gr = g.createLinearGradient(0,0,0,256);
  gr.addColorStop(0, '#ffffff'); gr.addColorStop(0.5, '#e4e4e4'); gr.addColorStop(1, '#b4b4b4');
  g.fillStyle = gr; g.fillRect(0,0,512,256);
  g.fillStyle = '#ffffff';
  g.fillRect(40,50,110,50); g.fillRect(250,30,140,44); g.fillRect(420,70,60,80); g.fillRect(170,120,70,22);
  g.fillStyle = '#9a9a9a'; g.fillRect(0,150,512,10);
  const tex = new THREE.CanvasTexture(c); tex.mapping = THREE.EquirectangularReflectionMapping;
  const pm = new THREE.PMREMGenerator(renderer), rt = pm.fromEquirectangular(tex);
  tex.dispose(); pm.dispose();
  return rt.texture;
}
if (typeof THREE !== 'undefined'){
  renderer = new THREE.WebGLRenderer({antialias: true});
  renderer.setClearColor(0xffffff, 1);
  $('#c3').appendChild(renderer.domElement);
  scene = new THREE.Scene();
  camera = new THREE.PerspectiveCamera(35, 1, 1, 20000);
  scene.environment = makeEnv();
  scene.add(new THREE.HemisphereLight(0xffffff, 0xdcdcdc, 0.3));
  const dl = new THREE.DirectionalLight(0xffffff, 0.45); dl.position.set(200, 500, 300); scene.add(dl);
  const dl2 = new THREE.DirectionalLight(0xffffff, 0.15); dl2.position.set(-300, -200, -200); scene.add(dl2);
  floor = new THREE.GridHelper(1600, 32, 0xe4e4e4, 0xf0f0f0); scene.add(floor);
  mat = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.45, metalness: 0, side: THREE.DoubleSide, envMapIntensity: 0.9});
  const rd = renderer.domElement; let dragging = false, lx = 0, ly = 0;
  rd.addEventListener('pointerdown', e => { dragging = true; lx = e.clientX; ly = e.clientY; rd.setPointerCapture(e.pointerId); rd.style.cursor = 'grabbing'; });
  rd.addEventListener('pointermove', e => { if (!dragging) return; az -= (e.clientX-lx)*0.006; el = Math.max(-1.5, Math.min(1.5, el + (e.clientY-ly)*0.006)); lx = e.clientX; ly = e.clientY; });
  rd.addEventListener('pointerup', () => { dragging = false; rd.style.cursor = 'grab'; });
  rd.addEventListener('wheel', e => { e.preventDefault(); dist *= Math.exp(e.deltaY*0.001); dist = Math.max(80, Math.min(6000, dist)); }, {passive: false});
}
function camUpdate(){
  camera.position.set(dist*Math.cos(el)*Math.sin(az), dist*Math.sin(el), dist*Math.cos(el)*Math.cos(az));
  camera.lookAt(0,0,0);
}
function clearModel(){
  model = null; setDirty(false);
  if (!scene) return;
  for (const o of [mesh, wire, seam]) if (o){ scene.remove(o); if (o !== mesh && o.material) o.material.dispose(); if (o.geometry && o !== mesh && o !== wire) o.geometry.dispose(); }
  if (geo) geo.dispose();
  mesh = wire = seam = geo = null;
}
function install(m){
  clearModel();
  model = m; Pcur = 0;
  geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(m.pos), 3));
  colArr = new Float32Array(3*m.n).fill(1);
  geo.setAttribute('color', new THREE.BufferAttribute(colArr, 3));
  geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(m.tri), 1));
  mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; scene.add(mesh);
  wire = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({color: 0x000000, wireframe: true, transparent: true, opacity: 0.12}));
  wire.frustumCulled = false; wire.visible = S.wire; scene.add(wire);
  const sg = new THREE.BufferGeometry();
  sg.setAttribute('position', geo.attributes.position);
  sg.setIndex(new THREE.BufferAttribute(m.seamIdx, 1));
  seam = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({color: 0x111111})); seam.frustumCulled = false; scene.add(seam);
  floor.position.y = -0.36*Math.max(m.W0, m.D0);
  if (!fitted){ dist = Math.max(m.W0, m.D0)*2.3; fitted = true; }
  applyMat(); setDirty(false);
}

/* ---------- Build ---------- */
let bt = 0;
function scheduleBuild(d){ clearTimeout(bt); bt = setTimeout(build, d); }
function build(manual){
  if (!renderer) return;
  const o = S.shapes[0];
  if (!o || !o.closed){ clearModel(); if (manual === true) msg('Zuerst eine geschlossene Außenkontur zeichnen.', true); return; }
  const r = prepare(S.shapes, S.N);
  if (r.error){ msg(r.error, true); return; }
  msg('');
  install(buildModel(r.G, r.h));
}

/* ---------- Werte ---------- */
const fmt = (v, d) => v.toLocaleString('de-DE', {minimumFractionDigits: d, maximumFractionDigits: d});
function updateStats(){
  if (!model){ $('#st').textContent = ''; return; }
  const a = analyse(model);
  const shW = (1 - a.w/model.W0)*100, shD = (1 - a.d/model.D0)*100;
  const cut = Math.abs(model.At-model.Ab)/model.At > 0.005 ? 'oben ' + fmt(model.At/100, 0) + ' / unten ' + fmt(model.Ab/100, 0) + ' cm²' : '2 × ' + fmt(model.At/100, 0) + ' cm²';
  $('#st').textContent =
    'Volumen ' + fmt(a.vol/1e6, 1) + ' l. Höhe ' + fmt(a.h, 0) + ' mm. ' +
    'Einlaufen ' + fmt(shW, 1) + ' % breit, ' + fmt(shD, 1) + ' % tief. ' +
    'Dehnung Ø ' + fmt(a.mean*100, 1) + ' %, max ' + fmt(a.max*100, 1) + ' %. ' +
    'Zuschnitt ' + cut + '. Netz ' + fmt(model.h, 1) + ' mm Kante.';
  if (S.strain){
    const ref = Math.max(0.005, a.max*0.8);
    for (let i = 0; i < model.n; i++){
      const s = model.scnt[i] ? Math.max(0, Math.min(1, (model.sacc[i]/model.scnt[i])/ref)) : 0;
      colArr[3*i] = 1; colArr[3*i+1] = 1 - 0.82*s; colArr[3*i+2] = 1 - 0.42*s;
    }
    geo.attributes.color.needsUpdate = true;
  }
}

/* ---------- Regler ---------- */
function applyMat(){
  if (!mat) return;
  mat.color.set(S.strain ? '#ffffff' : $('#col').value);
  mat.roughness = 0.9 - 0.87*(+$('#gl').value/100);
  mat.metalness = +$('#me').value/100;
  mat.needsUpdate = true;
}
function readSliders(){
  S.pressure = +$('#pr').value; $('#prv').textContent = fmt(S.pressure, 1) + ' kPa';
  S.Et = Math.pow(1000, +$('#et').value/100);
  $('#etv').textContent = fmt(S.Et, S.Et < 10 ? 1 : 0) + ' N/mm';
  $('#glv').textContent = $('#gl').value + ' %'; $('#mev').textContent = $('#me').value + ' %';
  S.N = Math.round(250*Math.pow(16, +$('#rs').value/100)/10)*10; $('#rsv').textContent = '≈ ' + S.N;
  applyMat();
}
for (const id of ['#pr', '#et', '#col', '#gl', '#me', '#rs']) $(id).addEventListener('input', readSliders);
$('#rs').addEventListener('input', () => { if (model) setDirty(true); });
$('#ck1').addEventListener('change', e => {
  S.strain = e.target.checked;
  if (!S.strain && geo){ colArr.fill(1); geo.attributes.color.needsUpdate = true; }
  applyMat(); updateStats();
});
$('#ck2').addEventListener('change', e => { S.wire = e.target.checked; if (wire) wire.visible = S.wire; });
readSliders();

/* ---------- Größe & Loop ---------- */
function resize(){
  dpr = Math.min(window.devicePixelRatio || 1, 2);
  const l = $('#l').getBoundingClientRect();
  cw = l.width; ch = l.height;
  c2.width = Math.max(1, Math.round(cw*dpr)); c2.height = Math.max(1, Math.round(ch*dpr));
  if (renderer){
    const r = $('#r').getBoundingClientRect();
    renderer.setPixelRatio(dpr); renderer.setSize(r.width, r.height, false);
    renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%';
    camera.aspect = r.width/Math.max(1, r.height); camera.updateProjectionMatrix();
  }
}
new ResizeObserver(resize).observe($('#l'));
new ResizeObserver(resize).observe($('#r'));
window.addEventListener('resize', resize);

let frame = 0;
function loop(){
  requestAnimationFrame(loop);
  if (model && renderer){
    Pcur += (S.pressure - Pcur)*0.03; if (Math.abs(S.pressure - Pcur) < 1e-3) Pcur = S.pressure;
    stepSim(model, Pcur*1e-3, S.Et);
    geo.attributes.position.array.set(model.pos); geo.attributes.position.needsUpdate = true;
    geo.computeVertexNormals();
    if (frame % 8 === 0) updateStats();
  }
  if (frame % 15 === 0) updateHint();
  draw2();
  if (renderer){ camUpdate(); renderer.render(scene, camera); }
  frame++;
}
resize(); updateHint();
S.shapes = [mkShape(PRESETS.kreis(), true, true)]; build();
loop();
</script>
</body>
</html>