INFLATE v2.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/fc5d36f3c03650a6/" 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="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>INFLATE v2.0</title>
<style>
:root{
  color-scheme: light;
  --bg:#fff; --fg:#111; --mute:#8c8c8c; --line:#ebebeb; --acc:#ff2d95; --orange:#ff7a00;
  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{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 260px;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%}
#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}
.tag.b{top:auto;bottom:10px;right:14px}
.hint{position:absolute;left:14px;bottom:10px;right:110px;color:var(--mute);pointer-events:none}
.tr{position:absolute;right:12px;top:6px}
.tl{position:absolute;left:14px;top:10px;z-index:2;display:flex;gap:6px}
button.sim{background:#111;color:#fff;border-color:#111;display:flex;align-items:center;font-weight:600;letter-spacing:.02em}
button.sim:hover{background:#333;border-color:#333}
button.sim.run{background:var(--orange);border-color:var(--orange)}
button.sim.run:hover{background:#ff8f26;border-color:#ff8f26}
button.sim.pause{background:#fff;color:var(--orange);border-color:var(--orange)}
button.sim.pause:hover{background:#fff7f0}
.rbtn{display:flex;align-items:center}
.rbtn .dot{display:none;width:7px;height:7px;border-radius:50%;background:var(--orange);margin-left:8px}
.rbtn.dirty .dot{display:inline-block}
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}
.chk{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.note{color:var(--mute);margin:6px 0 0}
.row2{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0}
.rot3{display:grid;grid-template-columns:22px 1fr 1fr;gap:4px;align-items:center;margin:6px 0 8px}
.rot3 span{font-weight:600}
.rot3 button{padding:3px 2px;font-size:11px}
.lab2{font-weight:600;display:flex;align-items:center;gap:8px;margin-bottom:4px}
.sub{color:#444;margin-top:10px}
.sw{display:inline-block;width:12px;height:12px;border-radius:50%;border:1px solid rgba(0,0,0,.12)}
.kv{margin:4px 0;color:#333}
.kv b{font-weight:600}
#dlg{position:fixed;inset:0;z-index:50;background:rgba(255,255,255,0.82);display:flex;align-items:center;justify-content:center;padding:20px}
#dlg[hidden]{display:none}
#dlg .box{background:#fff;border:1px solid #d9d9d9;border-radius:4px;width:min(620px,100%);max-height:100%;overflow:auto;padding:16px 18px;box-shadow:0 8px 30px rgba(0,0,0,.08)}
#dlg .dh{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
#dlg textarea{width:100%;height:150px;font:11px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;border:1px solid #e3e3e3;border-radius:3px;padding:8px;resize:vertical;color:#333}
#dlg input[type=text]{font:inherit;border:1px solid #d9d9d9;border-radius:3px;padding:4px 8px;width:220px}
#dlg .item{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #f0f0f0}
#dlg .item span{flex:1}
#dlg .item small{color:var(--mute)}
#dlg .st{min-height:17px;color:#444;margin:6px 0}
#dlg .st.err{color:var(--acc)}
#dlg h3{font-size:12px;font-weight:600;margin:16px 0 6px}
@media (max-width:860px){
  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 v2.0</span>
    <button id="mDraw" class="on" title="Neue Teile und Innenformen mit dem Bézier-Stift zeichnen">Zeichnen</button>
    <button id="mEdit" title="Knoten, Kurven-Handles und Teile bearbeiten">Bearbeiten</button>
    <button id="mSew" title="Kanten und Innenlinien miteinander vernähen">Nähen</button>
    <span class="sp"></span>
    <button data-p="kissen">Kissen</button>
    <button data-p="ring">Ring</button>
    <button data-p="wuerfel">Würfel</button>
    <span class="sp"></span>
    <button id="bUndo">Zurück</button>
    <button id="bNew">Leeren</button>
    <span class="sp"></span>
    <button id="bSave" title="Teile, Innenformen, Nähte, Startlage und Regler als JSON speichern">Projekt speichern</button>
    <button id="bLoad" title="Ein gespeichertes INFLATE-Projekt (JSON) laden">Projekt laden</button>
    <input type="file" id="fileIn" accept=".json,application/json" hidden>
    <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">Schnittbogen in mm</div>
      <div class="tr"><button id="bFit" title="Alle Teile einpassen">Einpassen</button></div>
      <div class="hint" id="hint"></div>
    </section>
    <section id="r">
      <div id="c3"></div>
      <div class="tl">
        <button id="bStart" class="sim" title="Simulation starten, pausieren und fortsetzen"><span id="startTxt">START</span></button>
        <button id="bRestart" class="rbtn" title="Simulation mit dem aktuellen Schnitt und der Startlage neu beginnen">NEUSTART<span class="dot"></span></button>
        <button id="bStop" title="Simulation beenden und zurück zur Startlage">STOPP</button>
        <span id="gTools" style="display:flex;gap:6px">
          <button id="gMove" class="on" title="Teile verschieben: Teil ziehen = in der Bildebene, Pfeile ziehen = entlang X, Y oder Z (Taste W)">Verschieben</button>
          <button id="gRot" title="Teile drehen: Ringe ziehen = um X, Y oder Z, Teil ziehen = frei (Taste E)">Drehen</button>
        </span>
      </div>
      <div class="tag b" id="tag3"></div>
    </section>
    <aside>
      <h2>Auswahl</h2>
      <div id="selbox"></div>
      <h2>Aussehen</h2>
      <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="45"></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>
      <div class="row2"><button id="bRecolor" title="Allen Teilen neue, gut unterscheidbare Farben geben">Farben neu verteilen</button></div>
      <p class="note">Außenseite in Teilfarbe, Innenseite dunkel.</p>
      <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>
      <div class="f" style="margin-top:14px"><div class="lab"><span>Biegesteifigkeit</span><b id="bdv"></b></div><input type="range" id="bd" min="0" max="100" step="1" value="50"></div>
      <label class="chk"><input type="checkbox" id="ck3" checked> Sanft vernähen</label>
      <p class="note">Biegesteifigkeit hält die Teile glatter und verhindert Knäuel. Sanft vernähen zieht die Nähte erst langsam zusammen, bevor aufgepumpt wird.</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 Einschalten der Simulation.</p>
    </aside>
  </main>
  <footer><div id="st"></div></footer>
</div>

<div id="dlg" hidden>
  <div class="box">
    <div class="dh"><b id="dlgTitle"></b><button id="dlgClose">Schließen</button></div>
    <div id="dlgBody"></div>
  </div>
</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)
// Teil:  {id, name, col, nodes:[...], inner:[{nodes, closed}], pos:[x,y,z], rot:[qx,qy,qz,qw]}
//        Knoten {x,y,hi:{x,y},ho:{x,y},type}, Handles relativ zum Knoten.
//        Kurve c=0 ist die Außenkontur (geschlossen), c>=1 sind Innenformen:
//        geschlossen = Ausschnitt, offen = Innenlinie. Jede Kante jeder Kurve lässt sich vernähen.
//        pos/rot: Startlage im Raum (Mittelpunkt des Teils, Quaternion). Lokal: x rechts, y oben, z = Außenseite.
// Naht:  {a:{p,c,e}, b:{p,c,e}, flip}. flip=false: A von Anfang nach Ende trifft B von Ende nach Anfang. flip=true: gleichläufig.
const DT = 0.02, SUB = 20, DAMP = 0.996;

const lerp2 = (a,b,t) => [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t];
const curveOf = (p, c) => c ? p.inner[c-1] : p;
const isClosed = (p, c) => c ? !!p.inner[c-1].closed : true;
const curveIds = p => [0, ...(p.inner || []).map((_,i) => i+1)];
const nEdges = (p, c) => { const n = curveOf(p,c).nodes.length; return isClosed(p,c) ? n : n-1; };
const ek = (pid, c, e) => pid + ':' + (c || 0) + ':' + e;
function segCtrl(C, k){
  const n = C.nodes.length, a = C.nodes[k], b = C.nodes[(k+1)%n];
  return [[a.x,a.y], [a.x+a.ho.x, a.y+a.ho.y], [b.x+b.hi.x, b.y+b.hi.y], [b.x,b.y]];
}
function bz(c, t){
  const s = 1-t, A = s*s*s, B = 3*s*s*t, C = 3*s*t*t, D = t*t*t;
  return [A*c[0][0]+B*c[1][0]+C*c[2][0]+D*c[3][0], A*c[0][1]+B*c[1][1]+C*c[2][1]+D*c[3][1]];
}
function edgePoly(C, k, sub){ const c = segCtrl(C,k), out = []; for (let j = 0; j <= sub; j++) out.push(bz(c, j/sub)); return out; }
function curvePoly(C, closed, sub){
  const out = [], ne = closed ? C.nodes.length : C.nodes.length-1;
  for (let k = 0; k < ne; k++){ const e = edgePoly(C,k,sub); for (let j = 0; j < sub; j++) out.push(e[j]); }
  if (!closed && C.nodes.length){ const l = C.nodes[C.nodes.length-1]; out.push([l.x, l.y]); }
  return out;
}
const panelPoly = (P, sub) => curvePoly(P, true, sub);
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){
  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];
}
const edgeLen = (C, k) => plLen(edgePoly(C, k, 32));
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}
const d3 = (a,b) => Math.hypot(a[0]-b[0], a[1]-b[1], a[2]-b[2]);

// Quaternionen [x,y,z,w]
const QI = () => [0,0,0,1];
function qMul(a, b){
  return [a[3]*b[0]+a[0]*b[3]+a[1]*b[2]-a[2]*b[1], a[3]*b[1]-a[0]*b[2]+a[1]*b[3]+a[2]*b[0],
          a[3]*b[2]+a[0]*b[1]-a[1]*b[0]+a[2]*b[3], a[3]*b[3]-a[0]*b[0]-a[1]*b[1]-a[2]*b[2]];
}
function qAxis(ax, ay, az, ang){ const l = Math.hypot(ax,ay,az) || 1, s = Math.sin(ang/2)/l; return [ax*s, ay*s, az*s, Math.cos(ang/2)]; }
function qNorm(q){ const l = Math.hypot(q[0],q[1],q[2],q[3]) || 1; return [q[0]/l, q[1]/l, q[2]/l, q[3]/l]; }
function qRot(q, v){
  const tx = 2*(q[1]*v[2]-q[2]*v[1]), ty = 2*(q[2]*v[0]-q[0]*v[2]), tz = 2*(q[0]*v[1]-q[1]*v[0]);
  return [v[0] + q[3]*tx + (q[1]*tz-q[2]*ty), v[1] + q[3]*ty + (q[2]*tx-q[0]*tz), v[2] + q[3]*tz + (q[0]*ty-q[1]*tx)];
}
function bboxPanels(panels){
  let x0=1e9, x1=-1e9, y0=1e9, y1=-1e9;
  for (const p of panels) for (const q of panelPoly(p, 12)){ x0=Math.min(x0,q[0]); x1=Math.max(x1,q[0]); y0=Math.min(y0,q[1]); y1=Math.max(y1,q[1]); }
  return {x0, x1, y0, y1};
}
// Schnittbogen-Koordinate (x,y) eines Teils -> Startposition im Raum
function panelXform(p){
  const b = bboxPanels([p]), cx = (b.x0+b.x1)/2, cy = (b.y0+b.y1)/2, R = p.rot || QI(), P = p.pos || [0,0,0];
  const f = (x, y) => { const v = qRot(R, [x-cx, -(y-cy), 0]); return [v[0]+P[0], v[1]+P[1], v[2]+P[2]]; };
  f.normal = qRot(R, [0,0,1]);
  f.radius = Math.max(b.x1-b.x0, b.y1-b.y0)/2;
  return f;
}
function panelFrame(panels){
  const m = new Map(panels.map(p => [p.id, panelXform(p)]));
  return { pt: (p, x, y) => m.get(p.id)(x, y), normal: p => m.get(p.id).normal };
}
function edgeEnds3(place, p, c, k){
  const C = curveOf(p,c), n = C.nodes.length, a = C.nodes[k], b = C.nodes[(k+1)%n];
  return [place.pt(p,a.x,a.y), place.pt(p,b.x,b.y)];
}
function autoFlip(place, A, B){   // A, B: {p (Teil), c, e}
  const [a0,a1] = edgeEnds3(place, A.p, A.c, A.e), [b0,b1] = edgeEnds3(place, B.p, B.c, B.e);
  return d3(a0,b0) + d3(a1,b1) < d3(a0,b1) + d3(a1,b0);
}
// Offene Kanten anhand ihrer Startlage im Raum paaren
function autoSew(panels, seams){
  const place = panelFrame(panels), used = new Set();
  for (const s of seams){ used.add(ek(s.a.p, s.a.c, s.a.e)); used.add(ek(s.b.p, s.b.c, s.b.e)); }
  const E = [];
  for (const p of panels) for (const c of curveIds(p)) for (let k = 0; k < nEdges(p,c); k++){
    if (used.has(ek(p.id, c, k))) continue;
    const C = curveOf(p,c), [a0,a1] = edgeEnds3(place,p,c,k), m = bz(segCtrl(C,k), 0.5);
    E.push({p, c, k, a0, a1, m3: place.pt(p, m[0], m[1]), len: edgeLen(C,k)});
  }
  const cand = [];
  for (let i = 0; i < E.length; i++) for (let j = i+1; j < E.length; j++){
    const A = E[i], B = E[j]; if (A.p === B.p) continue;
    const cf = d3(A.a0,B.a0) + d3(A.a1,B.a1), cn = d3(A.a0,B.a1) + d3(A.a1,B.a0);
    const cost = Math.min(cf, cn) + d3(A.m3, B.m3), L = (A.len+B.len)/2;
    if (cost < 0.9*L && Math.abs(A.len-B.len) < 0.5*L) cand.push({i, j, cost, flip: cf < cn});
  }
  cand.sort((a,b) => a.cost-b.cost);
  const taken = new Set(), out = [];
  for (const c of cand){
    if (taken.has(c.i) || taken.has(c.j)) continue;
    taken.add(c.i); taken.add(c.j);
    const A = E[c.i], B = E[c.j];
    out.push({a: {p: A.p.id, c: A.c, e: A.k}, b: {p: B.p.id, c: B.c, e: B.k}, flip: c.flip});
  }
  return out;
}
// Gut unterscheidbare Teilfarben: feste Palette, danach Farbkreis mit goldenem Winkel
const PALETTE = ['#ff6f9f','#3fa9ff','#ffbf2e','#2fcf86','#a173ff','#ff8240','#22c9c0','#e55ff0','#8fc93a','#ff5757','#5a82ff','#d8a033'];
function pastel(i){
  if (i >= 1 && i <= PALETTE.length) return PALETTE[i-1];
  const h = (i*137.508 + 200) % 360, s = 0.75, l = 0.64;
  const k = n => (n + h/30) % 12, a = s*Math.min(l, 1-l);
  const f = n => l - a*Math.max(-1, Math.min(k(n)-3, Math.min(9-k(n), 1)));
  return '#' + [f(0), f(8), f(4)].map(v => Math.round(v*255).toString(16).padStart(2,'0')).join('');
}
// Gespiegeltes Gegenstück (mit Innenformen): liegt hinter dem Original, Außenseite zeigt in die Gegenrichtung
function mirrorPanel(p, id, gap){
  const b = bboxPanels([p]), cx = (b.x0+b.x1)/2, w = b.x1-b.x0, h = b.y1-b.y0;
  const mm = n => ({x: 2*cx - n.x + w + 40, y: n.y, hi: {x: -n.hi.x, y: n.hi.y}, ho: {x: -n.ho.x, y: n.ho.y}, type: n.type});
  const R = p.rot || QI(), P = p.pos || [0,0,0], nrm = qRot(R, [0,0,1]), g = gap != null ? gap : 0.15*Math.max(w, h);
  return {id, name: 'Teil ' + id, col: pastel(id), nodes: p.nodes.map(mm), inner: (p.inner || []).map(C => ({nodes: C.nodes.map(mm), closed: C.closed})),
    pos: [P[0]-nrm[0]*g, P[1]-nrm[1]*g, P[2]-nrm[2]*g], rot: qNorm(qMul(R, qAxis(0,1,0,Math.PI)))};
}
// Alle Kanten von A mit den gleichen Kanten von B vernähen (für Gegenstücke)
function pairAll(panels, A, B, seams){
  const place = panelFrame(panels), used = new Set(), out = [];
  for (const s of seams){ used.add(ek(s.a.p, s.a.c, s.a.e)); used.add(ek(s.b.p, s.b.c, s.b.e)); }
  for (const c of curveIds(A)) for (let k = 0; k < nEdges(A,c); k++){
    if (used.has(ek(A.id,c,k))) continue;
    out.push({a: {p: A.id, c, e: k}, b: {p: B.id, c, e: k}, flip: autoFlip(place, {p: A, c, e: k}, {p: B, c, e: k})});
  }
  return out;
}
// Prüft Innenformen: müssen im Teil liegen, Ausschnitte dürfen sich nicht schneiden
function checkPanel(p){
  const O = panelPoly(p, 12);
  if (selfIntersects(O)) return p.name + ' überschneidet sich selbst.';
  const holes = [];
  for (const c of curveIds(p)){
    if (!c) continue;
    const C = curveOf(p,c), cl = isClosed(p,c), pl = curvePoly(C, cl, 12);
    if (pl.some(q => !inPoly(q[0], q[1], O))) return 'Eine Innenform von ' + p.name + ' liegt nicht ganz im Teil.';
    if (cl){
      if (selfIntersects(pl)) return 'Ein Ausschnitt von ' + p.name + ' überschneidet sich selbst.';
      for (const h of holes) if (loopsIntersect(h, pl)) return 'Ausschnitte von ' + p.name + ' überlappen sich.';
      holes.push(pl);
    }
  }
  return null;
}

// Netz und Simulationsmodell. merge=false: Startlage (Teile getrennt, Nahtpaare als Linien).
function buildModel(panels, seams, N, merge){
  const place = panelFrame(panels);
  const idx = new Map(panels.map((p,i) => [p.id, i]));
  let Atot = 0;
  for (const p of panels){ Atot += Math.abs(polyArea(panelPoly(p, 16))); for (const c of curveIds(p)) if (c && isClosed(p,c)) Atot -= Math.abs(polyArea(curvePoly(curveOf(p,c), true, 16))); }
  const h = Math.min(40, Math.max(2.5, Math.sqrt(Math.max(Atot, 100)/((N || 1100)*0.866))));
  const partner = new Map();
  for (const s of seams){ partner.set(ek(s.a.p, s.a.c, s.a.e), s.b); partner.set(ek(s.b.p, s.b.c, s.b.e), s.a); }
  const lenOf = (pid, c, e) => { const p = panels[idx.get(pid)]; return edgeLen(curveOf(p, c), e); };
  const nOf = (pid, c, e) => {   // beide Seiten einer Naht gleich oft abtasten
    let len = lenOf(pid, c, e); const o = partner.get(ek(pid, c, e));
    if (o && idx.has(o.p)) len = (len + lenOf(o.p, o.c||0, o.e))/2;
    return Math.max(1, Math.round(len/h));
  };
  const P3 = [], tri = [], ea = [], eb = [], rs = [], lines = [], pidx = [], local = [], ranges = [], triL = [], triP = [], bA = [], bB = [], bR = [];
  const edgeIdx = new Map();
  let open = 0;
  for (let pi = 0; pi < panels.length; pi++){
    const p = panels[pi], base = P3.length/3, pts = [], cinfo = [], segs = [];
    // Kurven abtasten (nach Bogenlänge: die Längendifferenz einer Naht verteilt sich gleichmäßig)
    for (const c of curveIds(p)){
      const C = curveOf(p,c), cl = isClosed(p,c), s0 = pts.length, eStart = [];
      for (let k = 0; k < nEdges(p,c); k++){
        if (!partner.has(ek(p.id,c,k)) && cl) open++;
        eStart.push(pts.length - s0);
        const pl = edgePoly(C,k,32), len = plLen(pl), n = nOf(p.id, c, k);
        for (let j = 0; j < n; j++) pts.push(plAt(pl, len, j/n));
      }
      if (!cl){ const l = C.nodes[C.nodes.length-1]; pts.push([l.x, l.y]); }
      const M = pts.length - s0;
      cinfo.push({c, s0, M, eStart, closed: cl});
      for (let i = 0; i < M-1; i++) segs.push([s0+i, s0+i+1]);
      if (cl) segs.push([s0+M-1, s0]);
    }
    const Ms = pts.length, O = pts.slice(0, cinfo[0].M);
    const holes = cinfo.filter(ci => ci.c && ci.closed).map(ci => pts.slice(ci.s0, ci.s0+ci.M));
    const inHole = (x,y) => { for (const hp of holes) if (inPoly(x,y,hp)) return true; return false; };
    let x0=1e9, x1=-1e9, y0=1e9, y1=-1e9;
    for (const q of O){ x0=Math.min(x0,q[0]); x1=Math.max(x1,q[0]); y0=Math.min(y0,q[1]); y1=Math.max(y1,q[1]); }
    const sp = segs.map(([a,b]) => [pts[a], pts[b]]), rowH = h*0.8660254;
    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,O) || inHole(x,y)) continue;
        let ok = true;
        for (const [a,b] of sp) if (segDist(x,y,a,b) < 0.62*h){ ok = false; break; }
        if (ok) pts.push([x,y]);
      }
    const K = pts.length, 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 trs = K >= 3 ? 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 inside = (x,y) => inPoly(x,y,O) && !inHole(x,y);
    const okE = (a,b) => adj.has(a < b ? a*K+b : b*K+a) || inside((pts[a][0]+pts[b][0])/2, (pts[a][1]+pts[b][1])/2);
    for (const q of pts){ const X = place.pt(p, q[0], q[1]); P3.push(X[0], X[1], X[2]); pidx.push(pi); local.push(q[0], q[1]); }
    const nrm = place.normal(p), es = new Set(), lt = [];
    for (let i = 0; i < trs.length; i += 3){
      let a = trs[i], b = trs[i+1], c = trs[i+2];
      if (!inside((pts[a][0]+pts[b][0]+pts[c][0])/3, (pts[a][1]+pts[b][1]+pts[c][1])/3)) continue;
      if (!okE(a,b) || !okE(b,c) || !okE(c,a)) continue;
      const A3 = 3*(base+a), B3 = 3*(base+b), C3 = 3*(base+c);
      const ux = P3[B3]-P3[A3], uy = P3[B3+1]-P3[A3+1], uz = P3[B3+2]-P3[A3+2];
      const vx = P3[C3]-P3[A3], vy = P3[C3+1]-P3[A3+1], vz = P3[C3+2]-P3[A3+2];
      const dot = (uy*vz-uz*vy)*nrm[0] + (uz*vx-ux*vz)*nrm[1] + (ux*vy-uy*vx)*nrm[2];
      if (Math.abs(dot) < 1e-9) continue;
      if (dot < 0){ const t = b; b = c; c = t; }               // Dreiecke zeigen zur Außenseite
      tri.push(base+a, base+b, base+c);
      triL.push(pts[a][0], pts[a][1], pts[b][0], pts[b][1], pts[c][0], pts[c][1]); triP.push(pi); lt.push(a,b,c);
      for (const [u,v] of [[a,b],[b,c],[c,a]]){
        const key = u < v ? u*K+v : v*K+u;
        if (es.has(key)) continue; es.add(key);
        ea.push(base+u); eb.push(base+v); rs.push(Math.hypot(pts[u][0]-pts[v][0], pts[u][1]-pts[v][1]));
      }
    }
    // Biegung: Abstand der gegenüberliegenden Ecken zweier Nachbardreiecke (flacher Ruhezustand)
    const opp = new Map();
    for (let i = 0; i < lt.length; i += 3){
      const a = lt[i], b = lt[i+1], c = lt[i+2];
      for (const [u,v,w] of [[a,b,c],[b,c,a],[c,a,b]]){
        const key = u < v ? u*K+v : v*K+u, o = opp.get(key);
        if (o === undefined) opp.set(key, w);
        else if (o >= 0){ bA.push(base+o); bB.push(base+w); bR.push(Math.hypot(pts[o][0]-pts[w][0], pts[o][1]-pts[w][1])); opp.set(key, -1); }
      }
    }
    for (const [a,b] of segs) lines.push(base+a, base+b);
    for (const ci of cinfo) for (let k = 0; k < ci.eStart.length; k++){
      const n = (k+1 < ci.eStart.length ? ci.eStart[k+1] : (ci.closed ? ci.M : ci.M-1)) - ci.eStart[k], L = [];
      for (let j = 0; j <= n; j++) L.push(base + ci.s0 + (ci.closed ? (ci.eStart[k]+j) % ci.M : ci.eStart[k]+j));
      edgeIdx.set(ek(p.id, ci.c, k), L);
    }
    ranges.push({id: p.id, start: base, count: K});
  }
  const pairs = [], seamPairs = [];
  for (const s of seams){
    const A = edgeIdx.get(ek(s.a.p, s.a.c, s.a.e)), B = edgeIdx.get(ek(s.b.p, s.b.c, s.b.e)), sp = [];
    if (A && B && A.length === B.length){ const n = A.length-1; for (let j = 0; j <= n; j++){ pairs.push(A[j], B[s.flip ? j : n-j]); sp.push(A[j], B[s.flip ? j : n-j]); } }
    seamPairs.push(sp);
  }
  let n = P3.length/3, pos = Float64Array.from(P3), pid = Int32Array.from(pidx);
  let triA = tri, eaA = ea, ebA = eb, rsA = rs, linesA = lines, triLA = triL, triPA = triP, bAA = bA, bBA = bB, bRA = bR, mapA = null;
  if (merge && pairs.length){
    const par = new Int32Array(n); for (let i = 0; i < n; i++) par[i] = i;
    const find = i => { while (par[i] !== i){ par[i] = par[par[i]]; i = par[i]; } return i; };
    for (let i = 0; i < pairs.length; i += 2){ const a = find(pairs[i]), b = find(pairs[i+1]); if (a !== b) par[a] = b; }
    const map = new Int32Array(n).fill(-1); let m = 0;
    for (let i = 0; i < n; i++){ const r = find(i); if (map[r] < 0) map[r] = m++; map[i] = map[r]; }
    const np = new Float64Array(3*m), cnt = new Float64Array(m), npid = new Int32Array(m).fill(-1);
    for (let i = 0; i < n; i++){ const j = map[i]; np[3*j]+=pos[3*i]; np[3*j+1]+=pos[3*i+1]; np[3*j+2]+=pos[3*i+2]; cnt[j]++; if (npid[j] < 0) npid[j] = pid[i]; }
    for (let j = 0; j < m; j++){ np[3*j]/=cnt[j]; np[3*j+1]/=cnt[j]; np[3*j+2]/=cnt[j]; }
    triA = []; triLA = []; triPA = [];
    for (let i = 0, t = 0; i < tri.length; i += 3, t++){ const a = map[tri[i]], b = map[tri[i+1]], c = map[tri[i+2]]; if (a !== b && b !== c && a !== c){ triA.push(a,b,c); for (let q = 0; q < 6; q++) triLA.push(triL[6*t+q]); triPA.push(triP[t]); } }
    eaA = []; ebA = []; rsA = [];
    for (let e = 0; e < ea.length; e++){ const a = map[ea[e]], b = map[eb[e]]; if (a !== b){ eaA.push(a); ebA.push(b); rsA.push(rs[e]); } }
    const ls = new Set(); linesA = [];
    for (let i = 0; i < lines.length; i += 2){ let a = map[lines[i]], b = map[lines[i+1]]; if (a === b) continue; if (a > b){ const t = a; a = b; b = t; } const key = a*m+b; if (!ls.has(key)){ ls.add(key); linesA.push(a,b); } }
    bAA = []; bBA = []; bRA = [];
    for (let e = 0; e < bA.length; e++){ const a = map[bA[e]], b = map[bB[e]]; if (a !== b){ bAA.push(a); bBA.push(b); bRA.push(bR[e]); } }
    for (const [k, L] of edgeIdx) edgeIdx.set(k, L.map(i => map[i]));
    n = m; pos = np; pid = npid; mapA = map;
  }
  let bx0=1e9, bx1=-1e9, by0=1e9, by1=-1e9, bz0=1e9, bz1=-1e9;
  for (let i = 0; i < n; i++){ bx0=Math.min(bx0,pos[3*i]); bx1=Math.max(bx1,pos[3*i]); by0=Math.min(by0,pos[3*i+1]); by1=Math.max(by1,pos[3*i+1]); bz0=Math.min(bz0,pos[3*i+2]); bz1=Math.max(bz1,pos[3*i+2]); }
  const scnt = new Float32Array(n);
  for (let e = 0; e < eaA.length; e++){ scnt[eaA[e]]++; scnt[ebA[e]]++; }
  return {
    h, n, Atot, open, nPanels: panels.length, nSeams: seams.length, preview: !merge,
    size: Math.max(bx1-bx0, by1-by0, bz1-bz0, 1), center: [(bx0+bx1)/2, (by0+by1)/2, (bz0+bz1)/2], minY: by0,
    ranges: merge ? [] : ranges, local: merge ? null : Float64Array.from(local), edgeIdx, seamPairs: merge ? [] : seamPairs,
    pos, pid, vel: new Float64Array(3*n), prev: new Float64Array(3*n), frc: new Float64Array(3*n),
    ea: Int32Array.from(eaA), eb: Int32Array.from(ebA), rest: Float64Array.from(rsA),
    ba: Int32Array.from(bAA), bb: Int32Array.from(bBA), brest: Float64Array.from(bRA), map: mapA, pin: null,
    tri: Int32Array.from(triA), triLocal: Float32Array.from(triLA), triPid: Int32Array.from(triPA), lines: Uint32Array.from(linesA), pairs: merge ? new Uint32Array(0) : Uint32Array.from(pairs),
    sacc: new Float32Array(n), scnt
  };
}

function stepSim(m, P, Et, bend){
  const {n, pos, vel, prev, frc, ea, eb, rest, tri, ba, bb, brest} = m, E = rest.length, T = tri.length/3, EB = bend > 0 && brest ? brest.length : 0;
  const at = (1/(0.866*Et))/(DT*DT), atb = EB ? (1/(0.866*Et*bend))/(DT*DT) : 0, inv = DAMP/DT, kP = P/6, n3 = 3*n, pin = m.pin;
  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 e = 0; e < EB; e++){
      const a = ba[e]*3, b = bb[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-brest[e])/(2+atb)/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;
    }
    if (pin){ const i = 3*pin.i; pos[i] = prev[i] = pin.x; pos[i+1] = prev[i+1] = pin.y; pos[i+2] = prev[i+2] = pin.z; }
    for (let i = 0; i < n3; i++) vel[i] = (pos[i]-prev[i])*inv;
  }
}
// Hält die Form gedämpft in der Bildmitte: verschiebt alles starr zum Anker, bremst die Schwerpunktbewegung
function recenter(m, k, f){
  const {n, pos, vel, anchor} = m; if (!anchor || !n) return;
  let cx = 0, cy = 0, cz = 0, vx = 0, vy = 0, vz = 0;
  for (let i = 0; i < n; i++){ cx += pos[3*i]; cy += pos[3*i+1]; cz += pos[3*i+2]; vx += vel[3*i]; vy += vel[3*i+1]; vz += vel[3*i+2]; }
  const dx = (cx/n-anchor[0])*k, dy = (cy/n-anchor[1])*k, dz = (cz/n-anchor[2])*k;
  vx = vx/n*f; vy = vy/n*f; vz = vz/n*f;
  for (let i = 0; i < n; i++){ pos[3*i]-=dx; pos[3*i+1]-=dy; pos[3*i+2]-=dz; vel[3*i]-=vx; vel[3*i+1]-=vy; vel[3*i+2]-=vz; }
}

