index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Inflate body</title>
<style>
:root{--ink:#111;--mute:#8c8c8c;--line:#e7e7e7;--soft:#f5f5f5}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--ink);font:12.5px/1.45 "Helvetica Neue",Helvetica,Arial,sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}
#c{position:fixed;left:0;top:0;width:100%;height:100%;display:block;touch-action:none;outline:none}
#title{position:fixed;left:20px;top:18px;font-weight:600;font-size:13px;pointer-events:none}
#status{position:fixed;left:20px;top:38px;color:var(--mute);pointer-events:none;max-width:calc(100vw - 372px)}
#hint{position:fixed;left:20px;bottom:16px;color:var(--mute);max-width:calc(100vw - 372px);pointer-events:none}
#dots{position:fixed;left:0;top:0;width:100%;height:100%;pointer-events:none;overflow:hidden}
.dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border:1px solid var(--ink);background:#fff;border-radius:50%;pointer-events:auto;cursor:grab;touch-action:none}
.dot:hover{background:#ddd}.dot.sel{background:var(--ink)}
#panel{position:fixed;right:16px;top:16px;bottom:16px;width:300px;background:#fff;border:1px solid var(--line);border-radius:6px;display:flex;flex-direction:column;overflow:hidden}
#tabs{display:flex;border-bottom:1px solid var(--line);flex:none}
#tabs button{flex:1;padding:11px 0 9px;min-width:0;background:none;border:0;border-bottom:2px solid transparent;color:var(--mute);cursor:pointer;font:inherit}
#tabs button.on{color:var(--ink);border-bottom-color:var(--ink)}
#body{flex:1;overflow:auto}
.selbox{width:100%;margin:6px 0 2px;padding:6px 8px;border:1px solid var(--ink);border-radius:4px;background:#fff;color:var(--ink);font:inherit}
input.clr{width:44px;height:24px;border:1px solid var(--ink);border-radius:4px;padding:0;background:#fff;cursor:pointer}
#tabs button{font-size:12px;padding-left:0;padding-right:0}
#ov{position:fixed;left:0;right:316px;bottom:52px;display:none;justify-content:center;pointer-events:none}
.ovc{pointer-events:auto;background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:6px;padding:14px 18px 16px;width:380px;max-width:90%}
.ovt{font-weight:600;font-size:13px}.ovb{height:3px;background:var(--line);margin:10px 0 8px}.ovb i{display:block;height:100%;width:0;background:var(--ink)}
.ovl{color:#555;margin-bottom:2px}.ovc .btn{margin-top:10px}
.pane{display:none;padding:14px 14px 20px}.pane.on{display:block}
.note{margin:0 0 12px;color:var(--mute)}
.sl{margin:10px 0}.sltop{display:flex;justify-content:space-between}.val{color:var(--mute);font-variant-numeric:tabular-nums}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:16px;background:transparent;margin:0;display:block}
input[type=range]::-webkit-slider-runnable-track{height:1px;background:#bdbdbd}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:#111;margin-top:-5px;border:0}
input[type=range]::-moz-range-track{height:1px;background:#bdbdbd}
input[type=range]::-moz-range-thumb{width:11px;height:11px;border:0;border-radius:50%;background:#111}
.btns{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
.btn{border:1px solid var(--ink);background:#fff;color:var(--ink);padding:6px 10px;border-radius:4px;cursor:pointer;font:inherit}
.btn:hover{background:var(--soft)}.btn.fill{background:var(--ink);color:#fff}.btn.fill:hover{background:#333}
.seg{display:flex;margin:4px 0 10px;border:1px solid var(--ink);border-radius:4px;overflow:hidden}
.seg button{flex:1;border:0;background:#fff;padding:6px 0;cursor:pointer;font:inherit}.seg button.on{background:var(--ink);color:#fff}
.chk{display:flex;align-items:center;gap:8px;margin:8px 0;cursor:pointer}.chk input{accent-color:#111;margin:0}
.bar{height:2px;background:var(--line);margin:8px 0 4px}.bar i{display:block;height:100%;width:0;background:var(--ink)}
.btn[disabled]{opacity:.35;cursor:default}.btn[disabled]:hover{background:#fff}
.selbox{width:100%;margin:6px 0 2px;padding:6px 8px;border:1px solid var(--ink);border-radius:4px;background:#fff;color:var(--ink);font:inherit}
hr{border:0;border-top:1px solid var(--line);margin:14px 0}
.seamrow{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
.seamrow span{flex:1}.seamrow small{color:var(--mute);margin-left:6px}
.x{border:0;background:none;cursor:pointer;font-size:15px;color:var(--mute);padding:0 4px}.x:hover{color:var(--ink)}
.sw{display:inline-block;width:12px;height:12px;border:1px solid var(--ink);vertical-align:-2px;margin-right:6px}
#drop{position:fixed;inset:0;background:rgba(255,255,255,.9);display:none;align-items:center;justify-content:center;font-size:15px;pointer-events:none}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="dots"></div>
<div id="title">Inflate body</div>
<div id="status"></div>
<div id="hint"></div>
<div id="drop">Datei hier ablegen</div>
<div id="ov"><div class="ovc"><div class="ovt" id="ovT">Simulation</div><div class="ovb"><i id="ovBar"></i></div><div class="ovl" id="ovL"></div><div class="ovl" id="ovS"></div><button class="btn" id="ovX">Abbrechen</button></div></div>
<div id="panel"><div id="tabs"></div><div id="body"></div></div>
<input type="file" id="glbIn" accept=".glb,.gltf" hidden>
<input type="file" id="projIn" accept=".json,application/json" hidden>
<input type="file" id="texIn" accept="image/*" hidden>
<input type="file" id="norIn" accept="image/*" hidden>
<input type="file" id="pngIn" accept="image/png" hidden>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';
const T=window.THREE;
if(!T){document.body.insertAdjacentHTML('beforeend','<p style="padding:24px">three.js konnte nicht geladen werden.</p>');return;}
const $=(s)=>document.querySelector(s);
const clamp=(x,a,b)=>x<a?a:(x>b?b:x);
const lerp=(a,b,t)=>a+(b-a)*t;
const sstep=(a,b,x)=>{const t=clamp((x-a)/(b-a),0,1);return t*t*(3-2*t);};
/* ===================== Datenstrukturen ===================== */
class Heap{
constructor(){this.k=[];this.v=[];this.pt=0;}
get size(){return this.k.length;}
push(key,val){const k=this.k,v=this.v;let i=k.length;k.push(key);v.push(val);
while(i>0){const p=(i-1)>>1;if(k[p]<=key)break;k[i]=k[p];v[i]=v[p];i=p;}k[i]=key;v[i]=val;}
pop(){const k=this.k,v=this.v;const tk=k[0],tv=v[0];const lk=k.pop(),lv=v.pop();const n=k.length;
if(n>0){let i=0;for(;;){let l=2*i+1;if(l>=n)break;const r=l+1;if(r<n&&k[r]<k[l])l=r;if(k[l]>=lk)break;k[i]=k[l];v[i]=v[l];i=l;}k[i]=lk;v[i]=lv;}
this.pt=tk;return tv;}
}
class PointGrid{
constructor(pos,cell){
let mn=[1e30,1e30,1e30],mx=[-1e30,-1e30,-1e30];
for(let i=0;i<pos.length;i+=3)for(let a=0;a<3;a++){mn[a]=Math.min(mn[a],pos[i+a]);mx[a]=Math.max(mx[a],pos[i+a]);}
const range=Math.max(mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2])||1;
this.cell=Math.max(cell,range/300);this.pos=pos;this.map=new Map();
for(let i=0;i<pos.length/3;i++){const k=this.key(Math.floor(pos[3*i]/this.cell),Math.floor(pos[3*i+1]/this.cell),Math.floor(pos[3*i+2]/this.cell));
let a=this.map.get(k);if(!a){a=[];this.map.set(k,a);}a.push(i);}
}
key(x,y,z){return ((x+1024)*2048+(y+1024))*2048+(z+1024);}
nearest(x,y,z,maxR){
const c=this.cell,cx=Math.floor(x/c),cy=Math.floor(y/c),cz=Math.floor(z/c),P=this.pos,R=maxR||300;let best=-1,bd=Infinity;
const scan=(a,b,d)=>{const l=this.map.get(this.key(cx+a,cy+b,cz+d));if(!l)return;
for(const i of l){const ex=P[3*i]-x,ey=P[3*i+1]-y,ez=P[3*i+2]-z,q=ex*ex+ey*ey+ez*ez;if(q<bd){bd=q;best=i;}}};
for(let r=0;r<=R;r++){
if(r===0)scan(0,0,0);
else for(let a=-r;a<=r;a++)for(let b=-r;b<=r;b++){
if(a===-r||a===r||b===-r||b===r){for(let d=-r;d<=r;d++)scan(a,b,d);}
else{scan(a,b,-r);scan(a,b,r);}
}
if(best>=0&&bd<=(r*c)*(r*c))break;
}
return best;
}
}
function buildTopology(nv,idx){
const nt=idx.length/3,em=new Map();
for(let t=0;t<nt;t++)for(let e=0;e<3;e++){
const a=idx[3*t+e],b=idx[3*t+(e+1)%3];const lo=a<b?a:b,hi=a<b?b:a,k=lo*nv+hi;em.set(k,(em.get(k)||0)+1);
}
const ne=em.size,ea=new Uint32Array(ne),eb=new Uint32Array(ne),ec=new Uint8Array(ne);let i=0;
for(const [k,c] of em){const lo=Math.floor(k/nv);ea[i]=lo;eb[i]=k-lo*nv;ec[i]=c;i++;}
const deg=new Uint32Array(nv);for(i=0;i<ne;i++){deg[ea[i]]++;deg[eb[i]]++;}
const off=new Uint32Array(nv+1);for(i=0;i<nv;i++)off[i+1]=off[i]+deg[i];
const fill=off.slice(0,nv),nbr=new Uint32Array(off[nv]),nb=new Uint8Array(off[nv]),bnd=new Uint8Array(nv);
for(i=0;i<ne;i++){const a=ea[i],b=eb[i],f=ec[i]===1?1:0;
nbr[fill[a]]=b;nb[fill[a]++]=f;nbr[fill[b]]=a;nb[fill[b]++]=f;if(f){bnd[a]=1;bnd[b]=1;}}
return {nv,off,nbr,nbB:nb,bnd};
}
function adjLengths(topo,pos){
const {off,nbr,nv}=topo,L=new Float32Array(nbr.length);
for(let i=0;i<nv;i++)for(let k=off[i];k<off[i+1];k++){const j=nbr[k];
L[k]=Math.hypot(pos[3*i]-pos[3*j],pos[3*i+1]-pos[3*j+1],pos[3*i+2]-pos[3*j+2]);}
return L;
}
function faceNormals(pos,idx,out){
out.fill(0);
for(let t=0;t<idx.length;t+=3){
const a=idx[t]*3,b=idx[t+1]*3,c=idx[t+2]*3;
const ux=pos[b]-pos[a],uy=pos[b+1]-pos[a+1],uz=pos[b+2]-pos[a+2],vx=pos[c]-pos[a],vy=pos[c+1]-pos[a+1],vz=pos[c+2]-pos[a+2];
const nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx;
out[a]+=nx;out[a+1]+=ny;out[a+2]+=nz;out[b]+=nx;out[b+1]+=ny;out[b+2]+=nz;out[c]+=nx;out[c+1]+=ny;out[c+2]+=nz;
}
for(let i=0;i<out.length;i+=3){const l=Math.sqrt(out[i]*out[i]+out[i+1]*out[i+1]+out[i+2]*out[i+2])||1;out[i]/=l;out[i+1]/=l;out[i+2]/=l;}
}
function spatialSortTris(pos,idx){
const nt=idx.length/3;let mn=[1e30,1e30,1e30],mx=[-1e30,-1e30,-1e30];
for(let i=0;i<pos.length;i+=3)for(let a=0;a<3;a++){mn[a]=Math.min(mn[a],pos[i+a]);mx[a]=Math.max(mx[a],pos[i+a]);}
const s=[0,1,2].map(a=>1023/((mx[a]-mn[a])||1)),keys=new Uint32Array(nt);
const part=v=>{v&=0x3ff;v=(v|(v<<16))&0x030000FF;v=(v|(v<<8))&0x0300F00F;v=(v|(v<<4))&0x030C30C3;v=(v|(v<<2))&0x09249249;return v;};
for(let t=0;t<nt;t++){const a=idx[3*t]*3,b=idx[3*t+1]*3,c=idx[3*t+2]*3;
const q=k=>Math.floor(((pos[a+k]+pos[b+k]+pos[c+k])/3-mn[k])*s[k]);
keys[t]=(part(q(0))|(part(q(1))<<1)|(part(q(2))<<2))>>>0;}
const order=new Uint32Array(nt);for(let i=0;i<nt;i++)order[i]=i;order.sort((x,y)=>keys[x]-keys[y]);
const out=new Uint32Array(idx.length);for(let i=0;i<nt;i++){const t=order[i];out[3*i]=idx[3*t];out[3*i+1]=idx[3*t+1];out[3*i+2]=idx[3*t+2];}
return out;
}
function blurField(m,topo,passes){
const {off,nbr,nv}=topo;let a=m,b=new Float32Array(nv);
for(let p=0;p<passes;p++){
for(let i=0;i<nv;i++){let s=a[i];const n=off[i+1]-off[i];for(let k=off[i];k<off[i+1];k++)s+=a[nbr[k]];b[i]=s/(n+1);}
const t=a;a=b;b=t;
}
if(a!==m)m.set(a);
}
/* ===================== Prozeduraler Körper (SDF + Surface Nets) ===================== */
function makeProceduralBody(){
const defs=[['Hips',null,0,0.96,0],['Spine','Hips',0,1.08,0],['Chest','Spine',0,1.26,0],['Neck','Chest',0,1.48,0],['Head','Neck',0,1.56,0],['HeadTop','Head',0,1.76,0]];
for(const [s,k] of [['L',1],['R',-1]]){
defs.push(['Shoulder'+s,'Chest',0.07*k,1.44,0],['UpperArm'+s,'Shoulder'+s,0.20*k,1.42,0],['ForeArm'+s,'UpperArm'+s,0.48*k,1.42,0],
['Hand'+s,'ForeArm'+s,0.73*k,1.42,0],['HandEnd'+s,'Hand'+s,0.86*k,1.42,0],
['UpperLeg'+s,'Hips',0.095*k,0.92,0],['LowerLeg'+s,'UpperLeg'+s,0.098*k,0.50,0],['Foot'+s,'LowerLeg'+s,0.10*k,0.085,0],['Toe'+s,'Foot'+s,0.10*k,0.04,0.14]);
}
const prims=[];const add=(b,a,c,ra,rb,fz)=>prims.push({b,a,c,ra,rb,fz:fz||1});
add('Hips',[0,0.93,0],[0,1.0,0],0.145,0.14,1.25);
add('Spine',[0,1.0,0],[0,1.2,0],0.125,0.135,1.3);
add('Chest',[0,1.22,0],[0,1.40,0],0.14,0.16,1.3);
add('Chest',[-0.17,1.42,0],[0.17,1.42,0],0.06,0.06,1);
add('Neck',[0,1.46,0],[0,1.56,0],0.058,0.05,1);
add('Head',[0,1.635,0],[0,1.665,0],0.092,0.092,1);
for(const [s,k] of [['L',1],['R',-1]]){
add('UpperArm'+s,[0.20*k,1.42,0],[0.48*k,1.42,0],0.052,0.043);
add('ForeArm'+s,[0.48*k,1.42,0],[0.73*k,1.42,0],0.043,0.033);
add('Hand'+s,[0.73*k,1.42,0],[0.84*k,1.42,0],0.033,0.028);
add('UpperLeg'+s,[0.095*k,0.93,0],[0.098*k,0.50,0],0.088,0.058);
add('LowerLeg'+s,[0.098*k,0.50,0],[0.10*k,0.09,0],0.056,0.04);
add('Foot'+s,[0.10*k,0.06,-0.03],[0.10*k,0.04,0.14],0.045,0.03);
}
const np=prims.length,PR=new Float64Array(np*10);
prims.forEach((p,i)=>{const o=i*10;PR[o]=p.a[0];PR[o+1]=p.a[1];PR[o+2]=p.a[2];
const bx=p.c[0]-p.a[0],by=p.c[1]-p.a[1],bz=p.c[2]-p.a[2];PR[o+3]=bx;PR[o+4]=by;PR[o+5]=bz;PR[o+6]=1/(bx*bx+by*by+bz*bz||1);PR[o+7]=p.ra;PR[o+8]=p.rb;PR[o+9]=p.fz;});
const pd=(i,x,y,z)=>{const o=i*10,fz=PR[o+9],pz=z*fz;const pax=x-PR[o],pay=y-PR[o+1],paz=pz-PR[o+2];
let t=(pax*PR[o+3]+pay*PR[o+4]+paz*PR[o+5])*PR[o+6];t=t<0?0:(t>1?1:t);
const dx=pax-PR[o+3]*t,dy=pay-PR[o+4]*t,dz=paz-PR[o+5]*t;
return (Math.sqrt(dx*dx+dy*dy+dz*dz)-(PR[o+7]+(PR[o+8]-PR[o+7])*t))/fz;};
const K=0.035;
const sdf=(x,y,z)=>{let d=pd(0,x,y,z);for(let i=1;i<np;i++){const e=pd(i,x,y,z);const h=Math.max(K-Math.abs(d-e),0)/K;d=Math.min(d,e)-h*h*K*0.25;}return d;};
const H=0.024,oy=-0.04,oz=-0.16;
const nx=2*Math.ceil(1.0/H)+1,ox=-(nx-1)/2*H,ny=Math.ceil(1.9/H)+1,nz=Math.ceil(0.46/H)+1; // zentriert: das Netz ist exakt links/rechts symmetrisch
const f=new Float32Array(nx*ny*nz);
for(let z=0;z<nz;z++)for(let y=0;y<ny;y++)for(let x=0;x<nx;x++)f[(z*ny+y)*nx+x]=sdf(ox+x*H,oy+y*H,oz+z*H);
// Surface Nets
const cx=nx-1,cy=ny-1,cz=nz-1,cell=new Int32Array(cx*cy*cz).fill(-1),V=[];
const co=[[0,0,0],[1,0,0],[0,1,0],[1,1,0],[0,0,1],[1,0,1],[0,1,1],[1,1,1]];
const ed=[[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]];
const val=new Float64Array(8);
for(let z=0;z<cz;z++)for(let y=0;y<cy;y++)for(let x=0;x<cx;x++){
let m=0;for(let c=0;c<8;c++){const o=co[c];const v=f[((z+o[2])*ny+(y+o[1]))*nx+(x+o[0])];val[c]=v;if(v<0)m|=1<<c;}
if(m===0||m===255)continue;
let sx=0,sy=0,sz=0,n=0;
for(const [a,b] of ed){if(((m>>a)&1)!==((m>>b)&1)){const t=val[a]/(val[a]-val[b]),A=co[a],B=co[b];
sx+=A[0]+(B[0]-A[0])*t;sy+=A[1]+(B[1]-A[1])*t;sz+=A[2]+(B[2]-A[2])*t;n++;}}
cell[(z*cy+y)*cx+x]=V.length/3;V.push(ox+(x+sx/n)*H,oy+(y+sy/n)*H,oz+(z+sz/n)*H);
}
const F=[],cid=(x,y,z)=>cell[(z*cy+y)*cx+x];
const quad=(a,b,c,d,fl)=>{if(fl)F.push(a,d,c,a,c,b);else F.push(a,b,c,a,c,d);};
for(let z=0;z<nz;z++)for(let y=0;y<ny;y++)for(let x=0;x<nx;x++){
const v0=f[(z*ny+y)*nx+x]<0;
if(x<nx-1&&y>=1&&y<ny-1&&z>=1&&z<nz-1){const v1=f[(z*ny+y)*nx+x+1]<0;if(v0!==v1)quad(cid(x,y-1,z-1),cid(x,y,z-1),cid(x,y,z),cid(x,y-1,z),!v0);}
if(y<ny-1&&x>=1&&x<nx-1&&z>=1&&z<nz-1){const v1=f[(z*ny+y+1)*nx+x]<0;if(v0!==v1)quad(cid(x-1,y,z-1),cid(x-1,y,z),cid(x,y,z),cid(x,y,z-1),!v0);}
if(z<nz-1&&x>=1&&x<nx-1&&y>=1&&y<ny-1){const v1=f[((z+1)*ny+y)*nx+x]<0;if(v0!==v1)quad(cid(x-1,y-1,z),cid(x,y-1,z),cid(x,y,z),cid(x-1,y,z),!v0);}
}
const nv=V.length/3,pos=new Float32Array(V),nrm=new Float32Array(nv*3);
// auf die Oberfläche projizieren, Normalen aus dem Gradienten
const e=H*0.5;
for(let i=0;i<nv;i++){
let x=pos[3*i],y=pos[3*i+1],z=pos[3*i+2],gx=0,gy=0,gz=1;
for(let it=0;it<3;it++){
const d=sdf(x,y,z);gx=sdf(x+e,y,z)-sdf(x-e,y,z);gy=sdf(x,y+e,z)-sdf(x,y-e,z);gz=sdf(x,y,z+e)-sdf(x,y,z-e);
const g2=gx*gx+gy*gy+gz*gz||1;const s=clamp(d*(2*e)/g2,-H,H);x-=gx*s;y-=gy*s;z-=gz*s;
}
const l=Math.sqrt(gx*gx+gy*gy+gz*gz)||1;pos[3*i]=x;pos[3*i+1]=y;pos[3*i+2]=z;nrm[3*i]=gx/l;nrm[3*i+1]=gy/l;nrm[3*i+2]=gz/l;
}
let idx=new Uint32Array(F);
// Orientierung prüfen (Volumen > 0)
let vol=0;for(let t=0;t<idx.length;t+=3){const a=idx[t]*3,b=idx[t+1]*3,c=idx[t+2]*3;
vol+=pos[a]*(pos[b+1]*pos[c+2]-pos[b+2]*pos[c+1])-pos[a+1]*(pos[b]*pos[c+2]-pos[b+2]*pos[c])+pos[a+2]*(pos[b]*pos[c+1]-pos[b+1]*pos[c]);}
if(vol<0)for(let t=0;t<idx.length;t+=3){const q=idx[t+1];idx[t+1]=idx[t+2];idx[t+2]=q;}
idx=spatialSortTris(pos,idx);
// Skin-Gewichte
const bi={};defs.forEach((d,i)=>bi[d[0]]=i);const pb=prims.map(p=>bi[p.b]),nb=defs.length,tau=0.016;
const si=new Uint16Array(nv*4),sw=new Float32Array(nv*4),w=new Float64Array(nb),dv=new Float64Array(np);
for(let i=0;i<nv;i++){
let dm=1e9;for(let j=0;j<np;j++){dv[j]=pd(j,pos[3*i],pos[3*i+1],pos[3*i+2]);if(dv[j]<dm)dm=dv[j];}
w.fill(0);for(let j=0;j<np;j++){const q=dv[j]-dm;if(q<6*tau)w[pb[j]]+=Math.exp(-q/tau);}
let tot=0;for(let k=0;k<4;k++){let bk=-1,bw=0;for(let b=0;b<nb;b++)if(w[b]>bw){bw=w[b];bk=b;}
if(bk<0)break;si[4*i+k]=bk;sw[4*i+k]=bw;tot+=bw;w[bk]=0;}
for(let k=0;k<4;k++)sw[4*i+k]/=tot||1;
}
return {pos,nrm,idx,si,sw,defs};
}
function weldGeometry(geo){
const A=geo.attributes,P=A.position,n0=P.count;let mn=[1e30,1e30,1e30],mx=[-1e30,-1e30,-1e30];
for(let i=0;i<n0;i++)for(let a=0;a<3;a++){const v=P.array[3*i+a];mn[a]=Math.min(mn[a],v);mx[a]=Math.max(mx[a],v);}
const q=Math.max(mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2])*1e-5,map=new Map(),remap=new Uint32Array(n0),keep=[];let nv=0;
for(let i=0;i<n0;i++){const k=Math.round(P.array[3*i]/q)+','+Math.round(P.array[3*i+1]/q)+','+Math.round(P.array[3*i+2]/q);
let id=map.get(k);if(id===undefined){id=nv++;map.set(k,id);keep.push(i);}remap[i]=id;}
const pos=new Float32Array(nv*3),si=new Uint16Array(nv*4),sw=new Float32Array(nv*4),SI=A.skinIndex.array,SW=A.skinWeight.array;
keep.forEach((o,i)=>{pos[3*i]=P.array[3*o];pos[3*i+1]=P.array[3*o+1];pos[3*i+2]=P.array[3*o+2];
let s=0;for(let k=0;k<4;k++){si[4*i+k]=SI[4*o+k];sw[4*i+k]=SW[4*o+k];s+=sw[4*i+k];}
if(s<=0){sw[4*i]=1;}else for(let k=0;k<4;k++)sw[4*i+k]/=s;});
const src=geo.index?geo.index.array:null,nt=(src?src.length:n0)/3,id=[];
for(let t=0;t<nt;t++){const a=remap[src?src[3*t]:3*t],b=remap[src?src[3*t+1]:3*t+1],c=remap[src?src[3*t+2]:3*t+2];if(a!==b&&b!==c&&a!==c)id.push(a,b,c);}
let idx=spatialSortTris(pos,new Uint32Array(id));const nrm=new Float32Array(nv*3);faceNormals(pos,idx,nrm);
return {pos,nrm,idx,si,sw};
}
/* ===================== Surf: gehäutete Oberfläche für Picking/Pinsel ===================== */
let rig=null;
function skinTo(mesh,pos,si,sw,out){
const sk=mesh.skeleton;sk.update();const bm=sk.boneMatrices,B=mesh.bindMatrix.elements;
const M=new T.Matrix4().multiplyMatrices(mesh.matrixWorld,mesh.bindMatrixInverse).elements,n=pos.length/3;
for(let i=0;i<n;i++){
const x=pos[3*i],y=pos[3*i+1],z=pos[3*i+2];
const bx=B[0]*x+B[4]*y+B[8]*z+B[12],by=B[1]*x+B[5]*y+B[9]*z+B[13],bz=B[2]*x+B[6]*y+B[10]*z+B[14];
let sx=0,sy=0,sz=0;
for(let k=0;k<4;k++){const w=sw[4*i+k];if(w===0)continue;const o=si[4*i+k]*16;
sx+=w*(bm[o]*bx+bm[o+4]*by+bm[o+8]*bz+bm[o+12]);sy+=w*(bm[o+1]*bx+bm[o+5]*by+bm[o+9]*bz+bm[o+13]);sz+=w*(bm[o+2]*bx+bm[o+6]*by+bm[o+10]*bz+bm[o+14]);}
out[3*i]=M[0]*sx+M[4]*sy+M[8]*sz+M[12];out[3*i+1]=M[1]*sx+M[5]*sy+M[9]*sz+M[13];out[3*i+2]=M[2]*sx+M[6]*sy+M[10]*sz+M[14];
}
}
class Surf{
constructor(mesh,pos,idx,si,sw){
this.mesh=mesh;this.pos=pos;this.idx=idx;this.si=si;this.sw=sw;this.n=pos.length/3;this.nt=idx.length/3;
this.world=new Float32Array(pos.length);this.nrm=new Float32Array(pos.length);
this.K=48;this.nc=Math.ceil(this.nt/this.K);this.cmin=new Float32Array(this.nc*3);this.cmax=new Float32Array(this.nc*3);this.dirty=true;
}
refresh(){
if(!this.dirty)return;this.dirty=false;rig.updateMatrixWorld(true);
skinTo(this.mesh,this.pos,this.si,this.sw,this.world);faceNormals(this.world,this.idx,this.nrm);
const W=this.world,I=this.idx;
for(let c=0;c<this.nc;c++){let a0=1e30,a1=1e30,a2=1e30,b0=-1e30,b1=-1e30,b2=-1e30;
const t1=Math.min(c*this.K+this.K,this.nt);
for(let t=c*this.K;t<t1;t++)for(let k=0;k<3;k++){const v=I[3*t+k]*3;
if(W[v]<a0)a0=W[v];if(W[v]>b0)b0=W[v];if(W[v+1]<a1)a1=W[v+1];if(W[v+1]>b1)b1=W[v+1];if(W[v+2]<a2)a2=W[v+2];if(W[v+2]>b2)b2=W[v+2];}
this.cmin[3*c]=a0;this.cmin[3*c+1]=a1;this.cmin[3*c+2]=a2;this.cmax[3*c]=b0;this.cmax[3*c+1]=b1;this.cmax[3*c+2]=b2;}
}
bbox(){this.refresh();const W=this.world;let mn=[1e30,1e30,1e30],mx=[-1e30,-1e30,-1e30];
for(let i=0;i<W.length;i+=3)for(let a=0;a<3;a++){if(W[i+a]<mn[a])mn[a]=W[i+a];if(W[i+a]>mx[a])mx[a]=W[i+a];}return {mn,mx};}
raycast(ox,oy,oz,dx,dy,dz){
this.refresh();const W=this.world,I=this.idx;
if(Math.abs(dx)<1e-12)dx=1e-12;if(Math.abs(dy)<1e-12)dy=1e-12;if(Math.abs(dz)<1e-12)dz=1e-12;
const ix=1/dx,iy=1/dy,iz=1/dz;let best=Infinity,bt=-1,bu=0,bv=0;
for(let c=0;c<this.nc;c++){
let a=(this.cmin[3*c]-ox)*ix,b=(this.cmax[3*c]-ox)*ix,tn=Math.min(a,b),tx=Math.max(a,b);
a=(this.cmin[3*c+1]-oy)*iy;b=(this.cmax[3*c+1]-oy)*iy;tn=Math.max(tn,Math.min(a,b));tx=Math.min(tx,Math.max(a,b));
a=(this.cmin[3*c+2]-oz)*iz;b=(this.cmax[3*c+2]-oz)*iz;tn=Math.max(tn,Math.min(a,b));tx=Math.min(tx,Math.max(a,b));
if(tx<0||tn>tx||tn>best)continue;
const t1=Math.min(c*this.K+this.K,this.nt);
for(let t=c*this.K;t<t1;t++){
const A=I[3*t]*3,B=I[3*t+1]*3,C=I[3*t+2]*3;
const e1x=W[B]-W[A],e1y=W[B+1]-W[A+1],e1z=W[B+2]-W[A+2],e2x=W[C]-W[A],e2y=W[C+1]-W[A+1],e2z=W[C+2]-W[A+2];
const px=dy*e2z-dz*e2y,py=dz*e2x-dx*e2z,pz=dx*e2y-dy*e2x,det=e1x*px+e1y*py+e1z*pz;
if(det>-1e-14&&det<1e-14)continue;const inv=1/det;
const tx_=ox-W[A],ty_=oy-W[A+1],tz_=oz-W[A+2],u=(tx_*px+ty_*py+tz_*pz)*inv;if(u<0||u>1)continue;
const qx=ty_*e1z-tz_*e1y,qy=tz_*e1x-tx_*e1z,qz=tx_*e1y-ty_*e1x,v=(dx*qx+dy*qy+dz*qz)*inv;if(v<0||u+v>1)continue;
const tt=(e2x*qx+e2y*qy+e2z*qz)*inv;if(tt>1e-6&&tt<best){best=tt;bt=t;bu=u;bv=v;}
}
}
if(bt<0)return null;
const A=I[3*bt],B=I[3*bt+1],C=I[3*bt+2],w0=1-bu-bv,N=this.nrm;
let nx=N[3*A]*w0+N[3*B]*bu+N[3*C]*bv,ny=N[3*A+1]*w0+N[3*B+1]*bu+N[3*C+1]*bv,nz=N[3*A+2]*w0+N[3*B+2]*bu+N[3*C+2]*bv;
const l=Math.sqrt(nx*nx+ny*ny+nz*nz)||1;
return {tri:bt,t:best,px:ox+dx*best,py:oy+dy*best,pz:oz+dz*best,n:[nx/l,ny/l,nz/l],v:[A,B,C],b:[w0,bu,bv]};
}
}
/* ===================== Zustand ===================== */
const P={brush:6,maskMode:'paint',grayVal:1,strength:0.35,gtarget:'P',showMap:true,subdiv:2,edgeSmooth:2,
fill:1,showBody:true,wire:false,chambers:true,orbit:false,mirror:false,
net:{mode:'voronoi',minArea:4,width:1,round:3,dense:3.5,sparse:9,tool:'pts',dval:1,dstr:0.5},
sim:{zug:60,press:1.2,soft:0.35,bend:0.3,iters:600,fair:60,preview:true,detail:0,rim:0.8,lift:0.3,mode:'press',dome:0.6,smooth:0.6,foldW:1.5,fold:25},
mat:{color:'#39ff14',rough:0.3,clear:0.6,clearRough:0.15,metal:0,env:0.8,glow:0.07,texOn:true,norOn:true,texScale:1.5,norScale:1.5,norS:1,norFlip:false,texMirror:true,norMirror:true}};
const AUTOKEY='inflate-body-autosave';
const S={sim:null,tab:'mask',body:null,shell:null,points:[],maskVer:0,shellVer:-1,touched:false,undo:[],editVer:0,
netDirty:false,heightsDirty:false,overlayDirty:true,stroke:null,drag:null,sel:null,space:false,
ptr:{x:0,y:0,inside:false},orbit:null,hit:null,mir:null,delStroke:false,snap:false,hq:null,visDirty:false,simValid:false,simStale:false,saveDirty:false,pngWhich:null,tex:{has:false,hasN:false,name:'',nameN:''},Ul:1,fillAnim:null,bones:[],rest:[],glbOk:false};
const EPS=0.004;
/* ===================== Szene ===================== */
const canvas=$('#c');
const renderer=new T.WebGLRenderer({canvas,antialias:true});
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));renderer.setClearColor(0xffffff,1);
const scene=new T.Scene();
const camera=new T.PerspectiveCamera(32,1,0.05,60);
scene.add(new T.HemisphereLight(0xffffff,0xbdbdbd,0.5));
const key=new T.DirectionalLight(0xffffff,0.5);key.position.set(2.2,4,3);scene.add(key);
const fillL=new T.DirectionalLight(0xffffff,0.16);fillL.position.set(-3,1.5,-2);scene.add(fillL);
(function(){const c=document.createElement('canvas');c.width=c.height=128;const g=c.getContext('2d'),r=g.createRadialGradient(64,64,0,64,64,64);
r.addColorStop(0,'rgba(0,0,0,0.16)');r.addColorStop(1,'rgba(0,0,0,0)');g.fillStyle=r;g.fillRect(0,0,128,128);
const m=new T.Mesh(new T.PlaneGeometry(1.7,1.7),new T.MeshBasicMaterial({map:new T.CanvasTexture(c),transparent:true,depthWrite:false}));
m.rotation.x=-Math.PI/2;m.position.y=0.002;scene.add(m);})();
const bodyMat=new T.MeshStandardMaterial({color:0xffffff,vertexColors:true,roughness:0.9,metalness:0,skinning:true});
const NEON='#39ff14';
function makeEnv(){
try{
const pm=new T.PMREMGenerator(renderer),es=new T.Scene();
const sky=new T.Mesh(new T.SphereGeometry(10,32,16),new T.MeshBasicMaterial({side:T.BackSide,vertexColors:true})),pa=sky.geometry.attributes.position,cols=new Float32Array(pa.count*3);
for(let i=0;i<pa.count;i++){const v=0.28+0.42*(pa.getY(i)/10*0.5+0.5);cols[3*i]=cols[3*i+1]=cols[3*i+2]=v;}
sky.geometry.setAttribute('color',new T.BufferAttribute(cols,3));es.add(sky);
const panel=(w,h,x,y,z,k)=>{const m=new T.Mesh(new T.PlaneGeometry(w,h),new T.MeshBasicMaterial({color:new T.Color(k,k,k),side:T.DoubleSide}));m.position.set(x,y,z);m.lookAt(0,0,0);es.add(m);};
panel(7,3,4,7,5,5);panel(3,7,-8,3,2,3.5);panel(9,2,0,2,-8,2.5);
const rt=pm.fromScene(es,0.04),tex=rt.texture;pm.dispose();return tex;
}catch(e){return null;}
}
/* Texturen ohne UVs: Dreiseiten-Projektion (Triplanar) im Ruhezustand, deshalb bleibt die Textur beim Posieren und Aufpumpen am Körper */
const texWhite=new T.DataTexture(new Uint8Array([255,255,255,255]),1,1,T.RGBAFormat);texWhite.needsUpdate=true;
const texFlat=new T.DataTexture(new Uint8Array([128,128,255,255]),1,1,T.RGBAFormat);texFlat.needsUpdate=true;
const mu={uTex:{value:texWhite},uNor:{value:texFlat},uTexOn:{value:0},uNorOn:{value:0},uUv:{value:1},uNorUv:{value:1},uNorS:{value:1},uNorFlip:{value:1}};
const TRI_GLSL=[
'vec3 triW(vec3 n){vec3 w=pow(abs(n),vec3(4.0));return w/(w.x+w.y+w.z+1e-5);}',
'vec3 triTex(sampler2D t,vec3 p,vec3 n){vec3 w=triW(n);return texture2D(t,p.zy).rgb*w.x+texture2D(t,p.xz).rgb*w.y+texture2D(t,p.xy).rgb*w.z;}',
'vec3 triNormal(vec3 N,vec3 ax,vec3 ay,vec3 az,sampler2D t,vec3 p,vec3 rn,float k,float fl){',
' vec3 w=triW(rn);',
' vec3 a=texture2D(t,p.zy).xyz*2.0-1.0;vec3 b=texture2D(t,p.xz).xyz*2.0-1.0;vec3 c=texture2D(t,p.xy).xyz*2.0-1.0;',
' vec3 d=(az*a.x+ay*(a.y*fl))*w.x+(ax*b.x+az*(b.y*fl))*w.y+(ax*c.x+ay*(c.y*fl))*w.z;',
' d-=dot(d,N)*N;return normalize(N+d*k);}'
].join('\n');
const shellMat=new T.MeshPhysicalMaterial({color:P.mat.color,vertexColors:true,roughness:P.mat.rough,metalness:P.mat.metal,clearcoat:P.mat.clear,clearcoatRoughness:P.mat.clearRough,reflectivity:0.5,side:T.DoubleSide,skinning:true});
shellMat.onBeforeCompile=function(sh){
Object.assign(sh.uniforms,mu);
sh.vertexShader=sh.vertexShader
.replace('#include <common>','#include <common>\nattribute vec3 restP;\nattribute vec3 restN;\nvarying vec3 vRP;\nvarying vec3 vRN;\nvarying vec3 vAX;\nvarying vec3 vAY;\nvarying vec3 vAZ;')
.replace('#include <begin_vertex>','#include <begin_vertex>\nvRP=restP;vRN=restN;\nmat3 mvr=mat3(modelViewMatrix);\n#ifdef USE_SKINNING\nvAX=normalize(mvr*(skinMatrix*vec4(1.0,0.0,0.0,0.0)).xyz);vAY=normalize(mvr*(skinMatrix*vec4(0.0,1.0,0.0,0.0)).xyz);vAZ=normalize(mvr*(skinMatrix*vec4(0.0,0.0,1.0,0.0)).xyz);\n#else\nvAX=normalize(mvr[0]);vAY=normalize(mvr[1]);vAZ=normalize(mvr[2]);\n#endif');
sh.fragmentShader=sh.fragmentShader
.replace('#include <common>','#include <common>\nuniform sampler2D uTex;uniform sampler2D uNor;uniform float uTexOn;uniform float uNorOn;uniform float uUv;uniform float uNorUv;uniform float uNorS;uniform float uNorFlip;\nvarying vec3 vRP;varying vec3 vRN;varying vec3 vAX;varying vec3 vAY;varying vec3 vAZ;\n'+TRI_GLSL)
.replace('#include <map_fragment>','#include <map_fragment>\nif(uTexOn>0.5){diffuseColor.rgb*=triTex(uTex,vRP*uUv,vRN);}')
.replace('#include <normal_fragment_maps>','#include <normal_fragment_maps>\nif(uNorOn>0.5){normal=triNormal(normal,normalize(vAX),normalize(vAY),normalize(vAZ),uNor,vRP*uNorUv,vRN,uNorS,uNorFlip);}');
};
const envTex=makeEnv();if(envTex){shellMat.envMap=envTex;shellMat.envMapIntensity=P.mat.env;}
const wireMat=new T.MeshBasicMaterial({color:0x666666,wireframe:true,transparent:true,opacity:0.25,depthWrite:false,skinning:true});
const ring=new T.Mesh(new T.RingGeometry(0.93,1,56),new T.MeshBasicMaterial({color:0x111111,side:T.DoubleSide,transparent:true,opacity:0.85,depthTest:false}));
ring.visible=false;ring.renderOrder=10;scene.add(ring);
const overlay=new T.Group();scene.add(overlay);
const dotTex=(function(){const c=document.createElement('canvas');c.width=c.height=32;const g=c.getContext('2d');g.fillStyle='#fff';g.beginPath();g.arc(16,16,14,0,7);g.fill();g.lineWidth=4;g.strokeStyle='#111';g.stroke();return new T.CanvasTexture(c);})();
const cam={th:0.45,ph:1.38,r:4.0,tx:0,ty:0.92,tz:0};
function placeCam(){const sp=Math.sin(cam.ph);camera.position.set(cam.tx+cam.r*sp*Math.sin(cam.th),cam.ty+cam.r*Math.cos(cam.ph),cam.tz+cam.r*sp*Math.cos(cam.th));camera.lookAt(cam.tx,cam.ty,cam.tz);}
function resize(){const w=window.innerWidth,h=window.innerHeight;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();}
window.addEventListener('resize',resize);
/* ===================== Rig & Körper ===================== */
function setStatus(t){$('#status').textContent=t||'';}
function disposeBody(){
if(S.sim)abortSim();disposeHQ();
if(rig){scene.remove(rig);rig.traverse(o=>{if(o.geometry)o.geometry.dispose();});}
S.body=null;S.shell=null;S.points=[];S.undo=[];S.bones=[];S.rest=[];S.sel=null;$('#dots').innerHTML='';clearOverlay();
}
function newRig(){rig=new T.Group();scene.add(rig);}
function createProcedural(){
disposeBody();newRig();
const d=makeProceduralBody(),bones=[];
d.defs.forEach(def=>{const b=new T.Bone();b.name=def[0];bones.push(b);});
d.defs.forEach((def,i)=>{const b=bones[i];if(def[1]){const pi=d.defs.findIndex(q=>q[0]===def[1]),pd=d.defs[pi];bones[pi].add(b);b.position.set(def[2]-pd[2],def[3]-pd[3],def[4]-pd[4]);}else b.position.set(def[2],def[3],def[4]);});
const mesh=new T.SkinnedMesh(new T.BufferGeometry(),bodyMat);mesh.add(bones[0]);rig.add(mesh);rig.updateMatrixWorld(true);
mesh.bind(new T.Skeleton(bones));
initBody(mesh,d,'Prozeduraler Körper',null);
}
function initBody(mesh,d,label,root){
const geo=new T.BufferGeometry(),n=d.pos.length/3;
geo.setAttribute('position',new T.BufferAttribute(d.pos,3));geo.setAttribute('normal',new T.BufferAttribute(d.nrm,3));
const colA=new T.BufferAttribute(new Float32Array(n*3).fill(0.82),3);colA.setUsage(T.DynamicDrawUsage);geo.setAttribute('color',colA);
geo.setAttribute('skinIndex',new T.Uint16BufferAttribute(d.si,4));geo.setAttribute('skinWeight',new T.Float32BufferAttribute(d.sw,4));
geo.setIndex(new T.BufferAttribute(d.idx,1));
mesh.geometry=geo;mesh.material=bodyMat;mesh.frustumCulled=false;
const B={mesh,n,pos:d.pos,idx:d.idx,si:d.si,sw:d.sw,mask:new Float32Array(n),col:colA.array,colA,topo:buildTopology(n,d.idx),label};
B.surf=new Surf(mesh,B.pos,B.idx,B.si,B.sw);S.body=B;
const lb=B.surf.bbox();
if(root){ // GLB: auf 1.8 Einheiten Höhe normalisieren und auf den Boden stellen
root.scale.multiplyScalar(1.8/(lb.mx[1]-lb.mn[1]));rig.updateMatrixWorld(true);B.surf.dirty=true;
const b2=B.surf.bbox();root.position.x-=(b2.mn[0]+b2.mx[0])/2;root.position.z-=(b2.mn[2]+b2.mx[2])/2;root.position.y-=b2.mn[1];
rig.updateMatrixWorld(true);B.surf.dirty=true;
}
const wb=B.surf.bbox(),wd=Math.hypot(wb.mx[0]-wb.mn[0],wb.mx[1]-wb.mn[1],wb.mx[2]-wb.mn[2]);
let mn=[1e30,1e30,1e30],mx=[-1e30,-1e30,-1e30];for(let i=0;i<d.pos.length;i+=3)for(let a=0;a<3;a++){mn[a]=Math.min(mn[a],d.pos[i+a]);mx[a]=Math.max(mx[a],d.pos[i+a]);}
S.Ul=Math.hypot(mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2])/wd;
{const ax=(mx[0]-mn[0])>=(mx[2]-mn[2])?0:2;S.mir={axis:ax,c:(mn[ax]+mx[ax])/2};}
S.bones=mesh.skeleton.bones;S.rest=S.bones.map(b=>({p:b.position.clone(),q:b.quaternion.clone(),s:b.scale.clone()}));
computeAim();buildDots();refreshMaterial();
S.maskVer=0;S.touched=false;
$('#bodyLabel').textContent=label;
setTab(S.tab);
}
function computeAim(){
const bones=S.bones,wp=bones.map(b=>b.getWorldPosition(new T.Vector3())),ix=new Map(bones.map((b,i)=>[b,i]));
bones.forEach((b,i)=>{
b.userData.aim=null;const kids=b.children.filter(c=>c.isBone);if(!kids.length)return;
const dir=new T.Vector3(0,1,0);
if(b.parent&&b.parent.isBone){dir.subVectors(wp[i],wp[ix.get(b.parent)]);if(dir.lengthSq()<1e-12)dir.set(0,1,0);}dir.normalize();
let best=null,bs=-2;for(const k of kids){const v=new T.Vector3().subVectors(wp[ix.get(k)],wp[i]);if(v.lengthSq()<1e-12)continue;v.normalize();const s=v.dot(dir);if(s>bs){bs=s;best=k;}}
b.userData.aim=best;
});
}
const MINOR=/(thumb|index|middle|ring|pinky|finger|toe|eye|jaw|end$|_end|tip|top)/i;
function buildDots(){
const host=$('#dots');host.innerHTML='';
for(const b of S.bones){
if(!b.userData.aim||MINOR.test(b.name))continue;
const el=document.createElement('div');el.className='dot';el.title=b.name;host.appendChild(el);b.userData.dot=el;
el.addEventListener('pointerdown',e=>{
e.preventDefault();e.stopPropagation();el.setPointerCapture(e.pointerId);selectBone(b);
const c=b.userData.aim,cw=c.getWorldPosition(new T.Vector3()),dirv=camera.getWorldDirection(new T.Vector3()),plane=new T.Plane().setFromNormalAndCoplanarPoint(dirv,cw);
const ray=rayFrom(e),hit=ray.ray.intersectPlane(plane,new T.Vector3());
S.pdrag={bone:b,plane,off:hit?hit.clone().sub(cw):new T.Vector3()};S.touched=true;
});
el.addEventListener('pointermove',e=>{
if(!S.pdrag||S.pdrag.bone!==b)return;const ray=rayFrom(e),hit=ray.ray.intersectPlane(S.pdrag.plane,new T.Vector3());
if(hit){aimBone(b,hit.sub(S.pdrag.off));syncRot();markPose();}
});
const end=()=>{S.pdrag=null;};el.addEventListener('pointerup',end);el.addEventListener('pointercancel',end);
}
}
function aimBone(bone,target){
const c=bone.userData.aim;bone.updateWorldMatrix(true,true);
const bp=bone.getWorldPosition(new T.Vector3()),cp=c.getWorldPosition(new T.Vector3());
const a=cp.sub(bp).normalize(),b=target.clone().sub(bp).normalize();if(a.lengthSq()<1e-9||b.lengthSq()<1e-9)return;
const q=new T.Quaternion().setFromUnitVectors(a,b),wq=bone.getWorldQuaternion(new T.Quaternion()).premultiply(q);
const pq=bone.parent?bone.parent.getWorldQuaternion(new T.Quaternion()):new T.Quaternion();
bone.quaternion.copy(pq.invert().multiply(wq));bone.updateMatrixWorld(true);
}
function markPose(){if(S.body)S.body.surf.dirty=true;if(S.shell)S.shell.surf.dirty=true;S.overlayDirty=true;}
function resetPose(){S.bones.forEach((b,i)=>{b.position.copy(S.rest[i].p);b.quaternion.copy(S.rest[i].q);b.scale.copy(S.rest[i].s);});rig.updateMatrixWorld(true);syncRot();markPose();}
function aPose(){
resetPose();
for(const b of S.bones){
if(!/arm/i.test(b.name)||/fore|lower|hand/i.test(b.name)||!b.userData.aim)continue;
b.updateWorldMatrix(true,false);const bp=b.getWorldPosition(new T.Vector3()),cp=b.userData.aim.getWorldPosition(new T.Vector3());
const sx=Math.sign(cp.x-bp.x)||1,len=cp.distanceTo(bp);
aimBone(b,new T.Vector3(bp.x+sx*Math.cos(0.7)*len,bp.y-Math.sin(0.7)*len,bp.z));
}
syncRot();markPose();
}
function selectBone(b){S.sel=b;S.bones.forEach(x=>{if(x.userData.dot)x.userData.dot.classList.toggle('sel',x===b);});$('#boneName').textContent=b?b.name:'–';syncRot();}
let rotSl=[];
function syncRot(){const b=S.sel;if(!b)return;['x','y','z'].forEach((a,i)=>rotSl[i]&&rotSl[i].set(Math.round(b.rotation[a]*180/Math.PI)));}
/* ===================== Maske ===================== */
function dominantBone(B,i){let bi=0,bw=-1;for(let k=0;k<4;k++){const w=B.sw[4*i+k];if(w>bw){bw=w;bi=B.si[4*i+k];}}return bi;}
function presetMask(kind){
const B=S.body;if(!B)return;const names=B.mesh.skeleton.bones.map(b=>b.name);
const torso=/hips|pelvis|spine|chest|abdomen|waist|torso/i,limb=/shoulder|clav|arm|leg|thigh|shin|calf|knee|elbow/i,excl=/hand|finger|thumb|index|middle|ring|pinky|foot|toe|head|neck|jaw|eye/i;
pushUndo(B.mask,'body',()=>{S.maskVer++;updateBodyColors();});
for(let i=0;i<B.n;i++){
const nm=names[dominantBone(B,i)]||'';let v=0;
if(kind==='all')v=1;else if(kind==='none')v=0;
else if(kind==='torso')v=torso.test(nm)&&!excl.test(nm)?1:0;
else v=((torso.test(nm)||limb.test(nm))&&!excl.test(nm))?1:0;
B.mask[i]=v;
}
S.maskVer++;S.touched=true;updateBodyColors();
}
function updateBodyColors(){
const B=S.body;if(!B)return;const c=B.col,m=B.mask;
if(S.tab==='mask')for(let i=0;i<B.n;i++){const v=0.55+0.45*m[i];c[3*i]=c[3*i+1]=c[3*i+2]=v;}
else c.fill(0.82);
B.colA.needsUpdate=true;
}
/* ===================== Hülle bauen ===================== */
function mergeSkin(si,sw,i,j,osi,osw,o){
const ci=[],cw=[];
const add=(b,w)=>{if(w<=0)return;const p=ci.indexOf(b);if(p>=0)cw[p]+=w;else{ci.push(b);cw.push(w);}};
for(let k=0;k<4;k++){add(si[4*i+k],sw[4*i+k]*0.5);add(si[4*j+k],sw[4*j+k]*0.5);}
const ord=ci.map((_,q)=>q).sort((a,b)=>cw[b]-cw[a]).slice(0,4);let t=0;ord.forEach(q=>t+=cw[q]);
for(let k=0;k<4;k++){if(k<ord.length){osi[4*o+k]=ci[ord[k]];osw[4*o+k]=cw[ord[k]]/t;}else{osi[4*o+k]=0;osw[4*o+k]=0;}}
}
function subdivide(m,alpha){
const nv=m.pos.length/3,nt=m.idx.length/3,emap=new Map(),mids=[],ids=new Uint32Array(nt*3);
for(let t=0;t<nt;t++)for(let e=0;e<3;e++){
const a=m.idx[3*t+e],b=m.idx[3*t+(e+1)%3],lo=a<b?a:b,hi=a<b?b:a,k=lo*nv+hi;let id=emap.get(k);
if(id===undefined){id=nv+mids.length/2;emap.set(k,id);mids.push(lo,hi);}ids[3*t+e]=id;
}
const N=nv+mids.length/2,pos=new Float32Array(N*3),nrm=new Float32Array(N*3),si=new Uint16Array(N*4),sw=new Float32Array(N*4),mk=new Float32Array(N),ex=(m.ex||[]).map(a=>{const o=new Float32Array(N);o.set(a);return o;});
pos.set(m.pos);nrm.set(m.nrm);si.set(m.si);sw.set(m.sw);mk.set(m.mk);
for(let q=0;q<mids.length/2;q++){
const i=mids[2*q],j=mids[2*q+1],o=nv+q;
const mx=(m.pos[3*i]+m.pos[3*j])/2,my=(m.pos[3*i+1]+m.pos[3*j+1])/2,mz=(m.pos[3*i+2]+m.pos[3*j+2])/2;
const di=(mx-m.pos[3*i])*m.nrm[3*i]+(my-m.pos[3*i+1])*m.nrm[3*i+1]+(mz-m.pos[3*i+2])*m.nrm[3*i+2];
const dj=(mx-m.pos[3*j])*m.nrm[3*j]+(my-m.pos[3*j+1])*m.nrm[3*j+1]+(mz-m.pos[3*j+2])*m.nrm[3*j+2];
const h=alpha*0.5;
pos[3*o]=mx-h*(di*m.nrm[3*i]+dj*m.nrm[3*j]);pos[3*o+1]=my-h*(di*m.nrm[3*i+1]+dj*m.nrm[3*j+1]);pos[3*o+2]=mz-h*(di*m.nrm[3*i+2]+dj*m.nrm[3*j+2]);
let nx=m.nrm[3*i]+m.nrm[3*j],ny=m.nrm[3*i+1]+m.nrm[3*j+1],nz=m.nrm[3*i+2]+m.nrm[3*j+2];const l=Math.sqrt(nx*nx+ny*ny+nz*nz)||1;
nrm[3*o]=nx/l;nrm[3*o+1]=ny/l;nrm[3*o+2]=nz/l;mk[o]=(m.mk[i]+m.mk[j])/2;mergeSkin(m.si,m.sw,i,j,si,sw,o);
for(let e2=0;e2<ex.length;e2++)ex[e2][o]=(m.ex[e2][i]+m.ex[e2][j])*0.5;
}
const idx=new Uint32Array(nt*12);
for(let t=0;t<nt;t++){
const a=m.idx[3*t],b=m.idx[3*t+1],c=m.idx[3*t+2],ab=ids[3*t],bc=ids[3*t+1],ca=ids[3*t+2],o=12*t;
idx[o]=a;idx[o+1]=ab;idx[o+2]=ca;idx[o+3]=ab;idx[o+4]=b;idx[o+5]=bc;idx[o+6]=ca;idx[o+7]=bc;idx[o+8]=c;idx[o+9]=ab;idx[o+10]=bc;idx[o+11]=ca;
}
return {pos,nrm,si,sw,mk,idx,ex};
}
function buildShell(){
const B=S.body;if(!B)return;abortSim();
const mk=B.mask.slice();blurField(mk,B.topo,P.edgeSmooth);
// grobe Dreiecke mit Maske >= 0.5 an mindestens einer Ecke
const keepT=[];for(let t=0;t<B.idx.length/3;t++){if(Math.max(mk[B.idx[3*t]],mk[B.idx[3*t+1]],mk[B.idx[3*t+2]])>=0.5)keepT.push(t);}
const old=S.shell;
if(!keepT.length){removeShell();S.shell=null;S.shellVer=S.maskVer;$('#shellInfo').textContent='Keine Hülle: die Maske ist leer.';setStatus('Die Maske ist leer.');return;}
const vmap=new Int32Array(B.n).fill(-1),vl=[];const idx0=new Uint32Array(keepT.length*3);
keepT.forEach((t,q)=>{for(let k=0;k<3;k++){const v=B.idx[3*t+k];if(vmap[v]<0){vmap[v]=vl.length;vl.push(v);}idx0[3*q+k]=vmap[v];}});
const n0=vl.length;let m={pos:new Float32Array(n0*3),nrm:new Float32Array(n0*3),si:new Uint16Array(n0*4),sw:new Float32Array(n0*4),mk:new Float32Array(n0),idx:idx0};
vl.forEach((v,i)=>{for(let a=0;a<3;a++){m.pos[3*i+a]=B.pos[3*v+a];m.nrm[3*i+a]=B.mesh.geometry.attributes.normal.array[3*v+a];}
for(let k=0;k<4;k++){m.si[4*i+k]=B.si[4*v+k];m.sw[4*i+k]=B.sw[4*v+k];}m.mk[i]=mk[v];});
let L=P.subdiv;while(L>0&&keepT.length*Math.pow(4,L)>180000)L--;
for(let l=0;l<L;l++)m=subdivide(m,0.75);
// Dreiecke behalten, deren Maskenmittel >= 0.5
const keepF=[];for(let t=0;t<m.idx.length/3;t++){if((m.mk[m.idx[3*t]]+m.mk[m.idx[3*t+1]]+m.mk[m.idx[3*t+2]])/3>=0.5)keepF.push(t);}
// Inseln entfernen
const par=new Int32Array(m.pos.length/3).map((_,i)=>i),find=x=>{while(par[x]!==x){par[x]=par[par[x]];x=par[x];}return x;};
for(const t of keepF){const a=find(m.idx[3*t]);for(let k=1;k<3;k++){const b=find(m.idx[3*t+k]);if(a!==b)par[b]=a;}}
const cnt=new Map();for(const t of keepF){const r=find(m.idx[3*t]);cnt.set(r,(cnt.get(r)||0)+1);}
const fin=keepF.filter(t=>cnt.get(find(m.idx[3*t]))>=12);
if(!fin.length){removeShell();S.shell=null;S.shellVer=S.maskVer;$('#shellInfo').textContent='Keine Hülle: die Maske ist zu klein.';return;}
const vm2=new Int32Array(m.pos.length/3).fill(-1),vl2=[],idx=new Uint32Array(fin.length*3);
fin.forEach((t,q)=>{for(let k=0;k<3;k++){const v=m.idx[3*t+k];if(vm2[v]<0){vm2[v]=vl2.length;vl2.push(v);}idx[3*q+k]=vm2[v];}});
const n=vl2.length,base=new Float32Array(n*3),nrm=new Float32Array(n*3),si=new Uint16Array(n*4),sw=new Float32Array(n*4);
vl2.forEach((v,i)=>{for(let a=0;a<3;a++){base[3*i+a]=m.pos[3*v+a];nrm[3*i+a]=m.nrm[3*v+a];}for(let k=0;k<4;k++){si[4*i+k]=m.si[4*v+k];sw[4*i+k]=m.sw[4*v+k];}});
const topo=buildTopology(n,idx);
// Rand glätten
for(let it=0;it<4;it++){const cp=base.slice();
for(let i=0;i<n;i++){if(!topo.bnd[i])continue;let sx=0,sy=0,sz=0,c=0;
for(let k=topo.off[i];k<topo.off[i+1];k++)if(topo.nbB[k]){const j=topo.nbr[k];sx+=cp[3*j];sy+=cp[3*j+1];sz+=cp[3*j+2];c++;}
if(c){base[3*i]=0.5*cp[3*i]+0.5*sx/c;base[3*i+1]=0.5*cp[3*i+1]+0.5*sy/c;base[3*i+2]=0.5*cp[3*i+2]+0.5*sz/c;}}}
// Basisfläche glätten (Taubin) und Normalen neu aus der geglätteten Fläche ableiten:
// das grobe Körper-Netz soll sich nicht in die Haut durchdrücken
taubin(base,topo,4,0.5,-0.53);
faceNormals(base,idx,nrm);smoothNormals(nrm,topo,6);
const sh={n,nt:idx.length/3,base,nrm,si,sw,idx,topo,pos:base.slice(),
Dm:new Float32Array(n).fill(0.5),Pm:new Float32Array(n).fill(0.5),Sm:new Float32Array(n).fill(0.5),col:new Float32Array(n*3).fill(1),L};
sh.adjLen=adjLengths(topo,base);
let edge=0;for(let i=0;i<sh.adjLen.length;i++)edge+=sh.adjLen[i];sh.edge=edge/sh.adjLen.length;
// Karten und Nähte von der alten Hülle übernehmen
if(old){const g=new PointGrid(old.base,old.edge||sh.edge);
for(let i=0;i<n;i++){const j=g.nearest(base[3*i],base[3*i+1],base[3*i+2],4);if(j>=0){sh.Pm[i]=old.Pm[j];sh.Sm[i]=old.Sm[j];sh.Dm[i]=old.Dm[j];}}}
removeShell();
const geo=new T.BufferGeometry();
const pa=new T.BufferAttribute(sh.pos,3);pa.setUsage(T.DynamicDrawUsage);geo.setAttribute('position',pa);
const na=new T.BufferAttribute(new Float32Array(n*3),3);na.setUsage(T.DynamicDrawUsage);geo.setAttribute('normal',na);
const ca=new T.BufferAttribute(sh.col,3);ca.setUsage(T.DynamicDrawUsage);geo.setAttribute('color',ca);
geo.setAttribute('skinIndex',new T.Uint16BufferAttribute(si,4));geo.setAttribute('skinWeight',new T.Float32BufferAttribute(sw,4));
geo.setAttribute('restP',new T.BufferAttribute(sh.base,3));geo.setAttribute('restN',new T.BufferAttribute(sh.nrm,3));
geo.setIndex(new T.BufferAttribute(idx,1));
const bm=B.mesh,par_=bm.parent||rig;
const mesh=new T.SkinnedMesh(geo,shellMat),wire=new T.SkinnedMesh(geo,wireMat);
for(const o of [mesh,wire]){o.position.copy(bm.position);o.quaternion.copy(bm.quaternion);o.scale.copy(bm.scale);o.frustumCulled=false;o.bindMode=bm.bindMode;par_.add(o);}
par_.updateMatrixWorld(true);mesh.bind(bm.skeleton,bm.bindMatrix);wire.bind(bm.skeleton,bm.bindMatrix);
sh.mesh=mesh;sh.wire=wire;sh.geo=geo;sh.nrmA=na;sh.posA=pa;sh.colA=ca;
sh.surf=new Surf(mesh,sh.pos,idx,si,sw);sh.grid=new PointGrid(base,sh.edge);
S.shell=sh;S.shellVer=S.maskVer;S.undo=S.undo.filter(u=>u.kind!=='shell');
S.points=S.points.filter(cp=>{const v=sh.grid.nearest(cp.pos.x,cp.pos.y,cp.pos.z,6);if(v<0)return false;
const d=Math.hypot(sh.base[3*v]-cp.pos.x,sh.base[3*v+1]-cp.pos.y,sh.base[3*v+2]-cp.pos.z);cp.vid=v;return d<4*sh.edge;});
S.netDirty=true;S.heightsDirty=true;
$('#shellInfo').textContent='Hülle: '+sh.nt.toLocaleString('de-DE')+' Dreiecke, Unterteilung '+L;
setStatus('');
}
function taubin(pos,topo,pairs,lam,mu){
const {off,nbr,nv}=topo,tmp=new Float32Array(pos.length);
for(let it=0;it<pairs*2;it++){
const f=(it&1)===0?lam:mu;tmp.set(pos);
for(let i=0;i<nv;i++){
if(topo.bnd[i])continue;const a=off[i],b=off[i+1];if(b===a)continue;let sx=0,sy=0,sz=0;
for(let k=a;k<b;k++){const j=nbr[k]*3;sx+=tmp[j];sy+=tmp[j+1];sz+=tmp[j+2];}
const inv=1/(b-a),i3=i*3;pos[i3]=tmp[i3]+f*(sx*inv-tmp[i3]);pos[i3+1]=tmp[i3+1]+f*(sy*inv-tmp[i3+1]);pos[i3+2]=tmp[i3+2]+f*(sz*inv-tmp[i3+2]);
}
}
}
function smoothNormals(v,topo,passes){
const {off,nbr,nv}=topo,tmp=new Float32Array(v.length);
for(let it=0;it<passes;it++){
tmp.set(v);
for(let i=0;i<nv;i++){
let sx=tmp[3*i],sy=tmp[3*i+1],sz=tmp[3*i+2];
for(let k=off[i];k<off[i+1];k++){const j=nbr[k]*3;sx+=tmp[j];sy+=tmp[j+1];sz+=tmp[j+2];}
const l=Math.sqrt(sx*sx+sy*sy+sz*sz)||1;v[3*i]=sx/l;v[3*i+1]=sy/l;v[3*i+2]=sz/l;
}
}
}
function removeShell(){const sh=S.shell;if(!sh)return;sh.mesh.parent&&sh.mesh.parent.remove(sh.mesh);sh.wire.parent&&sh.wire.parent.remove(sh.wire);sh.geo.dispose();}
function ensureShell(){if(!S.shell||S.shellVer!==S.maskVer){setStatus('Baue Hülle …');buildShell();}}
/* ===================== Nahtnetz aus Punkten (Voronoi / Delaunay) ===================== */
function pathBetween(sh,a,b){
if(a===b)return [a];
const {off,nbr}=sh.topo,D=sh._d||(sh._d=new Float64Array(sh.n).fill(Infinity)),Pv=sh._p||(sh._p=new Int32Array(sh.n).fill(-1));
const touched=[a],H=new Heap();D[a]=0;H.push(0,a);let found=false;
while(H.size){const u=H.pop(),du=H.pt;if(du>D[u])continue;if(u===b){found=true;break;}
for(let k=off[u];k<off[u+1];k++){const v=nbr[k],nd=du+sh.adjLen[k];if(nd<D[v]){if(D[v]===Infinity)touched.push(v);D[v]=nd;Pv[v]=u;H.push(nd,v);}}}
let path=null;if(found){path=[];for(let v=b;v!==-1;v=Pv[v])path.push(v);path.reverse();}
for(const v of touched){D[v]=Infinity;Pv[v]=-1;}
return path;
}
function voronoiLabels(sh,vids){
const {off,nbr}=sh.topo,n=sh.n,W=sh.adjLen,dist=new Float64Array(n).fill(Infinity),lab=new Int32Array(n).fill(-1),H=new Heap();
vids.forEach((v,k)=>{if(dist[v]>0){dist[v]=0;lab[v]=k;H.push(0,v);}});
while(H.size){const u=H.pop(),du=H.pt;if(du>dist[u])continue;
for(let k=off[u];k<off[u+1];k++){const v=nbr[k],nd=du+W[k];if(nd<dist[v]){dist[v]=nd;lab[v]=lab[u];H.push(nd,v);}}}
return {dist,lab};
}
function computeNetwork(sh0,vids0){
if(!sh0)S.netDirty=false;const sh=sh0||S.shell;if(!sh)return;
const {off,nbr}=sh.topo,n=sh.n,vids=vids0||S.points.map(p=>p.vid),m=vids.length,W=sh.adjLen;
const pin=sh.pin||(sh.pin=new Uint8Array(n));pin.fill(0);
for(let i=0;i<n;i++)if(sh.topo.bnd[i])pin[i]=1;
const raw=new Uint8Array(n);let anyRaw=false;
if(m>0){
const {lab}=voronoiLabels(sh,vids);
if(P.net.mode==='voronoi'){
for(let u=0;u<n;u++){if(lab[u]<0)continue;
for(let k=off[u];k<off[u+1];k++){const l=lab[nbr[k]];if(l>=0&&l>lab[u]){raw[u]=1;anyRaw=true;break;}}}
}else{
const pairs=new Set();
for(let u=0;u<n;u++){const a=lab[u];if(a<0)continue;for(let k=off[u];k<off[u+1];k++){const b=lab[nbr[k]];if(b>a)pairs.add(a*m+b);}}
for(const key of pairs){const a=Math.floor(key/m),b=key-a*m,path=pathBetween(sh,vids[a],vids[b]);
if(path)for(const v of path){raw[v]=1;anyRaw=true;}}
}
}
// Nahtbreite und Rundung: Abstand zum Netz, Schwelle, dann Weichzeichnen und erneute Schwelle
const wl=P.net.width*0.5*0.01*S.Ul;
if(anyRaw&&wl>sh.edge*0.75){
const d=new Float64Array(n).fill(Infinity),H=new Heap();
for(let i=0;i<n;i++)if(raw[i]){d[i]=0;H.push(0,i);}
while(H.size){const u=H.pop(),du=H.pt;if(du>d[u])continue;
for(let k=off[u];k<off[u+1];k++){const v=nbr[k],nd=du+W[k];if(nd<=wl&&nd<d[v]){d[v]=nd;H.push(nd,v);}}}
const ind=new Float32Array(n);for(let i=0;i<n;i++)ind[i]=d[i]<=wl?1:0;
const K=Math.round(P.net.round*(1<<sh.L));if(K>0)blurField(ind,sh.topo,K);
for(let i=0;i<n;i++)if(raw[i]||ind[i]>=0.5)pin[i]=1;
}else for(let i=0;i<n;i++)if(raw[i])pin[i]=1;
// Kammern: zusammenhängende Gebiete zwischen den Nähten
const ch=sh.chamber||(sh.chamber=new Int32Array(n));ch.fill(-1);let nc=0;const st=[];
for(let s=0;s<n;s++){if(pin[s]||ch[s]>=0)continue;ch[s]=nc;st.push(s);
while(st.length){const u=st.pop();for(let k=off[u];k<off[u+1];k++){const v=nbr[k];if(!pin[v]&&ch[v]<0){ch[v]=nc;st.push(v);}}}nc++;}
// Winzige Restkammern (Splitter an Nahtknoten oder am Maskenrand) werden zu Naht, sonst poppen sie als Beulen auf
const minV=Math.max(1,Math.round(P.net.minArea/(0.87*Math.pow(sh.edge/S.Ul*100,2))));
const csz=new Int32Array(nc);for(let i=0;i<n;i++)if(ch[i]>=0)csz[ch[i]]++;
const gone=new Uint8Array(nc);let dropped=0;for(let c=0;c<nc;c++)if(csz[c]<minV){gone[c]=1;dropped++;}
if(dropped)for(let i=0;i<n;i++)if(ch[i]>=0&&gone[ch[i]]){pin[i]=1;ch[i]=-1;}
nc-=dropped;sh.nCh=nc;sh.dropped=dropped;
const el=$('#netInfo');if(el&&sh===S.shell)el.textContent=m+' Punkte, '+nc+' Kammern'+(dropped?' ('+dropped+' zu kleine entfernt)':'');
}
/* ---- Punkte verteilen (dichtegesteuert, Poisson-Scheiben + Lloyd) ---- */
function spacingAt(d){return (P.net.sparse+(P.net.dense-P.net.sparse)*d)*0.01*S.Ul;}
/* ---- Symmetrie (vertikale Körpermitte, im Ruhezustand des Körpers) ---- */
function mirrorVec(v){const o=v.clone();o.setComponent(S.mir.axis,2*S.mir.c-v.getComponent(S.mir.axis));return o;}
function getMirrorMap(obj,posArr,grid,tol){
if(obj.mir)return obj.mir;const n=posArr.length/3,mir=new Int32Array(n).fill(-1),ax=S.mir.axis,c=S.mir.c;
for(let i=0;i<n;i++){
const p=[posArr[3*i],posArr[3*i+1],posArr[3*i+2]];p[ax]=2*c-p[ax];const j=grid.nearest(p[0],p[1],p[2],5);
if(j>=0&&Math.hypot(posArr[3*j]-p[0],posArr[3*j+1]-p[1],posArr[3*j+2]-p[2])<tol)mir[i]=j;
}
obj.mir=mir;return mir;
}
function bodyMirrorMap(){const B=S.body;if(!B.grid)B.grid=new PointGrid(B.pos,0.03*S.Ul);return getMirrorMap(B,B.pos,B.grid,0.04*S.Ul);}
function shellMirrorMap(){const sh=S.shell;return getMirrorMap(sh,sh.base,sh.grid,3.5*sh.edge);}
function mirrorOf(cp){
const sh=S.shell,pos=mirrorVec(cp.pos),v=sh.grid.nearest(pos.x,pos.y,pos.z,6);if(v<0)return null;
const d=Math.hypot(sh.base[3*v]-pos.x,sh.base[3*v+1]-pos.y,sh.base[3*v+2]-pos.z);return d>4*sh.edge?null:{pos,vid:v};
}
function addPoint(cp){
S.points.push(cp);
if(!P.mirror||!S.mir)return;
const sh=S.shell,lat=cp.pos.getComponent(S.mir.axis)-S.mir.c;
if(Math.abs(lat)<0.02*S.Ul){ // nahe der Achse: auf die Achse setzen, kein Partner
cp.pos.setComponent(S.mir.axis,S.mir.c);const v=sh.grid.nearest(cp.pos.x,cp.pos.y,cp.pos.z,6);if(v>=0)cp.vid=v;return;}
const m=mirrorOf(cp);if(m){const mc={pos:m.pos,vid:m.vid,mate:cp};cp.mate=mc;S.points.push(mc);}
}
function deletePoint(cp){
const set=new Set([cp]);if(P.mirror&&cp.mate)set.add(cp.mate);
S.points=S.points.filter(p=>!set.has(p));for(const p of S.points)if(p.mate&&set.has(p.mate))p.mate=null;
}
function deleteInBrush(hit){
const sh=S.shell;if(!sh||!S.points.length)return;sh.surf.refresh();
const W=sh.surf.world,R=P.brush*0.01,r2=R*R,del=new Set();
for(const p of S.points){const v=p.vid,dx=W[3*v]-hit.px,dy=W[3*v+1]-hit.py,dz=W[3*v+2]-hit.pz;
if(dx*dx+dy*dy+dz*dz<=r2){del.add(p);if(P.mirror&&p.mate)del.add(p.mate);}}
if(!del.size)return;
S.points=S.points.filter(p=>!del.has(p));for(const p of S.points)if(p.mate&&del.has(p.mate))p.mate=null;
pointsChanged();
}
function distributePoints(){
const sh=S.shell;if(!sh){setStatus('Es gibt noch keine Hülle. Bitte zuerst eine Maske malen.');return;}
if(simLocked())return;pushPointsUndo();
const n=sh.n,base=sh.base,mirror=!!(P.mirror&&S.mir),ax=mirror?S.mir.axis:0,cc=mirror?S.mir.c:0;
const order=new Uint32Array(n);for(let i=0;i<n;i++)order[i]=i;
for(let i=n-1;i>0;i--){const j=Math.floor(Math.random()*(i+1)),t=order[i];order[i]=order[j];order[j]=t;}
const px=[],py=[],pz=[],pr=[],prim=[],MAXP=2500;
for(let q=0;q<n&&prim.length<MAXP;q++){
const v=order[q];if(mirror&&base[3*v+ax]<cc)continue;
const r=spacingAt(sh.Dm[v]),x=base[3*v],y=base[3*v+1],z=base[3*v+2];let ok=true;
for(let k=0;k<px.length;k++){const dx=px[k]-x,dy=py[k]-y,dz=pz[k]-z,rr=0.5*(r+pr[k]);if(dx*dx+dy*dy+dz*dz<rr*rr){ok=false;break;}}
if(ok){px.push(x);py.push(y);pz.push(z);pr.push(r);prim.push(v);}
}
const expand=()=>{
const ex=prim.map(v=>({vid:v,mate:-1}));
if(!mirror)return ex;
for(let k=0;k<prim.length;k++){
const v=prim[k],lat=base[3*v+ax]-cc,r=spacingAt(sh.Dm[v]),p=[base[3*v],base[3*v+1],base[3*v+2]];
if(Math.abs(lat)<0.5*r){p[ax]=cc;const w=sh.grid.nearest(p[0],p[1],p[2],6);if(w>=0){ex[k].vid=w;prim[k]=w;}continue;}
p[ax]=2*cc-p[ax];const w=sh.grid.nearest(p[0],p[1],p[2],6);
if(w<0||Math.hypot(base[3*w]-p[0],base[3*w+1]-p[1],base[3*w+2]-p[2])>4*sh.edge)continue;
ex[k].mate=ex.length;ex.push({vid:w,mate:k});
}
return ex;
};
let ex=expand();
for(let it=0;it<3&&ex.length>1;it++){ // Lloyd-Relaxation, gewichtet mit der Dichte
const {lab}=voronoiLabels(sh,ex.map(e=>e.vid)),m=ex.length,cx=new Float64Array(m),cy=new Float64Array(m),cz=new Float64Array(m),cw=new Float64Array(m);
for(let v=0;v<n;v++){const l=lab[v];if(l<0)continue;const r=spacingAt(sh.Dm[v]),w=1/(r*r);cx[l]+=base[3*v]*w;cy[l]+=base[3*v+1]*w;cz[l]+=base[3*v+2]*w;cw[l]+=w;}
const best=new Float64Array(m).fill(Infinity),bi=ex.map(e=>e.vid);
for(let v=0;v<n;v++){const l=lab[v];if(l<0||l>=prim.length||!cw[l])continue;const dx=base[3*v]-cx[l]/cw[l],dy=base[3*v+1]-cy[l]/cw[l],dz=base[3*v+2]-cz[l]/cw[l],d=dx*dx+dy*dy+dz*dz;if(d<best[l]){best[l]=d;bi[l]=v;}}
for(let k=0;k<prim.length;k++)prim[k]=bi[k];
ex=expand();
}
const seen=new Set(),objs=ex.map(e=>{if(seen.has(e.vid))return null;seen.add(e.vid);
return {pos:new T.Vector3(base[3*e.vid],base[3*e.vid+1],base[3*e.vid+2]),vid:e.vid,mate:null};});
ex.forEach((e,k)=>{const o=objs[k];if(o&&k<prim.length&&e.mate>=0&&objs[e.mate]){o.mate=objs[e.mate];objs[e.mate].mate=o;}});
S.points=objs.filter(Boolean);pointsChanged();
setStatus(prim.length>=MAXP?'Es wurden höchstens '+MAXP+' Punkte gesetzt. Für mehr Punkte den Abstand vergrößern.':'');
}
function clearPoints(){if(simLocked())return;if(!S.points.length)return;pushPointsUndo();S.points=[];pointsChanged();}
function pushPointsUndo(){
S.saveDirty=true;
const snap=S.points.map(p=>({pos:p.pos.clone(),vid:p.vid,mate:p.mate?S.points.indexOf(p.mate):-1}));
S.undo.push({kind:'shell',fn:()=>{
const objs=snap.map(p=>({pos:p.pos.clone(),vid:p.vid,mate:null}));
snap.forEach((p,i)=>{if(p.mate>=0)objs[i].mate=objs[p.mate];});S.points=objs;pointsChanged();}});
if(S.undo.length>25)S.undo.shift();
}
function pointsChanged(){S.saveDirty=true;invalidateSim();S.netDirty=true;S.overlayDirty=true;S.touched=true;}
function netParamChanged(){invalidateSim();S.netDirty=true;S.heightsDirty=true;}
/* ---- Darstellung der Hülle ---- */
function applyOne(sh,sm){
const eps=EPS*S.Ul,{base,nrm,pos}=sh,f=P.fill,use=(sm&&sm.length===pos.length)?sm:null;
for(let i=0;i<pos.length;i+=3){
const a=base[i]+nrm[i]*eps,b=base[i+1]+nrm[i+1]*eps,c=base[i+2]+nrm[i+2]*eps;
if(use){pos[i]=a+(use[i]-a)*f;pos[i+1]=b+(use[i+1]-b)*f;pos[i+2]=c+(use[i+2]-c)*f;}else{pos[i]=a;pos[i+1]=b;pos[i+2]=c;}
}
faceNormals(pos,sh.idx,sh.nrmA.array);sh.posA.needsUpdate=true;sh.nrmA.needsUpdate=true;if(sh.surf)sh.surf.dirty=true;
}
function applyShell(){
const sh=S.shell;if(sh)applyOne(sh,S.hq?null:sh.sim);
if(S.hq)applyOne(S.hq,S.hq.sim);
}
function matSpecial(){
const sh=S.shell,tab=S.tab;if(!sh)return false;
if(tab==='gray'&&P.showMap)return true;
if(tab==='seam'&&P.net.tool==='dens')return true;
return !!(P.chambers&&tab==='seam'&&sh.chamber&&sh.pin);
}
function applyMaterial(special){
const m=P.mat,c=new T.Color(m.color);
if(special){shellMat.color.set(0xffffff);shellMat.emissive.set(0x000000);shellMat.envMapIntensity=0.15;mu.uTexOn.value=0;mu.uNorOn.value=0;}
else{shellMat.color.copy(c);shellMat.emissive.copy(c).multiplyScalar(m.glow);shellMat.envMapIntensity=m.env;
mu.uTexOn.value=(S.tex.has&&m.texOn)?1:0;mu.uNorOn.value=(S.tex.hasN&&m.norOn)?1:0;}
shellMat.roughness=m.rough;shellMat.metalness=m.metal;shellMat.clearcoat=m.clear;shellMat.clearcoatRoughness=m.clearRough;
mu.uUv.value=m.texScale/Math.max(1e-6,S.Ul);mu.uNorUv.value=m.norScale/Math.max(1e-6,S.Ul);mu.uNorS.value=m.norS;mu.uNorFlip.value=m.norFlip?-1:1;
}
function applyWrap(){
const w=on=>on?T.MirroredRepeatWrapping:T.RepeatWrapping;
if(S.tex.has){mu.uTex.value.wrapS=mu.uTex.value.wrapT=w(P.mat.texMirror);mu.uTex.value.needsUpdate=true;}
if(S.tex.hasN){mu.uNor.value.wrapS=mu.uNor.value.wrapT=w(P.mat.norMirror);mu.uNor.value.needsUpdate=true;}
}
function refreshMaterial(){applyMaterial(matSpecial());}
function updateTexInfo(){const a=$('#texInfo'),b=$('#norInfo');if(a)a.textContent=S.tex.has?S.tex.name:'keine';if(b)b.textContent=S.tex.hasN?S.tex.nameN:'keine';}
function setTexImage(img,kind,name){
try{
const t=new T.Texture(img);t.wrapS=t.wrapT=T.RepeatWrapping;t.anisotropy=Math.min(8,renderer.capabilities.getMaxAnisotropy());t.needsUpdate=true;
if(kind==='tex'){if(mu.uTex.value!==texWhite)mu.uTex.value.dispose();mu.uTex.value=t;S.tex.has=true;S.tex.name=name;}
else{if(mu.uNor.value!==texFlat)mu.uNor.value.dispose();mu.uNor.value=t;S.tex.hasN=true;S.tex.nameN=name;}
applyWrap();refreshMaterial();updateTexInfo();
}catch(e){setStatus('Die Textur konnte nicht verwendet werden.');}
}
function clearTex(kind){
if(kind==='tex'){if(mu.uTex.value!==texWhite)mu.uTex.value.dispose();mu.uTex.value=texWhite;S.tex.has=false;S.tex.name='';}
else{if(mu.uNor.value!==texFlat)mu.uNor.value.dispose();mu.uNor.value=texFlat;S.tex.hasN=false;S.tex.nameN='';}
refreshMaterial();updateTexInfo();
}
function loadTexFile(file,kind){
const url=URL.createObjectURL(file),img=new Image();
img.onload=()=>{URL.revokeObjectURL(url);setTexImage(img,kind,file.name);};
img.onerror=()=>{URL.revokeObjectURL(url);setStatus('Das Bild konnte nicht gelesen werden.');};img.src=url;
}
function autoLoadTextures(){
[['texture_map.jpg','tex'],['normal_map.jpg','nor']].forEach(([u,k])=>{const img=new Image();img.onload=()=>setTexImage(img,k,u);img.onerror=()=>{};img.src=u;});
}
function updateShellColors(){
const sh=S.shell;if(!sh)return;const c=sh.col,n=sh.n,tab=S.tab;
const mapArr=(tab==='gray'&&P.showMap)?(P.gtarget==='P'?sh.Pm:sh.Sm):((tab==='seam'&&P.net.tool==='dens')?sh.Dm:null);
const chMode=!mapArr&&P.chambers&&tab==='seam'&&sh.chamber&&sh.pin;
applyMaterial(!!(mapArr||chMode));
if(mapArr){for(let i=0;i<n;i++){const v=0.05+0.95*mapArr[i];c[3*i]=c[3*i+1]=c[3*i+2]=v;}}
else if(chMode){
const col=new T.Color();
for(let i=0;i<n;i++){const ch=sh.chamber[i];
if(sh.pin[i]||ch<0){c[3*i]=c[3*i+1]=c[3*i+2]=0.08;continue;}
col.setHSL(0.2+((ch*0.618)%1)*0.22,1,0.52);c[3*i]=col.r;c[3*i+1]=col.g;c[3*i+2]=col.b;}
}else c.fill(1);
sh.colA.needsUpdate=true;
}
/* ===================== Overlay: Punkte ===================== */
function clearOverlay(){while(overlay.children.length){const o=overlay.children.pop();if(o.geometry)o.geometry.dispose();}overlay.children.length=0;}
function updateSeamOverlay(){
S.overlayDirty=false;clearOverlay();const sh=S.shell;if(!sh||!S.points.length)return;
sh.surf.refresh();const W=sh.surf.world,N=sh.surf.nrm,lift=0.004,cps=[];
for(const p of S.points){const v=p.vid;cps.push(W[3*v]+N[3*v]*lift,W[3*v+1]+N[3*v+1]*lift,W[3*v+2]+N[3*v+2]*lift);}
const g=new T.BufferGeometry();g.setAttribute('position',new T.Float32BufferAttribute(cps,3));
const sz=S.points.length>400?6:9;
const a=new T.Points(g,new T.PointsMaterial({size:sz,sizeAttenuation:false,map:dotTex,transparent:true,alphaTest:0.4,color:0xffffff}));a.frustumCulled=false;overlay.add(a);
const b=new T.Points(g,new T.PointsMaterial({size:sz,sizeAttenuation:false,map:dotTex,transparent:true,opacity:0.3,depthTest:false,color:0xffffff}));b.frustumCulled=false;b.renderOrder=6;overlay.add(b);
}
/* ===================== Simulation (Stufe B): Gewebe unter Druck ===================== */
const MAX_HQ_TRIS=900000;
function SolverFactory(){
class Heap{
constructor(){this.k=[];this.v=[];this.pt=0;}
get size(){return this.k.length;}
push(key,val){const k=this.k,v=this.v;let i=k.length;k.push(key);v.push(val);
while(i>0){const p=(i-1)>>1;if(k[p]<=key)break;k[i]=k[p];v[i]=v[p];i=p;}k[i]=key;v[i]=val;}
pop(){const k=this.k,v=this.v;const tk=k[0],tv=v[0];const lk=k.pop(),lv=v.pop();const n=k.length;
if(n>0){let i=0;for(;;){let l=2*i+1;if(l>=n)break;const r=l+1;if(r<n&&k[r]<k[l])l=r;if(k[l]>=lk)break;k[i]=k[l];v[i]=v[l];i=l;}k[i]=lk;v[i]=lv;}
this.pt=tk;return tv;}
}
class Solver{
constructor(d){
const n=d.n,base=d.base,nrm=d.nrm,idx=d.idx,pin=d.pin,Sm=d.Sm,Pm=d.Pm,par=d.params,off=d.off,nbr=d.nbr,pt=d.pinType;
this.n=n;this.base=base;this.nrm=nrm;this.idx=idx;this.pin=pin;this.total=par.iters+(par.fair||0);this.phys=par.iters;this.fair=par.fair||0;this.it=0;this.off=off;this.nbr=nbr;
this.eps=d.eps;this.eta0=Math.min(d.edge0*0.25,d.edge*0.5);this.maxDisp=d.maxDisp;this.edgePasses=par.edgePasses||2;
// Mindestabstand zum Körper: an Rand und Nähten abgehoben, mit weichem Auslauf, damit die Ränder rund abstehen
const adjL=new Float32Array(nbr.length);
for(let i=0;i<n;i++)for(let k=off[i];k<off[i+1];k++){const j=nbr[k];adjL[k]=Math.sqrt((base[3*i]-base[3*j])**2+(base[3*i+1]-base[3*j+1])**2+(base[3*i+2]-base[3*j+2])**2);}
const e=d.edge,rim=par.rim||0,lift=par.lift||0,Rb=Math.max(3*rim,3*e),Rs=Math.max(3*lift,2*e);
const dmap=(type,lim)=>{const dd=new Float64Array(n).fill(Infinity),H=new Heap();
for(let i=0;i<n;i++)if(pt[i]===type){dd[i]=0;H.push(0,i);}
while(H.size){const u=H.pop(),du=H.pt;if(du>dd[u])continue;
for(let k=off[u];k<off[u+1];k++){const v=nbr[k],nd=du+adjL[k];if(nd<=lim&&nd<dd[v]){dd[v]=nd;H.push(nd,v);}}}
return dd;};
const ss=(a,b,x)=>{const t=Math.min(1,Math.max(0,(x-a)/(b-a)));return t*t*(3-2*t);};
const dB=rim>0?dmap(1,Rb):null,dS=lift>0?dmap(2,Rs):null;
this.minOff=new Float32Array(n);
for(let i=0;i<n;i++){let m=0;
if(dB&&isFinite(dB[i]))m=Math.max(m,rim*(1-ss(0,Rb,dB[i])));
if(dS&&isFinite(dS[i]))m=Math.max(m,lift*(1-ss(0,Rs,dS[i])));
this.minOff[i]=d.eps+m;}
// Abstand zu Rand und Nähten: dort sind Falten erlaubt, im Inneren der Kammern wird geglättet
const foldW=Math.max(par.foldW||0,1e-9),dP=new Float64Array(n).fill(Infinity);
{const H=new Heap();for(let i=0;i<n;i++)if(pt[i]>0){dP[i]=0;H.push(0,i);}
while(H.size){const u=H.pop(),du=H.pt;if(du>dP[u])continue;
for(let k=off[u];k<off[u+1];k++){const v=nbr[k],nd=du+adjL[k];if(nd<=foldW&&nd<dP[v]){dP[v]=nd;H.push(nd,v);}}}}
this.growth=par.mode==='growth';
this.smoothK=Math.round((this.growth?0.3:1)*(par.smooth>0?1+11*par.smooth:0));
this.hasSmooth=this.smoothK>0;this.sw=new Float32Array(n);
for(let i=0;i<n;i++)this.sw[i]=pin[i]?0:(isFinite(dP[i])?ss(0,foldW,dP[i]):1);
// Taubin: Durchlassgrenze skaliert mit der Kantenlänge, damit feine Netze dieselbe räumliche Glättung bekommen
const kpb=0.113*Math.pow(e/Math.max(d.edge0,1e-12),2);this.lamS=0.5;this.muS=1/(kpb-1/this.lamS);
const x=this.x=new Float32Array(3*n);
for(let i=0;i<n;i++){const m=this.minOff[i];x[3*i]=base[3*i]+nrm[3*i]*m;x[3*i+1]=base[3*i+1]+nrm[3*i+1]*m;x[3*i+2]=base[3*i+2]+nrm[3*i+2]*m;}
this.pk=new Float32Array(n);
// Kuppel-Führung: Der Druck wirkt in der Kammermitte stärker als nahe der Naht. So entsteht je Kammer genau eine Kuppel,
// und die Falten bleiben an den Nähten (im Wachstumsmodus aus, dort sind freie Falten gewollt)
const dome=this.growth?0:Math.min(1,Math.max(0,par.dome||0));
const gd=new Float32Array(n).fill(1);
if(dome>0){
const dist=new Float64Array(n).fill(Infinity),H=new Heap();
for(let i=0;i<n;i++)if(pt[i]>0){dist[i]=0;H.push(0,i);}
while(H.size){const u=H.pop(),du=H.pt;if(du>dist[u])continue;
for(let k=off[u];k<off[u+1];k++){const v=nbr[k],nd=du+adjL[k];if(nd<dist[v]){dist[v]=nd;H.push(nd,v);}}}
const ch=new Int32Array(n).fill(-1),dm=[],st=[];let nc=0;
for(let s0=0;s0<n;s0++){if(pt[s0]>0||ch[s0]>=0)continue;let m=0;ch[s0]=nc;st.push(s0);
while(st.length){const u=st.pop();if(isFinite(dist[u])&&dist[u]>m)m=dist[u];
for(let k=off[u];k<off[u+1];k++){const v=nbr[k];if(pt[v]===0&&ch[v]<0){ch[v]=nc;st.push(v);}}}
dm.push(m);nc++;}
for(let i=0;i<n;i++){if(ch[i]<0||!isFinite(dist[i]))continue;const t=Math.min(1,dist[i]/Math.max(dm[ch[i]],1e-12));gd[i]=Math.sqrt(Math.max(0,1-(1-t)*(1-t)));}
}
for(let i=0;i<n;i++)this.pk[i]=par.press*2*Pm[i]*((1-dome)+dome*gd[i]);
this.vn=new Float32Array(3*n);
const F=idx.length/3,map=new Map(),ea=[],eb=[],o1=[],o2=[];
const add=(a,b,c)=>{const lo=a<b?a:b,hi=a<b?b:a,k=lo*n+hi;let q=map.get(k);
if(q===undefined){q=ea.length;map.set(k,q);ea.push(lo);eb.push(hi);o1.push(c);o2.push(-1);}else o2[q]=c;};
for(let f=0;f<F;f++){const a=idx[3*f],b=idx[3*f+1],c=idx[3*f+2];add(a,b,c);add(b,c,a);add(c,a,b);}
const ne=ea.length,dist=(a,b)=>Math.sqrt((base[3*a]-base[3*b])**2+(base[3*a+1]-base[3*b+1])**2+(base[3*a+2]-base[3*b+2])**2);
this.ne=ne;this.EA=Uint32Array.from(ea);this.EB=Uint32Array.from(eb);
this.EL=new Float32Array(ne);this.EL0=new Float32Array(ne);this.EST=new Float32Array(ne);this.EFA=new Float32Array(ne);this.EFB=new Float32Array(ne);
const bc=[],bd=[],bl=[];
for(let q=0;q<ne;q++){
const a=ea[q],b=eb[q],Se=(Sm[a]+Sm[b])*0.5,g=0.3+1.4*Se;
const dd=Math.min(dP[a],dP[b]),fb=isFinite(dd)?(1-ss(0,foldW,dd)):0;
this.EL0[q]=dist(a,b);this.EL[q]=this.EL0[q]*(1+par.zug*g+(par.fold||0)*fb);
this.EST[q]=Math.min(1,Math.max(0.05,1-par.soft*(0.1+0.9*Se)));
const wa=pin[a]?0:1,wb=pin[b]?0:1,sw=wa+wb;
if(sw>0){this.EFA[q]=wa/sw;this.EFB[q]=wb/sw;}
if(o2[q]>=0&&par.bend>0){bc.push(o1[q]);bd.push(o2[q]);bl.push(dist(o1[q],o2[q])*(0.7+0.3*par.bend));}
}
this.nb=bc.length;this.BC=Uint32Array.from(bc);this.BD=Uint32Array.from(bd);this.BL=Float32Array.from(bl);
this.BFA=new Float32Array(this.nb);this.BFB=new Float32Array(this.nb);
for(let k=0;k<this.nb;k++){const wa=pin[bc[k]]?0:1,wb=pin[bd[k]]?0:1,sw=wa+wb;if(sw>0){this.BFA[k]=wa/sw;this.BFB[k]=wb/sw;}}
this.kb=Math.min(0.9,0.2+0.6*par.bend);
}
step(){if(this.it<this.phys)this.physStep();else this.fairStep();this.it++;}
physStep(){
const n=this.n,x=this.x,idx=this.idx,F=idx.length/3,vn=this.vn,pin=this.pin;
vn.fill(0);
for(let f=0;f<F;f++){
const a=idx[3*f]*3,b=idx[3*f+1]*3,c=idx[3*f+2]*3;
const ux=x[b]-x[a],uy=x[b+1]-x[a+1],uz=x[b+2]-x[a+2],vx=x[c]-x[a],vy=x[c+1]-x[a+1],vz=x[c+2]-x[a+2];
const nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx;
vn[a]+=nx;vn[a+1]+=ny;vn[a+2]+=nz;vn[b]+=nx;vn[b+1]+=ny;vn[b+2]+=nz;vn[c]+=nx;vn[c+1]+=ny;vn[c+2]+=nz;
}
const prog=this.it/this.phys,ramp=Math.min(1,this.it/(0.2*this.phys)),eta=this.eta0*ramp*(1-0.65*prog*prog);
for(let i=0;i<n;i++){
if(pin[i])continue;const i3=i*3,l=Math.sqrt(vn[i3]*vn[i3]+vn[i3+1]*vn[i3+1]+vn[i3+2]*vn[i3+2])+1e-30,k=eta*this.pk[i]/l;
x[i3]+=vn[i3]*k;x[i3+1]+=vn[i3+1]*k;x[i3+2]+=vn[i3+2]*k;
}
const ne=this.ne,EA=this.EA,EB=this.EB,EL=this.EL,EST=this.EST,EFA=this.EFA,EFB=this.EFB,fwd0=(this.it&1)===0;
const gro=this.growth,gp=gro?Math.min(1,this.it/(0.7*this.phys)):1,gs=gp*gp*(3-2*gp),EL0=this.EL0;
for(let pass=0;pass<this.edgePasses;pass++){
const fwd=fwd0===(pass===0);
for(let k=0;k<ne;k++){
const e=fwd?k:ne-1-k,a=EA[e]*3,b=EB[e]*3,dx=x[a]-x[b],dy=x[a+1]-x[b+1],dz=x[a+2]-x[b+2],d2=dx*dx+dy*dy+dz*dz,L=gro?EL0[e]+(EL[e]-EL0[e])*gs:EL[e];
if(gro){if(d2<1e-20)continue;}else if(d2<=L*L)continue;
const d=Math.sqrt(d2),c=(d-L)/d*EST[e],fa=EFA[e]*c,fb=EFB[e]*c;
x[a]-=dx*fa;x[a+1]-=dy*fa;x[a+2]-=dz*fa;x[b]+=dx*fb;x[b+1]+=dy*fb;x[b+2]+=dz*fb;
}
}
const nb=this.nb,BC=this.BC,BD=this.BD,BL=this.BL,BFA=this.BFA,BFB=this.BFB,kb=this.kb;
for(let k=0;k<nb;k++){
const a=BC[k]*3,b=BD[k]*3,dx=x[a]-x[b],dy=x[a+1]-x[b+1],dz=x[a+2]-x[b+2],d2=dx*dx+dy*dy+dz*dz,L=BL[k];
if(d2>=L*L||d2<1e-20)continue;
const d=Math.sqrt(d2),c=(d-L)/d*kb,fa=BFA[k]*c,fb=BFB[k]*c;
x[a]-=dx*fa;x[a+1]-=dy*fa;x[a+2]-=dz*fa;x[b]+=dx*fb;x[b+1]+=dy*fb;x[b+2]+=dz*fb;
}
if(this.hasSmooth&&this.it%3===0)this.smoothDisp();
this.project();
}
smoothDisp(){ // glättet die Auslenkung gegenüber der flachen Hülle (schrumpffrei), an den Nähten bleiben Falten erhalten
const n=this.n,x=this.x,base=this.base,nrm=this.nrm,mo=this.minOff,off=this.off,nbr=this.nbr,w=this.sw;
const d=this.dA||(this.dA=new Float32Array(3*n)),t=this.dB||(this.dB=new Float32Array(3*n));
for(let i=0;i<n;i++){const i3=i*3,m=mo[i];d[i3]=x[i3]-(base[i3]+nrm[i3]*m);d[i3+1]=x[i3+1]-(base[i3+1]+nrm[i3+1]*m);d[i3+2]=x[i3+2]-(base[i3+2]+nrm[i3+2]*m);}
for(let pass=0;pass<this.smoothK*2;pass++){
const f=(pass&1)===0?this.lamS:this.muS;t.set(d);
for(let i=0;i<n;i++){
const wi=w[i];if(wi<=0)continue;const a=off[i],b=off[i+1];if(b===a)continue;let sx=0,sy=0,sz=0;
for(let k=a;k<b;k++){const j=nbr[k]*3;sx+=t[j];sy+=t[j+1];sz+=t[j+2];}
const inv=1/(b-a),i3=i*3,g=f*wi;d[i3]=t[i3]+g*(sx*inv-t[i3]);d[i3+1]=t[i3+1]+g*(sy*inv-t[i3+1]);d[i3+2]=t[i3+2]+g*(sz*inv-t[i3+2]);
}
}
for(let i=0;i<n;i++){if(w[i]<=0)continue;const i3=i*3,m=mo[i];x[i3]=base[i3]+nrm[i3]*m+d[i3];x[i3+1]=base[i3+1]+nrm[i3+1]*m+d[i3+1];x[i3+2]=base[i3+2]+nrm[i3+2]*m+d[i3+2];}
}
project(){
const n=this.n,x=this.x,pin=this.pin,base=this.base,nrm=this.nrm,mo=this.minOff,md2=this.maxDisp*this.maxDisp;
for(let i=0;i<n;i++){
const i3=i*3,m=mo[i];
if(pin[i]){x[i3]=base[i3]+nrm[i3]*m;x[i3+1]=base[i3+1]+nrm[i3+1]*m;x[i3+2]=base[i3+2]+nrm[i3+2]*m;continue;}
let dx=x[i3]-base[i3],dy=x[i3+1]-base[i3+1],dz=x[i3+2]-base[i3+2];
const s=dx*nrm[i3]+dy*nrm[i3+1]+dz*nrm[i3+2];
if(s<m){const q=m-s;dx+=nrm[i3]*q;dy+=nrm[i3+1]*q;dz+=nrm[i3+2]*q;}
const l2=dx*dx+dy*dy+dz*dz;if(l2>md2){const q=Math.sqrt(md2/l2);dx*=q;dy*=q;dz*=q;}
x[i3]=base[i3]+dx;x[i3+1]=base[i3+1]+dy;x[i3+2]=base[i3+2]+dz;
}
}
fairStep(){
const n=this.n,x=this.x,off=this.off,nbr=this.nbr,pin=this.pin,tmp=this.tmp||(this.tmp=new Float32Array(3*n));
const f=((this.it-this.phys)&1)===0?0.5:-0.53;tmp.set(x);
for(let i=0;i<n;i++){
if(pin[i])continue;const a=off[i],b=off[i+1];if(b===a)continue;let sx=0,sy=0,sz=0;
for(let k=a;k<b;k++){const j=nbr[k]*3;sx+=tmp[j];sy+=tmp[j+1];sz+=tmp[j+2];}
const inv=1/(b-a),i3=i*3;x[i3]=tmp[i3]+f*(sx*inv-tmp[i3]);x[i3+1]=tmp[i3+1]+f*(sy*inv-tmp[i3+1]);x[i3+2]=tmp[i3+2]+f*(sz*inv-tmp[i3+2]);
}
this.project();
}
snapshot(){return new Float32Array(this.x);}
}
return Solver;
}
const Solver=SolverFactory();
const WORKER_SRC=['const Solver=('+SolverFactory.toString()+')();',
'var solver=null,job=0,running=false,preview=false,lastPrev=0,lastProg=0;',
'self.onmessage=function(e){var m=e.data;',
' if(m.type==="start"){try{solver=new Solver(m.data);}catch(err){self.postMessage({type:"error",job:m.job,msg:String(err)});return;}',
' job=m.job;preview=m.preview;running=true;lastPrev=0;lastProg=0;setTimeout(loop,0);}',
' else if(m.type==="cancel"){if(running&&solver){running=false;var p=solver.snapshot();self.postMessage({type:"cancelled",job:job,it:solver.it,total:solver.total,pos:p},[p.buffer]);}}',
'};',
'function loop(){if(!running)return;try{var t0=performance.now();',
' while(performance.now()-t0<25&&solver.it<solver.total)solver.step();',
' var now=performance.now();',
' if(solver.it>=solver.total){running=false;var q=solver.snapshot();self.postMessage({type:"done",job:job,it:solver.it,total:solver.total,pos:q},[q.buffer]);return;}',
' var msg={type:"progress",job:job,it:solver.it,total:solver.total};',
' if(preview&&now-lastPrev>800){lastPrev=now;lastProg=now;msg.pos=solver.snapshot();self.postMessage(msg,[msg.pos.buffer]);}',
' else if(now-lastProg>150){lastProg=now;self.postMessage(msg);}',
' }catch(err){running=false;self.postMessage({type:"error",job:job,msg:String(err)});return;}',
' setTimeout(loop,0);}'].join('\n');
let jobId=0;
function simLocked(){if(S.sim&&S.sim.running){setStatus('Die Simulation läuft. Bitte abbrechen oder warten.');return true;}return false;}
function simData(t){
const q=P.sim,pt=new Uint8Array(t.n);
for(let i=0;i<t.n;i++)pt[i]=t.topo.bnd[i]?1:(t.pin[i]?2:0);
return {n:t.n,base:t.base.slice(),nrm:t.nrm.slice(),idx:t.idx.slice(),pin:t.pin.slice(),pinType:pt,Pm:t.Pm.slice(),Sm:t.Sm.slice(),
off:t.topo.off.slice(),nbr:t.topo.nbr.slice(),edge:t.edge,edge0:S.shell.edge,eps:EPS*S.Ul,maxDisp:0.4*S.Ul,
params:{iters:0,fair:q.fair,press:q.press,zug:q.zug/100,soft:q.soft,bend:q.bend,edgePasses:2,rim:q.rim*0.01*S.Ul,lift:q.lift*0.01*S.Ul,mode:q.mode,dome:q.dome,smooth:q.smooth,foldW:q.foldW*0.01*S.Ul,fold:q.fold/100}};
}
function fmtT(ms){const s=Math.round(ms/1000);return Math.floor(s/60)+':'+String(s%60).padStart(2,'0');}
const ITER_MULT=[1,1.5,2];
function effLevel(){const sh=S.shell;let q=Math.min(1,P.sim.detail);if(!sh)return q;while(q>0&&sh.nt*Math.pow(4,q)>MAX_HQ_TRIS)q--;return q;}
function updateDetInfo(){
const el=$('#detInfo');if(!el)return;const sh=S.shell;if(!sh){el.textContent='';return;}
const q=effLevel(),nt=sh.nt*Math.pow(4,q);
el.textContent=(q<Math.min(1,P.sim.detail)?'Auf Stufe '+q+' begrenzt, sonst wären es zu viele Dreiecke. ':'')+'Simuliert wird mit etwa '+Math.round(nt/1000).toLocaleString('de-DE')+' Tsd. Dreiecken und '+Math.round(P.sim.iters*ITER_MULT[q])+' Iterationen.'+(q>0?' Pro Iteration dauert das etwa '+Math.pow(4,q)+' Mal so lang.':'');
}
function updateSimUI(){
const sim=S.sim,run=!!(sim&&sim.running),bar=$('#simBar'),txt=$('#simTxt');
const frac=sim?Math.min(1,sim.it/Math.max(1,sim.total)):0,has=!!((S.hq&&S.hq.sim)||(S.shell&&S.shell.sim));
if(bar&&txt){
bar.style.width=(frac*100).toFixed(1)+'%';
let t='';
if(run)t=sim.prep?'Das Netz wird vorbereitet …':(sim.it>=sim.phys?'Glättung ':'Iteration ')+sim.it+' von '+sim.total+', '+Math.round(frac*100)+' %, '+fmtT(performance.now()-sim.tRun)+' ('+(sim.mode==='worker'?'im Hintergrund':'im Hauptthread, zeitgeteilt')+')';
else if(has&&S.simValid)t=sim&&sim.cancelled?'Abgebrochen bei '+Math.round(frac*100)+' %. Das Zwischenergebnis bleibt sichtbar.':'Fertig nach '+fmtT(sim.dur)+'.';
else if(S.simStale)t='Das Ergebnis wurde verworfen, weil sich Maske, Punkte oder Karten geändert haben.';
txt.textContent=t;const go=$('#simGo'),st=$('#simStop');if(go)go.disabled=run;if(st)st.disabled=!run;
}
const ov=$('#ov');
if(ov){
ov.style.display=run?'flex':'none';
if(run){
const el=performance.now()-(sim.prep?sim.t0:sim.tRun),eta=(!sim.prep&&frac>0.03)?el*(1-frac)/frac:null;
$('#ovT').textContent=sim.prep?'Netz wird vorbereitet':(sim.it>=sim.phys?'Glättung':'Simulation');
$('#ovBar').style.width=(frac*100).toFixed(1)+'%';
$('#ovL').textContent=sim.prep?'Einen Moment, die feinere Hülle wird aufgebaut.':'Iteration '+sim.it+' von '+sim.total+' · '+Math.round(frac*100)+' % · '+fmtT(el)+(eta!==null?' · noch etwa '+fmtT(eta):'');
$('#ovS').textContent=(sim.nt?sim.nt.toLocaleString('de-DE')+' Dreiecke · ':'')+'Detail '+['Standard','Hoch','Sehr hoch'][sim.q||0]+' · '+(sim.mode==='worker'?'im Hintergrund':'zeitgeteilt im Hauptthread');
}
}
updateDetInfo();
}
function attachMesh(h){
const n=h.n,geo=new T.BufferGeometry();
const pa=new T.BufferAttribute(h.pos,3);pa.setUsage(T.DynamicDrawUsage);geo.setAttribute('position',pa);
const na=new T.BufferAttribute(new Float32Array(n*3),3);na.setUsage(T.DynamicDrawUsage);geo.setAttribute('normal',na);
geo.setAttribute('color',new T.BufferAttribute(h.col,3));
geo.setAttribute('skinIndex',new T.Uint16BufferAttribute(h.si,4));geo.setAttribute('skinWeight',new T.Float32BufferAttribute(h.sw,4));
geo.setAttribute('restP',new T.BufferAttribute(h.base,3));geo.setAttribute('restN',new T.BufferAttribute(h.nrm,3));
geo.setIndex(new T.BufferAttribute(h.idx,1));
const bm=S.body.mesh,par_=bm.parent||rig,mesh=new T.SkinnedMesh(geo,shellMat),wire=new T.SkinnedMesh(geo,wireMat);
for(const o of [mesh,wire]){o.position.copy(bm.position);o.quaternion.copy(bm.quaternion);o.scale.copy(bm.scale);o.frustumCulled=false;o.bindMode=bm.bindMode;o.visible=false;par_.add(o);}
par_.updateMatrixWorld(true);mesh.bind(bm.skeleton,bm.bindMatrix);wire.bind(bm.skeleton,bm.bindMatrix);
h.mesh=mesh;h.wire=wire;h.geo=geo;h.nrmA=na;h.posA=pa;
}
function disposeHQ(){
const h=S.hq;if(!h)return;
if(h.mesh.parent)h.mesh.parent.remove(h.mesh);if(h.wire.parent)h.wire.parent.remove(h.wire);h.geo.dispose();S.hq=null;S.visDirty=true;
}
function buildHQ(sh,q){
let m={pos:sh.base.slice(),nrm:sh.nrm.slice(),si:sh.si.slice(),sw:sh.sw.slice(),mk:new Float32Array(sh.n),idx:sh.idx.slice(),ex:[sh.Pm.slice(),sh.Sm.slice()]};
for(let l=0;l<q;l++)m=subdivide(m,0.5);
const n=m.pos.length/3,idx=m.idx,base=m.pos,nrm=m.nrm,topo=buildTopology(n,idx);
taubin(base,topo,3,0.5,-0.53);faceNormals(base,idx,nrm);smoothNormals(nrm,topo,4);
const h={n,nt:idx.length/3,base,nrm,si:m.si,sw:m.sw,idx,topo,pos:base.slice(),Pm:m.ex[0],Sm:m.ex[1],col:new Float32Array(n*3).fill(1),L:sh.L+q,hq:true,sim:null};
h.adjLen=adjLengths(topo,base);let e=0;for(let i=0;i<h.adjLen.length;i++)e+=h.adjLen[i];h.edge=e/h.adjLen.length;
h.grid=new PointGrid(base,h.edge);
attachMesh(h);
const vids=S.points.map(p=>h.grid.nearest(p.pos.x,p.pos.y,p.pos.z,6)).filter(v=>v>=0);
computeNetwork(h,vids);applyOne(h,null);
return h;
}
function showSim(pos){
const t=(S.sim&&S.sim.target)||S.shell;if(!t||pos.length!==t.pos.length)return;
t.sim=pos;S.heightsDirty=true;S.visDirty=true;
}
function endSimResources(sim){
if(sim.timer){clearTimeout(sim.timer);sim.timer=null;}if(sim.watch){clearTimeout(sim.watch);sim.watch=null;}
if(sim.worker){try{sim.worker.terminate();}catch(e){}sim.worker=null;}
if(sim.url){try{URL.revokeObjectURL(sim.url);}catch(e){}sim.url=null;}
}
function clearSimResult(){
if(S.shell)S.shell.sim=null;disposeHQ();S.simValid=false;S.heightsDirty=true;S.visDirty=true;
}
function failSim(sim,msg){
endSimResources(sim);sim.running=false;clearSimResult();setStatus('Die Simulation ist fehlgeschlagen: '+msg);updateSimUI();
}
function finishSim(sim,pos,cancelled){
endSimResources(sim);sim.running=false;sim.cancelled=cancelled;sim.dur=performance.now()-(sim.tRun||sim.t0);
const t=sim.target||S.shell;if(!t||!pos){updateSimUI();return;}
for(let i=0;i<pos.length;i++){if(!isFinite(pos[i])){failSim(sim,'ungültige Werte im Ergebnis');return;}}
if(sim.ver!==S.editVer||(sim.it<=0&&cancelled)){clearSimResult();S.simStale=sim.ver!==S.editVer;updateSimUI();return;}
t.sim=pos;S.simValid=true;S.simStale=false;S.heightsDirty=true;S.visDirty=true;setStatus('');updateSimUI();
}
function simMsg(sim,m){
if(S.sim!==sim||m.job!==sim.job)return;sim.gotMsg=true;if(sim.watch){clearTimeout(sim.watch);sim.watch=null;}
if(m.type==='progress'){sim.it=m.it;if(m.pos)showSim(m.pos);updateSimUI();}
else if(m.type==='done'){sim.it=m.it;finishSim(sim,m.pos,false);}
else if(m.type==='cancelled'){sim.it=m.it;finishSim(sim,m.pos,true);}
else if(m.type==='error')failSim(sim,m.msg);
}
function runMain(sim){
if(S.sim!==sim||!sim.running)return;
if(sim.worker){try{sim.worker.terminate();}catch(e){}sim.worker=null;}
sim.mode='main';sim.it=0;updateSimUI();setStatus('Die Simulation läuft im Hauptthread (zeitgeteilt).');
let solver=null,lastPrev=0;
const tick=()=>{
if(S.sim!==sim||!sim.running)return;
try{
if(!solver){const d=simData(sim.target);d.params.iters=sim.phys;solver=new Solver(d);sim.solver=solver;}
const t0=performance.now();while(performance.now()-t0<24&&solver.it<solver.total)solver.step();
sim.it=solver.it;
if(solver.it>=solver.total){finishSim(sim,solver.snapshot(),false);return;}
const now=performance.now();if(P.sim.preview&&now-lastPrev>900){lastPrev=now;showSim(solver.snapshot());}
updateSimUI();
}catch(err){failSim(sim,String(err));return;}
sim.timer=setTimeout(tick,0);
};
sim.timer=setTimeout(tick,20);
}
function startSim(){
const sh=S.shell;if(!sh){setStatus('Es gibt noch keine Hülle. Bitte zuerst eine Maske malen.');return;}
if(S.sim&&S.sim.running)return;
if(S.netDirty)computeNetwork();
let pins=0;for(let i=0;i<sh.n;i++)if(sh.pin[i])pins++;
if(!pins){setStatus('Nichts hält die Hülle: Es fehlen ein Maskenrand oder Nähte.');return;}
clearSimResult();S.simStale=false;
const q=effLevel(),phys=Math.round(P.sim.iters*ITER_MULT[q]);
const sim=S.sim={running:true,prep:q>0,job:++jobId,it:0,phys,total:phys+P.sim.fair,q,t0:performance.now(),tRun:performance.now(),worker:null,timer:null,mode:'worker',cancelled:false,ver:S.editVer,target:sh,nt:sh.nt*Math.pow(4,q)};
setStatus('');updateSimUI();
setTimeout(()=>launchSim(sim),q>0?120:0);
}
function launchSim(sim){
if(S.sim!==sim||!sim.running)return;
let target=S.shell;
if(sim.q>0){
try{target=buildHQ(S.shell,sim.q);S.hq=target;}
catch(e){
disposeHQ();target=S.shell;sim.q=0;sim.phys=P.sim.iters;sim.total=sim.phys+P.sim.fair;
setStatus('Das feine Netz war zu groß für den Speicher. Es wird mit Standard-Detail simuliert.');
}
}
if(S.sim!==sim||!sim.running){if(S.hq&&sim.cancelled)disposeHQ();return;}
sim.target=target;sim.prep=false;sim.nt=target.nt;sim.tRun=performance.now();updateSimUI();
let ok=false;
try{
sim.url=URL.createObjectURL(new Blob([WORKER_SRC],{type:'text/javascript'}));
const w=new Worker(sim.url);sim.worker=w;
w.onmessage=ev=>simMsg(sim,ev.data);
w.onerror=ev=>{if(ev&&ev.preventDefault)ev.preventDefault();if(S.sim===sim&&sim.running&&sim.worker===w)runMain(sim);};
const d=simData(target);d.params.iters=sim.phys;
w.postMessage({type:'start',job:sim.job,preview:P.sim.preview,data:d},[d.base.buffer,d.nrm.buffer,d.idx.buffer,d.pin.buffer,d.pinType.buffer,d.Pm.buffer,d.Sm.buffer,d.off.buffer,d.nbr.buffer]);
sim.watch=setTimeout(()=>{if(S.sim===sim&&sim.running&&!sim.gotMsg)runMain(sim);},12000);ok=true;
}catch(e){ok=false;}
if(!ok)runMain(sim);
}
function stopSim(){
const sim=S.sim;if(!sim||!sim.running)return;
if(sim.prep){sim.running=false;sim.cancelled=true;setStatus('');updateSimUI();return;}
if(sim.mode==='worker'&&sim.worker){
sim.worker.postMessage({type:'cancel'});
sim.watch=setTimeout(()=>{if(S.sim===sim&&sim.running)finishSim(sim,null,true);},2500);
}else if(sim.solver)finishSim(sim,sim.solver.snapshot(),true);
else finishSim(sim,null,true);
}
function abortSim(){
const sim=S.sim;if(sim&&sim.running){endSimResources(sim);sim.running=false;}
clearSimResult();updateSimUI();
}
function invalidateSim(){
S.editVer++;if(!((S.shell&&S.shell.sim)||(S.hq&&S.hq.sim)))return;if(S.sim&&S.sim.running)return;
clearSimResult();S.simStale=true;updateSimUI();
}
function discardSim(){if(simLocked())return;clearSimResult();S.simStale=false;updateSimUI();}
function markMaps(){S.heightsDirty=true;S.saveDirty=true;invalidateSim();}
/* ===================== Pinsel ===================== */
function pushUndo(arr,kind,after){S.saveDirty=true;S.undo.push({arr,copy:arr.slice(),kind,after});if(S.undo.length>25)S.undo.shift();}
function undo(){if(simLocked())return;const u=S.undo.pop();if(!u)return;if(u.fn){u.fn();return;}u.arr.set(u.copy);u.after&&u.after();}
function beginStroke(kind,erase){
if(kind!=='mask'&&simLocked())return;
const B=S.body,sh=S.shell;let arr,after;
if(kind==='mask'){arr=B.mask;after=()=>{S.maskVer++;updateBodyColors();};}
else{if(!sh)return;arr=kind==='P'?sh.Pm:(kind==='D'?sh.Dm:sh.Sm);after=kind==='D'?(()=>{S.heightsDirty=true;}):markMaps;}
pushUndo(arr,kind==='mask'?'body':'shell',after);
const mir=(P.mirror&&S.mir)?(kind==='mask'?bodyMirrorMap():shellMirrorMap()):null;
S.stroke={kind,erase,arr,after,last:null,mir};S.touched=true;
}
function dab(surf,p,n,R,st){
const W=surf.world,N=surf.nrm,arr=st.arr,r2=R*R,isD=st.kind==='D',tgt=st.erase?0.5:(isD?P.net.dval:P.grayVal),str=isD?P.net.dstr:P.strength;
for(let i=0;i<surf.n;i++){
const dx=W[3*i]-p[0];if(dx>R||dx<-R)continue;const dy=W[3*i+1]-p[1];if(dy>R||dy<-R)continue;const dz=W[3*i+2]-p[2];
const d2=dx*dx+dy*dy+dz*dz;if(d2>r2)continue;if(N[3*i]*n[0]+N[3*i+1]*n[1]+N[3*i+2]*n[2]<0)continue;
const w=1-sstep(0.45,1,Math.sqrt(d2)/R);
const mi=st.mir?st.mir[i]:-1;
if(st.kind==='mask'){arr[i]=st.erase?Math.min(arr[i],1-w):Math.max(arr[i],w);if(mi>=0&&mi!==i)arr[mi]=st.erase?Math.min(arr[mi],1-w):Math.max(arr[mi],w);}
else{arr[i]+=(tgt-arr[i])*w*str;if(mi>=0&&mi!==i)arr[mi]+=(tgt-arr[mi])*w*str;}
}
}
function applyStroke(hit){
const st=S.stroke,surf=st.kind==='mask'?S.body.surf:S.shell.surf,R=P.brush*0.01,pts=[];
if(st.last){const dx=hit.px-st.last.px,dy=hit.py-st.last.py,dz=hit.pz-st.last.pz,d=Math.sqrt(dx*dx+dy*dy+dz*dz),steps=Math.min(14,Math.floor(d/(R*0.3)));
for(let k=1;k<=steps;k++){const f=k/(steps+1);pts.push([st.last.px+dx*f,st.last.py+dy*f,st.last.pz+dz*f]);}}
pts.push([hit.px,hit.py,hit.pz]);st.last=hit;
for(const p of pts)dab(surf,p,hit.n,R,st);st.after();
}
/* ===================== Punkte: Interaktion ===================== */
function rayFrom(e){const r=canvas.getBoundingClientRect(),rc=new T.Raycaster();
rc.setFromCamera(new T.Vector2(((e.clientX-r.left)/r.width)*2-1,-((e.clientY-r.top)/r.height)*2+1),camera);return rc;}
function pickSurf(surf,e){const rc=rayFrom(e),o=rc.ray.origin,d=rc.ray.direction;return surf.raycast(o.x,o.y,o.z,d.x,d.y,d.z);}
function makeCP(hit){
const sh=S.shell,v=hit.v,b=hit.b,pos=new T.Vector3();
for(let k=0;k<3;k++)pos.x+=sh.base[3*v[k]]*b[k],pos.y+=sh.base[3*v[k]+1]*b[k],pos.z+=sh.base[3*v[k]+2]*b[k];
let bi=0;if(b[1]>b[bi])bi=1;if(b[2]>b[bi])bi=2;return {pos,vid:v[bi],mate:null};
}
function nearestPoint(cx,cy,maxPx){
const sh=S.shell;if(!sh)return -1;sh.surf.refresh();const r=canvas.getBoundingClientRect(),W=sh.surf.world,v=new T.Vector3();let best=-1,bd=maxPx*maxPx;
S.points.forEach((p,i)=>{v.set(W[3*p.vid],W[3*p.vid+1],W[3*p.vid+2]).project(camera);
const x=(v.x+1)/2*r.width+r.left,y=(1-v.y)/2*r.height+r.top,d=(x-cx)*(x-cx)+(y-cy)*(y-cy);if(d<bd){bd=d;best=i;}});
return best;
}
function removeLastPoint(){if(simLocked()||!S.points.length)return;pushPointsUndo();deletePoint(S.points[S.points.length-1]);pointsChanged();}
function removePointAtCursor(){
if(simLocked()||!S.shell)return;const i=S.ptr.inside?nearestPoint(S.ptr.x,S.ptr.y,16):-1;
if(i>=0){pushPointsUndo();deletePoint(S.points[i]);pointsChanged();}else removeLastPoint();
}
function pointsDown(e){
const sh=S.shell;if(!sh||simLocked())return;
const near=nearestPoint(e.clientX,e.clientY,10);
if(near>=0){
pushPointsUndo();
if(e.altKey){deletePoint(S.points[near]);pointsChanged();return;}
const cp=S.points[near];if(!P.mirror&&cp.mate){cp.mate.mate=null;cp.mate=null;}
S.drag={idx:near};return;
}
if(e.altKey)return;
const hit=pickSurf(sh.surf,e);if(!hit)return;
pushPointsUndo();addPoint(makeCP(hit));pointsChanged();
}
function beginDelete(){if(!S.shell||simLocked())return;pushPointsUndo();S.delStroke=true;}
/* ===================== UI ===================== */
const HINTS={pose:'Gelenkpunkte ziehen, um zu posieren. Rechte Maustaste dreht, Shift plus rechte Maustaste verschiebt, Mausrad zoomt.',
mask:'Weiß zeigt, wo die zweite Haut sitzt. Shift beim Malen radiert. Strg+Z macht rückgängig.',
seam:'Setzen: Klick setzt Punkte, Ziehen verschiebt sie, Alt+Klick löscht. Löschen: mit dem Pinsel Punkte entfernen. Entf löscht den Punkt unter dem Cursor.',
gray:'Weiß bedeutet mehr, Schwarz weniger. Shift setzt auf Mittelgrau zurück. Strg+Z macht rückgängig.',
sim:'Die Simulation läuft im Hintergrund. Währenddessen kannst du die Ansicht drehen und posieren.',
look:'Farbe, Glanz und Texturen der aufgeblasenen Hülle.',
file:'Projekt als JSON sichern, Karten als PNG sichern oder laden, Ansicht als PNG speichern.'};
const TABS=[['pose','Pose'],['mask','Maske'],['seam','Nähte'],['gray','Druck'],['sim','Sim'],['look','Look'],['file','Datei']];
const panes={};
TABS.forEach(([k,t])=>{const b=document.createElement('button');b.textContent=t;b.dataset.k=k;b.onclick=()=>setTab(k);$('#tabs').appendChild(b);
const p=document.createElement('div');p.className='pane';$('#body').appendChild(p);panes[k]=p;});
function note(p,t){const e=document.createElement('p');e.className='note';e.textContent=t;p.appendChild(e);return e;}
function slider(p,label,min,max,step,val,fmt,cb){
const w=document.createElement('div');w.className='sl';const top=document.createElement('div');top.className='sltop';
const l=document.createElement('span');l.textContent=label;const v=document.createElement('span');v.className='val';top.append(l,v);
const i=document.createElement('input');i.type='range';i.min=min;i.max=max;i.step=step;i.value=val;
const show=()=>{v.textContent=fmt?fmt(+i.value):i.value;};show();
i.addEventListener('input',()=>{show();cb(+i.value);});i.addEventListener('change',()=>i.blur());w.append(top,i);p.appendChild(w);
return {set(x){i.value=x;show();}};
}
function btn(p,label,cb,cls){const b=document.createElement('button');b.className='btn'+(cls?' '+cls:'');b.textContent=label;b.onclick=cb;p.appendChild(b);return b;}
function row(p){const d=document.createElement('div');d.className='btns';p.appendChild(d);return d;}
function seg(p,opts,val,cb){const w=document.createElement('div');w.className='seg';const bs=[];
const set=k=>bs.forEach(b=>b.classList.toggle('on',b.dataset.k===k));
opts.forEach(([k,t])=>{const b=document.createElement('button');b.textContent=t;b.dataset.k=k;b.onclick=()=>{set(k);cb(k);};w.appendChild(b);bs.push(b);});set(val);p.appendChild(w);return {set};}
function check(p,label,val,cb){const l=document.createElement('label');l.className='chk';const i=document.createElement('input');i.type='checkbox';i.checked=val;
i.onchange=()=>cb(i.checked);l.append(i,label);p.appendChild(l);return i;}
function hr(p){p.appendChild(document.createElement('hr'));}
const cm=v=>v.toFixed(1).replace('.',',')+' cm',f2=v=>v.toFixed(2).replace('.',',');
const brushSl=[],mirrorChecks=[];
function mirrorCheck(p){const c=check(p,'Symmetrisch (vertikale Körpermitte)',P.mirror,v=>{P.mirror=v;mirrorChecks.forEach(x=>{x.checked=v;});});mirrorChecks.push(c);return c;}
function brushSlider(p){const s=slider(p,'Pinselgröße',1,25,0.5,P.brush,cm,v=>{P.brush=v;brushSl.forEach(q=>q.set(v));});brushSl.push(s);}
function buildPanes(){
let p=panes.pose,r;
note(p,'Gelenkpunkte am Körper ziehen. Der gewählte Knochen lässt sich unten auch drehen.');
const bn=document.createElement('p');bn.innerHTML='Gewählt: <span id="boneName">–</span>';bn.style.margin='0 0 4px';p.appendChild(bn);
['x','y','z'].forEach(a=>rotSl.push(slider(p,'Drehung '+a.toUpperCase(),-180,180,1,0,v=>v+'°',v=>{if(S.sel){S.sel.rotation[a]=v*Math.PI/180;S.sel.updateMatrixWorld(true);markPose();S.touched=true;}})));
r=row(p);btn(r,'Bind-Pose',resetPose);btn(r,'A-Pose',aPose);
r=row(p);btn(r,'Orbit mit linker Maustaste',function(){P.orbit=!P.orbit;this.classList.toggle('fill',P.orbit);});hr(p);
const bl=document.createElement('p');bl.className='note';bl.innerHTML='Körper: <span id="bodyLabel"></span>';p.appendChild(bl);
r=row(p);btn(r,'GLB laden …',()=>$('#glbIn').click());
note(p,'Beim Start wird smoothed_body.glb aus dem selben Ordner geladen, falls vorhanden. Ein GLB lässt sich auch auf die Seite ziehen.');
p=panes.mask;note(p,'Die Maske ist zu Beginn leer. Male dort, wo die zweite Haut sitzen soll. So bleiben Hände, Füße und Achseln frei.');
seg(p,[['paint','Malen'],['erase','Radieren']],P.maskMode,k=>P.maskMode=k);brushSlider(p);mirrorCheck(p);
r=row(p);btn(r,'Maske leeren',()=>presetMask('none'));
r=row(p);btn(r,'Maske speichern',()=>exportMapPNG('mask'));btn(r,'Maske laden …',()=>pickPNG('mask'));
btn(r,'Maske weichzeichnen',()=>{pushUndo(S.body.mask,'body',()=>{S.maskVer++;updateBodyColors();});blurField(S.body.mask,S.body.topo,1);S.maskVer++;updateBodyColors();});
hr(p);slider(p,'Unterteilung der Hülle',0,3,1,P.subdiv,null,v=>{P.subdiv=v;});
slider(p,'Randglättung',0,6,1,P.edgeSmooth,null,v=>{P.edgeSmooth=v;});
r=row(p);btn(r,'Hülle bauen',()=>{setStatus('Baue Hülle …');setTimeout(()=>{buildShell();setTab(S.tab);},16);},'fill');
const si=document.createElement('p');si.id='shellInfo';si.className='note';p.appendChild(si);
p=panes.seam;note(p,'Setze Punkte auf der Hülle. Aus ihnen entsteht ein Nahtnetz, und die Kammern dazwischen werden aufgepumpt. Löschen entfernt Punkte mit dem Pinsel, Entf den Punkt unter dem Cursor.');
seg(p,[['pts','Setzen'],['del','Löschen'],['dens','Dichte']],P.net.tool,k=>{P.net.tool=k;S.heightsDirty=true;});
mirrorCheck(p);
seg(p,[['voronoi','Voronoi'],['delaunay','Delaunay']],P.net.mode,k=>{P.net.mode=k;netParamChanged();});
slider(p,'Nahtbreite',0.2,4,0.1,P.net.width,cm,v=>{P.net.width=v;netParamChanged();});
slider(p,'Rundung',0,8,1,P.net.round,null,v=>{P.net.round=v;netParamChanged();});
slider(p,'Mindestgröße einer Kammer',0,12,0.5,P.net.minArea,v=>v.toFixed(1).replace('.',',')+' cm²',v=>{P.net.minArea=v;netParamChanged();});
hr(p);note(p,'Dichte: Weiß verdichtet die Punkte, Schwarz lockert sie. „Punkte verteilen“ setzt sie danach automatisch.');
brushSlider(p);slider(p,'Stärke',0.02,1,0.01,P.net.dstr,f2,v=>P.net.dstr=v);
slider(p,'Dichtewert',0,1,0.01,P.net.dval,f2,v=>P.net.dval=v);
slider(p,'Abstand bei Dichte 1',1,15,0.5,P.net.dense,cm,v=>P.net.dense=v);
slider(p,'Abstand bei Dichte 0',2,30,0.5,P.net.sparse,cm,v=>P.net.sparse=v);
r=row(p);btn(r,'Punkte verteilen',distributePoints,'fill');btn(r,'Alle löschen',clearPoints);
r=row(p);btn(r,'Dichte speichern',()=>exportMapPNG('dens'));btn(r,'Dichte laden …',()=>pickPNG('dens'));
check(p,'Kammern einfärben',P.chambers,v=>{P.chambers=v;S.heightsDirty=true;});
const ni=document.createElement('p');ni.id='netInfo';ni.className='note';p.appendChild(ni);
p=panes.gray;
seg(p,[['P','Druck'],['S','Ausdehnung']],P.gtarget,k=>{P.gtarget=k;S.heightsDirty=true;});
brushSlider(p);mirrorCheck(p);slider(p,'Stärke',0.02,1,0.01,P.strength,f2,v=>P.strength=v);
slider(p,'Grauwert',0,1,0.01,P.grayVal,f2,v=>{P.grayVal=v;sw_.style.background='rgb('+Math.round(v*255)+','+Math.round(v*255)+','+Math.round(v*255)+')';});
const swp=document.createElement('p');swp.className='note';const sw_=document.createElement('span');sw_.className='sw';sw_.style.background='#fff';swp.append(sw_,'Mittelgrau ist neutral. Weiß erhöht, Schwarz senkt.');p.appendChild(swp);
check(p,'Karte auf der Hülle zeigen',P.showMap,v=>{P.showMap=v;S.heightsDirty=true;});
r=row(p);btn(r,'Karte zurücksetzen',()=>{const sh=S.shell;if(!sh||simLocked())return;const a=P.gtarget==='P'?sh.Pm:sh.Sm;pushUndo(a,'shell',markMaps);a.fill(0.5);markMaps();});
r=row(p);btn(r,'Karte speichern',()=>exportMapPNG(P.gtarget==='P'?'press':'stretch'));btn(r,'Karte laden …',()=>pickPNG(P.gtarget==='P'?'press':'stretch'));
note(p,'Die Karten wirken erst in der Simulation. Druck steuert, wie stark die Haut dort nach außen gedrückt wird. Ausdehnung steuert, wie dehnbar der Stoff ist.');
p=panes.sim;
note(p,'Das Gewebe wird unter Druck simuliert: Maskenrand und Nähte bleiben fest, überschüssiger Stoff wölbt sich zu Blasen. Vorher bleibt die Hülle flach.');
seg(p,[['press','Aufpumpen'],['growth','Wachstum (Darm)']],P.sim.mode,k=>{P.sim.mode=k;});
note(p,'Aufpumpen: Druck formt glatte Kammern, Falten entstehen nur an den Nähten. Wachstum: Der Stoff wächst, während die Nähte ihn festhalten, und knickt in darmartige Falten und Schlingen. Dafür Druck niedrig und Biegesteifigkeit höher stellen.');
slider(p,'Stoffzugabe',0,150,1,P.sim.zug,v=>v+' %',v=>P.sim.zug=v);
slider(p,'Druck',0.2,3,0.05,P.sim.press,f2,v=>P.sim.press=v);
slider(p,'Elastizität',0,0.9,0.01,P.sim.soft,f2,v=>P.sim.soft=v);
slider(p,'Biegesteifigkeit',0,1,0.01,P.sim.bend,f2,v=>P.sim.bend=v);
slider(p,'Kuppelform',0,1,0.01,P.sim.dome,f2,v=>P.sim.dome=v);
slider(p,'Kammerglättung',0,1,0.01,P.sim.smooth,f2,v=>P.sim.smooth=v);
slider(p,'Faltenbreite an Nähten',0.3,4,0.1,P.sim.foldW,cm,v=>P.sim.foldW=v);
slider(p,'Faltenzugabe an Nähten',0,100,1,P.sim.fold,v=>v+' %',v=>P.sim.fold=v);
slider(p,'Randabstand',0,4,0.1,P.sim.rim,cm,v=>P.sim.rim=v);
slider(p,'Nahtabstand',0,2,0.1,P.sim.lift,cm,v=>P.sim.lift=v);
note(p,'Der Randabstand lässt die Ränder der Hülle rund vom Körper abstehen. Der Nahtabstand hebt die Nähte leicht an.');
hr(p);note(p,'Simulationsdetail');
seg(p,[['0','Standard'],['1','Hoch']],String(Math.min(1,P.sim.detail)),k=>{P.sim.detail=+k;updateDetInfo();});
const di=document.createElement('p');di.id='detInfo';di.className='note';p.appendChild(di);
slider(p,'Iterationen',100,3000,50,P.sim.iters,null,v=>{P.sim.iters=v;updateDetInfo();});
slider(p,'Glättung am Ende',0,300,10,P.sim.fair,null,v=>P.sim.fair=v);
check(p,'Live-Vorschau während der Simulation',P.sim.preview,v=>P.sim.preview=v);
r=row(p);
const go=btn(r,'Simulieren',()=>startSim(),'fill');go.id='simGo';
const stop=btn(r,'Abbrechen',()=>stopSim());stop.id='simStop';stop.disabled=true;
const bar=document.createElement('div');bar.className='bar';bar.innerHTML='<i id="simBar"></i>';p.appendChild(bar);
const st=document.createElement('p');st.id='simTxt';st.className='note';st.style.minHeight='2.8em';p.appendChild(st);
hr(p);
window.__fillSl=slider(p,'Füllung',0,1,0.01,P.fill,f2,v=>{P.fill=v;S.fillAnim=null;S.heightsDirty=true;});
r=row(p);btn(r,'Aufpumpen abspielen',()=>{const sh=S.shell;if(!sh||!sh.sim){setStatus('Es gibt noch kein Simulationsergebnis.');return;}P.fill=0;S.fillAnim={t0:performance.now(),dur:2400};});
btn(r,'Ergebnis verwerfen',discardSim);
check(p,'Körper zeigen',P.showBody,v=>{P.showBody=v;applyVisibility();});
check(p,'Netz zeigen',P.wire,v=>{P.wire=v;applyVisibility();});
p=panes.look;
note(p,'Aussehen der aufgeblasenen Hülle.');
const cr=document.createElement('div');cr.className='sltop';cr.style.margin='10px 0';
const cl=document.createElement('span');cl.textContent='Farbe';const ci=document.createElement('input');ci.type='color';ci.className='clr';ci.value=P.mat.color;
ci.oninput=()=>{P.mat.color=ci.value;refreshMaterial();};cr.append(cl,ci);p.appendChild(cr);
slider(p,'Rauheit',0,1,0.01,P.mat.rough,f2,v=>{P.mat.rough=v;refreshMaterial();});
slider(p,'Klarlack',0,1,0.01,P.mat.clear,f2,v=>{const was=P.mat.clear>0;P.mat.clear=v;if((v>0)!==was)shellMat.needsUpdate=true;refreshMaterial();});
slider(p,'Klarlack-Rauheit',0,1,0.01,P.mat.clearRough,f2,v=>{P.mat.clearRough=v;refreshMaterial();});
slider(p,'Metall',0,1,0.01,P.mat.metal,f2,v=>{P.mat.metal=v;refreshMaterial();});
slider(p,'Reflexion',0,2,0.05,P.mat.env,f2,v=>{P.mat.env=v;refreshMaterial();});
slider(p,'Eigenleuchten',0,0.4,0.01,P.mat.glow,f2,v=>{P.mat.glow=v;refreshMaterial();});
hr(p);
const tl=document.createElement('p');tl.className='note';tl.innerHTML='Textur: <span id="texInfo">keine</span>';p.appendChild(tl);
r=row(p);btn(r,'Textur laden …',()=>$('#texIn').click());btn(r,'Entfernen',()=>clearTex('tex'));
check(p,'Textur anzeigen',P.mat.texOn,v=>{P.mat.texOn=v;refreshMaterial();});
slider(p,'Größe: Wiederholungen pro Meter',0.2,12,0.1,P.mat.texScale,f2,v=>{P.mat.texScale=v;refreshMaterial();});
check(p,'Spiegelnd wiederholen',P.mat.texMirror,v=>{P.mat.texMirror=v;applyWrap();});
hr(p);
const nl=document.createElement('p');nl.className='note';nl.innerHTML='Normal Map: <span id="norInfo">keine</span>';p.appendChild(nl);
r=row(p);btn(r,'Normal Map laden …',()=>$('#norIn').click());btn(r,'Entfernen',()=>clearTex('nor'));
check(p,'Normal Map anzeigen',P.mat.norOn,v=>{P.mat.norOn=v;refreshMaterial();});
slider(p,'Größe: Wiederholungen pro Meter',0.2,12,0.1,P.mat.norScale,f2,v=>{P.mat.norScale=v;refreshMaterial();});
slider(p,'Stärke',0,4,0.05,P.mat.norS,f2,v=>{P.mat.norS=v;refreshMaterial();});
check(p,'Spiegelnd wiederholen',P.mat.norMirror,v=>{P.mat.norMirror=v;applyWrap();});
check(p,'Grünkanal invertieren',P.mat.norFlip,v=>{P.mat.norFlip=v;refreshMaterial();});
note(p,'Die Farbe wird mit der Textur multipliziert. Für die reine Textur die Farbe auf Weiß stellen. Die Bilder werden ohne UVs aus drei Richtungen auf die Hülle projiziert und bleiben beim Posieren und Aufpumpen am Körper. Beim Start werden texture_map.jpg und normal_map.jpg aus dem Ordner der Seite geladen, falls vorhanden. Bilder lassen sich auch auf die Seite ziehen.');
updateTexInfo();
p=panes.file;
note(p,'Speichert Maske, Karten, Punkte, Einstellungen und Pose als JSON. Zum Laden muss derselbe Körper geöffnet sein.');
r=row(p);btn(r,'Projekt speichern',saveProject,'fill');btn(r,'Projekt laden …',()=>$('#projIn').click());
hr(p);
note(p,'Karte als Grafik: ein PNG mit einem Grauwert-Pixel pro Vertex. Es lässt sich verlustfrei wieder laden, aber nur auf demselben Körper beziehungsweise derselben Hülle.');
const sel=document.createElement('select');sel.id='mapSel';sel.className='selbox';
[['mask','Maske (Körper)'],['dens','Dichte (Hülle)'],['press','Druck (Hülle)'],['stretch','Ausdehnung (Hülle)']].forEach(([k,t])=>{const o=document.createElement('option');o.value=k;o.textContent=t;sel.appendChild(o);});
p.appendChild(sel);
r=row(p);btn(r,'Als PNG speichern',exportMapPNG);btn(r,'PNG laden …',()=>$('#pngIn').click());
hr(p);r=row(p);btn(r,'Ansicht als PNG',()=>{S.snap=true;});
note(p,'JSON, PNG und GLB lassen sich auch auf die Seite ziehen.');
hr(p);r=row(p);btn(r,'Automatische Sicherung laden',restoreAutosave);
const ri=document.createElement('p');ri.id='restInfo';ri.className='note';p.appendChild(ri);updateRestoreInfo();
note(p,'Eine Datei namens inflate_project.json im Ordner der Seite wird beim Start automatisch geladen. Alle Änderungen werden außerdem regelmäßig im Browser gesichert.');
updateDetInfo();
}
buildPanes();
function setTab(k){
S.tab=k;Object.keys(panes).forEach(t=>panes[t].classList.toggle('on',t===k));
[...$('#tabs').children].forEach(b=>b.classList.toggle('on',b.dataset.k===k));
if(k==='seam'||k==='gray'||k==='sim'){if(S.body&&(!S.shell||S.shellVer!==S.maskVer))ensureShell();}
$('#hint').textContent=HINTS[k];applyVisibility();updateBodyColors();S.heightsDirty=true;S.overlayDirty=true;updateDetInfo();
}
function applyVisibility(){
const B=S.body,sh=S.shell,hq=S.hq;if(!B)return;
B.mesh.visible=P.showBody||S.tab==='mask';
const showHQ=!!(hq&&hq.sim)&&S.tab!=='mask'&&S.tab!=='seam'&&S.tab!=='gray';
if(sh){sh.mesh.visible=S.tab!=='mask'&&!showHQ;sh.wire.visible=P.wire&&sh.mesh.visible;}
if(hq){hq.mesh.visible=showHQ;hq.wire.visible=P.wire&&showHQ;}
overlay.visible=(S.tab==='seam');
$('#dots').style.display=S.tab==='pose'?'block':'none';
}
/* ===================== Speichern / Laden ===================== */
function b64(u8){let s='';for(let i=0;i<u8.length;i+=0x8000)s+=String.fromCharCode.apply(null,u8.subarray(i,i+0x8000));return btoa(s);}
function unb64(str){const s=atob(str),u=new Uint8Array(s.length);for(let i=0;i<s.length;i++)u[i]=s.charCodeAt(i);return u;}
function q8(a){const u=new Uint8Array(a.length);for(let i=0;i<a.length;i++)u[i]=Math.round(clamp(a[i],0,1)*255);return u;}
function stamp(){return new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');}
function download(name,blob){
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();
setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1500);
}
const SAVE_KEYS=['subdiv','edgeSmooth','mirror','brush','fill','gtarget','maskMode','grayVal','strength','chambers','showBody','wire'];
function projectData(){
const B=S.body,sh=S.shell;if(!B)return null;
const pose={};S.bones.forEach(b=>{pose[b.name]=b.quaternion.toArray();});
const settings={};SAVE_KEYS.forEach(k=>{settings[k]=P[k];});settings.net=Object.assign({},P.net);settings.sim=Object.assign({},P.sim);settings.mat=Object.assign({},P.mat);
let shell=null;
if(sh){
let mn=[1e30,1e30,1e30],mx=[-1e30,-1e30,-1e30];
for(let i=0;i<sh.base.length;i+=3)for(let a=0;a<3;a++){mn[a]=Math.min(mn[a],sh.base[i+a]);mx[a]=Math.max(mx[a],sh.base[i+a]);}
const sc=mx.map((v,a)=>(v-mn[a])||1),qp=new Uint16Array(sh.base.length);
for(let i=0;i<sh.base.length;i+=3)for(let a=0;a<3;a++)qp[i+a]=Math.round((sh.base[i+a]-mn[a])/sc[a]*65535);
shell={n:sh.n,L:sh.L,min:mn,size:sc,base:b64(new Uint8Array(qp.buffer)),dens:b64(q8(sh.Dm)),press:b64(q8(sh.Pm)),stretch:b64(q8(sh.Sm))};
}
return {app:'inflate-body',version:2,saved:new Date().toISOString(),body:{label:B.label,n:B.n},settings,mask:b64(q8(B.mask)),shell,
points:S.points.map(p=>[+p.pos.x.toFixed(5),+p.pos.y.toFixed(5),+p.pos.z.toFixed(5)]),
mates:S.points.map(p=>p.mate?S.points.indexOf(p.mate):-1),pose};
}
function saveProject(){
const d=projectData();if(!d){setStatus('Es ist kein Körper geladen.');return;}
download('inflate_project.json',new Blob([JSON.stringify(d)],{type:'application/json'}));
setStatus('Das Projekt wurde gespeichert (inflate_project.json).');
}
async function loadProject(file){
let d;try{d=JSON.parse(await file.text());}catch(e){setStatus('Die Datei ist kein gültiges JSON.');return;}
loadProjectData(d);
}
function loadProjectData(d){
if(simLocked())return false;const B=S.body;if(!B)return false;
if(!d||d.app!=='inflate-body'||!d.mask){setStatus('Das ist keine Inflate-Projektdatei.');return false;}
let m8;try{m8=unb64(d.mask);}catch(e){setStatus('Die Maske in der Datei ist beschädigt.');return false;}
if(m8.length!==B.n){setStatus('Die Datei passt nicht zum geladenen Körper ('+m8.length+' statt '+B.n+' Vertices). Bitte denselben Körper laden.');return false;}
abortSim();
const st=d.settings||{};
SAVE_KEYS.forEach(k=>{if(k in st)P[k]=st[k];});
if(st.net)Object.assign(P.net,st.net);if(st.sim)Object.assign(P.sim,st.sim);if(st.mat)Object.assign(P.mat,st.mat);
P.sim.detail=Math.min(1,P.sim.detail||0);applyWrap();
for(let i=0;i<B.n;i++)B.mask[i]=m8[i]/255;S.maskVer++;
const pts=(d.points||[]).map(a=>({pos:new T.Vector3(a[0],a[1],a[2]),vid:-1,mate:null}));
(d.mates||[]).forEach((m,i)=>{if(m>=0&&pts[m]&&pts[i])pts[i].mate=pts[m];});S.points=pts;
if(d.pose){S.bones.forEach(b=>{const q=d.pose[b.name];if(q&&q.length===4)b.quaternion.fromArray(q);});rig.updateMatrixWorld(true);markPose();}
S.undo=[];S.editVer++;
rebuildPanes();updateBodyColors();
buildShell();
const sh=S.shell;
if(sh&&d.shell&&d.shell.base){
try{
const q=new Uint16Array(unb64(d.shell.base).buffer.slice(0)),n0=d.shell.n,bp=new Float32Array(n0*3);
for(let i=0;i<n0;i++)for(let a=0;a<3;a++)bp[3*i+a]=d.shell.min[a]+q[3*i+a]/65535*d.shell.size[a];
const dd=unb64(d.shell.dens),pp=unb64(d.shell.press),ss=unb64(d.shell.stretch),g=new PointGrid(bp,sh.edge);
for(let i=0;i<sh.n;i++){const j=g.nearest(sh.base[3*i],sh.base[3*i+1],sh.base[3*i+2],4);if(j>=0&&j<n0){sh.Dm[i]=dd[j]/255;sh.Pm[i]=pp[j]/255;sh.Sm[i]=ss[j]/255;}}
}catch(e){setStatus('Die Karten der Hülle konnten nicht gelesen werden.');}
}
setTab(S.tab);S.netDirty=true;S.heightsDirty=true;refreshMaterial();S.saveDirty=true;
if(!$('#status').textContent)setStatus('Das Projekt wurde geladen.');
return true;
}
function mapTarget(which){
const B=S.body,sh=S.shell;
if(which==='mask')return B?{arr:B.mask,kind:'body',after:()=>{S.maskVer++;updateBodyColors();}}:null;
if(!sh)return null;
return {dens:{arr:sh.Dm,kind:'shell',after:()=>{S.heightsDirty=true;}},press:{arr:sh.Pm,kind:'shell',after:markMaps},stretch:{arr:sh.Sm,kind:'shell',after:markMaps}}[which]||null;
}
function mapDims(n){const W=Math.max(32,Math.ceil(Math.sqrt(n)));return [W,Math.ceil(n/W)];}
function exportMapPNG(whichArg){
const which=(typeof whichArg==='string'&&whichArg)||$('#mapSel').value,t=mapTarget(which);
if(!t){setStatus('Für diese Karte gibt es noch keine Hülle. Bitte zuerst eine Maske malen.');return;}
const n=t.arr.length,[W,H]=mapDims(n),c=document.createElement('canvas');c.width=W;c.height=H;
const g=c.getContext('2d'),im=g.createImageData(W,H);
for(let i=0;i<W*H;i++){const v=i<n?Math.round(clamp(t.arr[i],0,1)*255):0;im.data[4*i]=im.data[4*i+1]=im.data[4*i+2]=v;im.data[4*i+3]=255;}
g.putImageData(im,0,0);
c.toBlob(b=>{if(b){download('inflate-'+which+'-'+n+'.png',b);setStatus('Die Karte wurde gespeichert.');}else setStatus('Das PNG konnte nicht erzeugt werden.');},'image/png');
}
function pickPNG(which){S.pngWhich=which;$('#pngIn').click();}
function importMapPNG(file,whichArg){
if(simLocked())return;const nm=/inflate-(mask|dens|press|stretch)-/.exec(file.name),which=whichArg||(nm&&nm[1])||$('#mapSel').value,t=mapTarget(which);
if(!t){setStatus('Für diese Karte gibt es noch keine Hülle. Bitte zuerst eine Maske malen.');return;}
const img=new Image(),url=URL.createObjectURL(file);
img.onload=()=>{
URL.revokeObjectURL(url);const n=t.arr.length,[W,H]=mapDims(n);
if(img.width!==W||img.height!==H){setStatus('Das PNG passt nicht zu dieser Karte (erwartet '+W+' × '+H+' Pixel, gefunden '+img.width+' × '+img.height+').');return;}
const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d');g.drawImage(img,0,0);
const px=g.getImageData(0,0,W,H).data;pushUndo(t.arr,t.kind,t.after);
for(let i=0;i<n;i++)t.arr[i]=px[4*i]/255;t.after();setStatus('Die Karte wurde aus dem PNG geladen.');
};
img.onerror=()=>{URL.revokeObjectURL(url);setStatus('Das PNG konnte nicht gelesen werden.');};img.src=url;
}
function routeFile(f){
if(!f)return;const n=f.name.toLowerCase();
if(n.endsWith('.json'))loadProject(f);
else if(/\.(png|jpe?g|webp)$/.test(n)){
if(/^inflate-(mask|dens|press|stretch)-/.test(n))importMapPNG(f);else loadTexFile(f,/normal|nrm|_n\./.test(n)?'nor':'tex');
}else loadFile(f);
}
function rebuildPanes(){
Object.keys(panes).forEach(k=>{panes[k].innerHTML='';});brushSl.length=0;rotSl.length=0;mirrorChecks.length=0;
buildPanes();if(S.body)$('#bodyLabel').textContent=S.body.label;updateSimUI();
}
/* ---- automatische Sicherung im Browser ---- */
function autosaveTime(){try{return localStorage.getItem(AUTOKEY+'-t');}catch(e){return null;}}
function updateRestoreInfo(){const el=$('#restInfo');if(!el)return;const t=autosaveTime();el.textContent=t?'Automatisch gesichert: '+new Date(t).toLocaleString('de-DE'):'Noch keine automatische Sicherung.';}
function restoreAutosave(){
try{const s=localStorage.getItem(AUTOKEY);if(!s){setStatus('Es gibt keine automatische Sicherung.');return;}loadProjectData(JSON.parse(s));}
catch(e){setStatus('Die automatische Sicherung konnte nicht gelesen werden.');}
}
setInterval(()=>{
if(!S.saveDirty||!S.body||(S.sim&&S.sim.running))return;S.saveDirty=false;
try{const d=projectData();localStorage.setItem(AUTOKEY,JSON.stringify(d));localStorage.setItem(AUTOKEY+'-t',new Date().toISOString());updateRestoreInfo();}catch(e){}
},5000);
$('#projIn').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';if(f)loadProject(f);});
$('#pngIn').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';const w=S.pngWhich;S.pngWhich=null;if(f)importMapPNG(f,w);});
$('#texIn').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';if(f)loadTexFile(f,'tex');});
$('#norIn').addEventListener('change',e=>{const f=e.target.files[0];e.target.value='';if(f)loadTexFile(f,'nor');});
/* ===================== Eingaben ===================== */
function toolTab(){return S.tab==='mask'||S.tab==='gray'||S.tab==='seam';}
canvas.addEventListener('contextmenu',e=>e.preventDefault());
canvas.addEventListener('pointerdown',e=>{
canvas.setPointerCapture(e.pointerId);S.ptr.x=e.clientX;S.ptr.y=e.clientY;
const orb=e.button===2||e.button===1||S.space||P.orbit||(S.tab==='pose'||S.tab==='sim'||S.tab==='file')&&e.button===0;
if(orb){S.orbit={pan:e.shiftKey||e.button===1,x:e.clientX,y:e.clientY};return;}
if(e.button!==0||!S.body)return;
if(S.tab==='mask')beginStroke('mask',e.shiftKey||P.maskMode==='erase');
else if(S.tab==='gray')beginStroke(P.gtarget,e.shiftKey);
else if(S.tab==='seam'){if(P.net.tool==='dens')beginStroke('D',e.shiftKey);else if(P.net.tool==='del')beginDelete();else pointsDown(e);}
});
canvas.addEventListener('pointermove',e=>{
S.ptr.x=e.clientX;S.ptr.y=e.clientY;S.ptr.inside=true;
if(S.orbit){const dx=e.clientX-S.orbit.x,dy=e.clientY-S.orbit.y;S.orbit.x=e.clientX;S.orbit.y=e.clientY;
if(S.orbit.pan){const k=2*cam.r*Math.tan(camera.fov*Math.PI/360)/canvas.clientHeight;
cam.tx-=Math.cos(cam.th)*dx*k;cam.tz+=Math.sin(cam.th)*dx*k;cam.ty+=dy*k;}
else{cam.th-=dx*0.006;cam.ph=clamp(cam.ph-dy*0.006,0.15,Math.PI-0.15);}}
});
const up=()=>{if(S.stroke||S.drag||S.delStroke)S.saveDirty=true;S.orbit=null;S.stroke=null;S.drag=null;S.delStroke=false;};
canvas.addEventListener('pointerup',up);canvas.addEventListener('pointercancel',up);
canvas.addEventListener('pointerleave',()=>{S.ptr.inside=false;});
canvas.addEventListener('wheel',e=>{e.preventDefault();cam.r=clamp(cam.r*Math.exp(e.deltaY*0.001),1.2,12);},{passive:false});
window.addEventListener('keydown',e=>{
if(e.code==='Space'){S.space=true;e.preventDefault();return;}
if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'){undo();e.preventDefault();return;}
if(S.tab==='seam'&&(e.key==='Backspace'||e.key==='Delete')){removePointAtCursor();e.preventDefault();}
if(e.key==='['){P.brush=clamp(P.brush-1,1,25);brushSl.forEach(q=>q.set(P.brush));}
if(e.key===']'){P.brush=clamp(P.brush+1,1,25);brushSl.forEach(q=>q.set(P.brush));}
});
window.addEventListener('keyup',e=>{if(e.code==='Space'){S.space=false;e.preventDefault();}});
/* ===================== GLB ===================== */
function loadScript(urls){return new Promise((res,rej)=>{let i=0;const next=()=>{if(i>=urls.length)return rej(new Error('script'));
const s=document.createElement('script');s.src=urls[i++];const to=setTimeout(()=>{s.remove();next();},6000);
s.onload=()=>{clearTimeout(to);res();};s.onerror=()=>{clearTimeout(to);s.remove();next();};document.head.appendChild(s);};next();});}
async function ensureGLTF(){
if(T.GLTFLoader)return true;
try{await loadScript(['https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js','https://unpkg.com/three@0.128.0/examples/js/loaders/GLTFLoader.js']);}catch(e){return false;}
return !!T.GLTFLoader;
}
function adoptGLTF(gltf,label){
const root=gltf.scene;let best=null,bn=0;const all=[];
root.traverse(o=>{if(o.isSkinnedMesh){all.push(o);const c=o.geometry.attributes.position.count;if(c>bn){bn=c;best=o;}}});
if(!best){setStatus('Dieses GLB enthält kein gerigtes Mesh.');return false;}
all.forEach(o=>{if(o!==best)o.visible=false;});
const tab=S.tab;disposeBody();newRig();rig.add(root);root.updateMatrixWorld(true);
initBody(best,weldGeometry(best.geometry),label,root);setTab(tab);setStatus('');return true;
}
async function loadGLBBuffer(buf,label){
if(!(await ensureGLTF())){setStatus('Der GLTFLoader konnte nicht geladen werden.');return false;}
setStatus('Lade '+label+' …');
return new Promise(res=>{new T.GLTFLoader().parse(buf,'',g=>{setTimeout(()=>{res(adoptGLTF(g,label));},10);},err=>{setStatus('GLB konnte nicht gelesen werden.');res(false);});});
}
function loadFile(f){if(!f)return;const r=new FileReader();r.onload=()=>loadGLBBuffer(r.result,f.name);r.readAsArrayBuffer(f);}
$('#ovX').addEventListener('click',()=>stopSim());
$('#glbIn').addEventListener('change',e=>loadFile(e.target.files[0]));
window.addEventListener('dragover',e=>{e.preventDefault();$('#drop').style.display='flex';});
window.addEventListener('dragleave',e=>{if(!e.relatedTarget)$('#drop').style.display='none';});
window.addEventListener('drop',e=>{e.preventDefault();$('#drop').style.display='none';routeFile(e.dataTransfer.files[0]);});
async function tryDefaultGLB(){
for(const url of ['smoothed_body.glb','../smoothed_body.glb','/smoothed_body.glb']){
try{const r=await fetch(url);if(!r.ok)continue;const buf=await r.arrayBuffer();
if(S.touched)return;await loadGLBBuffer(buf,url.split('/').pop());return;}catch(e){}
}
}
/* ===================== Schleife ===================== */
const _v=new T.Vector3();
function updateDots(){
if(S.tab!=='pose')return;const r=canvas.getBoundingClientRect();
for(const b of S.bones){const el=b.userData.dot;if(!el)continue;b.userData.aim.getWorldPosition(_v).project(camera);
el.style.transform='translate('+((_v.x+1)/2*r.width+r.left)+'px,'+((1-_v.y)/2*r.height+r.top)+'px)';}
}
function pointerWork(){
const B=S.body;ring.visible=false;S.hit=null;if(!B||!toolTab()||!S.ptr.inside||S.orbit)return;
const surf=S.tab==='mask'?B.surf:(S.shell&&S.shell.surf);if(!surf)return;
const rc=rayFrom({clientX:S.ptr.x,clientY:S.ptr.y}),o=rc.ray.origin,d=rc.ray.direction,hit=surf.raycast(o.x,o.y,o.z,d.x,d.y,d.z);
S.hit=hit;if(!hit)return;
if(S.tab!=='seam'||P.net.tool!=='pts'){const R=P.brush*0.01;ring.visible=true;ring.scale.setScalar(R);
ring.position.set(hit.px+hit.n[0]*0.004,hit.py+hit.n[1]*0.004,hit.pz+hit.n[2]*0.004);
ring.quaternion.setFromUnitVectors(new T.Vector3(0,0,1),new T.Vector3(hit.n[0],hit.n[1],hit.n[2]));}
if(S.stroke)applyStroke(hit);
if(S.delStroke)deleteInBrush(hit);
if(S.drag&&S.points[S.drag.idx]){
const cp=S.points[S.drag.idx],nc=makeCP(hit);cp.pos.copy(nc.pos);cp.vid=nc.vid;
if(P.mirror&&S.mir&&cp.mate&&S.points.includes(cp.mate)){const m=mirrorOf(cp);if(m){cp.mate.pos.copy(m.pos);cp.mate.vid=m.vid;}}
pointsChanged();
}
}
function frame(now){
requestAnimationFrame(frame);
if(S.fillAnim){const t=clamp((now-S.fillAnim.t0)/S.fillAnim.dur,0,1);P.fill=t*t*(3-2*t);window.__fillSl&&window.__fillSl.set(P.fill);S.heightsDirty=true;if(t>=1)S.fillAnim=null;}
if(S.netDirty&&S.shell&&(!S.drag||now-(S.netT||0)>90)){S.netT=now;computeNetwork();S.heightsDirty=true;}
if(S.heightsDirty&&S.shell){S.heightsDirty=false;applyShell();updateShellColors();S.overlayDirty=true;}
if(S.visDirty){S.visDirty=false;applyVisibility();}
pointerWork();
if(S.overlayDirty&&overlay.visible)updateSeamOverlay();
updateDots();placeCam();renderer.render(scene,camera);
if(S.snap){S.snap=false;try{const a=document.createElement('a');a.href=canvas.toDataURL('image/png');a.download='inflate-ansicht-'+stamp()+'.png';document.body.appendChild(a);a.click();a.remove();}catch(e){setStatus('Die Ansicht konnte nicht gespeichert werden.');}}
}
resize();createProcedural();
requestAnimationFrame(frame);
(async()=>{
await tryDefaultGLB();autoLoadTextures();
try{const r=await fetch('inflate_project.json');if(r.ok){const d=await r.json();if(!S.touched)loadProjectData(d);}}catch(e){}
})();
window.inflateBody={S,P,buildShell,presetMask,setTab,mu,shellMat};
})();
</script>
</body>
</html>