blob_creature_designer

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/61562f2c4dd0c540/" sandbox="allow-scripts allow-forms allow-modals" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BLOBBER v1.0</title>
<style>
  :root{
    --ink:#111214; --ink-60:#6b6e73; --ink-30:#a7abb0;
    --line:#e6e7e9; --line-2:#f1f2f3;
    --acc:#0b6b62; --acc-soft:#e6f0ef;
    --side:296px;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0}
  body{
    background:#fff;color:var(--ink);
    font:400 13px/1.45 ui-sans-serif,-apple-system,"Segoe UI",Inter,Helvetica,Arial,sans-serif;
    display:flex;overflow:hidden;-webkit-font-smoothing:antialiased;
  }
  aside{width:var(--side);flex:0 0 var(--side);height:100%;display:flex;flex-direction:column;background:#fff}
  aside.left{border-right:1px solid var(--line)}
  aside.right{border-left:1px solid var(--line)}
  header{padding:16px 18px 12px;border-bottom:1px solid var(--line)}
  h1{margin:0;font-size:14px;font-weight:600;letter-spacing:.14em;text-transform:uppercase}
  header p{margin:4px 0 0;font-size:11.5px;color:var(--ink-60)}
  .scroll{overflow-y:auto;flex:1;padding-bottom:14px;scrollbar-width:thin}
  section{border-bottom:1px solid var(--line-2);padding:13px 18px 15px}
  section[hidden]{display:none}
  .eyebrow{font:500 10px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
    letter-spacing:.18em;text-transform:uppercase;color:var(--ink-30);
    display:flex;justify-content:space-between;align-items:baseline;margin-bottom:11px}
  .eyebrow b{font-weight:500;color:var(--acc)}
  .row{display:flex;align-items:center;gap:10px;margin:8px 0}
  .row label{flex:1;font-size:12px;color:var(--ink-60)}
  .row output{font:400 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
    color:var(--ink);min-width:38px;text-align:right}
  input[type=range]{-webkit-appearance:none;appearance:none;width:104px;height:16px;background:transparent;margin:0;cursor:ew-resize}
  input[type=range]::-webkit-slider-runnable-track{height:1px;background:var(--line)}
  input[type=range]::-moz-range-track{height:1px;background:var(--line)}
  input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:9px;height:9px;border-radius:50%;background:var(--ink);margin-top:-4px}
  input[type=range]::-moz-range-thumb{width:9px;height:9px;border:0;border-radius:50%;background:var(--ink)}
  input[type=range]:focus-visible{outline:2px solid var(--acc);outline-offset:3px}
  button{font:inherit;font-size:12px;background:#fff;color:var(--ink);border:1px solid var(--line);
    border-radius:2px;padding:7px 9px;cursor:pointer;transition:border-color .12s,background .12s}
  button:hover{border-color:var(--ink-30)}
  button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
  button[disabled]{opacity:.4;cursor:default}
  button[disabled]:hover{border-color:var(--line)}
  .btn-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px}
  .btn-grid.three{grid-template-columns:1fr 1fr 1fr}
  .seg{display:flex;border:1px solid var(--line);border-radius:2px;overflow:hidden}
  .seg button{border:0;border-radius:0;flex:1;padding:7px 4px;color:var(--ink-60)}
  .seg button+button{border-left:1px solid var(--line)}
  .seg button[aria-pressed="true"]{background:var(--acc-soft);color:var(--acc);font-weight:500}
  .checks{display:grid;grid-template-columns:1fr 1fr;gap:4px 10px}
  .check{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--ink-60);cursor:pointer}
  .check input{accent-color:var(--acc);margin:0}
  .note{font-size:11.5px;color:var(--ink-30);margin:9px 0 0;line-height:1.5}
  .swatches{display:flex;gap:6px;margin:12px 0 4px}
  .swatch{flex:1}
  .swatch input[type=color]{-webkit-appearance:none;appearance:none;width:100%;height:32px;padding:0;
    border:1px solid var(--line);border-radius:2px;background:none;cursor:pointer}
  .swatch input[type=color]::-webkit-color-swatch-wrapper{padding:2px}
  .swatch input[type=color]::-webkit-color-swatch{border:0;border-radius:1px}
  .swatch input[type=color]::-moz-color-swatch{border:0;border-radius:1px}
  .scale-label{display:flex;justify-content:space-between;
    font:400 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.1em;text-transform:uppercase;
    color:var(--ink-30);margin-top:2px}
  #thickCanvas{width:100%;height:96px;display:block;border:1px solid var(--line);
    border-radius:2px;cursor:crosshair;touch-action:none}
  .tabs{display:flex;border-bottom:1px solid var(--line)}
  .tabs button{flex:1;border:0;border-radius:0;padding:12px 4px;color:var(--ink-30);
    font:500 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.14em;text-transform:uppercase}
  .tabs button+button{border-left:1px solid var(--line-2)}
  .tabs button[aria-pressed="true"]{color:var(--acc);box-shadow:inset 0 -1px 0 var(--acc)}
  main{flex:1;position:relative;min-width:0}
  canvas{display:block}
  #stage canvas{touch-action:none}
  .toolbar{position:absolute;left:50%;top:16px;transform:translateX(-50%);
    display:flex;gap:8px;pointer-events:none;z-index:3}
  .pill{pointer-events:auto;background:rgba(255,255,255,.9);backdrop-filter:blur(8px);
    border:1px solid var(--line);border-radius:2px;display:flex}
  .pill button{border:0;border-radius:0;background:transparent;padding:8px 12px;color:var(--ink-60);font-size:12px;white-space:nowrap}
  .pill button+button{border-left:1px solid var(--line)}
  .pill button[aria-pressed="true"]{background:var(--ink);color:#fff}
  .pill button.on{color:var(--acc);font-weight:500}
  .stat{position:absolute;left:20px;bottom:18px;
    font:400 11px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--ink-30);pointer-events:none}
  .stat b{font-weight:400;color:var(--ink-60)}
  .hint{position:absolute;right:20px;bottom:18px;max-width:290px;text-align:right;
    font-size:11.5px;color:var(--ink-30);pointer-events:none;line-height:1.5}
  .busy{position:absolute;inset:0;background:rgba(255,255,255,.72);display:none;
    align-items:center;justify-content:center;
    font:500 11px/1 ui-monospace,Menlo,monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--acc);z-index:4}
  .busy[data-on="1"]{display:flex}
  #sheetView{position:absolute;inset:0;background:#fff;z-index:5;display:flex;flex-direction:column}
  #sheetView[hidden]{display:none}
  .sheetTop{display:flex;align-items:center;gap:14px;padding:12px 18px;border-bottom:1px solid var(--line)}
  .sheetTop span{font:400 11.5px/1.4 ui-monospace,Menlo,monospace;color:var(--ink-60);flex:1}
  .sheetBody{flex:1;overflow:auto;padding:24px;background:#fafafa}
  .sheetBody svg{background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);display:block;margin:0 auto}
  @media (max-width:1100px){
    :root{--side:250px}
    .pill button{padding:8px 8px}
  }
  @media (max-width:860px){
    body{flex-wrap:wrap}
    aside{width:50%;flex:0 0 50%;height:44%;order:2}
    main{flex:0 0 100%;height:56%;order:1}
  }
  @media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>

<aside class="left">
  <header>
    <h1>Blobber <span style="font-weight:400;letter-spacing:.06em;color:var(--ink-30)">v1.0</span></h1>
    <p>Kreatur aufbauen · <span id="buildId" style="font-family:ui-monospace,Menlo,monospace;font-size:10.5px">build 2026-08-23.1</span></p>
  </header>
  <div class="scroll">

    <section>
      <div class="eyebrow"><span>Zentrum &amp; Symmetrie</span></div>
      <div class="row"><label for="sCenter">Rumpfradius</label><input type="range" id="sCenter" min="0.15" max="1.2" step="0.01" value="0.55"><output id="oCenter"></output></div>
      <div class="seg" role="group" aria-label="Symmetrie beim Erstellen">
        <button id="mirOff" aria-pressed="false">Asymmetrisch</button>
        <button id="mirX" aria-pressed="true">Spiegel X</button>
        <button id="mirRad" aria-pressed="false">Radial</button>
      </div>
      <div class="row"><label for="sRadial">Radiale Kopien</label><input type="range" id="sRadial" min="2" max="12" step="1" value="5"><output id="oRadial"></output></div>
    </section>

    <section id="secCurve">
      <div class="eyebrow"><span>Kurven</span><b id="limbCount">0</b></div>
      <div class="row"><label for="sCtrl">Kontrollpunkte</label><input type="range" id="sCtrl" min="2" max="10" step="1" value="4"><output id="oCtrl"></output></div>
      <div class="row"><label for="sTens">Kurvenspannung</label><input type="range" id="sTens" min="0" max="1" step="0.02" value="0.5"><output id="oTens"></output></div>
      <div class="row"><label for="sSeg">Knochenabstand</label><input type="range" id="sSeg" min="0.06" max="0.45" step="0.005" value="0.18"><output id="oSeg"></output></div>
      <div class="row"><label for="sJit">Tiefen-Versatz</label><input type="range" id="sJit" min="0" max="0.6" step="0.01" value="0.12"><output id="oJit"></output></div>
      <button id="bClear" style="width:100%">Kurven löschen</button>
    </section>

    <section id="secBlob" hidden>
      <div class="eyebrow"><span>Blobs</span><b id="blobCount">0</b></div>
      <div class="row"><label for="sBlobR">Neuer Radius</label><input type="range" id="sBlobR" min="0.04" max="0.6" step="0.005" value="0.16"><output id="oBlobR"></output></div>
      <div class="row"><label for="sLinkR">Verbindungsdicke</label><input type="range" id="sLinkR" min="0.1" max="1" step="0.02" value="0.6"><output id="oLinkR"></output></div>
      <button id="bBlobClear" style="width:100%">Blobs löschen</button>
      <p class="note">Klick ins Leere setzt einen Blob. Die Blobs verbinden sich automatisch mit dem jeweils nächstgelegenen Nachbarn zu einer Armatur.</p>
    </section>

    <section>
      <div class="eyebrow"><span>Auswahl</span></div>
      <div class="row"><label for="sLScale">Dicke ×</label><input type="range" id="sLScale" min="0.2" max="3" step="0.02" value="1" disabled><output id="oLScale"></output></div>
      <div class="row"><label for="sLBones">Knochen</label><input type="range" id="sLBones" min="0" max="40" step="1" value="0" disabled><output id="oLBones"></output></div>
      <div class="btn-grid">
        <button id="bDel" disabled>Löschen</button>
        <button id="bStraight" disabled>Punkte glätten</button>
      </div>
      <p class="note" id="selNote">Nichts ausgewählt.</p>
    </section>

    <section>
      <div class="eyebrow"><span>Dickenverlauf</span><b id="thickInfo">3 Punkte</b></div>
      <canvas id="thickCanvas"></canvas>
      <div class="scale-label"><span>Ansatz</span><span>Spitze</span></div>
      <div class="row"><label for="sThick">Dicke gesamt</label><input type="range" id="sThick" min="0.03" max="0.6" step="0.005" value="0.26"><output id="oThick"></output></div>
      <p class="note">Ziehen verschiebt, Klick setzt einen Punkt, Doppelklick entfernt ihn.</p>
    </section>

  </div>
</aside>

<main id="stage">
  <div class="toolbar">
    <div class="pill" role="group" aria-label="Wiedergabe">
      <button id="bPlay" class="on">Anhalten</button>
      <button id="bRest">Ruhepose</button>
    </div>
    <div class="pill" role="group" aria-label="Bearbeiten">
      <button id="bUndoAll" disabled>Rückgängig</button>
    </div>
    <div class="pill" role="group" aria-label="Mesh">
      <button id="bBuild">Mesh neu</button>
      <button id="bDrop">Mesh löschen</button>
    </div>
    <div class="pill" role="group" aria-label="Modus">
      <button id="tCurve" aria-pressed="true">Kurven</button>
      <button id="tBlob" aria-pressed="false">Blobs</button>
      <button id="tInteract" aria-pressed="false">Interaktion</button>
    </div>
  </div>
  <div class="stat" id="stat"></div>
  <div class="hint" id="hint"></div>
  <div class="busy" id="busy">rechnet</div>
  <div id="sheetView" hidden>
    <div class="sheetTop">
      <span id="sheetInfo"></span>
      <button id="sheetSave">Als Druckbogen sichern</button>
      <button id="sheetClose">Schließen</button>
    </div>
    <div class="sheetBody" id="sheetBody"></div>
  </div>
</main>

<aside class="right">
  <div class="tabs" role="group" aria-label="Bereiche">
    <button id="tabMesh" aria-pressed="true">I · Mesh</button>
    <button id="tabAnim" aria-pressed="false">II · Bewegung</button>
    <button id="tabCut" aria-pressed="false">III · Schnitt</button>
    <button id="tabOut" aria-pressed="false">IV · Export</button>
  </div>
  <div class="scroll">

    <div id="panMesh">
      <section>
        <div class="eyebrow"><span>Mesh</span><b id="meshInfo">—</b></div>
        <div class="row"><label for="sRes">Auflösung</label><input type="range" id="sRes" min="32" max="128" step="8" value="72"><output id="oRes"></output></div>
        <div class="row"><label for="sK">Verschmelzung</label><input type="range" id="sK" min="0" max="0.4" step="0.005" value="0.12"><output id="oK"></output></div>
        <div class="row"><label for="sSharp">Bindungsschärfe</label><input type="range" id="sSharp" min="1" max="6" step="0.1" value="3"><output id="oSharp"></output></div>
        <div class="row"><label for="sSmooth">Gewichte glätten</label><input type="range" id="sSmooth" min="0" max="6" step="1" value="2"><output id="oSmooth"></output></div>
        <div class="row"><label for="sUV">UV-Dichte</label><input type="range" id="sUV" min="0.2" max="4" step="0.05" value="1"><output id="oUV"></output></div>
        <button id="bRebuild" style="width:100%;margin-top:6px">Neu berechnen</button>
        <p class="note" id="buildNote">Erzeugt ein geschlossenes Mesh und bindet es an die Knochen.</p>
      </section>

      <section>
        <div class="eyebrow"><span>Farbe</span></div>
        <div class="swatches">
          <span class="swatch" style="flex:0 0 56px"><input type="color" id="p0" value="#2f6f6a" aria-label="Grundfarbe"></span>
          <span style="flex:1;align-self:center;font-size:12px;color:var(--ink-60)">Grundfarbe des Mesh</span>
        </div>
      </section>
      <section>
        <div class="eyebrow"><span>Material</span></div>
        <div class="row"><label for="sGloss">Glanz</label><input type="range" id="sGloss" min="0" max="1" step="0.01" value="0.62"><output id="oGloss"></output></div>
        <div class="row"><label for="sCoat">Klarlack</label><input type="range" id="sCoat" min="0" max="1" step="0.01" value="0.45"><output id="oCoat"></output></div>
        <div class="btn-grid" style="margin-top:12px">
          <button id="bTex">Textur laden</button>
          <button id="bTexOff" disabled>Textur entfernen</button>
        </div>
        <input type="file" id="texIn" accept="image/*" hidden>
        <div class="row"><label for="sTile">Kacheln</label><input type="range" id="sTile" min="0.25" max="8" step="0.05" value="1"><output id="oTile"></output></div>
        <div class="row"><label for="sTexMix">Mischung</label><input type="range" id="sTexMix" min="0" max="1" step="0.01" value="1"><output id="oTexMix"></output></div>
        <p class="note" id="texNote">Quadratische Bilder funktionieren am besten. Die Kachelung spiegelt in U und V.</p>
      </section>

      <section>
        <div class="eyebrow"><span>Ansicht</span></div>
        <div class="checks">
          <label class="check"><input type="checkbox" id="cSketch" checked> Skizze</label>
          <label class="check"><input type="checkbox" id="cBones"> Skelett</label>
          <label class="check"><input type="checkbox" id="cWire"> Drahtgitter</label>
          <label class="check"><input type="checkbox" id="cGrid" checked> Raster</label>
          <label class="check"><input type="checkbox" id="cMesh" checked> Mesh</label>
          <label class="check"><input type="checkbox" id="cUV"> UV-Prüfung</label>
        </div>
      </section>
    </div>

    <div id="panAnim" hidden>
      <section>
        <div class="eyebrow"><span>Symmetrie der Bewegung</span></div>
        <div class="seg" role="group" aria-label="Symmetrie der Bewegung">
          <button id="asOff" aria-pressed="false">Frei</button>
          <button id="asX" aria-pressed="true">Spiegel X</button>
          <button id="asRad" aria-pressed="false">Radial</button>
        </div>
      </section>
      <section>
        <div class="eyebrow"><span>Strömung</span></div>
        <div class="row"><label for="sAmt">Strömungsstärke</label><input type="range" id="sAmt" min="0" max="40" step="0.5" value="6"><output id="oAmt"></output></div>
        <div class="row"><label for="sFreq">Feldfrequenz</label><input type="range" id="sFreq" min="0.1" max="3" step="0.02" value="0.85"><output id="oFreq"></output></div>
        <div class="row"><label for="sSpeed">Tempo</label><input type="range" id="sSpeed" min="0" max="1.5" step="0.01" value="0.35"><output id="oSpeed"></output></div>
        <div class="row"><label for="sWave">Böigkeit</label><input type="range" id="sWave" min="0" max="1" step="0.02" value="0.4"><output id="oWave"></output></div>
        <div class="row"><label for="sWind">Grundströmung</label><input type="range" id="sWind" min="0" max="3" step="0.02" value="0.35"><output id="oWind"></output></div>
        <div class="row"><label for="sWindDir">Richtung °</label><input type="range" id="sWindDir" min="0" max="360" step="1" value="30"><output id="oWindDir"></output></div>
        <div class="row"><label for="sLift">Auftrieb Y</label><input type="range" id="sLift" min="-1" max="1" step="0.02" value="0.1"><output id="oLift"></output></div>
      </section>
      <section>
        <div class="eyebrow"><span>Verhalten</span></div>
        <div class="row"><label for="sSpring">Steifigkeit</label><input type="range" id="sSpring" min="1" max="80" step="0.5" value="18"><output id="oSpring"></output></div>
        <div class="row"><label for="sStiff">Verlauf Ansatz→Spitze</label><input type="range" id="sStiff" min="0" max="3" step="0.05" value="1"><output id="oStiff"></output></div>
        <div class="row"><label for="sDamp">Dämpfung</label><input type="range" id="sDamp" min="0.2" max="12" step="0.1" value="3"><output id="oDamp"></output></div>
        <div class="row"><label for="sInert">Trägheit</label><input type="range" id="sInert" min="0" max="1" step="0.01" value="0.5"><output id="oInert"></output></div>
        <div class="row"><label for="sBend">Beugungsgrenze °</label><input type="range" id="sBend" min="5" max="150" step="1" value="70"><output id="oBend"></output></div>
        <div class="row"><label for="sBody">Körperwippen</label><input type="range" id="sBody" min="0" max="1" step="0.01" value="0.25"><output id="oBody"></output></div>
        <div class="row"><label for="sDrag">Zieh-Widerstand</label><input type="range" id="sDrag" min="0" max="30" step="0.5" value="10"><output id="oDrag"></output></div>
        <p class="note">Zieh-Widerstand bestimmt, wie stark die Gliedmaßen nachschleppen, wenn du die Kreatur im Interaktionsmodus durch den Raum bewegst.</p>
      </section>
    </div>

    <div id="panCut" hidden>
      <section>
        <div class="eyebrow"><span>Abwicklung</span><b id="cutInfo">—</b></div>
        <div class="row"><label for="sGore">Bahnen je Strang</label><input type="range" id="sGore" min="1" max="6" step="1" value="2"><output id="oGore"></output></div>
        <div class="row"><label for="sBodyGore">Bahnen Rumpf</label><input type="range" id="sBodyGore" min="2" max="8" step="1" value="4"><output id="oBodyGore"></output></div>
        <div class="row"><label for="sHeight">Zielhöhe cm</label><input type="range" id="sHeight" min="10" max="120" step="1" value="40"><output id="oHeight"></output></div>
        <div class="row"><label for="sSeam">Saumzugabe mm</label><input type="range" id="sSeam" min="0" max="25" step="0.5" value="5"><output id="oSeam"></output></div>
        <div class="row"><label for="sStretch">Dehnung %</label><input type="range" id="sStretch" min="0" max="30" step="1" value="0"><output id="oStretch"></output></div>
        <div class="row"><label for="sNotch">Kerbenabstand cm</label><input type="range" id="sNotch" min="2" max="15" step="0.5" value="5"><output id="oNotch"></output></div>
        <div class="seg" role="group" aria-label="Seitenformat">
          <button id="pgA4" aria-pressed="true">A4</button>
          <button id="pgA3" aria-pressed="false">A3</button>
        </div>
        <label class="check" style="margin:10px 0 6px"><input type="checkbox" id="cPatTex"> Textur auf den Bogen legen</label>
        <div class="row"><label for="sPatTexA">Deckkraft</label><input type="range" id="sPatTexA" min="0.1" max="1" step="0.05" value="1"><output id="oPatTexA"></output></div>
        <div class="btn-grid">
          <button id="bCutPrev">Vorschau</button>
          <button id="bCutSave">Bogen sichern</button>
        </div>
        <div id="cutPreview" style="margin-top:12px;border:1px solid var(--line);border-radius:2px;padding:5px;background:#fff;min-height:50px;overflow:hidden"></div>
        <p class="note" id="cutNote">1 Bahn = eine Längsnaht, wenigste Teile. Mehr Bahnen = rundere Form. Der Rumpf wird als Kugel genähert, die Ansätze sind als Öffnungen markiert.</p>
      </section>
    </div>

    <div id="panOut" hidden>
      <section>
        <div class="eyebrow"><span>Export</span></div>
        <label class="check" style="margin-bottom:10px"><input type="checkbox" id="cPose"> FBX in aktueller Pose</label>
        <div class="btn-grid three">
          <button id="bFbx">FBX</button>
          <button id="bObj">OBJ</button>
          <button id="bRig">Rig JSON</button>
        </div>
        <div class="row"><label for="sExpCm">Exportgröße cm</label><input type="range" id="sExpCm" min="5" max="400" step="5" value="100"><output id="oExpCm"></output></div>
        <p class="note" id="expNote">Binäres FBX 7.4 mit Mesh, Armature, Häutungsgewichten, UVs und Vertexfarben. In Unity: Rig auf „Generic“ stellen, Scale Factor 0,01 belassen — 100 cm ergeben dann 1 Einheit.</p>
      </section>
      <section>
        <div class="eyebrow"><span>Szene</span></div>
        <div class="btn-grid">
          <button id="bScene">Sichern</button>
          <button id="bLoad">Laden</button>
        </div>
        <input type="file" id="fileIn" accept="application/json" hidden>
        <p class="note">Sichert Kurven, Blobs, Dickenverlauf und alle Regler als JSON.</p>
      </section>
    </div>

  </div>
