RHIZOMORPH DESIGNER v1.0

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/7c69dbe054a53159/" sandbox="allow-scripts allow-forms allow-modals allow-downloads" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ROOT MESH DESIGNER</title>
<script type="importmap">
{"imports":{
  "three":"https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.module.min.js",
  "three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<style>
  :root{ --ink:#111; --ink-2:#555; --ink-3:#8a8a8a; --line:#e2e2e2; --line-2:#f0f0f0; --panel-w:292px; }
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;background:#fff;color:var(--ink);
    font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:11px;-webkit-font-smoothing:antialiased}
  #view{position:fixed;inset:0 var(--panel-w) 0 0}
  canvas{display:block;touch-action:none}
  #panel{position:fixed;top:0;right:0;bottom:0;width:var(--panel-w);background:#fff;
    border-left:1px solid var(--line);overflow-y:auto;overscroll-behavior:contain}
  #panel::-webkit-scrollbar{width:6px} #panel::-webkit-scrollbar-thumb{background:#ddd}
  .head{padding:16px 16px 12px;border-bottom:1px solid var(--line)}
  .head h1{margin:0;font-size:11px;letter-spacing:.14em;font-weight:600}
  .sec{border-bottom:1px solid var(--line-2);padding:10px 16px 14px}
  .sec>h2{margin:0 0 10px;font-size:9px;letter-spacing:.18em;color:var(--ink-3);font-weight:600;text-transform:uppercase}
  .row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:0 0 3px}
  .row label{color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .row .val{color:var(--ink);font-variant-numeric:tabular-nums;min-width:44px;text-align:right}
  input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:14px;background:transparent;margin:0 0 9px;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;background:var(--ink);border-radius:50%;margin-top:-4px}
  input[type=range]::-moz-range-thumb{width:9px;height:9px;background:var(--ink);border:0;border-radius:50%}
  input[type=range].gray{height:20px;margin:0 0 9px}
  input[type=range].gray::-webkit-slider-runnable-track{height:16px;border:1px solid var(--line);
    background:linear-gradient(90deg,#fff 0%,#111 100%)}
  input[type=range].gray::-moz-range-track{height:16px;border:1px solid var(--line);
    background:linear-gradient(90deg,#fff 0%,#111 100%)}
  input[type=range].gray::-webkit-slider-thumb{-webkit-appearance:none;width:5px;height:24px;border-radius:0;
    background:#fff;border:1px solid var(--ink);margin-top:-5px;box-shadow:0 0 0 1px #fff}
  input[type=range].gray::-moz-range-thumb{width:5px;height:24px;border-radius:0;background:#fff;border:1px solid var(--ink)}
  .chip{width:11px;height:11px;border:1px solid var(--line);flex:none}
  .check{display:flex;align-items:center;gap:8px;margin:0 0 7px;cursor:pointer;color:var(--ink-2)}
  .check input{appearance:none;width:11px;height:11px;border:1px solid #bbb;background:#fff;margin:0;cursor:pointer;flex:none}
  .check input:checked{background:var(--ink);border-color:var(--ink)}
  select,input[type=color],input[type=text]{width:100%;font:inherit;color:var(--ink);background:#fff;
    border:1px solid var(--line);padding:5px 6px;margin:0 0 8px}
  input[type=color]{padding:2px;height:24px;cursor:pointer} select{cursor:pointer}
  button{width:100%;font:inherit;letter-spacing:.08em;color:var(--ink);background:#fff;border:1px solid var(--ink);
    padding:8px;margin:0 0 6px;cursor:pointer;text-transform:uppercase}
  button:hover{background:var(--ink);color:#fff}
  button.ghost{border-color:var(--line);color:var(--ink-2)}
  button.ghost:hover{background:#f6f6f6;color:var(--ink);border-color:#ccc}
  .btn2{display:flex;gap:6px}
  .grp{border-bottom:1px solid var(--line)}
  .grph{display:flex;align-items:center;justify-content:space-between;width:100%;margin:0;padding:11px 16px;
    border:0;background:#fff;color:var(--ink);font:inherit;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer}
  .grph:hover{background:#f7f7f7;color:var(--ink)}
  .grph i{font-style:normal;color:var(--ink-3);font-size:11px}
  .grpb{border-top:1px solid var(--line-2)}
  .grpb .sec:last-child{border-bottom:0}
  .swatch{display:flex;gap:4px;margin:0 0 8px}
  .swatch button{width:auto;flex:1;padding:7px 0;margin:0;border-color:var(--line);font-size:9px;letter-spacing:.06em}
  .swatch button.on{border-color:var(--ink);background:var(--ink);color:#fff}
  .note{color:var(--ink-3);line-height:1.6;margin:2px 0 0}
  #bar{position:fixed;top:12px;left:12px;display:flex;gap:4px;z-index:20}
  #bar button{width:auto;margin:0;padding:7px 11px;font-size:10px;letter-spacing:.1em;
    border:1px solid var(--line);background:rgba(255,255,255,.94);color:var(--ink-2)}
  #bar button:hover{background:var(--ink);color:#fff;border-color:var(--ink)}
  #bar button.on{background:var(--ink);color:#fff;border-color:var(--ink)}
  #bar button:disabled{opacity:.3} #bar button:disabled:hover{background:rgba(255,255,255,.94);color:var(--ink-2);border-color:var(--line)}
  #bar .gap{width:8px}
  #status{position:fixed;left:16px;bottom:14px;color:var(--ink-3);letter-spacing:.04em;pointer-events:none}
  #status b{color:var(--ink);font-weight:600}
  #drop{position:fixed;inset:0 var(--panel-w) 0 0;display:none;place-items:center;background:rgba(255,255,255,.92);
    border:1px dashed #bbb;color:var(--ink);letter-spacing:.1em}
  #err{position:fixed;inset:0;display:none;place-items:center;padding:40px;text-align:center;color:#b00;background:#fff;line-height:1.7}
  #ovl{position:fixed;inset:0 var(--panel-w) 0 0;display:none;place-items:center;background:rgba(255,255,255,.82);z-index:40}
  #ovl .box{width:250px;text-align:left}
  #ovl .lab{color:var(--ink);letter-spacing:.1em;margin:0 0 9px}
  #ovl .bar{height:1px;background:var(--line)}
  #ovl .bar i{display:block;height:1px;background:var(--ink);width:0%;transition:width .12s linear}
  @media (max-width:820px){ :root{--panel-w:100%} #view,#drop{inset:0 0 52% 0} #panel{top:48%;border-left:0;border-top:1px solid var(--line)} }
</style>
</head>
<body>
<div id="view"></div>
<div id="drop">GLB / GLTF / FBX HIER ABLEGEN</div>
<div id="bar">
  <button id="bLayer1">Ebene 1 · Dichte</button>
  <button id="bLayer2">Ebene 2 · Abstand</button>
  <span class="gap"></span>
  <button id="bPose">Pose</button>
  <span class="gap"></span>
  <button id="bGrow">Wurzeln wachsen</button>
  <button id="bMesh">Mesh bauen</button>
  <button id="bDel">Löschen</button>
  <button id="bClearMap">Ebene leeren</button>
  <span class="gap"></span>
  <button id="bUndo" title="Rückgängig (Strg+Z)">↶</button>
  <button id="bRedo" title="Wiederholen (Strg+Umschalt+Z)">↷</button>
</div>
<div id="status"></div>
<div id="panel"><div class="head"><h1>ROOT MESH DESIGNER</h1></div><div id="ui"></div></div>
<div id="ovl"><div class="box"><div class="lab" id="ovlTxt">…</div><div class="bar"><i id="ovlBar"></i></div></div></div>
<div id="err"></div>

<script type="module">
// ═════════════════════════════════════════════════════════════════════════════
// 0 · THREE
// ═════════════════════════════════════════════════════════════════════════════
let THREE, addonsAvailable = true;
try{
  THREE = await import('three');
}catch(e){
  addonsAvailable = false;
  try{ THREE = await import('https://unpkg.com/three@0.160.0/build/three.module.js'); }
  catch(e2){
    const d=document.getElementById('err'); d.style.display='grid';
    d.textContent='three.js konnte nicht geladen werden. Netzwerk oder CDN prüfen.';
    throw e2;
  }
}
const V3=THREE.Vector3, QT=THREE.Quaternion;
const clamp=THREE.MathUtils.clamp;

// ═════════════════════════════════════════════════════════════════════════════
// 1 · PARAMETER
// ═════════════════════════════════════════════════════════════════════════════
const P = {
  mapRes:1024, layer:0, poseMode:false,
  // Pinsel
  brushMode:'paint', brushRadius:0.055, hardness:0.35, flow:0.55, target:0.15,
  normalLimit:0.25, mirror:true,
  // Saat · Kreispackung aus Ebene 1
  rMin:0.005, rMax:0.030, gamma:1.6, jitter:0.25, packing:0.85,
  samples:30000, densityBias:0.35, alphaMin:0.10, relax:6, seed:1,
  // Wuchsraum: Haut = Boden, Ebene 2 = Deckenhöhe
  lift:0.05, hug:0.004, stick:0.85, settle:0.55, steps:110, stepLen:0.005, turn:0.35,
  curlAmp:0.40, noiseScale:5.5, gravity:0.0, killOffPaint:true,
  // Stützen (gedachte Zylinder auf der Haut)
  pillarsOn:true, pMode:'grid', pSpace:1.0, pCols:0.045, pRows:0.045,
  pFill:0.55, pHeight:1.2, pPush:2.2, pSwirl:0.9, pSwirlRange:0.022, showPillars:true,
  // Platzsuche & Verdichtung
  senseDist:0.016, senseAngle:0.6, senseForce:1.20,
  occCell:0.006, occMax:6, fuse:true,
  oscAmp:0.30, oscFreq:0.45, oscJitter:0.45,
  // Schwarm
  nbRadius:0.024, sepDist:0.008, sepForce:1.20, alignForce:0.25, cohForce:0.15, twistForce:0.10,
  // Verzweigung
  branchProb:0.055, branchAngle:0.85, branchDecay:0.80, maxDepth:4, maxAgents:7000,
  // Querschnitt
  rootScale:0.16, rootMin:0.0006, thick:[[0,1],[0.35,0.72],[1,0.30]], tubeSides:4, simplify:0.14,
  tipSpheres:false, tipScale:1.8, tipDetail:0,
  shootsOn:false, shootProb:0.30, shootLen:0.055, shootSteps:16,
  shootThick:1.2, shootWobble:0.9, bladeWidth:2.5, bladeThick:0.30,
  bladeRise:0.004, bladeSides:6,
  rootColor:'#1a1a1a', stemColor:'#2f7d4f', shade:0.45,
  // Ansicht
  poseMirror:true, showBody:true, ghost:0.16, showPaint:true, otherLayer:0.45,
  showRoots:true, showPreview:true,
  // Export
  exportFormat:'glb', exportBody:false
};

// ═════════════════════════════════════════════════════════════════════════════
// 2 · SZENE
// ═════════════════════════════════════════════════════════════════════════════
const viewEl=document.getElementById('view');
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.setClearColor(0xffffff,1);
viewEl.appendChild(renderer.domElement);
const el=renderer.domElement;

const scene=new THREE.Scene();
const camera=new THREE.PerspectiveCamera(38,1,0.01,100);
scene.add(new THREE.HemisphereLight(0xffffff,0xdedede,2.1));
const keyL=new THREE.DirectionalLight(0xffffff,1.4); keyL.position.set(1.4,2.2,1.8); scene.add(keyL);
const fillL=new THREE.DirectionalLight(0xffffff,0.5); fillL.position.set(-1.6,0.6,-1.2); scene.add(fillL);
const LIGHT_DIR=new V3(0.55,0.72,0.62).normalize();

const cam={target:new V3(0,0.95,0),theta:0.5,phi:1.32,dist:2.8};
function applyCam(){
  const s=Math.sin(cam.phi),r=cam.dist;
  camera.position.set(cam.target.x+r*s*Math.sin(cam.theta),
                      cam.target.y+r*Math.cos(cam.phi),
                      cam.target.z+r*s*Math.cos(cam.theta));
  camera.lookAt(cam.target); camera.updateMatrixWorld();
}
function resize(){
  const w=viewEl.clientWidth,h=viewEl.clientHeight;
  renderer.setSize(w,h); camera.aspect=w/h; camera.updateProjectionMatrix(); applyCam();
}
addEventListener('resize',resize);

let rndState=1;
function srand(s){ rndState=(s>>>0)||1; }
function rnd(){
  rndState|=0; rndState=rndState+0x6D2B79F5|0;
  let t=Math.imul(rndState^rndState>>>15,1|rndState);
  t=t+Math.imul(t^t>>>7,61|t)^t;
  return ((t^t>>>14)>>>0)/4294967296;
}

// ═════════════════════════════════════════════════════════════════════════════
// 3 · DUMMY-KÖRPER (Rückfallebene, UV-Atlas ohne Überlappung)
// ═════════════════════════════════════════════════════════════════════════════
function frames(pts){
  const N=pts.length,T=[],Nr=[],B=[];
  for(let i=0;i<N;i++){
    const t=new V3().subVectors(pts[Math.min(N-1,i+1)],pts[Math.max(0,i-1)]);
    if(t.lengthSq()<1e-14) t.set(0,1,0);
    T.push(t.normalize());
  }
  let n0=new V3(0,1,0); if(Math.abs(T[0].dot(n0))>0.9) n0.set(1,0,0);
  Nr.push(new V3().crossVectors(T[0],n0).normalize());
  for(let i=1;i<N;i++){
    const ax=new V3().crossVectors(T[i-1],T[i]); const n=Nr[i-1].clone();
    if(ax.lengthSq()>1e-12) n.applyAxisAngle(ax.normalize(),Math.acos(clamp(T[i-1].dot(T[i]),-1,1)));
    Nr.push(n.normalize());
  }
  for(let i=0;i<N;i++) B.push(new V3().crossVectors(T[i],Nr[i]).normalize());
  return {T,N:Nr,B};
}
function loft(sections,radial,sub){
  const ctrl=sections.map(s=>new V3(s[0],s[1],s[2]));
  const rad=sections.map(s=>new V3(s[3],s[4],0));
  const cC=new THREE.CatmullRomCurve3(ctrl,false,'centripetal',0.5);
  const rC=new THREE.CatmullRomCurve3(rad,false,'catmullrom',0.5);
  const steps=Math.max(2,(sections.length-1)*sub), pts=[], rs=[];
  for(let i=0;i<=steps;i++){const t=i/steps; pts.push(cC.getPoint(t)); rs.push(rC.getPoint(t));}
  const F=frames(pts), pos=[], idx=[], uv=[];
  for(let i=0;i<pts.length;i++) for(let j=0;j<=radial;j++){
    const a=(j%radial)/radial*Math.PI*2;
    const v=pts[i].clone().addScaledVector(F.N[i],Math.cos(a)*Math.max(rs[i].x,0.001))
                          .addScaledVector(F.B[i],Math.sin(a)*Math.max(rs[i].y,0.001));
    pos.push(v.x,v.y,v.z); uv.push(j/radial,i/(pts.length-1));
  }
  const W=radial+1;
  for(let i=0;i<pts.length-1;i++) for(let j=0;j<radial;j++){
    const a=i*W+j,b=i*W+j+1;
    idx.push(a,b,b+W, a,b+W,a+W);
  }
  return {pos,idx,uv};
}
const PARTS=[
  {radial:22,sub:4,s:[[0,0.90,0,0.095,0.125],[0,0.98,0,0.115,0.155],[0,1.06,0,0.101,0.140],
   [0,1.14,0,0.095,0.130],[0,1.24,0,0.110,0.155],[0,1.34,0,0.116,0.176],[0,1.43,0,0.104,0.170],[0,1.47,0,0.075,0.092]]},
  {radial:20,sub:4,s:[[0,1.44,0,0.055,0.055],[0,1.53,0,0.058,0.058],[0,1.58,0.004,0.082,0.078],
   [0,1.64,0.006,0.098,0.090],[0,1.70,0.004,0.093,0.086],[0,1.755,0,0.062,0.058],[0,1.78,0,0.02,0.02]]},
  {radial:14,sub:4,s:[[0.14,1.44,0,0.060,0.060],[0.30,1.44,0,0.048,0.048],[0.45,1.44,0,0.042,0.042],
   [0.58,1.44,0,0.036,0.036],[0.70,1.44,0,0.031,0.031],[0.79,1.44,0,0.040,0.040],[0.88,1.44,0,0.014,0.014]]},
  {radial:14,sub:4,s:[[-0.14,1.44,0,0.060,0.060],[-0.30,1.44,0,0.048,0.048],[-0.45,1.44,0,0.042,0.042],
   [-0.58,1.44,0,0.036,0.036],[-0.70,1.44,0,0.031,0.031],[-0.79,1.44,0,0.040,0.040],[-0.88,1.44,0,0.014,0.014]]},
  {radial:16,sub:4,s:[[0.10,0.97,0,0.098,0.098],[0.10,0.76,0,0.083,0.083],[0.11,0.55,0,0.062,0.062],
   [0.11,0.36,0,0.053,0.053],[0.11,0.12,0,0.042,0.042],[0.11,0.045,0.02,0.042,0.042],[0.11,0.03,0.17,0.026,0.030]]},
  {radial:16,sub:4,s:[[-0.10,0.97,0,0.098,0.098],[-0.10,0.76,0,0.083,0.083],[-0.11,0.55,0,0.062,0.062],
   [-0.11,0.36,0,0.053,0.053],[-0.11,0.12,0,0.042,0.042],[-0.11,0.045,0.02,0.042,0.042],[-0.11,0.03,0.17,0.026,0.030]]},
];
function buildDummy(){
  const pos=[],idx=[],uv=[]; let off=0;
  PARTS.forEach((part,pi)=>{
    const g=loft(part.s,part.radial,part.sub);
    for(const v of g.pos) pos.push(v);
    const col=pi%3,row=(pi/3)|0;
    for(let k=0;k<g.uv.length;k+=2){
      uv.push(0.012+col*0.333+g.uv[k]*0.309, 0.012+row*0.5+g.uv[k+1]*0.476);
    }
    for(const i of g.idx) idx.push(i+off);
    off=pos.length/3;
  });
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
  geo.setAttribute('uv',new THREE.Float32BufferAttribute(uv,2));
  geo.setIndex(idx); geo.computeVertexNormals();
  const mesh=new THREE.Mesh(geo,new THREE.MeshBasicMaterial());
  const root=new THREE.Group(); root.add(mesh); root.updateMatrixWorld(true);
  return {root,mesh};
}

// ═════════════════════════════════════════════════════════════════════════════
// 4 · KÖRPER
// ═════════════════════════════════════════════════════════════════════════════
let body=null, bodyMat=null, modelName='—';
let srcRoot=null, srcMesh=null;
let triA=null,triB=null,triC=null,triCDF=null,triArea=0,texelSpacing=0.002,uvScale=1;

function bodyShader(){
  return new THREE.ShaderMaterial({
    transparent:true, depthWrite:true, side:THREE.FrontSide,
    uniforms:{
      map:{value:null}, uGhost:{value:P.ghost}, uShow:{value:1}, uOther:{value:P.otherLayer},
      uLayer:{value:P.layer}, uBase:{value:new THREE.Color(0xdadada)}, uL:{value:LIGHT_DIR.clone()},
      uOtherCol:{value:new THREE.Color(0.09,0.60,0.34)}
    },
    vertexShader:`
      varying vec2 vUv; varying vec3 vN;
      void main(){
        vUv=uv;
        vN=normalize(mat3(modelMatrix)*normal);
        gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);
      }`,
    fragmentShader:`
      uniform sampler2D map; uniform float uGhost,uShow,uOther,uLayer;
      uniform vec3 uBase,uL,uOtherCol;
      varying vec2 vUv; varying vec3 vN;
      void main(){
        vec4 t=texture2D(map,vUv);
        vec2 A=(uLayer<0.5)?t.rg:t.ba;
        vec2 B=(uLayer<0.5)?t.ba:t.rg;
        float aA=A.y*uShow, aB=B.y*uOther*uShow;
        vec3 col=uBase;
        col=mix(col,uOtherCol*(0.35+0.65*B.x),aB);
        col=mix(col,vec3(A.x),aA);
        vec3 n=normalize(vN);
        float key=max(dot(n,normalize(uL)),0.0);
        float hemi=0.5+0.5*n.y;
        float sh=0.34+0.42*key+0.24*hemi;
        float cover=max(aA,aB);
        gl_FragColor=vec4(col*sh, mix(1.0, mix(uGhost,1.0,cover), uShow));
      }`
  });
}
function disposeBody(){
  if(!body) return;
  scene.remove(body); body.geometry.dispose(); bodyMat.dispose(); body=null;
}
function normalizeObject(obj,targetHeight=1.78){
  obj.updateMatrixWorld(true);
  const box=new THREE.Box3().setFromObject(obj), size=new V3();
  box.getSize(size);
  if(size.y>1e-6) obj.scale.multiplyScalar(targetHeight/size.y);
  obj.updateMatrixWorld(true); box.setFromObject(obj);
  const c=new V3(); box.getCenter(c);
  obj.position.x-=c.x; obj.position.z-=c.z; obj.position.y-=box.min.y;
  obj.updateMatrixWorld(true);
}
const _bv=new V3();
function deform(){
  srcRoot.updateMatrixWorld(true);
  if(srcMesh.isSkinnedMesh) srcMesh.skeleton.update();
  const sp=srcMesh.geometry.attributes.position;
  const pa=body.geometry.attributes.position;
  const skinned=srcMesh.isSkinnedMesh&&srcMesh.getVertexPosition;
  for(let i=0;i<sp.count;i++){
    if(skinned) srcMesh.getVertexPosition(i,_bv); else _bv.fromBufferAttribute(sp,i);
    srcMesh.localToWorld(_bv);
    pa.setXYZ(i,_bv.x,_bv.y,_bv.z);
  }
  pa.needsUpdate=true;
  body.geometry.computeVertexNormals();
  body.geometry.computeBoundingSphere();
}
function buildTriangleList(){
  const g=body.geometry, p=g.attributes.position, ix=g.index, uvA=g.attributes.uv;
  const nt=ix.count/3;
  if(!triA||triA.length!==nt*3){
    triA=new Float32Array(nt*3); triB=new Float32Array(nt*3); triC=new Float32Array(nt*3);
    triCDF=new Float32Array(nt);
  }
  triArea=0;
  let uvArea=0;
  const a=new V3(),b=new V3(),c=new V3(),e1=new V3(),e2=new V3();
  for(let t=0;t<nt;t++){
    const i0=ix.getX(t*3),i1=ix.getX(t*3+1),i2=ix.getX(t*3+2);
    a.fromBufferAttribute(p,i0); b.fromBufferAttribute(p,i1); c.fromBufferAttribute(p,i2);
    triA[t*3]=a.x;triA[t*3+1]=a.y;triA[t*3+2]=a.z;
    triB[t*3]=b.x;triB[t*3+1]=b.y;triB[t*3+2]=b.z;
    triC[t*3]=c.x;triC[t*3+1]=c.y;triC[t*3+2]=c.z;
    e1.subVectors(b,a); e2.subVectors(c,a);
    triArea+=0.5*e1.cross(e2).length();
    triCDF[t]=triArea;
    const du1=uvA.getX(i1)-uvA.getX(i0), dv1=uvA.getY(i1)-uvA.getY(i0);
    const du2=uvA.getX(i2)-uvA.getX(i0), dv2=uvA.getY(i2)-uvA.getY(i0);
    uvArea+=0.5*Math.abs(du1*dv2-du2*dv1);
  }
  uvScale=uvArea>1e-12?Math.sqrt(triArea/uvArea):1;   // Weltmaß je UV-Einheit
  if(texelIds&&texelIds.length) texelSpacing=Math.sqrt(triArea/texelIds.length);
}
function refreshAfterPose(){
  buildTriangleList();
  tGrid=null; skinLevels=null;
  updateTexelPositions();
  buildTexelHash();
  if(roots.length){ clearRoots(); setStatus('Pose geändert — Wurzeln neu wachsen lassen.'); }
  else setStatus();
}
async function setBody(root,mesh,name){
  disposeBody();
  srcRoot=root; srcMesh=mesh; srcRoot.updateMatrixWorld(true);
  const sg=mesh.geometry;
  if(!sg.attributes.uv){
    paintData=null; texelIds=null; hMap=null; R=0; clearRoots();
    setStatus('Modell hat keine UV-Koordinaten — Malen nicht möglich.');
    return;
  }
  const n=sg.attributes.position.count;
  const geo=new THREE.BufferGeometry();
  geo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(n*3),3));
  geo.setAttribute('uv',new THREE.Float32BufferAttribute(sg.attributes.uv.array.slice(),2));
  if(sg.index) geo.setIndex(new THREE.BufferAttribute(sg.index.array.slice(),1));
  else{ const idx=new Uint32Array(n); for(let i=0;i<n;i++) idx[i]=i; geo.setIndex(new THREE.BufferAttribute(idx,1)); }
  bodyMat=bodyShader();
  body=new THREE.Mesh(geo,bodyMat);
  body.frustumCulled=false;
  scene.add(body);
  modelName=name;
  initPose();
  deform();
  buildTriangleList();
  clearRoots();
  await buildPaintMap(P.mapRes);
  buildTriangleList();
  resetHistory();
  await autoBodypaint();
  setStatus('Modell geladen: '+name);
}
function adoptModel(obj,name){
  let best=null;
  obj.traverse(o=>{
    if(!o.isMesh||!o.geometry?.attributes?.position) return;
    const score=o.geometry.attributes.position.count*(o.isSkinnedMesh?10:1);
    if(!best||score>best.score) best={mesh:o,score};
  });
  if(!best){ setStatus('Kein Mesh in der Datei gefunden.'); return; }
  const group=new THREE.Group(); group.add(obj);
  normalizeObject(group);
  setBody(group,best.mesh,name);
}

// ── Loader
let GLTFLoader=null,FBXLoader=null,GLTFExporter=null;
async function addon(path){
  if(!addonsAvailable) return null;
  try{ return await import('three/addons/'+path); }
  catch(e){ console.warn(e); addonsAvailable=false; return null; }
}
async function ensureLoaders(){
  if(GLTFLoader&&FBXLoader) return true;
  const a=await addon('loaders/GLTFLoader.js'); if(!a) return false;
  const b=await addon('loaders/FBXLoader.js'); if(!b) return false;
  GLTFLoader=a.GLTFLoader; FBXLoader=b.FBXLoader; return true;
}
async function loadFromBuffer(buf,name){
  if(!await ensureLoaders()){ setStatus('Loader nicht verfügbar — Dummy bleibt aktiv.'); return; }
  setStatus('Lade '+name+' …');
  try{
    if(/\.fbx$/i.test(name)) adoptModel(new FBXLoader().parse(buf,''),name);
    else await new Promise((res,rej)=>new GLTFLoader().parse(buf,'',g=>{adoptModel(g.scene,name);res();},rej));
  }catch(e){ console.error(e); setStatus('Datei konnte nicht gelesen werden.'); }
}
async function loadFromURL(url){
  let buf;
  try{
    const r=await fetch(url); if(!r.ok) return false;
    buf=await r.arrayBuffer(); if(buf.byteLength<64) return false;
  }catch(e){ return false; }
  await loadFromBuffer(buf,url.split('/').pop());
  return true;
}
async function autoLoad(){
  for(const f of ['body_minimal.glb','body.glb','body.gltf','body.fbx','model.glb'])
    if(await loadFromURL('./'+f)) return;
}

// ═════════════════════════════════════════════════════════════════════════════
// 5 · POSE
// ═════════════════════════════════════════════════════════════════════════════
let restQ=new Map(), poseQ=new Map(), orderedBones=[], useNameSide=false;
let boneHandles=[], selectedBone=null;
const _qp=new QT(),_qtmp=new QT(),_t1=new V3();
const mirrorQ=q=>new QT(q.x,-q.y,-q.z,q.w);
const SKIP=/(_End$|End$|Index|Middle|Ring|Pinky|Thumb|Eye|Jaw|Toe_)/i;

function initPose(){
  restQ.clear(); poseQ.clear(); orderedBones=[]; selectedBone=null;
  if(srcMesh.isSkinnedMesh){
    const bones=srcMesh.skeleton.bones;
    for(const b of bones) restQ.set(b.uuid,b.quaternion.clone());
    const depth=b=>{let d=0,p=b.parent; while(p){d++;p=p.parent;} return d;};
    orderedBones=bones.slice().sort((a,b)=>depth(a)-depth(b));
    useNameSide=bones.some(b=>/Left|Right/i.test(b.name));
  }
  rebuildBoneHandles();
}
function counterpart(bone){
  const n=bone.name; let m=null;
  if(/Left/.test(n)) m=n.replace('Left','Right');
  else if(/Right/.test(n)) m=n.replace('Right','Left');
  else if(/\.L$/.test(n)) m=n.replace(/\.L$/,'.R');
  else if(/\.R$/.test(n)) m=n.replace(/\.R$/,'.L');
  if(!m) return null;
  return srcMesh.skeleton.bones.find(b=>b.name===m)||null;
}
function isSourceSide(bone){
  if(useNameSide){
    if(/Right/.test(bone.name)||/\.R$/.test(bone.name)) return false;
    return true;
  }
  return _t1.setFromMatrixPosition(bone.matrixWorld).x>=-0.01;
}
const isCenter=bone=>counterpart(bone)===null;
function applyPose(){
  if(!srcMesh?.isSkinnedMesh) return;
  for(const b of orderedBones){
    const rest=restQ.get(b.uuid); if(!rest) continue;
    const d=poseQ.get(b.uuid);
    if(!d){ b.quaternion.copy(rest); continue; }
    if(b.parent) b.parent.getWorldQuaternion(_qp); else _qp.identity();
    b.quaternion.copy(_qp).invert().multiply(d).multiply(_qp).multiply(rest);
  }
  srcRoot.updateMatrixWorld(true);
  srcMesh.skeleton.update();
  deform();
  rebuildBoneHandles();
}
function twistAboutX(q){
  const t=new QT(q.x,0,0,q.w);
  return t.lengthSq()<1e-12 ? new QT() : t.normalize();
}
function applyWorldDelta(bone,q){
  const d=(P.poseMirror&&isCenter(bone))?twistAboutX(q):q;
  const next=d.clone().multiply(poseQ.get(bone.uuid)||new QT());
  poseQ.set(bone.uuid,next);
  const cp=P.poseMirror?counterpart(bone):null;
  if(cp) poseQ.set(cp.uuid,mirrorQ(next));
  applyPose();
}
function resetPose(){ poseQ.clear(); applyPose(); refreshAfterPose(); }
const worldPosOf=b=>new V3().setFromMatrixPosition(b.matrixWorld);
function firstChildBone(b){ return b.children.find(x=>x.isBone)||null; }
function boneAxis(b){
  const o=worldPosOf(b), c=firstChildBone(b);
  if(c) return worldPosOf(c).sub(o).normalize();
  if(b.parent?.isBone) return o.clone().sub(worldPosOf(b.parent)).normalize();
  return new V3(0,1,0);
}
function ringTexture(stroke){
  const c=document.createElement('canvas'); c.width=c.height=64;
  const x=c.getContext('2d');
  x.beginPath(); x.arc(32,32,25,0,Math.PI*2);
  x.fillStyle='#fff'; x.fill(); x.lineWidth=8; x.strokeStyle=stroke; x.stroke();
  const t=new THREE.CanvasTexture(c); t.needsUpdate=true; return t;
}
const pointOpts={sizeAttenuation:false,map:ringTexture('#111'),transparent:true,alphaTest:0.35,depthTest:false};
const boneGeo=new THREE.BufferGeometry();
boneGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const bonePts=new THREE.Points(boneGeo,new THREE.PointsMaterial({...pointOpts,size:12}));
bonePts.renderOrder=12; bonePts.frustumCulled=false; scene.add(bonePts);
const selGeo=new THREE.BufferGeometry();
selGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const selPt=new THREE.Points(selGeo,new THREE.PointsMaterial({...pointOpts,size:18,color:0xff2200}));
selPt.renderOrder=13; selPt.frustumCulled=false; selPt.visible=false; scene.add(selPt);
const skelGeo=new THREE.BufferGeometry();
skelGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(3),3));
const skelLines=new THREE.LineSegments(skelGeo,new THREE.LineBasicMaterial({color:0xbbbbbb,depthTest:false}));
skelLines.renderOrder=11; skelLines.frustumCulled=false; scene.add(skelLines);

function rebuildBoneHandles(){
  boneHandles=[];
  if(srcMesh?.isSkinnedMesh){
    for(const b of srcMesh.skeleton.bones){
      if(SKIP.test(b.name)) continue;
      if(P.poseMirror&&!isSourceSide(b)) continue;
      boneHandles.push({bone:b,world:new V3().setFromMatrixPosition(b.matrixWorld)});
    }
  }
  const arr=new Float32Array(Math.max(1,boneHandles.length)*3);
  boneHandles.forEach((h,i)=>{arr[i*3]=h.world.x;arr[i*3+1]=h.world.y;arr[i*3+2]=h.world.z;});
  boneGeo.setAttribute('position',new THREE.Float32BufferAttribute(arr,3));
  boneGeo.setDrawRange(0,boneHandles.length);

  const seg=[];
  if(srcMesh?.isSkinnedMesh){
    const set=new Set(srcMesh.skeleton.bones);
    for(const b of srcMesh.skeleton.bones){
      if(!b.parent||!set.has(b.parent)) continue;
      const a=new V3().setFromMatrixPosition(b.parent.matrixWorld);
      const c=new V3().setFromMatrixPosition(b.matrixWorld);
      seg.push(a.x,a.y,a.z,c.x,c.y,c.z);
    }
  }
  skelGeo.setAttribute('position',new THREE.Float32BufferAttribute(new Float32Array(seg.length?seg:[0,0,0]),3));
  skelGeo.setDrawRange(0,seg.length/3);

  if(selectedBone){
    const h=boneHandles.find(x=>x.bone===selectedBone);
    if(h){
      const a=selGeo.attributes.position.array;
      a[0]=h.world.x; a[1]=h.world.y; a[2]=h.world.z;
      selGeo.attributes.position.needsUpdate=true;
    }else selectedBone=null;
  }
}

// ═════════════════════════════════════════════════════════════════════════════
// 6 · MALKARTE — zwei Ebenen in einer RGBA-Textur
//     R = Wert Ebene 1 · G = Deckung Ebene 1 · B = Wert Ebene 2 · A = Deckung Ebene 2
// ═════════════════════════════════════════════════════════════════════════════
let R=0;
let texPos=null,texNrm=null,texValid=null,texelIds=null,texTri=null,texBary=null;
let paintData=null,paintTex=null,paintDirty=false;
let hMap=null,hStart=null,hIdx=null;
const HCELL=0.035;
const cellKey=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
const frameP=()=>new Promise(r=>requestAnimationFrame(r));
const vOff=()=>P.layer*2, aOff=()=>P.layer*2+1;

async function buildPaintMap(res){
  if(paintTex){ paintTex.dispose(); paintTex=null; }
  R=res|0;
  const N=R*R;
  texValid=new Uint8Array(N); texTri=new Int32Array(N); texBary=new Float32Array(N*2);
  texPos=new Float32Array(N*3); texNrm=new Float32Array(N*3);
  paintData=new Uint8Array(N*4);
  const uv=body.geometry.attributes.uv, ix=body.geometry.index;
  const nt=ix.count/3;
  let overdraw=0;
  setStatus('Karte wird aufgebaut …');
  await frameP();

  for(let t=0;t<nt;t++){
    const i0=ix.getX(t*3),i1=ix.getX(t*3+1),i2=ix.getX(t*3+2);
    const u0=uv.getX(i0)*R, v0=uv.getY(i0)*R;
    const u1=uv.getX(i1)*R, v1=uv.getY(i1)*R;
    const u2=uv.getX(i2)*R, v2=uv.getY(i2)*R;
    const den=(v1-v2)*(u0-u2)+(u2-u1)*(v0-v2);
    if(Math.abs(den)<1e-12) continue;
    const inv=1/den;
    const x0=Math.max(0,Math.floor(Math.min(u0,u1,u2))-1), x1=Math.min(R-1,Math.ceil(Math.max(u0,u1,u2))+1);
    const y0=Math.max(0,Math.floor(Math.min(v0,v1,v2))-1), y1=Math.min(R-1,Math.ceil(Math.max(v0,v1,v2))+1);
    for(let y=y0;y<=y1;y++){
      const py=y+0.5;
      for(let x=x0;x<=x1;x++){
        const px=x+0.5;
        let l0=((v1-v2)*(px-u2)+(u2-u1)*(py-v2))*inv;
        let l1=((v2-v0)*(px-u2)+(u0-u2)*(py-v2))*inv;
        let l2=1-l0-l1;
        const inside=(l0>=-0.002&&l1>=-0.002&&l2>=-0.002);
        const k=y*R+x;
        if(!inside){
          if(texValid[k]) continue;
          l0=clamp(l0,0,1); l1=clamp(l1,0,1); l2=clamp(l2,0,1);
          const s=l0+l1+l2||1; l0/=s; l1/=s; l2/=s;
          if(Math.hypot(px-(u0*l0+u1*l1+u2*l2),py-(v0*l0+v1*l1+v2*l2))>1.6) continue;
        }else if(texValid[k]===1) overdraw++;
        texTri[k]=t; texBary[k*2]=l0; texBary[k*2+1]=l1;
        texValid[k]=inside?1:2;
      }
    }
    if((t&2047)===0){ setStatus('Karte wird aufgebaut … '+Math.round(t/nt*100)+'%'); await frameP(); }
  }
  let cnt=0; for(let i=0;i<N;i++) if(texValid[i]) cnt++;
  texelIds=new Int32Array(cnt);
  let w=0; for(let i=0;i<N;i++) if(texValid[i]) texelIds[w++]=i;

  updateTexelPositions();
  buildTexelHash();
  skinLevels=null;

  paintTex=new THREE.DataTexture(paintData,R,R,THREE.RGBAFormat,THREE.UnsignedByteType);
  paintTex.magFilter=THREE.LinearFilter; paintTex.minFilter=THREE.LinearFilter;
  paintTex.generateMipmaps=false; paintTex.wrapS=paintTex.wrapT=THREE.ClampToEdgeWrapping;
  paintTex.needsUpdate=true;
  bodyMat.uniforms.map.value=paintTex;

  setStatus('Karte '+R+'×'+R+' · '+cnt.toLocaleString('de-DE')+' Texel'
    +(overdraw>cnt*0.05?' · Achtung: UV-Inseln überlappen':''));
  await frameP();
}
function updateTexelPositions(){
  if(!texelIds) return;
  const g=body.geometry;
  const pa=g.attributes.position.array, na=g.attributes.normal.array, ia=g.index.array;
  for(let k=0;k<texelIds.length;k++){
    const i=texelIds[k], t=texTri[i];
    const l0=texBary[i*2], l1=texBary[i*2+1], l2=1-l0-l1;
    const a=ia[t*3]*3, b=ia[t*3+1]*3, c=ia[t*3+2]*3;
    texPos[i*3  ]=pa[a  ]*l0+pa[b  ]*l1+pa[c  ]*l2;
    texPos[i*3+1]=pa[a+1]*l0+pa[b+1]*l1+pa[c+1]*l2;
    texPos[i*3+2]=pa[a+2]*l0+pa[b+2]*l1+pa[c+2]*l2;
    let nx=na[a]*l0+na[b]*l1+na[c]*l2;
    let ny=na[a+1]*l0+na[b+1]*l1+na[c+1]*l2;
    let nz=na[a+2]*l0+na[b+2]*l1+na[c+2]*l2;
    const ln=Math.hypot(nx,ny,nz)||1;
    texNrm[i*3]=nx/ln; texNrm[i*3+1]=ny/ln; texNrm[i*3+2]=nz/ln;
  }
}
function buildTexelHash(){
  const n=texelIds.length;
  hMap=new Map();
  const bucketOf=new Int32Array(n);
  const counts=[];
  for(let k=0;k<n;k++){
    const i=texelIds[k];
    const key=cellKey(Math.floor(texPos[i*3]/HCELL),Math.floor(texPos[i*3+1]/HCELL),Math.floor(texPos[i*3+2]/HCELL));
    let b=hMap.get(key);
    if(b===undefined){ b=counts.length; hMap.set(key,b); counts.push(0); }
    bucketOf[k]=b; counts[b]++;
  }
  hStart=new Int32Array(counts.length+1);
  for(let b=0;b<counts.length;b++) hStart[b+1]=hStart[b]+counts[b];
  const cursor=hStart.slice(0,counts.length);
  hIdx=new Int32Array(n);
  for(let k=0;k<n;k++){ const b=bucketOf[k]; hIdx[cursor[b]++]=texelIds[k]; }
}
function forTexelsNear(c,r,cb){
  if(!hMap) return;
  const gx0=Math.floor((c.x-r)/HCELL),gx1=Math.floor((c.x+r)/HCELL);
  const gy0=Math.floor((c.y-r)/HCELL),gy1=Math.floor((c.y+r)/HCELL);
  const gz0=Math.floor((c.z-r)/HCELL),gz1=Math.floor((c.z+r)/HCELL);
  for(let x=gx0;x<=gx1;x++)for(let y=gy0;y<=gy1;y++)for(let z=gz0;z<=gz1;z++){
    const b=hMap.get(cellKey(x,y,z)); if(b===undefined) continue;
    for(let k=hStart[b];k<hStart[b+1];k++) cb(hIdx[k]);
  }
}
function clearLayer(){
  if(!paintData) return;
  const vo=vOff(), ao=aOff();
  for(let i=0;i<R*R;i++){ paintData[i*4+vo]=0; paintData[i*4+ao]=0; }
  paintDirty=true; pushHistory(); setStatus('Ebene '+(P.layer+1)+' geleert.');
}
// ── Pinsel
function stampAt(c,n){
  const r=P.brushRadius, r2=r*r;
  const soft=Math.max(1e-3,1-P.hardness);
  const nl=P.normalLimit, flow=P.flow;
  const target=Math.round(clamp(P.target,0,1)*255);
  const vo=vOff(), ao=aOff();
  const erase=P.brushMode==='erase';
  forTexelsNear(c,r,i=>{
    const dx=texPos[i*3]-c.x, dy=texPos[i*3+1]-c.y, dz=texPos[i*3+2]-c.z;
    const d2=dx*dx+dy*dy+dz*dz; if(d2>r2) return;
    const dot=texNrm[i*3]*n.x+texNrm[i*3+1]*n.y+texNrm[i*3+2]*n.z;
    if(dot<nl) return;
    const k=1-Math.sqrt(d2)/r;
    let f=clamp(k/soft,0,1); f=f*f*(3-2*f);
    const w=f*flow;
    if(w<=0) return;
    const o=i*4;
    if(erase){
      paintData[o+ao]=Math.max(0,paintData[o+ao]-w*255);
      if(paintData[o+ao]<2){ paintData[o+vo]=0; paintData[o+ao]=0; }
    }else{
      const v=paintData[o+vo], a=paintData[o+ao];
      paintData[o+vo]=v+(target-v)*w;
      paintData[o+ao]=Math.min(255,a+(255-a)*w);
    }
  });
  paintDirty=true;
}
function smoothAt(c,n){
  const r=P.brushRadius, r2=r*r, nl=P.normalLimit;
  const vo=vOff(), ao=aOff();
  const ids=[], ws=[];
  let bx0=1e9,bx1=-1,by0=1e9,by1=-1;
  forTexelsNear(c,r,i=>{
    const dx=texPos[i*3]-c.x, dy=texPos[i*3+1]-c.y, dz=texPos[i*3+2]-c.z;
    const d2=dx*dx+dy*dy+dz*dz; if(d2>r2) return;
    const dot=texNrm[i*3]*n.x+texNrm[i*3+1]*n.y+texNrm[i*3+2]*n.z;
    if(dot<nl) return;
    ids.push(i); ws.push(clamp((1-Math.sqrt(d2)/r)*1.8,0,1));
    const x=i%R, y=(i/R)|0;
    if(x<bx0)bx0=x; if(x>bx1)bx1=x; if(y<by0)by0=y; if(y>by1)by1=y;
  });
  if(!ids.length) return;
  const kr=clamp(Math.round(r/Math.max(texelSpacing,1e-6)*0.5),1,48);
  const px0=Math.max(0,bx0-kr), px1=Math.min(R-1,bx1+kr);
  const py0=Math.max(0,by0-kr), py1=Math.min(R-1,by1+kr);
  const W=px1-px0+1, H=py1-py0+1;
  const sv=new Float32Array(W*H), sa=new Float32Array(W*H), mk=new Float32Array(W*H);
  for(let y=0;y<H;y++)for(let x=0;x<W;x++){
    const gi=(py0+y)*R+(px0+x); if(!texValid[gi]) continue;
    const o=gi*4, a=paintData[o+ao];
    const q=y*W+x;
    mk[q]=1; sa[q]=a; sv[q]=paintData[o+vo]*(a/255);
  }
  const box=(src,dst,w,h,horiz)=>{
    const len=horiz?w:h, cross=horiz?h:w;
    const pre=new Float32Array(len+1);
    for(let c2=0;c2<cross;c2++){
      for(let i=0;i<len;i++){
        const q=horiz?(c2*w+i):(i*w+c2);
        pre[i+1]=pre[i]+src[q];
      }
      for(let i=0;i<len;i++){
        const a=Math.max(0,i-kr), b=Math.min(len,i+kr+1);
        const q=horiz?(c2*w+i):(i*w+c2);
        dst[q]=pre[b]-pre[a];
      }
    }
  };
  const t1=new Float32Array(W*H),t2=new Float32Array(W*H),t3=new Float32Array(W*H);
  const u1=new Float32Array(W*H),u2=new Float32Array(W*H),u3=new Float32Array(W*H);
  box(sv,t1,W,H,true); box(sa,t2,W,H,true); box(mk,t3,W,H,true);
  box(t1,u1,W,H,false); box(t2,u2,W,H,false); box(t3,u3,W,H,false);
  const str=clamp(P.flow*1.8,0,1);
  for(let q=0;q<ids.length;q++){
    const i=ids[q], x=i%R, y=(i/R)|0;
    const p=(y-py0)*W+(x-px0);
    const m=u3[p]; if(m<1e-6) continue;
    const aB=u2[p]/m;
    const vB=(u2[p]>1e-4)?(u1[p]/(u2[p]/255)):paintData[i*4+vo];
    const w=ws[q]*str;
    const o=i*4;
    paintData[o+vo]=paintData[o+vo]+(vB-paintData[o+vo])*w;
    paintData[o+ao]=paintData[o+ao]+(aB-paintData[o+ao])*w;
  }
  paintDirty=true;
}
const _mn2=new V3();
function paintAt(hit){
  const c=hit.point, n=hit.normal;
  if(P.brushMode==='smooth') smoothAt(c,n); else stampAt(c,n);
  if(P.mirror){
    _mn2.set(-n.x,n.y,n.z);
    const cm={x:-c.x,y:c.y,z:c.z};
    if(P.brushMode==='smooth') smoothAt(cm,_mn2); else stampAt(cm,_mn2);
  }
}
const _s=[0,0,0,0];
function sampleMap(u,v){
  const x=clamp(u,0,1)*R-0.5, y=clamp(v,0,1)*R-0.5;
  const x0=Math.floor(x), y0=Math.floor(y), fx=x-x0, fy=y-y0;
  _s[0]=_s[1]=_s[2]=_s[3]=0;
  for(let j=0;j<2;j++)for(let i=0;i<2;i++){
    const xx=clamp(x0+i,0,R-1), yy=clamp(y0+j,0,R-1);
    const w=(i?fx:1-fx)*(j?fy:1-fy);
    const o=(yy*R+xx)*4;
    _s[0]+=paintData[o]*w; _s[1]+=paintData[o+1]*w;
    _s[2]+=paintData[o+2]*w; _s[3]+=paintData[o+3]*w;
  }
  _s[0]/=255; _s[1]/=255; _s[2]/=255; _s[3]/=255;
  return _s;
}
function paintedRatio(layer){
  if(!texelIds) return 0;
  const ao=layer*2+1;
  let c=0;
  for(let k=0;k<texelIds.length;k++) if(paintData[texelIds[k]*4+ao]>25) c++;
  return c/texelIds.length;
}

// ═════════════════════════════════════════════════════════════════════════════
// 7 · UNDO
// ═════════════════════════════════════════════════════════════════════════════
let history=[], histIdx=-1;
function pushHistory(){
  if(!paintData) return;
  history=history.slice(0,histIdx+1);
  history.push(paintData.slice());
  const cap=R>1024?4:10;
  while(history.length>cap) history.shift();
  histIdx=history.length-1; updateBar();
}
function resetHistory(){ history=[]; histIdx=-1; pushHistory(); }
function restore(i){ paintData.set(history[i]); paintDirty=true; updateBar(); setStatus(); }
function undo(){ if(histIdx<=0) return; histIdx--; restore(histIdx); }
function redo(){ if(histIdx>=history.length-1) return; histIdx++; restore(histIdx); }
addEventListener('keydown',ev=>{
  if(!(ev.ctrlKey||ev.metaKey)) return;
  const k=ev.key.toLowerCase();
  if(k==='z'){ ev.preventDefault(); ev.shiftKey?redo():undo(); }
  else if(k==='y'){ ev.preventDefault(); redo(); }
});

// ═════════════════════════════════════════════════════════════════════════════
// 8 · PINSELZEIGER
// ═════════════════════════════════════════════════════════════════════════════
function ringMesh(op){
  const g=new THREE.RingGeometry(0.965,1,64);
  const m=new THREE.MeshBasicMaterial({color:0x111111,transparent:true,opacity:op,
    side:THREE.DoubleSide,depthTest:false});
  const r=new THREE.Mesh(g,m); r.renderOrder=30; r.frustumCulled=false; r.visible=false;
  scene.add(r); return r;
}
const cursor=ringMesh(0.9), cursorM=ringMesh(0.35);
const _up=new V3(0,0,1), _mp=new V3(), _mn=new V3();
function placeCursor(hit){
  cursor.position.copy(hit.point).addScaledVector(hit.normal,0.004);
  cursor.quaternion.setFromUnitVectors(_up,hit.normal);
  cursor.scale.setScalar(P.brushRadius);
  _mn.set(-hit.normal.x,hit.normal.y,hit.normal.z);
  _mp.set(-hit.point.x,hit.point.y,hit.point.z).addScaledVector(_mn,0.004);
  cursorM.position.copy(_mp);
  cursorM.quaternion.setFromUnitVectors(_up,_mn);
  cursorM.scale.setScalar(P.brushRadius);
}

// ═════════════════════════════════════════════════════════════════════════════
// 9 · POINTER
// ═════════════════════════════════════════════════════════════════════════════
const raycaster=new THREE.Raycaster();
const ndc=new THREE.Vector2();
const _nm=new THREE.Matrix3(), _cd=new V3(), _pr=new V3();
let painting=false, orbiting=null, hoverHit=null, strokeTouched=false, dragBone=null;
const ROT_SPEED=0.012, ROOT_SPEED=0.005;

function setNDC(ev){
  const r=el.getBoundingClientRect();
  ndc.x=((ev.clientX-r.left)/r.width)*2-1;
  ndc.y=-((ev.clientY-r.top)/r.height)*2+1;
  raycaster.setFromCamera(ndc,camera);
}
function toScreen(w){
  _pr.copy(w).project(camera);
  const r=el.getBoundingClientRect();
  return [(_pr.x*0.5+0.5)*r.width+r.left,(-_pr.y*0.5+0.5)*r.height+r.top];
}
function pickFrom(list,ev,px){
  let best=null,bd=px*px;
  for(const e of list){
    const [x,y]=toScreen(e.world);
    const d=(x-ev.clientX)**2+(y-ev.clientY)**2;
    if(d<bd){bd=d;best=e;}
  }
  return best;
}
function hitBody(){
  if(!body) return null;
  const h=raycaster.intersectObject(body,false);
  if(!h.length) return null;
  const it=h[0];
  _nm.getNormalMatrix(body.matrixWorld);
  const n=it.face?it.face.normal.clone().applyMatrix3(_nm).normalize():new V3(0,0,1);
  return {point:it.point.clone(),normal:n};
}
el.addEventListener('pointerdown',ev=>{
  el.setPointerCapture(ev.pointerId);
  setNDC(ev);
  if(ev.button===0&&!ev.altKey){
    if(P.poseMode){
      const h=pickFrom(boneHandles,ev,14);
      if(h){
        selectedBone=h.bone; rebuildBoneHandles();
        const set=new Set(srcMesh.skeleton.bones);
        const parent=(h.bone.parent&&set.has(h.bone.parent))?h.bone.parent:null;
        const plane=new THREE.Plane();
        camera.getWorldDirection(_cd);
        plane.setFromNormalAndCoplanarPoint(_cd.clone().negate(),h.world.clone());
        dragBone={handle:h.bone,rot:ev.shiftKey?h.bone:(parent||h.bone),plane,
                  twist:ev.shiftKey,x:ev.clientX,y:ev.clientY,
                  // Der Wurzelknochen hat keinen brauchbaren Hebelarm — sein
                  // erstes Kind sitzt fast im selben Punkt. Deshalb dort über
                  // das Mausdelta drehen statt über die Zielrichtung.
                  ball:!ev.shiftKey&&!parent};
        setStatus((ev.shiftKey?'Twist: ':'Bone: ')+h.bone.name.replace(/^mixamorig\d*:?/,''));
        return;
      }
    }else if(!ev.shiftKey&&paintData){
      const hit=hitBody();
      if(hit){ painting=true; strokeTouched=true; paintAt(hit); placeCursor(hit); return; }
    }
  }
  orbiting={x:ev.clientX,y:ev.clientY,mode:(ev.button===1||ev.shiftKey)?'pan':'orbit'};
});
el.addEventListener('pointermove',ev=>{
  setNDC(ev);
  if(dragBone){
    const b=dragBone.rot;
    if(dragBone.twist){
      const dx=ev.clientX-dragBone.x; dragBone.x=ev.clientX;
      if(dx) applyWorldDelta(b,_qtmp.setFromAxisAngle(boneAxis(b),dx*ROT_SPEED).clone());
      return;
    }
    if(dragBone.ball){
      const dx=ev.clientX-dragBone.x, dy=ev.clientY-dragBone.y;
      dragBone.x=ev.clientX; dragBone.y=ev.clientY;
      if(!dx&&!dy) return;
      const right=new V3().setFromMatrixColumn(camera.matrixWorld,0).normalize();
      const q=new QT().setFromAxisAngle(new V3(0,1,0),-dx*ROOT_SPEED);
      if(dy) q.multiply(_qtmp.setFromAxisAngle(right,dy*ROOT_SPEED));
      applyWorldDelta(b,q);
      return;
    }
    const target=new V3();
    if(!raycaster.ray.intersectPlane(dragBone.plane,target)) return;
    const O=worldPosOf(b);
    const tipBone=(dragBone.handle!==b)?dragBone.handle:firstChildBone(b);
    if(!tipBone) return;
    const v0=worldPosOf(tipBone).sub(O), v1=target.sub(O);
    if(v0.lengthSq()<1e-8||v1.lengthSq()<1e-8) return;
    applyWorldDelta(b,new QT().setFromUnitVectors(v0.normalize(),v1.normalize()));
    return;
  }
  if(painting){
    const hit=hitBody();
    if(hit){ paintAt(hit); placeCursor(hit); hoverHit=hit; }
    return;
  }
  if(orbiting){
    const dx=ev.clientX-orbiting.x,dy=ev.clientY-orbiting.y;
    orbiting.x=ev.clientX; orbiting.y=ev.clientY;
    if(orbiting.mode==='orbit'){
      cam.theta-=dx*0.006;
      cam.phi=clamp(cam.phi-dy*0.006,0.08,Math.PI-0.08);
    }else{
      const right=new V3().setFromMatrixColumn(camera.matrixWorld,0);
      const up=new V3().setFromMatrixColumn(camera.matrixWorld,1);
      cam.target.addScaledVector(right,-dx*0.0016*cam.dist).addScaledVector(up,dy*0.0016*cam.dist);
    }
    applyCam(); return;
  }
  if(P.poseMode){ hoverHit=null; el.style.cursor=pickFrom(boneHandles,ev,14)?'grab':'default'; return; }
  hoverHit=hitBody();
  if(hoverHit) placeCursor(hoverHit);
});
addEventListener('pointerup',()=>{
  if(painting&&strokeTouched) pushHistory();
  if(dragBone){ dragBone=null; refreshAfterPose(); }
  painting=false; strokeTouched=false; orbiting=null; setStatus();
});
el.addEventListener('wheel',ev=>{
  ev.preventDefault();
  if(ev.altKey){
    P.brushRadius=clamp(P.brushRadius*Math.exp(-ev.deltaY*0.0016),0.004,0.4);
    syncUI(); return;
  }
  cam.dist=clamp(cam.dist*Math.exp(ev.deltaY*0.0011),0.4,20);
  applyCam();
},{passive:false});
el.addEventListener('pointerleave',()=>{ if(!painting) hoverHit=null; });
el.addEventListener('contextmenu',e=>e.preventDefault());

const dropEl=document.getElementById('drop');
['dragenter','dragover'].forEach(t=>addEventListener(t,e=>{e.preventDefault(); dropEl.style.display='grid';}));
addEventListener('dragleave',e=>{ if(e.target===dropEl) dropEl.style.display='none'; });
addEventListener('drop',async e=>{
  e.preventDefault(); dropEl.style.display='none';
  const f=e.dataTransfer?.files?.[0]; if(!f) return;
  loadFromBuffer(await f.arrayBuffer(),f.name);
});

// ═════════════════════════════════════════════════════════════════════════════
// 10 · WURZELWERK
//      Ebene 1 → Dichte und Stärke (weiß = fein und dicht, schwarz = grob und weit)
//      Ebene 2 → Abstand des Ansatzes von der Haut (schwarz = weit, weiß = knapp,
//                unbemalt = direkt auf der Haut)
// ═════════════════════════════════════════════════════════════════════════════
const PERM=(()=>{const p=[151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,
240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,
20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,
41,55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,
159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,
47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,
253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,
81,51,145,235,249,14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,
205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180];
const a=new Uint8Array(512);for(let i=0;i<512;i++)a[i]=p[i&255];return a;})();
const fade=t=>t*t*t*(t*(t*6-15)+10), lerpN=(t,a,b)=>a+t*(b-a);
function grad(h,x,y,z){h&=15;const u=h<8?x:y,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,B=PERM[X+1]+Y,BA=PERM[B]+Z,BB=PERM[B+1]+Z;
  return lerpN(w,lerpN(v,lerpN(u,grad(PERM[AA],x,y,z),grad(PERM[BA],x-1,y,z)),
                         lerpN(u,grad(PERM[AB],x,y-1,z),grad(PERM[BB],x-1,y-1,z))),
                 lerpN(v,lerpN(u,grad(PERM[AA+1],x,y,z-1),grad(PERM[BA+1],x-1,y,z-1)),
                         lerpN(u,grad(PERM[AB+1],x,y-1,z-1),grad(PERM[BB+1],x-1,y-1,z-1))));
}
const NO=[[0,0,0],[37.1,12.3,5.2],[-19.7,51.9,-3.4]];
const psi=(i,x,y,z)=>noise3(x+NO[i][0],y+NO[i][1],z+NO[i][2]);
function curl(p,out){
  const s=P.noiseScale,x=p.x*s,y=p.y*s,z=p.z*s,e=0.14;
  out.set(
    (psi(2,x,y+e,z)-psi(2,x,y-e,z))/(2*e) - (psi(1,x,y,z+e)-psi(1,x,y,z-e))/(2*e),
    (psi(0,x,y,z+e)-psi(0,x,y,z-e))/(2*e) - (psi(2,x+e,y,z)-psi(2,x-e,y,z))/(2*e),
    (psi(1,x+e,y,z)-psi(1,x,y,z-e))/(2*e) - (psi(0,x,y+e,z)-psi(0,x,y-e,z))/(2*e));
  const l=out.length(); if(l>1e-6) out.multiplyScalar(1/l);
  return out;
}

let seeds=[], roots=[], shoots=[], rootPts=0, busy=false;
let rootMesh=null, preLines=null, pillarLines=null;
const rootGroup=new THREE.Group(); scene.add(rootGroup);
const preGroup=new THREE.Group(); scene.add(preGroup);

const ovlEl=document.getElementById('ovl'), ovlTxt=document.getElementById('ovlTxt'),
      ovlBar=document.getElementById('ovlBar');
function progress(t,f){ ovlEl.style.display='grid'; ovlTxt.textContent=t; ovlBar.style.width=Math.round(clamp(f,0,1)*100)+'%'; }
function progressEnd(){ ovlEl.style.display='none'; }
const step=async(t,f)=>{ progress(t,f); await frameP(); };

function clearMeshOnly(){
  if(rootMesh){ rootGroup.remove(rootMesh); rootMesh.geometry.dispose(); rootMesh.material.dispose(); rootMesh=null; }
  rootG=null; rootC=null;
}
function clearRoots(){
  clearMeshOnly();
  if(preLines){ preGroup.remove(preLines); preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
  if(pillarLines){ preGroup.remove(pillarLines); pillarLines.geometry.dispose(); pillarLines.material.dispose(); pillarLines=null; }
  rootGroup.clear(); preGroup.clear();
  seeds=[]; roots=[]; shoots=[]; rootPts=0; pillars=[]; pGrid=null; pBlock=null; occ=null; setStatus();
}

// ═══ Oberflächen-Projektion (Dreiecksraster) — für die Saat ═══
let tGrid=null, tCell=0.03;
const tkey=(x,y,z)=>(x+1024)+(y+1024)*2048+(z+1024)*4194304;
function buildTriGrid(){
  tCell=Math.max(0.02,Math.sqrt(triArea/Math.max(1,triCDF.length))*3);
  tGrid=new Map();
  const nt=triCDF.length;
  for(let t=0;t<nt;t++){
    let x0=1e9,y0=1e9,z0=1e9,x1=-1e9,y1=-1e9,z1=-1e9;
    for(const A of [triA,triB,triC]){
      const x=A[t*3],y=A[t*3+1],z=A[t*3+2];
      if(x<x0)x0=x; if(y<y0)y0=y; if(z<z0)z0=z;
      if(x>x1)x1=x; if(y>y1)y1=y; if(z>z1)z1=z;
    }
    for(let x=Math.floor(x0/tCell);x<=Math.floor(x1/tCell);x++)
    for(let y=Math.floor(y0/tCell);y<=Math.floor(y1/tCell);y++)
    for(let z=Math.floor(z0/tCell);z<=Math.floor(z1/tCell);z++){
      const k=tkey(x,y,z); let arr=tGrid.get(k); if(!arr){arr=[];tGrid.set(k,arr);} arr.push(t);
    }
  }
}
const _pt={d2:0,l0:0,l1:0,l2:0,x:0,y:0,z:0};
function ptTri(px,py,pz,t){
  const o=t*3;
  const ax=triA[o],ay=triA[o+1],az=triA[o+2];
  const bx=triB[o],by=triB[o+1],bz=triB[o+2];
  const cx=triC[o],cy=triC[o+1],cz=triC[o+2];
  const abx=bx-ax,aby=by-ay,abz=bz-az, acx=cx-ax,acy=cy-ay,acz=cz-az;
  const apx=px-ax,apy=py-ay,apz=pz-az;
  const d1=abx*apx+aby*apy+abz*apz, d2=acx*apx+acy*apy+acz*apz;
  let l0,l1,l2;
  if(d1<=0&&d2<=0){ l0=1;l1=0;l2=0; }
  else{
    const bpx=px-bx,bpy=py-by,bpz=pz-bz;
    const d3=abx*bpx+aby*bpy+abz*bpz, d4=acx*bpx+acy*bpy+acz*bpz;
    if(d3>=0&&d4<=d3){ l0=0;l1=1;l2=0; }
    else{
      const vc=d1*d4-d3*d2;
      if(vc<=0&&d1>=0&&d3<=0){ const v=d1/(d1-d3); l0=1-v;l1=v;l2=0; }
      else{
        const cpx=px-cx,cpy=py-cy,cpz=pz-cz;
        const d5=abx*cpx+aby*cpy+abz*cpz, d6=acx*cpx+acy*cpy+acz*cpz;
        if(d6>=0&&d5<=d6){ l0=0;l1=0;l2=1; }
        else{
          const vb=d5*d2-d1*d6;
          if(vb<=0&&d2>=0&&d6<=0){ const w=d2/(d2-d6); l0=1-w;l1=0;l2=w; }
          else{
            const va=d3*d6-d5*d4;
            if(va<=0&&(d4-d3)>=0&&(d5-d6)>=0){ const w=(d4-d3)/((d4-d3)+(d5-d6)); l0=0;l1=1-w;l2=w; }
            else{ const den=1/(va+vb+vc), v=vb*den, w=vc*den; l0=1-v-w;l1=v;l2=w; }
          }
        }
      }
    }
  }
  const qx=ax*l0+bx*l1+cx*l2, qy=ay*l0+by*l1+cy*l2, qz=az*l0+bz*l1+cz*l2;
  _pt.d2=(qx-px)**2+(qy-py)**2+(qz-pz)**2;
  _pt.l0=l0;_pt.l1=l1;_pt.l2=l2;_pt.x=qx;_pt.y=qy;_pt.z=qz;
  return _pt;
}
const _sp={x:0,y:0,z:0,nx:0,ny:1,nz:0,u:0,v:0,t:-1,ok:false};
function onSurface(px,py,pz){
  _sp.ok=false;
  if(!tGrid) buildTriGrid();
  const gx=Math.floor(px/tCell),gy=Math.floor(py/tCell),gz=Math.floor(pz/tCell);
  let best=1e9,bt=-1,bl0=0,bl1=0,bl2=0,bx=0,by=0,bz=0;
  for(let ring=0;ring<=4;ring++){
    if(bt>=0&&Math.sqrt(best)<=ring*tCell) break;
    for(let x=gx-ring;x<=gx+ring;x++)for(let y=gy-ring;y<=gy+ring;y++)for(let z=gz-ring;z<=gz+ring;z++){
      if(ring>0&&Math.abs(x-gx)<ring&&Math.abs(y-gy)<ring&&Math.abs(z-gz)<ring) continue;
      const arr=tGrid.get(tkey(x,y,z)); if(!arr) continue;
      for(const t of arr){
        const r=ptTri(px,py,pz,t);
        if(r.d2<best){ best=r.d2;bt=t;bl0=r.l0;bl1=r.l1;bl2=r.l2;bx=r.x;by=r.y;bz=r.z; }
      }
    }
  }
  if(bt<0) return _sp;
  const g=body.geometry, ia=g.index.array, na=g.attributes.normal.array, ua=g.attributes.uv.array;
  const i0=ia[bt*3],i1=ia[bt*3+1],i2=ia[bt*3+2];
  let nx=na[i0*3]*bl0+na[i1*3]*bl1+na[i2*3]*bl2;
  let ny=na[i0*3+1]*bl0+na[i1*3+1]*bl1+na[i2*3+1]*bl2;
  let nz=na[i0*3+2]*bl0+na[i1*3+2]*bl1+na[i2*3+2]*bl2;
  const l=Math.hypot(nx,ny,nz)||1;
  _sp.x=bx;_sp.y=by;_sp.z=bz;_sp.nx=nx/l;_sp.ny=ny/l;_sp.nz=nz/l;
  _sp.u=ua[i0*2]*bl0+ua[i1*2]*bl1+ua[i2*2]*bl2;
  _sp.v=ua[i0*2+1]*bl0+ua[i1*2+1]*bl1+ua[i2*2+1]*bl2;
  _sp.t=bt;_sp.ok=true;
  return _sp;
}

// ═══ Hautfeld: zweistufiges Punktraster für schnelle Nächster-Punkt-Abfragen ═══
let skinLevels=null;
function buildSkinLevel(cell){
  const acc=new Map();
  const g=body.geometry, ua=g.attributes.uv.array, ia=g.index.array;
  for(let k=0;k<texelIds.length;k++){
    const i=texelIds[k];
    const x=texPos[i*3],y=texPos[i*3+1],z=texPos[i*3+2];
    const kk=cellKey(Math.floor(x/cell),Math.floor(y/cell),Math.floor(z/cell));
    let e=acc.get(kk);
    if(!e){
      // Leittexel der Zelle liefert UV — nicht mitteln, sonst wird an
      // UV-Nähten über zwei Inseln hinweg gemischt
      const t=texTri[i];
      const l0=texBary[i*2], l1=texBary[i*2+1], l2=1-l0-l1;
      const i0=ia[t*3],i1=ia[t*3+1],i2=ia[t*3+2];
      e={x:0,y:0,z:0,nx:0,ny:0,nz:0,n:0,
         u:ua[i0*2]*l0+ua[i1*2]*l1+ua[i2*2]*l2,
         v:ua[i0*2+1]*l0+ua[i1*2+1]*l1+ua[i2*2+1]*l2};
      acc.set(kk,e);
    }
    e.x+=x; e.y+=y; e.z+=z;
    e.nx+=texNrm[i*3]; e.ny+=texNrm[i*3+1]; e.nz+=texNrm[i*3+2];
    e.n++;
  }
  const n=acc.size;
  const L={cell,map:new Map(),
    x:new Float32Array(n),y:new Float32Array(n),z:new Float32Array(n),
    nx:new Float32Array(n),ny:new Float32Array(n),nz:new Float32Array(n),
    u:new Float32Array(n),v:new Float32Array(n)};
  let i=0;
  for(const [k,e] of acc){
    L.x[i]=e.x/e.n; L.y[i]=e.y/e.n; L.z[i]=e.z/e.n;
    const l=Math.hypot(e.nx,e.ny,e.nz)||1;
    L.nx[i]=e.nx/l; L.ny[i]=e.ny/l; L.nz[i]=e.nz/l;
    L.u[i]=e.u; L.v[i]=e.v;
    L.map.set(k,i); i++;
  }
  return L;
}
function buildSkinField(){
  skinLevels=[buildSkinLevel(0.010),buildSkinLevel(0.055)];
}
const _sk={ok:false,x:0,y:0,z:0,nx:0,ny:1,nz:0,u:0,v:0,d:0};
function queryLevel(L,x,y,z,rings){
  const c=L.cell;
  const gx=Math.floor(x/c),gy=Math.floor(y/c),gz=Math.floor(z/c);
  let best=1e9,bi=-1;
  for(let ring=0;ring<=rings;ring++){
    if(bi>=0&&best<=((ring-1)*c)*((ring-1)*c)) break;
    for(let a=gx-ring;a<=gx+ring;a++)for(let b=gy-ring;b<=gy+ring;b++)for(let d=gz-ring;d<=gz+ring;d++){
      if(ring>0&&Math.abs(a-gx)<ring&&Math.abs(b-gy)<ring&&Math.abs(d-gz)<ring) continue;
      const id=L.map.get(cellKey(a,b,d)); if(id===undefined) continue;
      const q=(L.x[id]-x)**2+(L.y[id]-y)**2+(L.z[id]-z)**2;
      if(q<best){ best=q; bi=id; }
    }
  }
  return bi<0?-1:(_qi=bi,_qd=best,bi);
}
let _qi=-1,_qd=0;
function nearestSkin(x,y,z){
  _sk.ok=false;
  if(!skinLevels) return _sk;
  let L=skinLevels[0];
  let id=queryLevel(L,x,y,z,2);
  if(id<0){ L=skinLevels[1]; id=queryLevel(L,x,y,z,4); }
  if(id<0) return _sk;
  _sk.ok=true;
  _sk.x=L.x[id]; _sk.y=L.y[id]; _sk.z=L.z[id];
  _sk.nx=L.nx[id]; _sk.ny=L.ny[id]; _sk.nz=L.nz[id];
  _sk.u=L.u[id]; _sk.v=L.v[id];
  _sk.d=Math.sqrt(_qd);
  return _sk;
}

// ═══ 1 · Saat: Kreispackung aus Ebene 1 ═══
const radiusFor=v=>P.rMin+(P.rMax-P.rMin)*Math.pow(clamp(1-v,0,1),P.gamma);
const spacingOf=A=>2*A.r*P.packing;
const _mm={v:0,a:0,r:0,h:0};
function readMaps(u,v,out){
  const m=sampleMap(u,v);
  out.v=m[0]; out.a=m[1];
  out.r=radiusFor(m[0]);
  out.h=m[3]*(1-m[2])*P.lift;
  return out;
}
function pickTriangle(){
  const t=rnd()*triArea;
  let lo=0,hi=triCDF.length-1;
  while(lo<hi){ const m=(lo+hi)>>1; if(triCDF[m]<t) lo=m+1; else hi=m; }
  return lo;
}
function surfaceSample(){
  const t=pickTriangle();
  let b1=rnd(),b2=rnd(); if(b1+b2>1){b1=1-b1;b2=1-b2;}
  const b0=1-b1-b2;
  const g=body.geometry, ia=g.index.array, ua=g.attributes.uv.array, na=g.attributes.normal.array;
  const i0=ia[t*3],i1=ia[t*3+1],i2=ia[t*3+2], o=t*3;
  const x=triA[o]*b0+triB[o]*b1+triC[o]*b2;
  const y=triA[o+1]*b0+triB[o+1]*b1+triC[o+1]*b2;
  const z=triA[o+2]*b0+triB[o+2]*b1+triC[o+2]*b2;
  let nx=na[i0*3]*b0+na[i1*3]*b1+na[i2*3]*b2;
  let ny=na[i0*3+1]*b0+na[i1*3+1]*b1+na[i2*3+1]*b2;
  let nz=na[i0*3+2]*b0+na[i1*3+2]*b1+na[i2*3+2]*b2;
  const l=Math.hypot(nx,ny,nz)||1;
  return {x,y,z,nx:nx/l,ny:ny/l,nz:nz/l,
          u:ua[i0*2]*b0+ua[i1*2]*b1+ua[i2*2]*b2,
          v:ua[i0*2+1]*b0+ua[i1*2+1]*b1+ua[i2*2+1]*b2};
}
function sampleSeeds(){
  srand(P.seed);
  const cand=[];
  for(let i=0;i<P.samples;i++){
    const p=surfaceSample();
    readMaps(p.u,p.v,_mm);
    if(_mm.a<P.alphaMin) continue;                 // radiert / unbemalt → keine Wurzel
    if(P.mirror&&p.x<-0.001) continue;
    if(P.densityBias>0 && rnd()>1-P.densityBias+P.densityBias*(1-_mm.v)) continue;
    let r=_mm.r*(1+(rnd()*2-1)*P.jitter);
    r=Math.max(0.0008,r*Math.min(1,_mm.a*1.6));
    cand.push({x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,u:p.u,v:p.v,r,h:_mm.h});
  }
  cand.sort((A,B)=>B.r-A.r);
  const cell=Math.max(0.006,2*P.rMax*(1+P.jitter)*1.02);
  const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
  const grid=new Map();
  const acc=[];
  const fits=t=>{
    const gx=Math.floor(t.x/cell),gy=Math.floor(t.y/cell),gz=Math.floor(t.z/cell);
    for(let x=gx-1;x<=gx+1;x++)for(let y=gy-1;y<=gy+1;y++)for(let z=gz-1;z<=gz+1;z++){
      const arr=grid.get(key(x,y,z)); if(!arr) continue;
      for(const o of arr){
        const dx=o.x-t.x,dy=o.y-t.y,dz=o.z-t.z;
        const lim=(o.r+t.r)*P.packing;
        if(dx*dx+dy*dy+dz*dz<lim*lim) return false;
      }
    }
    return true;
  };
  const insert=t=>{
    const k=key(Math.floor(t.x/cell),Math.floor(t.y/cell),Math.floor(t.z/cell));
    let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(t);
    t.s=spacingOf(t); acc.push(t);
  };
  for(const t of cand){
    if(P.mirror){
      if(Math.abs(t.x)<t.r*P.packing){
        t.x=0; t.nx=0;
        const l=Math.hypot(t.ny,t.nz)||1; t.ny/=l; t.nz/=l;
        if(fits(t)) insert(t);
      }else{
        const m={x:-t.x,y:t.y,z:t.z,nx:-t.nx,ny:t.ny,nz:t.nz,u:t.u,v:t.v,r:t.r,h:t.h,mir:true,srcRef:t};
        if(fits(t)&&fits(m)){ insert(t); insert(m); }
      }
    }else if(fits(t)) insert(t);
  }
  seeds=acc;
  return seeds.length>2;
}
function syncMirrors(){
  for(const A of seeds){
    if(!A.mir) continue;
    const S=A.srcRef;
    A.x=-S.x; A.y=S.y; A.z=S.z;
    A.nx=-S.nx; A.ny=S.ny; A.nz=S.nz;
    A.r=S.r; A.h=S.h; A.s=S.s;
  }
}
async function relaxSeeds(){
  const iters=P.relax|0; if(iters<=0){ syncMirrors(); return; }
  const n=seeds.length;
  const fx=new Float64Array(n),fy=new Float64Array(n),fz=new Float64Array(n);
  const cell=Math.max(0.006,2*P.rMax*(1+P.jitter)*1.05);
  const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
  for(let it=0;it<iters;it++){
    syncMirrors();
    const grid=new Map();
    for(let i=0;i<n;i++){
      const k=key(Math.floor(seeds[i].x/cell),Math.floor(seeds[i].y/cell),Math.floor(seeds[i].z/cell));
      let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(i);
    }
    fx.fill(0);fy.fill(0);fz.fill(0);
    for(let i=0;i<n;i++){
      const A=seeds[i];
      const gx=Math.floor(A.x/cell),gy=Math.floor(A.y/cell),gz=Math.floor(A.z/cell);
      for(let x=gx-1;x<=gx+1;x++)for(let y=gy-1;y<=gy+1;y++)for(let z=gz-1;z<=gz+1;z++){
        const arr=grid.get(key(x,y,z)); if(!arr) continue;
        for(const j of arr){
          if(j<=i) continue;
          const B=seeds[j];
          let ex=B.x-A.x, ey=B.y-A.y, ez=B.z-A.z;
          const d=Math.hypot(ex,ey,ez);
          const want=(A.r+B.r)*P.packing;
          if(d>=want||d<1e-9) continue;
          ex/=d; ey/=d; ez/=d;
          const push=(want-d)*0.3;
          fx[i]-=ex*push; fy[i]-=ey*push; fz[i]-=ez*push;
          fx[j]+=ex*push; fy[j]+=ey*push; fz[j]+=ez*push;
        }
      }
    }
    for(let i=0;i<n;i++){
      const A=seeds[i];
      if(A.mir) continue;
      if(P.mirror&&A.x<1e-4&&fx[i]<0) fx[i]=0;
      const r=onSurface(A.x+fx[i],A.y+fy[i],A.z+fz[i]);
      if(!r.ok) continue;
      A.x=r.x;A.y=r.y;A.z=r.z;A.nx=r.nx;A.ny=r.ny;A.nz=r.nz;A.u=r.u;A.v=r.v;
      readMaps(A.u,A.v,_mm);
      A.r=_mm.r*(A.r/Math.max(1e-6,radiusFor(_mm.v)));
      A.h=_mm.h; A.s=spacingOf(A);
      if(P.mirror&&A.x<-0.0005){ A.x=0; A.nx=0; const l=Math.hypot(A.ny,A.nz)||1; A.ny/=l; A.nz/=l; }
    }
    await step('Saat ausgleichen '+(it+1)+' / '+iters,0.06+0.24*(it+1)/iters);
  }
  syncMirrors();
  const alive=new Set();
  for(const A of seeds){
    if(A.mir) continue;
    readMaps(A.u,A.v,_mm);
    if(_mm.a>=P.alphaMin) alive.add(A);
  }
  seeds=seeds.filter(A=>A.mir?alive.has(A.srcRef):alive.has(A));
}

// ═══ 2 · Wachstum: Agenten mit Schwarmverhalten und Hautablenkung ═══
// ═══ 2 · Stützen: gedachte Zylinder, senkrecht auf der Haut ═══
//     Sie sind Hindernis, nicht Geometrie. Die Wurzeln müssen sich durch die
//     Gassen dazwischen fädeln — das erzeugt die Verdichtung und lässt die
//     Zylinderräume als Luftkanäle frei.
let pillars=[], pGrid=null, pCell=0.05, pBlock=null;
let occ=null, occCellSz=0.006;
const occKeyAt=(x,y,z)=>cellKey(Math.floor(x/occCellSz),Math.floor(y/occCellSz),Math.floor(z/occCellSz));
const occAt=(x,y,z)=>occ.get(occKeyAt(x,y,z))||0;
function occAdd(x,y,z){
  const k=occKeyAt(x,y,z);
  occ.set(k,(occ.get(k)||0)+1);
}
/** Rastermodus: gleichmäßige Zeilen und Spalten im UV-Raum der Haut.
 *  Der Abstand ist in Weltmaß angegeben und über uvScale umgerechnet. */
function gridPillars(){
  const su=Math.max(1e-5,P.pCols/Math.max(1e-6,uvScale));
  const sv=Math.max(1e-5,P.pRows/Math.max(1e-6,uvScale));
  const rr=Math.max(0.0006,Math.min(P.pCols,P.pRows)*0.5*P.pFill);
  const best=new Map();
  for(let k=0;k<texelIds.length;k++){
    const i=texelIds[k];
    const px=texPos[i*3];
    if(P.mirror&&px<-0.001) continue;
    const u=((i%R)+0.5)/R, v=(((i/R)|0)+0.5)/R;
    const gi=Math.floor(u/su), gj=Math.floor(v/sv);
    const d=(u-(gi+0.5)*su)**2+(v-(gj+0.5)*sv)**2;
    const kk=gi*1048576+gj;
    const e=best.get(kk);
    if(!e||d<e.d) best.set(kk,{d,i,u,v});
  }
  for(const e of best.values()){
    readMaps(e.u,e.v,_mm);
    if(_mm.a<P.alphaMin) continue;
    const i=e.i;
    const t={x:texPos[i*3],y:texPos[i*3+1],z:texPos[i*3+2],
             nx:texNrm[i*3],ny:texNrm[i*3+1],nz:texNrm[i*3+2],
             r:rr,rr,h:P.hug+_mm.h*P.pHeight+0.004};
    pillars.push(t);
    if(P.mirror&&Math.abs(t.x)>=rr){
      const p=onSurface(-t.x,t.y,t.z);
      if(!p.ok) continue;
      readMaps(p.u,p.v,_mm);
      if(_mm.a<P.alphaMin) continue;
      pillars.push({x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,
                    r:rr,rr,h:P.hug+_mm.h*P.pHeight+0.004});
    }
  }
}
function samplePillars(){
  pillars=[]; pGrid=null;
  if(!P.pillarsOn) return;
  if(P.pMode==='grid'){ gridPillars(); return; }
  srand(P.seed*104729+17);
  const cand=[];
  const N=Math.max(2000,(P.samples*0.7)|0);
  for(let i=0;i<N;i++){
    const p=surfaceSample();
    readMaps(p.u,p.v,_mm);
    if(_mm.a<P.alphaMin) continue;
    if(P.mirror&&p.x<-0.001) continue;
    const r=Math.max(0.0015,_mm.r*P.pSpace*(1+(rnd()*2-1)*P.jitter*0.5));
    cand.push({x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,r,
               rr:Math.max(0.0006,r*P.pFill),
               h:P.hug+_mm.h*P.pHeight+0.004});
  }
  cand.sort((A,B)=>B.r-A.r);
  const cell=Math.max(0.006,2*P.rMax*P.pSpace*(1+P.jitter)*1.05);
  const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
  const grid=new Map();
  const fits=t=>{
    const gx=Math.floor(t.x/cell),gy=Math.floor(t.y/cell),gz=Math.floor(t.z/cell);
    for(let x=gx-1;x<=gx+1;x++)for(let y=gy-1;y<=gy+1;y++)for(let z=gz-1;z<=gz+1;z++){
      const arr=grid.get(key(x,y,z)); if(!arr) continue;
      for(const o of arr){
        const dx=o.x-t.x,dy=o.y-t.y,dz=o.z-t.z;
        const lim=o.r+t.r;
        if(dx*dx+dy*dy+dz*dz<lim*lim) return false;
      }
    }
    return true;
  };
  const insert=t=>{
    const k=key(Math.floor(t.x/cell),Math.floor(t.y/cell),Math.floor(t.z/cell));
    let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(t);
    pillars.push(t);
  };
  for(const t of cand){
    if(P.mirror&&Math.abs(t.x)>=t.r){
      // Die Spiegelseite kann bei asymmetrischer Pose woanders liegen —
      // deshalb den gespiegelten Punkt auf die echte Oberfläche zurückholen
      const p=onSurface(-t.x,t.y,t.z);
      if(!p.ok) continue;
      readMaps(p.u,p.v,_mm);
      if(_mm.a<P.alphaMin) continue;
      const m={x:p.x,y:p.y,z:p.z,nx:p.nx,ny:p.ny,nz:p.nz,r:t.r,rr:t.rr,
               h:P.hug+_mm.h*P.pHeight+0.004};
      if(fits(t)&&fits(m)){ insert(t); insert(m); }
    }else if(fits(t)) insert(t);
  }
}
function buildPillarGrid(){
  occCellSz=Math.max(0.002,P.occCell);
  let maxR=0.004;
  for(const q of pillars) if(q.rr>maxR) maxR=q.rr;
  pCell=Math.max(0.012,maxR*2.2);
  pGrid=new Map();
  pBlock=new Set();
  const seen=new Set();
  const tu=new V3(), tv=new V3(), up=new V3();
  for(let i=0;i<pillars.length;i++){
    const q=pillars[i];
    seen.clear();
    const steps=Math.max(1,Math.ceil(q.h/(pCell*0.5)));
    for(let s=0;s<=steps;s++){
      const t=q.h*s/steps;
      const k=cellKey(Math.floor((q.x+q.nx*t)/pCell),
                      Math.floor((q.y+q.ny*t)/pCell),
                      Math.floor((q.z+q.nz*t)/pCell));
      if(seen.has(k)) continue;
      seen.add(k);
      let arr=pGrid.get(k); if(!arr){arr=[];pGrid.set(k,arr);} arr.push(i);
    }
    // grobe Rasterung fürs Fühlern: Achse plus ein Ring, damit die Spitzen
    // den Zylinder als besetzt lesen, ohne ihn jedes Mal neu zu suchen
    up.set(q.nx,q.ny,q.nz);
    tu.set(0,1,0); if(Math.abs(up.y)>0.9) tu.set(1,0,0);
    tu.addScaledVector(up,-tu.dot(up)).normalize();
    tv.crossVectors(up,tu).normalize();
    const az=Math.max(1,Math.ceil(q.h/(occCellSz*0.6)));
    const rr=q.rr*0.7;
    for(let s=0;s<=az;s++){
      const t=q.h*s/az;
      const bx=q.x+q.nx*t, by=q.y+q.ny*t, bz=q.z+q.nz*t;
      pBlock.add(occKeyAt(bx,by,bz));
      for(let k=0;k<6;k++){
        const a=k/6*Math.PI*2, c=Math.cos(a)*rr, sn=Math.sin(a)*rr;
        pBlock.add(occKeyAt(bx+tu.x*c+tv.x*sn, by+tu.y*c+tv.y*sn, bz+tu.z*c+tv.z*sn));
      }
    }
  }
}
/** Abstoßung vom Zylindermantel plus Wirbel um dessen Achse; schreibt in _pf.
 *  Der Wirbel wirkt über den Mantel hinaus bis swirlR und dreht bei allen
 *  Zylindern im selben Sinn — daher sind die Walzen achssymmetrisch. */
const _pf={x:0,y:0,z:0,hit:0,ox:0,oy:0,oz:0,depth:0,sx:0,sy:0,sz:0};
function pillarField(x,y,z,rad,swirlR){
  _pf.x=_pf.y=_pf.z=0; _pf.hit=0; _pf.depth=0;
  _pf.sx=_pf.sy=_pf.sz=0;
  if(!pGrid) return _pf;
  const sw=swirlR||0;
  const gx=Math.floor(x/pCell),gy=Math.floor(y/pCell),gz=Math.floor(z/pCell);
  const rng=sw>pCell?2:1;
  for(let a=gx-rng;a<=gx+rng;a++)for(let b=gy-rng;b<=gy+rng;b++)for(let c=gz-rng;c<=gz+rng;c++){
    const arr=pGrid.get(cellKey(a,b,c)); if(!arr) continue;
    for(const i of arr){
      const q=pillars[i];
      const ex=x-q.x, ey=y-q.y, ez=z-q.z;
      const t=ex*q.nx+ey*q.ny+ez*q.nz;
      if(t<-0.006||t>q.h) continue;                 // ober- oder unterhalb vorbei
      let rx=ex-q.nx*t, ry=ey-q.ny*t, rz=ez-q.nz*t;
      let d=Math.hypot(rx,ry,rz);
      if(d<1e-7){ rx=1; ry=0; rz=0; d=1e-7; }
      rx/=d; ry/=d; rz/=d;
      if(sw>0&&d<q.rr+sw){
        // Umfangsrichtung = Achse × Radialrichtung
        const w=1-clamp((d-q.rr)/sw,0,1);
        _pf.sx+=(q.ny*rz-q.nz*ry)*w;
        _pf.sy+=(q.nz*rx-q.nx*rz)*w;
        _pf.sz+=(q.nx*ry-q.ny*rx)*w;
      }
      const want=q.rr+rad;
      if(d>=want) continue;
      const dep=want-d;
      _pf.x+=rx*dep/want; _pf.y+=ry*dep/want; _pf.z+=rz*dep/want;
      _pf.hit=1;
      if(dep>_pf.depth){ _pf.depth=dep; _pf.ox=rx; _pf.oy=ry; _pf.oz=rz; }
    }
  }
  return _pf;
}
const insidePillar=(x,y,z,rad)=>pillarField(x,y,z,rad,0).hit===1;

// ═══ 3 · Wachstum ═══
const _gp=new V3(), _gf=new V3(), _gq=new V3();
const _rv=[0,0,0];
function rotAround(vx,vy,vz,ax,ay,az,ang){
  const c=Math.cos(ang), s=Math.sin(ang);
  const dot=ax*vx+ay*vy+az*vz;
  const cx=ay*vz-az*vy, cy=az*vx-ax*vz, cz=ax*vy-ay*vx;
  _rv[0]=vx*c+cx*s+ax*dot*(1-c);
  _rv[1]=vy*c+cy*s+ay*dot*(1-c);
  _rv[2]=vz*c+cz*s+az*dot*(1-c);
  return _rv;
}
function newAgent(x,y,z,dx,dy,dz,r0,life,depth,ph,g0){
  return {x,y,z,dx,dy,dz,r0,life:Math.max(2,life|0),age:0,depth,off:0,ph,g0,
          pts:[{x,y,z,b:r0,t:0,g:g0}]};
}
/** Dickenprofil: 0 = Ansatz, 1 = Spitze */
function curveAt(t){
  const c=P.thick;
  if(!c||!c.length) return 1;
  t=clamp(t,0,1);
  if(t<=c[0][0]) return c[0][1];
  for(let i=1;i<c.length;i++){
    if(t<=c[i][0]){
      const a=c[i-1], b=c[i], d=b[0]-a[0];
      return d<1e-6?b[1]:a[1]+(b[1]-a[1])*((t-a[0])/d);
    }
  }
  return c[c.length-1][1];
}
const thickAt=(b,t)=>Math.max(P.rootMin,b*curveAt(t));
function radiusAt(a){ return thickAt(a.r0,a.age/a.life); }
/** Belegung entlang einer Fühlerrichtung, zwei Stützstellen */
function probeCost(x,y,z,dx,dy,dz,dist){
  const x1=x+dx*dist*0.55, y1=y+dy*dist*0.55, z1=z+dz*dist*0.55;
  const x2=x+dx*dist,      y2=y+dy*dist,      z2=z+dz*dist;
  let c=occAt(x1,y1,z1)*0.6+occAt(x2,y2,z2);
  // Zylinder zählen als voll besetzt — die Spitze sucht sich die Gasse
  if(pBlock&&pBlock.size){
    if(pBlock.has(occKeyAt(x1,y1,z1))) c+=P.occMax;
    if(pBlock.has(occKeyAt(x2,y2,z2))) c+=P.occMax*1.5;
  }
  return c;
}
async function growRoots(){
  roots=[]; rootPts=0;
  const S=Math.max(2,P.steps|0);
  srand(P.seed*7919+13);
  occCellSz=Math.max(0.002,P.occCell);
  occ=new Map();
  const maxA=P.maxAgents|0;
  let act=[];
  for(const A of seeds){
    if(act.length>=maxA) break;
    const s=nearestSkin(A.x,A.y,A.z);
    if(!s.ok) continue;
    const base=Math.max(P.rootMin*2, A.r*P.rootScale);
    const off=P.hug+base;
    const x=s.x+s.nx*off, y=s.y+s.ny*off, z=s.z+s.nz*off;
    if(pGrid&&insidePillar(x,y,z,base)) continue;    // Saat steht nicht im Zylinder
    // Startrichtung: zufällig in der Tangentialebene
    let ux=rnd()*2-1, uy=rnd()*2-1, uz=rnd()*2-1;
    const dn=ux*s.nx+uy*s.ny+uz*s.nz;
    ux-=s.nx*dn; uy-=s.ny*dn; uz-=s.nz*dn;
    const l=Math.hypot(ux,uy,uz)||1;
    act.push(newAgent(x,y,z,ux/l,uy/l,uz/l,base,S,0,rnd()*Math.PI*2,0));
    occAdd(x,y,z);
  }
  if(!act.length) return;

  const cellN=Math.max(P.nbRadius,0.006);
  const key=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
  const grid=new Map();
  const spawn=[];

  for(let st=0; st<S && act.length; st++){
    grid.clear();
    for(let i=0;i<act.length;i++){
      const a=act[i];
      const k=key(Math.floor(a.x/cellN),Math.floor(a.y/cellN),Math.floor(a.z/cellN));
      let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(i);
    }
    const nr2=P.nbRadius*P.nbRadius, sd=P.sepDist, sd2=sd*sd;
    spawn.length=0;
    const next=[];
    for(let i=0;i<act.length;i++){
      const a=act[i];
      const r=radiusAt(a);
      const s=nearestSkin(a.x,a.y,a.z);
      let dead=false, floorH=0, ceilH=0, nx=0,ny=1,nz=0, sdist=0;
      let fx=0,fy=0,fz=0;

      if(!s.ok) dead=true;
      else{
        nx=s.nx; ny=s.ny; nz=s.nz;
        // ── Ebene 2 spannt die Decke auf, die Haut ist der Boden
        let lift=0;
        if(paintData){
          const m=sampleMap(s.u,s.v);
          lift=m[3]*(1-m[2])*P.lift;
          if(P.killOffPaint){
            if(m[1]<P.alphaMin){ a.off++; if(a.off>3) dead=true; } else a.off=0;
          }
        }
        floorH=P.hug+r; ceilH=floorH+lift;
        sdist=(a.x-s.x)*nx+(a.y-s.y)*ny+(a.z-s.z)*nz;
        // Findet die Spitze keine plausible Haut mehr — etwa weil der nächste
        // Punkt auf der Gegenseite eines dünnen Glieds liegt — endet sie hier.
        // Sonst schleudert die Rückholkraft sie als Stachel vom Körper weg.
        if(s.d>ceilH+0.03||sdist<-0.02) dead=true;
        else{
          if(sdist<floorH) { const e=(floorH-sdist)*3; fx+=nx*e; fy+=ny*e; fz+=nz*e; }
          else if(sdist>ceilH){ const e=(sdist-ceilH)*3; fx-=nx*e; fy-=ny*e; fz-=nz*e; }
          // ── Bodenzug: die Schale füllt sich von der Haut aufwärts, statt
          //    dass alles an der Decke hängt und über der Haut zu schweben scheint
          if(P.settle>0&&lift>1e-6){
            const up=clamp((sdist-floorH)/lift,0,1)*P.settle;
            fx-=nx*up; fy-=ny*up; fz-=nz*up;
          }
        }
        // ── Platzsuche: fünf Fühler, der freieste gewinnt
        if(P.senseForce>0){
          let bx=a.dx,by=a.dy,bz=a.dz;
          let best=probeCost(a.x,a.y,a.z,a.dx,a.dy,a.dz,P.senseDist);
          let ox=ny*a.dz-nz*a.dy, oy=nz*a.dx-nx*a.dz, oz=nx*a.dy-ny*a.dx;
          const ol=Math.hypot(ox,oy,oz);
          const axes=[[nx,ny,nz]];
          if(ol>1e-6) axes.push([ox/ol,oy/ol,oz/ol]);
          for(const ax of axes){
            for(const sgn of [1,-1]){
              const v=rotAround(a.dx,a.dy,a.dz,ax[0],ax[1],ax[2],P.senseAngle*sgn);
              const c=probeCost(a.x,a.y,a.z,v[0],v[1],v[2],P.senseDist);
              if(c<best-1e-9){ best=c; bx=v[0]; by=v[1]; bz=v[2]; }
            }
          }
          fx+=(bx-a.dx)*P.senseForce; fy+=(by-a.dy)*P.senseForce; fz+=(bz-a.dz)*P.senseForce;
        }
        // ── Zylinder: Abstoßung vom Mantel und Wirbel um die Achse
        if(pGrid&&(P.pPush>0||P.pSwirl!==0)){
          const pf=pillarField(a.x,a.y,a.z,r+P.sepDist*0.5,P.pSwirlRange);
          if(pf.hit&&P.pPush>0){ fx+=pf.x*P.pPush; fy+=pf.y*P.pPush; fz+=pf.z*P.pPush; }
          if(P.pSwirl!==0){
            const sl=Math.hypot(pf.sx,pf.sy,pf.sz);
            if(sl>1e-9){
              const w=P.pSwirl*Math.min(1,sl);
              fx+=pf.sx/sl*w; fy+=pf.sy/sl*w; fz+=pf.sz/sl*w;
            }
          }
        }
        // ── Oszillation quer zur Bahn
        let bx2=ny*a.dz-nz*a.dy, by2=nz*a.dx-nx*a.dz, bz2=nx*a.dy-ny*a.dx;
        const bl=Math.hypot(bx2,by2,bz2);
        if(bl>1e-9&&P.oscAmp>0){
          const o=Math.sin(a.ph)*P.oscAmp;
          fx+=bx2/bl*o; fy+=by2/bl*o; fz+=bz2/bl*o;
        }
        a.ph+=P.oscFreq*(1+(rnd()*2-1)*P.oscJitter);
      }
      // ── Perlin-Curl
      _gp.set(a.x,a.y,a.z);
      if(P.mirror&&_gp.x<0){ _gq.set(-_gp.x,_gp.y,_gp.z); curl(_gq,_gf); _gf.x=-_gf.x; }
      else curl(_gp,_gf);
      fx+=_gf.x*P.curlAmp; fy+=_gf.y*P.curlAmp; fz+=_gf.z*P.curlAmp;
      fy-=P.gravity;
      // ── Schwarm
      let cnt=0,cx=0,cy=0,cz=0,vx=0,vy=0,vz=0,px=0,py=0,pz=0;
      const gx=Math.floor(a.x/cellN),gy=Math.floor(a.y/cellN),gz=Math.floor(a.z/cellN);
      for(let bx=gx-1;bx<=gx+1;bx++)for(let by=gy-1;by<=gy+1;by++)for(let bz=gz-1;bz<=gz+1;bz++){
        const arr=grid.get(key(bx,by,bz)); if(!arr) continue;
        for(const j of arr){
          if(j===i) continue;
          const b=act[j];
          const ex=b.x-a.x, ey=b.y-a.y, ez=b.z-a.z;
          const q=ex*ex+ey*ey+ez*ez;
          if(q>nr2||q<1e-12) continue;
          cnt++; cx+=b.x; cy+=b.y; cz+=b.z; vx+=b.dx; vy+=b.dy; vz+=b.dz;
          if(q<sd2){
            const d=Math.sqrt(q), w=(sd-d)/sd;
            px-=ex/d*w; py-=ey/d*w; pz-=ez/d*w;
          }
        }
      }
      fx+=px*P.sepForce; fy+=py*P.sepForce; fz+=pz*P.sepForce;
      if(cnt){
        fx+=(vx/cnt-a.dx)*P.alignForce;
        fy+=(vy/cnt-a.dy)*P.alignForce;
        fz+=(vz/cnt-a.dz)*P.alignForce;
        let ox=cx/cnt-a.x, oy=cy/cnt-a.y, oz=cz/cnt-a.z;
        const ol=Math.hypot(ox,oy,oz)||1; ox/=ol; oy/=ol; oz/=ol;
        fx+=ox*P.cohForce; fy+=oy*P.cohForce; fz+=oz*P.cohForce;
        let tx=a.dy*oz-a.dz*oy, ty=a.dz*ox-a.dx*oz, tz=a.dx*oy-a.dy*ox;
        const tl=Math.hypot(tx,ty,tz);
        if(tl>1e-6){ fx+=tx/tl*P.twistForce; fy+=ty/tl*P.twistForce; fz+=tz/tl*P.twistForce; }
      }
      // Tote Spitzen enden hier — kein letzter, ungebremster Schritt mehr
      if(dead){ roots.push(a.pts); rootPts+=a.pts.length; continue; }
      // ── Richtung; an Boden und Decke wird der austretende Anteil gekappt
      let ux=a.dx+fx*P.turn, uy=a.dy+fy*P.turn, uz=a.dz+fz*P.turn;
      if(s.ok){
        const dn=ux*nx+uy*ny+uz*nz;
        if((sdist<=floorH&&dn<0)||(sdist>=ceilH&&dn>0)){ ux-=nx*dn; uy-=ny*dn; uz-=nz*dn; }
      }
      const ul=Math.hypot(ux,uy,uz);
      if(ul>1e-9){ ux/=ul; uy/=ul; uz/=ul; } else { ux=a.dx; uy=a.dy; uz=a.dz; }
      a.dx=ux; a.dy=uy; a.dz=uz;
      a.x+=ux*P.stepLen; a.y+=uy*P.stepLen; a.z+=uz*P.stepLen;
      // ── in die Schale zurückholen, höchstens um eine Schrittlänge:
      //    ein großer Sprung hier wäre der Stachel, der vom Körper wegsticht
      if(s.ok){
        const lim=P.stepLen*1.5;
        const sd2n=(a.x-s.x)*nx+(a.y-s.y)*ny+(a.z-s.z)*nz;
        let corr=0;
        if(sd2n<floorH) corr=(floorH-sd2n)*P.stick;
        else if(sd2n>ceilH) corr=(ceilH-sd2n)*P.stick;
        corr=clamp(corr,-lim,lim);
        if(corr){ a.x+=nx*corr; a.y+=ny*corr; a.z+=nz*corr; }
      }
      // ── aus dem Zylinder schieben, damit nichts durchdringt
      if(pGrid){
        const pf=pillarField(a.x,a.y,a.z,r,0);
        if(pf.hit&&pf.depth>0){
          a.x+=pf.ox*pf.depth; a.y+=pf.oy*pf.depth; a.z+=pf.oz*pf.depth;
        }
      }
      a.age++;
      // ── Anastomose: trifft die Spitze auf dichtes Geflecht, verwächst sie damit
      if(!dead&&P.fuse&&a.age>3&&occAt(a.x,a.y,a.z)>P.occMax) dead=true;
      occAdd(a.x,a.y,a.z);
      const gNow=clamp(a.g0+a.age*P.stepLen/Math.max(1e-6,S*P.stepLen),0,1);
      a.pts.push({x:a.x,y:a.y,z:a.z,b:a.r0,t:a.age/a.life,g:gNow});
      // ── Verzweigung
      if(!dead && s.ok && a.depth<(P.maxDepth|0) && a.age<a.life-2
         && act.length+spawn.length+next.length<maxA && rnd()<P.branchProb){
        let ax=nx,ay=ny,az=nz;
        if(rnd()<0.5){
          const ox=ny*a.dz-nz*a.dy, oy=nz*a.dx-nx*a.dz, oz=nx*a.dy-ny*a.dx;
          const ol=Math.hypot(ox,oy,oz);
          if(ol>1e-6){ ax=ox/ol; ay=oy/ol; az=oz/ol; }
        }
        const ang=P.branchAngle*(0.4+0.6*rnd())*(rnd()<0.5?1:-1);
        const v=rotAround(a.dx,a.dy,a.dz,ax,ay,az,ang);
        spawn.push(newAgent(a.x,a.y,a.z,v[0],v[1],v[2],
          Math.max(P.rootMin,radiusAt(a)*P.branchDecay),
          Math.max(3,Math.round((a.life-a.age)*(0.5+0.5*rnd()))),a.depth+1,rnd()*Math.PI*2,gNow));
      }
      if(dead||a.age>=a.life){ roots.push(a.pts); rootPts+=a.pts.length; }
      else next.push(a);
    }
    act=next.concat(spawn);
    if((st&3)===0) await step('Wurzeln wachsen '+(st+1)+' / '+S+' · '+act.length+' Spitzen',0.32+0.5*st/S);
  }
  for(const a of act){ roots.push(a.pts); rootPts+=a.pts.length; }
}

// ═══ 5 · Pflanzenkörper: Sprosse aus der Saat, nach außen statt in die Schale.
//     Gleiches Curl-Feld, gleiche Dickenkurve wie die Wurzeln.
// ═══ 5 · Halme: liegen flach über der Wurzelmatte und winden sich mit ihr.
//     Gleiches Curl-Feld und Schlingern wie die Wurzeln, nur eine Lage höher.
function growShoots(){
  shoots=[];
  if(!P.shootsOn||!seeds.length) return;
  srand(P.seed*31337+5);
  const S=Math.max(2,P.shootSteps|0);
  const dl=P.shootLen/S;
  // Einheitliche Stärke aus dem Mittel der Saat — sonst erbt jeder Halm den
  // Radius seines Saatkorns und einzelne werden klobig.
  let mr=0; for(const A of seeds) mr+=A.r; mr/=seeds.length;
  const base=Math.max(P.rootMin*2, mr*P.rootScale*P.shootThick);
  const f=new V3(), p=new V3(), q=new V3();
  for(const A of seeds){
    if(rnd()>P.shootProb) continue;
    let s=nearestSkin(A.x,A.y,A.z);
    if(!s.ok) continue;
    let lift=0;
    if(paintData){
      const m=sampleMap(s.u,s.v);
      if(m[1]<P.alphaMin) continue;
      lift=m[3]*(1-m[2])*P.lift;
    }
    const h0=P.hug+lift+P.bladeRise;
    let x=s.x+s.nx*h0, y=s.y+s.ny*h0, z=s.z+s.nz*h0;
    // Startrichtung tangential, nicht nach außen
    let dx=rnd()*2-1, dy=rnd()*2-1, dz=rnd()*2-1;
    let dn=dx*s.nx+dy*s.ny+dz*s.nz;
    dx-=s.nx*dn; dy-=s.ny*dn; dz-=s.nz*dn;
    let dl0=Math.hypot(dx,dy,dz)||1; dx/=dl0; dy/=dl0; dz/=dl0;
    let ph=rnd()*Math.PI*2;
    const pts=[];
    for(let k=0;k<=S;k++){
      s=nearestSkin(x,y,z);
      if(!s.ok) break;
      let lf=0;
      if(paintData){
        const m=sampleMap(s.u,s.v);
        if(m[1]<P.alphaMin) break;
        lf=m[3]*(1-m[2])*P.lift;
      }
      // auf die Sollhöhe über der Matte ziehen, begrenzt auf eine Schrittlänge
      const want=P.hug+lf+P.bladeRise;
      const sd=(x-s.x)*s.nx+(y-s.y)*s.ny+(z-s.z)*s.nz;
      const corr=clamp((want-sd)*0.8,-dl,dl);
      x+=s.nx*corr; y+=s.ny*corr; z+=s.nz*corr;
      pts.push({x,y,z,b:base,t:k/S,g:k/S,nx:s.nx,ny:s.ny,nz:s.nz});
      if(k===S) break;
      p.set(x,y,z);
      if(P.mirror&&p.x<0){ q.set(-p.x,p.y,p.z); curl(q,f); f.x=-f.x; } else curl(p,f);
      let fx=f.x*P.shootWobble, fy=f.y*P.shootWobble, fz=f.z*P.shootWobble;
      let bx=s.ny*dz-s.nz*dy, by=s.nz*dx-s.nx*dz, bz=s.nx*dy-s.ny*dx;
      const bl=Math.hypot(bx,by,bz);
      if(bl>1e-9&&P.oscAmp>0){
        const o=Math.sin(ph)*P.oscAmp;
        fx+=bx/bl*o; fy+=by/bl*o; fz+=bz/bl*o;
      }
      ph+=P.oscFreq*(1+(rnd()*2-1)*P.oscJitter);
      let ux=dx+fx*P.turn, uy=dy+fy*P.turn, uz=dz+fz*P.turn;
      dn=ux*s.nx+uy*s.ny+uz*s.nz;            // flach halten
      ux-=s.nx*dn; uy-=s.ny*dn; uz-=s.nz*dn;
      const l=Math.hypot(ux,uy,uz);
      if(l>1e-9){ ux/=l; uy/=l; uz/=l; } else { ux=dx; uy=dy; uz=dz; }
      dx=ux; dy=uy; dz=uz;
      x+=ux*dl; y+=uy*dl; z+=uz*dl;
    }
    if(pts.length>2) shoots.push(pts);
  }
}

// ═══ 6 · Vorschau (Linien) ═══
function buildPillarPreview(){
  if(pillarLines){ preGroup.remove(pillarLines); pillarLines.geometry.dispose(); pillarLines.material.dispose(); pillarLines=null; }
  if(!pillars.length) return;
  const K=6;
  const lin=new Float32Array(pillars.length*(K*2+1)*6);
  let w=0;
  const up=new V3(), tu=new V3(), tv=new V3();
  for(const q of pillars){
    up.set(q.nx,q.ny,q.nz);
    tu.set(0,1,0);
    if(Math.abs(up.y)>0.9) tu.set(1,0,0);
    tu.addScaledVector(up,-tu.dot(up)).normalize();
    tv.crossVectors(up,tu).normalize();
    const bx=q.x, by=q.y, bz=q.z;
    const tx=q.x+q.nx*q.h, ty=q.y+q.ny*q.h, tz=q.z+q.nz*q.h;
    lin[w++]=bx; lin[w++]=by; lin[w++]=bz; lin[w++]=tx; lin[w++]=ty; lin[w++]=tz;
    for(let k=0;k<K;k++){
      const a0=k/K*Math.PI*2, a1=(k+1)/K*Math.PI*2;
      const c0=Math.cos(a0)*q.rr, s0=Math.sin(a0)*q.rr;
      const c1=Math.cos(a1)*q.rr, s1=Math.sin(a1)*q.rr;
      for(const h of [0,q.h]){
        lin[w++]=q.x+q.nx*h+tu.x*c0+tv.x*s0;
        lin[w++]=q.y+q.ny*h+tu.y*c0+tv.y*s0;
        lin[w++]=q.z+q.nz*h+tu.z*c0+tv.z*s0;
        lin[w++]=q.x+q.nx*h+tu.x*c1+tv.x*s1;
        lin[w++]=q.y+q.ny*h+tu.y*c1+tv.y*s1;
        lin[w++]=q.z+q.nz*h+tu.z*c1+tv.z*s1;
      }
    }
  }
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.Float32BufferAttribute(lin,3));
  g.setDrawRange(0,w/3);
  pillarLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({
    color:0x111111,transparent:true,opacity:0.18}));
  pillarLines.frustumCulled=false; preGroup.add(pillarLines);
}
function buildPreview(){
  if(preLines){ preGroup.remove(preLines); preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
  const all=shoots.length?roots.concat(shoots):roots;
  if(!all.length) return;
  let segs=0;
  for(const p of all) segs+=Math.max(0,p.length-1);
  const lin=new Float32Array(Math.max(1,segs*6));
  let w=0;
  for(const p of all){
    for(let i=0;i<p.length-1;i++){
      lin[w++]=p[i].x; lin[w++]=p[i].y; lin[w++]=p[i].z;
      lin[w++]=p[i+1].x; lin[w++]=p[i+1].y; lin[w++]=p[i+1].z;
    }
  }
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.Float32BufferAttribute(lin,3));
  g.setDrawRange(0,w/3);
  preLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:0x111111,transparent:true,opacity:0.8}));
  preLines.frustumCulled=false; preGroup.add(preLines);
}

// ═══ 6 · Mesh: Low-Poly-Röhren, jede Wurzel geschlossen ═══
function decimate(p){
  const thr=Math.cos(clamp(P.simplify,0,1.2));
  if(P.simplify<=0.001||p.length<3) return p;
  const out=[p[0]];
  for(let i=1;i<p.length-1;i++){
    const a=out[out.length-1], b=p[i], c=p[i+1];
    let ax=b.x-a.x, ay=b.y-a.y, az=b.z-a.z;
    let cx=c.x-b.x, cy=c.y-b.y, cz=c.z-b.z;
    const al=Math.hypot(ax,ay,az)||1, cl=Math.hypot(cx,cy,cz)||1;
    const dot=(ax*cx+ay*cy+az*cz)/(al*cl);
    if(dot<thr) out.push(b);
  }
  out.push(p[p.length-1]);
  return out;
}
function addRoot(pts,sides,pos,idx,gs,cs,cls){
  const N=pts.length; if(N<2) return;
  const P3=[]; for(const p of pts) P3.push(new V3(p.x,p.y,p.z));
  const F=frames(P3);
  const base=pos.length/3;
  for(let i=0;i<N;i++){
    const c=P3[i], nn=F.N[i], bb=F.B[i], r=thickAt(pts[i].b,pts[i].t);
    for(let j=0;j<sides;j++){
      const a=j/sides*Math.PI*2, ca=Math.cos(a)*r, sa=Math.sin(a)*r;
      pos.push(c.x+nn.x*ca+bb.x*sa, c.y+nn.y*ca+bb.y*sa, c.z+nn.z*ca+bb.z*sa);
      gs.push(pts[i].g||0); cs.push(cls);
    }
  }
  for(let i=0;i<N-1;i++)for(let j=0;j<sides;j++){
    const a=base+i*sides+j, b=base+i*sides+(j+1)%sides;
    idx.push(a,b,b+sides, a,b+sides,a+sides);
  }
  const c0=base+N*sides, c1=c0+1;
  pos.push(P3[0].x,P3[0].y,P3[0].z, P3[N-1].x,P3[N-1].y,P3[N-1].z);
  gs.push(pts[0].g||0, pts[N-1].g||0); cs.push(cls,cls);
  const last=base+(N-1)*sides;
  for(let j=0;j<sides;j++){
    const a=base+j, b=base+(j+1)%sides;
    idx.push(c0,b,a);
    const a2=last+j, b2=last+(j+1)%sides;
    idx.push(c1,a2,b2);
  }
}
/** Kugelvorlage: IcosahedronGeometry ist unindiziert — hier einmal
 *  verschweißen, sonst kostet jede Kugel das Fünffache an Ecken. */
function sphereTemplate(detail){
  const g=new THREE.IcosahedronGeometry(1,clamp(detail|0,0,2));
  const p=g.attributes.position;
  if(g.index){
    const pt={pos:new Float32Array(p.array), idx:Array.from(g.index.array)};
    g.dispose(); return pt;
  }
  const pos=[], idx=new Array(p.count), map=new Map();
  for(let i=0;i<p.count;i++){
    const x=p.getX(i), y=p.getY(i), z=p.getZ(i);
    const k=Math.round(x*1e4)+'_'+Math.round(y*1e4)+'_'+Math.round(z*1e4);
    let id=map.get(k);
    if(id===undefined){ id=pos.length/3; map.set(k,id); pos.push(x,y,z); }
    idx[i]=id;
  }
  g.dispose();
  return {pos:new Float32Array(pos), idx};
}
/** Kugel am Bahnende, in dieselben Puffer wie die Röhren */
function addSphere(tpl,x,y,z,r,g,pos,idx,gs,cs,cls){
  const vc=tpl.pos.length/3;
  const base=pos.length/3;
  for(let i=0;i<vc;i++){
    pos.push(tpl.pos[i*3]*r+x, tpl.pos[i*3+1]*r+y, tpl.pos[i*3+2]*r+z);
    gs.push(g); cs.push(cls);
  }
  for(let i=0;i<tpl.idx.length;i++) idx.push(tpl.idx[i]+base);
}
/** Halm: flacher Querschnitt, dessen breite Seite an der Hautnormalen
 *  ausgerichtet ist — sonst läge das Band in zufälliger Verdrehung. */
function addBlade(pts,sides,pos,idx,gs,cs,cls){
  const N=pts.length; if(N<2) return;
  const P3=[]; for(const p of pts) P3.push(new V3(p.x,p.y,p.z));
  const base=pos.length/3;
  const T=new V3(), Nn=new V3(), Sd=new V3(), Up=new V3();
  for(let i=0;i<N;i++){
    T.subVectors(P3[Math.min(N-1,i+1)],P3[Math.max(0,i-1)]);
    if(T.lengthSq()<1e-14) T.set(1,0,0);
    T.normalize();
    Nn.set(pts[i].nx||0,pts[i].ny||1,pts[i].nz||0);
    Sd.crossVectors(T,Nn);
    if(Sd.lengthSq()<1e-12) Sd.set(1,0,0); else Sd.normalize();
    Up.crossVectors(Sd,T).normalize();
    const r=thickAt(pts[i].b,pts[i].t);
    const w=r*P.bladeWidth, th=Math.max(1e-5,r*P.bladeThick);
    for(let j=0;j<sides;j++){
      const a=j/sides*Math.PI*2, c=Math.cos(a)*w, sn=Math.sin(a)*th;
      pos.push(P3[i].x+Sd.x*c+Up.x*sn, P3[i].y+Sd.y*c+Up.y*sn, P3[i].z+Sd.z*c+Up.z*sn);
      gs.push(pts[i].g||0); cs.push(cls);
    }
  }
  for(let i=0;i<N-1;i++)for(let j=0;j<sides;j++){
    const a=base+i*sides+j, b=base+i*sides+(j+1)%sides;
    idx.push(a,b,b+sides, a,b+sides,a+sides);
  }
  const c0=base+N*sides, c1=c0+1;
  pos.push(P3[0].x,P3[0].y,P3[0].z, P3[N-1].x,P3[N-1].y,P3[N-1].z);
  gs.push(pts[0].g||0, pts[N-1].g||0); cs.push(cls,cls);
  const last=base+(N-1)*sides;
  for(let j=0;j<sides;j++){
    const a=base+j, b=base+(j+1)%sides;
    idx.push(c0,b,a);
    const a2=last+j, b2=last+(j+1)%sides;
    idx.push(c1,a2,b2);
  }
}
let rootG=null, rootC=null;
/** Je Klasse eine eigene Farbe. Der Verlauf innerhalb einer Klasse ist nur
 *  noch eine Aufhellung derselben Farbe — dadurch bleiben Wurzelwerk,
 *  Halme auf den ersten Blick auseinanderzuhalten. */
function shadePair(hex){
  const c=new THREE.Color(hex), h={};
  c.getHSL(h);
  const s=clamp(P.shade,0,0.9);
  return [new THREE.Color().setHSL(h.h,h.s,clamp(h.l*(1-s*0.75),0.02,0.95)),
          new THREE.Color().setHSL(h.h,h.s*(1-0.3*s),clamp(h.l+(1-h.l)*s,0.05,0.97))];
}
function applyRootColors(){
  if(!rootMesh||!rootG) return;
  const pal=[shadePair(P.rootColor),shadePair(P.stemColor)];
  const arr=rootMesh.geometry.attributes.color.array;
  for(let i=0;i<rootG.length;i++){
    const t=rootG[i];
    const p=pal[(rootC&&rootC[i])|0]||pal[0];
    const c1=p[0], c2=p[1];
    arr[i*3  ]=c1.r+(c2.r-c1.r)*t;
    arr[i*3+1]=c1.g+(c2.g-c1.g)*t;
    arr[i*3+2]=c1.b+(c2.b-c1.b)*t;
  }
  rootMesh.geometry.attributes.color.needsUpdate=true;
}
async function buildMesh(){
  if(busy||!roots.length) return;
  busy=true;
  try{
    clearMeshOnly();
    const sides=Math.max(3,P.tubeSides|0);
    const pos=[], idx=[], gs=[], cs=[];
    const tipTpl=P.tipSpheres?sphereTemplate(P.tipDetail):null;
    for(let k=0;k<roots.length;k++){
      const p=decimate(roots[k]);
      addRoot(p,sides,pos,idx,gs,cs,0);
      if(tipTpl&&p.length){
        const q=p[p.length-1];
        addSphere(tipTpl,q.x,q.y,q.z,thickAt(q.b,q.t)*P.tipScale,q.g||0,pos,idx,gs,cs,0);
      }
      if((k&255)===0) await step('Mesh: Wurzeln '+k+' / '+roots.length,0.05+0.75*k/roots.length);
    }
    for(let k=0;k<shoots.length;k++){
      addBlade(shoots[k],Math.max(3,P.bladeSides|0),pos,idx,gs,cs,1);
      if((k&255)===0) await step('Mesh: Halme '+k+' / '+shoots.length,0.8+0.1*k/Math.max(1,shoots.length));
    }
    if(!pos.length){ setStatus('Nichts zu bauen.'); return; }
    await step('Mesh: zusammenbauen',0.94);
    const geo=new THREE.BufferGeometry();
    geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
    geo.setAttribute('color',new THREE.Float32BufferAttribute(new Float32Array(gs.length*3),3));
    geo.setIndex(new THREE.Uint32BufferAttribute(idx,1));
    geo.computeVertexNormals(); geo.computeBoundingSphere();
    rootMesh=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
      vertexColors:true,roughness:0.5,metalness:0,side:THREE.FrontSide}));
    rootMesh.frustumCulled=false; rootGroup.add(rootMesh);
    rootG=new Float32Array(gs); rootC=new Uint8Array(cs);
    applyRootColors();
    P.showPreview=false; syncUI();
    setStatus('Mesh gebaut.');
  }catch(e){
    console.error(e);
    setStatus('Mesh fehlgeschlagen: '+(e&&e.message?e.message:e));
  } finally { busy=false; progressEnd(); }
}
function meshTris(){ return rootMesh?rootMesh.geometry.index.count/3:0; }

// ── Gesamtlauf
async function generate(){
  if(busy) return;
  if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
  busy=true;
  try{
    clearRoots();
    await step('Saat setzen',0.02);
    buildTriGrid();
    if(!sampleSeeds()){ setStatus('Keine bemalte Fläche auf Ebene 1 — erst malen.'); return; }
    await step('Saat setzen · '+seeds.length,0.05);
    await relaxSeeds();
    await step('Hautfeld aufbauen',0.28);
    buildSkinField();
    await step('Stützen setzen',0.31);
    samplePillars();
    if(pillars.length) buildPillarGrid();
    await growRoots();
    await step('Pflanzenkörper',0.86);
    growShoots();
    await step('Vorschau',0.9);
    buildPillarPreview();
    buildPreview();
    P.showPreview=true; syncUI();
    setStatus('Wurzeln gewachsen — Mesh bauen.');
  }catch(e){
    console.error(e);
    setStatus('Fehlgeschlagen: '+(e&&e.message?e.message:e));
  } finally { busy=false; progressEnd(); }
}

// ═════════════════════════════════════════════════════════════════════════════
// 11 · EXPORT
// ═════════════════════════════════════════════════════════════════════════════
function download(blob,name){
  const a=document.createElement('a');
  a.href=URL.createObjectURL(blob); a.download=name; a.click();
  setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function exportPieces(){
  const out=[];
  if(rootMesh) out.push({name:'roots',geo:rootMesh.geometry});
  if(P.exportBody&&body) out.push({name:'body',geo:body.geometry});
  return out;
}
function exportOBJ(pieces){
  let out='# ROOT MESH DESIGNER\n', voff=1;
  for(const {name,geo} of pieces){
    const p=geo.attributes.position,n=geo.attributes.normal,ix=geo.index;
    const col=geo.attributes.color;
    out+='o '+name+'\n';
    for(let i=0;i<p.count;i++){
      out+=`v ${p.getX(i).toFixed(5)} ${p.getY(i).toFixed(5)} ${p.getZ(i).toFixed(5)}`;
      if(col) out+=` ${col.getX(i).toFixed(4)} ${col.getY(i).toFixed(4)} ${col.getZ(i).toFixed(4)}`;
      out+='\n';
    }
    for(let i=0;i<n.count;i++) out+=`vn ${n.getX(i).toFixed(4)} ${n.getY(i).toFixed(4)} ${n.getZ(i).toFixed(4)}\n`;
    if(ix) for(let i=0;i<ix.count;i+=3){
      const a=ix.getX(i)+voff,b=ix.getX(i+1)+voff,c=ix.getX(i+2)+voff;
      out+=`f ${a}//${a} ${b}//${b} ${c}//${c}\n`;
    }
    voff+=p.count;
  }
  download(new Blob([out],{type:'text/plain'}),'root_mesh.obj');
}
async function exportGLTF(pieces,binary){
  if(!GLTFExporter){
    const m=await addon('exporters/GLTFExporter.js');
    if(!m){ setStatus('GLTFExporter nicht verfügbar — OBJ nutzen.'); return; }
    GLTFExporter=m.GLTFExporter;
  }
  const root=new THREE.Group(); root.name='root_mesh';
  for(const {name,geo} of pieces){
    const vc=name==='roots'&&!!geo.attributes.color;
    const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
      color: name==='body'?0xdddddd:0xffffff, vertexColors:vc,
      roughness:0.55, metalness:0}));
    m.name=name; root.add(m);
  }
  new GLTFExporter().parse(root,res=>{
    if(binary) download(new Blob([res],{type:'model/gltf-binary'}),'root_mesh.glb');
    else download(new Blob([JSON.stringify(res,null,1)],{type:'model/gltf+json'}),'root_mesh.gltf');
    setStatus('Export fertig.');
  },err=>{ console.error(err); setStatus('Export fehlgeschlagen.'); },{binary});
}
function doExport(){
  const pieces=exportPieces();
  if(!pieces.length){ setStatus('Nichts zu exportieren — erst Mesh bauen.'); return; }
  if(P.exportFormat==='obj') exportOBJ(pieces);
  else exportGLTF(pieces,P.exportFormat==='glb');
}
// ── bodypaint.png: R = Ebene 1 Wert · G = Ebene 2 Wert
//    B = Deckung beider Ebenen, je 4 Bit (hoch = Ebene 1, tief = Ebene 2)
//    Alphakanal bleibt voll, damit die Datei verlustfrei durch Canvas läuft.
function applyBodypaint(img){
  const w=img.naturalWidth||img.width, h=img.naturalHeight||img.height;
  if(!w||!h||!paintData) return false;
  const cv=document.createElement('canvas'); cv.width=w; cv.height=h;
  const ctx=cv.getContext('2d',{willReadFrequently:true});
  ctx.drawImage(img,0,0);
  const d=ctx.getImageData(0,0,w,h).data;
  for(let y=0;y<R;y++){
    const sy=Math.min(h-1,Math.floor((y+0.5)/R*h));
    for(let x=0;x<R;x++){
      const sx=Math.min(w-1,Math.floor((x+0.5)/R*w));   // nächster Nachbar,
      const o=(sy*w+sx)*4, t=(y*R+x)*4;                 // sonst zerfließen die Nibbles
      const b=d[o+2];
      paintData[t  ]=d[o];
      paintData[t+1]=(b>>4)*17;
      paintData[t+2]=d[o+1];
      paintData[t+3]=(b&15)*17;
    }
  }
  paintDirty=true; pushHistory();
  setStatus('bodypaint geladen ('+w+'×'+h+').');
  return true;
}
function importBodypaint(file){
  if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
  const url=URL.createObjectURL(file);
  const img=new Image();
  img.onload=()=>{ applyBodypaint(img); URL.revokeObjectURL(url); };
  img.onerror=()=>{ setStatus('Bild konnte nicht gelesen werden.'); URL.revokeObjectURL(url); };
  img.src=url;
}
function exportBodypaint(){
  if(!paintData) return;
  const cv=document.createElement('canvas'); cv.width=cv.height=R;
  const ctx=cv.getContext('2d');
  const img=ctx.createImageData(R,R);
  for(let i=0;i<R*R;i++){
    const o=i*4;
    img.data[o  ]=paintData[o];
    img.data[o+1]=paintData[o+2];
    img.data[o+2]=(Math.round(paintData[o+1]/17)<<4)|Math.round(paintData[o+3]/17);
    img.data[o+3]=255;
  }
  ctx.putImageData(img,0,0);
  cv.toBlob(b=>download(b,'bodypaint.png'));
}
async function autoBodypaint(){
  if(!paintData) return;
  try{
    const r=await fetch('./bodypaint.png');
    if(!r.ok) return;
    const url=URL.createObjectURL(await r.blob());
    await new Promise(res=>{
      const img=new Image();
      img.onload=()=>{ applyBodypaint(img); URL.revokeObjectURL(url); res(); };
      img.onerror=()=>{ URL.revokeObjectURL(url); res(); };
      img.src=url;
    });
  }catch(e){ /* keine Datei vorhanden */ }
}