function analyse(m){
  const {pos, tri, ea, eb, rest, sacc} = m, E = rest.length;
  let V = 0, x0=1e9, x1=-1e9, y0=1e9, y1=-1e9, z0=1e9, z1=-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]){ x0=Math.min(x0,pos[q]); x1=Math.max(x1,pos[q]); y0=Math.min(y0,pos[q+1]); y1=Math.max(y1,pos[q+1]); z0=Math.min(z0,pos[q+2]); z1=Math.max(z1,pos[q+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 s = (Math.hypot(pos[a]-pos[b], pos[a+1]-pos[b+1], pos[a+2]-pos[b+2]) - 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, w: x1-x0, h: y1-y0, d: z1-z0, mean: sum/Math.max(1,E), max: mx};
}

// Vorlagen
function circleNodes(cx, cy, R){
  const k = 0.5523*R;
  return [0,1,2,3].map(i => { const a = i*Math.PI/2, c = Math.cos(a), s = Math.sin(a);
    return {x: cx+R*c, y: cy+R*s, hi: {x: k*s, y: -k*c}, ho: {x: -k*s, y: k*c}, type: 'smooth'}; });
}
function rectNodes(cx, cy, w, h){
  return [[-1,-1],[1,-1],[1,1],[-1,1]].map(([a,b]) => ({x: cx+a*w/2, y: cy+b*h/2, hi: {x:0,y:0}, ho: {x:0,y:0}, type: 'corner'}));
}
function makePreset(name){
  let panels = [], seams = [], id = 1;
  if (name === 'kissen' || name === 'ring'){
    const A = {id: id, name: 'Teil 1', col: pastel(id), nodes: circleNodes(-170, 0, 150), inner: [], pos: [0,0,22], rot: QI()}; id++;
    if (name === 'ring') A.inner.push({nodes: circleNodes(-170, 0, 55), closed: true});
    const B = mirrorPanel(A, id++, 44);
    panels = [A, B];
    seams = pairAll(panels, A, B, []);
  } else if (name === 'wuerfel'){
    const s = 140, H = s/2, P = Math.PI/2;
    const L = [   // Würfelnetz auf dem Schnittbogen und passende Startlage im Raum
      [0, 0,     [0,0,H],  QI()],
      [0, -s,    [0,H,0],  qAxis(1,0,0,-P)],
      [0, s,     [0,-H,0], qAxis(1,0,0,P)],
      [-s, 0,    [-H,0,0], qAxis(0,1,0,-P)],
      [s, 0,     [H,0,0],  qAxis(0,1,0,P)],
      [2*s, 0,   [0,0,-H], qAxis(0,1,0,2*P)]
    ];
    panels = L.map(([x,y,pos,rot]) => { const i = id++; return {id: i, name: 'Teil ' + i, col: pastel(i), nodes: rectNodes(x, y, s, s), inner: [], pos, rot}; });
    seams = autoSew(panels, []);
  }
  return {panels, seams, nextId: id};
}
/* ============================ /CORE ============================ */

/* ============================ UI ============================ */
const $ = s => document.querySelector(s);
const S = {panels: [], seams: [], nextId: 1, mode: 'draw', sel: null, sim: false, run: 'stop', tool: 'move', bend: 0.02, soft: true, pressure: 4, Et: 6, strain: false, wire: false, N: 1100};
const COLS = ['#2d7dff','#18a058','#f08c00','#8a4dff','#00a3a3','#c2410c','#4d7c0f','#1d4ed8','#b45309','#0f766e','#7c3aed','#be123c'];
let draft = null, pending = null, hoverEdge = null, mark = null;   // mark: Klickpunkt aus 3D im Schnittbogen
let model = null, Pcur = 0, fitted = false, dirty = false;
const history = [];
const V = {s: 1, ox: 0, oy: 0};
const byId = id => S.panels.find(p => p.id === id);
const seamColor = i => COLS[i % COLS.length];
const sideIs = (sd, pid, c, e) => sd.p === pid && (sd.c || 0) === (c || 0) && sd.e === e;
const seamIndexOf = (pid, c, e) => S.seams.findIndex(s => sideIs(s.a, pid, c, e) || sideIs(s.b, pid, c, e));
const curveName = (p, c) => !c ? 'Kontur' : (isClosed(p,c) ? 'Ausschnitt ' + c : 'Innenlinie ' + c);

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({panels: S.panels, seams: S.seams, nextId: S.nextId})); if (history.length > 60) history.shift(); }
function undo(){
  if (draft){ draft.nodes.pop(); if (!draft.nodes.length) draft = null; updateHint(); return; }
  if (!history.length) return;
  const o = JSON.parse(history.pop());
  S.panels = o.panels; S.seams = o.seams; S.nextId = o.nextId; S.sel = null; pending = null;
  msg(''); changed();
}
function setDirty(v){ dirty = v; $('#bRestart').classList.toggle('dirty', v); }
// Nach jeder Änderung: Simulation aus -> Startlage neu aufbauen; Simulation an -> Hinweis am Button
function changed(){
  renderSel(); updateHint();
  if (S.sim){ if (model) setDirty(true); }
  else scheduleBuild(120);
}

