FLUFFY_CELLULAR_ROOM

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/af095fc7b38279ca/" 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, maximum-scale=1">
<title>KAMMERBODEN</title>
<style>
  html,body{margin:0;height:100%;background:#fff;overflow:hidden;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;color:#222}
  canvas{display:block;touch-action:none}
  #panel{position:fixed;top:12px;left:12px;width:248px;max-height:calc(100% - 24px);overflow-y:auto;background:rgba(255,255,255,.96);border:1px solid #e8e8e8;border-radius:10px;padding:10px 12px 12px;box-sizing:border-box;z-index:5}
  #panel h1{font-size:11px;letter-spacing:.2em;font-weight:600;margin:0;display:flex;justify-content:space-between;align-items:center}
  #toggle{flex:none;width:24px;padding:0;border:none;background:none;font-size:15px;color:#999;cursor:pointer}
  .row{margin-top:9px}
  .lab{display:flex;justify-content:space-between;color:#888;font-size:11px}
  .lab b{font-weight:400;color:#222}
  input[type=range]{width:100%;margin:2px 0 0;accent-color:#999;height:18px}
  .seg{display:flex;gap:4px;margin-top:3px}
  button{font:inherit;font-size:11px;padding:5px 6px;background:#fff;border:1px solid #ddd;border-radius:6px;color:#222;cursor:pointer;flex:1}
  button.on{background:#f0f0f0;border-color:#bbb}
  button:active{background:#e8e8e8}
  #st{color:#999;font-size:10.5px;margin-top:6px;min-height:13px}
  .hint{color:#aaa;font-size:10.5px;margin-top:10px;line-height:1.45}
  #content.hide{display:none}
  .cols{display:flex;gap:6px;margin-top:4px}
  .cols label{flex:1;display:flex;flex-direction:column;gap:2px;color:#888;font-size:10.5px}
  input[type=color]{width:100%;height:24px;padding:0;border:1px solid #ddd;border-radius:6px;background:#fff;cursor:pointer}
  #drop{position:fixed;inset:0;background:rgba(255,255,255,.85);display:none;align-items:center;justify-content:center;font-size:13px;letter-spacing:.15em;color:#999;z-index:9}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="panel">
  <h1>KAMMERBODEN <button id="toggle" title="ein-/ausklappen">–</button></h1>
  <div id="content">
    <div class="row"><div class="lab">Körper</div>
      <div class="seg"><button id="bDummy" class="on">Dummy</button><button id="bGlb">GLB laden…</button></div>
      <input type="file" id="file" accept=".glb" style="display:none">
      <div id="st"></div></div>
    <div class="row"><div class="lab">Kamera</div>
      <div class="seg"><button id="bCam">Links ziehen = Kamera</button><button id="bTop">Von oben</button></div></div>
    <div class="row"><div class="lab">Ort</div>
      <div class="seg" id="space"><button data-s="floor" class="on">Fläche</button><button data-s="room">Raum</button><button data-s="cells">Zellraum</button></div></div>
    <div class="row" id="roomRow" style="display:none"><div class="lab">Raum- / Verbandsgröße <b id="o_roomsize">1.00</b></div>
      <input type="range" id="s_roomsize" min="0.7" max="1.6" step="0.05" value="1"></div>
    <div id="squeezeRows" style="display:none">
      <div class="row"><div class="lab">Verengung <b id="o_squeeze"></b></div><input type="range" id="s_squeeze" min="0" max="1" step="0.01" value="0.4"></div>
      <div class="row"><div class="lab">Raum atmet <b id="o_roomBreath"></b></div><input type="range" id="s_roomBreath" min="0" max="1" step="0.01" value="0.3"></div></div>
    <div id="cellRows" style="display:none">
      <div class="row"><div class="lab">Dichte des Verbands <b id="o_fill"></b></div><input type="range" id="s_fill" min="0" max="1" step="0.01" value="0.6"></div>
      <div class="row"><div class="lab">Anschmiegen <b id="o_hug"></b></div><input type="range" id="s_hug" min="0" max="1" step="0.01" value="0.6"></div>
      <div class="row"><div class="lab">Schub <b id="o_push"></b></div><input type="range" id="s_push" min="0" max="1.5" step="0.01" value="0.8"></div>
      <div class="row"><div class="lab">Richtung (°) <b id="o_dir"></b></div><input type="range" id="s_dir" min="0" max="360" step="1" value="90"></div>
      <div class="row"><div class="lab">Welle durch den Verband <b id="o_wave"></b></div><input type="range" id="s_wave" min="0" max="1.5" step="0.01" value="0.9"></div></div>
    <div class="row"><div class="lab">Modus</div>
      <div class="seg" id="modes"><button data-m="breath" class="on">Atmen</button><button data-m="answer">Antwort</button><button data-m="manual">Manuell</button></div></div>
    <div class="row"><div class="lab">Pinsel</div>
      <div class="seg" id="brush"><button data-b="inflate" class="on">Aufblasen</button><button data-b="deflate">Deflaten</button></div></div>
    <div id="sliders"></div>
    <div class="row"><div class="lab">Material</div>
      <div class="cols"><label>Flach<input type="color" id="c_colDef" value="#e9ebee"></label><label>Prall<input type="color" id="c_colInf" value="#ffccd8"></label><label>Körper<input type="color" id="c_colBody" value="#2b2d31"></label></div></div>
    <div id="matSliders"></div>
    <div class="row"><div class="lab">Mesh-Auflösung der Kammern <b id="o_meshRes"></b></div><input type="range" id="s_meshRes" min="1" max="4" step="1" value="2"></div>
    <div class="row"><div class="seg"><button id="bFacets" class="on">Facetten</button><button id="bCut" class="on">Anschnitt (Zellraum)</button></div></div>
    <div class="row"><div class="lab">Kammern <b id="o_cells"></b></div>
      <input type="range" id="cells" min="40" max="260" step="1" value="130">
      <div class="seg" style="margin-top:6px"><button id="bNew">Neu verteilen</button></div></div>
    <div class="row"><div class="seg"><button id="bPause">Pause</button><button id="bReset">Körper reset</button></div>
      <div class="seg" style="margin-top:4px"><button id="bFlat">Alle deflaten</button><button id="bFull">Alle auf</button></div></div>
    <div class="hint">Am Körper ziehen: greifen · Kammern anklicken/streichen: aufblasen/deflaten · Rechte Maus: orbit · Shift+rechte Maus / Mitteltaste: verschieben · Mausrad: zoomen (zum Cursor) · Pfeiltasten: verschieben · GLB auch per Drag&amp;Drop</div>
  </div>
</div>
<div id="drop">GLB HIER ABLEGEN</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';

// ==SIM-START==
// ---------------------------------------------------------------
// Simulationskern (kein THREE): Power-Diagramm-Kammerflächen + Membran-Solver + geglättete Kontaktfläche + Verlet-Ragdoll
// ---------------------------------------------------------------
const ARENA_SEG = 48, HALF_PI = Math.PI / 2;
const GRID = 0.12, LAM = 1.5, STIFF = 90, WAVE = 260, DENT_DAMP = 5, BLUR = 3;
const P = { gravity: 3.5, friction: 0.12, height: 1.1, inflateRate: 1.2, deflateRate: 2, lift: 0.3, upBias: 0.5, maxSpeed: 2.5, contactK: 30, bodyScale: 1,
            flow: 0.4, reaction: 0.9, tempo: 0.5, cells: 130, round: 0.75, vari: 0.7, soft: 0.7, smooth: 0.6, room: 1, squeeze: 0.4, roomBreath: 0.3, hug: 0.6, wave: 0.9, fill: 0.6, push: 0.8, dir: 90, meshRes: 2,
            colDef: '#e9ebee', colInf: '#ffccd8', colBody: '#2b2d31', rough: 0.9, metal: 0, coat: 0, sheen: 0, opacity: 1, facets: true, cut: true,
            space: 'floor', mode: 'breath', brush: 'inflate' };

function mulberry32(a){ return function(){ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; }
function hash3(x,y,z){ let h=Math.imul(x,374761393)^Math.imul(y,668265263)^Math.imul(z,1274126177); h=Math.imul(h^(h>>>13),1103515245); h^=h>>>16; return (h>>>0)/4294967295; }
const sm = t => t*t*(3-2*t);
const lerp = (a,b,t) => a+(b-a)*t;
const clamp = (x,a,b) => x<a?a:(x>b?b:x);
const sstep = (a,b,x) => { const t=Math.min(1,Math.max(0,(x-a)/(b-a))); return t*t*(3-2*t); };
function vnoise(x,y,z){
  const xi=Math.floor(x), yi=Math.floor(y), zi=Math.floor(z);
  const xf=sm(x-xi), yf=sm(y-yi), zf=sm(z-zi);
  return lerp(
    lerp(lerp(hash3(xi,yi,zi),hash3(xi+1,yi,zi),xf), lerp(hash3(xi,yi+1,zi),hash3(xi+1,yi+1,zi),xf), yf),
    lerp(lerp(hash3(xi,yi,zi+1),hash3(xi+1,yi,zi+1),xf), lerp(hash3(xi,yi+1,zi+1),hash3(xi+1,yi+1,zi+1),xf), yf), zf);
}

// ---- Power-Diagramm (Voronoi mit Gewichten = unterschiedlich große Kammern)

function clipPoly(poly,nx,nz,c,tag){
  const out=[], n=poly.length;
  for(let i=0;i<n;i++){
    const A=poly[i], B=poly[(i+1)%n];
    const da=nx*A.x+nz*A.z-c, db=nx*B.x+nz*B.z-c;
    const ia=da<=0, ib=db<=0;
    if(ia){
      out.push({x:A.x,z:A.z,tag:A.tag});
      if(!ib){ const t=da/(da-db); out.push({x:A.x+(B.x-A.x)*t, z:A.z+(B.z-A.z)*t, tag}); }
    } else if(ib){
      const t=da/(da-db); out.push({x:A.x+(B.x-A.x)*t, z:A.z+(B.z-A.z)*t, tag:A.tag});
    }
  }
  return out;
}
function polyCentroid(poly){
  let A=0,cx=0,cz=0; const n=poly.length;
  if(n<3) return {x:poly[0]?poly[0].x:0, z:poly[0]?poly[0].z:0, area:0};
  for(let k=0;k<n;k++){ const a=poly[k], b=poly[(k+1)%n]; const cr=a.x*b.z-b.x*a.z; A+=cr; cx+=(a.x+b.x)*cr; cz+=(a.z+b.z)*cr; }
  A*=0.5; if(Math.abs(A)<1e-9) return {x:poly[0].x, z:poly[0].z, area:0};
  return {x:cx/(6*A), z:cz/(6*A), area:Math.abs(A)};
}
// Abstand zur Kammerkante; mit Rundung zusätzlich Abstand zu einem Kreis um den Schwerpunkt
function edgeDist(F,i,x,z){
  const sx=F.sx, sz=F.sz, w2=F.w2, n=F.n;
  const dx=x-sx[i], dz=z-sz[i], di=dx*dx+dz*dz-w2[i];
  const nb=F.nb[i].idx; let m=1e9;
  for(let k=0;k<nb.length;k++){
    const j=nb[k], ex=x-sx[j], ez=z-sz[j];
    const v=(ex*ex+ez*ez-w2[j]-di)/(2*F.D[i*n+j]);
    if(v<m) m=v;
  }
  if(P.round>0.01){
    const rk=lerp(2.2,0.9,P.round), c=F.rr[i]*rk-Math.hypot(x-F.cx[i],z-F.cz[i]);
    if(c<m) m=c;
  }
  return m<0?0:m;
}

// ---- Gebiet einer Kammerfläche: Scheibe (Boden) oder Rechteck (Raumwände)
function domainPoly(S){
  if(S.type==='disk'){ const p=[]; for(let k=0;k<ARENA_SEG;k++){ const a=-2*Math.PI*k/ARENA_SEG; p.push({x:S.R*Math.cos(a), z:S.R*Math.sin(a), tag:-1}); } return p; }
  return [{x:-S.hu,z:-S.hv,tag:-1},{x:S.hu,z:-S.hv,tag:-1},{x:S.hu,z:S.hv,tag:-1},{x:-S.hu,z:S.hv,tag:-1}];
}
function inDomain(S,x,z,m){ return S.type==='disk' ? x*x+z*z<(S.R+m)*(S.R+m) : (Math.abs(x)<S.hu+m && Math.abs(z)<S.hv+m); }
function domainArea(S){ return S.type==='disk' ? Math.PI*S.R*S.R : 4*S.hu*S.hv; }
function domainExt(S){ return S.type==='disk' ? S.R : Math.max(S.hu,S.hv); }

// ---- Power-Diagramm (Voronoi mit Gewichten = unterschiedlich große Kammern)
// Kanten tragen den Nachbar-Index; -1 = Rand
function cellPolygon(i,sx,sz,w2,S){
  let poly=domainPoly(S);
  for(let j=0;j<sx.length;j++){
    if(j===i) continue;
    const dx=sx[j]-sx[i], dz=sz[j]-sz[i];
    const c=(sx[j]*sx[j]+sz[j]*sz[j]-sx[i]*sx[i]-sz[i]*sz[i]-w2[j]+w2[i])/2;
    poly=clipPoly(poly,dx,dz,c,j);
    if(poly.length<3) break;
  }
  return poly;
}
function sampleSeeds(n,seed,vari,S){
  const rnd=mulberry32(seed), sg=vari*0.85, rmean=Math.sqrt(domainArea(S)*0.4/(Math.PI*n)), rs=[];
  for(let i=0;i<n;i++) rs.push(rmean*Math.exp((rnd()*2-1)*sg));
  rs.sort((a,b)=>b-a);
  const pts=[];
  for(const r of rs){
    for(let t=0;t<250;t++){
      let x, z;
      if(S.type==='disk'){ const a=rnd()*Math.PI*2, q=(S.R-r*0.5)*Math.sqrt(rnd()); x=q*Math.cos(a); z=q*Math.sin(a); }
      else { x=(rnd()*2-1)*Math.max(0.05,S.hu-r*0.5); z=(rnd()*2-1)*Math.max(0.05,S.hv-r*0.5); }
      let ok=true;
      for(const p of pts){ const dx=x-p.x, dz=z-p.z, m=0.9*(r+p.r); if(dx*dx+dz*dz<m*m){ ok=false; break; } }
      if(ok){ pts.push({x,z,r}); break; }
    }
  }
  return pts;
}
function computeW(F){
  for(let i=0;i<F.n;i++) F.W[i]=clamp(0.9*edgeDist(F,i,F.cx[i],F.cz[i]),0.1,0.8);
}
function nearestBrute(F,x,z){
  let bi=0,bd=1e18; for(let i=0;i<F.n;i++){ const dx=x-F.sx[i], dz=z-F.sz[i], d=dx*dx+dz*dz-F.w2[i]; if(d<bd){bd=d;bi=i;} } return bi;
}
function buildGrid(F){
  const G=GRID, E=domainExt(F.S), N=Math.ceil(2*E/G)+3, g0=-E-G, NN=N*N;
  F.G=G; F.gN=N; F.g0=g0;
  F.gCell=new Uint16Array(NN); F.gAct=new Uint8Array(NN); F.gFac=new Float32Array(NN); F.gH0=new Float32Array(NN);
  F.d=new Float32Array(NN); F.dp=new Float32Array(NN); F.dn=new Float32Array(NN);
  // Kontaktfläche: geglättetes Höhenfeld + Hubgeschwindigkeit
  F.Hs=new Float32Array(NN); F.Hb=new Float32Array(NN); F.Hc=new Float32Array(NN); F.tmp=new Float32Array(NN);
  F.b0=new Float32Array(NN); F.b0b=new Float32Array(NN); F.b0p=new Float32Array(NN); F.Vs=new Float32Array(NN); F.hasPrev=false;
  F.dmean=new Float32Array(F.n); F.gCount=new Float32Array(F.n);
  for(let j=0;j<N;j++) for(let i=0;i<N;i++){
    const k=i+j*N, x=g0+i*G, z=g0+j*G;
    F.gCell[k]=nearestBrute(F,x,z);
    if(inDomain(F.S,x,z,G)){ F.gAct[k]=1; F.gCount[F.gCell[k]]++; }
  }
}
function gridFactors(F){
  const N=F.gN;
  for(let j=0;j<N;j++) for(let i=0;i<N;i++){
    const k=i+j*N, c=F.gCell[k];
    F.gFac[k]=prof(Math.min(1,edgeDist(F,c,F.g0+i*F.G,F.g0+j*F.G)/F.W[c]));
  }
}
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
// Kammerfläche mit lokalem Rahmen: o = Ursprung, u = lokale x-Achse, nn = Normale (Kammern wachsen in diese Richtung), wv = lokale z-Achse
function buildField(n,seed,vari,S,o,u,nn,id){
  let pts=sampleSeeds(n,seed,vari,S), polys=null, sx, sz, w2;
  for(let tr=0;tr<6;tr++){
    const m=pts.length;
    sx=Float64Array.from(pts,p=>p.x); sz=Float64Array.from(pts,p=>p.z); w2=Float64Array.from(pts,p=>p.r*p.r);
    polys=[]; const keep=[];
    for(let i=0;i<m;i++){ const poly=cellPolygon(i,sx,sz,w2,S); polys.push(poly); keep.push(poly.length>=3 && polyCentroid(poly).area>0.01); }
    if(keep.every(Boolean)) break;
    pts=pts.filter((_,i)=>keep[i]);
  }
  const N=pts.length;
  const F={ id, S, o, u, nn, wv:cross(u,nn), nox:id*23.7, noz:id*11.3, su:1, sv:1, pv:0, role:'floor',
    n:N, sx, sz, w2, polys, cx:new Float64Array(N), cz:new Float64Array(N), area:new Float64Array(N),
    rr:new Float64Array(N), hf:new Float32Array(N), W:new Float64Array(N), nb:[], D:new Float32Array(N*N), V:new Float32Array(N),
    ovT:new Float32Array(N), ovUntil:new Float64Array(N), load:new Float32Array(N), fat:new Float32Array(N),
    contact:new Float32Array(N), cacc:new Float32Array(N), tgt:new Float32Array(N), areaRel:new Float32Array(N) };
  let atot=0;
  for(let i=0;i<N;i++){
    const c=polyCentroid(polys[i]); F.cx[i]=c.x; F.cz[i]=c.z; F.area[i]=c.area; atot+=c.area;
    F.rr[i]=Math.sqrt(c.area/Math.PI); F.hf[i]=clamp(F.rr[i]/0.5,0.45,1.3);
    for(let j=0;j<N;j++) F.D[i*N+j]=Math.hypot(sx[i]-sx[j], sz[i]-sz[j]);
  }
  for(let i=0;i<N;i++){
    const poly=polys[i], m=poly.length, map=new Map();
    for(let k=0;k<m;k++){
      const tg=poly[k].tag; if(tg<0) continue;
      const b=poly[(k+1)%m]; map.set(tg,(map.get(tg)||0)+Math.hypot(b.x-poly[k].x,b.z-poly[k].z));
    }
    F.nb.push({ idx:[...map.keys()], len:[...map.values()] });
    F.areaRel[i]=F.area[i]/(atot/N);
  }
  computeW(F); buildGrid(F); gridFactors(F);
  return F;
}
function reshapeFloor(F){ computeW(F); gridFactors(F); }

// Welt: 'floor' = runde Fläche, 'room' = Raum aus 6 Kammerflächen, 'cells' = Zellraum (3D-Power-Voronoi)
const DISK_R=3.6, DISK_AREA=Math.PI*DISK_R*DISK_R;
// Raum: Verengung staucht die Flächen (Kammern rücken zusammen), Wände/Decke wandern nach innen
function roomDims(W,sq){ return { aE:W.a*(1-0.45*sq), hE:W.hr*(1-0.38*sq) }; }
function placeRoom(W,dt){
  const tgt=clamp(P.squeeze+P.roomBreath*0.45*Math.sin(W.phase),0,1);
  if(dt>0){ W.phase+=dt*(0.25+P.tempo*0.5)*0.35; const d=tgt-W.sq, mx=0.06*dt; W.sq+=d>mx?mx:(d<-mx?-mx:d); } else W.sq=tgt;
  const {aE,hE}=roomDims(W,W.sq), a=W.a, hr=W.hr;
  for(const F of W.fields){
    let o, su, sv;
    switch(F.role){
      // Wände wandern als Ganzes zum Körper (keine Stauchung -> Kammern behalten ihre Form)
      case 'floor': o=[0,0,0]; break;
      case 'ceil':  o=[0,hE,0]; break;
      case 'wx+': o=[aE,hr/2,0]; break;
      case 'wx-': o=[-aE,hr/2,0]; break;
      case 'wz+': o=[0,hr/2,aE]; break;
      case 'wz-': o=[0,hr/2,-aE]; break;
    }
    // Geschwindigkeit der Fläche entlang ihrer Normale (für die Kontaktdämpfung)
    if(dt>0){ const dx=o[0]-F.o[0], dy=o[1]-F.o[1], dz=o[2]-F.o[2]; F.pv=(dx*F.nn[0]+dy*F.nn[1]+dz*F.nn[2])/dt; } else F.pv=0;
    F.o=o; F.su=1; F.sv=1;
  }
  W.bound.a=aE; W.bound.hr=hE;
}
function buildWorld(space,cells,seed,vari){
  if(space==='cells') return buildCellWorld(cells,seed,vari);
  const fields=[];
  if(space==='room'){
    const a=2.0*P.room, hr=2.8*P.room, hh=hr/2;
    const defs=[
      ['floor',{type:'rect',hu:a,hv:a},[0,0,0],[1,0,0],[0,1,0]],
      ['ceil',{type:'rect',hu:a,hv:a},[0,hr,0],[1,0,0],[0,-1,0]],
      ['wx+',{type:'rect',hu:a,hv:hh},[a,hh,0],[0,0,1],[-1,0,0]],
      ['wx-',{type:'rect',hu:a,hv:hh},[-a,hh,0],[0,0,1],[1,0,0]],
      ['wz+',{type:'rect',hu:a,hv:hh},[0,hh,a],[1,0,0],[0,0,-1]],
      ['wz-',{type:'rect',hu:a,hv:hh},[0,hh,-a],[1,0,0],[0,0,1]]];
    defs.forEach(([role,S,o,u,nn],k)=>{ const n=Math.max(8,Math.round(cells*domainArea(S)/DISK_AREA)); const F=buildField(n,seed+k*101,vari,S,o,u,nn,k); F.role=role; fields.push(F); });
    const W={ space, fields, c3:null, bound:{type:'box', a, hr}, a, hr, ext:Math.max(a,hh), sq:0, phase:0 };
    placeRoom(W,0);
    return W;
  }
  fields.push(buildField(cells,seed,vari,{type:'disk',R:DISK_R},[0,0,0],[1,0,0],[0,1,0],0));
  return { space, fields, c3:null, bound:{type:'disk', R:DISK_R}, ext:DISK_R };
}

// ---- Höhenfeld: Höhe = Hmax * V * prof(t), t = Abstand zur Kammerkante / Kammerbreite, plus Membran-Delle
let QC=0, QH=0, QNX=0, QNY=1, QNZ=0, QVS=0;
// Kissenprofil: steile, weiche Flanke, breite runde Kuppe (keine Spitzen)
function prof(t){ const u=1-t; return 1-u*u; }   // parabolische Kuppe: rund oben, keine Spitze
function nearestCell(F,x,z){
  let gi=Math.round((x-F.g0)/F.G), gj=Math.round((z-F.g0)/F.G);
  gi=clamp(gi,0,F.gN-1); gj=clamp(gj,0,F.gN-1);
  let c=F.gCell[gi+gj*F.gN]; const sx=F.sx, sz=F.sz, w2=F.w2;
  let dx=x-sx[c], dz=z-sz[c], best=dx*dx+dz*dz-w2[c];
  for(let it=0;it<8;it++){
    let moved=false; const nb=F.nb[c].idx;
    for(let k=0;k<nb.length;k++){ const j=nb[k]; dx=x-sx[j]; dz=z-sz[j]; const d=dx*dx+dz*dz-w2[j]; if(d<best){ best=d; c=j; moved=true; } }
    if(!moved) break;
  }
  return c;
}
function bil(F,A,x,z){
  const N=F.gN, u=(x-F.g0)/F.G, v=(z-F.g0)/F.G;
  let i=Math.floor(u), j=Math.floor(v); i=i<0?0:(i>N-2?N-2:i); j=j<0?0:(j>N-2?N-2:j);
  let fu=u-i, fv=v-j; fu=fu<0?0:(fu>1?1:fu); fv=fv<0?0:(fv>1?1:fv);
  const k=i+j*N;
  return (A[k]*(1-fu)+A[k+1]*fu)*(1-fv)+(A[k+N]*(1-fu)+A[k+N+1]*fu)*fv;
}
function dentAt(F,x,z){ return bil(F,F.d,x,z); }
// exakte (kantige) Oberfläche – fürs Rendern
function surfaceH(F,c,h0,x,z){
  if(h0<=0) return 0;
  const h=h0+dentAt(F,x,z)-LAM*F.dmean[c];
  return h<0?0:h;
}
// geglättete Kontaktfläche: Täler zwischen Kammern werden weich ausgerundet (wie eine rollende Kugel sie spürt)
function blurGrid(F,src,dst,passes){
  const N=F.gN, t=F.tmp;
  dst.set(src);
  for(let p=0;p<passes;p++){
    for(let j=0;j<N;j++){ const r=j*N; t[r]=dst[r]; t[r+N-1]=dst[r+N-1]; for(let i=1;i<N-1;i++) t[r+i]=(dst[r+i-1]+2*dst[r+i]+dst[r+i+1])*0.25; }
    for(let i=0;i<N;i++){ dst[i]=t[i]; dst[(N-1)*N+i]=t[(N-1)*N+i]; }
    for(let j=1;j<N-1;j++){ const r=j*N; for(let i=0;i<N;i++) dst[r+i]=(t[r+i-N]+2*t[r+i]+t[r+i+N])*0.25; }
  }
}
function updateSurface(F,h){
  const NN=F.gN*F.gN, Hs=F.Hs, Hb=F.Hb, Hc=F.Hc, act=F.gAct, cell=F.gCell, d=F.d, b0=F.b0, g0=F.gH0, dm=F.dmean;
  for(let k=0;k<NN;k++){
    if(!act[k]){ Hs[k]=0; b0[k]=0; continue; }
    const h0=g0[k]; b0[k]=h0;
    if(h0<=0){ Hs[k]=0; continue; }
    const v=h0+d[k]-LAM*dm[cell[k]]; Hs[k]=v<0?0:v;
  }
  blurGrid(F,Hs,Hb,BLUR);
  for(let k=0;k<NN;k++){ const a=Hs[k], b=Hb[k], df=a-b; Hc[k]=(a+b+Math.sqrt(df*df+0.0004))*0.5; } // weiches Maximum: Kuppen bleiben, Täler füllen sich
  blurGrid(F,b0,F.b0b,BLUR);
  const bb=F.b0b, bp=F.b0p, Vs=F.Vs, ih=1/h;
  if(F.hasPrev){ for(let k=0;k<NN;k++) Vs[k]=(bb[k]-bp[k])*ih; } else Vs.fill(0);
  bp.set(bb); F.hasPrev=true;
}
// Abfrage im lokalen Rahmen: Höhe, Normale, Hubgeschwindigkeit der geglätteten Fläche
function fieldQuery(F,x,z){
  const e=F.G, A=F.Hc;
  QH=bil(F,A,x,z);
  const hx=(bil(F,A,x+e,z)-bil(F,A,x-e,z))/(2*e*F.su), hz=(bil(F,A,x,z+e)-bil(F,A,x,z-e))/(2*e*F.sv);
  const l=Math.sqrt(hx*hx+1+hz*hz); QNX=-hx/l; QNY=1/l; QNZ=-hz/l;
  QVS=bil(F,F.Vs,x,z);
}
// Kontaktkraft drückt die Membran ein (Beschleunigung nach innen)
function dentForce(F,x,z,fn,rad,h){
  const G=F.G, N=F.gN, g0=F.g0, hh=h*h;
  const i0=Math.max(0,Math.floor((x-rad-g0)/G)), i1=Math.min(N-1,Math.ceil((x+rad-g0)/G));
  const j0=Math.max(0,Math.floor((z-rad-g0)/G)), j1=Math.min(N-1,Math.ceil((z+rad-g0)/G));
  const r2i=1/(rad*rad);
  for(let j=j0;j<=j1;j++) for(let i=i0;i<=i1;i++){
    const k=i+j*N; if(!F.gAct[k]) continue;
    const dx=g0+i*G-x, dz=g0+j*G-z, q=(dx*dx+dz*dz)*r2i; if(q>=1) continue;
    const w=(1-q)*(1-q); F.dp[k]+=fn*hh*w;
  }
}
// Membran-Solver: gedämpfte Wellengleichung je Kammer, Rückstellkraft steigt mit Luftdruck
function stepGrid(F,h){
  const N=F.gN, d=F.d, dp=F.dp, dn=F.dn, cell=F.gCell, act=F.gAct, V=F.V, hh=h*h, dmp=1-DENT_DAMP*h;
  for(let k=0;k<N*N;k++){ if(act[k]){ const c=cell[k]; F.gH0[k]=P.height*F.hf[c]*V[c]*F.gFac[k]; } }
  for(let j=1;j<N-1;j++) for(let i=1;i<N-1;i++){
    const k=i+j*N; if(!act[k]) continue;
    const c=cell[k], dk=d[k];
    const lap=(d[k-1]-dk)*(cell[k-1]===c?1:0.15)+(d[k+1]-dk)*(cell[k+1]===c?1:0.15)
             +(d[k-N]-dk)*(cell[k-N]===c?1:0.15)+(d[k+N]-dk)*(cell[k+N]===c?1:0.15);
    let nd=dk+(dk-dp[k])*dmp+(WAVE*lap-STIFF*(0.4+1.6*V[c])*dk)*hh;
    const lo=-F.gH0[k]; if(nd<lo) nd=lo; else if(nd>0.12) nd=0.12;
    dn[k]=nd;
  }
  for(let j=1;j<N-1;j++) for(let i=1;i<N-1;i++){ const k=i+j*N; if(act[k]){ dp[k]=d[k]; d[k]=dn[k]; } }
}
function updateDentMean(F){
  F.dmean.fill(0);
  for(let k=0;k<F.gN*F.gN;k++) if(F.gAct[k]) F.dmean[F.gCell[k]]+=F.d[k];
  for(let c=0;c<F.n;c++) F.dmean[c]=F.gCount[c]>0?F.dmean[c]/F.gCount[c]:0;
}

// ---- Kammer-Dynamik
let simTime=0, pulseT=1;
function pulse(FS,dt){
  pulseT-=dt; if(pulseT>0) return;
  let tot=0; for(const F of FS) tot+=F.n;
  pulseT=(1.6+Math.random()*2.2)*Math.min(1,P.cells/tot)*(FS.length>1?2:1);
  let r=Math.random()*tot;
  for(const F of FS){ if(r<F.n){ const i=r|0; F.ovT[i]=1; F.ovUntil[i]=simTime+1.0; return; } r-=F.n; }
}
function updateCells(F,dt){
  const n=F.n, V=F.V, tgt=F.tgt;
  for(let i=0;i<n;i++) F.load[i]+=(F.contact[i]-F.load[i])*Math.min(1,dt*8);
  const t=simTime*P.tempo*0.35;
  for(let i=0;i<n;i++){
    let tg;
    const nx=F.cx[i]+F.nox, nz=F.cz[i]+F.noz, ny=F.is3?F.cy[i]:0;
    if(simTime<F.ovUntil[i]) tg=F.ovT[i];
    else if(F.is3 && P.mode!=='manual'){
      // Zellverband: alle Kissen bleiben gefüllt und atmen weich; nahe am Körper schmiegen sie sich an
      const nn=0.6*vnoise(nx*0.45+t,ny*0.45+7,nz*0.45-t*0.7)+0.4*vnoise(nx*0.9-t*1.3,ny*0.9,nz*0.9+3+t);
      // peristaltische Welle durch den Verband (Richtung dreht langsam) -> der Körper wird getragen, gedreht, weitergereicht
      const wa=P.dir*Math.PI/180+0.5*Math.sin(simTime*P.tempo*0.11), wdx=Math.sin(wa), wdz=Math.cos(wa), wdy=0.3*Math.sin(simTime*P.tempo*0.08);
      const wave=Math.sin((nx*wdx+ny*wdy+nz*wdz)*2.4-simTime*(0.4+P.tempo*1.4));
      // Schub: Zellen hinter dem Körper (gegen die Richtung) blähen sich, vor ihm geben sie nach -> der Körper wird weitergeschoben
      const pdx=Math.sin(P.dir*Math.PI/180), pdz=Math.cos(P.dir*Math.PI/180), ahead=((F.cx[i]-F.bx)*pdx+(F.cz[i]-F.bz)*pdz)/0.7;
      tg=0.15+0.45*P.fill+0.4*nn+P.wave*0.3*wave+P.hug*0.5*F.prox[i]+P.push*(0.55*Math.max(0,-clamp(ahead,-1,1))-0.4*Math.max(0,clamp(ahead,-1,1)));
      F.ahd[i]=clamp(ahead,-1,1);
      if(P.mode==='answer'){
        const nb=F.nb[i]; let s=0, l=0;
        for(let k=0;k<nb.idx.length;k++){ s+=F.load[nb.idx[k]]*nb.len[k]; l+=nb.len[k]; }
        const nbL=1-Math.exp(-0.8*(l>0?s/l:0)), selfL=1-Math.exp(-0.8*F.load[i]);
        F.fat[i]+=(selfL-F.fat[i])*dt/3.5;
        tg+=P.reaction*(0.5*selfL-0.35*nbL)-1.2*F.fat[i]*F.fat[i];
      }
    }
    else if(P.mode==='breath'){
      const nn=F.is3 ? 0.65*vnoise(nx*0.45+t,ny*0.45+7,nz*0.45-t*0.7)+0.35*vnoise(nx*0.9-t*1.3,ny*0.9,nz*0.9+3+t)
                     : 0.65*vnoise(nx*0.28+7,nz*0.28,t)+0.35*vnoise(nx*0.6,nz*0.6+3,t*1.7+11);
      tg=sstep(0.40,0.60,nn);
    } else if(P.mode==='answer'){
      const nb=F.nb[i]; let s=0, l=0;
      for(let k=0;k<nb.idx.length;k++){ s+=F.load[nb.idx[k]]*nb.len[k]; l+=nb.len[k]; }
      const nbL=1-Math.exp(-0.8*(l>0?s/l:0)), selfL=1-Math.exp(-0.8*F.load[i]);
      const base=0.22+0.3*vnoise(nx*0.3+3+ny*0.3,nz*0.3,t*0.6);
      // Ermüdung: eine Kammer, die lange trägt, gibt langsam nach -> der Körper rollt weiter (Dialog statt Stillstand)
      F.fat[i]+=(selfL-F.fat[i])*dt/3.5;
      tg=base+P.reaction*(0.9*selfL-0.6*nbL)-1.3*F.fat[i]*F.fat[i]*2;
    } else tg=0;
    tgt[i]=tg<0?0:(tg>1?1:tg);
  }
  for(let i=0;i<n;i++){
    const d=tgt[i]-V[i], rate=d>0?P.inflateRate:P.deflateRate;
    let dv=d*(1-Math.exp(-rate*1.6*dt))+Math.sign(d)*Math.min(Math.abs(d),0.04*rate*dt);
    // Oberfläche darf sich nur mit begrenzter Geschwindigkeit (m/s) heben/senken -> Gliedmaßen werden sanft getragen
    const lim=(d>0?P.lift:P.lift*2)*dt/Math.max(0.05,F.is3?F.Wc[i]:P.height*F.hf[i]);
    if(dv>lim) dv=lim; else if(dv<-lim) dv=-lim;
    // Zellraum: ein Kissen, das tief in den Körper drückt, gibt nach (statt ihn zu verschlucken)
    if(F.is3){ const thr=0.04+0.05*Math.max(0,1-P.soft)+0.08*P.push*Math.max(0,-F.ahd[i]), e=F.pen[i]-thr; if(e>0){ const y=-e*dt*12/Math.max(0.1,F.Wc[i]); if(dv>y) dv=y; } }
    V[i]+=dv;
  }
  // Luftaustausch zwischen Nachbarn: belastete Kammern drücken Luft hinaus
  if(P.flow>0){
    for(let i=0;i<n;i++){
      const nb=F.nb[i], pi=V[i]+0.25*(1-Math.exp(-0.8*F.load[i]));
      for(let k=0;k<nb.idx.length;k++){
        const j=nb.idx[k]; if(j<i) continue;
        const pj=V[j]+0.25*(1-Math.exp(-0.8*F.load[j]));
        const f=P.flow*(pi-pj)*nb.len[k]*0.35*dt;
        V[i]-=f/F.areaRel[i]; V[j]+=f/F.areaRel[j];
      }
    }
  }
  for(let i=0;i<n;i++){
    let v=V[i]; if(v<0) v=0; else if(v>1) v=1;
    if(tgt[i]===0 && v<0.015) v=0;
    V[i]=v;
  }
}

// ---- Zellraum: 3D-Power-Voronoi. Jede Zelle ist ein Luftkissen (geschrumpfte, gerundete Zelle).
// Aufgeblasen füllt sie ihre Zelle fast ganz, deflatet schrumpft sie zum Punkt. Der Körper lebt in den Zwischenräumen.
const SMK=0.04, GMIN=0.025, VOX=0.1;
let C3X=0, C3Y=1, C3Z=0, C3V=0;
// Der Zellverband ist eine ellipsoide Wolke um den Körper (kein Raum mehr, nur ein Boden).
// Er folgt dem Körper langsam (Ursprung O) und kann sich zusammenziehen (Skalierung s): Welt = O + s * Referenz
const NONE3=65535;
function buildCellWorld(cells,seed,vari){
  const EA=1.75*P.room, EB=1.0*P.room;
  const inE=(x,y,z,m)=>(x*x+z*z)/((EA-m)*(EA-m))+y*y/((EB-m)*(EB-m))<1;
  const n0=Math.max(12,Math.round(cells*0.6)), vol=4.18879*EA*EA*EB, rnd=mulberry32(seed+7), sg=vari*0.7;
  const rmean=Math.cbrt(vol*0.3/(n0*4.18879)), rs=[];
  for(let i=0;i<n0;i++) rs.push(rmean*Math.exp((rnd()*2-1)*sg));
  rs.sort((x,y)=>y-x);
  const pts=[];
  for(const r of rs){
    for(let t=0;t<400;t++){
      const x=(rnd()*2-1)*EA, y=(rnd()*2-1)*EB, z=(rnd()*2-1)*EA;
      if(!inE(x,y,z,Math.min(r*0.4,EB*0.4))) continue;
      let ok=true;
      for(const p of pts){ const dx=x-p.x, dy=y-p.y, dz=z-p.z, mm=0.85*(r+p.r); if(dx*dx+dy*dy+dz*dz<mm*mm){ ok=false; break; } }
      if(ok){ pts.push({x,y,z,r}); break; }
    }
  }
  const N=pts.length;
  const C={ is3:true, n:N, EA, EB, sx:new Float64Array(N), sy:new Float64Array(N), sz:new Float64Array(N), w2:new Float64Array(N),
    cx:new Float64Array(N), cy:new Float64Array(N), cz:new Float64Array(N), vol:new Float64Array(N), Rq:new Float64Array(N), Wc:new Float64Array(N),
    V:new Float32Array(N), pen:new Float32Array(N), g:new Float32Array(N), gv:new Float32Array(N), nb:[], pl:[], tc:[],
    ovT:new Float32Array(N), ovUntil:new Float64Array(N), load:new Float32Array(N), fat:new Float32Array(N),
    contact:new Float32Array(N), cacc:new Float32Array(N), tgt:new Float32Array(N), areaRel:new Float32Array(N), nox:0, noz:0,
    o:[0,EB*0.6,0], ov:[0,0,0], s:1, sdot:0, bx:0, by:0, bz:0 };
  for(let i=0;i<N;i++){ C.sx[i]=pts[i].x; C.sy[i]=pts[i].y; C.sz[i]=pts[i].z; C.w2[i]=pts[i].r*pts[i].r; }
  // Voxel-Abtastung (nur innerhalb des Ellipsoids): Zuordnung, Schwerpunkte, Volumen, Nachbarschaft
  const VX=0.08, nx=Math.ceil(2*EA/VX), ny=Math.ceil(2*EB/VX), nz=nx;
  C.VX=VX; C.vnx=nx; C.vny=ny; C.vnz=nz; C.vox=new Uint16Array(nx*ny*nz).fill(NONE3);
  const near=(x,y,z)=>{ let bi=0,bd=1e18; for(let i=0;i<N;i++){ const dx=x-C.sx[i], dy=y-C.sy[i], dz=z-C.sz[i], d=dx*dx+dy*dy+dz*dz-C.w2[i]; if(d<bd){bd=d;bi=i;} } return bi; };
  C.near=near;
  for(let k=0;k<nz;k++) for(let j=0;j<ny;j++) for(let i=0;i<nx;i++){
    const x=-EA+(i+0.5)*VX, y=-EB+(j+0.5)*VX, z=-EA+(k+0.5)*VX;
    if(!inE(x,y,z,0)) continue;
    const c=near(x,y,z);
    C.vox[i+nx*(j+ny*k)]=c; C.cx[c]+=x; C.cy[c]+=y; C.cz[c]+=z; C.vol[c]+=1;
  }
  const maps=[]; for(let i=0;i<N;i++) maps.push(new Map());
  const link=(p,q)=>{ if(p===q||p===NONE3||q===NONE3) return; maps[p].set(q,(maps[p].get(q)||0)+VX*VX); maps[q].set(p,(maps[q].get(p)||0)+VX*VX); };
  for(let k=0;k<nz;k++) for(let j=0;j<ny;j++) for(let i=0;i<nx;i++){
    const c=C.vox[i+nx*(j+ny*k)]; if(c===NONE3) continue;
    if(i+1<nx) link(c,C.vox[i+1+nx*(j+ny*k)]);
    if(j+1<ny) link(c,C.vox[i+nx*(j+1+ny*k)]);
    if(k+1<nz) link(c,C.vox[i+nx*(j+ny*(k+1))]);
  }
  let vt=0;
  for(let i=0;i<N;i++){
    const v=Math.max(1,C.vol[i]); if(C.vol[i]<1){ C.cx[i]=C.sx[i]; C.cy[i]=C.sy[i]; C.cz[i]=C.sz[i]; } else { C.cx[i]/=v; C.cy[i]/=v; C.cz[i]/=v; }
    C.vol[i]=v*VX*VX*VX; vt+=C.vol[i];
    C.Rq[i]=Math.cbrt(3*C.vol[i]/(4*Math.PI));
    const idx=[...maps[i].keys()], len=[...maps[i].values()];
    C.nb.push({idx,len});
    // Ebenen: t = c - p·n  (t > 0 innerhalb der Zelle i)
    const pl=new Float64Array(idx.length*4);
    idx.forEach((j,k)=>{
      const dx=C.sx[j]-C.sx[i], dy=C.sy[j]-C.sy[i], dz=C.sz[j]-C.sz[i], D=Math.sqrt(dx*dx+dy*dy+dz*dz);
      const s2j=C.sx[j]**2+C.sy[j]**2+C.sz[j]**2, s2i=C.sx[i]**2+C.sy[i]**2+C.sz[i]**2;
      pl[k*4]=dx/D; pl[k*4+1]=dy/D; pl[k*4+2]=dz/D; pl[k*4+3]=(s2j-s2i-C.w2[j]+C.w2[i])/(2*D);
    });
    C.pl.push(pl);
  }
  for(let i=0;i<N;i++) C.areaRel[i]=C.vol[i]/(vt/N);
  C.prox=new Float32Array(N); C.ahd=new Float32Array(N);
  C.touch=new Uint8Array(N*6);   // keine Wände mehr
  reshapeCells(C);
  const bound={type:'floor'};
  const W={ space:'cells', fields:[], c3:C, bound, ext:EA*1.15, sq:0, phase:0 };
  placeCells(W,0,null);
  return W;
}
function bodyCom(B){ let x=0,y=0,z=0; const p=B.pos; for(let j=0;j<B.N;j++){ x+=p[j*3]; y+=p[j*3+1]; z+=p[j*3+2]; } return [x/B.N,y/B.N,z/B.N]; }
// Verband: folgt dem Körper weich und zieht sich (Verengung / Atmen) um ihn zusammen
function placeCells(W,dt,B){
  const C=W.c3, tgt=clamp(P.squeeze+P.roomBreath*0.45*Math.sin(W.phase),0,1);
  if(dt>0){ W.phase+=dt*(0.25+P.tempo*0.5)*0.35; const d=tgt-W.sq, mx=0.06*dt; W.sq+=d>mx?mx:(d<-mx?-mx:d); } else W.sq=tgt;
  const s=1-0.4*W.sq;
  C.sdot=dt>0?(s-C.s)/dt:0; C.s=s;
  if(B){
    const cm=bodyCom(B), T=[cm[0], Math.max(cm[1], C.EB*s*0.45), cm[2]];
    for(let k=0;k<3;k++){
      let d=(T[k]-C.o[k])*(dt>0?Math.min(1,dt*0.7):1); const mx=0.35*dt;
      if(dt>0){ if(d>mx) d=mx; else if(d<-mx) d=-mx; }
      C.o[k]+=d; C.ov[k]=dt>0?d/dt:0;
    }
  }
}
function snapCells(W,B){ const C=W.c3, cm=bodyCom(B); C.o=[cm[0],Math.max(cm[1],C.EB*C.s*0.45),cm[2]]; C.ov=[0,0,0]; }
// Nähe jeder Zelle zum Körper (für das Anschmiegen) + Körperschwerpunkt im Referenzraum (für den Schub)
function cellProx(C,B){
  const s=C.s, o=C.o, p=B.pos;
  for(let i=0;i<C.n;i++){
    const x=o[0]+C.cx[i]*s, y=o[1]+C.cy[i]*s, z=o[2]+C.cz[i]*s; let m=1e9;
    for(let j=0;j<B.N;j++){ const d=(p[j*3]-x)**2+(p[j*3+1]-y)**2+(p[j*3+2]-z)**2; if(d<m) m=d; }
    C.prox[i]=Math.exp(-Math.max(0,Math.sqrt(m)-C.Rq[i]*s)/0.45);
  }
  const cm=bodyCom(B); C.bx=(cm[0]-o[0])/s; C.by=(cm[1]-o[1])/s; C.bz=(cm[2]-o[2])/s;
}
// Inkreis-Abstand (bestimmt, wie weit eine Zelle schrumpfen kann)
function reshapeCells(C){
  C.kr=lerp(1.9,1.15,P.round);
  for(let i=0;i<C.n;i++){
    const pl=C.pl[i], x=C.cx[i], y=C.cy[i], z=C.cz[i]; let m=C.Rq[i]*C.kr;
    for(let k=0;k<pl.length;k+=4){ const t=pl[k+3]-(x*pl[k]+y*pl[k+1]+z*pl[k+2]); if(t<m) m=t; }
    C.Wc[i]=Math.max(0.05,m);
    C.g[i]=GMIN+Math.pow(1-C.V[i],1.6)*C.Wc[i];
  }
}
function nearestCell3(C,x,y,z){ const s=C.s, o=C.o; return nearestCell3Ref(C,(x-o[0])/s,(y-o[1])/s,(z-o[2])/s); }
function nearestCell3Ref(C,x,y,z){
  let i=Math.floor((x+C.EA)/C.VX), j=Math.floor((y+C.EB)/C.VX), k=Math.floor((z+C.EA)/C.VX);
  let c=NONE3;
  if(i>=0&&j>=0&&k>=0&&i<C.vnx&&j<C.vny&&k<C.vnz) c=C.vox[i+C.vnx*(j+C.vny*k)];
  if(c===NONE3) return C.near(x,y,z);
  let dx=x-C.sx[c], dy=y-C.sy[c], dz=z-C.sz[c], best=dx*dx+dy*dy+dz*dz-C.w2[c];
  for(let it=0;it<6;it++){
    let moved=false; const nb=C.nb[c].idx;
    for(let q=0;q<nb.length;q++){ const j2=nb[q]; dx=x-C.sx[j2]; dy=y-C.sy[j2]; dz=z-C.sz[j2]; const d=dx*dx+dy*dy+dz*dz-C.w2[j2]; if(d<best){ best=d; c=j2; moved=true; } }
    if(!moved) break;
  }
  return c;
}
// Tiefe eines Punkts im Kissen der Zelle i (positiv = innen); Außennormale in C3X/Y/Z. Gibt -1e9 zurück, wenn sicher weit draußen.
const _tt=new Float64Array(64), _tn=new Float64Array(192), _wl=new Float64Array([0,-1,0,0, 0,1,0,0, 0,0,-1,0, 0,0,1,0, 0,0,0,-1, 0,0,0,1]);
// Welt -> Referenzraum (der Zellverband wird um die Raummitte gestaucht = Verengung)
function cellDepth(C,i,x,y,z,reach){
  const s=C.s, o=C.o, xr=(x-o[0])/s, yr=(y-o[1])/s, zr=(z-o[2])/s;
  const d=cellDepthRef(C,i,xr,yr,zr,reach/s);
  if(d<-1e8) return d;
  // Hautgeschwindigkeit: Aufblasen + Zusammenziehen + Mitwandern des Verbands
  C3V=C.gv[i]*s+C.sdot*(xr*C3X+yr*C3Y+zr*C3Z)+C.ov[0]*C3X+C.ov[1]*C3Y+C.ov[2]*C3Z;
  return d*s;
}
function cellDepthRef(C,i,x,y,z,reach){
  const pl=C.pl[i], g=C.g[i], m=pl.length>>2; let cnt=0, tmin=1e9;
  for(let k=0;k<m;k++){
    const nx=pl[k*4], ny=pl[k*4+1], nz=pl[k*4+2], t=pl[k*4+3]-(x*nx+y*ny+z*nz);
    if(cnt<63){ _tt[cnt]=t; _tn[cnt*3]=nx; _tn[cnt*3+1]=ny; _tn[cnt*3+2]=nz; cnt++; }
    if(t<tmin) tmin=t;
  }
  // Rundung: Kugel um den Schwerpunkt, zu berührten Wänden hin extrudiert
  const tc=C.touch, o6=i*6;
  let rx=x-C.cx[i], ry=y-C.cy[i], rz=z-C.cz[i];
  if((rx<0&&tc[o6])||(rx>0&&tc[o6+1])) rx=0;
  if((ry<0&&tc[o6+2])||(ry>0&&tc[o6+3])) ry=0;
  if((rz<0&&tc[o6+4])||(rz>0&&tc[o6+5])) rz=0;
  const rl=Math.sqrt(rx*rx+ry*ry+rz*rz)||1e-6, tr=C.Rq[i]*C.kr-rl;
  _tt[cnt]=tr; _tn[cnt*3]=rx/rl; _tn[cnt*3+1]=ry/rl; _tn[cnt*3+2]=rz/rl; if(tr<tmin) tmin=tr; cnt++;
  if(C.V[i]<0.04) return -1e9;   // ganz deflatet: kein Kissen mehr
  if(tmin-g<-reach) return -1e9;
  let s=0, ox=0, oy=0, oz=0;
  for(let k=0;k<cnt;k++){ const w=Math.exp(-(_tt[k]-tmin)/SMK); s+=w; ox+=w*_tn[k*3]; oy+=w*_tn[k*3+1]; oz+=w*_tn[k*3+2]; }
  const ol=Math.sqrt(ox*ox+oy*oy+oz*oz)||1; C3X=ox/ol; C3Y=oy/ol; C3Z=oz/ol;
  C3V=C.gv[i];
  return tmin-SMK*Math.log(s)-g;
}
function updateCells3(C,dt){
  for(let i=0;i<C.n;i++){
    const g=GMIN+Math.pow(1-C.V[i],1.6)*C.Wc[i];
    C.gv[i]=clamp((C.g[i]-g)/dt,-1,1); C.g[i]=g;   // positiv: Kissenhaut wandert nach außen
  }
}

// ---- Ragdoll (Verlet / PBD)
const JN=['pelvis','chest','neck','head','crown','shL','elL','wrL','shR','elR','wrR','hpL','knL','anL','toL','hpR','knR','anR','toR'];
const JI={}; JN.forEach((n,i)=>JI[n]=i);
const DUMMY_REST = {
  pelvis:[0,0.95,0], chest:[0,1.32,0], neck:[0,1.50,0], head:[0,1.58,0.01], crown:[0,1.76,0.01],
  shL:[0.19,1.45,0], elL:[0.31,1.17,0], wrL:[0.40,0.92,0.02],
  shR:[-0.19,1.45,0], elR:[-0.31,1.17,0], wrR:[-0.40,0.92,0.02],
  hpL:[0.09,0.93,0], knL:[0.10,0.50,0.02], anL:[0.10,0.08,0], toL:[0.10,0.03,0.17],
  hpR:[-0.09,0.93,0], knR:[-0.10,0.50,0.02], anR:[-0.10,0.08,0], toR:[-0.10,0.03,0.17]
};
const BONES=[['pelvis','chest'],['chest','neck'],['neck','head'],['head','crown'],
  ['chest','shL'],['shL','elL'],['elL','wrL'],['chest','shR'],['shR','elR'],['elR','wrR'],
  ['pelvis','hpL'],['hpL','knL'],['knL','anL'],['anL','toL'],['pelvis','hpR'],['hpR','knR'],['knR','anR'],['anR','toR'],
  ['hpL','hpR'],['shL','shR'],['neck','shL'],['neck','shR']];
const SOFT=[['shL','hpL',0.4],['shR','hpR',0.4],['shL','hpR',0.3],['shR','hpL',0.3],['head','chest',0.35]];
const MINFRAC=[['shL','elL','wrL'],['shR','elR','wrR'],['hpL','knL','anL'],['hpR','knR','anR']];
const MINPAIR=[['knL','knR',.14],['anL','anR',.14],['toL','toR',.12],['wrL','hpL',.13],['wrR','hpR',.13],
  ['elL','hpL',.10],['elR','hpR',.10],['wrL','head',.12],['wrR','head',.12],['wrL','wrR',.12]];
const INVM={pelvis:.55,chest:.6,head:.8,crown:.9};

function makeBody(rest,s){
  const N=JN.length;
  const B={N,s,rest,pos:new Float64Array(N*3),prev:new Float64Array(N*3),w:new Float64Array(N),
    ia:[],ib:[],len:[],k:[],type:[],nc:0,probes:[],drag:null,acc:new Float64Array(N*3),accS:new Float64Array(N*3)};
  for(let j=0;j<N;j++) B.w[j]=INVM[JN[j]]||1;
  const d=(a,b)=>Math.hypot(rest[JI[a]][0]-rest[JI[b]][0],rest[JI[a]][1]-rest[JI[b]][1],rest[JI[a]][2]-rest[JI[b]][2]);
  const addC=(a,b,k,type,len)=>{ B.ia.push(JI[a]); B.ib.push(JI[b]); B.k.push(k); B.type.push(type); B.len.push(len); B.nc++; };
  for(const [a,b] of BONES) addC(a,b,1,0,d(a,b));
  for(const [a,b,k] of SOFT) addC(a,b,k,0,d(a,b));
  for(const [a,b,c] of MINFRAC) addC(a,c,0.8,1,0.28*(d(a,b)+d(b,c)));
  for(const [a,b,m] of MINPAIR) addC(a,b,0.6,1,m*s);
  const pr=(a,b,t,r)=>B.probes.push({a:JI[a],b:JI[b],t,r:r*s});
  [['pelvis',.12],['chest',.13],['head',.08],['crown',.07],['shL',.07],['shR',.07],['elL',.055],['elR',.055],
   ['wrL',.05],['wrR',.05],['hpL',.085],['hpR',.085],['knL',.07],['knR',.07],['anL',.055],['anR',.055],['toL',.04],['toR',.04]]
   .forEach(([a,r])=>pr(a,a,0,r));
  pr('pelvis','chest',.5,.12); pr('chest','neck',.5,.07); pr('head','crown',.5,.10);
  pr('shL','shR',.25,.07); pr('shL','shR',.5,.08); pr('shL','shR',.75,.07); pr('hpL','hpR',.5,.09);
  pr('shL','elL',.5,.055); pr('shR','elR',.5,.055); pr('elL','wrL',.5,.05); pr('elR','wrR',.5,.05);
  for(const sd of ['L','R']){
    pr('hp'+sd,'kn'+sd,.33,.075); pr('hp'+sd,'kn'+sd,.67,.075);
    pr('kn'+sd,'an'+sd,.33,.06); pr('kn'+sd,'an'+sd,.67,.06); pr('an'+sd,'to'+sd,.5,.045);
  }
  buildSegs(B);
  return B;
}
// legt den Körper auf den Rücken (Gesicht nach oben), Kopf Richtung Bildschirm-oben (-z)
function placeLying(B,lift){
  const N=B.N, pts=[]; let mx=0, mz=0, miny=1e9;
  for(let j=0;j<N;j++){ const r=B.rest[j]; const p=[r[0], r[2], -r[1]]; pts.push(p); mx+=p[0]; mz+=p[2]; if(p[1]<miny) miny=p[1]; }
  mx/=N; mz/=N;
  for(let j=0;j<N;j++){
    const x=pts[j][0]-mx+(Math.random()-.5)*0.02, y=pts[j][1]-miny+lift, z=pts[j][2]-mz+(Math.random()-.5)*0.02;
    B.pos[j*3]=B.prev[j*3]=x; B.pos[j*3+1]=B.prev[j*3+1]=y; B.pos[j*3+2]=B.prev[j*3+2]=z;
  }
  B.drag=null; B.accS.fill(0);
}

// ---- grobe Gelenk-Limits (Kegel um Achsen, Scharniere für Knie/Ellbogen)
const C108=Math.cos(108*Math.PI/180), S108=Math.sin(108*Math.PI/180), C125=Math.cos(125*Math.PI/180), S125=Math.sin(125*Math.PI/180);
const C70=Math.cos(70*Math.PI/180), S70=Math.sin(70*Math.PI/180), C50=Math.cos(50*Math.PI/180), S50=Math.sin(50*Math.PI/180), C55=Math.cos(55*Math.PI/180), S55=Math.sin(55*Math.PI/180);
const C65=Math.cos(65*Math.PI/180), S65=Math.sin(65*Math.PI/180);
function nrm(v){ const l=Math.hypot(v[0],v[1],v[2])||1; return [v[0]/l,v[1]/l,v[2]/l]; }
let LIMCAP=0.02;
function coneLimit(B,a,b,u,cosT,sinT,k){
  const pos=B.pos, w=B.w, A=a*3, Q=b*3;
  let dx=pos[Q]-pos[A], dy=pos[Q+1]-pos[A+1], dz=pos[Q+2]-pos[A+2];
  const len=Math.sqrt(dx*dx+dy*dy+dz*dz); if(len<1e-9) return;
  dx/=len; dy/=len; dz/=len;
  const ca=dx*u[0]+dy*u[1]+dz*u[2]; if(ca>=cosT) return;
  let px=dx-u[0]*ca, py=dy-u[1]*ca, pz=dz-u[2]*ca, pl=Math.sqrt(px*px+py*py+pz*pz);
  if(pl<1e-6){ px=u[1]; py=-u[0]; pz=0; pl=Math.sqrt(px*px+py*py); if(pl<1e-6){ px=0; py=u[2]; pz=-u[1]; pl=Math.sqrt(py*py+pz*pz)||1; } }
  let cx=(pos[A]+(u[0]*cosT+px/pl*sinT)*len-pos[Q])*k, cy=(pos[A+1]+(u[1]*cosT+py/pl*sinT)*len-pos[Q+1])*k, cz=(pos[A+2]+(u[2]*cosT+pz/pl*sinT)*len-pos[Q+2])*k;
  const cm=Math.sqrt(cx*cx+cy*cy+cz*cz), cap=LIMCAP*B.s, cs=cm>cap?cap/cm:1;
  cx*=cs; cy*=cs; cz*=cs;
  const wa=w[a]*0.3, wb=w[b], sm_=wa+wb;
  pos[Q]+=cx*wb/sm_; pos[Q+1]+=cy*wb/sm_; pos[Q+2]+=cz*wb/sm_;
  pos[A]-=cx*wa/sm_; pos[A+1]-=cy*wa/sm_; pos[A+2]-=cz*wa/sm_;
}
// sign +1: Knie liegt vor der Linie Hüfte–Knöchel (Knie knickt nur nach hinten); sign -1: Ellbogen hinter der Linie Schulter–Handgelenk
function hinge(B,a,m,c,f,sign,minOff,k){
  const pos=B.pos, A=a*3, M=m*3, Cc=c*3;
  const ox=pos[M]-(pos[A]+pos[Cc])/2, oy=pos[M+1]-(pos[A+1]+pos[Cc+1])/2, oz=pos[M+2]-(pos[A+2]+pos[Cc+2])/2;
  const off=(ox*f[0]+oy*f[1]+oz*f[2])*sign; if(off>=minOff) return;
  let c_=(minOff-off)*sign*k; const cp=LIMCAP*B.s; if(c_>cp) c_=cp; else if(c_<-cp) c_=-cp;
  pos[M]+=f[0]*c_*0.6; pos[M+1]+=f[1]*c_*0.6; pos[M+2]+=f[2]*c_*0.6;
  pos[A]-=f[0]*c_*0.2; pos[A+1]-=f[1]*c_*0.2; pos[A+2]-=f[2]*c_*0.2;
  pos[Cc]-=f[0]*c_*0.2; pos[Cc+1]-=f[1]*c_*0.2; pos[Cc+2]-=f[2]*c_*0.2;
}
function legFwd(p,a,c,side){
  const cx=p[c*3]-p[a*3], cy=p[c*3+1]-p[a*3+1], cz=p[c*3+2]-p[a*3+2], cl=Math.hypot(cx,cy,cz)||1;
  const fx=(cy*side[2]-cz*side[1])/cl, fy=(cz*side[0]-cx*side[2])/cl, fz=(cx*side[1]-cy*side[0])/cl, m=Math.hypot(fx,fy,fz)||1e-9;
  return [fx/m,fy/m,fz/m,m];
}
function limits(B){
  const p=B.pos, g=n=>[p[JI[n]*3],p[JI[n]*3+1],p[JI[n]*3+2]], sub=(a,b)=>[a[0]-b[0],a[1]-b[1],a[2]-b[2]];
  const up=nrm(sub(g('chest'),g('pelvis'))), sd0=nrm(sub(g('hpL'),g('hpR')));
  const fwd=nrm([sd0[1]*up[2]-sd0[2]*up[1], sd0[2]*up[0]-sd0[0]*up[2], sd0[0]*up[1]-sd0[1]*up[0]]);
  const side=nrm([up[1]*fwd[2]-up[2]*fwd[1], up[2]*fwd[0]-up[0]*fwd[2], up[0]*fwd[1]-up[1]*fwd[0]]);
  for(const sg of [1,-1]){
    const S=sg>0?'L':'R', hp=JI['hp'+S], kn=JI['kn'+S], an=JI['an'+S], to=JI['to'+S], sh=JI['sh'+S], el=JI['el'+S], wr=JI['wr'+S];
    coneLimit(B,hp,kn,nrm([-up[0]+fwd[0]*0.45+side[0]*sg*0.1,-up[1]+fwd[1]*0.45+side[1]*sg*0.1,-up[2]+fwd[2]*0.45+side[2]*sg*0.1]),C108,S108,0.5);
    // Bein-Vorwärtsrichtung aus Hüftachse und Hüfte–Knöchel-Sehne (stetig auch bei stark gebeugter Hüfte)
    const lf=legFwd(p,hp,an,side), lm=lf[3];
    if(lm>0.05) hinge(B,hp,kn,an,lf,1,0,0.6*Math.min(1,lm*2));
    const sh_=nrm([p[an*3]-p[kn*3],p[an*3+1]-p[kn*3+1],p[an*3+2]-p[kn*3+2]]), lw=sstep(0.1,0.4,lm), ff=nrm([lf[0]*lw+fwd[0]*(1-lw),lf[1]*lw+fwd[1]*(1-lw),lf[2]*lw+fwd[2]*(1-lw)]), fd=ff[0]*sh_[0]+ff[1]*sh_[1]+ff[2]*sh_[2];
    coneLimit(B,an,to,nrm([ff[0]-sh_[0]*fd,ff[1]-sh_[1]*fd,ff[2]-sh_[2]*fd]),C65,S65,0.3);
    coneLimit(B,sh,el,nrm([side[0]*sg*0.7-up[0]*0.4+fwd[0]*0.2,side[1]*sg*0.7-up[1]*0.4+fwd[1]*0.2,side[2]*sg*0.7-up[2]*0.4+fwd[2]*0.2]),C125,S125,0.5);
    const af=legFwd(p,sh,wr,side), am=af[3];
    if(am>0.05) hinge(B,sh,el,wr,af,-1,-0.02*B.s,0.35*Math.min(1,am*2));
  }
  coneLimit(B,JI.neck,JI.head,up,C70,S70,0.5);
  coneLimit(B,JI.head,JI.crown,nrm(sub(g('head'),g('neck'))),C50,S50,0.5);
}
// weicher Kontakt: progressive Feder + Dämpfer gegen die geglättete, nachgebende Kammerhaut
let CK=0, CCd=0, CMU=0, CUB=0, CP0=0.05, FLX=0, FLZ=0;
function applyContact(B,pr,pen,NX,NY,NZ,bx,by,bz,vs,vx,vy,vz){
  const vn=vx*NX+vy*NY+vz*NZ, pp=pen/CP0;
  // progressiv: anfangs sehr weich, tiefer eingedrückt deutlich fester
  let fn=CK*pen*(1+pp*pp)-CCd*(1+pp)*(vn-vs); if(fn<=0) return 0;
  // Richtung: Flächennormale, mit 'Tragen' Richtung Grundnormale gebogen
  const ub=CUB; let dx=NX*(1-ub)+bx*ub, dy=NY*(1-ub)+by*ub, dz=NZ*(1-ub)+bz*ub; const dl=Math.sqrt(dx*dx+dy*dy+dz*dz)||1; dx/=dl; dy/=dl; dz/=dl;
  let ax=dx*fn, ay=dy*fn, az=dz*fn;
  // viskose Reibung (geschwindigkeitsproportional, durch Coulomb begrenzt)
  // relativ zur Haut: im Zellraum strömt die Kissenhaut langsam in Schubrichtung (Peristaltik) und nimmt den Körper per Reibung mit
  const fdn=FLX*NX+FLZ*NZ, rvx=vx-(FLX-fdn*NX), rvy=vy+fdn*NY, rvz=vz-(FLZ-fdn*NZ);
  const rn=rvx*NX+rvy*NY+rvz*NZ, tx=rvx-rn*NX, ty=rvy-rn*NY, tz=rvz-rn*NZ, mag=Math.sqrt(tx*tx+ty*ty+tz*tz);
  if(mag>1e-6){ const fr=Math.min(CMU*fn, mag*12)/mag; ax-=tx*fr; ay-=ty*fr; az-=tz*fr; }
  const acc=B.acc, w=B.w, a=pr.a*3, b=pr.b*3, t=pr.t, it=1-t;
  acc[a]+=ax*it*w[pr.a]; acc[a+1]+=ay*it*w[pr.a]; acc[a+2]+=az*it*w[pr.a];
  if(pr.a!==pr.b){ acc[b]+=ax*t*w[pr.b]; acc[b+1]+=ay*t*w[pr.b]; acc[b+2]+=az*t*w[pr.b]; }
  return fn;
}
function contacts(B,W,h){
  const pos=B.pos, prev=B.prev, sc=Math.max(1,B.s), ih=1/h, mg=P.soft*6, hm=P.height*1.3+0.45, FS=W.fields, C=W.c3;
  CK=P.contactK; CCd=2.2*Math.sqrt(CK); CMU=P.friction*1.5; CUB=P.upBias; CP0=0.05*sc; FLX=0; FLZ=0;
  B.acc.fill(0); if(C) C.pen.fill(0);
  for(let q=0;q<B.probes.length;q++){
    const pr=B.probes[q], a=pr.a*3, b=pr.b*3, t=pr.t, it=1-t;
    const qx=pos[a]*it+pos[b]*t, qy=pos[a+1]*it+pos[b+1]*t, qz=pos[a+2]*it+pos[b+2]*t;
    const vx=((pos[a]-prev[a])*it+(pos[b]-prev[b])*t)*ih, vy=((pos[a+1]-prev[a+1])*it+(pos[b+1]-prev[b+1])*t)*ih, vz=((pos[a+2]-prev[a+2])*it+(pos[b+2]-prev[b+2])*t)*ih;
    for(let f=0;f<FS.length;f++){
      const F=FS[f], o=F.o, u=F.u, nn=F.nn, wv=F.wv;
      const rx=qx-o[0], ry=qy-o[1], rz=qz-o[2];
      const ly=rx*nn[0]+ry*nn[1]+rz*nn[2]; if(ly>hm+pr.r) continue;
      const lx=(rx*u[0]+ry*u[1]+rz*u[2])/F.su, lz=(rx*wv[0]+ry*wv[1]+rz*wv[2])/F.sv;
      if(!inDomain(F.S,lx,lz,0)) continue;
      fieldQuery(F,lx,lz);
      const s=(ly-QH)*QNY-pr.r;
      if(s<0.05){ const cw=1-s/0.05; F.cacc[nearestCell(F,lx,lz)]+=cw>1?1:cw; }
      if(s>=0) continue;
      const NX=u[0]*QNX+nn[0]*QNY+wv[0]*QNZ, NY=u[1]*QNX+nn[1]*QNY+wv[1]*QNZ, NZ=u[2]*QNX+nn[2]*QNY+wv[2]*QNZ;
      const fn=applyContact(B,pr,Math.min(-s,0.25*sc),NX,NY,NZ,nn[0],nn[1],nn[2],(QVS+F.pv)*QNY,vx,vy,vz);
      if(mg>0 && fn>0) dentForce(F,lx,lz,fn*mg,Math.max(0.12,pr.r*2),h);
    }
    if(C){
      const ang=P.dir*Math.PI/180, fs=P.push*0.18; FLX=Math.sin(ang)*fs; FLZ=Math.cos(ang)*fs; CMU=Math.max(P.friction,0.25)*1.5;
      const c0=nearestCell3(C,qx,qy,qz), nb=C.nb[c0].idx, reach=pr.r+0.05;
      for(let k=-1;k<nb.length;k++){
        const c=k<0?c0:nb[k];
        const d=cellDepth(C,c,qx,qy,qz,reach); if(d<-reach) continue;
        const pen=d+pr.r;
        if(pen>C.pen[c]) C.pen[c]=pen;
        if(pen>-0.05){ const cw=1+pen/0.05; C.cacc[c]+=cw>1?1:cw; }
        if(pen<=0) continue;
        // seitlich gequetscht -> nach oben herausgedrückt (Tragen), von oben gedrückt -> normale Richtung
        const up=C3Y>-0.3 && P.gravity>0.05; CUB=P.upBias*Math.min(1,P.gravity/2);
        applyContact(B,pr,Math.min(pen,0.25*sc),C3X,C3Y,C3Z,up?0:C3X,up?1:C3Y,up?0:C3Z,C3V,vx,vy,vz);
      }
    }
  }
}
// weiche Raumgrenzen (Box): Feder-Dämpfer kurz vor der harten Wand
function softWalls(B,W,h){
  const BD=W.bound; if(!W.c3) return;
  const pos=B.pos, prev=B.prev, acc=B.acc, w=B.w, ih=1/h, z0=0.1, m=0.05;
  const lo=[-1e9, m*0.4, -1e9], hi=[1e9, 1e9, 1e9];
  for(let j=0;j<B.N;j++) for(let c=0;c<3;c++){
    const i=j*3+c, x=pos[i], v=(x-prev[i])*ih;
    let p=lo[c]+z0-x;
    if(p>0){ let f=CK*p*(1+(p/0.04)**2)-CCd*v; if(f>0) acc[i]+=f*w[j]; continue; }
    p=x-(hi[c]-z0);
    if(p>0){ let f=CK*p*(1+(p/0.04)**2)+CCd*v; if(f>0) acc[i]-=f*w[j]; }
  }
}
// Notanschlag gegen tiefes Durchdringen – verschiebt Position UND Vorposition, erzeugt also keinen Stoß
function shiftProbe(B,pr,NX,NY,NZ,corr){
  const pos=B.pos, prev=B.prev, w=B.w, a=pr.a*3, b=pr.b*3, t=pr.t, it=1-t, single=(pr.a===pr.b), wa=w[pr.a], wb=single?0:w[pr.b];
  let da, db; if(single){ da=1; db=0; } else { const den=it*it*wa+t*t*wb; da=it*wa/den; db=t*wb/den; }
  pos[a]+=NX*corr*da; pos[a+1]+=NY*corr*da; pos[a+2]+=NZ*corr*da;
  prev[a]+=NX*corr*da; prev[a+1]+=NY*corr*da; prev[a+2]+=NZ*corr*da;
  if(!single){ pos[b]+=NX*corr*db; pos[b+1]+=NY*corr*db; pos[b+2]+=NZ*corr*db; prev[b]+=NX*corr*db; prev[b+1]+=NY*corr*db; prev[b+2]+=NZ*corr*db; }
}
function backstop(B,W){
  const pos=B.pos, maxPen=0.16*Math.max(1,B.s), hm=P.height*1.3+0.45, FS=W.fields, C=W.c3;
  for(let q=0;q<B.probes.length;q++){
    const pr=B.probes[q], a=pr.a*3, b=pr.b*3, t=pr.t, it=1-t;
    for(let f=0;f<FS.length;f++){
      const F=FS[f], o=F.o, u=F.u, nn=F.nn, wv=F.wv;
      const qx=pos[a]*it+pos[b]*t, qy=pos[a+1]*it+pos[b+1]*t, qz=pos[a+2]*it+pos[b+2]*t;
      const rx=qx-o[0], ry=qy-o[1], rz=qz-o[2];
      const ly=rx*nn[0]+ry*nn[1]+rz*nn[2]; if(ly>hm) continue;
      const lx=(rx*u[0]+ry*u[1]+rz*u[2])/F.su, lz=(rx*wv[0]+ry*wv[1]+rz*wv[2])/F.sv;
      if(!inDomain(F.S,lx,lz,0)) continue;
      fieldQuery(F,lx,lz);
      const s=(ly-QH)*QNY-pr.r+maxPen; if(s>=0) continue;
      shiftProbe(B,pr,u[0]*QNX+nn[0]*QNY+wv[0]*QNZ,u[1]*QNX+nn[1]*QNY+wv[1]*QNZ,u[2]*QNX+nn[2]*QNY+wv[2]*QNZ,-s*0.5);
    }
    if(C){
      const qx=pos[a]*it+pos[b]*t, qy=pos[a+1]*it+pos[b+1]*t, qz=pos[a+2]*it+pos[b+2]*t;
      const c0=nearestCell3(C,qx,qy,qz), nb=C.nb[c0].idx;
      for(let k=-1;k<nb.length;k++){
        const c=k<0?c0:nb[k], d=cellDepth(C,c,qx,qy,qz,0);
        const over=d+pr.r-maxPen; if(over>0) shiftProbe(B,pr,C3X,C3Y,C3Z,over*0.5);
      }
    }
  }
}
// ---- Selbstkollision: Kapseln der Gliedmaßen dürfen sich nicht durchdringen
const SEGS=[['pelvis','chest',.11],['head','crown',.09],['shL','elL',.05],['elL','wrL',.045],['shR','elR',.05],['elR','wrR',.045],
  ['hpL','knL',.07],['knL','anL',.055],['anL','toL',.04],['hpR','knR',.07],['knR','anR',.055],['anR','toR',.04]];
const SEG_SKIP=[[0,1],[0,2],[0,4],[0,6],[0,9]];
function buildSegs(B){
  B.segs=SEGS.map(([a,b,r])=>({a:JI[a],b:JI[b],r:r*B.s}));
  B.spairs=[];
  for(let i=0;i<B.segs.length;i++) for(let j=i+1;j<B.segs.length;j++){
    const A=B.segs[i], Bq=B.segs[j];
    if(A.a===Bq.a||A.a===Bq.b||A.b===Bq.a||A.b===Bq.b) continue;
    if(SEG_SKIP.some(([p,q])=>p===i&&q===j)) continue;
    B.spairs.push(i,j);
  }
}
function selfCollide(B,k){
  const pos=B.pos, w=B.w, S=B.segs, sp=B.spairs;
  for(let m=0;m<sp.length;m+=2){
    const A=S[sp[m]], Q=S[sp[m+1]], a1=A.a*3, a2=A.b*3, b1=Q.a*3, b2=Q.b*3, rs=A.r+Q.r;
    const d1x=pos[a2]-pos[a1], d1y=pos[a2+1]-pos[a1+1], d1z=pos[a2+2]-pos[a1+2];
    const d2x=pos[b2]-pos[b1], d2y=pos[b2+1]-pos[b1+1], d2z=pos[b2+2]-pos[b1+2];
    const rx=pos[a1]-pos[b1], ry=pos[a1+1]-pos[b1+1], rz=pos[a1+2]-pos[b1+2];
    const aa=d1x*d1x+d1y*d1y+d1z*d1z, ee=d2x*d2x+d2y*d2y+d2z*d2z, ff=d2x*rx+d2y*ry+d2z*rz;
    if(aa<1e-9||ee<1e-9) continue;
    const cc=d1x*rx+d1y*ry+d1z*rz, bb=d1x*d2x+d1y*d2y+d1z*d2z, den=aa*ee-bb*bb;
    let s=den>1e-9?clamp((bb*ff-cc*ee)/den,0,1):0, t=(bb*s+ff)/ee;
    if(t<0){ t=0; s=clamp(-cc/aa,0,1); } else if(t>1){ t=1; s=clamp((bb-cc)/aa,0,1); }
    const nx=rx+d1x*s-d2x*t, ny=ry+d1y*s-d2y*t, nz=rz+d1z*s-d2z*t, dist=Math.sqrt(nx*nx+ny*ny+nz*nz);
    if(dist>=rs||dist<1e-7) continue;
    const ux=nx/dist, uy=ny/dist, uz=nz/dist;
    const w1=(1-s)*w[A.a], w2=s*w[A.b], w3=(1-t)*w[Q.a], w4=t*w[Q.b];
    const den2=(1-s)*w1+s*w2+(1-t)*w3+t*w4; if(den2<1e-9) continue;
    const lam=(rs-dist)*k/den2;
    pos[a1]+=ux*lam*w1; pos[a1+1]+=uy*lam*w1; pos[a1+2]+=uz*lam*w1;
    pos[a2]+=ux*lam*w2; pos[a2+1]+=uy*lam*w2; pos[a2+2]+=uz*lam*w2;
    pos[b1]-=ux*lam*w3; pos[b1+1]-=uy*lam*w3; pos[b1+2]-=uz*lam*w3;
    pos[b2]-=ux*lam*w4; pos[b2+1]-=uy*lam*w4; pos[b2+2]-=uz*lam*w4;
  }
}
// Begrenzung: runde Arena oder Raum-Box
function bound(B,BD){
  const pos=B.pos, prev=B.prev, N=B.N;
  if(BD.type==='floor'){
    for(let j=0;j<N;j++){ const i=j*3+1; if(pos[i]<0.02){ pos[i]=0.02; if(prev[i]<0.02) prev[i]=0.02; } }
  } else if(BD.type==='disk'){
    const RW=BD.R-0.25;
    for(let j=0;j<N;j++){
      const x=pos[j*3], z=pos[j*3+2], r2=x*x+z*z;
      if(r2>RW*RW){ const k=RW/Math.sqrt(r2); pos[j*3]*=k; pos[j*3+2]*=k; prev[j*3]*=k; prev[j*3+2]*=k; }
    }
  } else {
    const m=0.05, lo=[-BD.a+m, (BD.y0||0)+m*0.4, -BD.a+m], hi=[BD.a-m, BD.hr-m, BD.a-m];
    for(let j=0;j<N;j++) for(let c=0;c<3;c++){
      const i=j*3+c, v=pos[i];
      if(v<lo[c]){ pos[i]=lo[c]; if(prev[i]<lo[c]) prev[i]=lo[c]; }
      else if(v>hi[c]){ pos[i]=hi[c]; if(prev[i]>hi[c]) prev[i]=hi[c]; }
    }
  }
}
function stepBody(B,W,h){
  const N=B.N, pos=B.pos, prev=B.prev, w=B.w, g=-P.gravity, damp=0.996, IT=5;
  const vmax=P.maxSpeed*h*Math.max(1,B.s), hh=h*h, acc=B.acc, accS=B.accS;
  contacts(B,W,h);
  softWalls(B,W,h);
  // Kontaktkräfte zeitlich glätten -> keine Schläge, sondern ein Anschwellen der Kraft
  const tau=0.005+P.smooth*0.06, al=h/(tau+h);
  for(let i=0;i<acc.length;i++) accS[i]+=(acc[i]-accS[i])*al;
  for(let j=0;j<N;j++){
    const i=j*3;
    let vx=(pos[i]-prev[i])*damp, vy=(pos[i+1]-prev[i+1])*damp, vz=(pos[i+2]-prev[i+2])*damp;
    const m=Math.sqrt(vx*vx+vy*vy+vz*vz); if(m>vmax){ const k=vmax/m; vx*=k; vy*=k; vz*=k; }
    prev[i]=pos[i]; prev[i+1]=pos[i+1]; prev[i+2]=pos[i+2];
    pos[i]+=vx+accS[i]*hh; pos[i+1]+=vy+(g+accS[i+1])*hh; pos[i+2]+=vz+accS[i+2]*hh;
  }
  for(let it=0;it<IT;it++){
    if(B.drag && it===0){
      const j=B.drag.j*3;
      pos[j]+=(B.drag.x-pos[j])*0.3; pos[j+1]+=(B.drag.y-pos[j+1])*0.3; pos[j+2]+=(B.drag.z-pos[j+2])*0.3;
    }
    for(let c=0;c<B.nc;c++){
      const a=B.ia[c]*3, b=B.ib[c]*3;
      const dx=pos[b]-pos[a], dy=pos[b+1]-pos[a+1], dz=pos[b+2]-pos[a+2];
      const d=Math.sqrt(dx*dx+dy*dy+dz*dz); if(d<1e-9) continue;
      if(B.type[c]===1 && d>=B.len[c]) continue;
      const wa=w[B.ia[c]], wb=w[B.ib[c]], s=B.k[c]*(d-B.len[c])/(d*(wa+wb));
      pos[a]+=dx*s*wa; pos[a+1]+=dy*s*wa; pos[a+2]+=dz*s*wa;
      pos[b]-=dx*s*wb; pos[b+1]-=dy*s*wb; pos[b+2]-=dz*s*wb;
    }
    limits(B);
    selfCollide(B,0.5);
    if(it===IT-1) backstop(B,W);
    bound(B,W.bound);
  }
  for(let j=0;j<N;j++){
    const i=j*3, vx=pos[i]-prev[i], vy=pos[i+1]-prev[i+1], vz=pos[i+2]-prev[i+2], m=Math.sqrt(vx*vx+vy*vy+vz*vz);
    if(m>vmax){ const k=vmax/m; prev[i]=pos[i]-vx*k; prev[i+1]=pos[i+1]-vy*k; prev[i+2]=pos[i+2]-vz*k; }
  }
}
let simAcc=0;
function simFrame(B,W,dt){
  const FS=W.c3?[W.c3]:W.fields;
  simTime+=dt; for(const F of FS) F.cacc.fill(0);
  if(P.mode==='answer') pulse(FS,dt);
  if(W.c3) cellProx(W.c3,B);
  simAcc+=dt; const H=1/180; let steps=0;
  while(simAcc>=H && steps<8){
    for(const F of FS) updateCells(F,H);
    if(W.c3){ updateCells3(W.c3,H); placeCells(W,H,B); }
    if(W.space==='room') placeRoom(W,H);
    stepBody(B,W,H);
    if(!W.c3) for(const F of FS){ stepGrid(F,H); updateDentMean(F); updateSurface(F,H); }
    simAcc-=H; steps++;
  }
  if(simAcc>H*2) simAcc=0;
  const sn=Math.max(1,steps);
  for(const F of FS) for(let i=0;i<F.n;i++) F.contact[i]=F.cacc[i]/sn;
  for(let i=0;i<B.pos.length;i++) if(!isFinite(B.pos[i])) return false;
  return true;
}
// ==SIM-END==

// ---------------------------------------------------------------
// Szene
// ---------------------------------------------------------------
const $ = s => document.querySelector(s);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff);
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.05, 300);
const controls = new OrbitControls(camera, renderer.domElement);
function viewFrom(tilt){
  const room = world && world.space !== 'floor', ext = world ? world.ext : 3.6;
  const a = innerWidth / innerHeight, d = (ext * (room ? 1.5 : 1.12)) / (Math.tan(THREE.MathUtils.degToRad(17.5)) * Math.min(1, a));
  const ty = world && world.c3 ? world.c3.o[1] : room ? world.bound.hr * 0.3 : 0;
  const tx = world && world.c3 ? world.c3.o[0] : 0, tz = world && world.c3 ? world.c3.o[2] : 0;
  controls.target.set(tx, ty, tz);
  camera.position.set(tx, ty + d * Math.cos(tilt), tz + d * Math.sin(tilt)); camera.lookAt(tx, ty, tz); controls.update();
}
controls.enableDamping = true; controls.dampingFactor = 0.08;
controls.minDistance = 1.2; controls.maxDistance = 60; controls.maxPolarAngle = Math.PI;
controls.enablePan = true; controls.screenSpacePanning = true; controls.zoomToCursor = true;
controls.listenToKeyEvents(window); controls.keyPanSpeed = 14;
let camMode = false;
function setCamMode(on){
  camMode = on;
  controls.mouseButtons = { LEFT: on ? THREE.MOUSE.ROTATE : -1, MIDDLE: THREE.MOUSE.PAN, RIGHT: on ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE };
  controls.touches = { ONE: on ? THREE.TOUCH.ROTATE : -1, TWO: on ? THREE.TOUCH.DOLLY_PAN : THREE.TOUCH.DOLLY_ROTATE };
  $('#bCam').classList.toggle('on', on);
}

scene.add(new THREE.HemisphereLight(0xffffff, 0xe6e8ee, 1.7));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(-4, 11, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
Object.assign(sun.shadow.camera, { left: -5.5, right: 5.5, top: 5.5, bottom: -5.5, near: 1, far: 30 });
sun.shadow.bias = -0.0006; sun.shadow.normalBias = 0.03;
scene.add(sun);

// ---- Kammerflächen (Boden bzw. 6 Raumflächen), jede in eigener Gruppe mit lokalem Rahmen
let world = null, fieldVis = [];
const baseMat = new THREE.MeshStandardMaterial({ color: 0xe4e6eb, roughness: 1, side: THREE.DoubleSide });
const chamberMat = new THREE.MeshPhysicalMaterial({ vertexColors: true, flatShading: true, roughness: 0.9, metalness: 0, side: THREE.DoubleSide });
// Materialeinstellungen aus dem Panel übernehmen
function applyMaterials(){
  chamberMat.roughness = P.rough; chamberMat.metalness = P.metal;
  chamberMat.clearcoat = P.coat; chamberMat.clearcoatRoughness = 0.25;
  chamberMat.sheen = P.sheen; chamberMat.sheenRoughness = 0.6; chamberMat.sheenColor.set(P.colInf);
  chamberMat.opacity = P.opacity; chamberMat.transparent = P.opacity < 0.999; chamberMat.depthWrite = P.opacity > 0.6;
  if (chamberMat.flatShading !== P.facets){ chamberMat.flatShading = P.facets; chamberMat.needsUpdate = true; }
  chamberMat.needsUpdate = true;
  const c = new THREE.Color(P.colDef).multiplyScalar(0.93); baseMat.color.copy(c); roomBoxMat.color.copy(c);
  bodyMat.color.set(P.colBody); glbMat.color.set(P.colBody);
}
const lineMat = new THREE.LineBasicMaterial({ color: 0xd2d5dc });
function buildFieldVis(F){
  const grp = new THREE.Group();
  const m = new THREE.Matrix4().makeBasis(new THREE.Vector3(...F.u), new THREE.Vector3(...F.nn), new THREE.Vector3(...F.wv));
  grp.quaternion.setFromRotationMatrix(m); grp.position.set(...F.o);
  const isFloor = F.nn[1] > 0.5;
  const baseGeo = F.S.type === 'disk' ? new THREE.CircleGeometry(F.S.R, 72) : new THREE.PlaneGeometry(2 * F.S.hu, 2 * F.S.hv);
  const base = new THREE.Mesh(baseGeo.rotateX(-Math.PI / 2), baseMat);
  base.position.y = -0.004; base.receiveShadow = true; grp.add(base);
  const RES = P.meshRes | 0, nr = [3, 5, 8, 12][RES - 1], segL = [0.32, 0.22, 0.12, 0.07][RES - 1];
  const rings = []; for (let k = 0; k < nr; k++) rings.push(0.92 * k / nr);
  const pos = [], cell = [], fac = [], idx = []; let bse = 0;
  const factor = (i, x, z) => prof(Math.min(1, edgeDist(F, i, x, z) / F.W[i]));
  for (let i = 0; i < F.n; i++){
    const poly = F.polys[i]; if (poly.length < 3) continue;
    const bp = [];
    for (let k = 0; k < poly.length; k++){
      const a = poly[k], b = poly[(k + 1) % poly.length], ns = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.z - a.z) / segL));
      for (let s = 0; s < ns; s++){ const u = s / ns; bp.push([a.x + (b.x - a.x) * u, a.z + (b.z - a.z) * u]); }
    }
    const mm = bp.length;
    for (const r of rings) for (let k = 0; k < mm; k++){
      const x = bp[k][0] + (F.cx[i] - bp[k][0]) * r, z = bp[k][1] + (F.cz[i] - bp[k][1]) * r;
      pos.push(x, 0, z); cell.push(i); fac.push(factor(i, x, z));
    }
    pos.push(F.cx[i], 0, F.cz[i]); cell.push(i); fac.push(factor(i, F.cx[i], F.cz[i]));
    const c = bse + rings.length * mm;
    for (let r = 0; r < rings.length - 1; r++) for (let k = 0; k < mm; k++){
      const k1 = (k + 1) % mm, A0 = bse + r * mm + k, A1 = bse + r * mm + k1, B0 = bse + (r + 1) * mm + k, B1 = bse + (r + 1) * mm + k1;
      idx.push(A0, A1, B1, A0, B1, B0);
    }
    const lr = bse + (rings.length - 1) * mm;
    for (let k = 0; k < mm; k++) idx.push(lr + k, lr + (k + 1) % mm, c);
    bse += rings.length * mm + 1;
  }
  const fPos = new Float32Array(pos), fCell = Uint16Array.from(cell), fFac = Float32Array.from(fac), fCol = new Float32Array(pos.length);
  const g = new THREE.BufferGeometry();
  const pa = new THREE.BufferAttribute(fPos, 3); pa.setUsage(THREE.DynamicDrawUsage);
  const ca = new THREE.BufferAttribute(fCol, 3); ca.setUsage(THREE.DynamicDrawUsage);
  g.setAttribute('position', pa); g.setAttribute('color', ca); g.setIndex(idx);
  g.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0.5, 0), domainExt(F.S) * 1.6 + 3);
  const mesh = new THREE.Mesh(g, chamberMat);
  mesh.castShadow = isFloor; mesh.receiveShadow = true; mesh.frustumCulled = false; mesh.userData.F = F;
  grp.add(mesh);
  const ls = [];
  for (let i = 0; i < F.n; i++){
    const poly = F.polys[i], mm = poly.length;
    for (let k = 0; k < mm; k++){
      const tg = poly[k].tag; if (tg >= 0 && tg < i) continue;
      const b = poly[(k + 1) % mm]; ls.push(poly[k].x, 0.003, poly[k].z, b.x, 0.003, b.z);
    }
  }
  const lg = new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.Float32BufferAttribute(ls, 3));
  grp.add(new THREE.LineSegments(lg, lineMat));
  scene.add(grp);
  return { F, grp, mesh, fPos, fCell, fFac, fCol };
}
function clearWorldVis(){
  for (const v of fieldVis){ scene.remove(v.grp); v.grp.traverse(o => { if (o.geometry) o.geometry.dispose(); }); }
  fieldVis = [];
}
function buildWorldVis(){
  clearWorldVis();
  if (cellVis){ scene.remove(cellVis.ground); cellVis.ground.geometry.dispose(); scene.remove(cellVis.grp); cellVis.grp.traverse(o => { if (o.geometry) o.geometry.dispose(); }); cellVis = null; }
  if (world.c3) cellVis = buildCellsVis(world.c3);
  else fieldVis = world.fields.map(buildFieldVis);
}
const _cl = new THREE.Vector3(), _cD = new THREE.Color(), _cI = new THREE.Color();
function updateWorldVis(){
  if (cellVis){ updateCellsVis(); return; }
  const H = P.height; _cD.set(P.colDef); _cI.set(P.colInf);
  const cDef = [_cD.r, _cD.g, _cD.b], cInf = [_cI.r, _cI.g, _cI.b];
  for (const vis of fieldVis){
    const F = vis.F, V = F.V, fPos = vis.fPos, fCol = vis.fCol, fCell = vis.fCell, fFac = vis.fFac;
    // im Raum: Flächen, hinter denen die Kamera steht, ausblenden (Puppenhaus-Blick)
    if (world.space === 'room'){
      vis.grp.position.set(...F.o); vis.grp.scale.set(F.su, 1, F.sv);
      _cl.copy(camera.position).sub(vis.grp.position);
      vis.grp.visible = _cl.x * F.nn[0] + _cl.y * F.nn[1] + _cl.z * F.nn[2] > -0.05;
      if (!vis.grp.visible) continue;
    } else vis.grp.visible = true;
    for (let v = 0; v < fCell.length; v++){
      const c = fCell[v], val = V[c], t = Math.pow(val, 0.8);
      const h0 = H * F.hf[c] * val * fFac[v];
      fPos[v * 3 + 1] = surfaceH(F, c, h0, fPos[v * 3], fPos[v * 3 + 2]);
      fCol[v * 3] = lerp(cDef[0], cInf[0], t); fCol[v * 3 + 1] = lerp(cDef[1], cInf[1], t); fCol[v * 3 + 2] = lerp(cDef[2], cInf[2], t);
    }
    vis.mesh.geometry.attributes.position.needsUpdate = true;
    vis.mesh.geometry.attributes.color.needsUpdate = true;
  }
}

// ---- Zellraum-Darstellung: jede 3D-Zelle als Low-Poly-Kissen (Ikosaeder, Strahlen bis zur Kissenhaut)
const ICOS = {};
function getIco(detail){ return ICOS[detail] || (ICOS[detail] = makeIco(detail)); }
function makeIco(detail){ return (() => {
  const g = new THREE.IcosahedronGeometry(1, detail), p = g.attributes.position.array, map = new Map(), dirs = [], idx = [];
  for (let i = 0; i < p.length / 3; i++){
    const key = p[i * 3].toFixed(4) + ',' + p[i * 3 + 1].toFixed(4) + ',' + p[i * 3 + 2].toFixed(4);
    let k = map.get(key); if (k === undefined){ k = dirs.length / 3; map.set(key, k); dirs.push(p[i * 3], p[i * 3 + 1], p[i * 3 + 2]); }
    idx.push(k);
  }
  g.dispose(); return { dirs: Float32Array.from(dirs), idx, n: dirs.length / 3 };
})(); }
let cellVis = null;
const roomBoxMat = new THREE.MeshStandardMaterial({ color: 0xeceef1, roughness: 1 });
function buildCellsVis(C){
  const ICO = getIco(P.meshRes | 0), nd = ICO.n, nv = C.n * nd, pos = new Float32Array(nv * 3), col = new Float32Array(nv * 3), vcell = new Uint16Array(nv), idx = [];
  for (let i = 0; i < C.n; i++){ for (let k = 0; k < nd; k++) vcell[i * nd + k] = i; for (const t of ICO.idx) idx.push(i * nd + t); }
  const g = new THREE.BufferGeometry();
  const pa = new THREE.BufferAttribute(pos, 3); pa.setUsage(THREE.DynamicDrawUsage);
  const ca = new THREE.BufferAttribute(col, 3); ca.setUsage(THREE.DynamicDrawUsage);
  g.setAttribute('position', pa); g.setAttribute('color', ca); g.setIndex(idx);
  g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), C.EA * 2 + 1);
  const mesh = new THREE.Mesh(g, chamberMat); mesh.castShadow = true; mesh.receiveShadow = true; mesh.frustumCulled = false; mesh.userData.C = C;
  // nur noch ein Boden (folgt nicht dem Verband)
  const ground = new THREE.Mesh(new THREE.CircleGeometry(60, 96).rotateX(-Math.PI / 2), roomBoxMat);
  ground.position.y = -0.002; ground.receiveShadow = true; scene.add(ground);
  const grp = new THREE.Group(); grp.add(mesh); scene.add(grp);
  // Ebenen-Abstände vom Schwerpunkt (statisch)
  C.tc = C.pl.map((pl, i) => { const t = new Float64Array(pl.length / 4); for (let k = 0; k < t.length; k++) t[k] = pl[k * 4 + 3] - (C.cx[i] * pl[k * 4] + C.cy[i] * pl[k * 4 + 1] + C.cz[i] * pl[k * 4 + 2]); return t; });
  return { C, grp, mesh, ground, pos, col, vcell, ico: ICO };
}
const _pr = [];
function updateCellsVis(){
  const v = cellVis, C = v.C, nd = v.ico.n, D = v.ico.dirs, pos = v.pos, col = v.col, K = 0.04;
  // Sondenpositionen des Körpers (für sichtbare Dellen)
  // Verband gestaucht: Gruppe skalieren, Rechnung im Referenzraum
  // Verband: Gruppe = Ursprung O + Skalierung s, Rechnung im Referenzraum
  const sc = C.s, O = C.o, rx = x => (x - O[0]) / sc, ry = y => (y - O[1]) / sc, rz = z => (z - O[2]) / sc;
  v.grp.scale.setScalar(sc); v.grp.position.set(O[0], O[1], O[2]);
  const floorR = -O[1] / sc;   // Boden im Referenzraum
  const B = body, bp = B.pos; _pr.length = 0;
  for (const pr of B.probes){ const t = pr.t, it = 1 - t; _pr.push(rx(bp[pr.a * 3] * it + bp[pr.b * 3] * t), ry(bp[pr.a * 3 + 1] * it + bp[pr.b * 3 + 1] * t), rz(bp[pr.a * 3 + 2] * it + bp[pr.b * 3 + 2] * t), pr.r / sc); }
  const px = rx(bp[JI.pelvis * 3]), py = ry(bp[JI.pelvis * 3 + 1]), pz = rz(bp[JI.pelvis * 3 + 2]);
  // Anschnitt: Kissen zwischen Kamera und Körper ausblenden
  let cdx = rx(camera.position.x) - px, cdy = ry(camera.position.y) - py, cdz = rz(camera.position.z) - pz; const cl = Math.hypot(cdx, cdy, cdz) || 1; cdx /= cl; cdy /= cl; cdz /= cl;
  const ci = new THREE.Color(P.colInf), cd = new THREE.Color(P.colDef);
  for (let i = 0; i < C.n; i++){
    const cx = C.cx[i], cy = C.cy[i], cz = C.cz[i], g = C.g[i], pl = C.pl[i], tc = C.tc[i], m = tc.length, o6 = i * 6, tch = C.touch;
    const hidden = P.cut && ((cx - px) * cdx + (cy - py) * cdy + (cz - pz) * cdz) > C.Rq[i] * 0.4;
    const val = C.V[i], tt = Math.pow(val, 0.8);
    const cr = lerp(cd.r, ci.r, tt), cg = lerp(cd.g, ci.g, tt), cb = lerp(cd.b, ci.b, tt);
    const near = Math.hypot(cx - px, cy - py, cz - pz) < C.Rq[i] * 2.2 + 1.3;
    const rr = C.Rq[i] * C.kr - g;
    for (let k = 0; k < nd; k++){
      const o = (i * nd + k) * 3, dx = D[k * 3], dy = D[k * 3 + 1], dz = D[k * 3 + 2];
      let r = 0;
      if (!hidden && val >= 0.04){
        let rmin = 1e9; const rs = _rs; let c = 0;
        for (let q = 0; q < m; q++){ const dn = dx * pl[q * 4] + dy * pl[q * 4 + 1] + dz * pl[q * 4 + 2]; if (dn > 1e-4){ const rq = (tc[q] - g) / dn; rs[c++] = rq; if (rq < rmin) rmin = rq; } }
        let ex = dx, ey = dy, ez = dz;
        if ((dx < 0 && tch[o6]) || (dx > 0 && tch[o6 + 1])) ex = 0;
        if ((dy < 0 && tch[o6 + 2]) || (dy > 0 && tch[o6 + 3])) ey = 0;
        if ((dz < 0 && tch[o6 + 4]) || (dz > 0 && tch[o6 + 5])) ez = 0;
        const el = Math.hypot(ex, ey, ez); if (el > 1e-4){ const rq = rr / el; rs[c++] = rq; if (rq < rmin) rmin = rq; }
        let s = 0; for (let q = 0; q < c; q++) s += Math.exp(-(rs[q] - rmin) / K);
        r = rmin - K * Math.log(s);
        // der Boden schneidet hart
        if (dy < -1e-4) r = Math.min(r, (floorR - cy) / dy);
        if (r < 0) r = 0;
        // Delle: Körper drückt die Kissenhaut ein
        if (near && r > 0){
          const x = cx + dx * r, y = cy + dy * r, z = cz + dz * r; let push = 0;
          for (let q = 0; q < _pr.length; q += 4){
            const ddx = x - _pr[q], ddy = y - _pr[q + 1], ddz = z - _pr[q + 2], rad = _pr[q + 3] + 0.07, d2 = ddx * ddx + ddy * ddy + ddz * ddz;
            if (d2 < rad * rad){ const p = rad - Math.sqrt(d2); if (p > push) push = p; }
          }
          r = Math.max(0, r - push * (0.4 + 0.5 * Math.min(1, P.soft)));
        }
      }
      pos[o] = cx + dx * r; pos[o + 1] = cy + dy * r; pos[o + 2] = cz + dz * r;
      col[o] = cr; col[o + 1] = cg; col[o + 2] = cb;
    }
  }
  v.mesh.geometry.attributes.position.needsUpdate = true;
  v.mesh.geometry.attributes.color.needsUpdate = true;
}
const _rs = new Float64Array(80);

// ---- Körper: Dummy-Visual
let body = null, rig = null;
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x2b2d31, roughness: 0.62, metalness: 0.05, flatShading: true, side: THREE.DoubleSide });
const dummy = new THREE.Group(); scene.add(dummy);
const sphGeo = new THREE.IcosahedronGeometry(1, 1), cylGeo = new THREE.CylinderGeometry(1, 1, 1, 8, 1, false);
const DV = [['pelvis','chest',.10],['hpL','hpR',.07],['shL','shR',.06],['shL','hpL',.045],['shR','hpR',.045],['chest','neck',.055],['neck','head',.04],
  ['shL','elL',.05],['elL','wrL',.04],['shR','elR',.05],['elR','wrR',.04],['hpL','knL',.07],['knL','anL',.055],['hpR','knR',.07],['knR','anR',.055],
  ['anL','toL',.04],['anR','toR',.04]];