</aside>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
"use strict";
const BUILD="2026-08-23.1";
console.log("BLOBBER v1.0 — build "+BUILD);

/* ============================================================
   Perlin 3D
   ============================================================ */
const perm = new Uint8Array(512);
(function(){
  const p = Array.from({length:256}, (_,i)=>i);
  let s = 20260821;
  const rnd = ()=>{ s^=s<<13; s^=s>>>17; s^=s<<5; return (s>>>0)/4294967296; };
  for(let i=255;i>0;i--){ const j=Math.floor(rnd()*(i+1)); const t=p[i]; p[i]=p[j]; p[j]=t; }
  for(let i=0;i<512;i++) perm[i]=p[i&255];
})();
const fade = t => t*t*t*(t*(t*6-15)+10);
const lerp = (a,b,t) => a+(b-a)*t;
function grad(h,x,y,z){ h&=15; const u=h<8?x:y; const v=h<4?y:(h===12||h===14?x:z); return ((h&1)?-u:u)+((h&2)?-v:v); }
function noise3(x,y,z){
  const X=Math.floor(x)&255, Y=Math.floor(y)&255, Z=Math.floor(z)&255;
  x-=Math.floor(x); y-=Math.floor(y); z-=Math.floor(z);
  const u=fade(x), v=fade(y), w=fade(z);
  const A=perm[X]+Y, AA=perm[A]+Z, AB=perm[A+1]+Z;
  const B=perm[X+1]+Y, BA=perm[B]+Z, BB=perm[B+1]+Z;
  return lerp(
    lerp(lerp(grad(perm[AA],x,y,z),   grad(perm[BA],x-1,y,z),   u),
         lerp(grad(perm[AB],x,y-1,z), grad(perm[BB],x-1,y-1,z), u), v),
    lerp(lerp(grad(perm[AA+1],x,y,z-1),   grad(perm[BA+1],x-1,y,z-1),   u),
         lerp(grad(perm[AB+1],x,y-1,z-1), grad(perm[BB+1],x-1,y-1,z-1), u), v), w);
}

/* ============================================================
   SDF + Surface Nets
   ============================================================ */
function smin(a,b,k){
  if(k<=1e-6) return Math.min(a,b);
  const h = Math.max(k-Math.abs(a-b),0)/k;
  return Math.min(a,b) - h*h*k*0.25;
}
function sdRoundCone(px,py,pz, ax,ay,az, bx,by,bz, r1,r2){
  const bax=bx-ax, bay=by-ay, baz=bz-az;
  const l2 = bax*bax+bay*bay+baz*baz;
  const pax=px-ax, pay=py-ay, paz=pz-az;
  if(l2 < 1e-9) return Math.sqrt(pax*pax+pay*pay+paz*paz) - r1;
  const rr=r1-r2, a2=l2-rr*rr, il2=1/l2;
  const y = pax*bax+pay*bay+paz*baz;
  const z = y-l2;
  const xx=pax*l2-bax*y, xy=pay*l2-bay*y, xz=paz*l2-baz*y;
  const x2 = xx*xx+xy*xy+xz*xz, y2=y*y*l2, z2=z*z*l2;
  const k = Math.sign(rr)*rr*rr*x2;
  if(Math.sign(z)*a2*z2 > k) return Math.sqrt(x2+z2)*il2 - r2;
  if(Math.sign(y)*a2*y2 < k) return Math.sqrt(x2+y2)*il2 - r1;
  return (Math.sqrt(x2*a2*il2)+y*rr)*il2 - r1;
}
const cube_edges = new Int32Array(24), edge_table = new Int32Array(256);
(function(){
  let k=0;
  for(let i=0;i<8;++i) for(let j=1;j<=4;j<<=1){ const p=i^j; if(i<=p){ cube_edges[k++]=i; cube_edges[k++]=p; } }
  for(let i=0;i<256;++i){
    let em=0;
    for(let j=0;j<24;j+=2){
      const a=!!(i&(1<<cube_edges[j])), b=!!(i&(1<<cube_edges[j+1]));
      em |= a!==b ? (1<<(j>>1)) : 0;
    }
    edge_table[i]=em;
  }
})();
function surfaceNets(dims, potential, bounds){
  let buffer = new Int32Array(4096);
  const scale=[0,0,0], shift=[0,0,0];
  for(let i=0;i<3;++i){ scale[i]=(bounds[1][i]-bounds[0][i])/dims[i]; shift[i]=bounds[0][i]; }
  const vertices=[], faces=[];
  let n=0, buf_no=1;
  const x=[0,0,0], R=[1, dims[0]+1, (dims[0]+1)*(dims[1]+1)], grid=new Float32Array(8);
  if(R[2]*2 > buffer.length) buffer = new Int32Array(R[2]*2);
  for(x[2]=0; x[2]<dims[2]-1; ++x[2], n+=dims[0], buf_no^=1, R[2]=-R[2]){
    let m = 1 + (dims[0]+1)*(1 + buf_no*(dims[1]+1));
    for(x[1]=0; x[1]<dims[1]-1; ++x[1], ++n, m+=2)
    for(x[0]=0; x[0]<dims[0]-1; ++x[0], ++n, ++m){
      let mask=0, g=0, idx=n;
      for(let k=0;k<2;++k, idx+=dims[0]*(dims[1]-2))
      for(let j=0;j<2;++j, idx+=dims[0]-2)
      for(let i=0;i<2;++i, ++g, ++idx){
        const p = potential[idx];
        grid[g]=p;
        mask |= (p<0) ? (1<<g) : 0;
      }
      if(mask===0 || mask===0xff) continue;
      const edge_mask = edge_table[mask];
      const v=[0,0,0];
      let e_count=0;
      for(let i=0;i<12;++i){
        if(!(edge_mask&(1<<i))) continue;
        ++e_count;
        const e0=cube_edges[i<<1], e1=cube_edges[(i<<1)+1];
        const g0=grid[e0], g1=grid[e1];
        let t=g0-g1;
        if(Math.abs(t)>1e-6) t=g0/t; else continue;
        for(let j=0,k=1;j<3;++j,k<<=1){
          const a=e0&k, b=e1&k;
          if(a!==b) v[j] += a ? 1.0-t : t;
          else v[j] += a ? 1.0 : 0;
        }
      }
      const s=1/e_count;
      for(let i=0;i<3;++i) v[i] = shift[i] + scale[i]*(x[i] + s*v[i]);
      buffer[m]=vertices.length;
      vertices.push(v);
      for(let i=0;i<3;++i){
        if(!(edge_mask&(1<<i))) continue;
        const iu=(i+1)%3, iv=(i+2)%3;
        if(x[iu]===0 || x[iv]===0) continue;
        const du=R[iu], dv=R[iv];
        if(mask&1) faces.push([buffer[m], buffer[m-du], buffer[m-du-dv], buffer[m-dv]]);
        else       faces.push([buffer[m], buffer[m-dv], buffer[m-du-dv], buffer[m-du]]);
      }
    }
  }
  return {vertices, faces};
}

/* ============================================================
   FBX 7.4 (binär)
   ============================================================ */
class FBXBuf {
  constructor(){ this.b=new Uint8Array(1<<20); this.v=new DataView(this.b.buffer); this.p=0; }
  need(n){ if(this.p+n<=this.b.length) return;
    let s=this.b.length; while(s<this.p+n) s*=2;
    const nb=new Uint8Array(s); nb.set(this.b); this.b=nb; this.v=new DataView(nb.buffer); }
  u8(x){ this.need(1); this.v.setUint8(this.p,x); this.p+=1; }
  u32(x){ this.need(4); this.v.setUint32(this.p,x,true); this.p+=4; }
  i32(x){ this.need(4); this.v.setInt32(this.p,x,true); this.p+=4; }
  f64(x){ this.need(8); this.v.setFloat64(this.p,x,true); this.p+=8; }
  i64(x){ this.need(8); this.v.setBigInt64(this.p,BigInt(x),true); this.p+=8; }
  raw(u8){ this.need(u8.length); this.b.set(u8,this.p); this.p+=u8.length; }
  bytes(s){ const e=new Uint8Array(s.length); for(let i=0;i<s.length;i++) e[i]=s.charCodeAt(i)&255; return e; }
  str(s){ const e=this.bytes(s); this.u32(e.length); this.raw(e); }
  skip(n){ this.need(n); this.p+=n; }
  patchU32(at,x){ this.v.setUint32(at,x,true); }
  done(){ return this.b.slice(0,this.p); }
}
const FP = { I:v=>({t:'I',v}), L:v=>({t:'L',v}), D:v=>({t:'D',v}), C:v=>({t:'C',v}), S:v=>({t:'S',v}),
             R:v=>({t:'R',v}), di:v=>({t:'d',v}), ii:v=>({t:'i',v}) };
const FN = (name, props, children) => ({name, props:props||[], children:children||[]});
const objName = (name, cls) => name + '\u0000\u0001' + cls;
function writeProp(buf,p){
  buf.u8(p.t.charCodeAt(0));
  switch(p.t){
    case 'C': buf.u8(p.v?1:0); break;
    case 'I': buf.i32(p.v); break;
    case 'D': buf.f64(p.v); break;
    case 'L': buf.i64(p.v); break;
    case 'S': buf.str(p.v); break;
    case 'R': buf.u32(p.v.length); buf.raw(p.v); break;
    case 'd': case 'i': {
      const a=p.v, n=a.length, stride = p.t==='d'?8:4;
      buf.u32(n); buf.u32(0); buf.u32(n*stride);
      if(p.t==='d') for(let i=0;i<n;i++) buf.f64(a[i]);
      else for(let i=0;i<n;i++) buf.i32(a[i]);
      break;
    }
    default: throw new Error('FBX prop '+p.t);
  }
}
function writeNode(buf,node){
  const start=buf.p;
  buf.u32(0); buf.u32(0); buf.u32(0);
  const nb=buf.bytes(node.name);
  buf.u8(nb.length); buf.raw(nb);
  const ps=buf.p;
  for(const p of node.props) writeProp(buf,p);
  const plen=buf.p-ps;
  for(const c of node.children) writeNode(buf,c);
  if(node.children.length) buf.skip(13);
  const end=buf.p;
  buf.patchU32(start,end); buf.patchU32(start+4,node.props.length); buf.patchU32(start+8,plen);
}
const p70 = (name,type,sub,flag,vals,vt) =>
  FN('P',[FP.S(name),FP.S(type),FP.S(sub),FP.S(flag)].concat((vals||[]).map(v=>(vt||FP.D)(v))));

function fbxBuild(o){
  const {positions,normals,colors,uvs,quads,bones,skinIndex,skinWeight} = o;
  const meshName = o.meshName || 'blob';
  const nv = positions.length/3;
  let uid = 1000000; const ID = ()=>++uid;
  const geoId=ID(), modelId=ID(), matId=ID(), skinId=ID(), poseId=ID();
  const boneIds = bones.map(()=>ID()), clusterIds = bones.map(()=>ID()), attrIds = bones.map(()=>ID());

  const verts = Float64Array.from(positions);
  const pvi = new Int32Array(quads.length*4);
  for(let q=0;q<quads.length;q++){
    const f=quads[q];
    pvi[q*4]=f[0]; pvi[q*4+1]=f[1]; pvi[q*4+2]=f[2]; pvi[q*4+3]=~f[3];
  }
  const nrm = Float64Array.from(normals);
  const geoChildren = [
    FN('Vertices',[FP.di(verts)]),
    FN('PolygonVertexIndex',[FP.ii(pvi)]),
    FN('GeometryVersion',[FP.I(124)]),
    FN('LayerElementNormal',[FP.I(0)],[
      FN('Version',[FP.I(102)]), FN('Name',[FP.S('')]),
      FN('MappingInformationType',[FP.S('ByVertice')]),
      FN('ReferenceInformationType',[FP.S('Direct')]),
      FN('Normals',[FP.di(nrm)])
    ])
  ];
  if(colors){
    const col = new Float64Array(quads.length*4*4);
    for(let q=0;q<quads.length;q++) for(let k=0;k<4;k++){
      const vi=quads[q][k], d=(q*4+k)*4;
      col[d]=colors[vi*3]; col[d+1]=colors[vi*3+1]; col[d+2]=colors[vi*3+2]; col[d+3]=1;
    }
    geoChildren.push(FN('LayerElementColor',[FP.I(0)],[
      FN('Version',[FP.I(101)]), FN('Name',[FP.S('Col')]),
      FN('MappingInformationType',[FP.S('ByPolygonVertex')]),
      FN('ReferenceInformationType',[FP.S('Direct')]),
      FN('Colors',[FP.di(col)])
    ]));
  }
  if(uvs){
    const uva=new Float64Array(quads.length*4*2);
    for(let q=0;q<quads.length;q++) for(let k=0;k<4;k++){
      const vi=quads[q][k], d=(q*4+k)*2;
      uva[d]=uvs[vi*2]; uva[d+1]=uvs[vi*2+1];
    }
    geoChildren.push(FN('LayerElementUV',[FP.I(0)],[
      FN('Version',[FP.I(101)]), FN('Name',[FP.S('UVMap')]),
      FN('MappingInformationType',[FP.S('ByPolygonVertex')]),
      FN('ReferenceInformationType',[FP.S('Direct')]),
      FN('UV',[FP.di(uva)])
    ]));
  }
  geoChildren.push(FN('LayerElementMaterial',[FP.I(0)],[
    FN('Version',[FP.I(101)]), FN('Name',[FP.S('')]),
    FN('MappingInformationType',[FP.S('AllSame')]),
    FN('ReferenceInformationType',[FP.S('IndexToDirect')]),
    FN('Materials',[FP.ii(new Int32Array([0]))])
  ]));
  const layerEntries = [FN('LayerElement',[],[FN('Type',[FP.S('LayerElementNormal')]),FN('TypedIndex',[FP.I(0)])])];
  if(uvs) layerEntries.push(FN('LayerElement',[],[FN('Type',[FP.S('LayerElementUV')]),FN('TypedIndex',[FP.I(0)])]));
  if(colors) layerEntries.push(FN('LayerElement',[],[FN('Type',[FP.S('LayerElementColor')]),FN('TypedIndex',[FP.I(0)])]));
  layerEntries.push(FN('LayerElement',[],[FN('Type',[FP.S('LayerElementMaterial')]),FN('TypedIndex',[FP.I(0)])]));
  geoChildren.push(FN('Layer',[FP.I(0)],[FN('Version',[FP.I(100)])].concat(layerEntries)));
  const geometry = FN('Geometry',[FP.L(geoId),FP.S(objName(meshName,'Geometry')),FP.S('Mesh')],geoChildren);

  const meshModel = FN('Model',[FP.L(modelId),FP.S(objName(meshName,'Model')),FP.S('Mesh')],[
    FN('Version',[FP.I(232)]),
    FN('Properties70',[],[
      p70('RotationActive','bool','','',[1],FP.I),
      p70('InheritType','enum','','',[1],FP.I),
      p70('DefaultAttributeIndex','int','Integer','',[0],FP.I),
      p70('Lcl Translation','Lcl Translation','','A',[0,0,0]),
      p70('Lcl Rotation','Lcl Rotation','','A',[0,0,0]),
      p70('Lcl Scaling','Lcl Scaling','','A',[1,1,1])
    ]),
    FN('Shading',[FP.C(1)]), FN('Culling',[FP.S('CullingOff')])
  ]);
  const boneModels = bones.map((b,i)=>FN('Model',
    [FP.L(boneIds[i]),FP.S(objName(b.name,'Model')),FP.S('LimbNode')],[
      FN('Version',[FP.I(232)]),
      FN('Properties70',[],[
        p70('RotationActive','bool','','',[1],FP.I),
        p70('InheritType','enum','','',[1],FP.I),
        p70('DefaultAttributeIndex','int','Integer','',[0],FP.I),
        p70('Size','double','Number','',[b.size||1]),
        p70('Lcl Translation','Lcl Translation','','A',b.pos),
        p70('Lcl Rotation','Lcl Rotation','','A',b.rot||[0,0,0]),
        p70('Lcl Scaling','Lcl Scaling','','A',[1,1,1])
      ]),
      FN('Shading',[FP.C(1)]), FN('Culling',[FP.S('CullingOff')])
    ]));
  /* Skelett-Attribute: Autodesk schreibt sie immer, Unity erkennt die Knochen damit zuverlässiger */
  const boneAttrs = bones.map((b,i)=>FN('NodeAttribute',
    [FP.L(attrIds[i]), FP.S(objName(b.name+'_attr','NodeAttribute')), FP.S('LimbNode')],[
      FN('Properties70',[],[p70('Size','double','Number','',[1])]),
      FN('TypeFlags',[FP.S('Skeleton')])
    ]));
  const material = FN('Material',[FP.L(matId),FP.S(objName(meshName+'_mat','Material')),FP.S('')],[
    FN('Version',[FP.I(102)]), FN('ShadingModel',[FP.S('phong')]), FN('MultiLayer',[FP.I(0)]),
    FN('Properties70',[],[
      p70('DiffuseColor','Color','','A',[0.8,0.8,0.8]),
      p70('SpecularColor','Color','','A',[0.4,0.4,0.4]),
      p70('Shininess','double','Number','',[48])
    ])
  ]);
  const skin = FN('Deformer',[FP.L(skinId),FP.S(objName(meshName+'_skin','Deformer')),FP.S('Skin')],[
    FN('Version',[FP.I(101)]), FN('Link_DeformAcuracy',[FP.D(50)])
  ]);
  const idxB = bones.map(()=>[]), wB = bones.map(()=>[]);
  for(let v=0;v<nv;v++) for(let s=0;s<4;s++){
    const w = skinWeight[v*4+s];
    if(w>1e-5){ const b=skinIndex[v*4+s]; idxB[b].push(v); wB[b].push(w); }
  }
  const clusters = bones.map((b,i)=>{
    const kids=[FN('Version',[FP.I(100)]), FN('UserData',[FP.S(''),FP.S('')])];
    if(idxB[i].length){
      kids.push(FN('Indexes',[FP.ii(Int32Array.from(idxB[i]))]));
      kids.push(FN('Weights',[FP.di(Float64Array.from(wB[i]))]));
    }
    kids.push(FN('Transform',[FP.di(Float64Array.from(b.transform))]));
    kids.push(FN('TransformLink',[FP.di(Float64Array.from(b.transformLink))]));
    return FN('Deformer',[FP.L(clusterIds[i]),FP.S(objName(b.name+'_cl','SubDeformer')),FP.S('Cluster')],kids);
  });
  const poseNodes = [FN('PoseNode',[],[FN('Node',[FP.L(modelId)]),
      FN('Matrix',[FP.di(Float64Array.from([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]))])])]
    .concat(bones.map((b,i)=>FN('PoseNode',[],[FN('Node',[FP.L(boneIds[i])]),
      FN('Matrix',[FP.di(Float64Array.from(b.transformLink))])])));
  const pose = FN('Pose',[FP.L(poseId),FP.S(objName('BIND_POSES','Pose')),FP.S('BindPose')],
    [FN('Type',[FP.S('BindPose')]),FN('Version',[FP.I(100)]),FN('NbPoseNodes',[FP.I(poseNodes.length)])].concat(poseNodes));

  const conns=[];
  const c=(a,b)=>conns.push(FN('C',[FP.S('OO'),FP.L(a),FP.L(b)]));
  c(modelId,0); c(geoId,modelId); c(matId,modelId); c(skinId,geoId);
  bones.forEach((b,i)=>{
    c(boneIds[i], b.parent<0 ? 0 : boneIds[b.parent]);
    c(attrIds[i], boneIds[i]);
    c(clusterIds[i], skinId);
    c(boneIds[i], clusterIds[i]);
  });

  const d=new Date();
  const pad2=v=>String(v).padStart(2,'0'), pad3=v=>String(v).padStart(3,'0');
  const stamp=d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate())+' '
    +pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds())+':'+pad3(d.getMilliseconds());
  const FILE_ID=new Uint8Array([0x28,0xb3,0x2a,0xeb,0xb6,0x24,0xcc,0xc2,0xbf,0xc8,0xb0,0x2a,0xa9,0x2b,0xfc,0xf1]);
  /* Der Footer-Code unten ist die Prüfsumme genau dieses Paars aus FileId und
     Zeitstempel. Beides muss konstant bleiben, sonst hält das FBX-SDK die Datei
     für beschädigt — Blender prüft das nicht und öffnet sie trotzdem. */
  const FIXED_TIME='1970-01-01 10:00:00:000';
  const CREATOR='Blobber v1.0';

  const objectList=[geometry,meshModel].concat(boneModels,boneAttrs,[material,skin],clusters,[pose]);
  const tree = [
    FN('FBXHeaderExtension',[],[
      FN('FBXHeaderVersion',[FP.I(1003)]),
      FN('FBXVersion',[FP.I(7400)]),
      FN('EncryptionType',[FP.I(0)]),
      FN('CreationTimeStamp',[],[
        FN('Version',[FP.I(1000)]),
        FN('Year',[FP.I(d.getFullYear())]), FN('Month',[FP.I(d.getMonth()+1)]), FN('Day',[FP.I(d.getDate())]),
        FN('Hour',[FP.I(d.getHours())]), FN('Minute',[FP.I(d.getMinutes())]),
        FN('Second',[FP.I(d.getSeconds())]), FN('Millisecond',[FP.I(d.getMilliseconds())])
      ]),
      FN('Creator',[FP.S(CREATOR)]),
      FN('SceneInfo',[FP.S(objName('GlobalInfo','SceneInfo')),FP.S('UserData')],[
        FN('Type',[FP.S('UserData')]),
        FN('Version',[FP.I(100)]),
        FN('MetaData',[],[
          FN('Version',[FP.I(100)]), FN('Title',[FP.S('')]), FN('Subject',[FP.S('')]),
          FN('Author',[FP.S('')]), FN('Keywords',[FP.S('')]), FN('Revision',[FP.S('')]),
          FN('Comment',[FP.S('')])
        ]),
        FN('Properties70',[],[
          p70('DocumentUrl','KString','Url','',[meshName+'.fbx'],FP.S),
          p70('SrcDocumentUrl','KString','Url','',[meshName+'.fbx'],FP.S),
          p70('Original','Compound','','',[]),
          p70('Original|ApplicationVendor','KString','','',['Blobber'],FP.S),
          p70('Original|ApplicationName','KString','','',[CREATOR],FP.S),
          p70('LastSaved','Compound','','',[]),
          p70('LastSaved|ApplicationVendor','KString','','',['Blobber'],FP.S),
          p70('LastSaved|ApplicationName','KString','','',[CREATOR],FP.S)
        ])
      ])
    ]),
    FN('FileId',[FP.R(FILE_ID)]),
    FN('CreationTime',[FP.S(FIXED_TIME)]),
    FN('Creator',[FP.S(CREATOR)]),
    FN('GlobalSettings',[],[FN('Version',[FP.I(1000)]),FN('Properties70',[],[
      p70('UpAxis','int','Integer','',[1],FP.I),
      p70('UpAxisSign','int','Integer','',[1],FP.I),
      p70('FrontAxis','int','Integer','',[2],FP.I),
      p70('FrontAxisSign','int','Integer','',[1],FP.I),
      p70('CoordAxis','int','Integer','',[0],FP.I),
      p70('CoordAxisSign','int','Integer','',[1],FP.I),
      p70('OriginalUpAxis','int','Integer','',[1],FP.I),
      p70('OriginalUpAxisSign','int','Integer','',[1],FP.I),
      p70('UnitScaleFactor','double','Number','',[1]),
      p70('OriginalUnitScaleFactor','double','Number','',[1]),
      p70('AmbientColor','ColorRGB','Color','',[0,0,0]),
      p70('DefaultCamera','KString','','',['Producer Perspective'],FP.S),
      p70('TimeMode','enum','','',[11],FP.I),
      p70('TimeProtocol','enum','','',[2],FP.I),
      p70('SnapOnFrameMode','enum','','',[0],FP.I),
      p70('TimeSpanStart','KTime','Time','',[0],FP.L),
      p70('TimeSpanStop','KTime','Time','',[46186158000],FP.L),
      p70('CustomFrameRate','double','Number','',[-1]),
      p70('CurrentTimeMarker','int','Integer','',[-1],FP.I)
    ])]),
    FN('Documents',[],[FN('Count',[FP.I(1)]),
      FN('Document',[FP.L(1),FP.S(''),FP.S('Scene')],[
        FN('Properties70',[],[
          p70('SourceObject','object','','',[]),
          p70('ActiveAnimStackName','KString','','',[''],FP.S)
        ]),
        FN('RootNode',[FP.L(0)])])]),
    FN('References',[],[]),
    FN('Definitions',[],[
      FN('Version',[FP.I(100)]), FN('Count',[FP.I(objectList.length)]),
      FN('ObjectType',[FP.S('Geometry')],[FN('Count',[FP.I(1)])]),
      FN('ObjectType',[FP.S('NodeAttribute')],[FN('Count',[FP.I(bones.length)])]),
      FN('ObjectType',[FP.S('Model')],[FN('Count',[FP.I(1+bones.length)])]),
      FN('ObjectType',[FP.S('Material')],[FN('Count',[FP.I(1)])]),
      FN('ObjectType',[FP.S('Deformer')],[FN('Count',[FP.I(1+bones.length)])]),
      FN('ObjectType',[FP.S('Pose')],[FN('Count',[FP.I(1)])]),
      FN('ObjectType',[FP.S('GlobalSettings')],[FN('Count',[FP.I(1)])])
    ]),
    FN('Objects',[],objectList),
    FN('Connections',[],conns),
    FN('Takes',[],[FN('Current',[FP.S('')])])
  ];

  const buf = new FBXBuf();
  buf.raw(buf.bytes('Kaydara FBX Binary  ')); buf.u8(0); buf.u8(0x1a); buf.u8(0);
  buf.u32(7400);
  for(const n of tree) writeNode(buf,n);
  buf.skip(13);
  buf.raw(new Uint8Array([0xfa,0xbc,0xab,0x09,0xd0,0xc8,0xd4,0x66,0xb1,0x76,0xfb,0x83,0x1c,0xf7,0x26,0x7e]));
  let pad=(16-(buf.p%16))%16;
  if(pad===0) pad=16;                       /* nie null Füllbytes — das SDK verlangt einen Block */
  buf.skip(pad);
  buf.u32(0); buf.u32(7400); buf.skip(120);
  buf.raw(new Uint8Array([0xf8,0x5a,0x8c,0x6a,0xde,0xf5,0xd9,0x7e,0xec,0xe9,0x0c,0xe3,0x75,0x8f,0x29,0x0b]));
  return buf.done();
}

