cellular_hull_designer

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/d1b7dcea63a07cc6/" 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>CELL_MESH_BODY</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</button>
  <button id="bLayer2">Ebene 2</button>
  <span class="gap"></span>
  <button id="bPose">Pose</button>
  <span class="gap"></span>
  <button id="bGrow">Struktur erzeugen</button>
  <button id="bMesh">Mesh bauen</button>
  <button id="bDelSph">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>CELL_MESH_BODY</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,
  // Struktur
  mode:'voronoi', rMin:0.006, rMax:0.036, gamma:1.6, jitter:0.18,
  packing:0.55, samples:45000, densityBias:0.35, alphaMin:0.10,
  relax:8, gap:0.0, regular:0.15, ringPoints:8, seed:1,
  // Wuchs (Ebene 2 → Höhe)
  growHeight:0.12, growSteps:6, wobble:0.55, noiseScale:3.2, straighten:0.55,
  align:0.35, cohesion:0.0, sepKeep:0.85, relaxIter:3, levels:1,
  // Wände & Adern
  showWalls:true, wallThick:0.0, capTop:false,
  veinScale:0.16, taper:0.35, tubeSides:4, showVeins:true, showJoints:false, jointScale:1.4,
  sphereDetail:1, sphereColor:'#111111', tipColor:'#b4b4b4', showPreview:true,
  // Ansicht
  poseMirror:true,
  showBody:true, ghost:0.16, showPaint:true, showTissue: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;

function bodyShader(){
  return new THREE.ShaderMaterial({
    transparent:true, depthWrite:true, side:THREE.FrontSide,
    uniforms:{
      map:{value:null}, uGhost:{value:P.ghost}, uShow:{value:1},
      uLayer:{value:P.layer}, uBase:{value:new THREE.Color(0xdadada)}, uL:{value:LIGHT_DIR.clone()}
    },
    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,uLayer;
      uniform vec3 uBase,uL;
      varying vec2 vUv; varying vec3 vN;
      void main(){
        vec4 t=texture2D(map,vUv);
        vec2 A=(uLayer<0.5)?t.rg:t.ba;      // Wert und Deckung der aktiven Ebene
        float a=A.y*uShow;
        vec3 col=mix(uBase,vec3(A.x),a);
        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;
        gl_FragColor=vec4(col*sh, mix(1.0, mix(uGhost,1.0,a), 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);
}
/** Quelle (ggf. skinned + posiert) → Weltkoordinaten in die Anzeigegeometrie schreiben */
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;
  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;
  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;
  }
  if(texelIds&&texelIds.length) texelSpacing=Math.sqrt(triArea/texelIds.length);
}
/** volle Neuberechnung nach Posenänderung */
function refreshAfterPose(){
  buildTriangleList();
  tGrid=null;
  updateTexelPositions();
  buildTexelHash();
  if(seeds.length) generate();
  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; clearTissue();
    setStatus('Keine UV-Koordinaten.');
    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();
  clearTissue();
  await buildPaintMap(P.mapRes);
  buildTriangleList();
  resetHistory();
  await autoLoadMap();
  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.'); 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;
/** Achsensymmetrie nur nutzen, solange der Körper wirklich symmetrisch steht */
const poseAsym=()=>!!(srcMesh?.isSkinnedMesh&&!P.poseMirror&&poseQ.size>0);
const mirrorOn=()=>P.mirror&&!poseAsym();

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();

  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?' · UV überlappt':''));
  await frameP();
}
/** Texel-Weltpositionen aus der aktuellen (ggf. posierten) Geometrie */
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(), all=P.layer===0;
  for(let i=0;i<R*R;i++){
    paintData[i*4+vo]=0; paintData[i*4+ao]=0;
    if(all){ paintData[i*4+2]=0; paintData[i*4+3]=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';
  const lay2=P.layer===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;
    const k=1-Math.sqrt(d2)/r;
    let f=clamp(k/soft,0,1); f=f*f*(3-2*f);
    let 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; }
      if(!lay2){                                   // Ebene 1 weg = Ebene 2 dort sinnlos
        paintData[o+3]=Math.max(0,paintData[o+3]-w*255);
        if(paintData[o+3]<2){ paintData[o+2]=0; paintData[o+3]=0; }
      }
    }else{
      if(lay2){
        const base=paintData[o+1]/255;             // nur wo Ebene 1 gedeckt ist
        if(base<=0.004) return;
        w*=base;
      }
      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);
      if(lay2&&paintData[o+ao]>paintData[o+1]) paintData[o+ao]=paintData[o+1];
    }
  });
  paintDirty=true;
}
/** Glätten: separabler Boxfilter, Kernel skaliert mit dem Pinselradius */
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)=>{                 // Präfixsummen je Zeile/Spalte
    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;
    if(P.layer===1&&paintData[o+3]>paintData[o+1]) paintData[o+3]=paintData[o+1];
  }
  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(mirrorOn()){
    _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);
  }
}
/** bilinear → [v1,a1,v2,a2] in 0..1 */
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;

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};
        setStatus(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;
    }
    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 · STRUKTUR & GEWEBE
//      Ebene 1 → Zellgröße (schwarz große Zellen, weiß feine)
//      Ebene 2 → Wuchshöhe über der Haut (schwarz weit, weiß nah, unbemalt = flach)
// ═════════════════════════════════════════════════════════════════════════════
// ── Perlin / Curl
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=[], agents=[], edges=[], loops=[], jointList=[];
let tissue=null, joints=null, preFaces=null, preLines=null;
const tisGroup=new THREE.Group(); scene.add(tisGroup);
const preGroup=new THREE.Group(); scene.add(preGroup);
let busy=false;

// ── Fortschrittsanzeige
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 clearTissue(){
  tisGroup.clear(); preGroup.clear();
  if(tissue){ tissue.geometry.dispose(); tissue.material.dispose(); tissue=null; }
  if(joints){ joints.geometry.dispose(); joints.material.dispose(); joints=null; }
  if(preFaces){ preFaces.geometry.dispose(); preFaces.material.dispose(); preFaces=null; }
  if(preLines){ preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
  seeds=[]; agents=[]; edges=[]; loops=[]; jointList=[]; setStatus();
}
function clearMeshOnly(){
  if(tissue){ tisGroup.remove(tissue); tissue.geometry.dispose(); tissue.material.dispose(); tissue=null; }
  tissueT=null;
  if(joints){ tisGroup.remove(joints); joints.geometry.dispose(); joints.material.dispose(); joints=null; }
  jointList=[];
}

// ═══ Oberflächen-Projektion: Dreiecksraster + nächster Punkt ═══
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){                        // nächster Punkt auf Dreieck 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;
}
// Kreisradius und Wuchshöhe aus den beiden Karten
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;
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.growHeight;
  return out;
}
const _mm={v:0,a:0,r:0,h:0};