const dBones = DV.map(([a, b, r]) => { const m = new THREE.Mesh(cylGeo, bodyMat); m.castShadow = true; dummy.add(m); return { m, a: JI[a], b: JI[b], r }; });
const jointR = {}; DV.forEach(([a, b, r]) => { jointR[a] = Math.max(jointR[a] || 0, r); jointR[b] = Math.max(jointR[b] || 0, r); });
const dJoints = Object.entries(jointR).filter(([n]) => n !== 'head').map(([n, r]) => { const m = new THREE.Mesh(sphGeo, bodyMat); m.scale.setScalar(r * 1.05); m.castShadow = true; dummy.add(m); return { m, j: JI[n] }; });
const dHead = new THREE.Mesh(sphGeo, bodyMat); dHead.scale.set(0.085, 0.105, 0.095); dHead.castShadow = true; dummy.add(dHead);
const _y = new THREE.Vector3(0, 1, 0), _a = new THREE.Vector3(), _b = new THREE.Vector3(), _d = new THREE.Vector3();
function updateDummy(){
  const p = body.pos;
  for (const jt of dJoints) jt.m.position.set(p[jt.j * 3], p[jt.j * 3 + 1], p[jt.j * 3 + 2]);
  for (const bn of dBones){
    _a.set(p[bn.a * 3], p[bn.a * 3 + 1], p[bn.a * 3 + 2]); _b.set(p[bn.b * 3], p[bn.b * 3 + 1], p[bn.b * 3 + 2]);
    _d.subVectors(_b, _a); const len = _d.length(); if (len < 1e-6) continue;
    bn.m.position.addVectors(_a, _b).multiplyScalar(0.5); bn.m.scale.set(bn.r, len, bn.r); bn.m.quaternion.setFromUnitVectors(_y, _d.divideScalar(len));
  }
  const h = JI.head * 3, c = JI.crown * 3;
  dHead.position.set((p[h] + p[c]) / 2, (p[h + 1] + p[c + 1]) / 2, (p[h + 2] + p[c + 2]) / 2);
  _d.set(p[c] - p[h], p[c + 1] - p[h + 1], p[c + 2] - p[h + 2]).normalize(); dHead.quaternion.setFromUnitVectors(_y, _d);
}