/* ============================================================
   Dickenverlauf — monotone Hermite-Kurve
   ============================================================ */
const thickPts = [{x:0,y:0.62},{x:0.38,y:0.78},{x:1,y:0.1}];
function thickAt(t){
  const P=thickPts, n=P.length;
  t = Math.max(0, Math.min(1, t));
  if(n===1) return P[0].y;
  let i=0; while(i<n-2 && t>P[i+1].x) i++;
  const x0=P[i].x, x1=P[i+1].x, y0=P[i].y, y1=P[i+1].y;
  const h=Math.max(1e-6, x1-x0), s=(y1-y0)/h;
  const sPrev = i>0 ? (y0-P[i-1].y)/Math.max(1e-6,x0-P[i-1].x) : s;
  const sNext = i+2<n ? (P[i+2].y-y1)/Math.max(1e-6,P[i+2].x-x1) : s;
  const mono=(a,b)=> (a*b<=0) ? 0 : (2*a*b)/(a+b);
  const m0 = i>0 ? mono(sPrev,s) : s;
  const m1 = i+2<n ? mono(s,sNext) : s;
  const u=(t-x0)/h, u2=u*u, u3=u2*u;
  const y = (2*u3-3*u2+1)*y0 + (u3-2*u2+u)*h*m0 + (-2*u3+3*u2)*y1 + (u3-u2)*h*m1;
  return Math.max(0, Math.min(1, y));
}
const radiusAt = (t,L) => Math.max(0.008, S.thick * thickAt(t) * (L && L.scale ? L.scale : 1));

/* ============================================================
   Szene
   ============================================================ */
const stage = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setClearColor(0xefefef,1);
renderer.outputEncoding = THREE.sRGBEncoding;
stage.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 200);
scene.add(new THREE.HemisphereLight(0xffffff, 0xc9ced2, 0.72));
const key  = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(2.5,3.5,2); scene.add(key);
const fill = new THREE.DirectionalLight(0xffffff, 0.3); fill.position.set(-3,1.2,-2.5); scene.add(fill);
const rim  = new THREE.DirectionalLight(0xffffff, 0.28); rim.position.set(0,-2,-1); scene.add(rim);

/* keine Umgebungsspiegelung — reine Lichtschattierung */

const grid = new THREE.GridHelper(10, 20, 0xd2d4d6, 0xe4e5e6);
grid.position.y=-1.6; grid.material.transparent=true; grid.material.opacity=0.85;
scene.add(grid);

const orbit = {theta:0.75, phi:1.25, dist:4.6, target:new THREE.Vector3(0,0,0)};
function applyCamera(){
  const sp=Math.sin(orbit.phi), cp=Math.cos(orbit.phi);
  camera.position.set(
    orbit.target.x+orbit.dist*sp*Math.sin(orbit.theta),
    orbit.target.y+orbit.dist*cp,
    orbit.target.z+orbit.dist*sp*Math.cos(orbit.theta));
  camera.lookAt(orbit.target);
}
function resize(){
  const w=stage.clientWidth, h=stage.clientHeight;
  renderer.setSize(w,h);
  camera.aspect=w/h; camera.updateProjectionMatrix();
}
window.addEventListener('resize', ()=>{ resize(); drawThick(); });

/* ============================================================
   Zustand
   ============================================================ */
const S = {
  centerR:0.55, seg:0.18, jit:0.12, ctrlN:4, tension:0.5, thick:0.26,
  mirror:'x', radial:5,
  res:72, k:0.12, sharp:3, smooth:2, uvDensity:1, bend:70,
  gloss:0.62, coat:0.45,
  animSym:'x',
  amt:6, freq:0.85, speed:0.35, wave:0.4, stiff:1, lift:0.1, body:0.25,
  spring:18, damp:3, inert:0.5, wind:0.35, windDir:30, tile:1, texMix:1,
  blobR:0.16, linkR:0.6, drag:10,
  expCm:100, gore:2, bodyGore:4, patH:40, seam:5, stretch:0, notch:5, patStations:18, page:'a4', patTexA:1,
  play:true, tool:'curve'
};
/* limb = {ctrl:[V3], parent:idx|-1, attachT:0..1, group:int, vi:int, tf:{mir,ang}} */
let limbs=[], groupSeq=0;
let blobs=[], blobSeq=0, blobOrder=[];
let mesh=null, skeleton=null, chains=[], helper=null, wireMesh=null;
let quadFaces=null, boneDepth=null, boneLimb=null, limbTotal=0;
let stale=true, sel=null;   /* {t:'ctrl',limb,ctrl} | {t:'blob',i} */

const mainColor = new THREE.Color('#2f6f6a').convertSRGBToLinear();
const matSurface = new THREE.MeshPhysicalMaterial({
  color:0xffffff, vertexColors:true, roughness:0.25, metalness:0.0,
  clearcoat:0.45, clearcoatRoughness:0.18, skinning:true});
const texMix={value:1};
let texURL=null;
matSurface.onBeforeCompile = sh=>{
  sh.uniforms.uTexMix = texMix;
  sh.fragmentShader = 'uniform float uTexMix;\n' + sh.fragmentShader.replace(
    '#include <map_fragment>',
    ['#ifdef USE_MAP',
     '  vec4 tc = texture2D( map, vUv );',
     '  tc = mapTexelToLinear( tc );',
     '  diffuseColor *= mix( vec4(1.0), tc, uTexMix );',
     '#endif'].join('\n'));
};
const matWire = new THREE.MeshBasicMaterial({color:0x9aa0a4, wireframe:true, transparent:true, opacity:0.3, skinning:true});
const matUV = (function(){
  const c=document.createElement('canvas'); c.width=c.height=256;
  const g=c.getContext('2d'), n=8, q=256/n;
  for(let y=0;y<n;y++) for(let x=0;x<n;x++){
    g.fillStyle=((x+y)%2)?'#ffffff':'#c3d4d0';
    g.fillRect(x*q,y*q,q,q);
  }
  g.strokeStyle='#0b6b62'; g.lineWidth=4; g.strokeRect(2,2,252,252);
  const t=new THREE.CanvasTexture(c);
  t.wrapS=t.wrapT=THREE.RepeatWrapping;
  t.encoding=THREE.sRGBEncoding;
  t.anisotropy=4;
  return new THREE.MeshStandardMaterial({map:t, roughness:0.5, metalness:0, skinning:true});
})();
const surfaceMat = ()=> document.getElementById('cUV').checked ? matUV : matSurface;
function applyMaterial(){
  matSurface.roughness = lerp(0.85,0.06,S.gloss);
  matSurface.clearcoat = S.coat;
  matSurface.clearcoatRoughness = lerp(0.35,0.02,S.gloss);
  matSurface.needsUpdate = true;
}

/* ============================================================
   Symmetrie-Transformationen
   ============================================================ */
function applyTF(tf, v){
  const p=v.clone();
  if(tf.mir) p.x=-p.x;
  if(tf.ang){ const c=Math.cos(tf.ang), s=Math.sin(tf.ang); const x=p.x*c+p.z*s, z=-p.x*s+p.z*c; p.x=x; p.z=z; }
  return p;
}
function invTF(tf, v){
  const p=v.clone();
  if(tf.ang){ const c=Math.cos(-tf.ang), s=Math.sin(-tf.ang); const x=p.x*c+p.z*s, z=-p.x*s+p.z*c; p.x=x; p.z=z; }
  if(tf.mir) p.x=-p.x;
  return p;
}
const groupMembers = g => limbs.map((L,i)=>({L,i})).filter(o=>o.L.group===g).sort((a,b)=>a.L.vi-b.L.vi);

/* ============================================================
   Splines auflösen
   ============================================================ */
function resolveLimbs(){
  for(let i=0;i<limbs.length;i++){
    const L=limbs[i];
    let origin=new THREE.Vector3(0,0,0);
    if(L.parent>=0 && L.parent<i && limbs[L.parent]._curve)
      origin=limbs[L.parent]._curve.getPointAt(Math.min(0.999, Math.max(0.001, L.attachT)));
    L._o=origin;
    const pts=[origin.clone()];
    for(const c of L.ctrl) if(c.distanceTo(pts[pts.length-1])>1e-4) pts.push(c.clone());
    if(pts.length<2) pts.push(pts[0].clone().add(new THREE.Vector3(0.02,0.02,0)));
    L._curve=new THREE.CatmullRomCurve3(pts,false,'catmullrom',S.tension);
    const len=Math.max(1e-3, L._curve.getLength());
    const n = L.nb>0 ? Math.max(2, Math.min(40, Math.round(L.nb)))
                     : Math.max(2, Math.min(40, Math.round(len/S.seg)));
    L._j=L._curve.getSpacedPoints(n).slice(1);
  }
}

const blobGroup = g => blobs.map((b,i)=>({b,i})).filter(o=>o.b.group===g);
/* Knoten, an die sich Blobs anhängen dürfen: Rumpf und alle Gliedgelenke */
function armatureNodes(){
  const nodes=[{p:new THREE.Vector3(0,0,0), r:S.centerR, ref:{kind:'root'}}];
  limbs.forEach((L,li)=>{
    if(!L._j) return;
    const n=L._j.length;
    L._j.forEach((q,i)=>nodes.push({p:q.clone(), r:radiusAt((i+1)/n,L), ref:{kind:'limb',limb:li,j:i}}));
  });
  return nodes;
}
/* Aufspannender Baum: nur der Vertreter jeder Symmetriegruppe sucht sich den
   nächstgelegenen Knoten; die Kopien erben den gespiegelten bzw. gedrehten
   Elternknoten, damit die Armatur symmetrisch bleibt. */
function refNode(ref){
  if(ref.kind==='root') return {p:new THREE.Vector3(0,0,0), r:S.centerR, ref};
  if(ref.kind==='limb'){
    const L=limbs[ref.limb];
    if(L && L._j && L._j[ref.j]) return {p:L._j[ref.j].clone(), r:radiusAt((ref.j+1)/L._j.length,L), ref};
    return {p:new THREE.Vector3(0,0,0), r:S.centerR, ref:{kind:'root'}};
  }
  const b=blobs[ref.i];
  if(b) return {p:b.p.clone(), r:b.r, ref};
  return {p:new THREE.Vector3(0,0,0), r:S.centerR, ref:{kind:'root'}};
}
function mapRef(ref, m){
  if(ref.kind==='root') return ref;
  if(ref.kind==='limb'){
    const src=limbs[ref.limb];
    if(!src) return ref;
    const mem=groupMembers(src.group);
    if(!mem.length) return ref;
    return {kind:'limb', limb:mem[m % mem.length].i, j:ref.j};
  }
  const src=blobs[ref.i];
  if(!src) return ref;
  const mem=blobGroup(src.group);
  if(!mem.length) return ref;
  return {kind:'blob', i:mem[m % mem.length].i};
}
function blobTree(){
  const byGroup=new Map();
  blobs.forEach((b,i)=>{
    if(!byGroup.has(b.group)) byGroup.set(b.group,[]);
    byGroup.get(b.group).push(i);
  });
  for(const list of byGroup.values()) list.sort((x,y)=>blobs[x].vi-blobs[y].vi);
  const reps=[...byGroup.values()].map(l=>l[0]);

  const inTree=armatureNodes();
  const rest=reps.slice();
  const primOrder=[], repParent=new Map();
  while(rest.length){
    let bk=-1, bn=-1, bd=Infinity;
    for(let k=0;k<rest.length;k++){
      const b=blobs[rest[k]];
      for(let m=0;m<inTree.length;m++){
        const d=b.p.distanceTo(inTree[m].p);
        if(d<bd){ bd=d; bk=k; bn=m; }
      }
    }
    if(bk<0) break;
    const bi=rest[bk]; rest.splice(bk,1);
    repParent.set(bi, inTree[bn].ref);
    primOrder.push(bi);
    inTree.push({p:blobs[bi].p, r:blobs[bi].r, ref:{kind:'blob', i:bi}});
  }

  const order=[];
  for(const rep of primOrder){
    const list=byGroup.get(blobs[rep].group);
    list.forEach((idx,m)=>order.push({blob:idx, parent:refNode(mapRef(repParent.get(rep), m))}));
  }
  return order;
}
function resolveAll(){ resolveLimbs(); blobOrder=blobTree(); }

/* ============================================================
   Skizze
   ============================================================ */
const sketch=new THREE.Group(); scene.add(sketch);
const blobGeo=new THREE.SphereGeometry(1,14,10);
const handleGeo=new THREE.SphereGeometry(1,12,10);
const matBlob=new THREE.MeshBasicMaterial({color:0x111214, wireframe:true, transparent:true, opacity:0.10});
const matLine=new THREE.LineBasicMaterial({color:0x0b6b62, transparent:true, opacity:0.8});
const matGhost=new THREE.LineBasicMaterial({color:0xa7abb0, transparent:true, opacity:0.6});
const matHandle=new THREE.MeshBasicMaterial({color:0x0b6b62});
const matHandleSel=new THREE.MeshBasicMaterial({color:0x111214});
const matJoint=new THREE.MeshBasicMaterial({color:0x0b6b62, transparent:true, opacity:0.45});
const matLineSel=new THREE.LineBasicMaterial({color:0x111214});
let handles=[], pickLines=[];

