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