// ---- Körper: GLB-Rig (Mixamo-artige Bones) mit Swing-Retargeting
// Bone-Namen vereinheitlichen: mixamorig:, mixamorig1:, mixamorig_, Blender-Suffixe .001, Armature|…
const norm = n => n.toLowerCase().replace(/\.\d+$/, '').replace(/^.*[|/]/, '').replace(/^(mixamorig\d*|def|org)[:_\-]?/, '').replace(/[^a-z0-9]/g, '');
// GLTF-Loader mit Draco- und Meshopt-Unterstützung (komprimierte Exporte)
function makeLoader(){
  const l = new GLTFLoader();
  const d = new DRACOLoader(); d.setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/draco/gltf/'); l.setDRACOLoader(d);
  l.setMeshoptDecoder(MeshoptDecoder);
  return l;
}
const ALIAS = {
  pelvis: ['hips', 'pelvis', 'hip'], spine: ['spine'], spine1: ['spine1'], chest: ['spine2', 'chest', 'upperchest', 'spine1'],
  neck: ['neck'], head: ['head'], crown: ['headtopend', 'headtop', 'headend'],
  shL: ['leftarm', 'upperarml', 'upperarmleft', 'armleft'], elL: ['leftforearm', 'lowerarml', 'forearml', 'forearmleft'], wrL: ['lefthand', 'handl', 'handleft'],
  shR: ['rightarm', 'upperarmr', 'upperarmright', 'armright'], elR: ['rightforearm', 'lowerarmr', 'forearmr', 'forearmright'], wrR: ['righthand', 'handr', 'handright'],
  hpL: ['leftupleg', 'thighl', 'upperlegl', 'thighleft'], knL: ['leftleg', 'calfl', 'shinl', 'lowerlegl', 'calfleft'], anL: ['leftfoot', 'footl', 'footleft'], toL: ['lefttoebase', 'balll', 'toel', 'toebasel'],
  hpR: ['rightupleg', 'thighr', 'upperlegr', 'thighright'], knR: ['rightleg', 'calfr', 'shinr', 'lowerlegr', 'calfright'], anR: ['rightfoot', 'footr', 'footright'], toR: ['righttoebase', 'ballr', 'toer', 'toebaser']
};
function findBones(root){
  const list = []; root.traverse(o => { if (o.name) list.push(o); });
  const found = {};
  for (const [key, names] of Object.entries(ALIAS)){
    for (const nm of names){ const o = list.find(x => norm(x.name) === nm); if (o){ found[key] = o; break; } }
    if (!found[key]) for (const nm of names){
      // Fallback: Name endet auf den gesuchten Namen (z. B. 'character_lefthand'); Bones bevorzugt
      const c = list.filter(x => norm(x.name).endsWith(nm)).sort((a, b) => (b.isBone - a.isBone) || a.name.length - b.name.length);
      if (c.length){ found[key] = c[0]; break; }
    }
  }
  return found;
}
const frameQ = (xv, yv) => {
  const x = xv.clone().normalize(), z = new THREE.Vector3().crossVectors(x, yv).normalize(), y = new THREE.Vector3().crossVectors(z, x).normalize();
  return new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z));
};
let glbRoot = null;
const glbMat = new THREE.MeshStandardMaterial({ color: 0x2b2d31, roughness: 0.6, metalness: 0.05, side: THREE.DoubleSide });
function setupRig(root, label){
  const bn = findBones(root);
  const need = ['pelvis', 'chest', 'neck', 'head', 'shL', 'elL', 'wrL', 'shR', 'elR', 'wrR', 'hpL', 'knL', 'anL', 'hpR', 'knR', 'anR'];
  const miss = need.filter(k => !bn[k]);
  if (miss.length){
    const names = []; root.traverse(o => { if (o.isBone) names.push(o.name); });
    console.warn('KAMMERBODEN: fehlende Bones', miss, 'gefunden:', names);
    status('Skelett nicht erkannt – fehlt: ' + miss.slice(0, 4).join(', ') + (names.length ? ' · Bones im GLB: ' + names.slice(0, 6).join(', ') + (names.length > 6 ? ' …' : '') : ' · das GLB enthält keine Bones'));
    return false;
  }
  root.updateMatrixWorld(true);
  let box = new THREE.Box3().setFromObject(root); const hh = box.max.y - box.min.y;
  if (!(hh > 1e-6)){ status('Modell hat keine Höhe'); return false; }
  root.scale.multiplyScalar(1.75 * P.bodyScale / hh); root.updateMatrixWorld(true);
  box = new THREE.Box3().setFromObject(root); root.position.y -= box.min.y; root.updateMatrixWorld(true);
  const JP = {}; const wp = o => o.getWorldPosition(new THREE.Vector3());
  for (const k of need) JP[k] = wp(bn[k]);
  JP.crown = bn.crown ? wp(bn.crown) : JP.head.clone().add(JP.head.clone().sub(JP.neck).normalize().multiplyScalar(0.19));
  for (const sd of ['L', 'R']) JP['to' + sd] = bn['to' + sd] ? wp(bn['to' + sd]) : JP['an' + sd].clone().add(new THREE.Vector3(0, -JP['an' + sd].y * 0.5, 0.17));
  const restArr = JN.map(n => [JP[n].x, JP[n].y, JP[n].z]);
  const s = (JP.crown.y - Math.min(JP.anL.y, JP.anR.y) + 0.06) / 1.76;
  const wq = o => o.getWorldQuaternion(new THREE.Quaternion());
  const rt = { root, bn, JP,
    restPel: frameQ(JP.hpL.clone().sub(JP.hpR), JP.chest.clone().sub(JP.pelvis)),
    restChest: frameQ(JP.shL.clone().sub(JP.shR), JP.neck.clone().sub(JP.chest)),
    spine: [], swing: [] };
  rt.restPelInv = rt.restPel.clone().invert(); rt.restChestInv = rt.restChest.clone().invert();
  rt.hipsQ = wq(bn.pelvis);
  if (bn.spine && bn.spine !== bn.chest) rt.spine.push({ o: bn.spine, q: wq(bn.spine), w: 0.33 });
  if (bn.spine1 && bn.spine1 !== bn.chest && bn.spine1 !== bn.spine) rt.spine.push({ o: bn.spine1, q: wq(bn.spine1), w: 0.66 });
  rt.chestQ = wq(bn.chest);
  const sw = (key, bone, a, b, parent) => rt.swing.push({ key, o: bn[bone] || bone, q: wq(bn[bone]), a: JI[a], b: JI[b], parent, rd: JP[b].clone().sub(JP[a]).normalize() });
  sw('neck', 'neck', 'neck', 'head', 'chest'); sw('head', 'head', 'head', 'crown', 'neck');
  for (const sd of ['L', 'R']){
    sw('arm' + sd, 'sh' + sd, 'sh' + sd, 'el' + sd, 'chest'); sw('fore' + sd, 'el' + sd, 'el' + sd, 'wr' + sd, 'arm' + sd);
    sw('up' + sd, 'hp' + sd, 'hp' + sd, 'kn' + sd, 'pelvis'); sw('low' + sd, 'kn' + sd, 'kn' + sd, 'an' + sd, 'up' + sd);
    sw('foot' + sd, 'an' + sd, 'an' + sd, 'to' + sd, 'low' + sd);
  }
  if (glbRoot){ scene.remove(glbRoot); }
  glbRoot = root; scene.add(root);
  root.traverse(o => { if (o.isMesh){ o.castShadow = true; o.receiveShadow = true; o.frustumCulled = false; o.material = glbMat; } });
  dummy.visible = false;
  rig = rt; body = makeBody(restArr, s); resetBody();
  $('#bDummy').classList.remove('on'); $('#bGlb').classList.add('on');
  status('GLB: ' + label);
  return true;
}
const _p = new THREE.Vector3(), _q = new THREE.Quaternion(), _tq = new THREE.Quaternion();
function applyRig(){
  const r = rig, p = body.pos, J = n => _p.set(p[JI[n] * 3], p[JI[n] * 3 + 1], p[JI[n] * 3 + 2]).clone();
  const dPel = frameQ(J('hpL').sub(J('hpR')), J('chest').sub(J('pelvis'))).multiply(r.restPelInv);
  const dChest = frameQ(J('shL').sub(J('shR')), J('neck').sub(J('chest'))).multiply(r.restChestInv);
  const setW = (o, qw) => { o.parent.getWorldQuaternion(_tq); o.quaternion.copy(_tq.invert().multiply(qw)); };
  const hips = r.bn.pelvis;
  hips.parent.updateWorldMatrix(true, false);
  hips.position.copy(hips.parent.worldToLocal(J('pelvis')));
  setW(hips, dPel.clone().multiply(r.hipsQ));
  for (const sp of r.spine) setW(sp.o, dPel.clone().slerp(dChest, sp.w).multiply(sp.q));
  setW(r.bn.chest, dChest.clone().multiply(r.chestQ));
  const D = { pelvis: dPel, chest: dChest };
  for (const e of r.swing){
    const par = D[e.parent];
    const cur = new THREE.Vector3(p[e.b * 3] - p[e.a * 3], p[e.b * 3 + 1] - p[e.a * 3 + 1], p[e.b * 3 + 2] - p[e.a * 3 + 2]).normalize();
    const rd = e.rd.clone().applyQuaternion(par);
    const d = _q.setFromUnitVectors(rd, cur).clone().multiply(par);
    D[e.key] = d;
    setW(e.o, d.clone().multiply(e.q));
  }
}