function rebuildSketch(){
  resolveAll();
  while(sketch.children.length){
    const c=sketch.children.pop();
    if(c.isLine && c.geometry) c.geometry.dispose();
  }
  handles=[]; pickLines=[]; previewLine=null;
  const totalJoints=limbs.reduce((s,L)=>s+L._j.length,0);
  const showBlobs=totalJoints<=260;

  const c=new THREE.Mesh(blobGeo, matBlob);
  c.scale.setScalar(S.centerR); sketch.add(c);

  limbs.forEach((L,li)=>{
    const smooth=L._curve.getPoints(Math.max(16, L._j.length*4));
    const pos=[];
    for(const p of smooth) pos.push(p.x,p.y,p.z);
    const g=new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(pos,3));
    const ln=new THREE.Line(g, (sel&&sel.limb===li)?matLineSel:matLine);
    ln.userData={limb:li};
    sketch.add(ln); pickLines.push(ln);

    if(showBlobs) L._j.forEach((p,i)=>{
      const b=new THREE.Mesh(blobGeo, matBlob);
      b.position.copy(p); b.scale.setScalar(radiusAt((i+1)/L._j.length, L)); sketch.add(b);
    });
    if(S.tool==='curve'){
      const a=new THREE.Mesh(handleGeo, matJoint);
      a.position.copy(L._o); a.scale.setScalar(0.026); sketch.add(a);
      L.ctrl.forEach((q,ci)=>{
        const isSel = sel && sel.t==='ctrl' && sel.limb===li && sel.ctrl===ci;
        const h=new THREE.Mesh(handleGeo, isSel?matHandleSel:matHandle);
        h.position.copy(q); h.scale.setScalar(isSel?0.042:0.032);
        h.userData={kind:'ctrl', limb:li, ctrl:ci};
        sketch.add(h); handles.push(h);
      });
    }
  });

  for(const e of blobOrder){
    const b=blobs[e.blob];
    const g=new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(
      [e.parent.p.x,e.parent.p.y,e.parent.p.z, b.p.x,b.p.y,b.p.z],3));
    sketch.add(new THREE.Line(g, matLine));
    if(showBlobs){
      const w=new THREE.Mesh(blobGeo, matBlob);
      w.position.copy(b.p); w.scale.setScalar(b.r); sketch.add(w);
    }
    if(S.tool==='blob'){
      const isSel = sel && sel.t==='blob' && sel.i===e.blob;
      const h=new THREE.Mesh(handleGeo, isSel?matHandleSel:matHandle);
      h.position.copy(b.p); h.scale.setScalar(isSel?0.046:0.034);
      h.userData={kind:'blob', i:e.blob};
      sketch.add(h); handles.push(h);
    }
  }
  updateGizmo();
  sketch.visible=document.getElementById('cSketch').checked;
}
function ensureSketch(){
  const cb=document.getElementById('cSketch');
  if(!cb.checked){ cb.checked=true; sketch.visible=true; }
}

/* ============================================================
   Achsen-Gizmo
   ============================================================ */
const gizmo=new THREE.Group(); gizmo.visible=false; gizmo.renderOrder=999; scene.add(gizmo);
const gizmoParts=[];
const AX=[new THREE.Vector3(1,0,0), new THREE.Vector3(0,1,0), new THREE.Vector3(0,0,1)];
[0xD85A30,0x639922,0x378ADD].forEach((col,i)=>{
  const m=new THREE.MeshBasicMaterial({color:col, depthTest:false,
    transparent:true, opacity:0.85, blending:THREE.MultiplyBlending});
  const g=new THREE.Group();
  const shaft=new THREE.Mesh(new THREE.CylinderGeometry(0.006,0.006,0.42,7), m);
  shaft.position.y=0.21;
  const head=new THREE.Mesh(new THREE.ConeGeometry(0.026,0.09,10), m);
  head.position.y=0.47;
  shaft.userData={axis:i}; head.userData={axis:i};
  shaft.renderOrder=999; head.renderOrder=999;
  g.add(shaft); g.add(head);
  g.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), AX[i]);
  gizmo.add(g); gizmoParts.push(shaft, head);
});
function selPoint(){
  if(!sel) return null;
  if(sel.t==='blob') return blobs[sel.i] ? blobs[sel.i].p : null;
  const L=limbs[sel.limb];
  return (L && L.ctrl[sel.ctrl]) ? L.ctrl[sel.ctrl] : null;
}
function updateGizmo(){
  const p=selPoint();
  gizmo.visible = !!p && S.tool!=='interact';
  if(p) gizmo.position.copy(p).add(bodyOffset);
}

/* ============================================================
   Zeichnen & Bearbeiten
   ============================================================ */
const ray=new THREE.Raycaster(), ndc=new THREE.Vector2();
const drawPlane=new THREE.Plane(), viewDir=new THREE.Vector3();
let stroke=null, pointerMode=null, last={x:0,y:0}, previewLine=null, dragHandle=null;

function setNDC(e){
  const r=renderer.domElement.getBoundingClientRect();
  ndc.set(((e.clientX-r.left)/r.width)*2-1, -((e.clientY-r.top)/r.height)*2+1);
  return r;
}
function planePoint(e, anchor, jitter){
  setNDC(e);
  ray.setFromCamera(ndc,camera);
  camera.getWorldDirection(viewDir);
  drawPlane.setFromNormalAndCoplanarPoint(viewDir, anchor);
  const out=new THREE.Vector3();
  if(!ray.ray.intersectPlane(drawPlane,out)) return null;
  if(jitter && S.jit>0){
    const nz=noise3(out.x*1.9+11.3, out.y*1.9-4.1, out.z*1.9+7.7);
    out.addScaledVector(viewDir, nz*S.jit);
  }
  return out;
}
/* Nächster Anknüpfpunkt im Bildschirmraum */
function findAttach(e){
  const r=setNDC(e);
  const px=(ndc.x*0.5+0.5)*r.width, py=(-ndc.y*0.5+0.5)*r.height;
  const v=new THREE.Vector3();
  let best={parent:-1, t:0, origin:new THREE.Vector3(0,0,0), d:1e9};
  limbs.forEach((L,li)=>{
    const n=48;
    for(let i=1;i<=n;i++){
      const t=i/n;
      v.copy(L._curve.getPointAt(t)).project(camera);
      const sx=(v.x*0.5+0.5)*r.width, sy=(-v.y*0.5+0.5)*r.height;
      const d=Math.hypot(sx-px, sy-py);
      if(d<best.d){ best={parent:li, t, origin:L._curve.getPointAt(t), d}; }
    }
  });
  if(best.d>28) return {parent:-1, t:0, origin:new THREE.Vector3(0,0,0)};
  return best;
}
function rebuildPreviewStroke(){
  if(previewLine){ sketch.remove(previewLine); previewLine.geometry.dispose(); previewLine=null; }
  if(!stroke || !stroke.raw.length) return;
  const pos=[stroke.origin.x, stroke.origin.y, stroke.origin.z];
  for(const p of stroke.raw) pos.push(p.x,p.y,p.z);
  const g=new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.Float32BufferAttribute(pos,3));
  previewLine=new THREE.Line(g, matGhost);
  sketch.add(previewLine);
}
function addStrokePoint(e){
  const p=planePoint(e, stroke.origin, true); if(!p) return;
  const prev=stroke.raw.length ? stroke.raw[stroke.raw.length-1] : stroke.origin;
  if(p.distanceTo(prev) < S.seg*0.45) return;
  if(stroke.raw.length>=60) return;
  stroke.raw.push(p);
  rebuildPreviewStroke();
}
function decimate(origin, raw, n){
  if(raw.length<=1) return raw.slice();
  const pts=[origin.clone()];
  for(const p of raw) if(p.distanceTo(pts[pts.length-1])>1e-4) pts.push(p.clone());
  if(pts.length<2) return raw.slice();
  const cu=new THREE.CatmullRomCurve3(pts,false,'catmullrom',0.5);
  return cu.getSpacedPoints(n).slice(1);
}
function variantList(){
  if(S.mirror==='radial'){
    const n=Math.max(2, Math.round(S.radial));
    return Array.from({length:n}, (_,i)=>({mir:0, ang:i*Math.PI*2/n}));
  }
  if(S.mirror==='x') return [{mir:0,ang:0},{mir:1,ang:0}];
  return [{mir:0,ang:0}];
}
function addLimb(ctrlDrawn, parentIdx, attachT){
  let tfs=variantList();
  if(S.mirror==='x'){
    const avgX=ctrlDrawn.reduce((s,p)=>s+p.x,0)/ctrlDrawn.length;
    const pGroup = parentIdx>=0 ? groupMembers(limbs[parentIdx].group).length : 1;
    if(Math.abs(avgX) < Math.max(0.05, S.thick*0.9) && pGroup<2) tfs=[{mir:0,ang:0}];
  }
  const g=++groupSeq;
  let pMembers=[], k=0, aligned=false;
  if(parentIdx>=0){
    pMembers=groupMembers(limbs[parentIdx].group);
    k=Math.max(0, pMembers.findIndex(o=>o.i===parentIdx));
    aligned = pMembers.length===tfs.length;
  }
  /* Grundraum: die gezeichnete Kurve gehört zur Variante k des Elternglieds */
  const base = aligned ? ctrlDrawn.map(p=>invTF(tfs[k],p)) : ctrlDrawn.map(p=>p.clone());
  tfs.forEach((tf,m)=>{
    const ctrl=base.map(p=>applyTF(tf,p));
    let par=-1;
    if(parentIdx>=0) par = aligned ? pMembers[m].i : parentIdx;
    limbs.push({ctrl, parent:par, attachT:attachT||0, group:g, vi:m, tf, scale:1, nb:0});
  });
}
function commitStroke(){
  if(!stroke){ return; }
  const raw=stroke.raw;
  if(!raw.length){ stroke=null; rebuildPreviewStroke(); return; }
  pushUndo();
  const ctrl=decimate(stroke.origin, raw, Math.max(2, Math.round(S.ctrlN)));
  const parentIdx=stroke.parent, attachT=stroke.t;
  stroke=null; rebuildPreviewStroke();
  addLimb(ctrl, parentIdx, attachT);
  rebuildSketch(); markStale(); updateStats();
}

const bodyOffset=new THREE.Vector3(), bodyVel=new THREE.Vector3(), bodyPrev=new THREE.Vector3();
const bodyPicker=new THREE.Mesh(new THREE.SphereGeometry(1,14,10),
  new THREE.MeshBasicMaterial({visible:false}));
scene.add(bodyPicker);
function recenterBody(){ bodyOffset.set(0,0,0); bodyVel.set(0,0,0); bodyPrev.set(0,0,0); }

let axisDrag=null, scaleDrag=null, bodyDrag=null;

function addBlob(p){
  pushUndo();
  let tfs=variantList();
  if(S.mirror==='x' && Math.abs(p.x) < Math.max(0.04, S.blobR*0.8)) tfs=[{mir:0,ang:0}];
  const g=++blobSeq, start=blobs.length;
  tfs.forEach((tf,m)=>blobs.push({p:applyTF(tf,p), r:S.blobR, group:g, vi:m, tf}));
  sel={t:'blob', i:start};
  rebuildSketch(); markStale(); updateStats(); updateSelUI();
}
function selGroup(){
  if(!sel) return [];
  if(sel.t==='blob'){
    const b=blobs[sel.i]; if(!b) return [];
    return blobGroup(b.group).map(o=>({tf:o.b.tf, get:()=>o.b.p}));
  }
  const L=limbs[sel.limb]; if(!L) return [];
  return groupMembers(L.group)
    .filter(o=>o.L.ctrl[sel.ctrl])
    .map(o=>({tf:o.L.tf, get:()=>o.L.ctrl[sel.ctrl]}));
}
function axisParam(e, origin, dir){
  setNDC(e); ray.setFromCamera(ndc,camera);
  const ro=ray.ray.origin, rd=ray.ray.direction;
  const w0=new THREE.Vector3().subVectors(origin, ro);
  const a=dir.dot(dir), b=dir.dot(rd), c=rd.dot(rd), d=dir.dot(w0), f=rd.dot(w0);
  const den=a*c-b*b;
  if(Math.abs(den)<1e-9) return null;
  return (b*f-c*d)/den;
}

renderer.domElement.addEventListener('contextmenu', e=>e.preventDefault());
renderer.domElement.addEventListener('pointerdown', e=>{
  renderer.domElement.setPointerCapture(e.pointerId);
  last={x:e.clientX,y:e.clientY};
  if(e.button===1){ pointerMode='pan'; return; }
  if(e.button!==0){ pointerMode='orbit'; return; }
  setNDC(e); ray.setFromCamera(ndc,camera);

  if(S.tool==='interact'){
    if(mesh){
      bodyPicker.updateMatrixWorld(true);
      if(ray.intersectObject(bodyPicker,false)[0]){
        const p=planePoint(e, bodyPicker.position, false);
        if(p){ bodyDrag={grab:p.clone().sub(bodyOffset)}; pointerMode='body'; return; }
      }
    }
    pointerMode='orbit'; return;
  }

  if(gizmo.visible){
    const gh=ray.intersectObjects(gizmoParts,false)[0];
    if(gh){
      if(e.shiftKey && sel.t==='blob'){ pushUndo(); scaleDrag={r0:blobs[sel.i].r}; pointerMode='scale'; return; }
      pushUndo();
      const dir=AX[gh.object.userData.axis].clone();
      const org=selPoint().clone().add(bodyOffset);
      const s0=axisParam(e, org, dir);
      if(s0!==null){
        axisDrag={dir, org, s0, base:selGroup().map(o=>({o, p0:o.get().clone()}))};
        pointerMode='axis';
      }
      return;
    }
  }

  const hh=ray.intersectObjects(handles,false)[0];
  if(hh){
    const u=hh.object.userData;
    sel = u.kind==='blob' ? {t:'blob', i:u.i} : {t:'ctrl', limb:u.limb, ctrl:u.ctrl};
    updateSelUI(); rebuildSketch();
    if(e.shiftKey && sel.t==='blob'){ pushUndo(); scaleDrag={r0:blobs[sel.i].r}; pointerMode='scale'; return; }
    pushUndo(); pointerMode='handle'; return;
  }

  if(S.tool==='curve'){
    ray.params.Line.threshold=0.05;
    const lh=ray.intersectObjects(pickLines,false)[0];
    if(lh){
      sel={t:'ctrl', limb:lh.object.userData.limb, ctrl:0};
      updateSelUI(); rebuildSketch(); pointerMode=null; return;
    }
    const at=findAttach(e);
    stroke={raw:[], origin:at.origin.clone(), parent:at.parent, t:at.t};
    pointerMode='draw'; ensureSketch(); return;
  }

  if(S.tool==='blob'){
    const p=planePoint(e, orbit.target, false);
    if(p){ addBlob(p); ensureSketch(); }
    pointerMode=null; return;
  }
  pointerMode='orbit';
});

renderer.domElement.addEventListener('pointermove', e=>{
  if(!pointerMode) return;
  const dx=e.clientX-last.x, dy=e.clientY-last.y;
  last={x:e.clientX,y:e.clientY};

  if(pointerMode==='draw'){ addStrokePoint(e); return; }

  if(pointerMode==='axis'){
    const s=axisParam(e, axisDrag.org, axisDrag.dir);
    if(s===null) return;
    const delta=axisDrag.dir.clone().multiplyScalar(s-axisDrag.s0);
    const selTF = sel.t==='blob' ? blobs[sel.i].tf : limbs[sel.limb].tf;
    const baseDelta=invTF(selTF, delta);
    for(const b of axisDrag.base) b.o.get().copy(b.p0).add(applyTF(b.o.tf, baseDelta));
    rebuildSketch();
    return;
  }
  if(pointerMode==='scale'){
    const f=Math.exp(-dy*0.008);
    const b=blobs[sel.i];
    const nr=Math.max(0.02, Math.min(1.2, b.r*f));
    for(const o of blobGroup(b.group)) o.b.r=nr;
    rebuildSketch();
    return;
  }
  if(pointerMode==='handle'){
    const cur=selPoint(); if(!cur) return;
    const p=planePoint(e, cur, false);
    if(p){
      const selTF = sel.t==='blob' ? blobs[sel.i].tf : limbs[sel.limb].tf;
      const base=invTF(selTF, p);
      for(const o of selGroup()) o.get().copy(applyTF(o.tf, base));
      rebuildSketch();
    }
    return;
  }
  if(pointerMode==='body'){
    const p=planePoint(e, bodyPicker.position, false);
    if(p) bodyOffset.copy(p).sub(bodyDrag.grab);
    return;
  }
  if(pointerMode==='orbit'){
    orbit.theta-=dx*0.006;
    orbit.phi=Math.max(0.06, Math.min(Math.PI-0.06, orbit.phi-dy*0.006));
  } else {
    const right=new THREE.Vector3().setFromMatrixColumn(camera.matrix,0);
    const up=new THREE.Vector3().setFromMatrixColumn(camera.matrix,1);
    const sc=orbit.dist*0.0016;
    orbit.target.addScaledVector(right,-dx*sc).addScaledVector(up,dy*sc);
  }
  applyCamera();
});

window.addEventListener('pointerup', ()=>{
  if(pointerMode==='draw') commitStroke();
  if(pointerMode==='handle'||pointerMode==='axis'||pointerMode==='scale'){ markStale(); updateSelUI(); }
  axisDrag=null; scaleDrag=null; bodyDrag=null;
  pointerMode=null;
});
renderer.domElement.addEventListener('wheel', e=>{
  e.preventDefault();
  orbit.dist=Math.max(0.8, Math.min(30, orbit.dist*Math.exp(e.deltaY*0.0011)));
  applyCamera();
}, {passive:false});
window.addEventListener('keydown', e=>{
  if(e.target.tagName==='INPUT') return;
  if((e.ctrlKey||e.metaKey) && (e.key==='z'||e.key==='Z')){ e.preventDefault(); undo(); return; }
  if((e.key==='Delete'||e.key==='Backspace') && sel){
    e.preventDefault();
    if(sel.t==='blob') deleteSelBlob(); else deleteCtrlPoint();
  }
});
function deleteSelBlob(){
  if(!sel || sel.t!=='blob' || !blobs[sel.i]) return;
  pushUndo();
  const g=blobs[sel.i].group;
  blobs=blobs.filter(b=>b.group!==g);
  sel=null; updateSelUI(); rebuildSketch(); markStale(); updateStats();
}
function deleteCtrlPoint(){
  if(!sel || sel.t!=='ctrl') return;
  const L=limbs[sel.limb]; if(!L) return;
  pushUndo();
  if(L.ctrl.length<=1){ deleteLimb(); return; }
  for(const o of groupMembers(L.group)) o.L.ctrl.splice(sel.ctrl,1);
  sel=null; updateSelUI(); rebuildSketch(); markStale();
}
function descendants(idx, acc){
  acc.add(idx);
  limbs.forEach((L,i)=>{ if(L.parent===idx && !acc.has(i)) descendants(i,acc); });
  return acc;
}
function deleteLimb(){
  if(!sel) return;
  if(sel.t==='blob'){ deleteSelBlob(); return; }
  if(!limbs[sel.limb]) return;
  pushUndo();
  const g=limbs[sel.limb].group;
  const doomed=new Set();
  for(const o of groupMembers(g)) descendants(o.i, doomed);
  const keep=[], remap=new Array(limbs.length).fill(-1);
  limbs.forEach((L,i)=>{ if(!doomed.has(i)){ remap[i]=keep.length; keep.push(L); } });
  for(const L of keep) L.parent = L.parent>=0 ? remap[L.parent] : -1;
  limbs=keep;
  sel=null; updateSelUI(); rebuildSketch(); markStale(); updateStats();
}
function smoothCtrl(){
  if(!sel || sel.t!=='ctrl' || !limbs[sel.limb]) return;
  pushUndo();
  const g=limbs[sel.limb].group;
  for(const o of groupMembers(g)){
    const c=o.L.ctrl;
    if(c.length<3) continue;
    const out=c.map(p=>p.clone());
    for(let i=1;i<c.length-1;i++)
      out[i].copy(c[i]).multiplyScalar(0.5).addScaledVector(c[i-1],0.25).addScaledVector(c[i+1],0.25);
    o.L.ctrl=out;
  }
  rebuildSketch(); markStale();
}

/* ============================================================
   Feld
   ============================================================ */