// ═══ 1 · Samen setzen (Dichte aus Ebene 1) ═══
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;
    if(mirrorOn()&&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(mirrorOn()){
      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;
  }
}
// ═══ 2 · Relaxation: Abstoßung + Rückprojektion (Lloyd-Ersatz) ═══
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;                                  // Spiegelbilder folgen ihren Quellen
      if(mirrorOn()&&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)));     // Streuung des Samens behalten
      A.h=_mm.h; A.s=spacingOf(A);
      if(mirrorOn()&&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('Ausgleich '+(it+1)+' / '+iters,0.1+0.5*(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));
}
// ── Spiegelpaare finden und Positionen exakt symmetrisch ziehen
function mirrorPairs(list,tol){
  const n=list.length, pair=new Int32Array(n).fill(-2);
  const cell=Math.max(tol*2,0.003);
  const key=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
  const grid=new Map();
  for(let i=0;i<n;i++){
    const A=list[i];
    const k=key(Math.floor(A.x/cell),Math.floor(A.y/cell),Math.floor(A.z/cell));
    let arr=grid.get(k); if(!arr){arr=[];grid.set(k,arr);} arr.push(i);
  }
  for(let i=0;i<n;i++){
    if(pair[i]!==-2) continue;
    const A=list[i];
    if(Math.abs(A.x)<tol*0.6){ pair[i]=i; continue; }
    const mx=-A.x, my=A.y, mz=A.z;
    const gx=Math.floor(mx/cell),gy=Math.floor(my/cell),gz=Math.floor(mz/cell);
    let best=-1,bd=tol*tol;
    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||pair[j]!==-2) continue;
        const B=list[j];
        const d=(B.x-mx)**2+(B.y-my)**2+(B.z-mz)**2;
        if(d<bd){ bd=d; best=j; }
      }
    }
    if(best>=0){ pair[i]=best; pair[best]=i; } else pair[i]=-1;
  }
  return pair;
}
function symmetrize(list,tol){
  if(!mirrorOn()) return null;
  const pair=mirrorPairs(list,tol);
  for(let i=0;i<list.length;i++){
    const j=pair[i];
    if(j<0) continue;
    const A=list[i];
    if(j===i){
      A.x=0;
      if(A.nx!==undefined){ A.nx=0; const l=Math.hypot(A.ny,A.nz)||1; A.ny/=l; A.nz/=l; }
      continue;
    }
    if(j<i) continue;
    const B=list[j];
    const x=(A.x-B.x)*0.5, y=(A.y+B.y)*0.5, z=(A.z+B.z)*0.5;
    A.x=x; A.y=y; A.z=z; B.x=-x; B.y=y; B.z=z;
    if(A.nx!==undefined&&B.nx!==undefined){
      let nx=(A.nx-B.nx)*0.5, ny=(A.ny+B.ny)*0.5, nz=(A.nz+B.nz)*0.5;
      const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
      A.nx=nx; A.ny=ny; A.nz=nz; B.nx=-nx; B.ny=ny; B.nz=nz;
    }
  }
  return pair;
}
// ═══ 3 · Zellen: Voronoi im Tangentialraum ═══
function clipHalf(poly,nx,ny,c){
  const out=[]; const n=poly.length;
  for(let i=0;i<n;i++){
    const A=poly[i], B=poly[(i+1)%n];
    const da=A.x*nx+A.y*ny-c, db=B.x*nx+B.y*ny-c;
    if(da<=0) out.push(A);
    if((da<0&&db>0)||(da>0&&db<0)){
      const t=da/(da-db);
      out.push({x:A.x+(B.x-A.x)*t, y:A.y+(B.y-A.y)*t});
    }
  }
  return out;
}
function buildCells(){
  const n=seeds.length;
  const cell=Math.max(0.01,2*P.rMax*(1+P.jitter)*P.packing*2.2);
  const key=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
  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);
  }
  const cells=[], near=[];
  const up=new V3(0,1,0), tu=new V3(), tv=new V3(), nn=new V3(), tmp=new V3();
  for(let i=0;i<n;i++){
    const A=seeds[i];
    nn.set(A.nx,A.ny,A.nz);
    tu.copy(up).addScaledVector(nn,-up.dot(nn));
    if(tu.lengthSq()<1e-8) tu.set(1,0,0).addScaledVector(nn,-nn.x);
    tu.normalize(); tv.crossVectors(nn,tu).normalize();
    near.length=0;
    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) near.push(j);
    }
    const R=A.s*1.4;
    let poly=[{x:-R,y:-R},{x:R,y:-R},{x:R,y:R},{x:-R,y:R}];
    const used=[];
    for(const j of near){
      const B=seeds[j];
      if(A.nx*B.nx+A.ny*B.ny+A.nz*B.nz<0.0) continue;      // andere Körperseite
      tmp.set(B.x-A.x,B.y-A.y,B.z-A.z);
      const dx=tmp.dot(tu), dy=tmp.dot(tv);
      const d2=dx*dx+dy*dy;
      if(d2<1e-12||d2>(A.s+B.s)*(A.s+B.s)) continue;
      poly=clipHalf(poly,dx,dy,d2*0.5);
      if(poly.length<3) break;
      used.push({j,dx,dy,c:d2*0.5,len:Math.sqrt(d2)});
    }
    if(poly.length<3){ cells.push(null); continue; }
    // Nachbarn bestimmen: welche Halbebene liegt auf welcher Kante?
    const nb=new Array(poly.length).fill(-1);
    for(let k=0;k<poly.length;k++){
      const A2=poly[k], B2=poly[(k+1)%poly.length];
      const mx=(A2.x+B2.x)*0.5, my=(A2.y+B2.y)*0.5;
      let bj=-1,bd=1e9;
      for(const u of used){
        const d=Math.abs(mx*u.dx+my*u.dy-u.c)/u.len;
        if(d<bd){bd=d;bj=u.j;}
      }
      nb[k]=(bd<A.s*0.02)?bj:-1;
    }
    cells.push({i,poly,nb,tu:tu.clone(),tv:tv.clone(),nn:nn.clone()});
  }
  return cells;
}
// ═══ 4 · Agenten und Kanten je nach Modus ═══
function regularize(pts,loops,iters,strength){
  if(strength<=0||iters<=0) return;
  const n=pts.length;
  const tx=new Float64Array(n),ty=new Float64Array(n),tz=new Float64Array(n),tw=new Float64Array(n);
  for(let it=0;it<iters;it++){
    tx.fill(0);ty.fill(0);tz.fill(0);tw.fill(0);
    for(const lp of loops){
      const m=lp.length; if(m<3) continue;
      let cx=0,cy=0,cz=0;
      for(const i of lp){ cx+=pts[i].x; cy+=pts[i].y; cz+=pts[i].z; }
      cx/=m; cy/=m; cz/=m;
      let rr=0;
      for(const i of lp) rr+=Math.hypot(pts[i].x-cx,pts[i].y-cy,pts[i].z-cz);
      rr/=m;
      for(const i of lp){
        let ex=pts[i].x-cx, ey=pts[i].y-cy, ez=pts[i].z-cz;
        const d=Math.hypot(ex,ey,ez)||1;
        tx[i]+=cx+ex/d*rr; ty[i]+=cy+ey/d*rr; tz[i]+=cz+ez/d*rr; tw[i]++;
      }
    }
    for(let i=0;i<n;i++){
      if(!tw[i]) continue;
      const p=pts[i];
      let nx=p.x+(tx[i]/tw[i]-p.x)*strength;
      let ny=p.y+(ty[i]/tw[i]-p.y)*strength;
      let nz=p.z+(tz[i]/tw[i]-p.z)*strength;
      const r=onSurface(nx,ny,nz);
      if(r.ok){ p.x=r.x; p.y=r.y; p.z=r.z; } else { p.x=nx; p.y=ny; p.z=nz; }
      if(mirrorOn()&&Math.abs(p.x)<1e-4) p.x=0;
    }
  }
}
function buildTopology(){
  agents=[]; edges=[]; loops=[];
  const finish=()=>{
    if(!agents.length) return;
    let mean=0; for(const a of agents) mean+=a.s||0.02; mean/=agents.length;
    symmetrize(agents,mean*0.45);
  };
  const asAgent=A=>({x:A.x,y:A.y,z:A.z,nx:A.nx,ny:A.ny,nz:A.nz,s:A.s||spacingOf(A),h:A.h,r:A.r});

  if(P.mode==='circle'){                       // Kreispackung als eigenständige Ringe
    const K=Math.max(3,P.ringPoints|0);
    const up=new V3(0,1,0), tu=new V3(), tv=new V3(), nn=new V3();
    for(const A of seeds){
      nn.set(A.nx,A.ny,A.nz);
      tu.copy(up).addScaledVector(nn,-up.dot(nn));
      if(tu.lengthSq()<1e-8) tu.set(1,0,0).addScaledVector(nn,-nn.x);
      tu.normalize(); tv.crossVectors(nn,tu).normalize();
      const rad=A.r*P.packing*(1-P.gap);
      const loop=[];
      for(let k=0;k<K;k++){
        const a2=k/K*Math.PI*2;
        const wx=A.x+tu.x*Math.cos(a2)*rad+tv.x*Math.sin(a2)*rad;
        const wy=A.y+tu.y*Math.cos(a2)*rad+tv.y*Math.sin(a2)*rad;
        const wz=A.z+tu.z*Math.cos(a2)*rad+tv.z*Math.sin(a2)*rad;
        const r=onSurface(wx,wy,wz);
        const px=r.ok?r.x:wx, py=r.ok?r.y:wy, pz=r.ok?r.z:wz;
        let h=A.h, sS=A.s||spacingOf(A);
        if(r.ok){ readMaps(r.u,r.v,_mm); h=_mm.h; }
        loop.push(agents.length);
        agents.push({x:px,y:py,z:pz,
                     nx:r.ok?r.nx:A.nx,ny:r.ok?r.ny:A.ny,nz:r.ok?r.nz:A.nz,
                     s:sS,h,r:A.r});
      }
      for(let k=0;k<loop.length;k++) edges.push(loop[k],loop[(k+1)%loop.length]);
      loops.push(loop);
    }
    finish();
    return;
  }

  const cells=buildCells();
  if(P.mode==='delaunay'){
    for(const A of seeds) agents.push(asAgent(A));
    const seen=new Set();
    for(const c of cells){
      if(!c) continue;
      for(const j of c.nb){
        if(j<0) continue;
        const a=Math.min(c.i,j), b=Math.max(c.i,j), k=a*1000003+b;
        if(seen.has(k)) continue;
        seen.add(k); edges.push(a,b);
      }
    }
    finish();
    return;
  }
  // Voronoi: Zellecken benachbarter Zellen verschweißen
  let meanS=0; for(const A of seeds) meanS+=(A.s||spacingOf(A)); meanS/=Math.max(1,seeds.length);
  const weld=P.gap<=0.001;
  const tol=meanS*0.32, wc=Math.max(tol*2,0.003);
  const wkey=(x,y,z)=>(x+2048)+(y+2048)*4096+(z+2048)*16777216;
  const wgrid=new Map();
  const pts=[];
  function addPoint(x,y,z){
    if(weld){
      const gx=Math.floor(x/wc),gy=Math.floor(y/wc),gz=Math.floor(z/wc);
      let best=-1,bd=tol*tol;
      for(let a2=gx-1;a2<=gx+1;a2++)for(let b2=gy-1;b2<=gy+1;b2++)for(let c2=gz-1;c2<=gz+1;c2++){
        const arr=wgrid.get(wkey(a2,b2,c2)); if(!arr) continue;
        for(const i of arr){
          const p=pts[i];
          const d=(p.x-x)**2+(p.y-y)**2+(p.z-z)**2;
          if(d<bd){ bd=d; best=i; }
        }
      }
      if(best>=0){
        const p=pts[best];
        p.x=(p.x*p.n+x)/(p.n+1); p.y=(p.y*p.n+y)/(p.n+1); p.z=(p.z*p.n+z)/(p.n+1); p.n++;
        return best;
      }
    }
    const id=pts.length;
    pts.push({x,y,z,n:1});
    if(weld){
      const k=wkey(Math.floor(x/wc),Math.floor(y/wc),Math.floor(z/wc));
      let arr=wgrid.get(k); if(!arr){arr=[];wgrid.set(k,arr);} arr.push(id);
    }
    return id;
  }
  const g=P.gap;
  for(const c of cells){
    if(!c) continue;
    const A=seeds[c.i], poly=c.poly;
    if(poly.length<3) continue;
    let cx=0,cy=0;
    for(const q of poly){cx+=q.x;cy+=q.y;} cx/=poly.length; cy/=poly.length;
    const loop=[];
    for(const q of poly){
      const x2=weld?q.x:cx+(q.x-cx)*(1-g), y2=weld?q.y:cy+(q.y-cy)*(1-g);
      const wx=A.x+c.tu.x*x2+c.tv.x*y2;
      const wy=A.y+c.tu.y*x2+c.tv.y*y2;
      const wz=A.z+c.tu.z*x2+c.tv.z*y2;
      const id=addPoint(wx,wy,wz);
      if(!loop.length||loop[loop.length-1]!==id) loop.push(id);
    }
    while(loop.length>1&&loop[0]===loop[loop.length-1]) loop.pop();
    if(loop.length<3) continue;
    loops.push(loop);
  }
  if(!pts.length) return;
  regularize(pts,loops,3,clamp(P.regular,0,1));
  for(const p of pts){
    const r=onSurface(p.x,p.y,p.z);
    let x=p.x,y=p.y,z=p.z,nx=0,ny=1,nz=0,rr=meanS*0.5,h=0;
    if(r.ok){
      x=r.x;y=r.y;z=r.z; nx=r.nx;ny=r.ny;nz=r.nz;
      readMaps(r.u,r.v,_mm); rr=_mm.r; h=_mm.h;
    }
    agents.push({x,y,z,nx,ny,nz,s:2*rr*P.packing,h,r:rr});
  }
  const seen=new Set();
  for(const lp of loops){
    for(let k=0;k<lp.length;k++){
      const i=lp[k], j=lp[(k+1)%lp.length];
      if(i===j) continue;
      const a=Math.min(i,j), b=Math.max(i,j), kk=a*1000003+b;
      if(seen.has(kk)) continue;
      seen.add(kk); edges.push(a,b);
    }
  }
  finish();
}
// ═══ 5 · Agenten wachsen mit Schwarmverhalten ═══
const veinR=(a,f)=>Math.max(0.0006, a.s*P.veinScale*(1-P.taper*f));
function growAgents(){
  const S=Math.max(1,P.growSteps|0), n=agents.length;
  const px=new Float64Array(n),py=new Float64Array(n),pz=new Float64Array(n);
  const dx=new Float64Array(n),dy=new Float64Array(n),dz=new Float64Array(n);
  const ax=new Float64Array(n),ay=new Float64Array(n),az=new Float64Array(n);
  const stepLen=new Float64Array(n);
  const b0x=new Float64Array(n),b0y=new Float64Array(n),b0z=new Float64Array(n);
  for(let i=0;i<n;i++){
    const a=agents[i], off=veinR(a,0);
    px[i]=a.x+a.nx*off; py[i]=a.y+a.ny*off; pz[i]=a.z+a.nz*off;
    b0x[i]=a.x; b0y[i]=a.y; b0z[i]=a.z;
    dx[i]=a.nx; dy[i]=a.ny; dz[i]=a.nz;
    stepLen[i]=a.h/S;
    a.path=[new V3(px[i],py[i],pz[i])];
  }
  // Nachbarschaft für Gleichrichtung und Kohäsion
  let adjStart,adjIdx;
  let meanS=0; for(let i=0;i<n;i++) meanS+=agents[i].s; meanS=n?meanS/n:0.03;
  const cellN=clamp(meanS*0.6,0.005,0.08);
  const key=(x,y,z)=>(x+512)+(y+512)*1024+(z+512)*1048576;
  if(edges.length){
    adjStart=new Int32Array(n+1); adjIdx=new Int32Array(edges.length);
    const cnt=new Int32Array(n);
    for(let e=0;e<edges.length;e++) cnt[edges[e]]++;
    for(let i=0;i<n;i++) adjStart[i+1]=adjStart[i]+cnt[i];
    const cur=adjStart.slice(0,n);
    for(let e=0;e<edges.length;e+=2){
      const i=edges[e], j=edges[e+1];
      adjIdx[cur[i]++]=j; adjIdx[cur[j]++]=i;
    }
  }else{
    adjStart=new Int32Array(n+1); adjIdx=new Int32Array(0);
  }
  let meanA=0; for(let i=0;i<n;i++) meanA+=agents[i].s||0.02; meanA=n?meanA/n:0.02;
  const mpair=mirrorOn()?mirrorPairs(agents,meanA*0.45):null;
  const keepSym=()=>{
    if(!mpair) return;
    for(let i=0;i<n;i++){
      const j=mpair[i];
      if(j<0) continue;
      if(j===i){ px[i]=0; continue; }
      if(j<i) continue;
      const x=(px[i]-px[j])*0.5, y=(py[i]+py[j])*0.5, z=(pz[i]+pz[j])*0.5;
      px[i]=x; py[i]=y; pz[i]=z;
      px[j]=-x; py[j]=y; pz[j]=z;
      const dxs=(dx[i]-dx[j])*0.5, dys=(dy[i]+dy[j])*0.5, dzs=(dz[i]+dz[j])*0.5;
      dx[i]=dxs; dy[i]=dys; dz[i]=dzs;
      dx[j]=-dxs; dy[j]=dys; dz[j]=dzs;
    }
  };
  const grid=new Map();
  const f=new V3(), q=new V3(), pp=new V3();
  const near=[];
  for(let k=1;k<=S;k++){
    for(let i=0;i<n;i++){
      if(stepLen[i]<=0) continue;
      const a=agents[i];
      pp.set(px[i],py[i],pz[i]);
      if(mirrorOn()&&pp.x<0){ q.set(-pp.x,pp.y,pp.z); curl(q,f); f.x=-f.x; } else curl(pp,f);
      let ux=dx[i]+f.x*P.wobble+a.nx*P.straighten;
      let uy=dy[i]+f.y*P.wobble+a.ny*P.straighten;
      let uz=dz[i]+f.z*P.wobble+a.nz*P.straighten;
      const l=Math.hypot(ux,uy,uz);
      if(l>1e-9){ ux/=l; uy/=l; uz/=l; } else { ux=a.nx; uy=a.ny; uz=a.nz; }
      ax[i]=ux; ay[i]=uy; az[i]=uz;
    }
    for(let i=0;i<n;i++){
      if(stepLen[i]<=0) continue;
      let ux=ax[i],uy=ay[i],uz=az[i];
      const s0=adjStart[i], s1=adjStart[i+1];
      if(s1>s0){
        if(P.align>0){
          let sx=0,sy=0,sz=0;
          for(let m=s0;m<s1;m++){ const j=adjIdx[m]; sx+=ax[j]; sy+=ay[j]; sz+=az[j]; }
          const c=s1-s0; sx/=c; sy/=c; sz/=c;
          ux+=(sx-ux)*P.align; uy+=(sy-uy)*P.align; uz+=(sz-uz)*P.align;
        }
        if(P.cohesion>0){
          let sx=0,sy=0,sz=0;
          for(let m=s0;m<s1;m++){ const j=adjIdx[m]; sx+=px[j]; sy+=py[j]; sz+=pz[j]; }
          const c=s1-s0; sx=sx/c-px[i]; sy=sy/c-py[i]; sz=sz/c-pz[i];
          const ll=Math.hypot(sx,sy,sz);
          if(ll>1e-9){ ux+=sx/ll*P.cohesion; uy+=sy/ll*P.cohesion; uz+=sz/ll*P.cohesion; }
        }
      }
      const l=Math.hypot(ux,uy,uz);
      if(l>1e-9){ ux/=l; uy/=l; uz/=l; }
      dx[i]=ux; dy[i]=uy; dz[i]=uz;
      px[i]+=ux*stepLen[i]; py[i]+=uy*stepLen[i]; pz[i]+=uz*stepLen[i];
    }
    for(let it=0;it<(P.relaxIter|0);it++){
      grid.clear();
      for(let i=0;i<n;i++){
        const kk=key(Math.floor(px[i]/cellN),Math.floor(py[i]/cellN),Math.floor(pz[i]/cellN));
        let arr=grid.get(kk); if(!arr){arr=[];grid.set(kk,arr);} arr.push(i);
      }
      for(let i=0;i<n;i++){
        near.length=0;
        const gx=Math.floor(px[i]/cellN),gy=Math.floor(py[i]/cellN),gz=Math.floor(pz[i]/cellN);
        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) near.push(j);
        }
        for(const j of near){
          const wi=stepLen[i]>0?1:0, wj=stepLen[j]>0?1:0;
          if(!wi&&!wj) continue;
          const d0=Math.hypot(b0x[j]-b0x[i],b0y[j]-b0y[i],b0z[j]-b0z[i]);
          let minD=d0*P.sepKeep;
          if(minD>cellN) minD=cellN;
          let ex=px[j]-px[i], ey=py[j]-py[i], ez=pz[j]-pz[i];
          const d=Math.hypot(ex,ey,ez);
          if(d>=minD) continue;
          if(d<1e-9){ ex=agents[i].nx*1e-4; ey=agents[i].ny*1e-4; ez=agents[i].nz*1e-4; }
          else { ex/=d; ey/=d; ez/=d; }
          const push=(minD-d)*0.7/(wi+wj);
          if(wi){ px[i]-=ex*push; py[i]-=ey*push; pz[i]-=ez*push; }
          if(wj){ px[j]+=ex*push; py[j]+=ey*push; pz[j]+=ez*push; }
        }
      }
    }
    keepSym();
    for(let i=0;i<n;i++) agents[i].path.push(new V3(px[i],py[i],pz[i]));
  }
}