// ---- Body-Umschaltung
function useDummy(){
  if (glbRoot){ scene.remove(glbRoot); glbRoot = null; }
  rig = null; dummy.visible = true;
  $('#bDummy').classList.add('on'); $('#bGlb').classList.remove('on');
  const sc = P.bodyScale, rest = JN.map(n => DUMMY_REST[n].map(v => v * sc));
  body = makeBody(rest, sc); resetBody();
  status('Dummy');
}
function resetBody(){ placeLying(body, world && world.c3 ? 0.55 * P.bodyScale : 0.35 * P.bodyScale); if (world && world.c3) snapCells(world, body); }
let lastGlb = null;
async function loadGLB(url, label){
  try {
    status('lade ' + label + ' …');
    const gltf = await makeLoader().loadAsync(url);
    if (!setupRig(gltf.scene, label)) return false;
    lastGlb = { url, label };
    return true;
  } catch (e){ console.error(e); status('GLB-Fehler: ' + (e.message || e)); return false; }
}
// lokale Datei direkt aus dem Speicher parsen (kein fetch nötig – funktioniert auch in Vorschau-Fenstern)
async function loadGLBFile(f){
  try {
    status('lade ' + f.name + ' …');
    const buf = await f.arrayBuffer();
    const gltf = await makeLoader().parseAsync(buf, '');
    if (!setupRig(gltf.scene, f.name)) return false;
    lastGlb = { buf, label: f.name };
    return true;
  } catch (e){ console.error(e); status('GLB-Fehler: ' + (e.message || e)); return false; }
}
async function reloadLast(){
  if (!lastGlb) return useDummy();
  if (lastGlb.buf){ const g = await makeLoader().parseAsync(lastGlb.buf.slice(0), ''); setupRig(g.scene, lastGlb.label); }
  else loadGLB(lastGlb.url, lastGlb.label);
}
// minimal_body.glb automatisch suchen: neben der HTML, im Ordner darüber und im Stammverzeichnis der Domain
async function autoLoad(){
  const pu = new URLSearchParams(location.search).get('glb');
  const names = [pu, 'minimal_body.glb', 'MINIMAL_BODY.glb', 'minimal-body.glb', 'Minimal_Body.glb', 'body.glb', 'MIMICRY_BODY.glb', 'mimicry_body.glb'].filter(Boolean);
  const dirs = ['', '../', '../../', '/'];
  const tried = new Set(); let foundAt = null;
  if (location.protocol === 'file:'){ status('Dummy · lokal (file://) darf der Browser keine GLB nachladen – Datei per Drag&Drop'); return; }
  status('suche minimal_body.glb …');
  for (const n of names) for (const d of dirs){
    let url; try { url = new URL(/^(https?:)?\//.test(n) ? n : d + n, location.href).href; } catch (e) { continue; }
    if (tried.has(url)) continue; tried.add(url);
    try {
      const r = await fetch(url, { cache: 'no-cache' });
      if (!r.ok) continue;
      const buf = await r.arrayBuffer();
      const m = new Uint8Array(buf, 0, Math.min(4, buf.byteLength));
      const isGlb = m.length === 4 && m[0] === 0x67 && m[1] === 0x6C && m[2] === 0x54 && m[3] === 0x46;   // 'glTF'
      if (!isGlb) continue;   // z. B. WordPress-404-Seite
      foundAt = url;
      const gltf = await makeLoader().parseAsync(buf.slice(0), url.slice(0, url.lastIndexOf('/') + 1));
      if (setupRig(gltf.scene, n + ' (' + url.replace(location.origin, '') + ')')){ lastGlb = { buf, label: n }; return; }
      else return;   // gefunden, aber Skelett passt nicht: Meldung stehen lassen
    } catch (e) { if (foundAt){ console.error(e); status('GLB gefunden (' + foundAt.replace(location.origin, '') + '), aber Fehler: ' + (e.message || e)); return; } }
  }
  status('Dummy · minimal_body.glb nicht gefunden');
}

// ---------------------------------------------------------------
// Interaktion
// ---------------------------------------------------------------
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), hit = new THREE.Vector3();
function setRay(e){
  const r = renderer.domElement.getBoundingClientRect();
  ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); ray.setFromCamera(ndc, camera);
}
function pickParticle(e){
  const r = renderer.domElement.getBoundingClientRect(), v = new THREE.Vector3(); let best = -1, bd = 38 * 38;
  for (let j = 0; j < body.N; j++){
    v.set(body.pos[j * 3], body.pos[j * 3 + 1], body.pos[j * 3 + 2]).project(camera);
    if (v.z > 1) continue;
    const sx = (v.x * 0.5 + 0.5) * r.width + r.left, sy = (-v.y * 0.5 + 0.5) * r.height + r.top;
    const d = (sx - e.clientX) ** 2 + (sy - e.clientY) ** 2; if (d < bd){ bd = d; best = j; }
  }
  return best;
}
const _lp = new THREE.Vector3();
function paint(e){
  setRay(e);
  if (cellVis){
    const h = ray.intersectObject(cellVis.mesh, false);
    if (!h.length) return;
    const ci = cellVis.vcell[h[0].face.a], C = cellVis.C;
    C.ovT[ci] = P.brush === 'inflate' ? 1 : 0; C.ovUntil[ci] = P.mode === 'manual' ? Infinity : simTime + 3;
    return;
  }
  const hits = ray.intersectObjects(fieldVis.filter(v => v.grp.visible).map(v => v.mesh), false);
  if (!hits.length) return;
  const F = hits[0].object.userData.F;
  _lp.copy(hits[0].point).sub(new THREE.Vector3(...F.o));
  const lx = _lp.x * F.u[0] + _lp.y * F.u[1] + _lp.z * F.u[2], lz = _lp.x * F.wv[0] + _lp.y * F.wv[1] + _lp.z * F.wv[2];
  if (!inDomain(F.S, lx, lz, 0)) return;
  const bi = nearestCell(F, lx, lz);
  F.ovT[bi] = P.brush === 'inflate' ? 1 : 0;
  F.ovUntil[bi] = P.mode === 'manual' ? Infinity : simTime + 3;
}
let painting = false, dragDY = 0;
const cv = renderer.domElement;
cv.addEventListener('pointerdown', e => {
  if (e.button !== 0 || camMode) return;
  const j = pickParticle(e);
  if (j >= 0){
    body.drag = { j, x: body.pos[j * 3], y: body.pos[j * 3 + 1] + 0.1, z: body.pos[j * 3 + 2] }; dragDY = body.drag.y;
  } else { painting = true; paint(e); }
});
cv.addEventListener('pointermove', e => {
  if (body.drag){
    setRay(e); plane.constant = -dragDY;
    if (ray.ray.intersectPlane(plane, hit)){ body.drag.x = hit.x; body.drag.z = hit.z; }
  } else if (painting) paint(e);
});
const endPtr = () => { body.drag = null; painting = false; };
addEventListener('pointerup', endPtr); addEventListener('pointercancel', endPtr);
cv.addEventListener('contextmenu', e => e.preventDefault());