function buildPrims(){
  const prims=[{a:[0,0,0], b:[0,0,0], r1:S.centerR, r2:S.centerR, grp:'body'}];
  limbs.forEach((L,li)=>{
    const n=L._j.length;
    let prev=[L._o.x,L._o.y,L._o.z], prevR=radiusAt(0,L);
    for(let i=0;i<n;i++){
      const p=L._j[i], r=radiusAt((i+1)/n, L);
      prims.push({a:prev, b:[p.x,p.y,p.z], r1:prevR, r2:r, grp:'L'+li});
      prev=[p.x,p.y,p.z]; prevR=r;
    }
  });
  for(const e of blobOrder){
    const b=blobs[e.blob], q=e.parent;
    const r1=Math.max(0.012, Math.min(q.r, b.r)*S.linkR);
    prims.push({a:[q.p.x,q.p.y,q.p.z], b:[b.p.x,b.p.y,b.p.z], r1, r2:b.r, grp:'B'+e.blob});
    prims.push({a:[b.p.x,b.p.y,b.p.z], b:[b.p.x,b.p.y,b.p.z], r1:b.r, r2:b.r, grp:'B'+e.blob});
  }
  const pad=2.5*S.k;
  for(const p of prims){
    const r=Math.max(p.r1,p.r2)+pad;
    p.min=[0,1,2].map(i=>Math.min(p.a[i],p.b[i])-r);
    p.max=[0,1,2].map(i=>Math.max(p.a[i],p.b[i])+r);
  }
  return prims;
}
function fieldAt(prims,x,y,z){
  let d=1e9;
  for(const p of prims) d=smin(d, sdRoundCone(x,y,z,p.a[0],p.a[1],p.a[2],p.b[0],p.b[1],p.b[2],p.r1,p.r2), S.k);
  return d;
}
/* rotationsminimierender Rahmen entlang einer Gliedkette */
function limbFrames(nodes){
  const T=[], N=[], arc=[0];
  for(let i=0;i<nodes.length-1;i++){
    const t=new THREE.Vector3().subVectors(nodes[i+1],nodes[i]);
    const l=t.length()||1e-6;
    arc.push(arc[i]+l);
    T.push(t.divideScalar(l));
  }
  T.push(T.length ? T[T.length-1].clone() : new THREE.Vector3(0,1,0));
  const seed=new THREE.Vector3(0,1,0);
  if(Math.abs(seed.dot(T[0]))>0.9) seed.set(1,0,0);
  seed.addScaledVector(T[0], -seed.dot(T[0])).normalize();
  N.push(seed);
  for(let i=1;i<T.length;i++){
    const n=N[i-1].clone();
    n.addScaledVector(T[i], -n.dot(T[i]));
    if(n.lengthSq()<1e-10){
      n.set(0,1,0);
      if(Math.abs(n.dot(T[i]))>0.9) n.set(1,0,0);
      n.addScaledVector(T[i], -n.dot(T[i]));
    }
    N.push(n.normalize());
  }
  return {T,N,arc};
}
function distToSeg(px,py,pz, ax,ay,az, bx,by,bz){
  const ux=bx-ax, uy=by-ay, uz=bz-az;
  const l2=ux*ux+uy*uy+uz*uz;
  let t = l2<1e-12 ? 0 : ((px-ax)*ux+(py-ay)*uy+(pz-az)*uz)/l2;
  t=t<0?0:(t>1?1:t);
  const dx=px-(ax+ux*t), dy=py-(ay+uy*t), dz=pz-(az+uz*t);
  return Math.sqrt(dx*dx+dy*dy+dz*dz);
}

/* ============================================================
   Mesh + Rig
   ============================================================ */
function buildMesh(){
  resolveAll();
  const prims=buildPrims();
  let mn=[1e9,1e9,1e9], mx=[-1e9,-1e9,-1e9];
  for(const p of prims) for(let i=0;i<3;i++){ mn[i]=Math.min(mn[i],p.min[i]); mx[i]=Math.max(mx[i],p.max[i]); }
  for(let i=0;i<3;i++){ mn[i]-=0.05; mx[i]+=0.05; }
  const span=Math.max(mx[0]-mn[0], mx[1]-mn[1], mx[2]-mn[2]);
  const cell=span/S.res;
  const dims=[0,1,2].map(i=>Math.max(5, Math.ceil((mx[i]-mn[i])/cell)+1));
  const total=dims[0]*dims[1]*dims[2];
  if(total>26e6) return {error:'Auflösung zu hoch für dieses Volumen.'};

  const pot=new Float32Array(total);
  let w=0;
  for(let z=0;z<dims[2];z++){
    const pz=mn[2]+z*cell;
    const slab=prims.filter(p=> pz>=p.min[2] && pz<=p.max[2]);
    for(let y=0;y<dims[1];y++){
      const py=mn[1]+y*cell;
      const row=slab.filter(p=> py>=p.min[1] && py<=p.max[1]);
      for(let x=0;x<dims[0];x++){
        const px=mn[0]+x*cell;
        let d=1e9;
        for(let j=0;j<row.length;j++){
          const p=row[j];
          if(px<p.min[0]||px>p.max[0]) continue;
          d=smin(d, sdRoundCone(px,py,pz,p.a[0],p.a[1],p.a[2],p.b[0],p.b[1],p.b[2],p.r1,p.r2), S.k);
        }
        pot[w++]= d>1e8 ? 1e3 : d;
      }
    }
  }
  const sn=surfaceNets(dims,pot,[[mn[0],mn[1],mn[2]],[mn[0]+dims[0]*cell,mn[1]+dims[1]*cell,mn[2]+dims[2]*cell]]);
  if(sn.vertices.length<4) return {error:'Kein Volumen gefunden.'};

  const tris=[];
  for(const f of sn.faces) tris.push(f[0],f[1],f[2], f[0],f[2],f[3]);
  let agree=0, tested=0;
  const step=Math.max(1, Math.floor(tris.length/3/60));
  for(let t=0; t<tris.length/3 && tested<60; t+=step){
    const A=sn.vertices[tris[t*3]], B=sn.vertices[tris[t*3+1]], C=sn.vertices[tris[t*3+2]];
    const ux=B[0]-A[0], uy=B[1]-A[1], uz=B[2]-A[2];
    const vx=C[0]-A[0], vy=C[1]-A[1], vz=C[2]-A[2];
    const nx=uy*vz-uz*vy, ny=uz*vx-ux*vz, nz=ux*vy-uy*vx;
    const e=cell*0.6;
    const gx=fieldAt(prims,A[0]+e,A[1],A[2])-fieldAt(prims,A[0]-e,A[1],A[2]);
    const gy=fieldAt(prims,A[0],A[1]+e,A[2])-fieldAt(prims,A[0],A[1]-e,A[2]);
    const gz=fieldAt(prims,A[0],A[1],A[2]+e)-fieldAt(prims,A[0],A[1],A[2]-e);
    if(nx*gx+ny*gy+nz*gz>0) agree++;
    tested++;
  }
  let quads=sn.faces;
  if(tested && agree<tested/2){
    for(let i=0;i<tris.length;i+=3){ const t=tris[i+1]; tris[i+1]=tris[i+2]; tris[i+2]=t; }
    quads=sn.faces.map(f=>[f[0],f[3],f[2],f[1]]);
  }

  /* --- Knochen, verzweigt --- */
  const bones=[], segs=[[0,0,0, 0,0,0]], depth=[0], limbOf=[-1], newChains=[];
  const restPos=[new THREE.Vector3(0,0,0)], boneAnchor=[null];
  const rootBone=new THREE.Bone(); rootBone.name='root'; bones.push(rootBone);
  const limbBones=[], limbNodes=[], frames=[];   /* pro Glied: Knochen, Stützpunkte, Rahmen */

  limbs.forEach((L,li)=>{
    const nodes=[L._o.clone()].concat(L._j.map(p=>p.clone()));
    let parentBone=rootBone, anchorPos=new THREE.Vector3(0,0,0);
    if(L.parent>=0 && limbBones[L.parent]){
      const PN=limbNodes[L.parent], PB=limbBones[L.parent];
      let bi=0, bd=1e9;
      for(let s=0;s<PN.length-1;s++){
        const d=distToSeg(L._o.x,L._o.y,L._o.z, PN[s].x,PN[s].y,PN[s].z, PN[s+1].x,PN[s+1].y,PN[s+1].z);
        if(d<bd){ bd=d; bi=s; }
      }
      parentBone=PB[bi]; anchorPos=PN[bi].clone();
    }
    const chain=[];
    const base=new THREE.Bone(); base.name='limb'+li+'_base';
    base.position.copy(L._o).sub(anchorPos);
    parentBone.add(base); bones.push(base); chain.push(base);
    restPos.push(L._o.clone()); boneAnchor.push(null);
    segs.push([L._o.x,L._o.y,L._o.z, nodes[1].x,nodes[1].y,nodes[1].z]);
    depth.push(0); limbOf.push(li);
    const bl=[base];
    let parent=base, prev=L._o;
    const n=L._j.length;
    L._j.forEach((p,i)=>{
      const b=new THREE.Bone(); b.name='limb'+li+'_'+i;
      b.position.set(p.x-prev.x, p.y-prev.y, p.z-prev.z);
      parent.add(b); bones.push(b); chain.push(b); bl.push(b);
      restPos.push(p.clone()); boneAnchor.push(null);
      const nx=L._j[i+1];
      segs.push(nx ? [p.x,p.y,p.z, nx.x,nx.y,nx.z] : [p.x,p.y,p.z, p.x,p.y,p.z]);
      depth.push((i+1)/n); limbOf.push(li);
      parent=b; prev=p;
    });
    limbBones[li]=bl; limbNodes[li]=nodes; frames[li]=limbFrames(nodes);
    newChains.push(chain);
  });

  /* --- Blobs: je ein Verbinder am Elternknoten plus der Blobknochen selbst --- */
  const blobBone=[];
  let maxBlobD=1e-4;
  for(const e of blobOrder) maxBlobD=Math.max(maxBlobD, blobs[e.blob].p.length());
  for(const e of blobOrder){
    const b=blobs[e.blob], q=e.parent;
    let pb=rootBone;
    if(q.ref.kind==='limb' && limbBones[q.ref.limb]) pb=limbBones[q.ref.limb][q.ref.j+1];
    else if(q.ref.kind==='blob' && blobBone[q.ref.i]) pb=blobBone[q.ref.i];
    const pbPos=restPos[bones.indexOf(pb)] || new THREE.Vector3();

    const conn=new THREE.Bone(); conn.name='blob'+e.blob+'_link';
    conn.position.copy(q.p).sub(pbPos);
    pb.add(conn); bones.push(conn);
    restPos.push(q.p.clone()); boneAnchor.push(b.p.clone());
    segs.push([q.p.x,q.p.y,q.p.z, b.p.x,b.p.y,b.p.z]);
    depth.push(Math.min(1, q.p.length()/maxBlobD)); limbOf.push(-2);

    const bb=new THREE.Bone(); bb.name='blob'+e.blob;
    bb.position.copy(b.p).sub(q.p);
    conn.add(bb); bones.push(bb);
    restPos.push(b.p.clone()); boneAnchor.push(b.p.clone());
    segs.push([b.p.x,b.p.y,b.p.z, b.p.x,b.p.y,b.p.z]);
    depth.push(Math.min(1, b.p.length()/maxBlobD)); limbOf.push(-2);

    blobBone[e.blob]=bb;
    newChains.push([conn, bb]);
  }

  /* --- Häutung --- */
  const V=sn.vertices.length, B=bones.length, KEEP=6, minD=cell*0.5;
  let idxL=new Int32Array(V*KEEP).fill(-1), wL=new Float32Array(V*KEEP);
  const bw=new Float32Array(B);
  for(let v=0;v<V;v++){
    const p=sn.vertices[v];
    for(let b=0;b<B;b++){
      const s=segs[b];
      let d=distToSeg(p[0],p[1],p[2], s[0],s[1],s[2], s[3],s[4],s[5]);
      if(b===0) d-=S.centerR*0.6;
      d=Math.max(minD,d);
      bw[b]=1/Math.pow(d,S.sharp);
    }
    for(let s=0;s<KEEP;s++){
      let best=-1, bv=-1;
      for(let b=0;b<B;b++){
        if(bw[b]>bv){
          let used=false;
          for(let q=0;q<s;q++) if(idxL[v*KEEP+q]===b){ used=true; break; }
          if(!used){ bv=bw[b]; best=b; }
        }
      }
      idxL[v*KEEP+s]=best<0?0:best;
      wL[v*KEEP+s]=bv<0?0:bv;
    }
    let sum=0; for(let s=0;s<KEEP;s++) sum+=wL[v*KEEP+s];
    if(sum>0) for(let s=0;s<KEEP;s++) wL[v*KEEP+s]/=sum;
  }
  if(S.smooth>0){
    const nb=Array.from({length:V},()=>[]);
    const link=(a,b)=>{ if(nb[a].indexOf(b)<0) nb[a].push(b); if(nb[b].indexOf(a)<0) nb[b].push(a); };
    for(const f of sn.faces){ link(f[0],f[1]); link(f[1],f[2]); link(f[2],f[3]); link(f[3],f[0]); }
    for(let it=0; it<S.smooth; it++){
      const ni=new Int32Array(V*KEEP), nw=new Float32Array(V*KEEP);
      const acc=new Map();
      for(let v=0;v<V;v++){
        acc.clear();
        const add=(base,f)=>{ for(let s=0;s<KEEP;s++){ const b=idxL[base+s], val=wL[base+s]*f; if(val>0) acc.set(b,(acc.get(b)||0)+val); } };
        add(v*KEEP,0.5);
        const list=nb[v], f=list.length?0.5/list.length:0;
        for(let q=0;q<list.length;q++) add(list[q]*KEEP,f);
        const entries=[...acc.entries()].sort((a,b)=>b[1]-a[1]).slice(0,KEEP);
        let sum=0; for(const e of entries) sum+=e[1];
        for(let s=0;s<KEEP;s++){
          ni[v*KEEP+s]=entries[s]?entries[s][0]:0;
          nw[v*KEEP+s]=entries[s]&&sum>0?entries[s][1]/sum:0;
        }
      }
      idxL=ni; wL=nw;
    }
  }

  const pos=new Float32Array(V*3);
  for(let v=0;v<V;v++){ pos[v*3]=sn.vertices[v][0]; pos[v*3+1]=sn.vertices[v][1]; pos[v*3+2]=sn.vertices[v][2]; }
  const si=new Uint16Array(V*4), sw=new Float32Array(V*4);
  for(let v=0;v<V;v++){
    let sum=0;
    for(let s=0;s<4;s++){ si[v*4+s]=idxL[v*KEEP+s]; sw[v*4+s]=wL[v*KEEP+s]; sum+=wL[v*KEEP+s]; }
    if(sum>0) for(let s=0;s<4;s++) sw[v*4+s]/=sum; else { sw[v*4]=1; si[v*4]=0; }
  }
  /* --- UVs: Rumpf sphärisch, Glieder zylindrisch entlang der Kette --- */
  const uv=new Float32Array(V*2);
  const _Q=new THREE.Vector3(), _D=new THREE.Vector3(), _Nv=new THREE.Vector3(), _Bv=new THREE.Vector3();
  for(let v=0;v<V;v++){
    const p=sn.vertices[v];
    const dom=si[v*4], li=limbOf[dom];
    if(li===-2 && boneAnchor[dom]){
      const c=boneAnchor[dom];
      const dx=p[0]-c.x, dy=p[1]-c.y, dz=p[2]-c.z;
      const r=Math.max(1e-6, Math.hypot(dx,dy,dz));
      uv[v*2]=Math.atan2(dz,dx)/(Math.PI*2)+0.5;
      uv[v*2+1]=Math.acos(Math.max(-1,Math.min(1,dy/r)))/Math.PI*r*Math.PI*S.uvDensity;
      continue;
    }
    if(li<0 || !frames[li]){
      const r=Math.max(1e-6, Math.hypot(p[0],p[1],p[2]));
      uv[v*2]=Math.atan2(p[2],p[0])/(Math.PI*2)+0.5;
      uv[v*2+1]=Math.acos(Math.max(-1,Math.min(1,p[1]/r)))/Math.PI*S.centerR*Math.PI*S.uvDensity;
      continue;
    }
    const nodes=limbNodes[li], fr=frames[li];
    let bs=0, bt=0, bd=1e9;
    for(let k=0;k<nodes.length-1;k++){
      const a=nodes[k], c=nodes[k+1];
      const ux=c.x-a.x, uy=c.y-a.y, uz=c.z-a.z;
      const l2=ux*ux+uy*uy+uz*uz;
      let t=l2<1e-12?0:((p[0]-a.x)*ux+(p[1]-a.y)*uy+(p[2]-a.z)*uz)/l2;
      t=t<0?0:(t>1?1:t);
      const dx=p[0]-(a.x+ux*t), dy=p[1]-(a.y+uy*t), dz=p[2]-(a.z+uz*t);
      const d=dx*dx+dy*dy+dz*dz;
      if(d<bd){ bd=d; bs=k; bt=t; }
    }
    const a=nodes[bs], c=nodes[bs+1];
    _Q.copy(a).lerp(c,bt);
    _D.set(p[0]-_Q.x, p[1]-_Q.y, p[2]-_Q.z);
    const T=fr.T[bs];
    _Nv.copy(fr.N[bs]).lerp(fr.N[bs+1],bt);
    _Nv.addScaledVector(T, -_Nv.dot(T));
    if(_Nv.lengthSq()<1e-10) _Nv.copy(fr.N[bs]);
    _Nv.normalize();
    _Bv.crossVectors(T,_Nv);
    uv[v*2]=Math.atan2(_D.dot(_Bv), _D.dot(_Nv))/(Math.PI*2)+0.5;
    uv[v*2+1]=(fr.arc[bs]+bt*(fr.arc[bs+1]-fr.arc[bs]))*S.uvDensity;
  }

  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos,3));
  geo.setAttribute('uv', new THREE.BufferAttribute(uv,2));
  geo.setAttribute('skinIndex', new THREE.BufferAttribute(si,4));
  geo.setAttribute('skinWeight', new THREE.BufferAttribute(sw,4));
  geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(V*3),3));
  geo.setIndex(tris);
  geo.computeVertexNormals();

  return {geo, bones, chains:newChains, quads,
          depth:Float32Array.from(depth), limbOf:Int32Array.from(limbOf),
          verts:V, tris:tris.length/3};
}

function recolor(){
  if(!mesh) return;
  const col=mesh.geometry.attributes.color.array;
  for(let i=0;i<col.length;i+=3){ col[i]=mainColor.r; col[i+1]=mainColor.g; col[i+2]=mainColor.b; }
  mesh.geometry.attributes.color.needsUpdate=true;
}
function disposeMesh(){
  if(mesh){ scene.remove(mesh); mesh.geometry.dispose(); mesh=null; }
  if(wireMesh){ scene.remove(wireMesh); wireMesh=null; }
  if(helper){ scene.remove(helper); helper=null; }
  skeleton=null; chains=[]; boneDepth=null; boneLimb=null;
}
function generate(){
  const busy=document.getElementById('busy');
  busy.dataset.on='1';
  setTimeout(()=>{
    const t0=performance.now();
    let r;
    try{ r=buildMesh(); }catch(err){ r={error:String(err&&err.message||err)}; }
    busy.dataset.on='0';
    if(r.error){
      document.getElementById('meshInfo').textContent='Fehler';
      document.getElementById('buildNote').textContent=r.error;
      document.getElementById('hint').textContent='Mesh-Aufbau fehlgeschlagen: '+r.error;
      return;
    }
    disposeMesh();
    mesh=new THREE.SkinnedMesh(r.geo, surfaceMat());
    mesh.frustumCulled=false;
    mesh.add(r.bones[0]);
    mesh.updateMatrixWorld(true);
    skeleton=new THREE.Skeleton(r.bones);
    mesh.bind(skeleton);
    chains=r.chains; quadFaces=r.quads; initFlowState();
    boneDepth=r.depth; boneLimb=r.limbOf; limbTotal=limbs.length;
    scene.add(mesh); recolor();

    wireMesh=new THREE.SkinnedMesh(r.geo, matWire);
    wireMesh.frustumCulled=false;
    wireMesh.bind(skeleton, new THREE.Matrix4());
    wireMesh.visible=document.getElementById('cWire').checked;
    scene.add(wireMesh);

    helper=new THREE.SkeletonHelper(mesh);
    helper.material.vertexColors=false;
    helper.material.color.set(0x0b6b62);
    helper.material.needsUpdate=true;
    helper.visible=document.getElementById('cBones').checked;
    scene.add(helper);

    mesh.visible=document.getElementById('cMesh').checked;
    stale=false; updateBuildButton();
    document.getElementById('meshInfo').textContent=(r.tris/1000).toFixed(1)+'k △';
    document.getElementById('buildNote').textContent=
      r.verts.toLocaleString('de-DE')+' Punkte · '+r.tris.toLocaleString('de-DE')+' Dreiecke · '+
      r.bones.length+' Knochen · '+Math.round(performance.now()-t0)+' ms';
    updateStats();
  },30);
}

/* ============================================================
   Strömung
   ============================================================ */
const _pp=new THREE.Vector3(), _pq=new THREE.Quaternion(), _ps=new THREE.Vector3();
const _wp=new THREE.Vector3(), _d=new THREE.Vector3(), _rest=new THREE.Vector3();
const _flow=new THREE.Vector3(), _tgt=new THREE.Vector3(), _iq=new THREE.Quaternion();
const _qa=new THREE.Quaternion(), _qb=new THREE.Quaternion();
const smoothstep01 = x => { const g=Math.min(1,Math.max(0,x)); return g*g*(3-2*g); };
/* Richtungswinkel und Amplitude wandern beide stetig durch das Rauschfeld */
function sampleFlow(X,Y,Z,out){
  const th = noise3(X,Y,Z)*Math.PI*2;
  const ph = noise3(X+19.3, Y+7.1, Z+4.4)*Math.PI*0.5;
  const g  = noise3(X+53.7, Y+31.9, Z+8.8);
  const amp = Math.pow(0.5+0.5*g, 1+S.wave*4);
  const cp = Math.cos(ph);
  out.set(Math.cos(th)*cp*amp, Math.sin(ph)*amp, Math.sin(th)*cp*amp);
  return out;
}
const _fs=new THREE.Vector3();
function flowVec(px,py,pz, t, out){
  const f=S.freq, adv=t*S.speed;
  if(S.animSym==='radial'){
    const n=Math.max(2,Math.round(S.radial));
    const r=Math.hypot(px,pz), th=Math.atan2(pz,px);
    const A=0.9, u=Math.cos(n*th)*A, v=Math.sin(n*th)*A;
    sampleFlow(r*f+u, py*f, v+adv, _fs);
    const damp=smoothstep01(r/0.3);
    const fr=_fs.x*damp + S.wind*damp, fy=_fs.y + S.lift, ft=_fs.z*damp;
    const c=Math.cos(th), sn=Math.sin(th);
    out.set(fr*c-ft*sn, fy, fr*sn+ft*c);
    return out;
  }
  const mir = S.animSym==='x';
  const qx = mir ? Math.abs(px) : px;
  sampleFlow(qx*f, py*f, pz*f+adv, _fs);
  const a=S.windDir*Math.PI/180;
  let bx=Math.cos(a)*S.wind, fx=_fs.x;
  const bz=Math.sin(a)*S.wind;
  if(mir){
    const sg = px<0 ? -1 : 1, sm = smoothstep01(Math.abs(px)/0.35);
    fx *= sm*sg; bx *= sm*sg;
  }
  out.set(fx+bx, _fs.y+S.lift, _fs.z+bz);
  return out;
}