// ═══ 6 · Geometrie ═══
function addTube(pts,r0,r1,sides,pos,idx,tt,t0,t1){
  const N=pts.length; if(N<2) return;
  let len=0;
  for(let i=1;i<N;i++) len+=pts[i].distanceTo(pts[i-1]);
  if(len<1e-6) return;
  const F=frames(pts), base=pos.length/3;
  for(let i=0;i<N;i++){
    const t=i/(N-1), r=r0+(r1-r0)*t;
    const c=pts[i], nn=F.N[i], bb=F.B[i];
    const tv=(t0===undefined)?t:(t0+(t1-t0)*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);
      if(tt) tt.push(tv);
    }
  }
  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);
  }
}
function addWall(A,B,t,pos,idx,tt){
  const N=A.length; if(N<2) return;
  let h=0;
  for(let i=1;i<N;i++) h+=A[i].distanceTo(A[i-1])+B[i].distanceTo(B[i-1]);
  if(h<1e-5) return;
  const base=pos.length/3;
  if(t<=1e-6){
    for(let i=0;i<N;i++){
      pos.push(A[i].x,A[i].y,A[i].z, B[i].x,B[i].y,B[i].z);
      if(tt){ const u=i/(N-1); tt.push(u,u); }
    }
    for(let i=0;i<N-1;i++){ const a=base+i*2; idx.push(a,a+1,a+3, a,a+3,a+2); }
    return;
  }
  const e=new V3(), g=new V3(), nn=new V3(), tmp=new V3(), half=t*0.5;
  for(let i=0;i<N;i++){
    const i0=Math.max(0,i-1), i1=Math.min(N-1,i+1);
    e.subVectors(B[i],A[i]);
    g.subVectors(A[i1],A[i0]); tmp.subVectors(B[i1],B[i0]); g.add(tmp);
    nn.crossVectors(e,g);
    if(nn.lengthSq()<1e-14) nn.set(0,1,0); else nn.normalize();
    pos.push(A[i].x+nn.x*half,A[i].y+nn.y*half,A[i].z+nn.z*half,
             B[i].x+nn.x*half,B[i].y+nn.y*half,B[i].z+nn.z*half,
             B[i].x-nn.x*half,B[i].y-nn.y*half,B[i].z-nn.z*half,
             A[i].x-nn.x*half,A[i].y-nn.y*half,A[i].z-nn.z*half);
    if(tt){ const u=i/(N-1); tt.push(u,u,u,u); }
  }
  for(let i=0;i<N-1;i++) for(let j=0;j<4;j++){
    const a=base+i*4+j, b=base+i*4+(j+1)%4;
    idx.push(a,b,b+4, a,b+4,a+4);
  }
  const last=base+(N-1)*4;
  idx.push(base,base+2,base+1, base,base+3,base+2);
  idx.push(last,last+1,last+2, last,last+2,last+3);
}
function tissueMat(){
  return new THREE.MeshStandardMaterial({color:0xffffff,vertexColors:true,
    roughness:0.45,metalness:0,side:THREE.DoubleSide});
}
let tissueT=null;                                   // Wuchsanteil je Vertex, für den Farbverlauf
const _cA=new THREE.Color(), _cB=new THREE.Color();
function gradientColors(tArr){
  _cA.set(P.sphereColor); _cB.set(P.tipColor);
  const c=new Float32Array(tArr.length*3);
  for(let i=0;i<tArr.length;i++){
    const t=clamp(tArr[i],0,1);
    c[i*3  ]=_cA.r+(_cB.r-_cA.r)*t;
    c[i*3+1]=_cA.g+(_cB.g-_cA.g)*t;
    c[i*3+2]=_cA.b+(_cB.b-_cA.b)*t;
  }
  return c;
}
function refreshTissueColors(){
  if(tissue&&tissueT){
    tissue.geometry.setAttribute('color',new THREE.Float32BufferAttribute(gradientColors(tissueT),3));
  }
  if(joints&&jointList.length){
    _cA.set(P.sphereColor); _cB.set(P.tipColor);
    const col=new THREE.Color();
    jointList.forEach((j,i)=>{
      const t=clamp(j.t||0,0,1);
      col.setRGB(_cA.r+(_cB.r-_cA.r)*t,_cA.g+(_cB.g-_cA.g)*t,_cA.b+(_cB.b-_cA.b)*t);
      joints.setColorAt(i,col);
    });
    if(joints.instanceColor) joints.instanceColor.needsUpdate=true;
  }
}
// ── Vorschau: flache Flächen und Linien, schnell
function buildPreview(){
  preGroup.clear();
  if(preFaces){ preFaces.geometry.dispose(); preFaces.material.dispose(); preFaces=null; }
  if(preLines){ preLines.geometry.dispose(); preLines.material.dispose(); preLines=null; }
  if(!agents.length) return;
  const S=Math.max(1,P.growSteps|0);
  const pos=[], idx=[], lin=[], tt=[];
  for(let e=0;e<edges.length;e+=2){
    const A=agents[edges[e]], B=agents[edges[e+1]];
    addWall(A.path,B.path,0,pos,idx,tt);
    const a=A.path[S], b=B.path[S];
    lin.push(a.x,a.y,a.z,b.x,b.y,b.z);
    const a0=A.path[0], b0=B.path[0];
    lin.push(a0.x,a0.y,a0.z,b0.x,b0.y,b0.z);
  }
  for(const a of agents){
    if(a.h<=1e-4) continue;
    for(let k=0;k<S;k++){
      const p=a.path[k], q=a.path[k+1];
      lin.push(p.x,p.y,p.z,q.x,q.y,q.z);
    }
  }
  if(pos.length){
    const g=new THREE.BufferGeometry();
    g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
    g.setIndex((pos.length/3)>65535?new THREE.Uint32BufferAttribute(idx,1):new THREE.Uint16BufferAttribute(idx,1));
    g.computeVertexNormals(); g.computeBoundingSphere();
    g.setAttribute('color',new THREE.Float32BufferAttribute(gradientColors(Float32Array.from(tt)),3));
    preFaces=new THREE.Mesh(g,new THREE.MeshStandardMaterial({color:0xffffff,vertexColors:true,
      roughness:0.7,metalness:0,side:THREE.DoubleSide,transparent:true,opacity:0.35,depthWrite:false}));
    preFaces.frustumCulled=false; preGroup.add(preFaces);
  }
  if(lin.length){
    const g=new THREE.BufferGeometry();
    g.setAttribute('position',new THREE.Float32BufferAttribute(lin,3));
    preLines=new THREE.LineSegments(g,new THREE.LineBasicMaterial({color:0x111111,transparent:true,opacity:0.75}));
    preLines.frustumCulled=false; preGroup.add(preLines);
  }
}
// ── Mesh: Wände mit Stärke, Adern, Knoten
async function buildMesh(){
  if(busy||!agents.length) return;
  busy=true;
  try{
    clearMeshOnly();
    await step('Mesh: Wände',0.1);
    const S=Math.max(1,P.growSteps|0), L=Math.max(1,P.levels|0), sides=Math.max(3,P.tubeSides|0);
    const pos=[], idx=[], tt=[];
    const lvl=[];
    for(let l=0;l<L;l++) lvl.push({f:(l+1)/L, k:Math.max(1,Math.round((l+1)/L*S))});
    if(P.showWalls){
      for(let e=0;e<edges.length;e+=2){
        const A=agents[edges[e]], B=agents[edges[e+1]];
        addWall(A.path,B.path,P.wallThick,pos,idx,tt);
        if((e&2047)===0) await step('Mesh: Wände',0.1+0.4*e/Math.max(1,edges.length));
      }
    }
    if(P.capTop&&loops.length){
      for(const lp of loops){
        if(lp.length<3) continue;
        const base=pos.length/3;
        let ok=true;
        for(const ai of lp){ const p=agents[ai].path[S]; if(agents[ai].h<=1e-4) ok=false; pos.push(p.x,p.y,p.z); tt.push(1); }
        if(!ok){ pos.length=base*3; tt.length=base; continue; }
        for(let k=1;k<lp.length-1;k++) idx.push(base,base+k,base+k+1);
      }
    }
    await step('Mesh: Adern',0.55);
    if(P.showVeins){
      for(const a of agents){
        if(a.h<=1e-4) continue;
        addTube(a.path,veinR(a,0),veinR(a,1),sides,pos,idx,tt,0,1);
      }
      for(const {f,k} of lvl){
        for(let e=0;e<edges.length;e+=2){
          const A=agents[edges[e]], B=agents[edges[e+1]];
          addTube([A.path[k],B.path[k]],veinR(A,f),veinR(B,f),sides,pos,idx,tt,f,f);
        }
        await step('Mesh: Adern',0.55+0.3*(f));
      }
    }
    jointList=[];
    if(P.showJoints&&P.showVeins){
      const seen=new Set();
      for(const a of agents){
        seen.clear();
        const put=(k,f)=>{
          if(seen.has(k)) return; seen.add(k);
          const p=a.path[k];
          jointList.push({x:p.x,y:p.y,z:p.z,r:veinR(a,f)*P.jointScale,t:f});
        };
        put(0,0);
        if(a.h>1e-4) for(const {f,k} of lvl) put(k,f);
      }
    }
    await step('Mesh: zusammenbauen',0.92);
    const mat=tissueMat();
    if(pos.length){
      const geo=new THREE.BufferGeometry();
      geo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
      geo.setIndex((pos.length/3)>65535?new THREE.Uint32BufferAttribute(idx,1):new THREE.Uint16BufferAttribute(idx,1));
      geo.computeVertexNormals(); geo.computeBoundingSphere();
      tissueT=Float32Array.from(tt);
      geo.setAttribute('color',new THREE.Float32BufferAttribute(gradientColors(tissueT),3));
      tissue=new THREE.Mesh(geo,mat); tissue.frustumCulled=false; tisGroup.add(tissue);
    }
    if(jointList.length){
      const jg=new THREE.IcosahedronGeometry(1,P.sphereDetail|0);
      const im=new THREE.InstancedMesh(jg,mat,jointList.length);
      im.frustumCulled=false;
      const mtx=new THREE.Matrix4(), q=new QT(), sc=new V3(), pv=new V3();
      jointList.forEach((j,i)=>{ pv.set(j.x,j.y,j.z); sc.setScalar(j.r); mtx.compose(pv,q,sc); im.setMatrixAt(i,mtx); });
      im.instanceMatrix.needsUpdate=true;
      im.instanceColor=new THREE.InstancedBufferAttribute(new Float32Array(jointList.length*3),3);
      joints=im; tisGroup.add(im);
      refreshTissueColors();
    }
    P.showPreview=false; syncUI();
    setStatus('Mesh gebaut.');
  } finally { busy=false; progressEnd(); }
}
function tissueTris(){
  let t=tissue?tissue.geometry.index.count/3:0;
  if(joints) t+=jointList.length*20*Math.pow(4,P.sphereDetail|0);
  return t;
}
// ── Gesamtlauf
async function generate(){
  if(busy) return;
  if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
  busy=true;
  try{
    clearTissue();
    await step('Samen setzen',0.02);
    buildTriGrid();
    if(!sampleSeeds()){ setStatus('Ebene 1 ist leer.'); return; }
    await step('Samen setzen · '+seeds.length,0.06);
    await relaxSeeds();
    await step('Zellen aufbauen',0.74);
    buildTopology();
    if(!agents.length){ setStatus('Keine Zellen.'); return; }
    await step('Wachstum',0.85);
    growAgents();
    await step('Vorschau',0.95);
    buildPreview();
    P.showPreview=true; syncUI();
    setStatus('Struktur bereit.');
  } 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 mergedJointGeometry(){
  if(!jointList.length) return null;
  const base=new THREE.IcosahedronGeometry(1,P.sphereDetail|0);
  const bp=base.attributes.position, bn=base.attributes.normal;
  const vc=bp.count;
  const bi=base.index?base.index.array:(()=>{const a=new Uint32Array(vc);for(let i=0;i<vc;i++)a[i]=i;return a;})();
  const ic=bi.length, nS=jointList.length;
  const pos=new Float32Array(nS*vc*3), nor=new Float32Array(nS*vc*3);
  const idx=(nS*vc>65535)?new Uint32Array(nS*ic):new Uint16Array(nS*ic);
  for(let s=0;s<nS;s++){
    const S=jointList[s], r=S.r, vo=s*vc;
    for(let i=0;i<vc;i++){
      pos[(vo+i)*3  ]=bp.getX(i)*r+S.x;
      pos[(vo+i)*3+1]=bp.getY(i)*r+S.y;
      pos[(vo+i)*3+2]=bp.getZ(i)*r+S.z;
      nor[(vo+i)*3  ]=bn.getX(i); nor[(vo+i)*3+1]=bn.getY(i); nor[(vo+i)*3+2]=bn.getZ(i);
    }
    for(let i=0;i<ic;i++) idx[s*ic+i]=bi[i]+vo;
  }
  base.dispose();
  const g=new THREE.BufferGeometry();
  g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3));
  g.setAttribute('normal',new THREE.Float32BufferAttribute(nor,3));
  g.setIndex(new THREE.BufferAttribute(idx,1));
  return g;
}
function exportPieces(){
  const out=[];
  if(tissue) out.push({name:'tissue',geo:tissue.geometry});
  const jg=mergedJointGeometry();
  if(jg) out.push({name:'joints',geo:jg});
  if(P.exportBody&&body) out.push({name:'body',geo:body.geometry});
  return out;
}
function exportOBJ(pieces){
  let out='# CELL_MESH_BODY\n', voff=1;
  for(const {name,geo} of pieces){
    const p=geo.attributes.position,n=geo.attributes.normal,ix=geo.index;
    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)}\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'}),'cell_mesh_body.obj');
}
async function exportGLTF(pieces,binary){
  if(!GLTFExporter){
    const m=await addon('exporters/GLTFExporter.js');
    if(!m){ setStatus('GLTFExporter fehlt.'); return; }
    GLTFExporter=m.GLTFExporter;
  }
  const root=new THREE.Group(); root.name='cell_mesh_body';
  for(const {name,geo} of pieces){
    const m=new THREE.Mesh(geo,new THREE.MeshStandardMaterial({
      color: name==='body'?0xdddddd:0xffffff,
      vertexColors: name!=='body'&&!!geo.attributes.color,
      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'}),'cell_mesh_body.glb');
    else download(new Blob([JSON.stringify(res,null,1)],{type:'model/gltf+json'}),'cell_mesh_body.gltf');
    setStatus('Export fertig.');
  },err=>{ console.error(err); setStatus('Export fehlgeschlagen.'); },{binary});
}
function doExport(){
  const pieces=exportPieces();
  if(!pieces.length){ setStatus('Nichts zu exportieren.'); return; }
  if(P.exportFormat==='obj') exportOBJ(pieces);
  else exportGLTF(pieces,P.exportFormat==='glb');
}
/** Kartendatei: eine PNG, 2R × R, links Ebene 1, rechts Ebene 2.
 *  Je Kachel: Rot = Helligkeit, Grün = Deckung, Blau frei, Alpha immer deckend
 *  (Alpha im Bild würde beim Zurücklesen durch die Canvas-Vorvervielfachung Werte verlieren). */
function paintMapCanvas(){
  const cv=document.createElement('canvas'); cv.width=R*2; cv.height=R;
  const ctx=cv.getContext('2d',{willReadFrequently:true});
  const img=ctx.createImageData(R*2,R);
  for(let y=0;y<R;y++) for(let x=0;x<R;x++){
    const src=(y*R+x)*4;
    const dA=(y*R*2+x)*4, dB=(y*R*2+R+x)*4;
    img.data[dA]=paintData[src];   img.data[dA+1]=paintData[src+1]; img.data[dA+2]=0; img.data[dA+3]=255;
    img.data[dB]=paintData[src+2]; img.data[dB+1]=paintData[src+3]; img.data[dB+2]=0; img.data[dB+3]=255;
  }
  ctx.putImageData(img,0,0);
  return cv;
}
function exportMapPNG(){
  if(!paintData) return;
  paintMapCanvas().toBlob(b=>{ download(b,'meshbodypaint.png'); setStatus('Karte gesichert.'); });
}
function applyMapImage(img,quiet){
  if(!paintData) return false;
  const two=img.width>=img.height*1.8;
  const w=two?img.width/2:img.width;
  const cv=document.createElement('canvas'); cv.width=R*(two?2:1); cv.height=R;
  const ctx=cv.getContext('2d',{willReadFrequently:true});
  ctx.clearRect(0,0,cv.width,cv.height);
  ctx.drawImage(img,0,0,cv.width,cv.height);
  const d=ctx.getImageData(0,0,cv.width,cv.height).data;
  if(two){
    for(let y=0;y<R;y++) for(let x=0;x<R;x++){
      const dst=(y*R+x)*4;
      const sA=(y*R*2+x)*4, sB=(y*R*2+R+x)*4;
      paintData[dst]=d[sA];   paintData[dst+1]=d[sA+1];
      paintData[dst+2]=d[sB]; paintData[dst+3]=Math.min(d[sB+1],d[sA+1]);
    }
  }else{
    const vo=vOff(), ao=aOff();
    for(let i=0;i<R*R;i++){
      const al=d[i*4+3];
      const v=al>0?(d[i*4]*0.299+d[i*4+1]*0.587+d[i*4+2]*0.114):0;
      paintData[i*4+vo]=v; paintData[i*4+ao]=al;
      if(P.layer===1&&paintData[i*4+3]>paintData[i*4+1]) paintData[i*4+3]=paintData[i*4+1];
    }
  }
  paintDirty=true; pushHistory();
  if(!quiet) setStatus(two?'Karte geladen.':'Ebene '+(P.layer+1)+' geladen.');
  return true;
}
function importMapPNG(file){
  if(!paintData){ setStatus('Erst ein Modell laden.'); return; }
  const url=URL.createObjectURL(file);
  const img=new Image();
  img.onload=()=>{ applyMapImage(img,false); URL.revokeObjectURL(url); };
  img.onerror=()=>{ setStatus('Bild konnte nicht gelesen werden.'); URL.revokeObjectURL(url); };
  img.src=url;
}
async function autoLoadMap(){
  if(!paintData) return false;
  try{
    const r=await fetch('./meshbodypaint.png',{cache:'no-store'});
    if(!r.ok) return false;
    const blob=await r.blob();
    const url=URL.createObjectURL(blob);
    const img=new Image();
    const ok=await new Promise(res=>{ img.onload=()=>res(true); img.onerror=()=>res(false); img.src=url; });
    URL.revokeObjectURL(url);
    if(!ok) return false;
    applyMapImage(img,true);
    resetHistory();
    setStatus('meshbodypaint.png geladen.');
    return true;
  }catch(e){ return false; }
}
// ═════════════════════════════════════════════════════════════════════════════
// 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); });
}
/** Graustufen-Regler: links weiß, rechts schwarz */
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);
}
const syncUI=()=>syncers.forEach(f=>f());