// ═════════════════════════════════════════════════════════════════════════════
// 12 · UI
// ═════════════════════════════════════════════════════════════════════════════
const ui=document.getElementById('ui');
const syncers=[];
function group(title,open){
  const wrap=document.createElement('div'); wrap.className='grp';
  const h=document.createElement('button'); h.className='grph';
  const t=document.createElement('span'); t.textContent=title;
  const i=document.createElement('i'); i.textContent=open?'−':'+';
  h.append(t,i);
  const bodyEl=document.createElement('div'); bodyEl.className='grpb';
  bodyEl.style.display=open?'block':'none';
  h.addEventListener('click',()=>{
    const o=bodyEl.style.display==='none';
    bodyEl.style.display=o?'block':'none'; i.textContent=o?'−':'+';
  });
  wrap.append(h,bodyEl); ui.appendChild(wrap); return bodyEl;
}
function section(t,parent){
  const d=document.createElement('div'); d.className='sec';
  if(t){ const h=document.createElement('h2'); h.textContent=t; d.appendChild(h); }
  (parent||ui).appendChild(d); return d;
}
function slider(par,label,k,min,max,st,fmt=2,cb){
  const row=document.createElement('div');row.className='row';
  const l=document.createElement('label');l.textContent=label;
  const v=document.createElement('span');v.className='val';v.textContent=(+P[k]).toFixed(fmt);
  row.append(l,v);par.appendChild(row);
  const i=document.createElement('input');i.type='range';i.min=min;i.max=max;i.step=st;i.value=P[k];
  i.addEventListener('input',()=>{P[k]=parseFloat(i.value);v.textContent=P[k].toFixed(fmt);cb&&cb();});
  par.appendChild(i);
  syncers.push(()=>{ i.value=P[k]; v.textContent=(+P[k]).toFixed(fmt); });
}
function graySlider(par,label,k){
  const row=document.createElement('div');row.className='row';
  const l=document.createElement('label');l.textContent=label;
  const chip=document.createElement('span');chip.className='chip';
  const v=document.createElement('span');v.className='val';
  const right=document.createElement('span');
  right.style.cssText='display:flex;align-items:center;gap:6px';
  right.append(chip,v);
  row.append(l,right);par.appendChild(row);
  const i=document.createElement('input');
  i.type='range';i.min=0;i.max=1;i.step=0.01;i.className='gray';i.value=1-P[k];
  const paint=()=>{
    const g=Math.round(P[k]*255);
    chip.style.background=`rgb(${g},${g},${g})`;
    v.textContent=P[k].toFixed(2);
  };
  i.addEventListener('input',()=>{ P[k]=1-parseFloat(i.value); paint(); });
  par.appendChild(i); paint();
  syncers.push(()=>{ i.value=1-P[k]; paint(); });
}
function check(par,label,k,cb){
  const w=document.createElement('label');w.className='check';
  const i=document.createElement('input');i.type='checkbox';i.checked=P[k];
  const s=document.createElement('span');s.textContent=label;
  i.addEventListener('change',()=>{P[k]=i.checked;cb&&cb();});
  w.append(i,s);par.appendChild(w);
  syncers.push(()=>{ i.checked=P[k]; });
  return i;
}
function choice(par,k,opts,cb){
  const s=document.createElement('select');
  for(const [v,t] of opts){const o=document.createElement('option');o.value=v;o.textContent=t;s.appendChild(o);}
  s.value=P[k];s.addEventListener('change',()=>{P[k]=s.value;cb&&cb();});par.appendChild(s);
  return s;
}
function button(par,label,fn,ghost){
  const b=document.createElement('button');b.textContent=label;if(ghost)b.className='ghost';
  b.addEventListener('click',fn);par.appendChild(b);return b;
}
function segmented(par,k,opts,cb){
  const w=document.createElement('div'); w.className='swatch';
  const bs=[];
  for(const [v,t] of opts){
    const b=document.createElement('button'); b.textContent=t;
    b.addEventListener('click',()=>{P[k]=v;bs.forEach(x=>x[0].classList.toggle('on',x[1]===P[k]));cb&&cb();});
    w.appendChild(b); bs.push([b,v]);
  }
  const upd=()=>bs.forEach(x=>x[0].classList.toggle('on',x[1]===P[k]));
  upd(); syncers.push(upd);
  par.appendChild(w);
}
/** Kurveneditor: Dickenprofil vom Ansatz (links) zur Spitze (rechts).
 *  Ziehen verschiebt, Klick auf freie Fläche setzt einen Punkt,
 *  Rechtsklick entfernt ihn. Die Endpunkte bleiben auf x = 0 und x = 1. */