/* ---------- Bearbeitungsfunktionen ---------- */
function remapSeams(pid, c, fn){   // fn(e) liefert neuen Kantenindex der Kurve c oder -1 (Naht entfernen)
  let lost = 0;
  const hit = sd => sd.p === pid && (sd.c || 0) === c;
  S.seams = S.seams.filter(s => { for (const sd of [s.a, s.b]) if (hit(sd) && fn(sd.e) < 0){ lost++; return false; } return true; });
  S.seams.forEach(s => { for (const sd of [s.a, s.b]) if (hit(sd)) sd.e = fn(sd.e); });
  return lost;
}
function insertNode(p, c, k, t){
  const C = curveOf(p,c), n = C.nodes.length, A = C.nodes[k], B = C.nodes[(k+1)%n], cc = segCtrl(C,k);
  const P01 = lerp2(cc[0],cc[1],t), P12 = lerp2(cc[1],cc[2],t), P23 = lerp2(cc[2],cc[3],t);
  const P012 = lerp2(P01,P12,t), P123 = lerp2(P12,P23,t), M = lerp2(P012,P123,t);
  A.ho = {x: P01[0]-A.x, y: P01[1]-A.y}; B.hi = {x: P23[0]-B.x, y: P23[1]-B.y};
  C.nodes.splice(k+1, 0, {x: M[0], y: M[1], hi: {x: P012[0]-M[0], y: P012[1]-M[1]}, ho: {x: P123[0]-M[0], y: P123[1]-M[1]}, type: 'smooth'});
  if (remapSeams(p.id, c, e => e === k ? -1 : (e > k ? e+1 : e))) msg('Die Naht an der geteilten Kante wurde gelöst.');
  return k+1;
}
function deletePanel(id){
  S.panels = S.panels.filter(p => p.id !== id);
  S.seams = S.seams.filter(s => s.a.p !== id && s.b.p !== id);
  S.sel = null;
}
function removeCurve(p, c){
  S.seams = S.seams.filter(s => !((s.a.p === p.id && (s.a.c||0) === c) || (s.b.p === p.id && (s.b.c||0) === c)));
  S.seams.forEach(s => { for (const sd of [s.a, s.b]) if (sd.p === p.id && (sd.c||0) > c) sd.c--; });
  p.inner.splice(c-1, 1);
}
function deleteNode(p, c, k){
  const C = curveOf(p,c), n = C.nodes.length, cl = isClosed(p,c);
  if (!c && n <= 3){ deletePanel(p.id); return; }
  if (c && ((cl && n <= 3) || (!cl && n <= 2))){ removeCurve(p, c); S.sel = {type: 'panel', id: p.id}; return; }
  const gone = cl ? [(k-1+n)%n, k] : [k-1, k <= n-2 ? k : -1];
  const lost = remapSeams(p.id, c, e => gone.includes(e) ? -1 : (e > k ? e-1 : e));
  C.nodes.splice(k, 1);
  if (lost) msg(lost === 1 ? 'Eine Naht wurde gelöst.' : lost + ' Nähte wurden gelöst.');
  S.sel = {type: 'panel', id: p.id};
}
function setNodeType(nd, C, k, closed, type){
  nd.type = type;
  if (type === 'smooth'){
    const lh = Math.hypot(nd.hi.x, nd.hi.y), lo = Math.hypot(nd.ho.x, nd.ho.y);
    if (lh < 1e-6 && lo < 1e-6){
      const n = C.nodes.length, a = C.nodes[closed ? (k-1+n)%n : Math.max(0,k-1)], b = C.nodes[closed ? (k+1)%n : Math.min(n-1,k+1)];
      const dx = (b.x-a.x)/6, dy = (b.y-a.y)/6;
      nd.ho = {x: dx, y: dy}; nd.hi = {x: -dx, y: -dy};
    } else {
      let dx = nd.ho.x - nd.hi.x, dy = nd.ho.y - nd.hi.y; const l = Math.hypot(dx,dy) || 1; dx /= l; dy /= l;
      nd.ho = {x: dx*lo, y: dy*lo}; nd.hi = {x: -dx*lh, y: -dy*lh};
    }
  }
}
function refreshFlips(){
  const place = panelFrame(S.panels);
  S.seams.forEach(s => { const A = byId(s.a.p), B = byId(s.b.p); if (A && B) s.flip = autoFlip(place, {p: A, c: s.a.c||0, e: s.a.e}, {p: B, c: s.b.c||0, e: s.b.e}); });
}
function defaultPose(p){ const b = bboxPanels([p]); p.pos = [(b.x0+b.x1)/2, -(b.y0+b.y1)/2, 0]; p.rot = QI(); }
function addMirror(id){
  const A = byId(id); if (!A) return;
  snap();
  const B = mirrorPanel(A, S.nextId++);
  S.panels.push(B);
  const add = pairAll(S.panels, A, B, S.seams);
  S.seams.push(...add);
  S.sel = {type: 'panel', id: B.id};
  msg('Gegenstück ' + B.name + ' liegt hinter ' + A.name + ', ' + add.length + ' Nähte gesetzt.');
  changed();
}
function countOpen(){ let n = 0; for (const p of S.panels) for (const c of curveIds(p)) if (isClosed(p,c)) for (let k = 0; k < nEdges(p,c); k++) if (seamIndexOf(p.id, c, k) < 0) n++; return n; }

/* ---------- 2D: Ansicht ---------- */
const c2 = $('#c2'), ctx = c2.getContext('2d');
let cw = 1, ch = 1, dpr = 1, cursor = null;
const w2s = (x,y) => [x*V.s + V.ox, y*V.s + V.oy];
const s2w = (x,y) => ({x: (x-V.ox)/V.s, y: (y-V.oy)/V.s});
function evS(e){ const r = c2.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; }
function evW(e){ const [x,y] = evS(e); return s2w(x,y); }
function fitView(){
  if (!S.panels.length){ V.s = Math.min(cw/640, ch/480); V.ox = cw/2; V.oy = ch/2; return; }
  const b = bboxPanels(S.panels), w = Math.max(50, b.x1-b.x0), h = Math.max(50, b.y1-b.y0);
  V.s = Math.max(0.15, Math.min(6, Math.min((cw-60)/w, (ch-110)/h)));
  V.ox = cw/2 - (b.x0+b.x1)/2*V.s; V.oy = ch/2 - (b.y0+b.y1)/2*V.s + 6;
}
c2.addEventListener('wheel', e => {
  e.preventDefault();
  const [sx,sy] = evS(e), w = s2w(sx,sy), f = Math.exp(-e.deltaY*0.0015);
  V.s = Math.max(0.1, Math.min(10, V.s*f)); V.ox = sx - w.x*V.s; V.oy = sy - w.y*V.s;
}, {passive: false});

/* ---------- 2D: Treffer ---------- */
function hitNode(e){
  const [sx,sy] = evS(e);
  for (let pi = S.panels.length-1; pi >= 0; pi--){
    const p = S.panels[pi];
    for (const c of curveIds(p)){ const C = curveOf(p,c);
      for (let k = 0; k < C.nodes.length; k++){ const q = w2s(C.nodes[k].x, C.nodes[k].y); if (Math.hypot(q[0]-sx, q[1]-sy) < 9) return {p, c, k}; } }
  }
  return null;
}
function hitHandle(e){
  const sel = S.sel; if (!sel || sel.type !== 'node') return null;
  const p = byId(sel.id); if (!p) return null;
  const nd = curveOf(p, sel.c).nodes[sel.k], [sx,sy] = evS(e); if (!nd) return null;
  for (const w of ['ho','hi']){
    if (Math.hypot(nd[w].x, nd[w].y) < 1e-6) continue;
    const q = w2s(nd.x+nd[w].x, nd.y+nd[w].y);
    if (Math.hypot(q[0]-sx, q[1]-sy) < 9) return {p, c: sel.c, k: sel.k, w};
  }
  return null;
}
function nearEdge(e, maxPx){
  const [sx,sy] = evS(e); let best = maxPx || 8, res = null;
  for (const p of S.panels) for (const c of curveIds(p)) for (let k = 0; k < nEdges(p,c); k++){
    const pl = edgePoly(curveOf(p,c),k,16).map(q => w2s(q[0],q[1]));
    for (let j = 0; j < 16; 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 = {p, c, k, t: (j+u)/16}; }
    }
  }
  return res;
}
function hitPanel(w){
  for (let i = S.panels.length-1; i >= 0; i--) if (inPoly(w.x, w.y, panelPoly(S.panels[i], 8))) return S.panels[i];
  return null;
}

