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 & 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>