/* ---- Kraft-Feder-Kette: jeder Knochen ist ein Kugelpendel im Elternraum ---- */
function initFlowState(){
  for(const chain of chains){
    for(let i=0;i<chain.length-1;i++){
      const bone=chain[i], child=chain[i+1];
      const d=child.position.clone();
      if(d.lengthSq()<1e-12) d.set(0,1,0);
      d.normalize();
      bone.userData.rest=d;
      bone.userData.dl=d.clone();
      bone.userData.vel=new THREE.Vector3();
      bone.userData.qp=null;
    }
  }
}
const _acc=new THREE.Vector3(), _perp=new THREE.Vector3();
function updateRig(dt, t){
  if(!chains.length || !skeleton) return;
  const root=skeleton.bones[0];
  if(S.body>0){
    const amp=S.body*0.5, ph=t*S.speed*0.6;
    if(S.animSym==='x') root.rotation.set(noise3(3.1,0.7,ph)*amp,0,0);
    else if(S.animSym==='radial') root.rotation.set(0,noise3(9.4,2.2,ph)*amp,0);
    else root.rotation.set(noise3(3.1,0.7,ph)*amp*0.5, noise3(9.4,2.2,ph)*amp, noise3(5.8,6.3,ph)*amp*0.5);
  } else root.rotation.set(0,0,0);

  const sub=Math.min(4, Math.max(1, Math.ceil(dt*120)));
  const h=dt/sub;
  const maxA=S.bend*Math.PI/180;

  for(const chain of chains){
    const L=chain.length;
    for(let i=0;i<L-1;i++){
      const bone=chain[i], st=bone.userData;
      if(!st.dl) continue;
      bone.parent.updateWorldMatrix(true,false);
      bone.parent.matrixWorld.decompose(_pp,_pq,_ps);
      _wp.copy(bone.position).applyQuaternion(_pq).add(_pp);

      /* Trägheit: die Drehung des Elternteils wird anteilig „verschlafen“ */
      if(st.qp){
        _qa.copy(_pq).invert().multiply(st.qp);
        _qb.set(0,0,0,1).slerp(_qa, S.inert);
        st.dl.applyQuaternion(_qb).normalize();
        st.vel.applyQuaternion(_qb);
      } else st.qp=new THREE.Quaternion();
      st.qp.copy(_pq);

      /* Feld einmal pro Bild abtasten, dann in den Elternraum drehen */
      flowVec(_wp.x,_wp.y,_wp.z, t, _flow);
      _flow.addScaledVector(bodyVel, -S.drag*0.12);   /* Nachschleppen beim Ziehen */
      _flow.applyQuaternion(_iq.copy(_pq).invert());

      const rest=st.rest;
      const k=S.spring*Math.pow(L/(i+1), S.stiff);
      const gain=S.amt*Math.pow((i+1)/L, 0.5);
      const decay=Math.exp(-S.damp*h);

      for(let sIt=0; sIt<sub; sIt++){
        _perp.copy(_flow).addScaledVector(st.dl, -_flow.dot(st.dl));   /* nur quer wirkt drehend */
        _acc.copy(_perp).multiplyScalar(gain);
        _acc.addScaledVector(rest, k).addScaledVector(st.dl, -k);      /* Rückstellfeder */
        st.vel.addScaledVector(_acc, h).multiplyScalar(decay);
        const vl=st.vel.lengthSq();
        if(vl>625) st.vel.multiplyScalar(25/Math.sqrt(vl));
        st.dl.addScaledVector(st.vel, h);
        if(st.dl.lengthSq()<1e-8) st.dl.copy(rest);
        st.dl.normalize();
        st.vel.addScaledVector(st.dl, -st.vel.dot(st.dl));             /* tangential halten */
      }

      const dot=Math.max(-1,Math.min(1, st.dl.dot(rest)));
      const ang=Math.acos(dot);
      if(ang>maxA && ang>1e-6){
        _qa.setFromUnitVectors(rest, st.dl);
        _qb.set(0,0,0,1).slerp(_qa, maxA/ang);
        st.dl.copy(rest).applyQuaternion(_qb).normalize();
        st.vel.multiplyScalar(0.4);
      }
      bone.quaternion.setFromUnitVectors(rest, st.dl);
    }
  }
}
function restPose(){
  if(!skeleton) return;
  for(const b of skeleton.bones) b.quaternion.identity();
  skeleton.bones[0].rotation.set(0,0,0);
  initFlowState();
}

/* ============================================================
   Export
   ============================================================ */
function download(name,data,type){
  const a=document.createElement('a');
  a.href=URL.createObjectURL(new Blob([data],{type:type||'text/plain'}));
  a.download=name; a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href),6000);
}
function skinnedPositions(){
  const g=mesh.geometry, pos=g.attributes.position, si=g.attributes.skinIndex, sw=g.attributes.skinWeight;
  mesh.updateMatrixWorld(true);
  const mats=skeleton.bones.map((b,i)=>new THREE.Matrix4().multiplyMatrices(b.matrixWorld, skeleton.boneInverses[i]));
  const out=new Float32Array(pos.count*3);
  const v=new THREE.Vector3(), acc=new THREE.Vector3(), tmp=new THREE.Vector3();
  for(let i=0;i<pos.count;i++){
    v.fromBufferAttribute(pos,i); acc.set(0,0,0);
    for(let s=0;s<4;s++){
      const w=sw.getComponent(i,s); if(w<=0) continue;
      tmp.copy(v).applyMatrix4(mats[si.getComponent(i,s)]);
      acc.addScaledVector(tmp,w);
    }
    out[i*3]=acc.x; out[i*3+1]=acc.y; out[i*3+2]=acc.z;
  }
  return out;
}
function exportOBJ(){
  if(!mesh) return;
  const P=skinnedPositions(), idx=mesh.geometry.index.array;
  const U=mesh.geometry.attributes.uv ? mesh.geometry.attributes.uv.array : null;
  const L=['# Blobber v1.0 — aktuelle Pose','o blob'];
  for(let i=0;i<P.length;i+=3) L.push('v '+P[i].toFixed(5)+' '+P[i+1].toFixed(5)+' '+P[i+2].toFixed(5));
  if(U) for(let i=0;i<U.length;i+=2) L.push('vt '+U[i].toFixed(5)+' '+U[i+1].toFixed(5));
  for(let i=0;i<idx.length;i+=3){
    const a=idx[i]+1, b=idx[i+1]+1, c=idx[i+2]+1;
    L.push(U ? ('f '+a+'/'+a+' '+b+'/'+b+' '+c+'/'+c) : ('f '+a+' '+b+' '+c));
  }
  download('blobber-pose.obj', L.join('\n'));
}
function exportFBX(){
  if(!mesh) return;
  const usePose=document.getElementById('cPose').checked;
  const g=mesh.geometry, e=new THREE.Euler(), DEG=180/Math.PI;

  /* FBX rechnet in Zentimetern. Wir skalieren so, dass die Kreatur die
     eingestellte Höhe hat — dann landet sie in Unity direkt in Metern. */
  const bb=new THREE.Box3().setFromBufferAttribute(g.attributes.position);
  const hw=Math.max(1e-4, bb.max.y-bb.min.y);
  const sc=S.expCm/hw;

  const bonesData=skeleton.bones.map((b,i)=>{
    const world=new THREE.Matrix4().copy(skeleton.boneInverses[i]).invert();
    const we=world.elements.slice();
    we[12]*=sc; we[13]*=sc; we[14]*=sc;
    const scaledWorld=new THREE.Matrix4().fromArray(we);
    const scaledInv=new THREE.Matrix4().copy(scaledWorld).invert();
    let rot=[0,0,0];
    if(usePose){ e.setFromQuaternion(b.quaternion,'ZYX'); rot=[e.x*DEG,e.y*DEG,e.z*DEG]; }
    const kids=b.children.filter(c=>c.isBone).map(c=>c.position.length()*sc).filter(v=>v>1e-4);
    const size=kids.length ? Math.max.apply(null,kids)
                           : Math.max(0.1, b.position.length()*sc || S.expCm*0.05);
    return {
      name: b.name||('bone'+i),
      parent: (b.parent&&b.parent.isBone) ? skeleton.bones.indexOf(b.parent) : -1,
      pos: b.position.toArray().map(v=>v*sc), rot, size,
      transformLink: Array.from(scaledWorld.elements),
      transform: Array.from(scaledInv.elements)
    };
  });
  const pos=Float32Array.from(g.attributes.position.array, v=>v*sc);
  download('blobber-creature.fbx', fbxBuild({
    positions:pos, normals:g.attributes.normal.array,
    colors:g.attributes.color.array, uvs:g.attributes.uv ? g.attributes.uv.array : null,
    quads:quadFaces, bones:bonesData,
    skinIndex:g.attributes.skinIndex.array, skinWeight:g.attributes.skinWeight.array,
    meshName:'blob'
  }), 'application/octet-stream');
  document.getElementById('expNote').textContent=
    'FBX geschrieben · '+skeleton.bones.length+' Knochen · Höhe '+Math.round(S.expCm)+' cm'+
    (usePose?' · in aktueller Pose':' · in Ruhepose');
}
function exportRig(){
  if(!mesh) return;
  const g=mesh.geometry;
  download('blobber-rig.json', JSON.stringify({
    format:'blobber.rig.v1',
    bones: skeleton.bones.map(b=>({
      name:b.name||'bone',
      parent:(b.parent&&b.parent.isBone)?skeleton.bones.indexOf(b.parent):-1,
      position:b.position.toArray()
    })),
    vertices:Array.from(g.attributes.position.array, v=>+v.toFixed(5)),
    triangles:Array.from(g.index.array),
    quads:quadFaces,
    skinIndex:Array.from(g.attributes.skinIndex.array),
    skinWeight:Array.from(g.attributes.skinWeight.array, v=>+v.toFixed(4))
  }), 'application/json');
}
function exportScene(){
  download('blobber-szene.json', JSON.stringify({
    format:'blobber.scene.v3',
    settings:S,
    blobs: blobs.map(b=>({p:[+b.p.x.toFixed(4),+b.p.y.toFixed(4),+b.p.z.toFixed(4)],
                          r:+b.r.toFixed(4), group:b.group, vi:b.vi, tf:b.tf})),
    color: document.getElementById('p0').value,
    thickness:thickPts,
    limbs:limbs.map(L=>({
      ctrl:L.ctrl.map(p=>[+p.x.toFixed(4),+p.y.toFixed(4),+p.z.toFixed(4)]),
      parent:L.parent, attachT:+L.attachT.toFixed(4), group:L.group, vi:L.vi, tf:L.tf,
      scale:L.scale||1, nb:L.nb||0
    }))
  }), 'application/json');
}
function loadScene(text){
  try{
    const d=JSON.parse(text);
    if(!d.limbs) throw new Error('Keine Gliedmaßen in der Datei.');
    if(d.settings) for(const k in d.settings) if(k in S && typeof S[k]===typeof d.settings[k]) S[k]=d.settings[k];
    const col = d.color || (d.palette && d.palette[1]);
    if(col) document.getElementById('p0').value=col;
    if(d.thickness && d.thickness.length){ thickPts.length=0; d.thickness.forEach(p=>thickPts.push({x:p.x,y:p.y})); }
    readPalette();
    if(d.format==='blob-rigger.scene.v2' || d.format==='blob-rigger.scene.v3' || d.format==='blobber.scene.v3'){
      limbs=d.limbs.map(L=>({
        ctrl:L.ctrl.map(p=>new THREE.Vector3(p[0],p[1],p[2])),
        parent:L.parent, attachT:L.attachT||0, group:L.group||0, vi:L.vi||0, tf:L.tf||{mir:0,ang:0},
        scale:L.scale||1, nb:L.nb||0
      }));
    } else {   /* v1: reine Punktketten */
      limbs=d.limbs.map((pts,i)=>({
        ctrl:pts.map(p=>new THREE.Vector3(p[0],p[1],p[2])),
        parent:-1, attachT:0, group:i+1, vi:0, tf:{mir:0,ang:0}, scale:1, nb:0
      }));
    }
    blobs=(d.blobs||[]).map(b=>({p:new THREE.Vector3(b.p[0],b.p[1],b.p[2]),
      r:b.r||0.16, group:b.group||0, vi:b.vi||0, tf:b.tf||{mir:0,ang:0}}));
    groupSeq=limbs.reduce((m,L)=>Math.max(m,L.group),0);
    blobSeq=blobs.reduce((m,b)=>Math.max(m,b.group),0);
    sel=null; updateSelUI(); syncSliders(); drawThick(); rebuildSketch(); updateStats(); generate();
  }catch(err){
    document.getElementById('buildNote').textContent='Datei konnte nicht gelesen werden: '+err.message;
  }
}

/* ============================================================
   Dickenverlauf-Editor
   ============================================================ */
const tc=document.getElementById('thickCanvas');
const tg=tc.getContext('2d');
let tDrag=-1;
function tcSize(){
  const r=tc.getBoundingClientRect(), dpr=Math.min(devicePixelRatio,2);
  tc.width=Math.max(1,Math.round(r.width*dpr)); tc.height=Math.max(1,Math.round(r.height*dpr));
  tg.setTransform(dpr,0,0,dpr,0,0);
  return {w:r.width, h:r.height};
}
function drawThick(){
  const {w,h}=tcSize(), pad=10;
  const X=t=>pad+t*(w-2*pad), Y=v=>h-pad-v*(h-2*pad);
  tg.clearRect(0,0,w,h);
  tg.strokeStyle='#f1f2f3'; tg.lineWidth=1;
  for(let i=1;i<4;i++){ const x=X(i/4); tg.beginPath(); tg.moveTo(x,pad); tg.lineTo(x,h-pad); tg.stroke(); }
  for(let i=1;i<3;i++){ const y=Y(i/3); tg.beginPath(); tg.moveTo(pad,y); tg.lineTo(w-pad,y); tg.stroke(); }
  tg.beginPath();
  tg.moveTo(X(0), Y(0));
  for(let i=0;i<=120;i++){ const t=i/120; tg.lineTo(X(t), Y(thickAt(t))); }
  tg.lineTo(X(1), Y(0)); tg.closePath();
  tg.fillStyle='#e6f0ef'; tg.fill();
  tg.beginPath();
  for(let i=0;i<=120;i++){ const t=i/120; const x=X(t), y=Y(thickAt(t)); i?tg.lineTo(x,y):tg.moveTo(x,y); }
  tg.strokeStyle='#111214'; tg.lineWidth=1.5; tg.stroke();
  thickPts.forEach((p,i)=>{
    tg.beginPath(); tg.arc(X(p.x), Y(p.y), i===tDrag?5.5:4.5, 0, Math.PI*2);
    tg.fillStyle= i===tDrag ? '#111214' : '#0b6b62'; tg.fill();
    tg.strokeStyle='#fff'; tg.lineWidth=1.5; tg.stroke();
  });
  document.getElementById('thickInfo').textContent=thickPts.length+' Punkte';
}
function tcPos(e){
  const r=tc.getBoundingClientRect(), pad=10;
  const t=(e.clientX-r.left-pad)/(r.width-2*pad);
  const v=1-(e.clientY-r.top-pad)/(r.height-2*pad);
  return {t:Math.max(0,Math.min(1,t)), v:Math.max(0,Math.min(1,v)), px:e.clientX-r.left, py:e.clientY-r.top, w:r.width, h:r.height};
}
function tcNearest(p){
  const pad=10;
  let bi=-1, bd=1e9;
  thickPts.forEach((q,i)=>{
    const x=pad+q.x*(p.w-2*pad), y=p.h-pad-q.y*(p.h-2*pad);
    const d=Math.hypot(x-p.px, y-p.py);
    if(d<bd){ bd=d; bi=i; }
  });
  return bd<12 ? bi : -1;
}
tc.addEventListener('pointerdown', e=>{
  tc.setPointerCapture(e.pointerId);
  const p=tcPos(e);
  let i=tcNearest(p);
  if(i<0){
    thickPts.push({x:p.t,y:p.v});
    thickPts.sort((a,b)=>a.x-b.x);
    i=thickPts.findIndex(q=>q.x===p.t && q.y===p.v);
    onThickChange();
  }
  tDrag=i; drawThick();
});
tc.addEventListener('pointermove', e=>{
  if(tDrag<0) return;
  const p=tcPos(e);
  const isEnd = tDrag===0 || tDrag===thickPts.length-1;
  if(!isEnd){
    const lo=thickPts[tDrag-1].x+0.01, hi=thickPts[tDrag+1].x-0.01;
    thickPts[tDrag].x=Math.max(lo, Math.min(hi, p.t));
  }
  thickPts[tDrag].y=p.v;
  drawThick(); onThickChange();
});
window.addEventListener('pointerup', ()=>{ if(tDrag>=0){ tDrag=-1; drawThick(); } });
tc.addEventListener('dblclick', e=>{
  const p=tcPos(e), i=tcNearest(p);
  if(i>0 && i<thickPts.length-1){ thickPts.splice(i,1); drawThick(); onThickChange(); }
});
function onThickChange(){ rebuildSketch(); markStale(); }

/* ============================================================
   Schnittbogen — Abwicklung in Keilbahnen
   ============================================================ */
function resamplePolyline(pts, n){
  const cum=[0];
  for(let i=0;i<pts.length-1;i++) cum.push(cum[i]+pts[i].distanceTo(pts[i+1]));
  const total=cum[cum.length-1];
  if(total<1e-6) return [pts[0].clone(), pts[0].clone().add(new THREE.Vector3(1e-3,0,0))];
  const out=[];
  for(let i=0;i<=n;i++){
    const d=total*i/n;
    let k=0; while(k<cum.length-2 && cum[k+1]<d) k++;
    const t=(d-cum[k])/Math.max(1e-9, cum[k+1]-cum[k]);
    out.push(pts[k].clone().lerp(pts[k+1], Math.max(0,Math.min(1,t))));
  }
  return out;
}
/* Strahl vom Mittelpunkt nach außen bis zum Vorzeichenwechsel, dann halbieren */
function surfaceDist(prims, ox,oy,oz, dx,dy,dz, maxT, minStep){
  let d=fieldAt(prims,ox,oy,oz);
  if(d>=0) return 0;
  let t=0, tin=0;
  for(let i=0;i<90;i++){
    tin=t; t+=Math.max(minStep, -d*0.9);
    if(t>maxT) return maxT;
    d=fieldAt(prims, ox+dx*t, oy+dy*t, oz+dz*t);
    if(d>=0) break;
  }
  let lo=tin, hi=t;
  for(let i=0;i<20;i++){
    const m=(lo+hi)*0.5;
    if(fieldAt(prims, ox+dx*m, oy+dy*m, oz+dz*m)<0) lo=m; else hi=m;
  }
  return (lo+hi)*0.5;
}
/* Anfang abschneiden, solange der Strang noch im Elternkörper steckt —
   sonst misst die erste Station dessen Umfang statt den eigenen. */