/* ---------- 2D: Eingabe ---------- */
let act = null;   // laufende Zieh-Aktion
c2.addEventListener('contextmenu', e => e.preventDefault());
c2.addEventListener('pointerdown', e => {
  c2.setPointerCapture(e.pointerId);
  const [sx,sy] = evS(e), w = evW(e);
  if (e.button === 1 || e.button === 2){ act = {type: 'pan', sx, sy}; return; }
  if (S.mode === 'draw'){
    if (draft && draft.nodes.length >= 3){
      const f = draft.nodes[0], q = w2s(f.x, f.y);
      if (Math.hypot(q[0]-sx, q[1]-sy) < 10){ finishDraft(true); return; }
    }
    if (draft && draft.nodes.length){   // zweiter Klick auf den letzten Knoten (Doppelklick) setzt keinen neuen Knoten
      const l = draft.nodes[draft.nodes.length-1], q = w2s(l.x, l.y);
      if (Math.hypot(q[0]-sx, q[1]-sy) < 5) return;
    }
    if (!draft){ const host = hitPanel(w); draft = {nodes: [], host: host ? host.id : null}; }
    const nd = {x: w.x, y: w.y, hi: {x:0,y:0}, ho: {x:0,y:0}, type: 'corner'};
    draft.nodes.push(nd);
    act = {type: 'draft', nd, sx, sy};
    updateHint();
    return;
  }
  if (S.mode === 'edit'){
    const hh = hitHandle(e);
    if (hh){ act = {type: 'handle', ...hh, alt: e.altKey, snapped: false}; return; }
    const hn = hitNode(e);
    if (hn){ S.sel = {type: 'node', id: hn.p.id, c: hn.c, k: hn.k}; act = {type: 'node', ...hn, snapped: false}; selChanged(); return; }
    if (e.shiftKey){
      const ne = nearEdge(e);
      if (ne){ snap(); const k = insertNode(ne.p, ne.c, ne.k, ne.t); S.sel = {type: 'node', id: ne.p.id, c: ne.c, k}; changed(); return; }
    }
    const hp = hitPanel(w);
    if (hp){ S.sel = {type: 'panel', id: hp.id}; act = {type: 'panel', p: hp, last: w, snapped: false}; selChanged(); return; }
    S.sel = null; selChanged();
    act = {type: 'pan', sx, sy};
    return;
  }
  if (S.mode === 'sew'){
    const ne = nearEdge(e);
    if (ne){
      const si = seamIndexOf(ne.p.id, ne.c, ne.k);
      if (!pending){
        if (si >= 0){ S.sel = {type: 'seam', i: si}; selChanged(); return; }
        pending = {p: ne.p.id, c: ne.c, e: ne.k}; S.sel = null; selChanged(); updateHint(); return;
      }
      if (pending.p === ne.p.id && pending.c === ne.c && pending.e === ne.k){ pending = null; updateHint(); return; }
      if (si >= 0){ msg('Diese Kante ist schon vernäht. Erst die Naht lösen.', true); return; }
      snap();
      const A = byId(pending.p), place = panelFrame(S.panels);
      S.seams.push({a: {p: A.id, c: pending.c, e: pending.e}, b: {p: ne.p.id, c: ne.c, e: ne.k},
        flip: autoFlip(place, {p: A, c: pending.c, e: pending.e}, {p: ne.p, c: ne.c, e: ne.k})});
      S.sel = {type: 'seam', i: S.seams.length-1}; pending = null; msg('');
      changed(); return;
    }
    act = {type: 'pan', sx, sy};
  }
});
c2.addEventListener('pointermove', e => {
  cursor = evW(e);
  const [sx,sy] = evS(e);
  if (S.mode === 'sew' && !act) hoverEdge = nearEdge(e);
  if (!act) return;
  if (act.type === 'pan'){ V.ox += sx-act.sx; V.oy += sy-act.sy; act.sx = sx; act.sy = sy; return; }
  if (act.type === 'draft'){
    if (Math.hypot(sx-act.sx, sy-act.sy) > 4){
      const w = evW(e), nd = act.nd;
      nd.type = 'smooth'; nd.ho = {x: w.x-nd.x, y: w.y-nd.y}; nd.hi = {x: -nd.ho.x, y: -nd.ho.y};
    }
    return;
  }
  if (!act.snapped){ snap(); act.snapped = true; }
  const w = evW(e);
  if (act.type === 'node'){ const nd = curveOf(act.p, act.c).nodes[act.k]; nd.x = w.x; nd.y = w.y; }
  else if (act.type === 'handle'){
    const nd = curveOf(act.p, act.c).nodes[act.k], o = act.w === 'ho' ? 'hi' : 'ho';
    nd[act.w] = {x: w.x-nd.x, y: w.y-nd.y};
    if (act.alt) nd.type = 'corner';
    if (nd.type === 'smooth'){
      const l = Math.hypot(nd[act.w].x, nd[act.w].y) || 1, lo = Math.hypot(nd[o].x, nd[o].y) || l;
      nd[o] = {x: -nd[act.w].x/l*lo, y: -nd[act.w].y/l*lo};
    }
  }
  else if (act.type === 'panel'){   // Verschieben auf dem Schnittbogen ändert die Startlage im Raum nicht
    const dx = w.x-act.last.x, dy = w.y-act.last.y;
    for (const c of curveIds(act.p)) for (const nd of curveOf(act.p,c).nodes){ nd.x += dx; nd.y += dy; }
    act.last = w;
  }
});
c2.addEventListener('pointerup', () => { if (act && act.snapped) changed(); act = null; });
c2.addEventListener('pointerleave', () => { cursor = null; hoverEdge = null; });
c2.addEventListener('dblclick', e => {
  if (S.mode === 'draw' && draft){ finishDraft(!draft.host); return; }
  if (S.mode !== 'edit') return;
  const hn = hitNode(e); if (!hn) return;
  snap(); const C = curveOf(hn.p, hn.c), nd = C.nodes[hn.k];
  setNodeType(nd, C, hn.k, isClosed(hn.p, hn.c), nd.type === 'smooth' ? 'corner' : 'smooth');
  S.sel = {type: 'node', id: hn.p.id, c: hn.c, k: hn.k}; changed();
});
// Entwurf abschließen: ohne Gastteil = neues Teil (immer geschlossen); im Teil = Ausschnitt (geschlossen) oder Innenlinie (offen)
function finishDraft(closed){
  if (!draft) return;
  const host = draft.host != null ? byId(draft.host) : null, nodes = draft.nodes;
  if (!host){
    if (nodes.length < 3){ msg('Ein Teil braucht mindestens drei Knoten.', true); return; }
    snap();
    const id = S.nextId++, p = {id, name: 'Teil ' + id, col: pastel(id), nodes, inner: []};
    defaultPose(p); S.panels.push(p); draft = null; S.sel = {type: 'panel', id};
  } else {
    if (closed && nodes.length < 3){ msg('Ein Ausschnitt braucht mindestens drei Knoten.', true); return; }
    if (!closed && nodes.length < 2){ msg('Eine Innenlinie braucht mindestens zwei Knoten.', true); return; }
    snap();
    host.inner = host.inner || [];
    host.inner.push({nodes, closed});
    draft = null; S.sel = {type: 'panel', id: host.id};
    msg((closed ? 'Ausschnitt' : 'Innenlinie') + ' in ' + host.name + ' angelegt.');
  }
  changed();
}
function deleteSelection(){
  const sel = S.sel;
  if (S.mode === 'draw' && draft){ draft.nodes.pop(); if (!draft.nodes.length) draft = null; updateHint(); return; }
  if (!sel) return;
  snap();
  if (sel.type === 'node'){ const p = byId(sel.id); if (p) deleteNode(p, sel.c, sel.k); }
  else if (sel.type === 'panel') deletePanel(sel.id);
  else if (sel.type === 'seam'){ S.seams.splice(sel.i, 1); S.sel = null; }
  changed();
}
window.addEventListener('keydown', e => {
  if (!$('#dlg').hidden){ if (e.key === 'Escape') dlgClose(); return; }
  const tag = (e.target && e.target.tagName) || '';
  if (tag === 'INPUT' && e.target.type !== 'range' && e.target.type !== 'checkbox') return;
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'){ e.preventDefault(); undo(); return; }
  if (e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); deleteSelection(); return; }
  if (!S.sim && !e.ctrlKey && !e.metaKey && !e.altKey){ if (e.key === 'w' || e.key === 'W'){ setTool3('move'); return; } if (e.key === 'e' || e.key === 'E'){ setTool3('rot'); return; } }
  if (e.key === 'Enter' && draft) finishDraft(!draft.host);
  if (e.key === 'Escape'){ draft = null; pending = null; mark = null; updateHint(); }
});