// ---------------------------------------------------------------
// UI
// ---------------------------------------------------------------
function status(t){ $('#st').textContent = t; }
const SL = [['bodyScale', 'Körpergröße', .6, 2.5, .05], ['gravity', 'Gravitation', 0, 12, .1], ['friction', 'Reibung', 0, 1.5, .01], ['height', 'Kammerhöhe', .2, 3, .01],
  ['contactK', 'Härte der Kammerhaut', 4, 120, 1], ['soft', 'Weichheit der Membran', 0, 1.5, .01], ['smooth', 'Sanftheit (Kraft anschwellen)', 0, 1, .01], ['round', 'Rundung der Kammern', 0, 1, .01], ['vari', 'Größenvariation', 0, 1, .01],
  ['lift', 'Hubgeschwindigkeit (m/s)', .05, 3, .01], ['maxSpeed', 'Max. Körpertempo (m/s)', .5, 8, .1], ['upBias', 'Tragen statt Wegschieben', 0, 1, .01],
  ['inflateRate', 'Aufblasen', .2, 4, .05], ['deflateRate', 'Deflaten', .2, 6, .05], ['flow', 'Luftaustausch', 0, 2, .01],
  ['reaction', 'Reaktion (− weichen / + tragen)', -1.2, 1.2, .01], ['tempo', 'Tempo', 0, 2, .01]];