function curveEditor(par,k){
  const W=260,H=84,PAD=7;
  const cv=document.createElement('canvas');
  cv.width=W*2; cv.height=H*2;
  cv.style.cssText='width:100%;height:'+H+'px;border:1px solid var(--line);margin:0 0 8px;cursor:crosshair;display:block';
  par.appendChild(cv);
  const g=cv.getContext('2d'); g.scale(2,2);
  const X=t=>PAD+t*(W-2*PAD), Y=v=>H-PAD-v*(H-2*PAD);
  const iX=px=>clamp((px-PAD)/(W-2*PAD),0,1), iY=py=>clamp((H-PAD-py)/(H-2*PAD),0,1);
  let drag=-1;
  function draw(){
    const c=P[k];
    g.clearRect(0,0,W,H);
    g.fillStyle='#fff'; g.fillRect(0,0,W,H);
    g.strokeStyle='#f0f0f0'; g.lineWidth=1;
    for(let i=1;i<4;i++){
      g.beginPath(); g.moveTo(X(i/4),PAD); g.lineTo(X(i/4),H-PAD); g.stroke();
    }
    g.strokeStyle='#e2e2e2';
    g.beginPath(); g.moveTo(PAD,H-PAD); g.lineTo(W-PAD,H-PAD); g.stroke();
    // Profil als Silhouette, an der Mittellinie gespiegelt
    const mid=H-PAD;
    g.fillStyle='#ececec'; g.beginPath();
    g.moveTo(X(c[0][0]),mid);
    for(const p of c) g.lineTo(X(p[0]),Y(p[1]*0.92));
    g.lineTo(X(c[c.length-1][0]),mid);
    g.closePath(); g.fill();
    g.strokeStyle='#111'; g.lineWidth=1.2; g.beginPath();
    c.forEach((p,i)=>i?g.lineTo(X(p[0]),Y(p[1]*0.92)):g.moveTo(X(p[0]),Y(p[1]*0.92)));
    g.stroke();
    g.fillStyle='#111';
    for(const p of c){
      g.beginPath(); g.arc(X(p[0]),Y(p[1]*0.92),3,0,Math.PI*2); g.fill();
    }
  }
  const local=ev=>{
    const r=cv.getBoundingClientRect();
    return [(ev.clientX-r.left)/r.width*W,(ev.clientY-r.top)/r.height*H];
  };
  const nearest=(px,py)=>{
    const c=P[k]; let best=-1,bd=100;
    c.forEach((p,i)=>{
      const d=(X(p[0])-px)**2+(Y(p[1]*0.92)-py)**2;
      if(d<bd){bd=d;best=i;}
    });
    return best;
  };
  cv.addEventListener('contextmenu',ev=>{
    ev.preventDefault();
    const [px,py]=local(ev), i=nearest(px,py);
    if(i>0&&i<P[k].length-1){ P[k].splice(i,1); draw(); }
  });
  cv.addEventListener('pointerdown',ev=>{
    if(ev.button!==0) return;
    cv.setPointerCapture(ev.pointerId);
    const [px,py]=local(ev);
    let i=nearest(px,py);
    if(i<0||(X(P[k][i][0])-px)**2+(Y(P[k][i][1]*0.92)-py)**2>90){
      const p=[iX(px),iY(py)/0.92];
      P[k].push(p); P[k].sort((a,b)=>a[0]-b[0]);
      i=P[k].indexOf(p);
    }
    drag=i; draw();
  });
  cv.addEventListener('pointermove',ev=>{
    if(drag<0) return;
    const c=P[k], [px,py]=local(ev);
    c[drag][1]=clamp(iY(py)/0.92,0,1);
    if(drag>0&&drag<c.length-1){
      c[drag][0]=clamp(iX(px),c[drag-1][0]+0.01,c[drag+1][0]-0.01);
    }
    draw();
  });
  const up=()=>{ drag=-1; };
  cv.addEventListener('pointerup',up); cv.addEventListener('pointercancel',up);
  draw();
  syncers.push(draw);
  return draw;
}
const syncUI=()=>syncers.forEach(f=>f());