/* ---------- 2D: Zeichnen ---------- */
function bezPath(c, nodes, closed){
  const n = nodes.length; if (!n) return;
  const q0 = w2s(nodes[0].x, nodes[0].y); c.moveTo(q0[0], q0[1]);
  for (let k = 0; k < (closed ? n : n-1); k++){
    const a = nodes[k], b = nodes[(k+1)%n];
    const p1 = w2s(a.x+a.ho.x, a.y+a.ho.y), p2 = w2s(b.x+b.hi.x, b.y+b.hi.y), p3 = w2s(b.x, b.y);
    c.bezierCurveTo(p1[0],p1[1], p2[0],p2[1], p3[0],p3[1]);
  }
  if (closed) c.closePath();
}
function edgePath(c, C, k){
  const cc = segCtrl(C,k), a = w2s(cc[0][0],cc[0][1]), b = w2s(cc[1][0],cc[1][1]), d = w2s(cc[2][0],cc[2][1]), e = w2s(cc[3][0],cc[3][1]);
  c.beginPath(); c.moveTo(a[0],a[1]); c.bezierCurveTo(b[0],b[1], d[0],d[1], e[0],e[1]);
}
function edgeMid(C, k){ return bz(segCtrl(C,k), 0.5); }
function labelPos(p, c, k, px){   // Bildschirmpunkt neben der Kantenmitte; bei der Kontur außerhalb des Teils
  const C = curveOf(p,c), cc = segCtrl(C,k), m = bz(cc, 0.5), a = bz(cc, 0.45), b = bz(cc, 0.55);
  let nx = -(b[1]-a[1]), ny = b[0]-a[0]; const l = Math.hypot(nx,ny) || 1; nx /= l; ny /= l;
  const step = 3/V.s, inOut = inPoly(m[0]+nx*step, m[1]+ny*step, panelPoly(p, 8));
  if ((!c && inOut) || (c && isClosed(p,c) && !inOut)){ nx = -nx; ny = -ny; }
  const q = w2s(m[0], m[1]); return [q[0]+nx*px, q[1]+ny*px];
}
// Pfeil auf der Kante bei Parameter t, vorwärts = Richtung Knoten k -> k+1
function edgeArrow(c, C, k, t, forward, col){
  const cc = segCtrl(C,k), a = bz(cc, Math.max(0, t-0.03)), b = bz(cc, Math.min(1, t+0.03)), m = bz(cc, t);
  let dx = b[0]-a[0], dy = b[1]-a[1]; if (!forward){ dx = -dx; dy = -dy; }
  const l = Math.hypot(dx,dy) || 1; dx /= l; dy /= l;
  const q = w2s(m[0], m[1]), s = 7;
  c.beginPath();
  c.moveTo(q[0]+dx*s, q[1]+dy*s);
  c.lineTo(q[0]-dx*s*0.7 - dy*s*0.75, q[1]-dy*s*0.7 + dx*s*0.75);
  c.lineTo(q[0]-dx*s*0.35, q[1]-dy*s*0.35);
  c.lineTo(q[0]-dx*s*0.7 + dy*s*0.75, q[1]-dy*s*0.7 - dx*s*0.75);
  c.closePath();
  c.fillStyle = col; c.fill(); c.strokeStyle = '#fff'; c.lineWidth = 1.2; c.stroke();
}
function draw2(){
  const c = ctx; c.setTransform(dpr,0,0,dpr,0,0); c.clearRect(0,0,cw,ch);
  let step = 50; while (step*V.s < 14) step *= 2;
  const w0 = s2w(0,0), w1 = s2w(cw,ch);
  c.lineWidth = 1;
  for (let x = Math.floor(w0.x/step)*step; x <= w1.x; x += step){ const X = x*V.s+V.ox; c.strokeStyle = (Math.round(x/step) % 2 === 0) ? '#eeeeee' : '#f6f6f6'; c.beginPath(); c.moveTo(X,0); c.lineTo(X,ch); c.stroke(); }
  for (let y = Math.floor(w0.y/step)*step; y <= w1.y; y += step){ const Y = y*V.s+V.oy; c.strokeStyle = (Math.round(y/step) % 2 === 0) ? '#eeeeee' : '#f6f6f6'; c.beginPath(); c.moveTo(0,Y); c.lineTo(cw,Y); c.stroke(); }
  const sel = S.sel, selPid = sel && (sel.type === 'panel' || sel.type === 'node') ? sel.id : null;
  // Teile in ihrer Pastellfarbe, Ausschnitte weiß, Innenlinien gestrichelt
  for (const p of S.panels){
    c.beginPath(); bezPath(c, p.nodes, true);
    for (const ci of curveIds(p)) if (ci && isClosed(p,ci)) bezPath(c, curveOf(p,ci).nodes, true);
    c.fillStyle = p.col || '#f4f4f4'; c.globalAlpha = p.id === selPid ? 1 : 0.75; c.fill('evenodd'); c.globalAlpha = 1;
    if (S.mode !== 'sew'){
      c.strokeStyle = '#111'; c.lineWidth = p.id === selPid ? 2.2 : 1.3; c.stroke();
      for (const ci of curveIds(p)) if (ci && !isClosed(p,ci)){ c.beginPath(); bezPath(c, curveOf(p,ci).nodes, false); c.setLineDash([6,4]); c.lineWidth = 1.4; c.stroke(); c.setLineDash([]); }
    }
    const b = bboxPanels([p]), q = w2s((b.x0+b.x1)/2, b.y0 + (b.y1-b.y0)*0.18);
    c.font = '11px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
    c.fillStyle = 'rgba(17,17,17,0.55)'; c.fillText(p.name, q[0], q[1]);
  }
  // Nähen
  if (S.mode === 'sew'){
    for (const p of S.panels) for (const ci of curveIds(p)) for (let k = 0; k < nEdges(p,ci); k++){
      const si = seamIndexOf(p.id, ci, k), C = curveOf(p,ci);
      edgePath(c, C, k);
      if (si >= 0){ c.strokeStyle = seamColor(si); c.lineWidth = (sel && sel.type === 'seam' && sel.i === si) ? 4.5 : 3; c.setLineDash([]); }
      else if (isClosed(p,ci)){ c.strokeStyle = '#ff2d95'; c.lineWidth = 1.6; c.setLineDash([6,4]); }
      else { c.strokeStyle = '#8c8c8c'; c.lineWidth = 1.4; c.setLineDash([3,4]); }
      c.stroke(); c.setLineDash([]);
    }
    if (hoverEdge && !act){ edgePath(c, curveOf(hoverEdge.p, hoverEdge.c), hoverEdge.k); c.strokeStyle = 'rgba(17,17,17,0.18)'; c.lineWidth = 9; c.stroke(); }
    if (pending){
      const p = byId(pending.p);
      if (p){
        const C = curveOf(p, pending.c);
        edgePath(c, C, pending.e); c.strokeStyle = '#111'; c.lineWidth = 4; c.stroke();
        edgeArrow(c, C, pending.e, 0.3, true, '#111'); edgeArrow(c, C, pending.e, 0.7, true, '#111');
        if (cursor){ const m = edgeMid(C, pending.e), a = w2s(m[0],m[1]), b = w2s(cursor.x,cursor.y); c.strokeStyle = '#111'; c.lineWidth = 1; c.setLineDash([4,4]); c.beginPath(); c.moveTo(a[0],a[1]); c.lineTo(b[0],b[1]); c.stroke(); c.setLineDash([]); }
      }
    }
    S.seams.forEach((s, i) => {
      const A = byId(s.a.p), B = byId(s.b.p); if (!A || !B) return;
      const CA = curveOf(A, s.a.c||0), CB = curveOf(B, s.b.c||0); if (!CA || !CB) return;
      const col = seamColor(i), isSel = sel && sel.type === 'seam' && sel.i === i;
      const ma = edgeMid(CA, s.a.e), mb = edgeMid(CB, s.b.e), qa = w2s(ma[0],ma[1]), qb = w2s(mb[0],mb[1]);
      const hov = hoverEdge && (sideIs(s.a, hoverEdge.p.id, hoverEdge.c, hoverEdge.k) || sideIs(s.b, hoverEdge.p.id, hoverEdge.c, hoverEdge.k));
      if (isSel || hov){ c.strokeStyle = col; c.lineWidth = 1.2; c.setLineDash([5,4]); c.beginPath(); c.moveTo(qa[0],qa[1]); c.lineTo(qb[0],qb[1]); c.stroke(); c.setLineDash([]); }
      // A läuft vorwärts. B vorwärts, wenn gleichläufig, sonst rückwärts: die Pfeile zeigen dieselbe Nährichtung.
      for (const t of [0.22, 0.78]){ edgeArrow(c, CA, s.a.e, t, true, col); edgeArrow(c, CB, s.b.e, t, s.flip, col); }
      c.font = '600 10px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
      for (const q of [qa, qb]){ c.beginPath(); c.arc(q[0], q[1], 8, 0, Math.PI*2); c.fillStyle = col; c.fill(); c.fillStyle = '#fff'; c.fillText(String(i+1), q[0], q[1]+0.5); }
    });
    c.font = '10px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.lineJoin = 'round';
    for (const p of S.panels) for (const ci of curveIds(p)) for (let k = 0; k < nEdges(p,ci); k++){
      const q = labelPos(p, ci, k, 14), txt = String(Math.round(edgeLen(curveOf(p,ci),k)));
      c.lineWidth = 3; c.strokeStyle = '#fff'; c.strokeText(txt, q[0], q[1]); c.fillStyle = '#8c8c8c'; c.fillText(txt, q[0], q[1]);
    }
  }
  // Bearbeiten: Knoten und Handles
  if (S.mode === 'edit'){
    for (const p of S.panels) for (const ci of curveIds(p)) curveOf(p,ci).nodes.forEach((nd, k) => {
      const q = w2s(nd.x, nd.y), on = sel && sel.type === 'node' && sel.id === p.id && (sel.c||0) === ci && sel.k === k;
      if (on) for (const w of ['hi','ho']){
        if (Math.hypot(nd[w].x, nd[w].y) < 1e-6) continue;
        const h = w2s(nd.x+nd[w].x, nd.y+nd[w].y);
        c.strokeStyle = '#ff2d95'; c.lineWidth = 1; c.beginPath(); c.moveTo(q[0],q[1]); c.lineTo(h[0],h[1]); c.stroke();
        c.fillStyle = '#fff'; c.beginPath(); c.arc(h[0], h[1], 4, 0, Math.PI*2); c.fill(); c.strokeStyle = '#ff2d95'; c.stroke();
      }
      const r = on ? 4.5 : 3.5;
      c.fillStyle = on ? '#ff2d95' : '#fff'; c.strokeStyle = on ? '#ff2d95' : '#111'; c.lineWidth = 1;
      c.beginPath();
      if (nd.type === 'smooth') c.arc(q[0], q[1], r, 0, Math.PI*2); else c.rect(q[0]-r, q[1]-r, 2*r, 2*r);
      c.fill(); c.stroke();
    });
  }
  // Zeichnen: Entwurf (im Teil blau = Innenform)
  if (draft && draft.nodes.length){
    const inner = draft.host != null, dc = inner ? '#2d7dff' : '#111';
    c.beginPath(); bezPath(c, draft.nodes, false); c.strokeStyle = dc; c.lineWidth = 1.5; c.stroke();
    if (cursor && !act){
      const l = draft.nodes[draft.nodes.length-1], a = w2s(l.x, l.y), b = w2s(cursor.x, cursor.y);
      c.strokeStyle = '#c9c9c9'; c.lineWidth = 1; c.setLineDash([4,4]); c.beginPath(); c.moveTo(a[0],a[1]); c.lineTo(b[0],b[1]); c.stroke(); c.setLineDash([]);
    }
    draft.nodes.forEach((nd, i) => {
      const q = w2s(nd.x, nd.y);
      if (i === 0 && draft.nodes.length >= 3){ c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.beginPath(); c.arc(q[0], q[1], 9, 0, Math.PI*2); c.stroke(); }
      if (nd.type === 'smooth') for (const w of ['hi','ho']){ const h = w2s(nd.x+nd[w].x, nd.y+nd[w].y); c.strokeStyle = '#ff2d95'; c.lineWidth = 1; c.beginPath(); c.moveTo(q[0],q[1]); c.lineTo(h[0],h[1]); c.stroke(); }
      c.fillStyle = '#fff'; c.strokeStyle = dc; c.lineWidth = 1; c.beginPath(); c.rect(q[0]-3.5, q[1]-3.5, 7, 7); c.fill(); c.stroke();
    });
  }
  if (mark) drawMark(c);
  const L100 = 100*V.s, bx = cw - 14, by = ch - 16;
  c.strokeStyle = '#8c8c8c'; c.fillStyle = '#8c8c8c'; c.lineWidth = 1;
  c.beginPath(); c.moveTo(bx-L100, by); c.lineTo(bx, by); c.moveTo(bx-L100, by-4); c.lineTo(bx-L100, 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.textBaseline = 'alphabetic'; c.fillText('100 mm', bx, by-8);
}

function drawMark(c){
  const t = performance.now()/1000, pulse = 3*Math.sin(t*4);
  if (mark.seam >= 0 && S.seams[mark.seam]){
    const s = S.seams[mark.seam], col = seamColor(mark.seam);
    for (const sd of [s.a, s.b]){ const P = byId(sd.p); if (!P) continue; edgePath(c, curveOf(P, sd.c||0), sd.e); c.strokeStyle = col; c.lineWidth = 5; c.globalAlpha = 0.5; c.stroke(); c.globalAlpha = 1; }
  }
  const pts = [mark].concat(mark.partner ? [mark.partner] : []);
  if (mark.partner){
    const a = w2s(mark.x, mark.y), b = w2s(mark.partner.x, mark.partner.y);
    c.strokeStyle = '#ff2d95'; c.lineWidth = 1.2; c.setLineDash([5,4]); c.beginPath(); c.moveTo(a[0],a[1]); c.lineTo(b[0],b[1]); c.stroke(); c.setLineDash([]);
  }
  for (const m of pts){
    const q = w2s(m.x, m.y), r = 11 + pulse;
    c.strokeStyle = '#ff2d95'; c.lineWidth = 2; c.beginPath(); c.arc(q[0], q[1], r, 0, Math.PI*2); c.stroke();
    c.lineWidth = 1.2; c.beginPath(); c.moveTo(q[0]-r-6, q[1]); c.lineTo(q[0]-r+4, q[1]); c.moveTo(q[0]+r-4, q[1]); c.lineTo(q[0]+r+6, q[1]);
    c.moveTo(q[0], q[1]-r-6); c.lineTo(q[0], q[1]-r+4); c.moveTo(q[0], q[1]+r-4); c.lineTo(q[0], q[1]+r+6); c.stroke();
    c.fillStyle = '#ff2d95'; c.beginPath(); c.arc(q[0], q[1], 2.5, 0, Math.PI*2); c.fill();
  }
  const q = w2s(mark.x, mark.y), P = byId(mark.pid);
  c.font = '600 11px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'left'; c.textBaseline = 'middle'; c.lineJoin = 'round';
  const txt = (P ? P.name : '') + (mark.seam >= 0 ? ', Naht ' + (mark.seam+1) : '');
  c.lineWidth = 3; c.strokeStyle = '#fff'; c.strokeText(txt, q[0]+20, q[1]-14); c.fillStyle = '#ff2d95'; c.fillText(txt, q[0]+20, q[1]-14);
}
// Nächste Kante eines Teils zu einem Schnittbogen-Punkt, mit Bogenlängen-Anteil f
function nearestEdge2D(p, x, y){
  let best = {d: 1e9};
  for (const c of curveIds(p)) for (let k = 0; k < nEdges(p,c); k++){
    const pl = edgePoly(curveOf(p,c), k, 32), L = plLen(pl); let acc = 0;
    for (let j = 0; j < 32; j++){
      const a = pl[j], b = pl[j+1], dx = b[0]-a[0], dy = b[1]-a[1], l2 = dx*dx+dy*dy || 1e-9, sl = Math.sqrt(l2);
      const u = Math.max(0, Math.min(1, ((x-a[0])*dx + (y-a[1])*dy)/l2)), d = Math.hypot(a[0]+u*dx-x, a[1]+u*dy-y);
      if (d < best.d) best = {d, c, k, f: L > 0 ? (acc + u*sl)/L : 0};
      acc += sl;
    }
  }
  return best;
}
// Sorgt dafür, dass der Marker im Schnittbogen sichtbar ist
function ensureVisible(m){
  const q = w2s(m.x, m.y);
  if (q[0] < 40 || q[0] > cw-40 || q[1] < 50 || q[1] > ch-50){ V.ox += cw/2 - q[0]; V.oy += ch/2 - q[1]; }
}

/* ---------- Seitenpanel: Auswahl ---------- */
function selChanged(){ renderSel(); paintColors(); }
function sideLabel(sd){
  const p = byId(sd.p); if (!p) return '';
  const c = sd.c || 0;
  return p.name + ', ' + (c ? curveName(p,c) + ', Abschnitt ' : 'Kante ') + (sd.e+1);
}
function renderSel(){
  const box = $('#selbox'), sel = S.sel; let h = '';
  if (S.mode === 'sew'){
    h += '<div class="row2"><button data-act="autosew" title="Offene Kanten anhand der Startlage im Raum paaren">Automatisch nähen</button><button data-act="autodir" title="Nahtrichtungen aus der Startlage im Raum neu bestimmen">Richtungen automatisch</button></div>';
    h += '<p class="note">' + S.seams.length + ' Nähte, ' + countOpen() + ' offene Kanten (Kontur und Ausschnitte). Innenlinien müssen nicht vernäht sein.</p>';
  }
  if (sel && (sel.type === 'panel' || sel.type === 'node')){
    const p = byId(sel.id);
    if (p){
      const nh = (p.inner || []).filter(C => C.closed).length, nl = (p.inner || []).length - nh;
      h += '<div class="lab2" style="margin-top:10px"><span class="sw" style="background:' + p.col + '"></span>' + p.name + '</div>';
      if (nh || nl) h += '<p class="kv">' + (nh ? nh + (nh === 1 ? ' Ausschnitt' : ' Ausschnitte') : '') + (nh && nl ? ', ' : '') + (nl ? nl + (nl === 1 ? ' Innenlinie' : ' Innenlinien') : '') + '</p>';
      h += '<div class="sub">Startlage im Raum drehen</div><div class="rot3">';
      for (const [ax, col] of [['x','#e5484d'],['y','#2fa36b'],['z','#3e63dd']]) h += '<span style="color:' + col + '">' + ax.toUpperCase() + '</span><button data-rot="' + ax + '-">−90°</button><button data-rot="' + ax + '+">+90°</button>';
      h += '</div>';
      h += '<div class="row2"><button data-act="reset3" title="Flach in die Ebene des Schnittbogens legen">Zurücksetzen</button><button data-act="flipside" title="Außen- und Innenseite tauschen (Teil um 180° wenden)">Wenden</button><button data-act="mirror" title="Gespiegelte Kopie hinter diesem Teil, alle Kanten und Innenformen vernäht">Gegenstück</button><button data-act="delpanel">Löschen</button></div>';
      h += '<p class="note">Bei ausgeschalteter Simulation in 3D: Werkzeug Verschieben (W) mit Achsenpfeilen oder Drehen (E) mit Ringen. Die Startlage wird mit „Projekt speichern“ gesichert.</p>';
      if (sel.type === 'node'){
        const c = sel.c || 0, C = curveOf(p, c), nd = C && C.nodes[sel.k];
        if (nd){
          h += '<div class="sub">' + curveName(p, c) + ', Knoten ' + (sel.k+1) + ' von ' + C.nodes.length + '</div>';
          h += '<div class="row2"><button data-act="smooth"' + (nd.type === 'smooth' ? ' class="on"' : '') + '>Glatt</button><button data-act="corner"' + (nd.type === 'corner' ? ' class="on"' : '') + '>Ecke</button><button data-act="delnode">Knoten löschen</button></div>';
          if (c) h += '<div class="row2"><button data-act="delcurve">' + curveName(p, c) + ' löschen</button></div>';
        }
      }
    }
  }
  if (sel && sel.type === 'seam' && S.seams[sel.i]){
    const s = S.seams[sel.i], A = byId(s.a.p), B = byId(s.b.p);
    if (A && B){
      const la = edgeLen(curveOf(A, s.a.c||0), s.a.e), lb = edgeLen(curveOf(B, s.b.c||0), s.b.e), d = (la-lb)/Math.min(la,lb)*100;
      h += '<div class="lab2" style="margin-top:10px"><span class="sw" style="background:' + seamColor(sel.i) + '"></span>Naht ' + (sel.i+1) + (s.flip ? ', gleichläufig' : ', gegenläufig') + '</div>';
      h += '<p class="kv"><span class="sw" style="background:' + A.col + ';width:9px;height:9px"></span> ' + sideLabel(s.a) + ': <b>' + fmt(la,0) + ' mm</b></p>';
      h += '<p class="kv"><span class="sw" style="background:' + B.col + ';width:9px;height:9px"></span> ' + sideLabel(s.b) + ': <b>' + fmt(lb,0) + ' mm</b></p>';
      h += '<p class="kv">Differenz: <b' + (Math.abs(d) >= 1 ? ' style="color:#ff2d95"' : '') + '>' + fmt(Math.abs(d),1) + ' %</b></p>';
      h += '<div class="row2"><button data-act="flip">Richtung umkehren</button><button data-act="unsew">Naht lösen</button></div>';
      h += '<p class="note">Die Pfeile auf beiden Kanten zeigen die Nährichtung: Pfeilspitze trifft auf Pfeilspitze. Die Längendifferenz wird gleichmäßig über die Naht verteilt.</p>';
    }
  }
  if (!h || (S.mode === 'sew' && !sel)) h += '<p class="note">' + ({
    draw: 'Außerhalb der Teile: neues Teil. In einem Teil: Innenform. Klicken setzt einen Eckknoten, Klicken und Ziehen einen runden Knoten. Ersten Knoten anklicken schließt die Form (im Teil: Ausschnitt). Enter oder Doppelklick beendet eine Innenlinie.',
    edit: 'Knoten anklicken zeigt seine Handles. Teil anklicken wählt es aus. Shift+Klick auf eine Kurve fügt einen Knoten ein, Doppelklick auf einen Knoten wechselt zwischen glatt und Ecke.',
    sew: 'Erst eine Kante anklicken, dann die Gegenkante. Das geht mit Konturen, Ausschnitträndern und Innenlinien. Eine vernähte Kante anklicken zeigt die Naht.'
  })[S.mode] + '</p>';
  box.innerHTML = h;
}
$('#selbox').addEventListener('click', e => {
  const b = e.target.closest('button'); if (!b) return;
  const sel = S.sel, p = sel && (sel.type === 'panel' || sel.type === 'node') ? byId(sel.id) : null;
  if (b.dataset.rot && p){
    snap();
    const ax = b.dataset.rot[0], sg = b.dataset.rot[1] === '+' ? 1 : -1;
    p.rot = qNorm(qMul(qAxis(ax === 'x' ? 1 : 0, ax === 'y' ? 1 : 0, ax === 'z' ? 1 : 0, sg*Math.PI/2), p.rot || QI()));
    placementChanged(p); return;
  }
  const a = b.dataset.act;
  if (a === 'reset3' && p){ snap(); defaultPose(p); placementChanged(p); }
  else if (a === 'mirror' && p) addMirror(p.id);
  else if (a === 'flipside' && p){
    snap(); p.rot = qNorm(qMul(p.rot || QI(), qAxis(0,1,0,Math.PI)));
    const place = panelFrame(S.panels);
    S.seams.forEach(s => { if (s.a.p === p.id || s.b.p === p.id){ const A = byId(s.a.p), B = byId(s.b.p); if (A && B) s.flip = autoFlip(place, {p: A, c: s.a.c||0, e: s.a.e}, {p: B, c: s.b.c||0, e: s.b.e}); } });
    msg(p.name + ' gewendet: die Außenseite zeigt jetzt in die Gegenrichtung.'); changed();
  }
  else if (a === 'delpanel' && p){ snap(); deletePanel(p.id); changed(); }
  else if ((a === 'smooth' || a === 'corner') && p && sel.type === 'node'){ snap(); const c = sel.c||0, C = curveOf(p,c); setNodeType(C.nodes[sel.k], C, sel.k, isClosed(p,c), a); changed(); }
  else if (a === 'delnode' && p && sel.type === 'node'){ snap(); deleteNode(p, sel.c||0, sel.k); changed(); }
  else if (a === 'delcurve' && p && sel.type === 'node' && sel.c){ snap(); removeCurve(p, sel.c); S.sel = {type: 'panel', id: p.id}; changed(); }
  else if (a === 'flip' && sel && sel.type === 'seam'){ snap(); S.seams[sel.i].flip = !S.seams[sel.i].flip; changed(); }
  else if (a === 'unsew' && sel && sel.type === 'seam'){ snap(); S.seams.splice(sel.i, 1); S.sel = null; changed(); }
  else if (a === 'autosew'){ snap(); const add = autoSew(S.panels, S.seams); S.seams.push(...add); msg(add.length ? add.length + ' Nähte gesetzt.' : 'Keine passenden Kanten gefunden.'); changed(); }
  else if (a === 'autodir'){ snap(); refreshFlips(); msg('Richtungen neu bestimmt.'); changed(); }
});
function placementChanged(p){
  if (!S.sim && model && model.preview){ updatePreviewPanel(p); renderSel(); }
  else changed();
}

/* ---------- Modus, Vorlagen, Kopfzeile ---------- */
function setMode(m){
  if (S.mode === 'draw' && m !== 'draw' && draft) finishDraft(!draft.host);
  draft = null;
  S.mode = m; pending = null; hoverEdge = null;
  if (m === 'sew' && S.sel && S.sel.type !== 'seam') S.sel = null;
  if (m !== 'sew' && S.sel && S.sel.type === 'seam') S.sel = null;
  $('#mDraw').classList.toggle('on', m === 'draw'); $('#mEdit').classList.toggle('on', m === 'edit'); $('#mSew').classList.toggle('on', m === 'sew');
  c2.style.cursor = m === 'draw' ? 'crosshair' : 'default';
  selChanged(); updateHint();
}
$('#mDraw').onclick = () => setMode('draw');
$('#mEdit').onclick = () => setMode('edit');
$('#mSew').onclick = () => setMode('sew');
function loadPreset(name){
  snap();
  const o = makePreset(name);
  S.panels = o.panels; S.seams = o.seams; S.nextId = o.nextId; S.sel = null; draft = null; pending = null; mark = null;
  fitted = false; fitView(); msg(''); renderSel(); updateHint();
  rebuild();
}
document.querySelectorAll('[data-p]').forEach(b => b.addEventListener('click', () => loadPreset(b.dataset.p)));
$('#bUndo').onclick = undo;
$('#bNew').onclick = () => { snap(); S.panels = []; S.seams = []; S.sel = null; draft = null; pending = null; mark = null; clearModel(); msg(''); renderSel(); updateHint(); };
$('#bFit').onclick = fitView;
function updateHint(){
  let t;
  if (S.mode === 'draw'){
    if (!draft) t = 'Außerhalb der Teile beginnt ein neues Teil, in einem Teil eine Innenform.';
    else if (draft.host != null) t = 'Innenform: ersten Knoten anklicken = Ausschnitt, Enter oder Doppelklick = Innenlinie.';
    else t = draft.nodes.length >= 3 ? 'Ersten Knoten anklicken oder Enter schließt das Teil.' : 'Weitere Knoten setzen. Ziehen beim Klicken erzeugt Handles.';
    t += ' Entf nimmt den letzten Knoten zurück.';
  }
  else if (S.mode === 'edit') t = 'Knoten anklicken, Handles ziehen (Alt bricht die Tangente). Shift+Klick auf eine Kurve fügt einen Knoten ein. Leere Fläche ziehen verschiebt die Ansicht.';
  else t = pending ? 'Jetzt die Gegenkante anklicken. Esc bricht ab.' : 'Kante anklicken, dann die Gegenkante. Pink gestrichelt: offen, grau gepunktet: Innenlinie.';
  $('#hint').textContent = t + ' Mausrad zoomt.';
  $('#tag3').textContent = (S.sim ? 'Stoff ziehen zieht an der Hülle (zum Entwirren), leere Fläche ziehen dreht die Ansicht.' : (S.tool === 'move' ? 'Verschieben (W): Teil ziehen = Bildebene, Shift = Tiefe, Pfeile = Achse.' : 'Drehen (E): Ringe ziehen = Achse, Teil ziehen = frei.') + ' Leere Fläche ziehen dreht die Ansicht.') + ' Klick zeigt die Stelle im Schnittbogen.';
}

/* ---------- SVG-Export ---------- */
function buildSVG(){
  if (!S.panels.length) return null;
  const b = bboxPanels(S.panels), m = 10, tx = m - b.x0, ty = m - b.y0, f = v => v.toFixed(2);
  const W = b.x1-b.x0+2*m, H = b.y1-b.y0+2*m;
  const pathOf = (nodes, closed) => {
    const n = nodes.length, P = (x,y) => f(x+tx) + ' ' + f(y+ty);
    let d = 'M' + P(nodes[0].x, nodes[0].y);
    for (let k = 0; k < (closed ? n : n-1); k++){ const a = nodes[k], c = nodes[(k+1)%n]; d += 'C' + P(a.x+a.ho.x, a.y+a.ho.y) + ' ' + P(c.x+c.hi.x, c.y+c.hi.y) + ' ' + P(c.x, c.y); }
    return d + (closed ? 'Z' : '');
  };
  let cuts = '', lines = '', marks = '';
  for (const p of S.panels){
    cuts += '    <path id="teil-' + p.id + '" d="' + pathOf(p.nodes, true) + '"/>\n';
    (p.inner || []).forEach((C, i) => {
      if (C.closed) cuts += '    <path id="teil-' + p.id + '-ausschnitt-' + (i+1) + '" d="' + pathOf(C.nodes, true) + '"/>\n';
      else lines += '    <path id="teil-' + p.id + '-innenlinie-' + (i+1) + '" d="' + pathOf(C.nodes, false) + '"/>\n';
    });
    const bb = bboxPanels([p]);
    marks += '    <text x="' + f((bb.x0+bb.x1)/2+tx) + '" y="' + f(bb.y0 + (bb.y1-bb.y0)*0.18 + ty) + '" font-size="7" text-anchor="middle">' + p.name + '</text>\n';
  }
  S.seams.forEach((s, i) => {
    const A = byId(s.a.p), B = byId(s.b.p); if (!A || !B) return;
    const CA = curveOf(A, s.a.c||0), CB = curveOf(B, s.b.c||0);
    for (const [C, e] of [[CA, s.a.e], [CB, s.b.e]]){ const q = edgeMid(C, e); marks += '    <text x="' + f(q[0]+tx) + '" y="' + f(q[1]+ty) + '" font-size="6" text-anchor="middle">N' + (i+1) + '</text>\n'; }
    const nA = CA.nodes[s.a.e], nB = CB.nodes[s.flip ? s.b.e : (s.b.e+1) % CB.nodes.length];
    for (const nd of [nA, nB]) marks += '    <circle cx="' + f(nd.x+tx) + '" cy="' + f(nd.y+ty) + '" r="1.2"/>\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' +
    '  <!-- schnitt: Konturen und Ausschnitte. naht: Innenlinien. markierung: Teilnamen, Nahtnummern, Nahtanfänge (die Punkte einer Naht treffen aufeinander). -->\n' +
    '  <g id="schnitt" fill="none" stroke="#000000" stroke-width="0.3">\n' + cuts + '  </g>\n' +
    (lines ? '  <g id="naht" fill="none" stroke="#0000ff" stroke-width="0.3">\n' + lines + '  </g>\n' : '') +
    '  <g id="markierung" fill="#0000ff" stroke="none" font-family="Helvetica, Arial, sans-serif">\n' + marks + '  </g>\n' +
    '</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';
}
function projectJSON(){
  return JSON.stringify({
    format: 'inflate-project', version: '2.0', saved: new Date().toISOString(),
    panels: S.panels, seams: S.seams, nextId: S.nextId,
    settings: {pressure: +$('#pr').value, stiffness: +$('#et').value, gloss: +$('#gl').value, metal: +$('#me').value, resolution: +$('#rs').value, bending: +$('#bd').value}
  }, null, 1);
}
const num = (v, d) => Number.isFinite(+v) ? +v : d;
const fixNode = n => ({x: num(n.x,0), y: num(n.y,0), hi: {x: num(n.hi && n.hi.x, 0), y: num(n.hi && n.hi.y, 0)}, ho: {x: num(n.ho && n.ho.x, 0), y: num(n.ho && n.ho.y, 0)}, type: n.type === 'smooth' ? 'smooth' : 'corner'});
function loadProject(o){
  if (!o || !Array.isArray(o.panels)) throw new Error('format');
  const panels = [];
  o.panels.forEach((p, i) => {
    if (!p || !Array.isArray(p.nodes) || p.nodes.length < 3) return;
    const id = Number.isFinite(p.id) ? p.id : 1000 + i;
    const q = {id, name: typeof p.name === 'string' ? p.name : 'Teil ' + id, col: /^#[0-9a-f]{6}$/i.test(p.col || '') ? p.col : pastel(id),
      nodes: p.nodes.map(fixNode),
      inner: (p.inner || []).filter(C => C && Array.isArray(C.nodes) && C.nodes.length >= 2).map(C => ({nodes: C.nodes.map(fixNode), closed: !!C.closed && C.nodes.length >= 3}))};
    if (Array.isArray(p.pos) && p.pos.length === 3 && Array.isArray(p.rot) && p.rot.length === 4){ q.pos = p.pos.map(v => num(v,0)); q.rot = qNorm(p.rot.map(v => num(v,0))); }
    else defaultPose(q);
    panels.push(q);
  });
  if (!panels.length) throw new Error('leer');
  const ok = sd => { if (!sd) return false; const P = panels.find(x => x.id === sd.p); const c = sd.c || 0; return !!P && (c === 0 || c <= P.inner.length) && Number.isInteger(sd.e) && sd.e >= 0 && sd.e < nEdges(P, c); };
  const seams = (o.seams || []).filter(s => s && ok(s.a) && ok(s.b)).map(s => ({a: {p: s.a.p, c: s.a.c || 0, e: s.a.e}, b: {p: s.b.p, c: s.b.c || 0, e: s.b.e}, flip: !!s.flip}));
  snap();
  S.panels = panels; S.seams = seams;
  S.nextId = Math.max(num(o.nextId, 1), ...panels.map(p => p.id + 1));
  const st = o.settings || {};
  for (const [k, id] of [['pressure','#pr'],['stiffness','#et'],['gloss','#gl'],['metal','#me'],['resolution','#rs'],['bending','#bd']]) if (Number.isFinite(+st[k])) $(id).value = st[k];
  readSliders();
  S.sel = null; draft = null; pending = null; mark = null; fitted = false;
  fitView(); renderSel(); updateHint(); rebuild();
  const lost = (o.seams || []).length - seams.length;
  msg('Projekt geladen: ' + panels.length + ' Teile, ' + seams.length + ' Nähte.' + (lost > 0 ? ' ' + lost + ' ungültige Nähte übersprungen.' : ''));
}
// Speicher: in der Chat-Vorschau window.storage, sonst (z. B. auf dem eigenen Server) localStorage
const store = {
  async list(){
    if (window.storage && window.storage.list){ try { const r = await window.storage.list('inflate:', false); return r && r.keys ? r.keys.map(k => typeof k === 'string' ? k : k.key) : []; } catch (e) { return []; } }
    try { return Object.keys(localStorage).filter(k => k.startsWith('inflate:')); } catch (e) { return []; }
  },
  async get(k){
    if (window.storage && window.storage.get){ try { const r = await window.storage.get(k, false); return r ? r.value : null; } catch (e) { return null; } }
    try { return localStorage.getItem(k); } catch (e) { return null; }
  },
  async set(k, v){
    if (window.storage && window.storage.set){ try { return !!(await window.storage.set(k, v, false)); } catch (e) { return false; } }
    try { localStorage.setItem(k, v); return true; } catch (e) { return false; }
  },
  async del(k){
    if (window.storage && window.storage.delete){ try { await window.storage.delete(k, false); return true; } catch (e) { return false; } }
    try { localStorage.removeItem(k); return true; } catch (e) { return false; }
  }
};
const esc = t => String(t).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]);
function dlgOpen(title, html){ $('#dlgTitle').textContent = title; $('#dlgBody').innerHTML = html; $('#dlg').hidden = false; }
function dlgClose(){ $('#dlg').hidden = true; }
function dlgStatus(t, err){ const e = $('#dlgSt'); if (e){ e.textContent = t; e.className = 'st' + (err ? ' err' : ''); } }
$('#dlgClose').onclick = dlgClose;
$('#dlg').addEventListener('click', e => { if (e.target.id === 'dlg') dlgClose(); });
async function copyText(text){
  try { await navigator.clipboard.writeText(text); return true; } catch (e) {}
  const ta = $('#dlgText'); if (!ta) return false;
  ta.focus(); ta.select();
  try { return document.execCommand('copy'); } catch (e) { return false; }
}
// Export-Dialog: Download (wo erlaubt), Kopieren, Text zum Markieren
function exportDialog(title, fname, text, mime, withStore){
  dlgOpen(title,
    (withStore ? '<div class="row2"><input type="text" id="dlgName" value="' + esc(fname.replace(/\.json$/,'')) + '"><button id="dlgStore" class="on">Im Browser speichern</button></div>' : '') +
    '<div class="row2"><button id="dlgDown">Datei herunterladen</button><button id="dlgCopy">In Zwischenablage kopieren</button></div>' +
    '<div class="st" id="dlgSt"></div>' +
    '<textarea id="dlgText" readonly spellcheck="false"></textarea>' +
    '<p class="note">In der Chat-Vorschau sind Downloads gesperrt. Dort funktionieren „Im Browser speichern“ und Kopieren. Auf deinem eigenen Server funktioniert auch der Download.</p>');
  $('#dlgText').value = text;
  $('#dlgText').addEventListener('focus', e => e.target.select());
  $('#dlgDown').onclick = async () => {
    const r = await saveFile(withStore ? (($('#dlgName').value || 'inflate-projekt').trim() + '.json') : fname, text, mime);
    dlgStatus(r === 'saved' ? 'Gespeichert.' : r === 'declined' ? 'Abgebrochen.' : 'Download angestoßen. Wenn nichts passiert, ist er hier gesperrt: bitte kopieren oder im Browser speichern.');
  };
  $('#dlgCopy').onclick = async () => dlgStatus(await copyText(text) ? 'In die Zwischenablage kopiert.' : 'Kopieren ist hier gesperrt. Der Text unten ist markiert, Strg/Cmd+C kopiert ihn.', false);
  if (withStore) $('#dlgStore').onclick = async () => {
    const name = ($('#dlgName').value || 'inflate-projekt').trim(), key = 'inflate:' + name.replace(/[\s\/\\'"]+/g, '_').slice(0, 120);
    const ok = await store.set(key, JSON.stringify({name, saved: new Date().toISOString(), project: JSON.parse(text)}));
    dlgStatus(ok ? 'Im Browser gespeichert als „' + name + '“.' : 'Speichern im Browser hat nicht geklappt. Bitte kopieren.', !ok);
  };
}
async function loadDialog(){
  dlgOpen('Projekt laden',
    '<h3>Im Browser gespeichert</h3><div id="dlgList"><p class="note">Lade …</p></div>' +
    '<h3>Aus Datei</h3><div class="row2"><button id="dlgFile">Datei wählen</button></div>' +
    '<h3>JSON einfügen</h3><textarea id="dlgPaste" spellcheck="false" placeholder="Projekt-JSON hier einfügen"></textarea>' +
    '<div class="row2"><button id="dlgPasteGo" class="on">Eingefügtes Projekt laden</button></div><div class="st" id="dlgSt"></div>');
  $('#dlgFile').onclick = () => $('#fileIn').click();
  $('#dlgPasteGo').onclick = () => {
    try { const o = JSON.parse($('#dlgPaste').value); loadProject(o.project || o); dlgClose(); }
    catch (err) { dlgStatus('Das ist kein gültiges INFLATE-Projekt.', true); }
  };
  const keys = (await store.list()).sort();
  const rows = [];
  for (const k of keys){
    const v = await store.get(k); let o = null; try { o = JSON.parse(v); } catch (e) {}
    if (!o) continue;
    const d = o.saved ? new Date(o.saved) : null;
    rows.push('<div class="item"><span>' + esc(o.name || k.slice(8)) + ' <small>' + (d ? d.toLocaleString('de-DE') : '') + '</small></span><button data-load="' + esc(k) + '">Laden</button><button data-del="' + esc(k) + '">Löschen</button></div>');
  }
  $('#dlgList').innerHTML = rows.length ? rows.join('') : '<p class="note">Noch nichts im Browser gespeichert.</p>';
  $('#dlgList').onclick = async e => {
    const b = e.target.closest('button'); if (!b) return;
    if (b.dataset.load){ try { const o = JSON.parse(await store.get(b.dataset.load)); loadProject(o.project || o); dlgClose(); } catch (err) { dlgStatus('Dieser Eintrag ist beschädigt.', true); } }
    else if (b.dataset.del){ await store.del(b.dataset.del); loadDialog(); }
  };
}
$('#bSave').onclick = () => {
  if (!S.panels.length){ msg('Noch keine Teile zum Speichern.', true); return; }
  exportDialog('Projekt speichern', 'inflate-projekt.json', projectJSON(), 'application/json', true);
};
$('#bLoad').onclick = loadDialog;
$('#fileIn').addEventListener('change', e => {
  const f = e.target.files && e.target.files[0]; if (!f) return;
  const r = new FileReader();
  r.onload = () => { try { const o = JSON.parse(r.result); loadProject(o.project || o); dlgClose(); } catch (err) { msg('Die Datei ist kein gültiges INFLATE-Projekt.', true); } e.target.value = ''; };
  r.readAsText(f);
});
$('#bSvg').onclick = () => {
  const svg = buildSVG();
  if (!svg){ msg('Noch keine Teile gezeichnet.', true); return; }
  msg(''); exportDialog('Schnittbogen als SVG', 'inflate-schnittbogen.svg', svg, 'image/svg+xml', false);
};

/* ---------- 3D ---------- */
let renderer, scene, camera, mesh, backMesh, wire, seam, conn, floor, geo, colArr, mat, backMat, raycaster;
let hlLines, hlPts, hlKey = '', gizmo, rings = [], hoverRing = -1, markObj, down3 = null, moveG, arrows = [], hoverArrow = -1;
let az = 0.6, el = 0.45, dist = 900;
const tgt = [0,0,0];
const AXES = [{a: [1,0,0], col: 0xe5484d}, {a: [0,1,0], col: 0x2fa36b}, {a: [0,0,1], col: 0x3e63dd}];
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;
}
// Dreh-Gizmo: drei Ringe (X rot, Y grün, Z blau) mit Pfeilspitze für die positive Drehrichtung
function makeGizmo(){
  gizmo = new THREE.Group(); gizmo.visible = false;
  AXES.forEach((ax, i) => {
    const m = new THREE.MeshBasicMaterial({color: ax.col, transparent: true, opacity: 0.7, depthTest: false});
    const ring = new THREE.Mesh(new THREE.TorusGeometry(1, 0.012, 8, 96), m);
    const cone = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.13, 14), m);
    cone.position.set(1, 0, 0);
    const pickT = new THREE.Mesh(new THREE.TorusGeometry(1, 0.075, 6, 48), new THREE.MeshBasicMaterial({transparent: true, opacity: 0, depthWrite: false, depthTest: false}));
    pickT.userData.axis = i;
    const g = new THREE.Group(); g.add(ring, cone, pickT);
    if (i === 0) g.rotation.y = Math.PI/2;   // Ringnormale entlang X
    if (i === 1) g.rotation.x = -Math.PI/2;  // Ringnormale entlang Y
    for (const o of [ring, cone, pickT]){ o.renderOrder = 1000; o.frustumCulled = false; }
    gizmo.add(g); rings.push({mat: m, pickT});
  });
  scene.add(gizmo);
}
// Verschiebe-Gizmo: drei Pfeile (X rot, Y grün, Z blau)
function makeMoveGizmo(){
  moveG = new THREE.Group(); moveG.visible = false;
  AXES.forEach((ax, i) => {
    const m = new THREE.MeshBasicMaterial({color: ax.col, transparent: true, opacity: 0.75, depthTest: false});
    const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 1, 8), m); shaft.position.y = 0.5;
    const head = new THREE.Mesh(new THREE.ConeGeometry(0.055, 0.17, 14), m); head.position.y = 1.07;
    const pick = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.2, 8), new THREE.MeshBasicMaterial({transparent: true, opacity: 0, depthWrite: false, depthTest: false}));
    pick.position.y = 0.6; pick.userData.axis = i;
    const g = new THREE.Group(); g.add(shaft, head, pick);
    if (i === 0) g.rotation.z = -Math.PI/2;   // Pfeil entlang X
    if (i === 2) g.rotation.x = Math.PI/2;    // Pfeil entlang Z
    for (const o of [shaft, head, pick]){ o.renderOrder = 1000; o.frustumCulled = false; }
    moveG.add(g); arrows.push({mat: m, pick});
  });
  scene.add(moveG);
}
let drag3 = null;
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, 30000);
  raycaster = new THREE.Raycaster();
  scene.environment = makeEnv();
  scene.add(new THREE.HemisphereLight(0xffffff, 0xdcdcdc, 0.45));
  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(3000, 60, 0xe4e4e4, 0xf0f0f0); scene.add(floor);
  mat = new THREE.MeshStandardMaterial({vertexColors: true, color: 0xffffff, roughness: 0.5, metalness: 0, side: THREE.FrontSide, envMapIntensity: 0.6});
  backMat = new THREE.MeshStandardMaterial({vertexColors: true, color: 0x3a3a3a, roughness: 0.85, metalness: 0, side: THREE.BackSide, envMapIntensity: 0.3});
  makeGizmo(); makeMoveGizmo();
  markObj = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshBasicMaterial({color: 0xff2d95, depthTest: false}));
  markObj.renderOrder = 1001; markObj.visible = false; scene.add(markObj);
  const rd = renderer.domElement;
  rd.addEventListener('pointerdown', e => {
    rd.setPointerCapture(e.pointerId);
    down3 = {x: e.clientX, y: e.clientY, button: e.button};
    const editable = model && model.preview && e.button === 0;
    const sp = selectedPanel();
    if (editable && sp && S.tool === 'rot'){
      const ri = pickRing(e);
      if (ri >= 0){ const A = AXES[ri].a; drag3 = {type: 'ring', p: sp, axis: A, lx: e.clientX, ly: e.clientY, last: ringVec(e, sp.pos || [0,0,0], A), snapped: false}; return; }
    }
    if (editable && sp && S.tool === 'move'){
      const ai = pickArrow(e);
      if (ai >= 0){ const A = AXES[ai].a, P0 = (sp.pos || [0,0,0]).slice(); drag3 = {type: 'axis', p: sp, axis: A, start: P0, s0: axisParam(e, P0, A), lx: e.clientX, ly: e.clientY, snapped: false}; return; }
    }
    if (model && !model.preview && S.run === 'run' && e.button === 0 && mesh){
      raycaster.setFromCamera(ndc(e), camera);
      const hs = raycaster.intersectObjects([mesh, backMesh]);
      if (hs.length){
        const f = hs[0].face, P = model.pos, pt = hs[0].point;
        let best = f.a, bd = 1e18;
        for (const i of [f.a, f.b, f.c]){ const d = (P[3*i]-pt.x)**2 + (P[3*i+1]-pt.y)**2 + (P[3*i+2]-pt.z)**2; if (d < bd){ bd = d; best = i; } }
        model.pin = {i: best, x: P[3*best], y: P[3*best+1], z: P[3*best+2]};
        drag3 = {type: 'grab', lx: e.clientX, ly: e.clientY}; rd.style.cursor = 'grabbing';
        return;
      }
    }
    const hit = editable ? pick3(e) : null;
    if (hit){
      S.sel = {type: 'panel', id: hit.id}; selChanged();
      const type = S.tool === 'rot' ? 'free' : (e.shiftKey ? 'depth' : 'move');
      drag3 = {type, p: hit, lx: e.clientX, ly: e.clientY, last: rayPlane(e, hit.pos || [0,0,0]), snapped: false};
      return;
    }
    drag3 = {type: 'orbit', lx: e.clientX, ly: e.clientY}; rd.style.cursor = 'grabbing';
  });
  rd.addEventListener('pointermove', e => {
    if (!drag3){
      const hr = (model && model.preview && gizmo.visible) ? pickRing(e) : -1;
      const ha = (model && model.preview && moveG.visible) ? pickArrow(e) : -1;
      if (hr !== hoverRing || ha !== hoverArrow){
        hoverRing = hr; hoverArrow = ha;
        rings.forEach((r, i) => r.mat.opacity = i === hr ? 1 : 0.7);
        arrows.forEach((a, i) => a.mat.opacity = i === ha ? 1 : 0.75);
        rd.style.cursor = (hr >= 0 || ha >= 0) ? 'pointer' : 'grab';
      }
      return;
    }
    const dx = e.clientX-drag3.lx, dy = e.clientY-drag3.ly; drag3.lx = e.clientX; drag3.ly = e.clientY;
    if (drag3.type === 'orbit'){ az -= dx*0.006; el = Math.max(-1.5, Math.min(1.5, el + dy*0.006)); return; }
    if (drag3.type === 'grab'){ if (model && model.pin){ const q = rayPlane(e, [model.pin.x, model.pin.y, model.pin.z]); if (q){ model.pin.x = q[0]; model.pin.y = q[1]; model.pin.z = q[2]; } } return; }
    if (!drag3.snapped){ snap(); drag3.snapped = true; }
    const p = drag3.p; p.pos = p.pos || [0,0,0]; p.rot = p.rot || QI();
    if (drag3.type === 'move'){
      const q = rayPlane(e, p.pos);
      if (q && drag3.last) p.pos = [p.pos[0]+q[0]-drag3.last[0], p.pos[1]+q[1]-drag3.last[1], p.pos[2]+q[2]-drag3.last[2]];
      drag3.last = rayPlane(e, p.pos);
    } else if (drag3.type === 'axis'){
      const s1 = axisParam(e, drag3.start, drag3.axis);
      if (s1 != null && drag3.s0 != null){ const d = s1 - drag3.s0, A = drag3.axis; p.pos = [drag3.start[0]+A[0]*d, drag3.start[1]+A[1]*d, drag3.start[2]+A[2]*d]; }
    } else if (drag3.type === 'depth'){
      const f = new THREE.Vector3(); camera.getWorldDirection(f); const k = -dy*dist*0.0025;
      p.pos = [p.pos[0]+f.x*k, p.pos[1]+f.y*k, p.pos[2]+f.z*k];
    } else if (drag3.type === 'ring'){
      const A = drag3.axis, f = new THREE.Vector3(); camera.getWorldDirection(f);
      let ang = 0;
      if (Math.abs(f.x*A[0] + f.y*A[1] + f.z*A[2]) > 0.2){   // Ring gut sichtbar: Winkel um die Achse aus der Mausposition
        const v = ringVec(e, p.pos, A), u = drag3.last;
        if (u && v){
          const cx = u[1]*v[2]-u[2]*v[1], cy = u[2]*v[0]-u[0]*v[2], cz = u[0]*v[1]-u[1]*v[0];
          ang = Math.atan2(cx*A[0] + cy*A[1] + cz*A[2], u[0]*v[0] + u[1]*v[1] + u[2]*v[2]);
        }
        drag3.last = v;
      } else ang = (dx - dy)*0.01;                             // Ring fast von der Kante gesehen: Mausweg
      p.rot = qNorm(qMul(qAxis(A[0], A[1], A[2], ang), p.rot));
    } else if (drag3.type === 'free'){
      const up = new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion), rt = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
      p.rot = qNorm(qMul(qAxis(rt.x, rt.y, rt.z, dy*0.01), qMul(qAxis(up.x, up.y, up.z, dx*0.01), p.rot)));
    }
    updatePreviewPanel(p);
  });
  rd.addEventListener('pointerup', e => {
    const wasRing = drag3 && (drag3.type === 'ring' || drag3.type === 'axis');
    if (model) model.pin = null;
    if (drag3 && drag3.snapped) renderSel();
    drag3 = null; rd.style.cursor = 'grab';
    if (down3 && down3.button === 0 && !wasRing && Math.hypot(e.clientX-down3.x, e.clientY-down3.y) < 4) clickMark(e);   // Klick ohne Ziehen
    down3 = null;
  });
  rd.addEventListener('wheel', e => { e.preventDefault(); dist *= Math.exp(e.deltaY*0.001); dist = Math.max(80, Math.min(10000, dist)); }, {passive: false});
}
const selectedPanel = () => S.sel && (S.sel.type === 'panel' || S.sel.type === 'node') ? byId(S.sel.id) : null;
function ndc(e){ const r = renderer.domElement.getBoundingClientRect(); return new THREE.Vector2(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1); }
function pick3(e){
  if (!mesh) return null;
  raycaster.setFromCamera(ndc(e), camera);
  const hits = raycaster.intersectObjects([mesh, backMesh]);
  if (!hits.length) return null;
  return S.panels[model.pid[hits[0].face.a]] || null;
}
function pickRing(e){
  if (!gizmo || !gizmo.visible) return -1;
  raycaster.setFromCamera(ndc(e), camera);
  const hits = raycaster.intersectObjects(rings.map(r => r.pickT));
  return hits.length ? hits[0].object.userData.axis : -1;
}
function pickArrow(e){
  if (!moveG || !moveG.visible) return -1;
  raycaster.setFromCamera(ndc(e), camera);
  const hits = raycaster.intersectObjects(arrows.map(a => a.pick));
  return hits.length ? hits[0].object.userData.axis : -1;
}
function axisParam(e, C, A){   // Parameter s auf der Achse C + s*A, der dem Mausstrahl am nächsten liegt
  raycaster.setFromCamera(ndc(e), camera);
  const O = raycaster.ray.origin, D = raycaster.ray.direction;
  const w = [C[0]-O.x, C[1]-O.y, C[2]-O.z], b = A[0]*D.x + A[1]*D.y + A[2]*D.z;
  const d = A[0]*w[0] + A[1]*w[1] + A[2]*w[2], ee = D.x*w[0] + D.y*w[1] + D.z*w[2], den = 1 - b*b;
  if (Math.abs(den) < 1e-4) return null;
  return (b*ee - d)/den;
}
function rayPlane(e, through){   // Schnittpunkt Mausstrahl mit der Ebene senkrecht zur Blickrichtung
  raycaster.setFromCamera(ndc(e), camera);
  const n = new THREE.Vector3(); camera.getWorldDirection(n);
  const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(n, new THREE.Vector3(through[0], through[1], through[2]));
  const out = new THREE.Vector3();
  return raycaster.ray.intersectPlane(plane, out) ? [out.x, out.y, out.z] : null;
}
function ringVec(e, C, A){   // Vektor vom Drehzentrum zum Mausstrahl in der Ringebene
  raycaster.setFromCamera(ndc(e), camera);
  const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(new THREE.Vector3(A[0],A[1],A[2]), new THREE.Vector3(C[0],C[1],C[2]));
  const out = new THREE.Vector3();
  return raycaster.ray.intersectPlane(plane, out) ? [out.x-C[0], out.y-C[1], out.z-C[2]] : null;
}
// Klick in 3D: Punkt auf der Fläche -> Stelle im Schnittbogen; nahe einer Naht auch die Gegenstelle
function clickMark(e){
  if (!mesh || !model) return;
  raycaster.setFromCamera(ndc(e), camera);
  const hits = raycaster.intersectObjects([mesh, backMesh]);
  if (!hits.length){ mark = null; return; }
  const h = hits[0], fi = h.faceIndex, T = model.tri, P = model.pos;
  const V3 = i => new THREE.Vector3(P[3*i], P[3*i+1], P[3*i+2]);
  const bc = new THREE.Triangle(V3(T[3*fi]), V3(T[3*fi+1]), V3(T[3*fi+2])).getBarycoord(h.point, new THREE.Vector3());
  const L = model.triLocal, o = 6*fi, p = S.panels[model.triPid[fi]];
  if (!p) return;
  const m = {pid: p.id, x: L[o]*bc.x + L[o+2]*bc.y + L[o+4]*bc.z, y: L[o+1]*bc.x + L[o+3]*bc.y + L[o+5]*bc.z, seam: -1, partner: null, face: fi, bc: [bc.x, bc.y, bc.z], model};
  const ne = nearestEdge2D(p, m.x, m.y);
  if (ne.d < Math.max(6, 1.6*model.h)){
    const C = curveOf(p, ne.c), pl = edgePoly(C, ne.k, 32), q = plAt(pl, plLen(pl), ne.f);
    m.x = q[0]; m.y = q[1];
    const si = seamIndexOf(p.id, ne.c, ne.k);
    if (si >= 0){
      const s = S.seams[si], mine = sideIs(s.a, p.id, ne.c, ne.k) ? s.a : s.b, oth = mine === s.a ? s.b : s.a, P2 = byId(oth.p);
      if (P2){
        const pl2 = edgePoly(curveOf(P2, oth.c||0), oth.e, 32), q2 = plAt(pl2, plLen(pl2), s.flip ? ne.f : 1-ne.f);
        m.seam = si; m.partner = {pid: oth.p, x: q2[0], y: q2[1]};
        if (S.mode === 'sew'){ S.sel = {type: 'seam', i: si}; renderSel(); }
      }
    }
  }
  mark = m; ensureVisible(m);
}
function updateMark3(){
  if (!markObj) return;
  const ok = !!(mark && mark.model === model && model && mark.face != null && mark.face*3 < model.tri.length);
  markObj.visible = ok;
  if (!ok) return;
  const T = model.tri, P = model.pos, f = mark.face, b = mark.bc;
  const a = 3*T[3*f], bb = 3*T[3*f+1], cc = 3*T[3*f+2];
  markObj.position.set(P[a]*b[0]+P[bb]*b[1]+P[cc]*b[2], P[a+1]*b[0]+P[bb+1]*b[1]+P[cc+1]*b[2], P[a+2]*b[0]+P[bb+2]*b[1]+P[cc+2]*b[2]);
  markObj.scale.setScalar(Math.max(2.5, model.h*0.6));
}
function updatePreviewPanel(p){
  if (!model || !model.preview) return;
  const r = model.ranges.find(x => x.id === p.id); if (!r) return;
  const xf = panelXform(p);
  for (let i = r.start; i < r.start + r.count; i++){
    const X = xf(model.local[2*i], model.local[2*i+1]);
    model.pos[3*i] = X[0]; model.pos[3*i+1] = X[1]; model.pos[3*i+2] = X[2];
  }
  geo.attributes.position.array.set(model.pos); geo.attributes.position.needsUpdate = true;
  geo.computeVertexNormals();
}
function updateGizmo(){
  const p = selectedPanel();
  const show = !!(gizmo && p && model && model.preview && !S.sim);
  gizmo.visible = show && S.tool === 'rot';
  moveG.visible = show && S.tool === 'move';
  if (!show) return;
  const xf = panelXform(p), P = p.pos || [0,0,0], r = Math.max(20, xf.radius*1.15);
  gizmo.position.set(P[0], P[1], P[2]); gizmo.scale.setScalar(r);
  moveG.position.set(P[0], P[1], P[2]); moveG.scale.setScalar(r);
}
// Naht in 3D hervorheben: gehoverte oder gewählte Naht, im Nähen-Modus
function updateHighlight(){
  if (!hlLines) return;
  let key = '', segs = [], col = '#111111';
  if (S.mode === 'sew' && model){
    let si = -1, single = null;
    if (hoverEdge){ si = seamIndexOf(hoverEdge.p.id, hoverEdge.c, hoverEdge.k); if (si < 0) single = {p: hoverEdge.p.id, c: hoverEdge.c, e: hoverEdge.k}; }
    if (si < 0 && !single && pending) single = pending;
    if (si < 0 && !single && S.sel && S.sel.type === 'seam') si = S.sel.i;
    if (si >= 0 && S.seams[si]){
      const s = S.seams[si];
      key = 's' + si + (s.flip ? 'f' : '');
      col = seamColor(si);
      for (const sd of [s.a, s.b]){ const L = model.edgeIdx.get(ek(sd.p, sd.c, sd.e)); if (L) for (let j = 0; j+1 < L.length; j++) segs.push(L[j], L[j+1]); }
      if (model.preview && model.seamPairs[si]) segs.push(...model.seamPairs[si]);
    } else if (single){
      key = 'e' + ek(single.p, single.c, single.e);
      const L = model.edgeIdx.get(ek(single.p, single.c, single.e)); if (L) for (let j = 0; j+1 < L.length; j++) segs.push(L[j], L[j+1]);
    }
  }
  key += model ? '|' + model.n + '|' + model.preview : '';
  if (key === hlKey) return;
  hlKey = key;
  const idx = new THREE.BufferAttribute(Uint32Array.from(segs), 1);
  hlLines.geometry.setIndex(idx); hlPts.geometry.setIndex(idx);
  hlLines.material.color.set(col); hlPts.material.color.set(col);
  hlLines.visible = hlPts.visible = segs.length > 0;
}
function camUpdate(){
  camera.position.set(tgt[0] + dist*Math.cos(el)*Math.sin(az), tgt[1] + dist*Math.sin(el), tgt[2] + dist*Math.cos(el)*Math.cos(az));
  camera.lookAt(tgt[0], tgt[1], tgt[2]);
}
function clearModel(){
  model = null; setDirty(false); hlKey = '';
  if (!scene) return;
  for (const o of [mesh, backMesh, wire, seam, conn, hlLines, hlPts]) if (o){ scene.remove(o); if (o !== mesh && o !== backMesh && o.material) o.material.dispose(); if (o.geometry && o !== mesh && o !== backMesh && o !== wire) o.geometry.dispose(); }
  if (geo) geo.dispose();
  mesh = backMesh = wire = seam = conn = geo = hlLines = hlPts = null;
}
const hex2rgb = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255];
function paintColors(){   // Pastellfarbe je Teil; ausgewähltes Teil in der Startlage etwas dunkler
  if (!model || !geo) return;
  const cols = S.panels.map(p => hex2rgb(p.col || '#dddddd'));
  const selId = S.sel && (S.sel.type === 'panel' || S.sel.type === 'node') ? S.sel.id : null;
  for (let i = 0; i < model.n; i++){
    const pi = model.pid[i], c = cols[pi] || [0.85,0.85,0.85];
    const k = (model.preview && S.panels[pi] && S.panels[pi].id === selId) ? 0.82 : 1;
    colArr[3*i] = c[0]*k; colArr[3*i+1] = c[1]*k; colArr[3*i+2] = c[2]*k;
  }
  geo.attributes.color.needsUpdate = true;
}
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));
  geo.computeVertexNormals();
  mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; scene.add(mesh);
  backMesh = new THREE.Mesh(geo, backMat); backMesh.frustumCulled = false; scene.add(backMesh);
  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.lines, 1));
  seam = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({color: 0x111111, transparent: true, opacity: 0.55})); seam.frustumCulled = false; scene.add(seam);
  if (m.preview && m.pairs.length){
    const cg = new THREE.BufferGeometry(), sub = [];
    for (let i = 0; i < m.pairs.length; i += 2) if ((i/2) % 3 === 0) sub.push(m.pairs[i], m.pairs[i+1]);
    cg.setAttribute('position', geo.attributes.position);
    cg.setIndex(new THREE.BufferAttribute(Uint32Array.from(sub), 1));
    conn = new THREE.LineSegments(cg, new THREE.LineBasicMaterial({color: 0xff2d95, transparent: true, opacity: 0.6})); conn.frustumCulled = false; scene.add(conn);
  }
  // Hervorhebung einer Naht (Linien + Punkte, immer sichtbar)
  const hg = new THREE.BufferGeometry(); hg.setAttribute('position', geo.attributes.position); hg.setIndex(new THREE.BufferAttribute(new Uint32Array(0), 1));
  hlLines = new THREE.LineSegments(hg, new THREE.LineBasicMaterial({color: 0x111111, depthTest: false}));
  const pg = new THREE.BufferGeometry(); pg.setAttribute('position', geo.attributes.position); pg.setIndex(new THREE.BufferAttribute(new Uint32Array(0), 1));
  hlPts = new THREE.Points(pg, new THREE.PointsMaterial({color: 0x111111, size: 6, sizeAttenuation: false, depthTest: false}));
  for (const o of [hlLines, hlPts]){ o.renderOrder = 900; o.frustumCulled = false; o.visible = false; scene.add(o); }
  hlKey = '';
  m.anchor = m.anchor || m.center.slice();
  if (!fitted){ dist = m.size*2.8; tgt[0] = m.center[0]; tgt[1] = m.center[1]; tgt[2] = m.center[2]; floor.position.y = m.minY - 0.35*m.size; fitted = true; }
  paintColors(); applyMat(); setDirty(false);
}
let bt = 0;
function scheduleBuild(d){ clearTimeout(bt); bt = setTimeout(() => { if (!S.sim) build(false); }, d); }
function build(merge){
  if (!renderer) return;
  if (!S.panels.length){ clearModel(); return; }
  for (const p of S.panels){ const err = checkPanel(p); if (err){ msg(err + ' Bitte einen Knoten verschieben.', true); return; } }
  const m = buildModel(S.panels, S.seams, S.N, merge);
  install(m);
  if (merge) msg(m.open ? m.open + (m.open === 1 ? ' Kante ist' : ' Kanten sind') + ' offen, die Hülle ist nicht dicht.' : '', m.open > 0);
  else msg('');
}
function openMsg(m){ msg(m.open ? m.open + (m.open === 1 ? ' Kante ist' : ' Kanten sind') + ' offen, die Hülle ist nicht dicht.' : '', m.open > 0); }
// Simulation starten: erst sanft vernähen (Teile getrennt, Nähte ziehen sich zusammen), dann verschmelzen und aufpumpen
function startSim(){
  if (!renderer) return;
  if (!S.panels.length){ clearModel(); return; }
  for (const p of S.panels){ const err = checkPanel(p); if (err){ msg(err + ' Bitte einen Knoten verschieben.', true); return; } }
  if (S.soft && S.seams.length){
    const U = buildModel(S.panels, S.seams, S.N, false);
    U.preview = false; U.zip = {frame: 0};
    install(U); msg('Nähte werden zusammengezogen …');
  } else { const M = buildModel(S.panels, S.seams, S.N, true); install(M); openMsg(M); }
}
function zipStep(m){
  const z = m.zip; z.frame++;
  const s = Math.min(1, z.frame/70);
  stepSim(m, S.pressure*1e-3*0.12*s, S.Et, Math.max(S.bend, 0.02));
  const P = m.pos, pr = m.pairs; let gap = 0;
  for (let it = 0; it < 2; it++) for (let i = 0; i < pr.length; i += 2){
    const a = 3*pr[i], b = 3*pr[i+1], dx = P[b]-P[a], dy = P[b+1]-P[a+1], dz = P[b+2]-P[a+2], k = 0.25*s;
    P[a]+=dx*k; P[a+1]+=dy*k; P[a+2]+=dz*k; P[b]-=dx*k; P[b+1]-=dy*k; P[b+2]-=dz*k;
    if (it === 1){ const d = Math.hypot(dx,dy,dz); if (d > gap) gap = d; }
  }
  z.gap = gap;
  if ((gap < 0.35*m.h && z.frame > 15) || z.frame > 260) finishZip(m);
}
function finishZip(U){
  const M = buildModel(S.panels, S.seams, S.N, true);
  if (M.map && M.map.length === U.n){
    const cnt = new Float64Array(M.n); M.pos.fill(0);
    for (let i = 0; i < U.n; i++){ const j = M.map[i]; M.pos[3*j]+=U.pos[3*i]; M.pos[3*j+1]+=U.pos[3*i+1]; M.pos[3*j+2]+=U.pos[3*i+2]; cnt[j]++; }
    for (let j = 0; j < M.n; j++) if (cnt[j]){ M.pos[3*j]/=cnt[j]; M.pos[3*j+1]/=cnt[j]; M.pos[3*j+2]/=cnt[j]; }
  }
  M.anchor = U.anchor;
  const keep = Pcur; install(M); Pcur = keep; openMsg(M);
}
function rebuild(){ if (S.sim) startSim(); else build(false); }
function setRunUI(){
  const b = $('#bStart');
  b.classList.toggle('run', S.run === 'run'); b.classList.toggle('pause', S.run === 'pause');
  $('#startTxt').textContent = S.run === 'run' ? 'PAUSE' : (S.run === 'pause' ? 'WEITER' : 'START');
  $('#bRestart').style.display = $('#bStop').style.display = S.run === 'stop' ? 'none' : '';
  $('#gTools').style.display = S.run === 'stop' ? 'flex' : 'none';
  S.sim = S.run !== 'stop';
  updateHint();
}
function setRun(r){
  const before = S.run; S.run = r; setRunUI();
  if (r === 'run' && before === 'stop') startSim();
  else if (r === 'stop'){ if (model) model.pin = null; build(false); }
}
$('#bStart').onclick = () => setRun(S.run === 'run' ? 'pause' : 'run');
$('#bRestart').onclick = () => { S.run = 'run'; setRunUI(); startSim(); };
$('#bStop').onclick = () => setRun('stop');
function setTool3(t){
  S.tool = t;
  $('#gMove').classList.toggle('on', t === 'move'); $('#gRot').classList.toggle('on', t === 'rot');
  updateHint();
}
$('#gMove').onclick = () => setTool3('move');
$('#gRot').onclick = () => setTool3('rot');