$('#sliders').innerHTML = SL.map(([k, l, mn, mx, st]) =>
  `<div class="row"><div class="lab">${l}<b id="o_${k}"></b></div><input type="range" id="s_${k}" min="${mn}" max="${mx}" step="${st}" value="${P[k]}"></div>`).join('');
for (const [k] of SL){
  const el = $('#s_' + k), out = $('#o_' + k), f = () => { P[k] = +el.value; out.textContent = (+el.value).toFixed(2); };
  el.addEventListener('input', f); f();
}
const ML = [['rough', 'Rauheit', 0, 1, .01], ['metal', 'Metallisch', 0, 1, .01], ['coat', 'Klarlack', 0, 1, .01], ['sheen', 'Samt / Stoff', 0, 1, .01], ['opacity', 'Deckkraft', 0.15, 1, .01]];
$('#matSliders').innerHTML = ML.map(([k, l, mn, mx, st]) =>
  `<div class="row"><div class="lab">${l}<b id="o_${k}"></b></div><input type="range" id="s_${k}" min="${mn}" max="${mx}" step="${st}" value="${P[k]}"></div>`).join('');
for (const [k] of [...ML, ['squeeze'], ['roomBreath'], ['hug'], ['wave'], ['fill'], ['push'], ['dir']]){
  const el = $('#s_' + k), out = $('#o_' + k), f = () => { P[k] = +el.value; out.textContent = (+el.value).toFixed(2); applyMaterials(); };
  el.addEventListener('input', f); f();
}
for (const k of ['colDef', 'colInf', 'colBody']) $('#c_' + k).addEventListener('input', e => { P[k] = e.target.value; applyMaterials(); });
const mrEl = $('#s_meshRes'), mrLab = ['grob', 'mittel', 'fein', 'sehr fein'];
const mrShow = () => { $('#o_meshRes').textContent = mrLab[+mrEl.value - 1]; };
mrEl.addEventListener('input', mrShow); mrShow();
mrEl.addEventListener('change', () => { P.meshRes = +mrEl.value; buildWorldVis(); });
$('#bFacets').onclick = e => { P.facets = !P.facets; e.target.classList.toggle('on', P.facets); applyMaterials(); };
$('#bCut').onclick = e => { P.cut = !P.cut; e.target.classList.toggle('on', P.cut); };
$('#s_round').addEventListener('input', () => { if (world.c3) reshapeCells(world.c3); else { for (const F of world.fields) reshapeFloor(F); buildWorldVis(); } });
$('#s_vari').addEventListener('change', () => newFloor(false));
$('#s_bodyScale').addEventListener('change', () => { if (rig && lastGlb) reloadLast(); else useDummy(); });
const cellsEl = $('#cells'); $('#o_cells').textContent = cellsEl.value;
cellsEl.addEventListener('input', () => { $('#o_cells').textContent = cellsEl.value; });
cellsEl.addEventListener('change', () => newFloor(false));
function seg(sel, attr, cb){
  const bs = [...document.querySelectorAll(sel + ' button')];
  bs.forEach(b => b.addEventListener('click', () => { bs.forEach(x => x.classList.toggle('on', x === b)); cb(b.dataset[attr]); }));
}
const allCh = () => world.c3 ? [world.c3] : world.fields;
seg('#modes', 'm', m => { P.mode = m; for (const F of allCh()) F.ovUntil.fill(0); });
// jede Ortsart merkt sich ihre Gravitation (Zellraum startet schwerelos)
const gravBy = { floor: 3.5, room: 3.5, cells: 0 };
seg('#space', 's', sp => {
  gravBy[P.space] = P.gravity; P.space = sp; P.gravity = gravBy[sp]; $('#s_gravity').value = P.gravity; $('#o_gravity').textContent = P.gravity.toFixed(2);
  $('#roomRow').style.display = sp === 'floor' ? 'none' : ''; $('#squeezeRows').style.display = sp === 'floor' ? 'none' : ''; $('#cellRows').style.display = sp === 'cells' ? '' : 'none';
  newFloor(false); resetBody(); viewFrom(sp === 'floor' ? 0.38 : 0.75);
});
seg('#brush', 'b', b => { P.brush = b; });
let paused = false;
$('#bPause').onclick = e => { paused = !paused; e.target.classList.toggle('on', paused); e.target.textContent = paused ? 'Weiter' : 'Pause'; };
$('#bReset').onclick = resetBody;
$('#bNew').onclick = () => newFloor(true);
const setAll = v => { for (const F of allCh()){ F.ovT.fill(v); F.ovUntil.fill(P.mode === 'manual' ? Infinity : simTime + 5); } };
$('#bFlat').onclick = () => setAll(0);
$('#bFull').onclick = () => setAll(1);
const roomEl = $('#s_roomsize');
roomEl.addEventListener('input', () => { $('#o_roomsize').textContent = (+roomEl.value).toFixed(2); });
roomEl.addEventListener('change', () => { P.room = +roomEl.value; newFloor(false); resetBody(); viewFrom(0.75); });
$('#bCam').onclick = () => setCamMode(!camMode);
$('#bTop').onclick = () => viewFrom(0.02);
$('#bDummy').onclick = useDummy;
$('#bGlb').onclick = () => $('#file').click();
$('#file').addEventListener('change', e => { const f = e.target.files[0]; if (f) loadGLBFile(f); e.target.value = ''; });
$('#toggle').onclick = () => { const c = $('#content'); c.classList.toggle('hide'); $('#toggle').textContent = c.classList.contains('hide') ? '+' : '–'; };
addEventListener('dragover', e => { e.preventDefault(); $('#drop').style.display = 'flex'; });
addEventListener('dragleave', e => { if (!e.relatedTarget) $('#drop').style.display = 'none'; });
addEventListener('drop', e => {
  e.preventDefault(); $('#drop').style.display = 'none';
  const f = e.dataTransfer.files[0]; if (f && /\.glb$/i.test(f.name)) loadGLBFile(f); else if (f) status('bitte eine .glb-Datei (binär) ablegen');
});
addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
});
function newFloor(reseed){
  P.cells = +cellsEl.value;
  world = buildWorld(P.space, P.cells, reseed ? (Math.random() * 1e9) | 0 : 20261008, P.vari);
  let tot = 0; for (const F of allCh()) tot += F.n;
  $('#o_cells').textContent = world.c3 ? tot + ' Raumzellen' : world.fields.length > 1 ? tot + ' auf 6 Flächen' : tot + ' / ' + cellsEl.value;
  buildWorldVis();
}

// ---------------------------------------------------------------
// Start
// ---------------------------------------------------------------
setCamMode(false);
newFloor(false);
viewFrom(0.38);
useDummy();
autoLoad();
let last = performance.now(), camFollow = null;
function loop(now){
  const dt = Math.min(0.05, (now - last) / 1000); last = now;
  if (!paused){
    if (!simFrame(body, world, dt)){ resetBody(); }
  }
  // Zellraum: Kamera wandert mit dem Verband mit
  if (world.c3){
    const o = world.c3.o; if (!camFollow) camFollow = [...o];
    const dx = o[0] - camFollow[0], dz = o[2] - camFollow[2];
    camera.position.x += dx; camera.position.z += dz; controls.target.x += dx; controls.target.z += dz; camFollow = [...o];
  } else camFollow = null;
  updateWorldVis();
  if (rig) applyRig(); else updateDummy();
  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
Assets
minimal_body.glb  (998 KB)
normal_map.jpg  (154 KB)