function extendEnd(pts, r){
  if(pts.length<2 || r<=1e-4) return pts;
  const a=pts[pts.length-2], b=pts[pts.length-1];
  const d=b.clone().sub(a);
  if(d.lengthSq()<1e-10) return pts;
  return pts.concat([b.clone().addScaledVector(d.normalize(), r*0.98)]);
}
function trimStart(pts, R){
  if(pts.length<2 || R<=1e-4) return pts;
  const o=pts[0];
  let i=1;
  while(i<pts.length-1 && o.distanceTo(pts[i])<R) i++;
  const a=pts[i-1], b=pts[i];
  let lo=0, hi=1;
  for(let k=0;k<22;k++){
    const m=(lo+hi)*0.5;
    if(o.distanceTo(a.clone().lerp(b,m))<R) lo=m; else hi=m;
  }
  const cut=a.clone().lerp(b,(lo+hi)*0.5);
  const rest=[cut].concat(pts.slice(i));
  let len=0;
  for(let k=0;k<rest.length-1;k++) len+=rest[k].distanceTo(rest[k+1]);
  return len>R*0.25 ? rest : pts;
}
function byKeyAlias(st, k){ st.key=k; }
/* Stränge: jedes Glied und jede Blobkette wird zu einer Röhre */
function patStrands(){
  const out=[];
  limbs.forEach((L,i)=>{
    if(!L._j || !L._j.length) return;
    const pts=[L._o.clone()].concat(L._j.map(q=>q.clone()));
    const R = L.parent>=0 && limbs[L.parent]
      ? radiusAt(Math.max(0,Math.min(1,L.attachT)), limbs[L.parent])
      : S.centerR;
    out.push({label:'Glied '+(i+1), origin:L._o.clone(),
              pts:extendEnd(trimStart(pts,R), radiusAt(1,L)),
              tags:new Set(['L'+i]), sym:'L'+L.group, vi:L.vi||0,
              key:'L'+i,
              contKey:(L.parent>=0 && L.attachT>=0.97) ? 'L'+L.parent : null});
  });
  const seen=new Map();
  for(const e of blobOrder){
    const b=blobs[e.blob], q=e.parent;
    if(q.ref.kind==='blob' && seen.has(q.ref.i)){
      const st=seen.get(q.ref.i);
      st.pts.push(b.p.clone());
      st.tags.add('B'+e.blob);
      st.tipR=b.r; byKeyAlias(st, 'B'+e.blob);
      seen.set(e.blob, st);
    } else {
      const nJ = q.ref.kind==='limb' && limbs[q.ref.limb] && limbs[q.ref.limb]._j
                 ? limbs[q.ref.limb]._j.length : 0;
      const st={label:'Blob '+(e.blob+1), origin:q.p.clone(),
                pts:trimStart([q.p.clone(), b.p.clone()], q.r), tags:new Set(['B'+e.blob]),
                sym:'B'+b.group, vi:b.vi||0, tipR:b.r, key:'B'+e.blob,
                contKey:(q.ref.kind==='limb' && q.ref.j===nJ-1) ? 'L'+q.ref.limb : null};
      out.push(st); seen.set(e.blob, st);
    }
  }
  for(const st of out) if(st.tipR) st.pts=extendEnd(st.pts, st.tipR);
  /* Stränge, die genau am Ende eines anderen ansetzen, zu einem Teil verbinden */
  const byKey=new Map();
  out.forEach(st=>{ if(st.key) byKey.set(st.key, st); });
  const merged=[];
  for(const st of out){
    const host = st.contKey ? byKey.get(st.contKey) : null;
    if(host && host!==st && !host.mergedInto){
      host.pts = host.pts.slice(0, host.pts.length-(host.tipR?1:0)).concat(st.pts.slice(1));
      st.tags.forEach(t=>host.tags.add(t));
      host.label += '+' + st.label;
      st.mergedInto = host;
      continue;
    }
    merged.push(st);
  }
  return merged;
}
/* Querschnitte messen: Breite je Bahn und Länge auf der Oberfläche */
function measureStrand(prims, pts, N, stations, maxT, minStep){
  const K=Math.max(48, N*12);
  const nodes=resamplePolyline(pts, stations);
  const fr=limbFrames(nodes);
  const W=[], MID=[];
  const T=new THREE.Vector3(), Nv=new THREE.Vector3(), Bv=new THREE.Vector3();
  for(let i=0;i<nodes.length;i++){
    const P=nodes[i];
    T.copy(fr.T[Math.min(i, fr.T.length-1)]);
    Nv.copy(fr.N[Math.min(i, fr.N.length-1)]);
    Nv.addScaledVector(T, -Nv.dot(T));
    if(Nv.lengthSq()<1e-12) Nv.set(T.y,-T.x,0);
    Nv.normalize();
    Bv.crossVectors(T,Nv);
    const sp=new Array(K);
    for(let k=0;k<K;k++){
      const a=k/K*Math.PI*2, c=Math.cos(a), sn=Math.sin(a);
      const dx=Nv.x*c+Bv.x*sn, dy=Nv.y*c+Bv.y*sn, dz=Nv.z*c+Bv.z*sn;
      const r=surfaceDist(prims,P.x,P.y,P.z,dx,dy,dz,maxT,minStep);
      sp[k]=new THREE.Vector3(P.x+dx*r, P.y+dy*r, P.z+dz*r);
    }
    const w=[], mid=[];
    for(let g=0; g<N; g++){
      let len=0;
      const k0=Math.round(g*K/N), k1=Math.round((g+1)*K/N);
      for(let k=k0;k<k1;k++) len+=sp[k].distanceTo(sp[(k+1)%K]);
      w.push(len);
      mid.push(sp[Math.round(k0+K/(2*N))%K].clone());
    }
    W.push(w); MID.push(mid);
  }
  return {W, MID, nodes};
}
function pieceFromGore(label, widths, ys, U, seam, notch, count, ids){
  const w=widths.map(v=>Math.max(0.4, v*U));
  const y=ys.map(v=>v*U);
  const H=y[y.length-1];
  const maxW=Math.max.apply(null,w);
  const seamPts=[];
  for(let i=0;i<w.length;i++) seamPts.push([-w[i]/2, y[i]]);
  for(let i=w.length-1;i>=0;i--) seamPts.push([w[i]/2, y[i]]);
  const cut=[[-w[0]/2-seam, -seam]];
  for(let i=0;i<w.length;i++) cut.push([-w[i]/2-seam, y[i]]);
  cut.push([-w[w.length-1]/2-seam, H+seam], [w[w.length-1]/2+seam, H+seam]);
  for(let i=w.length-1;i>=0;i--) cut.push([w[i]/2+seam, y[i]]);
  cut.push([w[0]/2+seam, -seam]);
  const notches=[];
  for(let d=notch; d<H-notch*0.4; d+=notch){
    let i=0; while(i<y.length-2 && y[i+1]<d) i++;
    const t=(d-y[i])/Math.max(1e-6, y[i+1]-y[i]);
    const ww=w[i]+(w[i+1]-w[i])*t;
    notches.push({y:d, x:ww/2, n:Math.round(d/notch)});
  }
  const o=ids||{};
  return {label, count:count||1, seamPts, cut, notches, marks:[],
          wArr:w, yArr:y,
          seamL:o.seamL||null, seamR:o.seamR||null, baseId:o.baseId||null,
          A:maxW/2+seam, H, w:maxW+2*seam, h:H+2*seam};
}
/* zu lange Bahnen in Abschnitte mit Klebekante teilen */
function splitGore(w, y, maxLen){
  const H=y[y.length-1];
  if(H<=maxLen || maxLen<20) return [{w, y, part:1, parts:1, yOff:0}];
  const parts=Math.ceil(H/maxLen), out=[];
  const at=d=>{ let i=0; while(i<y.length-2 && y[i+1]<d) i++;
    const t=(d-y[i])/Math.max(1e-6, y[i+1]-y[i]); return w[i]+(w[i+1]-w[i])*t; };
  for(let sIdx=0;sIdx<parts;sIdx++){
    const y0=H*sIdx/parts, y1=H*(sIdx+1)/parts;
    const ws=[at(y0)], ys=[0];
    for(let i=0;i<y.length;i++) if(y[i]>y0+1e-6 && y[i]<y1-1e-6){ ws.push(w[i]); ys.push(y[i]-y0); }
    ws.push(at(y1)); ys.push(y1-y0);
    out.push({w:ws, y:ys, part:sIdx+1, parts, glueStart:sIdx>0, glueEnd:sIdx<parts-1, yOff:y0});
  }
  return out;
}
const PAGES={a4:[210,297], a3:[297,420]};
function layoutPages(pieces, pageW, pageH, margin, gap){
  const uW=pageW-2*margin, uH=pageH-2*margin;
  const pages=[]; let cur=null, x=margin, y=margin, rowH=0;
  const newPage=()=>{ cur={items:[]}; pages.push(cur); x=margin; y=margin; rowH=0; };
  newPage();
  for(const p of pieces){
    let w=p.w, h=p.h, rot=false;
    if(h>uH && p.w<=uH && p.h<=uW){ rot=true; w=p.h; h=p.w; }
    if(x+w>margin+uW+1e-6 && cur.items.length){ x=margin; y+=rowH+gap; rowH=0; }
    if(y+h>margin+uH+1e-6 && cur.items.length){ newPage(); }
    cur.items.push({p,x,y,rot});
    x+=w+gap; rowH=Math.max(rowH,h);
  }
  return pages;
}
const n2=v=>(+v.toFixed(2));
function poly(pts){ return pts.map(p=>n2(p[0])+','+n2(p[1])).join(' '); }
function texDefs(tex){
  if(!tex) return '';
  const T=tex.tile, t2=n2(T*2);
  const img=x=>'<image xlink:href="'+tex.url+'" x="0" y="0" width="'+n2(T)+'" height="'+n2(T)+'" preserveAspectRatio="none"'+x+'/>';
  return '<defs><pattern id="tx" patternUnits="userSpaceOnUse" width="'+t2+'" height="'+t2+'">'
    + img('')
    + img(' transform="translate('+t2+',0) scale(-1,1)"')
    + img(' transform="translate(0,'+t2+') scale(1,-1)"')
    + img(' transform="translate('+t2+','+t2+') scale(-1,-1)"')
    + '</pattern></defs>';
}
function patternSVG(pieces, info, tex){
  const [pw,ph]=PAGES[S.page]||PAGES.a4;
  const margin=10, gap=6;
  const pages=layoutPages(pieces, pw, ph, margin, gap);
  const cols=Math.min(3, pages.length), rows=Math.ceil(pages.length/cols);
  const W=cols*pw, H=rows*ph;
  let o='<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" '
      +'width="'+W+'mm" height="'+H+'mm" viewBox="0 0 '+W+' '+H+'">';
  o+='<rect width="'+W+'" height="'+H+'" fill="#fff"/>';
  o+=texDefs(tex);
  o+='<g font-family="Helvetica,Arial,sans-serif" fill="none" stroke-linejoin="round">';
  pages.forEach((pg,pi)=>{
    const ox=(pi%cols)*pw, oy=Math.floor(pi/cols)*ph;
    o+='<g transform="translate('+ox+','+oy+')">';
    o+='<rect x="0.2" y="0.2" width="'+(pw-0.4)+'" height="'+(ph-0.4)+'" stroke="#c8ccd0" stroke-width="0.3" stroke-dasharray="3 2"/>';
    [[margin,margin],[pw-margin,margin],[margin,ph-margin],[pw-margin,ph-margin]].forEach(c=>{
      o+='<path d="M'+(c[0]-3)+' '+c[1]+'h6M'+c[0]+' '+(c[1]-3)+'v6" stroke="#111" stroke-width="0.25"/>';
    });
    o+='<text x="'+margin+'" y="'+(ph-margin+5)+'" font-size="3.2" fill="#6b6e73" stroke="none">BLOBBER v1.0 · Seite '+(pi+1)+'/'+pages.length+' · '+info+'</text>';
    if(pi===0){
      o+='<rect x="'+margin+'" y="'+margin+'" width="20" height="20" stroke="#111" stroke-width="0.3"/>';
      o+='<text x="'+(margin+22)+'" y="'+(margin+8)+'" font-size="3.2" fill="#6b6e73" stroke="none">Kontrollquadrat 20 mm</text>';
      o+='<text x="'+(margin+22)+'" y="'+(margin+13)+'" font-size="2.7" fill="#6b6e73" stroke="none">Gleiche Nahtkennung (z. B. G1n2) = diese beiden Kanten zusammennähen.</text>';
      o+='<text x="'+(margin+22)+'" y="'+(margin+17.5)+'" font-size="2.7" fill="#6b6e73" stroke="none">▸ A1 = Basis in die gleichnamige Öffnung im Rumpfteil einsetzen.</text>';
    }
    for(const it of pg.items){
      const p=it.p;
      const tfin = it.rot
        ? 'translate('+n2(it.x + p.h - (p.h-p.H)/2)+','+n2(it.y+p.A)+') rotate(90)'
        : 'translate('+n2(it.x+p.A)+','+n2(it.y+(p.h-p.H)/2)+')';
      o+='<g transform="'+tfin+'">';
      if(tex) o+='<polygon points="'+poly(p.cut)+'" fill="url(#tx)" fill-opacity="'+n2(tex.opacity)+'" stroke="none"/>';
      o+='<polygon points="'+poly(p.cut)+'" stroke="#111" stroke-width="0.4"/>';
      o+='<polygon points="'+poly(p.seamPts)+'" stroke="#0b6b62" stroke-width="0.25" stroke-dasharray="2.5 1.8"/>';
      for(const nt of p.notches){
        o+='<path d="M'+n2(-nt.x-1.5)+' '+n2(nt.y)+'h3M'+n2(nt.x-1.5)+' '+n2(nt.y)+'h3" stroke="#111" stroke-width="0.3"/>';
        o+='<text x="'+n2(nt.x+3)+'" y="'+n2(nt.y+1)+'" font-size="2.6" fill="#a7abb0" stroke="none">'+nt.n+'</text>';
      }
      for(const mk of p.marks){
        o+='<circle cx="'+n2(mk.x)+'" cy="'+n2(mk.y)+'" r="'+n2(mk.r)+'" stroke="#0b6b62" stroke-width="0.35" stroke-dasharray="2 1.5"/>';
        o+='<text x="'+n2(mk.x+mk.r+1.5)+'" y="'+n2(mk.y+1)+'" font-size="2.8" fill="#0b6b62" stroke="none">'+mk.label+'</text>';
      }
      if(p.glueStart) o+='<path d="M'+n2(-p.A)+' 0h'+n2(2*p.A)+'" stroke="#0b6b62" stroke-width="0.6" stroke-dasharray="5 2"/>';
      if(p.glueEnd) o+='<path d="M'+n2(-p.A)+' '+n2(p.H)+'h'+n2(2*p.A)+'" stroke="#0b6b62" stroke-width="0.6" stroke-dasharray="5 2"/>';
      if(p.wArr && (p.seamL||p.seamR)){
        const half=f=>{
          const yy=p.H*f;
          let i=0; while(i<p.yArr.length-2 && p.yArr[i+1]<yy) i++;
          const t=(yy-p.yArr[i])/Math.max(1e-6, p.yArr[i+1]-p.yArr[i]);
          return (p.wArr[i]+(p.wArr[i+1]-p.wArr[i])*t)/2;
        };
        [0.3,0.72].forEach(f=>{
          const hw=half(f), yy=p.H*f;
          if(hw<4.5) return;
          if(p.seamL) o+='<text transform="translate('+n2(-hw+2.6)+','+n2(yy)+') rotate(-90)" '
            +'font-size="2.9" fill="#0b6b62" stroke="none" text-anchor="middle">'+p.seamL+'</text>';
          if(p.seamR) o+='<text transform="translate('+n2(hw-2.6)+','+n2(yy)+') rotate(-90)" '
            +'font-size="2.9" fill="#0b6b62" stroke="none" text-anchor="middle">'+p.seamR+'</text>';
        });
      }
      if(p.baseId) o+='<text x="0" y="'+n2(Math.min(6, p.H*0.12))+'" font-size="3.1" fill="#0b6b62" '
        +'stroke="none" text-anchor="middle">▸ '+p.baseId+'</text>';
      o+='<text x="0" y="-2" font-size="3.4" fill="#111" stroke="none" text-anchor="middle">'+p.label+' · '+p.count+'×</text>';
      o+='</g>';
    }
    o+='</g>';
  });
  o+='</g></svg>';
  return o;
}
function buildPattern(){
  resolveAll();
  const prims=buildPrims();
  if(prims.length<2) return {error:'Nichts zum Abwickeln — erst eine Kreatur bauen.'};
  let mnY=1e9, mxY=-1e9, diag=0;
  for(const p of prims){ mnY=Math.min(mnY,p.min[1]); mxY=Math.max(mxY,p.max[1]); }
  for(const p of prims) diag=Math.max(diag, Math.hypot(p.max[0]-p.min[0], p.max[1]-p.min[1], p.max[2]-p.min[2]));
  const worldH=Math.max(1e-3, mxY-mnY);
  const U=(S.patH*10)/worldH*(1-S.stretch/100);      /* Welt → mm */
  const seam=S.seam, notch=S.notch*10;
  const N=Math.round(S.gore), M=Math.round(S.bodyGore);
  const maxT=diag, minStep=Math.max(1e-3, diag/400);

  const all=patStrands();
  /* Spiegelkopien nur einmal messen: der Bahnschnitt hängt vom Rahmenstart ab,
     der bei gespiegelten Strängen leicht anders liegt und sonst fast gleiche
     Teile als verschiedene ausweisen würde. */
  const bySym=new Map();
  for(const st of all){
    const k=st.sym||('x'+all.indexOf(st));
    if(!bySym.has(k)) bySym.set(k,[]);
    bySym.get(k).push(st);
  }
  const strands=[];
  for(const list of bySym.values()){
    list.sort((a,b)=>(a.vi||0)-(b.vi||0));
    const rep0=list[0]; rep0.copies=list.length;
    strands.push(rep0);
  }
  const raw=[], measures=[];
  for(const st of strands){
    /* nur die eigenen Primitive messen — sonst schneidet der Strahl den Rumpf
       oder die Wirtsröhre an und die Bahn wird an der Basis viel zu breit */
    const own = st.tags ? prims.filter(q=>st.tags.has(q.grp)) : prims;
    const m=measureStrand(own.length?own:prims, st.pts, N, Math.round(S.patStations||18), maxT, minStep);
    measures.push(m);
    const ys=[0];
    for(let i=1;i<m.MID.length;i++){
      let acc=0;
      for(let g=0;g<N;g++) acc+=m.MID[i][g].distanceTo(m.MID[i-1][g]);
      ys.push(ys[i-1]+acc/N);
    }
    const sid='G'+(strands.indexOf(st)+1);
    st.sid=sid;
    for(let g=0;g<N;g++) raw.push({
      label:sid+(N>1?'·'+(g+1):''),
      widths:m.W.map(w=>w[g]), ys, copies:st.copies||1,
      seamL:sid+'n'+(((g-1+N)%N)+1), seamR:sid+'n'+(g+1),
      baseId:'A'+(strands.indexOf(st)+1)});
  }

  /* Rumpf als Kugel mit markierten Öffnungen */
  const R=S.centerR, J=16;
  const bw=[], by=[];
  for(let j=0;j<=J;j++){
    const phi=Math.PI*j/J;
    bw.push(2*Math.PI*R*Math.sin(phi)/M);
    by.push(R*phi);
  }
  const bodyMarks=[];
  strands.forEach((st,i)=>{
    const s0=st.origin||st.pts[0];
    if(s0.length()>R*0.5) return;
    const dir=st.pts[0].clone().sub(s0);
    if(dir.lengthSq()<1e-8) dir.copy(st.pts[Math.min(1,st.pts.length-1)]).sub(s0);
    if(dir.lengthSq()<1e-8) return;
    dir.normalize();
    const th=Math.atan2(dir.z,dir.x), phi=Math.acos(Math.max(-1,Math.min(1,dir.y)));
    const u=((th+Math.PI)/(Math.PI*2))*M;
    const gi=Math.min(M-1, Math.floor(u)), fu=u-gi;
    const circ=measures[i].W[0].reduce((a,b)=>a+b,0);
    bodyMarks.push({gore:gi, x:(fu-0.5)*(2*Math.PI*R*Math.sin(phi)/M)*U, y:R*phi*U,
                    r:Math.max(2, circ/(2*Math.PI)*U), label:'A'+(i+1)});
  });

  /* gleiche Bahnen zusammenfassen, danach zu lange Teile aufteilen */
  const page=PAGES[S.page]||PAGES.a4;
  const maxLen=page[1]-2*10-2*seam-3;
  const uniq=[], byKey=new Map();
  for(const r of raw){
    const wm=r.widths.map(v=>v*U), ym=r.ys.map(v=>v*U);
    const key=wm.map(v=>Math.round(v*2)).join(',')+'|'+ym.map(v=>Math.round(v*2)).join(',');
    if(byKey.has(key)){ byKey.get(key).count+=(r.copies||1); continue; }
    const rec={label:r.label, w:wm, y:ym, count:r.copies||1, marks:[],
               seamL:r.seamL, seamR:r.seamR, baseId:r.baseId};
    byKey.set(key, rec); uniq.push(rec);
  }
  uniq.push({label:'R', w:bw.map(v=>v*U), y:by.map(v=>v*U), count:1, marks:bodyMarks, body:true, M});
  const pieces=[];
  for(const rec of uniq){
    if(rec.body){
      for(let g=0;g<rec.M;g++){
        const segs=splitGore(rec.w, rec.y, maxLen);
        segs.forEach(sg=>{
          const lab='R'+(rec.M>1?'·'+(g+1):'')+(sg.parts>1?' ('+sg.part+'/'+sg.parts+')':'');
          const pc=pieceFromGore(lab, sg.w, sg.y, 1, seam, notch, 1,
            {seamL:'Rn'+(((g-1+rec.M)%rec.M)+1), seamR:'Rn'+(g+1)});
          pc.glueStart=sg.glueStart; pc.glueEnd=sg.glueEnd;
          pc.marks=rec.marks.filter(m=>m.gore===g && m.y>=sg.yOff-1e-6 && m.y<=sg.yOff+sg.y[sg.y.length-1]+1e-6)
            .map(m=>({x:m.x, y:m.y-sg.yOff, r:m.r, label:m.label}));
          pieces.push(pc);
        });
      }
      continue;
    }
    const segs=splitGore(rec.w, rec.y, maxLen);
    segs.forEach(sg=>{
      const lab=rec.label+(sg.parts>1?' ('+sg.part+'/'+sg.parts+')':'');
      const pc=pieceFromGore(lab, sg.w, sg.y, 1, seam, notch, rec.count,
        {seamL:rec.seamL, seamR:rec.seamR, baseId: sg.yOff<1e-6 ? rec.baseId : null});
      pc.glueStart=sg.glueStart; pc.glueEnd=sg.glueEnd;
      pieces.push(pc);
    });
  }

  let tex=null;
  const wantTex=document.getElementById('cPatTex').checked;
  if(wantTex && texURL){
    const tile=U/Math.max(0.05, S.uvDensity*S.tile);
    if(tile>1 && tile<3000) tex={url:texURL, tile, opacity:S.patTexA};
  }
  const svg=patternSVG(pieces, Math.round(S.patH)+' cm · Saum '+seam+' mm', tex);
  const tooLong=pieces.filter(p=>p.h>page[1]-20 && p.w>page[1]-20).length;
  const split=pieces.filter(p=>p.glueStart||p.glueEnd).length;
  return {svg, count:pieces.length, split, tex:!!tex, texWanted:wantTex,
          total:pieces.reduce((a,p)=>a+p.count,0),
          maxLen:Math.max.apply(null,pieces.map(p=>p.H)),
          tooLong};
}