/* ---------- Werte ---------- */
const fmt = (v, d) => v.toLocaleString('de-DE', {minimumFractionDigits: d, maximumFractionDigits: d});
function updateStats(){
  if (!model){ $('#st').textContent = ''; return; }
  const base = model.nPanels + ' Teile, ' + model.nSeams + ' Nähte' + (model.open ? ', ' + model.open + ' offene Kanten' : '') + '. Zuschnitt ' + fmt(model.Atot/100, 0) + ' cm². Netz ' + fmt(model.h, 1) + ' mm Kante.';
  if (model.preview){ $('#st').textContent = 'Startlage vor dem Vernähen, pinke Linien verbinden Nahtpartner. ' + base; return; }
  if (model.zip){ $('#st').textContent = 'Vernähen: größter Nahtabstand ' + fmt(model.zip.gap || 0, 0) + ' mm. ' + base; return; }
  const a = analyse(model);
  $('#st').textContent =
    'Volumen ' + fmt(a.vol/1e6, 1) + ' l. Größe ' + fmt(a.w, 0) + ' × ' + fmt(a.h, 0) + ' × ' + fmt(a.d, 0) + ' mm. ' +
    'Dehnung Ø ' + fmt(a.mean*100, 1) + ' %, max ' + fmt(a.max*100, 1) + ' %. ' + base;
  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.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;
  const bv = +$('#bd').value; S.bend = bv === 0 ? 0 : 0.002*Math.pow(100, bv/100); $('#bdv').textContent = bv + ' %';
  applyMat();
}
for (const id of ['#pr', '#et', '#gl', '#me', '#rs', '#bd']) $(id).addEventListener('input', readSliders);
$('#ck3').addEventListener('change', e => { S.soft = e.target.checked; });
$('#bRecolor').addEventListener('click', () => { snap(); S.panels.forEach((p, i) => { p.col = pastel(i+1); }); renderSel(); paintColors(); });
$('#rs').addEventListener('input', () => { if (model && S.sim) setDirty(true); else if (!S.sim) scheduleBuild(200); });
$('#ck1').addEventListener('change', e => { S.strain = e.target.checked; if (!S.strain) paintColors(); updateStats(); });
$('#ck2').addEventListener('change', e => { S.wire = e.target.checked; if (wire) wire.visible = S.wire; });
readSliders();

/* ---------- Größe & Loop ---------- */
let sized = false;
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 (!sized && cw > 10){ sized = true; fitView(); }
  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 && !model.preview && S.run === 'run'){
    if (model.zip) zipStep(model);
    else {
      Pcur += (S.pressure - Pcur)*0.03; if (Math.abs(S.pressure - Pcur) < 1e-3) Pcur = S.pressure;
      stepSim(model, Pcur*1e-3, S.Et, S.bend);
    }
    recenter(model, 0.06, 0.6);
    geo.attributes.position.array.set(model.pos); geo.attributes.position.needsUpdate = true;
    geo.computeVertexNormals();
  }
  if (model && frame % 8 === 0) updateStats();
  draw2();
  if (renderer){ updateGizmo(); updateHighlight(); updateMark3(); camUpdate(); renderer.render(scene, camera); }
  frame++;
}
resize();
{ const o = makePreset('kissen'); S.panels = o.panels; S.seams = o.seams; S.nextId = o.nextId; }
fitView(); setMode('draw'); setRun('run');
loop();
</script>
</body>
</html>
script.js
console.log('läuft');
style.css
body {
  font-family: system-ui, sans-serif;
  margin: 2rem;
}