let layerNote=null;
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);
  if(layerNote) layerNote.textContent = i===0
    ? 'schwarz groß · weiß fein · leer nichts'
    : 'schwarz weit · weiß nah · leer flach';
  syncUI(); setStatus();
}
function setPoseMode(on){
  P.poseMode=on;
  document.getElementById('bPose').classList.toggle('on',on);
  if(on&&!srcMesh?.isSkinnedMesh){ setStatus('Kein Skelett.'); }
  else setStatus(on?'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',()=>{ 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(); setStatus(); });
  button(p,'Pose zurücksetzen',resetPose,true);

  const m=section('Auflösung',g);
  const sel=choice(m,'mapRes',[['512','512 × 512'],['1024','1024 × 1024'],['2048','2048 × 2048']]);
  sel.value=String(P.mapRes);
  sel.addEventListener('change',()=>{P.mapRes=parseInt(sel.value);});
  button(m,'Karte neu',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 · Zellgröße'],[1,'2 · Höhe']],()=>setLayer(P.layer));
  layerNote=document.createElement('p'); layerNote.className='note'; s.appendChild(layerNote);

  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 X','mirror');
  const note=document.createElement('p'); note.className='note';
  note.textContent='Ziehen malt · Umschalt dreht · Alt+Rad Radius';
  b.appendChild(note);
  const r=document.createElement('div');r.className='btn2';b.appendChild(r);
  button(r,'Ebene leeren',clearLayer,true);
  button(r,'Karte sichern',exportMapPNG,true);
  const mi=document.createElement('input');
  mi.type='file'; mi.accept='image/png,image/jpeg,image/webp'; mi.style.display='none';
  mi.addEventListener('change',()=>{ const f=mi.files[0]; if(f) importMapPNG(f); mi.value=''; });
  b.appendChild(mi);
  button(b,'Karte laden',()=>mi.click(),true);
}
// ── STRUKTUR
{
  const g=group('Struktur',true);
  const m=section('Muster',g);
  segmented(m,'mode',[['voronoi','Voronoi'],['delaunay','Delaunay'],['circle','Kreise']]);
  slider(m,'Regelmäßigkeit','regular',0,1,0.01,2);
  slider(m,'Zellspalt','gap',0,0.4,0.01,2);
  slider(m,'Kreispunkte','ringPoints',3,16,1,0);

  const s2=section('Packung · Ebene 1',g);
  slider(s2,'Minimum','rMin',0.001,0.04,0.0005,4);
  slider(s2,'Maximum','rMax',0.004,0.12,0.0005,4);
  slider(s2,'Gamma','gamma',0.2,4,0.05,2);
  slider(s2,'Streuung','jitter',0,0.6,0.01,2);
  slider(s2,'Packdichte','packing',0.1,1,0.01,2);
  slider(s2,'Kandidaten','samples',2000,250000,1000,0);
  slider(s2,'Dichte nach Wert','densityBias',0,1,0.01,2);
  slider(s2,'Schwelle Deckung','alphaMin',0.01,0.9,0.01,2);
  slider(s2,'Ausgleich','relax',0,30,1,0);
  slider(s2,'Seed','seed',1,999,1,0);
  button(s2,'Neu würfeln',()=>{P.seed=1+((Math.random()*998)|0);syncUI();generate();},true);

  const w=section('Wuchs · Ebene 2',g);
  slider(w,'Wuchshöhe','growHeight',0,0.5,0.005,3);
  slider(w,'Schritte','growSteps',1,16,1,0);
  slider(w,'Wobble','wobble',0,1.5,0.01,2);
  slider(w,'Feldskalierung','noiseScale',0.3,12,0.1,1);
  slider(w,'Aufrichtung','straighten',0,2,0.01,2);
  slider(w,'Gleichrichtung','align',0,1,0.01,2);
  slider(w,'Kohäsion','cohesion',0,1,0.01,2);
  slider(w,'Abstand halten','sepKeep',0,1.2,0.01,2);
  slider(w,'Entflechtung','relaxIter',0,6,1,0);

  const v=section('Wände & Adern',g);
  check(v,'Zellwände','showWalls');
  slider(v,'Wandstärke','wallThick',0,0.012,0.0002,4);
  check(v,'Deckel oben','capTop');
  check(v,'Adern','showVeins');
  slider(v,'Aderdicke','veinScale',0.01,0.4,0.002,3);
  slider(v,'Verjüngung','taper',0,0.9,0.01,2);
  slider(v,'Röhrenkanten','tubeSides',3,10,1,0);
  slider(v,'Ringe','levels',1,4,1,0);
  check(v,'Knotenkugeln','showJoints');
  slider(v,'Knotengröße','jointScale',0.8,2.5,0.05,2);
  slider(v,'Knotendetail','sphereDetail',0,3,1,0);
  const cw=document.createElement('div'); cw.className='btn2'; v.appendChild(cw);
  const c=document.createElement('input');c.type='color';c.value=P.sphereColor;
  c.title='Farbe an der Haut';
  c.addEventListener('input',()=>{ P.sphereColor=c.value; refreshTissueColors(); });
  const c2=document.createElement('input');c2.type='color';c2.value=P.tipColor;
  c2.title='Farbe an der Spitze';
  c2.addEventListener('input',()=>{ P.tipColor=c2.value; refreshTissueColors(); });
  cw.append(c,c2);
  const r=document.createElement('div');r.className='btn2';v.appendChild(r);
  button(r,'Erzeugen',generate);
  button(r,'Mesh bauen',buildMesh);
  button(v,'Löschen',clearTissue,true);
}
// ── ANSICHT
{
  const g=group('Ansicht',false);
  const s=section(null,g);
  check(s,'Körper','showBody');
  slider(s,'Deckkraft','ghost',0,1,0.01,2);
  check(s,'Malfläche zeigen','showPaint');
  check(s,'Gewebe','showTissue');
  check(s,'Vorschau','showPreview');
}
// ── EXPORT
{
  const g=group('Export',false);
  const s=section(null,g);
  choice(s,'exportFormat',[['glb','GLB'],['gltf','GLTF'],['obj','OBJ']]);
  check(s,'Körper mit','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('bDelSph').onclick=clearTissue;
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(`SAMEN <b>${seeds.length.toLocaleString('de-DE')}</b>`,
               `AGENTEN <b>${agents.length.toLocaleString('de-DE')}</b>`,
               `KANTEN <b>${(edges.length/2).toLocaleString('de-DE')}</b>`);
    const t=tissueTris();
    if(t) parts.push(`DREIECKE <b>${Math.round(t).toLocaleString('de-DE')}</b>`);
  }
  if(P.mirror&&poseAsym()) parts.push('<b>SPIEGELUNG AUS</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;
  }
  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&&mirrorOn();
  bonePts.visible=skelLines.visible=pose;
  selPt.visible=pose&&!!selectedBone;
  tisGroup.visible=P.showTissue;
  preGroup.visible=P.showPreview;
  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)
meshbodypaint.png  (947 KB)