function setLayer(i){
  P.layer=i;
  if(bodyMat) bodyMat.uniforms.uLayer.value=i;
  document.getElementById('bLayer1').classList.toggle('on',i===0);
  document.getElementById('bLayer2').classList.toggle('on',i===1);
  syncUI(); setStatus();
}
function setPoseMode(on){
  P.poseMode=on;
  document.getElementById('bPose').classList.toggle('on',on);
  if(on&&!srcMesh?.isSkinnedMesh){ setStatus('Modell hat kein Skelett — Pose nicht möglich.'); }
  else setStatus(on?'Bone ziehen · Umschalt = Twist':'');
  syncUI();
}

// ── KÖRPER
{
  const g=group('Körper',false);
  const s=section('Modell',g);
  const inp=document.createElement('input');
  inp.type='file'; inp.accept='.glb,.gltf,.fbx'; inp.style.display='none';
  inp.addEventListener('change',async()=>{const f=inp.files[0]; if(f) loadFromBuffer(await f.arrayBuffer(),f.name);});
  s.appendChild(inp);
  button(s,'Datei wählen',()=>inp.click(),true);
  const path=document.createElement('input');
  path.type='text'; path.placeholder='./body_minimal.glb'; s.appendChild(path);
  button(s,'Pfad laden',async()=>{
    const v=path.value.trim(); if(!v) return;
    if(!await loadFromURL(v)) setStatus('Nicht gefunden: '+v);
  },true);
  button(s,'Dummy verwenden',()=>{ const d=buildDummy(); setBody(d.root,d.mesh,'Dummy'); },true);

  const p=section('Pose',g);
  check(p,'Pose-Modus','poseMode',()=>setPoseMode(P.poseMode));
  check(p,'Pose spiegeln','poseMirror',()=>{ rebuildBoneHandles(); });
  button(p,'Pose zurücksetzen',resetPose,true);

  const m=section('Auflösung der Karte',g);
  const sel=choice(m,'mapRes',[['512','512 × 512'],['1024','1024 × 1024'],['2048','2048 × 2048 (schwer)']]);
  sel.value=String(P.mapRes);
  sel.addEventListener('change',()=>{P.mapRes=parseInt(sel.value);});
  button(m,'Karte neu aufbauen',async()=>{
    if(!body) return;
    await buildPaintMap(P.mapRes); buildTriangleList(); resetHistory();
  },true);
}
// ── PINSEL
{
  const g=group('Pinsel',true);
  const s=section('Ebene',g);
  segmented(s,'layer',[[0,'1 · Dichte'],[1,'2 · Höhe']],()=>setLayer(P.layer));

  const b=section('Werkzeug',g);
  segmented(b,'brushMode',[['paint','Malen'],['erase','Radieren'],['smooth','Glätten']]);
  graySlider(b,'Pinselwert','target');
  slider(b,'Radius','brushRadius',0.004,0.25,0.001,3);
  slider(b,'Härte','hardness',0,1,0.01,2);
  slider(b,'Fluss','flow',0.02,1,0.01,2);
  slider(b,'Normalengrenze','normalLimit',-1,0.9,0.01,2);
  check(b,'Spiegeln an X = 0','mirror');
  const r=document.createElement('div');r.className='btn2';b.appendChild(r);
  button(r,'Ebene leeren',clearLayer,true);
  button(r,'bodypaint.png',exportBodypaint,true);
  const mi=document.createElement('input');
  mi.type='file'; mi.accept='image/png'; mi.style.display='none';
  mi.addEventListener('change',()=>{ const f=mi.files[0]; if(f) importBodypaint(f); mi.value=''; });
  b.appendChild(mi);
  button(b,'bodypaint laden',()=>mi.click(),true);
}
// ── SAAT
{
  const g=group('Saat · Ebene 1',true);
  const s=section('Kreispackung',g);
  slider(s,'Minimum (weiß)','rMin',0.001,0.04,0.0005,4);
  slider(s,'Maximum (schwarz)','rMax',0.004,0.10,0.0005,4);
  slider(s,'Kontrast (Gamma)','gamma',0.2,4,0.05,2);
  slider(s,'Streuung','jitter',0,0.6,0.01,2);
  slider(s,'Packdichte','packing',0.1,1.2,0.01,2);
  slider(s,'Kandidaten','samples',2000,200000,1000,0);
  slider(s,'Dichte folgt Dunkelheit','densityBias',0,1,0.01,2);
  slider(s,'Schwelle Deckung','alphaMin',0.01,0.9,0.01,2);
  slider(s,'Ausgleich','relax',0,20,1,0);
  slider(s,'Startwert (Seed)','seed',1,999,1,0);
  button(s,'Neu würfeln',()=>{P.seed=1+((Math.random()*998)|0);syncUI();generate();},true);
}
// ── WUCHS
{
  const g=group('Wuchs · Ebene 2',true);
  const s=section('Bahn',g);
  slider(s,'Schritte','steps',6,240,1,0);
  slider(s,'Schrittlänge','stepLen',0.002,0.03,0.0005,4);
  slider(s,'Lenkung','turn',0.05,1.2,0.01,2);

  const h=section('Wuchsraum',g);
  slider(h,'Bodenabstand','hug',0,0.02,0.0005,4);
  slider(h,'Deckenhöhe max. (Ebene 2)','lift',0,0.3,0.005,3);
  slider(h,'Rückholkraft','stick',0,1,0.01,2);
  slider(h,'Bodenzug','settle',0,2,0.01,2);
  check(h,'Außerhalb der Bemalung stoppen','killOffPaint');

  const z=section('Stützen',g);
  check(z,'Zylinderraster','pillarsOn');
  segmented(z,'pMode',[['grid','Raster'],['pack','Packung']]);
  slider(z,'Spaltenabstand','pCols',0.006,0.2,0.001,3);
  slider(z,'Zeilenabstand','pRows',0.006,0.2,0.001,3);
  slider(z,'Rasterweite (Packung)','pSpace',0.3,3,0.02,2);
  slider(z,'Zylinderdicke','pFill',0.1,0.9,0.01,2);
  slider(z,'Zylinderhöhe','pHeight',0,2,0.02,2);
  slider(z,'Abstoßung','pPush',0,8,0.1,1);
  slider(z,'Wirbel um die Achse','pSwirl',-2,2,0.01,2);
  slider(z,'Wirbelreichweite','pSwirlRange',0.002,0.08,0.001,3);

  const pl=section('Platzsuche',g);
  slider(pl,'Fühlerweite','senseDist',0.004,0.06,0.001,3);
  slider(pl,'Fühlerwinkel','senseAngle',0.1,1.4,0.01,2);
  slider(pl,'Zug zum freien Raum','senseForce',0,3,0.01,2);
  slider(pl,'Rasterweite Belegung','occCell',0.002,0.02,0.0005,4);
  slider(pl,'Verdichtungsgrenze','occMax',1,40,1,0);
  check(pl,'Anastomose (Spitzen verwachsen)','fuse');

  const f=section('Feld',g);
  slider(f,'Perlin-Curl','curlAmp',0,2,0.01,2);
  slider(f,'Feldskalierung','noiseScale',0.3,16,0.1,1);
  slider(f,'Schwerkraft','gravity',0,1,0.01,2);
  slider(f,'Oszillation','oscAmp',0,2,0.01,2);
  slider(f,'Schwingweite','oscFreq',0.05,1.6,0.01,2);
  slider(f,'Unruhe','oscJitter',0,1,0.01,2);

  const b=section('Schwarm',g);
  slider(b,'Nachbarradius','nbRadius',0.006,0.08,0.001,3);
  slider(b,'Sicherheitsabstand','sepDist',0.002,0.04,0.0005,4);
  slider(b,'Trennung','sepForce',0,3,0.01,2);
  slider(b,'Gleichrichtung','alignForce',0,1.5,0.01,2);
  slider(b,'Kohäsion','cohForce',0,1.5,0.01,2);
  slider(b,'Verflechtung','twistForce',0,1.5,0.01,2);

  const v=section('Verzweigung',g);
  slider(v,'Verzweigungsrate','branchProb',0,0.3,0.005,3);
  slider(v,'Winkel','branchAngle',0.1,1.6,0.01,2);
  slider(v,'Stärkeabnahme','branchDecay',0.3,0.95,0.01,2);
  slider(v,'Maximale Tiefe','maxDepth',0,6,1,0);
  slider(v,'Maximale Spitzen','maxAgents',500,30000,500,0);
  const r=document.createElement('div');r.className='btn2';v.appendChild(r);
  button(r,'Wurzeln wachsen',generate);
  button(r,'Mesh bauen',buildMesh);
  button(v,'Löschen',clearRoots,true);
}
// ── QUERSCHNITT
{
  const g=group('Röhren & Mesh',true);
  const s=section(null,g);
  slider(s,'Grundstärke','rootScale',0.02,0.5,0.005,3);
  slider(s,'Mindeststärke','rootMin',0.0002,0.004,0.0001,4);
  const cs=section('Dickenprofil · Ansatz → Spitze',g);
  curveEditor(cs,'thick');
  const s2=section(null,g);
  slider(s2,'Kanten je Röhre','tubeSides',3,10,1,0);
  slider(s2,'Vereinfachen','simplify',0,0.6,0.01,2);
  check(s2,'Kugel am Bahnende','tipSpheres');
  slider(s2,'Kugelgröße','tipScale',0.6,4,0.05,2);
  slider(s2,'Detail Kugel','tipDetail',0,2,1,0);
  const cs2=section('Farben · Wurzel · Halm',g);
  const cw=document.createElement('div'); cw.className='btn2'; cs2.appendChild(cw);
  for(const k of ['rootColor','stemColor']){
    const c=document.createElement('input'); c.type='color'; c.value=P[k];
    c.style.margin='0';
    c.addEventListener('input',()=>{ P[k]=c.value; applyRootColors(); });
    cw.appendChild(c);
  }
  slider(cs2,'Aufhellung im Verlauf','shade',0,0.9,0.01,2,()=>applyRootColors());
  button(cs2,'Mesh bauen',buildMesh);

  const pf=section('Halme',g);
  check(pf,'Halme','shootsOn');
  slider(pf,'Anteil der Saat','shootProb',0,1,0.01,2);
  slider(pf,'Länge','shootLen',0.01,0.2,0.0025,4);
  slider(pf,'Schritte','shootSteps',2,40,1,0);
  slider(pf,'Stärke','shootThick',0.3,4,0.05,2);
  slider(pf,'Breite','bladeWidth',1,8,0.1,2);
  slider(pf,'Dicke','bladeThick',0.05,1,0.01,2);
  slider(pf,'Höhe über der Matte','bladeRise',0,0.03,0.0005,4);
  slider(pf,'Windung','shootWobble',0,2,0.01,2);
  slider(pf,'Kanten je Halm','bladeSides',3,10,1,0);
}
// ── ANSICHT
{
  const g=group('Ansicht',false);
  const s=section(null,g);
  check(s,'Körper','showBody');
  slider(s,'Deckkraft unbemalt','ghost',0,1,0.01,2);
  check(s,'Malfläche zeigen','showPaint');
  slider(s,'Andere Ebene (grün)','otherLayer',0,1,0.01,2);
  check(s,'Wurzel-Mesh','showRoots');
  check(s,'Linienvorschau','showPreview');
  check(s,'Stützen zeigen','showPillars');
}
// ── EXPORT
{
  const g=group('Export',false);
  const s=section(null,g);
  choice(s,'exportFormat',[['glb','GLB (binär)'],['gltf','GLTF (JSON)'],['obj','OBJ']]);
  check(s,'Körper mitexportieren','exportBody');
  button(s,'Exportieren',doExport);
}