/* ============================================================
   UI
   ============================================================ */
const fmt=(v,d)=>Number(v).toFixed(d===undefined?2:d);
const sliders=[];
function bind(id,out,key,digits,after){
  const el=document.getElementById(id), o=document.getElementById(out);
  if(!el || !o){ console.warn('Regler fehlt im Markup:', id, out); return; }
  const sync=()=>{ S[key]=parseFloat(el.value); o.textContent=fmt(el.value,digits); };
  el.addEventListener('input',()=>{ sync(); if(after) after(); });
  sliders.push(()=>{ el.value=S[key]; o.textContent=fmt(S[key],digits); });
  sync();
}
function syncSliders(){ sliders.forEach(f=>f()); }
bind('sCenter','oCenter','centerR',2, ()=>{ rebuildSketch(); markStale(); });
bind('sRadial','oRadial','radial',0);
bind('sCtrl','oCtrl','ctrlN',0);
bind('sTens','oTens','tension',2, ()=>{ rebuildSketch(); markStale(); });
bind('sSeg','oSeg','seg',3, ()=>{ rebuildSketch(); markStale(); });
bind('sJit','oJit','jit',2);
bind('sThick','oThick','thick',3, ()=>{ rebuildSketch(); markStale(); });
bind('sUV','oUV','uvDensity',2, markStale);
bind('sBend','oBend','bend',0);
bind('sRes','oRes','res',0, markStale);
bind('sK','oK','k',3, markStale);
bind('sSharp','oSharp','sharp',1, markStale);
bind('sSmooth','oSmooth','smooth',0, markStale);
bind('sGloss','oGloss','gloss',2, applyMaterial);
bind('sCoat','oCoat','coat',2, applyMaterial);
bind('sAmt','oAmt','amt',1);
bind('sWind','oWind','wind',2);
bind('sWindDir','oWindDir','windDir',0);
bind('sSpring','oSpring','spring',1);
bind('sDamp','oDamp','damp',1);
bind('sInert','oInert','inert',2);
bind('sTile','oTile','tile',2, applyTexture);
bind('sTexMix','oTexMix','texMix',2, applyTexture);
bind('sBlobR','oBlobR','blobR',3);
bind('sLinkR','oLinkR','linkR',2, ()=>{ markStale(); });
bind('sDrag','oDrag','drag',1);
bind('sGore','oGore','gore',0);
bind('sBodyGore','oBodyGore','bodyGore',0);
bind('sHeight','oHeight','patH',0);
bind('sSeam','oSeam','seam',1);
bind('sStretch','oStretch','stretch',0);
bind('sNotch','oNotch','notch',1);
bind('sPatTexA','oPatTexA','patTexA',2);
bind('sExpCm','oExpCm','expCm',0);
document.getElementById('cPatTex').addEventListener('change', ()=>{});

segGroup({a4:'pgA4', a3:'pgA3'}, 'page');
let patSVG=null;
function runPattern(preview){
  const busy=document.getElementById('busy');
  busy.dataset.on='1';
  setTimeout(()=>{
    let r;
    try{ r=buildPattern(); }catch(err){ r={error:String(err&&err.message||err)}; }
    busy.dataset.on='0';
    const note=document.getElementById('cutNote');
    if(r.error){
      document.getElementById('cutInfo').textContent='Fehler';
      note.textContent=r.error;
      return;
    }
    patSVG=r.svg;
    document.getElementById('cutInfo').textContent=r.total+' Teile';
    document.getElementById('cutPreview').innerHTML=
      r.svg.replace('<svg ','<svg style="width:100%;height:auto;display:block" ');
    note.textContent = r.count+' Papierteile, '+r.total+' Zuschnitte · längstes '+
      Math.round(r.maxLen/10)+' cm'+
      (r.split?' · '+r.split+' Teil(e) über mehrere Seiten, an der grünen Linie kleben':'')+
      (r.tooLong?' · '+r.tooLong+' Teil(e) zu breit für die Seite':'')+
      (r.texWanted && !r.tex ? ' · keine Textur geladen (Reiter I)' : r.tex ? ' · mit Textur' : '');
    if(!preview) download('blobber-schnittbogen.svg', patSVG, 'image/svg+xml');
  }, 30);
}
document.getElementById('bCutPrev').addEventListener('click', ()=>runPattern(true));
document.getElementById('bCutSave').addEventListener('click', ()=>{
  if(patSVG) download('blobber-schnittbogen.svg', patSVG, 'image/svg+xml');
  else runPattern(false);
});
bind('sFreq','oFreq','freq',2);
bind('sSpeed','oSpeed','speed',2);
bind('sWave','oWave','wave',2);
bind('sStiff','oStiff','stiff',2);
bind('sLift','oLift','lift',2);
bind('sBody','oBody','body',2);

const undoStack=[];
function snapshot(){
  return JSON.stringify({
    l: limbs.map(L=>({c:L.ctrl.map(q=>[q.x,q.y,q.z]), p:L.parent, t:L.attachT,
                      g:L.group, v:L.vi, tf:L.tf, s:L.scale||1, n:L.nb||0})),
    b: blobs.map(q=>({p:[q.p.x,q.p.y,q.p.z], r:q.r, g:q.group, v:q.vi, tf:q.tf})),
    gs: groupSeq, bs: blobSeq
  });
}
function pushUndo(){
  undoStack.push(snapshot());
  if(undoStack.length>50) undoStack.shift();
  updateUndoBtn();
}
function updateUndoBtn(){
  const b=document.getElementById('bUndoAll');
  if(b) b.disabled = undoStack.length===0;
}
function undo(){
  if(!undoStack.length) return;
  const d=JSON.parse(undoStack.pop());
  limbs=d.l.map(L=>({ctrl:L.c.map(c=>new THREE.Vector3(c[0],c[1],c[2])),
    parent:L.p, attachT:L.t, group:L.g, vi:L.v, tf:L.tf, scale:L.s, nb:L.n}));
  blobs=d.b.map(q=>({p:new THREE.Vector3(q.p[0],q.p[1],q.p[2]),
    r:q.r, group:q.g, vi:q.v, tf:q.tf}));
  groupSeq=d.gs; blobSeq=d.bs;
  sel=null;
  updateSelUI(); rebuildSketch(); markStale(); updateStats(); updateUndoBtn();
}
function markStale(){ stale=true; updateBuildButton(); }
function updateBuildButton(){
  const b=document.getElementById('bBuild');
  b.classList.toggle('on', !!(stale && (limbs.length||blobs.length)));
}
function updateStats(){
  document.getElementById('limbCount').textContent=limbs.length;
  document.getElementById('blobCount').textContent=blobs.length;
  const joints=limbs.reduce((s,L)=>s+(L._j?L._j.length:0),0);
  document.getElementById('stat').innerHTML=
    '<b>'+limbs.length+'</b> Kurven · <b>'+blobs.length+'</b> Blobs · <b>'+joints+'</b> Gelenke'+
    (skeleton?' · <b>'+skeleton.bones.length+'</b> Knochen':'');
}
const elLScale=document.getElementById('sLScale'), elLBones=document.getElementById('sLBones');
const outLScale=document.getElementById('oLScale'), outLBones=document.getElementById('oLBones');
function updateSelUI(){
  const isCtrl = !!sel && sel.t==='ctrl' && !!limbs[sel.limb];
  const isBlob = !!sel && sel.t==='blob' && !!blobs[sel.i];
  document.getElementById('bDel').disabled = !(isCtrl||isBlob);
  document.getElementById('bStraight').disabled = !isCtrl;
  elLScale.disabled = !(isCtrl||isBlob);
  elLBones.disabled = !isCtrl;
  const note=document.getElementById('selNote');
  if(isCtrl){
    const L=limbs[sel.limb];
    elLScale.value=L.scale||1;
    outLScale.textContent='×'+Number(L.scale||1).toFixed(2);
    elLBones.value=L.nb||0;
    outLBones.textContent = L.nb>0 ? String(L.nb) : 'auto';
    note.textContent='Kurve '+sel.limb+' · '+L.ctrl.length+' Kontrollpunkte. Regler wirken auf alle Symmetriekopien.';
  } else if(isBlob){
    const b=blobs[sel.i];
    elLScale.value=Math.max(0.2, Math.min(3, b.r/0.16));
    outLScale.textContent='r '+b.r.toFixed(3);
    outLBones.textContent='—';
    note.textContent='Blob '+sel.i+' · Pfeile ziehen bewegt auf der Achse, Umschalt+Ziehen skaliert.';
  } else {
    outLScale.textContent='—'; outLBones.textContent='—';
    note.textContent = S.tool==='blob'
      ? 'Klick ins Leere setzt einen Blob, Klick auf einen Griff wählt ihn aus.'
      : 'Nichts ausgewählt.';
  }
  updateGizmo();
}
function applyToGroup(fn){
  if(!sel || sel.t!=='ctrl' || !limbs[sel.limb]) return;
  for(const o of groupMembers(limbs[sel.limb].group)) fn(o.L);
  rebuildSketch(); markStale(); updateSelUI(); updateStats();
}
elLScale.addEventListener('input', ()=>{
  const v=parseFloat(elLScale.value);
  if(sel && sel.t==='blob' && blobs[sel.i]){
    const nr=Math.max(0.02, Math.min(1.2, 0.16*v));
    for(const o of blobGroup(blobs[sel.i].group)) o.b.r=nr;
    outLScale.textContent='r '+nr.toFixed(3);
    rebuildSketch(); markStale();
    return;
  }
  outLScale.textContent='×'+v.toFixed(2);
  applyToGroup(L=>{ L.scale=v; });
});
elLBones.addEventListener('input', ()=>{
  const v=Math.round(parseFloat(elLBones.value));
  outLBones.textContent = v>=2 ? String(v) : 'auto';
  applyToGroup(L=>{ L.nb = v>=2 ? v : 0; });
});
function readPalette(){
  mainColor.set(document.getElementById('p0').value).convertSRGBToLinear();
  recolor();
}
document.getElementById('p0').addEventListener('input', readPalette);

function segGroup(map,key,after){
  Object.entries(map).forEach(([k,id])=>{
    document.getElementById(id).addEventListener('click',()=>{
      S[key]=k;
      Object.entries(map).forEach(([kk,idd])=>
        document.getElementById(idd).setAttribute('aria-pressed', String(kk===k)));
      if(after) after();
    });
  });
}
segGroup({off:'mirOff', x:'mirX', radial:'mirRad'},'mirror');
segGroup({x:'asX', off:'asOff', radial:'asRad'},'animSym');
const tools={curve:'tCurve', blob:'tBlob', interact:'tInteract'};
function setTool(t){
  S.tool=t;
  Object.entries(tools).forEach(([k,id])=>
    document.getElementById(id).setAttribute('aria-pressed', String(k===t)));
  document.getElementById('secCurve').hidden = t!=='curve';
  document.getElementById('secBlob').hidden  = t!=='blob';
  const h=document.getElementById('hint');
  if(t==='curve') h.textContent='Ziehen zeichnet eine Kurve, Klick auf Kurve oder Griff wählt aus. Pfeile bewegen auf der Achse.';
  else if(t==='blob') h.textContent='Klick ins Leere setzt einen Blob. Pfeile bewegen, Umschalt+Ziehen skaliert.';
  else h.textContent='Ziehen umkreist. Ziehen am Rumpf bewegt die ganze Kreatur — die Gliedmaßen schleppen nach.';
  if(t!=='interact') recenterBody(); else { sel=null; }
  updateSelUI(); rebuildSketch();
}
Object.entries(tools).forEach(([k,id])=>document.getElementById(id).addEventListener('click',()=>setTool(k)));

const tabs={mesh:['tabMesh','panMesh'], anim:['tabAnim','panAnim'], cut:['tabCut','panCut'], out:['tabOut','panOut']};
function setTab(k){
  Object.entries(tabs).forEach(([kk,[bid,pid]])=>{
    const btn=document.getElementById(bid), pan=document.getElementById(pid);
    if(!btn || !pan){ console.warn('Reiter fehlt:', bid, pid, '— veraltete Datei?'); return; }
    btn.setAttribute('aria-pressed', String(kk===k));
    pan.hidden = kk!==k;
  });
}
Object.keys(tabs).forEach(k=>{
  const btn=document.getElementById(tabs[k][0]);
  if(btn) btn.addEventListener('click',()=>setTab(k));
  else console.warn('Reiterknopf fehlt:', tabs[k][0]);
});

document.getElementById('bBlobClear').addEventListener('click',()=>{
  pushUndo(); blobs=[]; sel=null; updateSelUI(); rebuildSketch(); markStale(); updateStats();
});
document.getElementById('bUndoAll').addEventListener('click', undo);
document.getElementById('bDel').addEventListener('click', deleteLimb);
document.getElementById('bStraight').addEventListener('click', smoothCtrl);
document.getElementById('bClear').addEventListener('click',()=>{
  pushUndo(); limbs=[]; sel=null; updateSelUI(); rebuildSketch(); markStale(); updateStats();
});
document.getElementById('bBuild').addEventListener('click', generate);
document.getElementById('bDrop').addEventListener('click', ()=>{
  disposeMesh(); markStale();
  document.getElementById('meshInfo').textContent='—';
  document.getElementById('buildNote').textContent='Mesh entfernt — das Grundgerüst bleibt erhalten.';
  const cb=document.getElementById('cSketch');
  if(!cb.checked){ cb.checked=true; sketch.visible=true; }
  updateStats();
});
document.getElementById('bRebuild').addEventListener('click', generate);
document.getElementById('cUV').addEventListener('change', ()=>{ if(mesh) mesh.material=surfaceMat(); });
document.getElementById('bPlay').addEventListener('click', e=>{
  S.play=!S.play;
  e.target.textContent=S.play?'Anhalten':'Abspielen';
  e.target.classList.toggle('on', S.play);
});
document.getElementById('bRest').addEventListener('click',()=>{
  S.play=false;
  const pb=document.getElementById('bPlay');
  pb.textContent='Abspielen'; pb.classList.remove('on');
  recenterBody(); restPose();
});
document.getElementById('bFbx').addEventListener('click', exportFBX);
document.getElementById('bObj').addEventListener('click', exportOBJ);
document.getElementById('bRig').addEventListener('click', exportRig);
document.getElementById('bScene').addEventListener('click', exportScene);
document.getElementById('bLoad').addEventListener('click',()=>document.getElementById('fileIn').click());
document.getElementById('fileIn').addEventListener('change', e=>{
  const f=e.target.files[0]; if(!f) return;
  const r=new FileReader(); r.onload=()=>loadScene(r.result); r.readAsText(f);
  e.target.value='';
});
/* ---------------- Textur ---------------- */
function applyTexture(){
  texMix.value=S.texMix;
  if(matSurface.map){
    matSurface.map.repeat.set(S.tile,S.tile);
    matSurface.map.needsUpdate=true;
  }
}
function setTexture(img, name){
  const t=new THREE.Texture(img);
  t.wrapS=t.wrapT=THREE.MirroredRepeatWrapping;   /* spiegelt in U und V */
  t.encoding=THREE.sRGBEncoding;
  t.anisotropy=renderer.capabilities.getMaxAnisotropy ? Math.min(8, renderer.capabilities.getMaxAnisotropy()) : 1;
  t.repeat.set(S.tile,S.tile);
  t.needsUpdate=true;
  if(matSurface.map) matSurface.map.dispose();
  matSurface.map=t;
  matSurface.needsUpdate=true;
  applyTexture();
  document.getElementById('bTexOff').disabled=false;
  document.getElementById('texNote').textContent=
    name+' — '+img.naturalWidth+'×'+img.naturalHeight+', gespiegelt gekachelt in U und V.';
}
document.getElementById('bTex').addEventListener('click', ()=>document.getElementById('texIn').click());
document.getElementById('texIn').addEventListener('change', e=>{
  const f=e.target.files && e.target.files[0];
  e.target.value='';
  if(!f) return;
  const rd=new FileReader();
  rd.onload=()=>{
    const img=new Image();
    img.onload=()=>{ texURL=rd.result; setTexture(img, f.name); };
    img.onerror=()=>{ document.getElementById('texNote').textContent='Bild konnte nicht gelesen werden.'; };
    img.src=rd.result;
  };
  rd.readAsDataURL(f);
});
document.getElementById('bTexOff').addEventListener('click', ()=>{
  texURL=null;
  if(matSurface.map){ matSurface.map.dispose(); matSurface.map=null; matSurface.needsUpdate=true; }
  document.getElementById('bTexOff').disabled=true;
  document.getElementById('texNote').textContent=
    'Quadratische Bilder funktionieren am besten. Die Kachelung spiegelt in U und V, deshalb bleiben die Übergänge nahtlos.';
});

document.getElementById('cSketch').addEventListener('change', e=>{ sketch.visible=e.target.checked; });
document.getElementById('cBones').addEventListener('change', e=>{ if(helper) helper.visible=e.target.checked; });
document.getElementById('cWire').addEventListener('change', e=>{ if(wireMesh) wireMesh.visible=e.target.checked; });
document.getElementById('cGrid').addEventListener('change', e=>{ grid.visible=e.target.checked; });
document.getElementById('cMesh').addEventListener('change', e=>{ if(mesh) mesh.visible=e.target.checked; });

/* ============================================================
   Demo
   ============================================================ */
function demo(){
  const arc=(dir,n,curl,step)=>{
    const pts=[], d=dir.clone().normalize(), p=d.clone().multiplyScalar(0.42);
    for(let i=0;i<n;i++){
      d.applyAxisAngle(new THREE.Vector3(0,0,1), curl);
      p.addScaledVector(d, step);
      pts.push(p.clone());
    }
    return pts;
  };
  S.mirror='x';
  addLimb(arc(new THREE.Vector3(0.9,0.5,0.18),4,0.16,0.34), -1, 0);
  addLimb(arc(new THREE.Vector3(0.45,-0.95,0.3),3,-0.12,0.3), -1, 0);
  addLimb(arc(new THREE.Vector3(0.1,1,0.15),4,0.08,0.3), -1, 0);
  resolveLimbs();
  /* Verzweigung: kleiner Nebenarm auf dem ersten Glied */
  const host=0, t=0.55;
  const o=limbs[host]._curve.getPointAt(t);
  const br=[];
  const dir=new THREE.Vector3(0.35,0.55,0.75).normalize(), p=o.clone();
  for(let i=0;i<3;i++){ p.addScaledVector(dir,0.22); dir.applyAxisAngle(new THREE.Vector3(1,0,0),0.25); br.push(p.clone()); }
  addLimb(br, host, t);
  resolveAll();
  /* zwei freistehende Blobs als Kopf */
  S.blobR=0.19;
  addBlob(new THREE.Vector3(0.22, 0.72, 0.30));
  S.blobR=0.12;
  addBlob(new THREE.Vector3(0.30, 1.02, 0.46));
  S.blobR=0.16;
  sel=null;
  rebuildSketch(); updateStats();
}

let t=0, lastT=performance.now();
function loop(){
  requestAnimationFrame(loop);
  const now=performance.now();
  const dt=Math.min(0.05,(now-lastT)/1000); lastT=now;

  if(pointerMode==='body') bodyVel.subVectors(bodyOffset, bodyPrev).divideScalar(Math.max(dt,1e-3));
  else bodyVel.multiplyScalar(Math.exp(-3.5*dt));
  bodyPrev.copy(bodyOffset);

  if(skeleton) skeleton.bones[0].position.copy(bodyOffset);
  sketch.position.copy(bodyOffset);
  bodyPicker.position.copy(bodyOffset);
  bodyPicker.scale.setScalar(S.centerR*1.35);
  if(gizmo.visible){
    gizmo.scale.setScalar(orbit.dist*0.2);
    const sp=selPoint();
    if(sp) gizmo.position.copy(sp).add(bodyOffset);
  }

  if(S.play){ t+=dt; updateRig(dt, t); }
  renderer.render(scene,camera);
}

resize(); applyCamera(); applyMaterial(); drawThick();
demo(); setTool('curve'); setTab('mesh'); updateSelUI(); updateBuildButton();
generate();
loop();
})();
</script>
</body>
</html>