// ── Toolbar / Status
const bUndo=document.getElementById('bUndo'), bRedo=document.getElementById('bRedo');
document.getElementById('bGrow').onclick=generate;
document.getElementById('bMesh').onclick=buildMesh;
document.getElementById('bDel').onclick=clearRoots;
document.getElementById('bClearMap').onclick=clearLayer;
document.getElementById('bLayer1').onclick=()=>setLayer(0);
document.getElementById('bLayer2').onclick=()=>setLayer(1);
document.getElementById('bPose').onclick=()=>setPoseMode(!P.poseMode);
bUndo.onclick=undo; bRedo.onclick=redo;
function updateBar(){
  bUndo.disabled=histIdx<=0;
  bRedo.disabled=histIdx>=history.length-1;
}
let ratio=[0,0];
function setStatus(msg){
  if(paintData) ratio=[paintedRatio(0),paintedRatio(1)];
  const parts=[`MODELL <b>${modelName}</b>`];
  if(R) parts.push(`KARTE <b>${R}×${R}</b>`,
    `EBENE 1 <b>${(ratio[0]*100).toFixed(1)}%</b>`,
    `EBENE 2 <b>${(ratio[1]*100).toFixed(1)}%</b>`);
  if(seeds.length) parts.push(`SAAT <b>${seeds.length.toLocaleString('de-DE')}</b>`);
  if(pillars.length) parts.push(`STÜTZEN <b>${pillars.length.toLocaleString('de-DE')}</b>`);
  if(roots.length){
    parts.push(`WURZELN <b>${roots.length.toLocaleString('de-DE')}</b>`,
               `PUNKTE <b>${rootPts.toLocaleString('de-DE')}</b>`);
    const t=meshTris();
    if(shoots.length) parts.push(`SPROSSE <b>${shoots.length.toLocaleString('de-DE')}</b>`);
    if(t) parts.push(`DREIECKE <b>${Math.round(t).toLocaleString('de-DE')}</b>`);
  }
  if(msg) parts.push(`<b>${msg}</b>`);
  document.getElementById('status').innerHTML=parts.join('&nbsp;&nbsp;·&nbsp;&nbsp;');
}

// ═════════════════════════════════════════════════════════════════════════════
// 13 · LOOP
// ═════════════════════════════════════════════════════════════════════════════
let frameN=0;
function tick(){
  requestAnimationFrame(tick);
  if(body){
    body.visible=P.showBody;
    bodyMat.uniforms.uGhost.value=P.ghost;
    bodyMat.uniforms.uShow.value=P.showPaint?1:0;
    bodyMat.uniforms.uOther.value=P.otherLayer;
  }
  if(paintDirty&&((frameN++&1)===0)){ paintTex.needsUpdate=true; paintDirty=false; }
  const pose=P.poseMode&&!!srcMesh?.isSkinnedMesh;
  const showCur=!!hoverHit&&!!paintData&&!orbiting&&!P.poseMode;
  cursor.visible=showCur; cursorM.visible=showCur&&P.mirror;
  bonePts.visible=skelLines.visible=pose;
  selPt.visible=pose&&!!selectedBone;
  rootGroup.visible=P.showRoots;
  preGroup.visible=P.showPreview||P.showPillars;
  if(preLines) preLines.visible=P.showPreview;
  if(pillarLines) pillarLines.visible=P.showPillars;
  renderer.render(scene,camera);
}

const d=buildDummy();
await setBody(d.root,d.mesh,'Dummy');
setLayer(0); setPoseMode(false);
resize(); setStatus(); tick();
autoLoad();
</script>
</body>
</html>
Assets
body_minimal.glb  (998 KB)