index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>INFLATE // PROJECT</title>
<style>
:root{--ink:#111;--mut:#8a8a8a;--line:#e8e8e8}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:#fff;color:var(--ink);font:13px/1.4 -apple-system,"Helvetica Neue",Arial,sans-serif;overflow:hidden}
#app{display:grid;grid-template-rows:48px 1fr;grid-template-columns:1fr 268px;height:100vh}
header{grid-column:1/3;display:flex;align-items:center;gap:18px;padding:0 16px;border-bottom:1px solid var(--line)}
header h1{font-size:12px;font-weight:600;letter-spacing:.12em}
.seg{display:flex;border:1px solid var(--ink);border-radius:999px;overflow:hidden}
.seg button{border:0;background:#fff;color:var(--ink);padding:6px 16px;font:inherit;cursor:pointer;letter-spacing:.04em}
.seg button.on{background:var(--ink);color:#fff}
#info{margin-left:auto;font-size:11px;color:var(--mut);font-variant-numeric:tabular-nums}
#stage{position:relative;overflow:hidden;background:#fff}
#c2,#c3{position:absolute;inset:0;width:100%;height:100%;display:block}
#c2{cursor:crosshair}
#hint{position:absolute;left:12px;bottom:10px;right:12px;font-size:11px;color:var(--mut);pointer-events:none}
#stage.live #hint{color:#666}
#msg{position:absolute;left:50%;top:12px;transform:translateX(-50%);background:#fff;border:1px solid var(--ink);border-radius:6px;padding:6px 12px;font-size:12px;display:none}
#bRe{position:absolute;left:12px;top:12px;display:none}
#stage.live #bRe{display:block}
aside{border-left:1px solid var(--line);overflow-y:auto;padding:4px 16px 24px}
aside h2{font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;margin:18px 0 8px}
.f{margin:9px 0}
.lab{display:flex;justify-content:space-between;font-size:12px;color:#444;margin-bottom:2px}
.lab b{font-weight:500;color:var(--ink);font-variant-numeric:tabular-nums}
input[type=range]{width:100%;accent-color:var(--ink)}
.row{display:flex;gap:6px;flex-wrap:wrap}
.btn{border:1px solid #dcdcdc;background:#fff;color:var(--ink);border-radius:6px;padding:5px 10px;font:inherit;font-size:12px;cursor:pointer}
.btn:hover{border-color:var(--ink)}
.btn.dark{background:var(--ink);color:#fff;border-color:var(--ink)}
.note{font-size:11px;color:var(--mut);margin-top:6px}
.inline{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#444}
textarea#uc{width:100%;height:200px;margin-top:8px;font:11px/1.45 ui-monospace,Menlo,Consolas,monospace;color:#111;background:#fafafa;border:1px solid #dcdcdc;border-radius:6px;padding:8px;resize:vertical;white-space:pre;tab-size:2}
#ucErr{font:11px/1.4 ui-monospace,Menlo,Consolas,monospace;color:#b00020;white-space:pre-wrap;margin-top:6px}
select{font:inherit;font-size:12px;border:1px solid #dcdcdc;border-radius:4px;background:#fff;padding:3px 6px}
input[type=color]{width:44px;height:24px;border:1px solid #dcdcdc;border-radius:4px;background:#fff;padding:1px;cursor:pointer}
.chk{display:flex;gap:6px;align-items:center;font-size:12px;margin:8px 0}
@media (max-width:760px){
html,body{overflow:auto}
#app{grid-template-columns:1fr;grid-template-rows:48px 62vh auto;height:auto}
header{grid-column:1}
#info{display:none}
aside{border-left:0;border-top:1px solid var(--line)}
}
</style>
</head>
<body>
<div id="app">
<header>
<h1>INFLATE // PROJECT</h1>
<div class="seg"><button id="mForm" class="on">FORM</button><button id="mLive">LIVE</button></div>
<div id="info"></div>
</header>
<section id="stage">
<canvas id="c2"></canvas>
<canvas id="c3"></canvas>
<button id="bRe" class="btn dark">Neu aufpumpen</button>
<div id="msg"></div>
<div id="hint"></div>
</section>
<aside>
<h2>Form</h2>
<div class="row">
<button class="btn" data-pre="kreis">Kreis</button>
<button class="btn" data-pre="viereck">Viereck</button>
<button class="btn" data-pre="kapsel">Kapsel</button>
<button class="btn" id="bClear">Neu zeichnen</button>
</div>
<p class="note">Ober- und Unterseite sind identisch, am Rand vernäht.</p>
<div class="f"><div class="lab"><span>Auflösung</span><b id="v_res"></b></div><input type="range" id="res" min="400" max="4000" step="100" value="1800"></div>
<p class="note">Mehr Partikel geben feinere Falten, rechnen aber langsamer. Gilt ab dem nächsten Aufpumpen.</p>
<h2>Verhalten</h2>
<div class="f"><div class="lab"><span>Druck</span><b id="v_pr"></b></div><input type="range" id="pr" min="0" max="100" step="1" value="35"></div>
<div class="f"><div class="lab"><span>Ausdehnung</span><b id="v_ex"></b></div><input type="range" id="ex" min="0" max="100" step="1" value="30"></div>
<div class="f"><div class="lab"><span>Falten</span><b id="v_fo"></b></div><input type="range" id="fo" min="0" max="100" step="1" value="65"></div>
<div class="f"><div class="lab"><span>Gravitation</span><b id="v_gr"></b></div><input type="range" id="gr" min="0" max="100" step="1" value="25"></div>
<p class="note">Wenig Druck und hohe Faltenwerte lassen den Stoff durchhängen und Falten werfen. Viel Druck zieht ihn glatt.</p>
<button class="btn dark" id="bRe2">Neu aufpumpen</button>
<h2>Material</h2>
<div class="f inline"><span>Farbe</span><input type="color" id="col" value="#d4d4d4"></div>
<div class="f"><div class="lab"><span>Glanz</span><b id="v_gl"></b></div><input type="range" id="gl" min="0" max="100" step="1" value="35"></div>
<div class="f"><div class="lab"><span>Glättung</span><b id="v_sm"></b></div><input type="range" id="sm" min="0" max="6" step="1" value="3"></div>
<p class="note">Glanz: matt bis glänzend. Glättung rundet die Darstellung, die Simulation bleibt unverändert.</p>
<h2>Plüsch</h2>
<label class="chk"><input type="checkbox" id="ckH"> Plüsch an</label>
<div class="f"><div class="lab"><span>Dichte</span><b id="v_hd"></b></div><input type="range" id="hd" min="10" max="250" step="10" value="60"></div>
<div class="f"><div class="lab"><span>Länge</span><b id="v_hl"></b></div><input type="range" id="hl" min="2" max="40" step="1" value="11"></div>
<div class="f"><div class="lab"><span>Hängen</span><b id="v_hf"></b></div><input type="range" id="hf" min="0" max="100" step="1" value="30"></div>
<div class="f"><div class="lab"><span>Wirr</span><b id="v_hw"></b></div><input type="range" id="hw" min="0" max="100" step="1" value="40"></div>
<p class="note">Reine Darstellung: Strichelchen sitzen auf der Hülle, folgen Normalen, Schwerkraft und Bewegung und tragen die Projektion. Die Simulation bleibt unberührt.</p>
<h2>Normal Map</h2>
<div class="row" style="align-items:center"><button class="btn" id="bNM">Laden …</button><input type="file" id="fNM" accept="image/*" hidden><span class="note" id="nmName" style="margin:0"></span></div>
<div class="f"><div class="lab"><span>Stärke</span><b id="v_nm"></b></div><input type="range" id="nm" min="0" max="100" step="1" value="40"></div>
<div class="f"><div class="lab"><span>Wiederholung</span><b id="v_rp"></b></div><input type="range" id="rp" min="1" max="100" step="1" value="20"></div>
<p class="note">Wiederholung zählt Kacheln pro Meter Zuschnitt. Die Map liegt im Schnittbogen, wandert also mit dem Stoff.</p>
<h2>Projektion</h2>
<div class="f inline"><span>Mitführen</span><select id="fol"><option value="2" selected>Haftet am Mesh</option><option value="1">Scheinwerfer</option><option value="0">Steht im Raum</option></select></div>
<div class="row"><button class="btn" id="bRef">Referenz setzen</button></div>
<p class="note">Haftet: Das Bild klebt an der Form, die beim Aufpumpen erreicht wurde (oder beim letzten „Referenz setzen“). Scheinwerfer: Der Projektor fährt mit dem Körper mit.</p>
<div class="f inline"><span>Muster</span><select id="pat"><option value="2" selected>Eigener Shader</option><option value="0">Triangle</option><option value="1">FBM</option></select></div>
<div id="ucBox">
<textarea id="uc" spellcheck="false">#extension GL_OES_standard_derivatives : enable
precision highp float;
uniform vec2 resolution;
uniform float time;
const float Pi = 35.14159;
uniform vec2 mouse;
const int complexity = 60; // More points of color.
const float mouse_factor = 356.0; // Makes it more/less jumpy.
const float mouse_offset = 5.0; // Drives complexity in the amount of curls/cuves. Zero is a single whirlpool.
const float fluid_speed = 5.0; // Drives speed, higher number will make it slower.
const float color_intensity = 0.5;
#define power 1.
void main()
{
vec3 finalCol = vec3(0,0,0);
vec2 p=(2.0*gl_FragCoord.xy-resolution)/max(resolution.x,resolution.y);
vec2 position = ( gl_FragCoord.xy / resolution.xy ) - mouse / 1.0;
for(int i=1;i<complexity;i++) {
vec2 newp=p + time*0.001;
newp.x+=0.5/float(i)*sin(float(i)*p.y+time/fluid_speed+0.2*float(i)) + 0.2; // + mouse.y/mouse_factor+mouse_offset;
newp.y+=0.4/float(i)*sin(float(i)*p.x+time/fluid_speed+0.3*float(i+10)) - 0.8; // - mouse.x/mouse_factor+mouse_offset;
p=newp;
}
vec3 col=vec3(color_intensity*sin(3.0*p.x)+color_intensity,color_intensity*sin(3.0*p.y)+color_intensity,color_intensity*sin(p.x+p.y)+color_intensity);
finalCol = vec3(col*col);
gl_FragColor=vec4(finalCol.rgb / col, 1) * power;
}
</textarea>
<div class="row" style="align-items:center;margin-top:6px"><button class="btn dark" id="bUC">Anwenden</button><span class="note" id="ucMsg" style="margin:0">Strg+Enter</span></div>
<div id="ucErr"></div>
<div class="f inline"><span>Shader-Auflösung</span><select id="ures"><option value="256">256</option><option value="512" selected>512</option><option value="1024">1024</option></select></div>
<p class="note">Shader im glslsandbox-Stil: time, mouse, resolution, surfacePosition, surfaceSize und gl_FragCoord werden auf den Bildraum des Projektors umgelegt. mouse folgt der Berührung, Skala zoomt.</p>
</div>
<div class="f"><div class="lab"><span>Helligkeit</span><b id="v_pi"></b></div><input type="range" id="pi" min="0" max="100" step="1" value="70"></div>
<div class="f"><div class="lab"><span>Kontrast</span><b id="v_pc"></b></div><input type="range" id="pc" min="0" max="100" step="1" value="65"></div>
<div class="f"><div class="lab"><span>Skala</span><b id="v_ps"></b></div><input type="range" id="ps" min="1" max="100" step="1" value="40"></div>
<div class="f"><div class="lab"><span>Tempo</span><b id="v_pt"></b></div><input type="range" id="pt" min="0" max="100" step="1" value="30"></div>
<div class="f"><div class="lab"><span>Farbigkeit</span><b id="v_cz"></b></div><input type="range" id="cz" min="0" max="100" step="1" value="80"></div>
<div class="f"><div class="lab"><span>Farbton</span><b id="v_ph"></b></div><input type="range" id="ph" min="0" max="100" step="1" value="0"></div>
<div class="f"><div class="lab"><span>Reaktion</span><b id="v_rx"></b></div><input type="range" id="rx" min="0" max="100" step="1" value="60"></div>
<p class="note">Reaktion: Berührung erzeugt Wellen, Bewegung der Oberfläche verschiebt Farbe und Muster.</p>
<label class="chk"><input type="checkbox" id="ckF" checked> Boden aussparen</label>
<h2>Projektor</h2>
<div class="f"><div class="lab"><span>Richtung</span><b id="v_az"></b></div><input type="range" id="az" min="-180" max="180" step="1" value="25"></div>
<div class="f"><div class="lab"><span>Höhe</span><b id="v_el"></b></div><input type="range" id="el" min="5" max="85" step="1" value="40"></div>
<div class="f"><div class="lab"><span>Öffnung</span><b id="v_fv"></b></div><input type="range" id="fv" min="10" max="70" step="1" value="32"></div>
<div class="f"><div class="lab"><span>Schatten auf Boden</span><b id="v_sk"></b></div><input type="range" id="sk" min="0" max="100" step="1" value="80"></div>
<div class="f"><div class="lab"><span>Streulicht auf Boden</span><b id="v_sp"></b></div><input type="range" id="sp" min="0" max="100" step="1" value="35"></div>
<label class="chk"><input type="checkbox" id="ckP"> Projektor zeigen</label>
<h2>Raum</h2>
<div class="f"><div class="lab"><span>Raumlicht</span><b id="v_rl"></b></div><input type="range" id="rl" min="0" max="100" step="1" value="20"></div>
<div class="f"><div class="lab"><span>Boden</span><b id="v_fl"></b></div><input type="range" id="fl" min="0" max="100" step="1" value="40"></div>
<p class="note">Der Boden nutzt dieselbe Normal Map für seine Struktur.</p>
<div class="f"><div class="lab"><span>Nebel ab</span><b id="v_fg"></b></div><input type="range" id="fg" min="3" max="30" step="0.5" value="10"></div>
</aside>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/delaunator@5.0.1/delaunator.min.js"></script>
<script>
//<CORE>
/* ===================== CORE: Kontur, Netz, Solver =====================
Einheiten: mm und s. Masse = Fläche (Dichte 1), Druck wirkt als Beschleunigung in mm/s².
Knoten: {x,y, ix,iy (Griff rein, relativ), ox,oy (Griff raus, relativ)} */
function bez(a,b,t){
const u=1-t, c0x=a.x+a.ox, c0y=a.y+a.oy, c1x=b.x+b.ix, c1y=b.y+b.iy;
return [u*u*u*a.x+3*u*u*t*c0x+3*u*t*t*c1x+t*t*t*b.x, u*u*u*a.y+3*u*u*t*c0y+3*u*t*t*c1y+t*t*t*b.y];
}
function flatten(nodes,closed,n){
n=n||32; const out=[], L=nodes.length, m=closed?L:L-1;
for(let i=0;i<m;i++){const a=nodes[i],b=nodes[(i+1)%L];for(let k=0;k<n;k++)out.push(bez(a,b,k/n));}
if(!closed&&L)out.push([nodes[L-1].x,nodes[L-1].y]);
return out;
}
function polyArea(P){let s=0;for(let i=0,n=P.length;i<n;i++){const a=P[i],b=P[(i+1)%n];s+=a[0]*b[1]-b[0]*a[1];}return s/2;}
function inPoly(x,y,P){let c=false;for(let i=0,j=P.length-1;i<P.length;j=i++){const a=P[i],b=P[j];if((a[1]>y)!==(b[1]>y)&&x<(b[0]-a[0])*(y-a[1])/(b[1]-a[1])+a[0])c=!c;}return c;}
function distSeg(px,py,a,b){const dx=b[0]-a[0],dy=b[1]-a[1];let t=((px-a[0])*dx+(py-a[1])*dy)/(dx*dx+dy*dy||1);t=Math.max(0,Math.min(1,t));return Math.hypot(px-a[0]-t*dx,py-a[1]-t*dy);}
function distPoly(x,y,P){let d=1e9;for(let i=0,n=P.length;i<n;i++){const v=distSeg(x,y,P[i],P[(i+1)%n]);if(v<d)d=v;}return d;}
function segX(a,b,c,d){
const o=(p,q,r)=>(q[0]-p[0])*(r[1]-p[1])-(q[1]-p[1])*(r[0]-p[0]);
return (o(a,b,c)>0)!==(o(a,b,d)>0)&&(o(c,d,a)>0)!==(o(c,d,b)>0);
}
function selfIntersects(P){
const n=P.length;
for(let i=0;i<n;i++)for(let j=i+2;j<n;j++){if(i===0&&j===n-1)continue;if(segX(P[i],P[(i+1)%n],P[j],P[(j+1)%n]))return true;}
return false;
}
function resample(P,s){
const n=P.length,L=[0];
for(let i=0;i<n;i++){const a=P[i],b=P[(i+1)%n];L.push(L[i]+Math.hypot(b[0]-a[0],b[1]-a[1]));}
const tot=L[n],m=Math.max(12,Math.round(tot/s)),out=[];let j=0;
for(let k=0;k<m;k++){const d=k*tot/m;while(L[j+1]<d)j++;const t=(d-L[j])/((L[j+1]-L[j])||1),a=P[j],b=P[(j+1)%n];out.push([a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t]);}
return out;
}
// Baut zwei identische Lagen, die sich die Randpartikel teilen (= Naht)
function buildCloth(nodes,target){
if(nodes.length<3)return {err:'Die Form braucht mindestens drei Punkte.'};
let P=flatten(nodes,true,40);
const A=polyArea(P);
if(Math.abs(A)<400)return {err:'Die Form ist zu klein.'};
if(selfIntersects(resample(P,Math.sqrt(Math.abs(A))/60)))return {err:'Die Kontur überschneidet sich selbst.'};
// Schwerpunkt in den Ursprung
let cx=0,cy=0;for(let i=0,n=P.length;i<n;i++){const a=P[i],b=P[(i+1)%n],c=a[0]*b[1]-b[0]*a[1];cx+=(a[0]+b[0])*c;cy+=(a[1]+b[1])*c;}
cx/=6*A;cy/=6*A;P=P.map(p=>[p[0]-cx,p[1]-cy]);
const area=Math.abs(A), s=Math.sqrt(area/(target*0.866));
const B=resample(P,s), I=[];
let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;for(const p of B){x0=Math.min(x0,p[0]);y0=Math.min(y0,p[1]);x1=Math.max(x1,p[0]);y1=Math.max(y1,p[1]);}
const dy=s*0.866;let row=0;
for(let y=y0+dy*0.5;y<y1;y+=dy,row++)for(let x=x0+(row%2?s*0.5:0);x<x1;x+=s)
if(inPoly(x,y,B)&&distPoly(x,y,B)>0.6*s)I.push([x,y]);
const nb=B.length,ni=I.length,all=B.concat(I),co=new Float64Array(all.length*2);
all.forEach((p,i)=>{co[2*i]=p[0];co[2*i+1]=p[1];});
const del=new Delaunator(co),T=del.triangles,tris=[];
for(let t=0;t<T.length;t+=3){
const a=T[t],b=T[t+1],c=T[t+2],pa=all[a],pb=all[b],pc=all[c];
if(!inPoly((pa[0]+pb[0]+pc[0])/3,(pa[1]+pb[1]+pc[1])/3,B))continue;
const m=Math.max(Math.hypot(pa[0]-pb[0],pa[1]-pb[1]),Math.hypot(pb[0]-pc[0],pb[1]-pc[1]),Math.hypot(pc[0]-pa[0],pc[1]-pa[1]));
if(m>2.4*s)continue;
const ar=(pb[0]-pa[0])*(pc[1]-pa[1])-(pb[1]-pa[1])*(pc[0]-pa[0]);
tris.push(ar>0?[a,c,b]:[a,b,c]); // Normale der Oberseite zeigt nach +y
}
const N=nb+2*ni, bot=k=>k<nb?k:k+ni;
const pos=new Float32Array(N*3), Y0=6, LIFT=1.2;
for(let k=0;k<nb+ni;k++){
const p=all[k];
pos[3*k]=p[0];pos[3*k+1]=Y0+(k<nb?0:LIFT);pos[3*k+2]=p[1];
if(k>=nb){const j=bot(k);pos[3*j]=p[0];pos[3*j+1]=Y0-LIFT;pos[3*j+2]=p[1];}
}
const uv=new Float32Array(N*2);
for(let k=0;k<nb+ni;k++){uv[2*k]=all[k][0];uv[2*k+1]=all[k][1];if(k>=nb){const j=bot(k);uv[2*j]=all[k][0];uv[2*j+1]=all[k][1];}}
const idx=new Uint32Array(tris.length*6);
tris.forEach((t,i)=>{idx.set(t,6*i);idx.set([bot(t[0]),bot(t[2]),bot(t[1])],6*i+3);});
// Kanten (Dehnung) und Gegenpunkte benachbarter Dreiecke (Biegung)
const flat=k=>all[k<nb+ni?k:k-ni];
const em=new Map(),eA=[],eB=[],bA=[],bB=[];
const key=(i,j)=>i<j?i*N+j:j*N+i;
for(const layer of [0,1]){
const opp=new Map();
for(const t0 of tris){
const t=layer?[bot(t0[0]),bot(t0[1]),bot(t0[2])]:t0;
for(let e=0;e<3;e++){
const i=t[e],j=t[(e+1)%3],o=t[(e+2)%3],k=key(i,j);
if(!em.has(k)){em.set(k,1);eA.push(i);eB.push(j);}
if(opp.has(k)){bA.push(opp.get(k));bB.push(o);}else opp.set(k,o);
}
}
}
const rl=(A_,B_)=>{const r=new Float32Array(A_.length);for(let e=0;e<A_.length;e++){const p=flat(A_[e]),q=flat(B_[e]);r[e]=Math.hypot(p[0]-q[0],p[1]-q[1]);}return r;};
return {N,nb,ni,s,area,pos0:pos,index:idx,uv,
eA:Int32Array.from(eA),eB:Int32Array.from(eB),eL:rl(eA,eB),
bA:Int32Array.from(bA),bB:Int32Array.from(bB),bL:rl(bA,bB),
outline:B};
}
function createSim(c){
const N=c.N, x=new Float32Array(c.pos0), p=new Float32Array(N*3), v=new Float32Array(N*3), F=new Float32Array(N*3);
const act=new Float32Array(N), w0=1/(0.866*c.s*c.s), w=new Float32Array(N).fill(w0);
const {eA,eB,eL,bA,bB,bL,index}=c, nT=index.length/3;
const S={x,v,act,time:0,volume:0,height:0,grab:null};
function reset(){x.set(c.pos0);v.fill(0);act.fill(0);w.fill(w0);S.time=0;S.grab=null;}
function solve(A,B,L,alpha){
for(let e=0,n=A.length;e<n;e++){
const i=3*A[e],j=3*B[e],wi=w[A[e]],wj=w[B[e]],ws=wi+wj;if(ws===0)continue;
const dx=x[j]-x[i],dy=x[j+1]-x[i+1],dz=x[j+2]-x[i+2],d=Math.sqrt(dx*dx+dy*dy+dz*dz);if(d<1e-9)continue;
const l=-(d-L[e])/(ws+alpha)/d;
x[i]-=wi*l*dx;x[i+1]-=wi*l*dy;x[i+2]-=wi*l*dz;
x[j]+=wj*l*dx;x[j+1]+=wj*l*dy;x[j+2]+=wj*l*dz;
}
}
// prm: P (Druck), g (Gravitation), aS (Dehn-Nachgiebigkeit), aB (Biege-Nachgiebigkeit), sub, damp
S.step=function(dt,prm){
const sub=prm.sub||8,h=dt/sub,h2=h*h,ramp=Math.min(1,S.time/1.2),P=prm.P*ramp;
// Druck: Kraft = P · Fläche · Normale, gleichmäßig auf die drei Ecken
F.fill(0);let vol=0;
for(let t=0;t<nT;t++){
const a=3*index[3*t],b=3*index[3*t+1],d=3*index[3*t+2];
const ux=x[b]-x[a],uy=x[b+1]-x[a+1],uz=x[b+2]-x[a+2],vx=x[d]-x[a],vy=x[d+1]-x[a+1],vz=x[d+2]-x[a+2];
const nx=uy*vz-uz*vy,ny=uz*vx-ux*vz,nz=ux*vy-uy*vx,k=P/6;
F[a]+=k*nx;F[a+1]+=k*ny;F[a+2]+=k*nz;F[b]+=k*nx;F[b+1]+=k*ny;F[b+2]+=k*nz;F[d]+=k*nx;F[d+1]+=k*ny;F[d+2]+=k*nz;
vol+=(x[a]*(x[b+1]*x[d+2]-x[b+2]*x[d+1])-x[a+1]*(x[b]*x[d+2]-x[b+2]*x[d])+x[a+2]*(x[b]*x[d+1]-x[b+1]*x[d]))/6;
}
S.volume=vol;
const g=S.grab, aS=prm.aS/h2, aB=prm.aB/h2, mu=0.55;
for(let s=0;s<sub;s++){
for(let i=0;i<N;i++){
const k=3*i,wi=w[i];
if(wi>0){v[k]+=h*F[k]*wi;v[k+1]+=h*(F[k+1]*wi-prm.g);v[k+2]+=h*F[k+2]*wi;}
p[k]=x[k];p[k+1]=x[k+1];p[k+2]=x[k+2];
x[k]+=h*v[k];x[k+1]+=h*v[k+1];x[k+2]+=h*v[k+2];
}
solve(eA,eB,eL,aS);
solve(bA,bB,bL,aB);
if(g)for(let m=0;m<g.idx.length;m++){
const k=3*g.idx[m],q=g.wt[m];
x[k]+=(g.tx+g.off[3*m]-x[k])*q;x[k+1]+=(g.ty+g.off[3*m+1]-x[k+1])*q;x[k+2]+=(g.tz+g.off[3*m+2]-x[k+2])*q;
}
for(let i=0;i<N;i++){
const k=3*i;
if(x[k+1]<0){x[k+1]=0;x[k]-=(x[k]-p[k])*mu;x[k+2]-=(x[k+2]-p[k+2])*mu;}
v[k]=(x[k]-p[k])/h;v[k+1]=(x[k+1]-p[k+1])/h;v[k+2]=(x[k+2]-p[k+2])/h;
}
}
const damp=Math.exp(-(prm.damp||1.2)*dt);let hy=0,bad=false;
for(let i=0;i<N;i++){
const k=3*i;v[k]*=damp;v[k+1]*=damp;v[k+2]*=damp;
const sp=Math.sqrt(v[k]*v[k]+v[k+1]*v[k+1]+v[k+2]*v[k+2]);
act[i]=Math.max(act[i]*0.94,Math.min(1,sp/220));
if(x[k+1]>hy)hy=x[k+1];
if(!(Math.abs(x[k])<1e5&&Math.abs(x[k+1])<1e5))bad=true;
}
if(g)for(let m=0;m<g.idx.length;m++)act[g.idx[m]]=Math.max(act[g.idx[m]],g.wt[m]);
S.height=hy;S.time+=dt;
if(bad)reset();
};
S.startGrab=function(px,py,pz,R){
const idx=[],off=[],wt=[];
for(let i=0;i<N;i++){
const k=3*i,dx=x[k]-px,dy=x[k+1]-py,dz=x[k+2]-pz,d=Math.sqrt(dx*dx+dy*dy+dz*dz);
if(d<R){idx.push(i);off.push(dx,dy,dz);const q=1-d/R;wt.push(Math.min(1,q*q*1.4));}
}
if(!idx.length)return false;
S.grab={idx:Int32Array.from(idx),off:Float32Array.from(off),wt:Float32Array.from(wt),tx:px,ty:py,tz:pz};
for(let m=0;m<idx.length;m++)if(wt[m]>0.95)w[idx[m]]=0;
return true;
};
S.moveGrab=function(tx,ty,tz){if(S.grab){S.grab.tx=tx;S.grab.ty=Math.max(0,ty);S.grab.tz=tz;}};
S.endGrab=function(){if(S.grab){for(const i of S.grab.idx)w[i]=w0;S.grab=null;}};
S.reset=reset;
return S;
}
//</CORE>
/* ===================== Parameter ===================== */
const $=id=>document.getElementById(id);
const lerpLog=(a,b,t)=>Math.exp(Math.log(a)+(Math.log(b)-Math.log(a))*t);
const prm={};
function readParams(){
prm.P=$('pr').value/100*1800; // mm/s² pro Flächeneinheit
prm.aS=lerpLog(4e-8,4e-6,$('ex').value/100); // Dehnung
prm.aB=lerpLog(1e-7,1e-2,$('fo').value/100); // Biegung: weicher = mehr Falten
prm.g=$('gr').value/100*400;
prm.sub=8;prm.damp=1.2;
$('v_res').textContent=$('res').value+' / Lage';
$('v_pr').textContent=$('pr').value+' %';
$('v_ex').textContent=$('ex').value+' %';
$('v_fo').textContent=$('fo').value+' %';
$('v_gr').textContent=$('gr').value+' %';
$('v_pi').textContent=$('pi').value+' %';
$('v_ps').textContent=$('ps').value;
$('v_pt').textContent=$('pt').value+' %';
$('v_ph').textContent=Math.round($('ph').value*3.6)+'°';
$('v_rx').textContent=$('rx').value+' %';
$('v_az').textContent=$('az').value+'°';
$('v_el').textContent=$('el').value+'°';
$('v_fv').textContent=$('fv').value+'°';
$('v_rl').textContent=$('rl').value+' %';
$('v_gl').textContent=$('gl').value+' %';
$('v_pc').textContent=$('pc').value+' %';
$('v_fl').textContent=$('fl').value+' %';
$('v_fg').textContent=$('fg').value+' m';
$('v_cz').textContent=$('cz').value+' %';
$('v_hd').textContent=($('hd').value*1)+' k';
$('v_hl').textContent=$('hl').value+' mm';
$('v_hf').textContent=$('hf').value+' %';
$('v_hw').textContent=$('hw').value+' %';
$('v_sk').textContent=$('sk').value+' %';
$('v_sp').textContent=$('sp').value+' %';
$('v_sm').textContent=$('sm').value;
$('v_nm').textContent=$('nm').value+' %';
$('v_rp').textContent=$('rp').value+' / m';
}
/* ===================== 2D-Editor (Pen-Tool) ===================== */
const c2=$('c2'),g2=c2.getContext('2d'),stage=$('stage');
const K=0.5523;
const nd=(x,y,ix=0,iy=0,ox=0,oy=0)=>({x,y,ix,iy,ox,oy});
const PRESETS={
kreis:()=>{const r=170,k=K*r;return [nd(0,-r,-k,0,k,0),nd(r,0,0,-k,0,k),nd(0,r,k,0,-k,0),nd(-r,0,0,k,0,-k)];},
viereck:()=>[nd(-160,-160),nd(160,-160),nd(160,160),nd(-160,160)],
kapsel:()=>{const r=90,a=130,k=K*r;return [nd(-a,-r,-k,0,0,0),nd(a,-r,0,0,k,0),nd(a+r,0,0,-k,0,k),nd(a,r,k,0,0,0),nd(-a,r,0,0,-k,0),nd(-a-r,0,0,k,0,-k)];}
};
const ed={nodes:PRESETS.kreis(),closed:true,sel:-1,drag:null,mouse:null,view:{cx:0,cy:0,sc:1},space:false,dirty:true};
let W2=0,H2=0,DPR=1;
function toS(x,y){const v=ed.view;return [W2/2+(x-v.cx)*v.sc,H2/2+(y-v.cy)*v.sc];}
function toW(sx,sy){const v=ed.view;return [(sx-W2/2)/v.sc+v.cx,(sy-H2/2)/v.sc+v.cy];}
function fitView(){
let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
const P=ed.nodes.length>1?flatten(ed.nodes,ed.closed,16):[[-200,-200],[200,200]];
for(const p of P){x0=Math.min(x0,p[0]);y0=Math.min(y0,p[1]);x1=Math.max(x1,p[0]);y1=Math.max(y1,p[1]);}
const w=Math.max(200,x1-x0),h=Math.max(200,y1-y0);
ed.view={cx:(x0+x1)/2,cy:(y0+y1)/2,sc:Math.min(W2/(w*1.6),H2/(h*1.6))};
}
function resize2(){
const r=stage.getBoundingClientRect();DPR=window.devicePixelRatio||1;W2=r.width;H2=r.height;
c2.width=W2*DPR;c2.height=H2*DPR;g2.setTransform(DPR,0,0,DPR,0,0);
}
function evPos(e){const r=c2.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top];}
function hitNode(sx,sy){for(let i=ed.nodes.length-1;i>=0;i--){const n=ed.nodes[i],[px,py]=toS(n.x,n.y);if(Math.hypot(px-sx,py-sy)<8)return i;}return -1;}
function hitHandle(sx,sy){
if(ed.sel<0)return null;const n=ed.nodes[ed.sel];
for(const h of ['o','i']){const hx=n[h+'x'],hy=n[h+'y'];if(!hx&&!hy)continue;const [px,py]=toS(n.x+hx,n.y+hy);if(Math.hypot(px-sx,py-sy)<7)return h;}
return null;
}
function hitCurve(sx,sy){
if(!ed.closed)return null;let best=null;const L=ed.nodes.length;
for(let i=0;i<L;i++)for(let k=0;k<=40;k++){const t=k/40,q=bez(ed.nodes[i],ed.nodes[(i+1)%L],t),[px,py]=toS(q[0],q[1]),d=Math.hypot(px-sx,py-sy);if(d<7&&(!best||d<best.d))best={i,t,d};}
return best;
}
function splitAt(i,t){
const L=ed.nodes.length,a=ed.nodes[i],b=ed.nodes[(i+1)%L];
const P0=[a.x,a.y],C0=[a.x+a.ox,a.y+a.oy],C1=[b.x+b.ix,b.y+b.iy],P1=[b.x,b.y];
const lp=(p,q)=>[p[0]+(q[0]-p[0])*t,p[1]+(q[1]-p[1])*t];
const A=lp(P0,C0),Bq=lp(C0,C1),C=lp(C1,P1),D=lp(A,Bq),E=lp(Bq,C),M=lp(D,E);
a.ox=A[0]-a.x;a.oy=A[1]-a.y;b.ix=C[0]-b.x;b.iy=C[1]-b.y;
ed.nodes.splice(i+1,0,nd(M[0],M[1],D[0]-M[0],D[1]-M[1],E[0]-M[0],E[1]-M[1]));
ed.sel=i+1;
}
function toggleNode(i){
const n=ed.nodes[i],L=ed.nodes.length;
if(n.ix||n.iy||n.ox||n.oy){n.ix=n.iy=n.ox=n.oy=0;return;}
const a=ed.nodes[(i-1+L)%L],b=ed.nodes[(i+1)%L];
let dx=b.x-a.x,dy=b.y-a.y;const d=Math.hypot(dx,dy)||1;dx/=d;dy/=d;
const la=Math.hypot(n.x-a.x,n.y-a.y)/3,lb=Math.hypot(b.x-n.x,b.y-n.y)/3;
n.ix=-dx*la;n.iy=-dy*la;n.ox=dx*lb;n.oy=dy*lb;
}
function changed(){ed.dirty=true;shapeDirty=true;}
c2.addEventListener('pointerdown',e=>{
const [sx,sy]=evPos(e);c2.setPointerCapture(e.pointerId);
if(e.button===1||e.button===2||ed.space){ed.drag={type:'pan',sx,sy,cx:ed.view.cx,cy:ed.view.cy};return;}
const hh=hitHandle(sx,sy);
if(hh){ed.drag={type:'handle',h:hh};return;}
const hn=hitNode(sx,sy);
if(!ed.closed&&hn===0&&ed.nodes.length>=3){ed.closed=true;ed.sel=0;changed();ed.drag={type:'node',i:0};return;}
if(hn>=0){ed.sel=hn;ed.drag={type:'node',i:hn};ed.dirty=true;return;}
if(!ed.closed){
const [x,y]=toW(sx,sy),last=ed.nodes[ed.nodes.length-1];
if(last){const [lx,ly]=toS(last.x,last.y);if(Math.hypot(lx-sx,ly-sy)<5)return;}
ed.nodes.push(nd(x,y));ed.sel=ed.nodes.length-1;ed.drag={type:'pull',i:ed.sel};changed();return;
}
ed.sel=-1;ed.dirty=true;
});
c2.addEventListener('pointermove',e=>{
const [sx,sy]=evPos(e);ed.mouse=[sx,sy];ed.dirty=true;
const d=ed.drag;if(!d)return;
const [x,y]=toW(sx,sy);
if(d.type==='pan'){ed.view.cx=d.cx-(sx-d.sx)/ed.view.sc;ed.view.cy=d.cy-(sy-d.sy)/ed.view.sc;return;}
if(d.type==='node'){const n=ed.nodes[d.i];n.x=x;n.y=y;changed();return;}
if(d.type==='pull'){const n=ed.nodes[d.i];n.ox=x-n.x;n.oy=y-n.y;if(!e.altKey){n.ix=-n.ox;n.iy=-n.oy;}changed();return;}
if(d.type==='handle'){
const n=ed.nodes[ed.sel],h=d.h,o=h==='o'?'i':'o';
n[h+'x']=x-n.x;n[h+'y']=y-n.y;
if(!e.altKey){const lo=Math.hypot(n[o+'x'],n[o+'y']);if(lo>0){const lh=Math.hypot(n[h+'x'],n[h+'y'])||1;n[o+'x']=-n[h+'x']/lh*lo;n[o+'y']=-n[h+'y']/lh*lo;}}
changed();
}
});
c2.addEventListener('pointerup',()=>{ed.drag=null;});
c2.addEventListener('contextmenu',e=>e.preventDefault());
c2.addEventListener('dblclick',e=>{
if(!ed.closed)return;const [sx,sy]=evPos(e);
const hn=hitNode(sx,sy);if(hn>=0){toggleNode(hn);changed();return;}
const hc=hitCurve(sx,sy);if(hc){splitAt(hc.i,hc.t);changed();}
});
c2.addEventListener('wheel',e=>{
e.preventDefault();const [sx,sy]=evPos(e),[x,y]=toW(sx,sy),f=Math.exp(-e.deltaY*0.0015);
ed.view.sc*=f;const [x2,y2]=toW(sx,sy);ed.view.cx+=x-x2;ed.view.cy+=y-y2;ed.dirty=true;
},{passive:false});
window.addEventListener('keydown',e=>{
if(mode!=='form'||/INPUT|TEXTAREA|SELECT/.test(e.target.tagName))return;
if(e.code==='Space'){ed.space=true;c2.style.cursor='grab';e.preventDefault();}
if((e.key==='Delete'||e.key==='Backspace')&&ed.sel>=0){
if(ed.closed&&ed.nodes.length<=3){flash('Eine geschlossene Form braucht mindestens drei Punkte.');return;}
ed.nodes.splice(ed.sel,1);ed.sel=-1;changed();e.preventDefault();
}
if(e.key==='Enter'&&!ed.closed&&ed.nodes.length>=3){ed.closed=true;changed();}
});
window.addEventListener('keyup',e=>{if(e.code==='Space'){ed.space=false;c2.style.cursor='crosshair';}});
function draw2(){
if(!ed.dirty)return;ed.dirty=false;
const g=g2;g.clearRect(0,0,W2,H2);g.fillStyle='#fff';g.fillRect(0,0,W2,H2);
// Raster 10 / 50 mm
const step=ed.view.sc*10<6?50:10;
const [wx0,wy0]=toW(0,0),[wx1,wy1]=toW(W2,H2);
g.lineWidth=1;
for(let x=Math.floor(wx0/step)*step;x<=wx1;x+=step){const sx=Math.round(toS(x,0)[0])+.5;g.strokeStyle=x%50===0?'#ececec':'#f6f6f6';g.beginPath();g.moveTo(sx,0);g.lineTo(sx,H2);g.stroke();}
for(let y=Math.floor(wy0/step)*step;y<=wy1;y+=step){const sy=Math.round(toS(0,y)[1])+.5;g.strokeStyle=y%50===0?'#ececec':'#f6f6f6';g.beginPath();g.moveTo(0,sy);g.lineTo(W2,sy);g.stroke();}
const N=ed.nodes;
if(N.length){
const P=flatten(N,ed.closed,32);
g.beginPath();P.forEach((p,i)=>{const [sx,sy]=toS(p[0],p[1]);i?g.lineTo(sx,sy):g.moveTo(sx,sy);});
if(ed.closed){g.closePath();g.fillStyle='#f3f3f3';g.fill();}
g.strokeStyle='#111';g.lineWidth=1.5;g.stroke();
// Gummiband beim Zeichnen
if(!ed.closed&&ed.mouse&&!ed.drag){
const last=N[N.length-1],[mx,my]=toW(ed.mouse[0],ed.mouse[1]),tmp=nd(mx,my);
g.beginPath();for(let k=0;k<=24;k++){const q=bez(last,tmp,k/24),[sx,sy]=toS(q[0],q[1]);k?g.lineTo(sx,sy):g.moveTo(sx,sy);}
g.strokeStyle='#bbb';g.lineWidth=1;g.stroke();
}
// Griffe des gewählten Knotens
if(ed.sel>=0&&N[ed.sel]){
const n=N[ed.sel],[nx,ny]=toS(n.x,n.y);
for(const h of ['i','o']){const hx=n[h+'x'],hy=n[h+'y'];if(!hx&&!hy)continue;const [px,py]=toS(n.x+hx,n.y+hy);
g.strokeStyle='#999';g.lineWidth=1;g.beginPath();g.moveTo(nx,ny);g.lineTo(px,py);g.stroke();
g.fillStyle='#fff';g.strokeStyle='#111';g.beginPath();g.arc(px,py,4,0,7);g.fill();g.stroke();}
}
N.forEach((n,i)=>{const [sx,sy]=toS(n.x,n.y);g.fillStyle=i===ed.sel?'#111':'#fff';g.strokeStyle='#111';g.lineWidth=1.2;
const r=(!ed.closed&&i===0&&N.length>=3)?5.5:4;g.fillRect(sx-r,sy-r,2*r,2*r);g.strokeRect(sx-r+.5,sy-r+.5,2*r-1,2*r-1);});
// Maße
if(ed.closed&&N.length>=3){
let x0=1e9,y0=1e9,x1=-1e9,y1=-1e9,L=0;
for(let i=0;i<P.length;i++){const p=P[i],q=P[(i+1)%P.length];x0=Math.min(x0,p[0]);y0=Math.min(y0,p[1]);x1=Math.max(x1,p[0]);y1=Math.max(y1,p[1]);L+=Math.hypot(q[0]-p[0],q[1]-p[1]);}
$('info').textContent=`${Math.round(x1-x0)} × ${Math.round(y1-y0)} mm · Umfang ${Math.round(L)} mm`;
} else $('info').textContent='Kontur offen';
}
}
/* ===================== 3D ===================== */
const c3=$('c3');
const renderer=new THREE.WebGLRenderer({canvas:c3,antialias:true});
renderer.setClearColor(0x000000,1);
const scene=new THREE.Scene();
const cam=new THREE.PerspectiveCamera(38,1,10,90000);
cam.position.set(0,520,980);
// Projektor-Kamera und Tiefenkarte für Verdeckung
const pcam=new THREE.PerspectiveCamera(32,1,50,6000);
const SH=2048, shadowRT=new THREE.WebGLRenderTarget(SH,SH,{minFilter:THREE.NearestFilter,magFilter:THREE.NearestFilter});
const pHelper=new THREE.CameraHelper(pcam);pHelper.visible=false;scene.add(pHelper);
const pBody=new THREE.Mesh(new THREE.BoxGeometry(60,40,90),new THREE.MeshBasicMaterial({color:0x444444,wireframe:true}));pBody.visible=false;scene.add(pBody);
const pTarget=new THREE.Vector3(0,60,0);
const blackTex=new THREE.DataTexture(new Uint8Array([0,0,0,255]),1,1,THREE.RGBAFormat);blackTex.needsUpdate=true;
const nmFlat=new THREE.DataTexture(new Uint8Array([128,128,255,255]),1,1,THREE.RGBAFormat);nmFlat.needsUpdate=true;
const U={
uShadow:{value:shadowRT.texture},uProjVP:{value:new THREE.Matrix4()},uProjPos:{value:new THREE.Vector3()},
uNear:{value:50},uFar:{value:6000},uBias:{value:3},
uTime:{value:0},uScale:{value:3},uInt:{value:1},uHue:{value:0},uReact:{value:.6},
uAmb:{value:.015},uLight:{value:.12},uLightDir:{value:new THREE.Vector3(.45,1,.3).normalize()},
uTouch:{value:new THREE.Vector3(0,-1e4,0)},uTouchAge:{value:99},uTouchHold:{value:0},
uAlbedo:{value:new THREE.Color(.66,.66,.66)},uGloss:{value:.35},
uNM:{value:nmFlat},uHasNM:{value:0},uNMStr:{value:.4},uRep:{value:.02},uFloorProj:{value:0},
uContentVP:{value:new THREE.Matrix4()},uStick:{value:0},uPattern:{value:2},uContrast:{value:2},
uRes:{value:new THREE.Vector2(1024,1024)},uMouse:{value:new THREE.Vector2(.5,.5)},backbuffer:{value:blackTex},
uFogStart:{value:10000},uFogLen:{value:9000},uUserTex:{value:blackTex},
uColorize:{value:.8},uShadowK:{value:.8},uSpill:{value:.5}
};
const VS=`
#ifdef USE_ACT
attribute float aAct;attribute vec3 aRef;
#endif
#ifdef HAIR
attribute float aTip;varying float vTip;
#endif
varying vec3 vW;varying vec3 vN;varying vec4 vP;varying vec4 vC;varying float vAct;varying vec2 vUv;
uniform mat4 uProjVP,uContentVP;uniform float uRep,uStick;
void main(){
vec4 w=modelMatrix*vec4(position,1.0);
vW=w.xyz;vN=normalize(mat3(modelMatrix)*normal);vP=uProjVP*w;
#ifdef USE_ACT
vAct=aAct;vUv=uv*uRep;
#ifdef HAIR
vTip=aTip;
#endif
vC=uContentVP*vec4(mix(w.xyz,aRef,uStick),1.0); // Inhalt aus Referenzform = haftet am Stoff
#else
vAct=0.0;vUv=w.xz*uRep;vC=uContentVP*w;
#endif
gl_Position=projectionMatrix*viewMatrix*w;
}`;
const UC_GLOBALS=`uniform vec2 uRes,uMouse;uniform sampler2D backbuffer;
float time;vec2 mouse;vec2 resolution;vec2 surfaceSize;vec2 surfacePosition;vec4 uc_fc;vec4 uc_out;
void uc_main();`;
const UC_STUB='void uc_main(){uc_out=vec4(0.0);}';
const FS=`
#include <packing>
uniform sampler2D uShadow;uniform vec3 uProjPos;uniform float uNear,uFar,uBias;
uniform float uTime,uScale,uInt,uHue,uReact,uAmb,uLight;uniform vec3 uLightDir;
uniform vec3 uTouch;uniform float uTouchAge,uTouchHold;uniform vec3 uAlbedo;uniform float uGloss;
uniform sampler2D uNM;uniform float uHasNM,uNMStr,uFloorProj;
varying vec3 vW;varying vec3 vN;varying vec4 vP;varying vec4 vC;varying float vAct;varying vec2 vUv;
#ifdef HAIR
varying float vTip;
#endif
// Hash ohne sin(): stabil auch bei großen Koordinaten und mittlerer Genauigkeit
vec2 hash22(vec2 p){vec3 p3=fract(vec3(p.xyx)*vec3(0.1031,0.1030,0.0973));p3+=dot(p3,p3.yzx+33.33);return fract((p3.xx+p3.yz)*p3.zy)*2.0-1.0;}
// Gradient-Noise mit quintischer Interpolation, keine Rasterstufen
float gnoise(vec2 p){vec2 i=floor(p),f=fract(p);vec2 u=f*f*f*(f*(f*6.0-15.0)+10.0);
return mix(mix(dot(hash22(i),f),dot(hash22(i+vec2(1,0)),f-vec2(1,0)),u.x),
mix(dot(hash22(i+vec2(0,1)),f-vec2(0,1)),dot(hash22(i+vec2(1,1)),f-vec2(1,1)),u.x),u.y)*0.5+0.5;}
float fbm(vec2 p){float a=0.5,s=0.0;mat2 m=mat2(1.6,1.2,-1.2,1.6);for(int i=0;i<5;i++){s+=a*gnoise(p);p=m*p+vec2(3.1,1.7);a*=0.5;}return s;}
// Triangle-Noise: verschachtelte Dreieckswellen, ergibt scharfe, helle Filamente
float tri(float x){return abs(fract(x)-0.5);}
vec2 tri2(vec2 p){return vec2(tri(p.x+tri(p.y*2.0)),tri(p.y+tri(p.x*2.0)));}
mat2 rot(float a){float c=cos(a),s=sin(a);return mat2(c,s,-s,c);}
float triNoise(vec2 p,float t){
float z=1.8,z2=2.5,rz=0.0;vec2 bp=p;mat2 m2=mat2(0.95534,0.29552,-0.29552,0.95534);
for(int i=0;i<5;i++){
vec2 dg=tri2(bp*1.85)*0.75;dg=rot(t*0.6)*dg;
p-=dg/z2;bp*=1.3;z2*=0.45;z*=0.42;
p*=1.21+(rz-1.0)*0.02;
rz+=tri(p.x+tri(p.y))*z;
p=-(m2*p);
}
return clamp(1.0-(rz-0.08)/0.45,0.0,1.0); // Täler der Dreieckswellen werden zu hellen Graten
}
vec3 pal(float t){return 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67)+t));}
uniform float uPattern,uContrast;uniform float uFogStart,uFogLen,uColorize,uShadowK,uSpill;
uniform sampler2D uUserTex; // vorab gerendertes Projektorbild des eigenen Shaders
// Projektorinhalt im Bildraum des Projektors. Bewegung (act) und Berührung (touch) stören das Muster.
vec3 content(vec2 uv,float act,float touch){
vec2 p=uv*uScale;float t=uTime;
vec2 w=vec2(1.0,-0.7)*uReact*(act*0.8+touch*0.6);
if(uPattern>1.5){
vec3 c=texture2D(uUserTex,uv+w*0.04).rgb;
// Farbigkeit: dunkle Bereiche werden zu einem farbigen Verlauf, helle zu dessen Komplementärfarbe. Kein reines Schwarz.
float lum=dot(c,vec3(0.299,0.587,0.114));
lum=clamp((lum-0.5)*mix(1.0,uContrast,0.35)+0.5,0.0,1.0);
float hs=gnoise(p*0.08+vec2(t*0.04,-t*0.03))*0.9+uv.x*0.25-uv.y*0.15+uHue+uReact*(act*0.2+touch*0.15);
vec3 lo=pal(hs)*0.42;
vec3 hi=pal(hs+0.5)*1.25;
vec3 tint=mix(lo,hi,smoothstep(0.05,0.95,lum))+pal(hs+0.25)*pow(lum,6.0)*0.5;
float sat=max(c.r,max(c.g,c.b))-min(c.r,min(c.g,c.b));
c=mix(c,tint,uColorize*mix(1.0,0.2,smoothstep(0.12,0.45,sat))); // bunte Shader bleiben weitgehend, wie sie sind
c=max(c,lo*uColorize); // nie reines Schwarz
return c*1.3+uReact*touch*0.35;
}
float f,h;
if(uPattern<0.5){
f=triNoise(p*0.35+w,t);
h=gnoise(p*0.15+vec2(t*0.05,0.0));
}else{
vec2 q=vec2(fbm(p+vec2(0.0,t*0.11)),fbm(p+vec2(5.2,1.3)-vec2(t*0.08,0.0)));
f=fbm(p+2.2*q+w*2.0);h=q.x;
}
f=clamp((f-0.5)*uContrast+0.5,0.0,1.0);
f=f*f*(3.0-2.0*f);
vec3 c=pal(h*0.8+uHue+uReact*(act*0.2+touch*0.15));
return mix(pal(h*0.8+uHue+0.5)*0.35,c*1.6,f)+uReact*touch*0.35; // Täler farbig statt schwarz
}
// Verdeckung aus Projektorsicht, Bias wächst bei flachem Einfall gegen Treppen
float shadowAt(vec2 uv,float dist,float ndl){
float lit=0.0;vec2 tx=vec2(1.5/${SH}.0);float bias=uBias+14.0*(1.0-ndl);
for(int i=-1;i<=1;i++)for(int j=-1;j<=1;j++){
float d=unpackRGBAToDepth(texture2D(uShadow,uv+vec2(float(i),float(j))*tx))*(uFar-uNear)+uNear;
lit+=(dist-bias<=d)?1.0:0.0;
}
return smoothstep(0.0,1.0,lit/9.0);
}
// Normal Map ohne Tangenten, über Bildschirmableitungen
vec3 perturb(vec3 p,vec3 N,vec3 m,float fd){
vec3 q0=dFdx(p),q1=dFdy(p);vec2 s0=dFdx(vUv),s1=dFdy(vUv);
vec3 q1p=cross(q1,N),q0p=cross(N,q0);
vec3 T=q1p*s0.x+q0p*s1.x,B=q1p*s0.y+q0p*s1.y;
float det=max(dot(T,T),dot(B,B));float sc=(det==0.0)?0.0:fd*inversesqrt(det);
return normalize(T*(m.x*sc)+B*(m.y*sc)+N*m.z);
}
float spec(vec3 N,vec3 L,vec3 V,float fres){
float sh=mix(6.0,600.0,uGloss*uGloss);
float ndh=max(dot(N,normalize(L+V)),0.0);
return uGloss*(sh+8.0)/25.13*pow(ndh,sh)*(0.15+0.85*fres)*0.35;
}
void main(){
float fd=gl_FrontFacing?1.0:-1.0;
vec3 N=normalize(vN)*fd;
#ifndef HAIR
if(uHasNM>0.5&&uNMStr>0.0){vec3 m=texture2D(uNM,vUv).xyz*2.0-1.0;m.xy*=uNMStr*2.0;N=perturb(vW,N,m,fd);}
#endif
vec3 V=normalize(cameraPosition-vW);
float fres=pow(1.0-max(dot(N,V),0.0),5.0);
float ndlD=max(dot(N,uLightDir),0.0);
vec3 col=uAlbedo*(uAmb+uLight*ndlD)+vec3(uLight*ndlD*spec(N,uLightDir,V,fres));
vec3 ndc=vP.xyz/vP.w;
bool inCone=vP.w>0.0&&abs(ndc.x)<1.0&&abs(ndc.y)<1.0;
bool proj=inCone;
#ifdef IS_GROUND
proj=proj&&uFloorProj>0.5;
if(inCone&&!proj){
// Boden ohne Bild: nur schwaches neutrales Streulicht des Projektors, das Objekt wirft seinen Schatten hinein
vec2 uvS=ndc.xy*0.5+0.5;vec3 toS=uProjPos-vW;float dS=length(toS);vec3 LS=toS/dS;
float ndlS=max(dot(N,LS),0.0);
float shS=shadowAt(uvS,dS,max(dot(normalize(vN),LS),0.0));
float vigS=smoothstep(1.0,0.6,max(abs(ndc.x),abs(ndc.y)));
float fallS=clamp(pow(1100.0/dS,2.0),0.0,2.0);
col+=uAlbedo*uSpill*ndlS*vigS*fallS*shS;
col*=1.0-uShadowK*(1.0-shS)*vigS*0.85;
}
#endif
if(proj){
vec2 uv=ndc.xy*0.5+0.5;
vec3 toP=uProjPos-vW;float dist=length(toP);vec3 Lp=toP/dist;
float ndl=max(dot(N,Lp),0.0);
float sh=shadowAt(uv,dist,max(dot(normalize(vN)*fd,Lp),0.0));
float vig=smoothstep(1.0,0.82,max(abs(ndc.x),abs(ndc.y)));
float fall=clamp(pow(1100.0/dist,2.0),0.0,2.0);
// Berührung: Ringwelle von der Berührungsstelle aus, plus Glühen solange gehalten wird
float d=distance(vW,uTouch);
float ring=max(0.0,sin(d*0.07-uTouchAge*7.0))*exp(-d*0.005)*exp(-uTouchAge*0.8)*step(d,uTouchAge*100.0+30.0);
float touch=ring+exp(-d*0.025)*uTouchHold;
vec2 cuv=vC.xy/vC.w*0.5+0.5;
vec3 L=content(cuv,vAct,touch)*uInt*sh*vig*fall;
col+=uAlbedo*L*ndl+L*ndl*spec(N,Lp,V,fres);
}
#ifdef HAIR
col*=mix(0.3,1.2,vTip); // Wurzel dunkel, Spitze hell: Tiefe im Fell
#endif
// Nebel in Vollschwarz, setzt erst weit hinten ein
col*=1.0-smoothstep(uFogStart,uFogStart+uFogLen,length(cameraPosition-vW));
gl_FragColor=vec4(pow(max(col,0.0),vec3(1.0/2.2)),1.0);
}`;
// Fremden glslsandbox-Code umbauen: eigene Deklarationen raus, main und gl_* umleiten
function processUser(code){
const c=code.replace(/^[ \t]*#extension[^\n]*$/gm,'')
.replace(/^[ \t]*precision\s+\w+\s+\w+\s*;/gm,'')
.replace(/^[ \t]*uniform\s+\w+\s+(time|mouse|resolution|surfaceSize|backbuffer)\s*;/gm,'')
.replace(/^[ \t]*varying\s+\w+\s+surfacePosition\s*;/gm,'');
return '#undef gl_FragColor\n#define gl_FragColor uc_out\n#define gl_FragCoord uc_fc\n#define main uc_main\n'+c+'\n#undef main\n';
}
// Vorab mit dem echten WebGL-Kontext kompilieren, damit ein Fehler nicht die Szene zerlegt
function testUser(block){
const gl=renderer.getContext(),gl2=renderer.capabilities.isWebGL2;
const head=(gl2?'#version 300 es\n#define texture2D texture\nout highp vec4 pc_fragColor;\n#define gl_FragColor pc_fragColor\n':'#extension GL_OES_standard_derivatives : enable\n')
+'precision highp float;\nprecision highp int;\n'+UC_GLOBALS+'\nvoid main(){uc_main();gl_FragColor=uc_out;}\n';
const sh=gl.createShader(gl.FRAGMENT_SHADER);gl.shaderSource(sh,head+block);gl.compileShader(sh);
const ok=gl.getShaderParameter(sh,gl.COMPILE_STATUS),log=gl.getShaderInfoLog(sh)||'';gl.deleteShader(sh);
if(ok)return null;
const off=head.split('\n').length+3; // Kopf plus die vier umleitenden Zeilen
return log.replace(/(ERROR:\s*\d+:)(\d+)/g,(m,a,n)=>a+Math.max(1,n-off));
}
let FS_USER=UC_STUB;
{const b=processUser(document.getElementById('uc').value);if(!testUser(b))FS_USER=b;}
let USER_RES=512;
const userRT=[0,1].map(()=>new THREE.WebGLRenderTarget(USER_RES,USER_RES,{minFilter:THREE.LinearFilter,magFilter:THREE.LinearFilter,depthBuffer:false}));
let userIdx=0;
const qU={uTime:U.uTime,uScale:U.uScale,uMouse:U.uMouse,uRes:{value:new THREE.Vector2(USER_RES,USER_RES)},backbuffer:{value:blackTex}};
const QFS=`uniform float uTime,uScale;varying vec2 vUv2;
${UC_GLOBALS}
void main(){
float k=max(0.2,uScale/6.0);
resolution=uRes/k;surfaceSize=vec2(k);surfacePosition=(vUv2-0.5)*surfaceSize;
uc_fc=vec4(vUv2*uRes,0.5,1.0);time=uTime;mouse=uMouse;uc_out=vec4(0.0);
uc_main();
vec3 c=uc_out.rgb;
gl_FragColor=vec4((c.r==c.r&&c.g==c.g&&c.b==c.b)?clamp(c,0.0,1.0):vec3(0.0),1.0); // NaN abfangen
}
/*USER*/`;
const qMat=new THREE.ShaderMaterial({uniforms:qU,depthTest:false,depthWrite:false,extensions:{derivatives:true},
vertexShader:'varying vec2 vUv2;void main(){vUv2=uv;gl_Position=vec4(position.xy,0.0,1.0);}',
fragmentShader:QFS.replace('/*USER*/',FS_USER)});
const qScene=new THREE.Scene(),qCam=new THREE.OrthographicCamera(-1,1,1,-1,0,1);
{const m=new THREE.Mesh(new THREE.PlaneGeometry(2,2),qMat);m.frustumCulled=false;qScene.add(m);}
function renderUser(){
const dst=userRT[userIdx];qU.backbuffer.value=userRT[1-userIdx].texture;
renderer.setRenderTarget(dst);renderer.render(qScene,qCam);renderer.setRenderTarget(null);
U.uUserTex.value=dst.texture;userIdx=1-userIdx;
}
function setUserRes(n){USER_RES=n;userRT.forEach(r=>r.setSize(n,n));qU.uRes.value.set(n,n);}
const hairMat=new THREE.ShaderMaterial({uniforms:U,vertexShader:VS,fragmentShader:FS,defines:{USE_ACT:'',HAIR:''},extensions:{derivatives:true}});
const matCloth=new THREE.ShaderMaterial({uniforms:U,vertexShader:VS,fragmentShader:FS,side:THREE.DoubleSide,defines:{USE_ACT:''},extensions:{derivatives:true}});
const Ug=Object.assign({},U,{uAlbedo:{value:new THREE.Color(.06,.063,.068)},uGloss:{value:.3},uAmb:{value:.04},uLight:{value:.25}});
const matGround=new THREE.ShaderMaterial({uniforms:Ug,vertexShader:VS,fragmentShader:FS,defines:{IS_GROUND:''},extensions:{derivatives:true}});
const depthMat=new THREE.ShaderMaterial({
uniforms:{uProjPos:U.uProjPos,uNear:U.uNear,uFar:U.uFar},side:THREE.DoubleSide,
vertexShader:`varying vec3 vW;void main(){vec4 w=modelMatrix*vec4(position,1.0);vW=w.xyz;gl_Position=projectionMatrix*viewMatrix*w;}`,
fragmentShader:`#include <packing>
uniform vec3 uProjPos;uniform float uNear,uFar;varying vec3 vW;
void main(){gl_FragColor=packDepthToRGBA(clamp((length(vW-uProjPos)-uNear)/(uFar-uNear),0.0,0.9999));}`
});
/* Darstellung: geglättete Kopie der Simulationspositionen (Taubin) und gemittelte Normalen */
let disp=null;
/* Plüsch: Linien, deren Wurzeln per Baryzentrik auf Dreiecken der Hülle sitzen */
let hair=null;
function buildHair(){
if(hair){scene.remove(hair.mesh);hair.mesh.geometry.dispose();hair=null;}
if(!$('ckH').checked||!cloth)return;
const n=Math.round($('hd').value*1000),idx=cloth.index,nT=idx.length/3;
const T=new Int32Array(n*3),B=new Float32Array(n*2),J=new Float32Array(n*3),L=new Float32Array(n);
for(let h=0;h<n;h++){
const t=Math.floor(Math.random()*nT);T[3*h]=idx[3*t];T[3*h+1]=idx[3*t+1];T[3*h+2]=idx[3*t+2];
let u=Math.random(),v=Math.random();if(u+v>1){u=1-u;v=1-v;}B[2*h]=u;B[2*h+1]=v;
let jx=Math.random()*2-1,jy=Math.random()*2-1,jz=Math.random()*2-1;const jl=Math.hypot(jx,jy,jz)||1;
J[3*h]=jx/jl;J[3*h+1]=jy/jl;J[3*h+2]=jz/jl;L[h]=0.55+0.9*Math.random();
}
const pos=new Float32Array(n*6),nrm=new Float32Array(n*6),ref=new Float32Array(n*6),act=new Float32Array(n*2),tip=new Float32Array(n*2),uv=new Float32Array(n*4);
for(let h=0;h<n;h++)tip[2*h+1]=1;
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.BufferAttribute(pos,3));geo.setAttribute('normal',new THREE.BufferAttribute(nrm,3));
geo.setAttribute('aRef',new THREE.BufferAttribute(ref,3));geo.setAttribute('aAct',new THREE.BufferAttribute(act,1));
geo.setAttribute('aTip',new THREE.BufferAttribute(tip,1));geo.setAttribute('uv',new THREE.BufferAttribute(uv,2));
const mesh=new THREE.LineSegments(geo,hairMat);mesh.frustumCulled=false;scene.add(mesh);
hair={n,T,B,J,L,pos,nrm,ref,act,geo,mesh};
if(disp&&sim)updateHair();
}
function updateHair(){
const {n,T,B,J,L,pos,nrm,ref,act,geo}=hair,P=disp.pos,Nn=disp.nrm,R=disp.ref,Ac=disp.act,V=sim.v;
const len=+$('hl').value,droop=$('hf').value/100*1.4,wild=$('hw').value/100*1.1,lag=0.0018;
for(let h=0;h<n;h++){
const a=3*T[3*h],b=3*T[3*h+1],c=3*T[3*h+2],u=B[2*h],v=B[2*h+1],w=1-u-v,o=6*h;
const rx=P[a]*w+P[b]*u+P[c]*v,ry=P[a+1]*w+P[b+1]*u+P[c+1]*v,rz=P[a+2]*w+P[b+2]*u+P[c+2]*v;
let nx=Nn[a]*w+Nn[b]*u+Nn[c]*v,ny=Nn[a+1]*w+Nn[b+1]*u+Nn[c+1]*v,nz=Nn[a+2]*w+Nn[b+2]*u+Nn[c+2]*v;
const nl=Math.sqrt(nx*nx+ny*ny+nz*nz)||1;nx/=nl;ny/=nl;nz/=nl;
const vx=V[a]*w+V[b]*u+V[c]*v,vy=V[a+1]*w+V[b+1]*u+V[c+1]*v,vz=V[a+2]*w+V[b+2]*u+V[c+2]*v;
let dx=nx+J[3*h]*wild-vx*lag,dy=ny+J[3*h+1]*wild-droop-vy*lag,dz=nz+J[3*h+2]*wild-vz*lag;
const s=len*L[h]/(Math.sqrt(dx*dx+dy*dy+dz*dz)||1);
pos[o]=rx;pos[o+1]=ry;pos[o+2]=rz;
pos[o+3]=rx+dx*s;pos[o+4]=Math.max(0,ry+dy*s);pos[o+5]=rz+dz*s;
nrm[o]=nrm[o+3]=nx;nrm[o+1]=nrm[o+4]=ny;nrm[o+2]=nrm[o+5]=nz;
const qx=R[a]*w+R[b]*u+R[c]*v,qy=R[a+1]*w+R[b+1]*u+R[c+1]*v,qz=R[a+2]*w+R[b+2]*u+R[c+2]*v;
ref[o]=ref[o+3]=qx;ref[o+1]=ref[o+4]=qy;ref[o+2]=ref[o+5]=qz;
act[2*h]=act[2*h+1]=Ac[T[3*h]];
}
geo.attributes.position.needsUpdate=true;geo.attributes.normal.needsUpdate=true;geo.attributes.aRef.needsUpdate=true;geo.attributes.aAct.needsUpdate=true;
}
function makeDisp(c){
const N=c.N,deg=new Int32Array(N+1);
for(let e=0;e<c.eA.length;e++){deg[c.eA[e]+1]++;deg[c.eB[e]+1]++;}
for(let i=0;i<N;i++)deg[i+1]+=deg[i];
const adj=new Int32Array(deg[N]),fill=deg.slice(0,N);
for(let e=0;e<c.eA.length;e++){adj[fill[c.eA[e]]++]=c.eB[e];adj[fill[c.eB[e]]++]=c.eA[e];}
return {N,st:deg,adj,index:c.index,pos:new Float32Array(N*3),tmp:new Float32Array(N*3),nrm:new Float32Array(N*3),nt:new Float32Array(N*3),
act:new Float32Array(N),at:new Float32Array(N),ref:new Float32Array(N*3)};
}
function lap(d,src,dst,f){
const {N,st,adj}=d;
for(let i=0;i<N;i++){
let ax=0,ay=0,az=0;const a=st[i],b=st[i+1],n=b-a,k=3*i;
for(let m=a;m<b;m++){const j=3*adj[m];ax+=src[j];ay+=src[j+1];az+=src[j+2];}
dst[k]=src[k]+f*(ax/n-src[k]);dst[k+1]=src[k+1]+f*(ay/n-src[k+1]);dst[k+2]=src[k+2]+f*(az/n-src[k+2]);
}
}
function smoothAct(d,src){
const {N,st,adj,act,at}=d;
const pass=(a,out)=>{for(let i=0;i<N;i++){let v=a[i]*2;for(let m=st[i];m<st[i+1];m++)v+=a[adj[m]];out[i]=v/(st[i+1]-st[i]+2);}};
pass(src,at);pass(at,act);pass(act,at);act.set(at);
}
function updateDisp(d,x,iters){
const {N,st,adj,index,pos,tmp,nrm,nt}=d;
pos.set(x);
for(let k=0;k<iters;k++){lap(d,pos,tmp,0.5);lap(d,tmp,pos,-0.53);}
for(let i=1;i<pos.length;i+=3)if(pos[i]<0)pos[i]=0;
nt.fill(0);
for(let t=0;t<index.length;t+=3){
const a=3*index[t],b=3*index[t+1],c=3*index[t+2];
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;
nt[a]+=nx;nt[a+1]+=ny;nt[a+2]+=nz;nt[b]+=nx;nt[b+1]+=ny;nt[b+2]+=nz;nt[c]+=nx;nt[c+1]+=ny;nt[c+2]+=nz;
}
for(let i=0;i<N;i++){const k=3*i,l=Math.sqrt(nt[k]*nt[k]+nt[k+1]*nt[k+1]+nt[k+2]*nt[k+2])||1;nt[k]/=l;nt[k+1]/=l;nt[k+2]/=l;}
if(iters>0){ // Normalen einmal über die Nachbarn mitteln
for(let i=0;i<N;i++){
const k=3*i;let ax=nt[k]*2,ay=nt[k+1]*2,az=nt[k+2]*2;
for(let m=st[i];m<st[i+1];m++){const j=3*adj[m];ax+=nt[j];ay+=nt[j+1];az+=nt[j+2];}
const l=Math.sqrt(ax*ax+ay*ay+az*az)||1;nrm[k]=ax/l;nrm[k+1]=ay/l;nrm[k+2]=az/l;
}
} else nrm.set(nt);
}
const ground=new THREE.Mesh(new THREE.PlaneGeometry(90000,90000).rotateX(-Math.PI/2),matGround);
scene.add(ground);
let cloth=null,sim=null,clothMesh=null,shapeDirty=true;
let refCaptured=false,centroidInit=false;const cS=new THREE.Vector3(),cRef=new THREE.Vector3(),fDelta=new THREE.Vector3();
function rebuild(){
if(!ed.closed){flash('Schließe zuerst die Kontur.');return false;}
const c=buildCloth(ed.nodes,+$('res').value);
if(c.err){flash(c.err);return false;}
cloth=c;sim=createSim(c);
if(clothMesh){scene.remove(clothMesh);clothMesh.geometry.dispose();}
disp=makeDisp(c);updateDisp(disp,sim.x,+$('sm').value);
const geo=new THREE.BufferGeometry();
geo.setAttribute('position',new THREE.BufferAttribute(disp.pos,3));
geo.setAttribute('normal',new THREE.BufferAttribute(disp.nrm,3));
geo.setAttribute('uv',new THREE.BufferAttribute(c.uv,2));
geo.setAttribute('aAct',new THREE.BufferAttribute(disp.act,1));
disp.ref.set(disp.pos);geo.setAttribute('aRef',new THREE.BufferAttribute(disp.ref,3));
refCaptured=false;cS.set(0,0,0);cRef.set(0,0,0);centroidInit=false;
geo.setIndex(new THREE.BufferAttribute(c.index,1));
clothMesh=new THREE.Mesh(geo,matCloth);clothMesh.frustumCulled=false;scene.add(clothMesh);
shapeDirty=false;
buildHair();
return true;
}
const controls=(()=>{
// Eigener Pointer-Handler zuerst registrieren, damit er Orbit beim Anfassen des Objekts stoppen kann
const ray=new THREE.Raycaster(),m=new THREE.Vector2(),plane=new THREE.Plane(),hit=new THREE.Vector3();
let grabbing=false;
function setRay(e){const r=c3.getBoundingClientRect();m.set((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1);ray.setFromCamera(m,cam);}
c3.addEventListener('pointerdown',e=>{
if(mode!=='live'||!sim||e.button!==0)return;
setRay(e);clothMesh.geometry.computeBoundingSphere();
const h=ray.intersectObject(clothMesh)[0];if(!h)return;
const R=Math.max(35,cloth.s*4);
if(!sim.startGrab(h.point.x,h.point.y,h.point.z,R))return;
e.stopImmediatePropagation();e.preventDefault();c3.setPointerCapture(e.pointerId);
grabbing=true;plane.setFromNormalAndCoplanarPoint(cam.getWorldDirection(new THREE.Vector3()).negate(),h.point);
U.uTouch.value.copy(h.point);U.uTouchAge.value=0;U.uTouchHold.value=1;c3.style.cursor='grabbing';
});
c3.addEventListener('pointermove',e=>{
if(!grabbing)return;setRay(e);
if(ray.ray.intersectPlane(plane,hit)){sim.moveGrab(hit.x,hit.y,hit.z);U.uTouch.value.set(hit.x,Math.max(0,hit.y),hit.z);}
});
const up=()=>{if(!grabbing)return;grabbing=false;sim.endGrab();c3.style.cursor='';};
c3.addEventListener('pointerup',up);c3.addEventListener('pointercancel',up);
const oc=new THREE.OrbitControls(cam,c3);
oc.target.set(0,60,0);oc.enableDamping=true;oc.dampingFactor=0.08;oc.maxPolarAngle=Math.PI*0.495;oc.minDistance=150;oc.maxDistance=20000;oc.update();
return oc;
})();
const P0VP=new THREE.Matrix4();
function updateProjector(){
const az=$('az').value*Math.PI/180,el=$('el').value*Math.PI/180,D=1300,mode=+$('fol').value;
const base=new THREE.Vector3(pTarget.x+Math.sin(az)*Math.cos(el)*D,pTarget.y+Math.sin(el)*D,pTarget.z+Math.cos(az)*Math.cos(el)*D);
pcam.fov=+$('fv').value;pcam.near=200;pcam.far=4000;pcam.updateProjectionMatrix();
// unbewegter Projektor: Bezugssystem für „Haftet“ und „Steht im Raum“
pcam.position.copy(base);pcam.lookAt(pTarget);pcam.updateMatrixWorld();
P0VP.multiplyMatrices(pcam.projectionMatrix,pcam.matrixWorldInverse);
// Scheinwerfer: Projektor um die Verschiebung des Körpers seit der Referenz mitführen
fDelta.set(0,0,0);if(mode>0&¢roidInit)fDelta.subVectors(cS,cRef);
pcam.position.copy(base).add(fDelta);pcam.lookAt(pTarget.clone().add(fDelta));pcam.updateMatrixWorld();
U.uProjVP.value.multiplyMatrices(pcam.projectionMatrix,pcam.matrixWorldInverse);
U.uContentVP.value.copy(mode===1?U.uProjVP.value:P0VP);
U.uStick.value=mode===2?1:0;
U.uProjPos.value.copy(pcam.position);U.uNear.value=pcam.near;U.uFar.value=pcam.far;
pHelper.update();pBody.position.copy(pcam.position);pBody.quaternion.copy(pcam.quaternion);
const show=$('ckP').checked;pHelper.visible=show;pBody.visible=show;
}
function readLook(){
U.uInt.value=$('pi').value/100*1.6;
U.uScale.value=1+$('ps').value/100*24;
U.uContrast.value=1+$('pc').value/100*4;
U.uPattern.value=+$('pat').value;
$('ucBox').style.display=$('pat').value==='2'?'block':'none';
U.uFogStart.value=$('fg').value*1000;
U.uColorize.value=$('cz').value/100;
U.uShadowK.value=$('sk').value/100;
U.uSpill.value=$('sp').value/100*1.5;
{const f=0.015+$('fl').value/100*0.2;Ug.uAlbedo.value.setRGB(f,f*1.04,f*1.1);}
U.uHue.value=$('ph').value/100;
U.uReact.value=$('rx').value/100*1.4;
U.uLight.value=$('rl').value/100*0.6;U.uAmb.value=$('rl').value/100*0.06;
U.uAlbedo.value.set($('col').value).convertSRGBToLinear();
U.uGloss.value=$('gl').value/100;
U.uNMStr.value=$('nm').value/100;
U.uRep.value=$('rp').value/1000;
U.uFloorProj.value=$('ckF').checked?0:1;
}
const texLoader=new THREE.TextureLoader();
function setNM(url,name){
texLoader.load(url,t=>{t.wrapS=t.wrapT=THREE.RepeatWrapping;t.anisotropy=renderer.capabilities.getMaxAnisotropy();U.uNM.value=t;U.uHasNM.value=1;$('nmName').textContent=name;},
undefined,()=>{$('nmName').textContent=name+' nicht gefunden';});
}
$('bRef').onclick=()=>{if(!disp)return;disp.ref.set(disp.pos);cRef.copy(cS);refCaptured=true;clothMesh.geometry.attributes.aRef.needsUpdate=true;};
$('bNM').onclick=()=>$('fNM').click();
$('fNM').onchange=e=>{const f=e.target.files[0];if(f)setNM(URL.createObjectURL(f),f.name);e.target.value='';};
setNM('normal_map.jpg','normal_map.jpg');
function applyUser(){
const b=processUser($('uc').value),err=testUser(b);
if(err){$('ucErr').textContent=err.trim();$('ucMsg').textContent='Fehler, alter Shader läuft weiter';return;}
$('ucErr').textContent='';$('ucMsg').textContent='übernommen';
qMat.fragmentShader=QFS.replace('/*USER*/',b);qMat.needsUpdate=true;
$('pat').value='2';readLook();
}
$('bUC').onclick=applyUser;
$('ckH').addEventListener('change',buildHair);$('hd').addEventListener('change',buildHair);
$('ures').addEventListener('change',()=>setUserRes(+$('ures').value));
$('uc').addEventListener('keydown',e=>{e.stopPropagation();if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();applyUser();}});
if(FS_USER===UC_STUB){const e=testUser(processUser($('uc').value));if(e)$('ucErr').textContent=e.trim();}
function resize3(){const r=stage.getBoundingClientRect();renderer.setPixelRatio(Math.min(2,window.devicePixelRatio||1));renderer.setSize(r.width,r.height,false);cam.aspect=r.width/Math.max(1,r.height);cam.updateProjectionMatrix();}
/* ===================== Modus, UI, Schleife ===================== */
let mode='form';
function setMode(m){
if(m==='live'&&(shapeDirty||!sim)){if(!rebuild())return;}
mode=m;$('mForm').classList.toggle('on',m==='form');$('mLive').classList.toggle('on',m==='live');
stage.classList.toggle('live',m==='live');
c2.style.display=m==='form'?'block':'none';c3.style.display=m==='live'?'block':'none';
$('hint').textContent=m==='form'
?'Klick: Ecke · Klick+Ziehen: Kurve · Alt+Ziehen: Griff trennen · Startpunkt oder Enter: schließen · Doppelklick: Punkt einfügen / Ecke↔Kurve · Entf: löschen · Leertaste oder rechte Maus: verschieben · Scrollen: Zoom'
:'Objekt ziehen: anfassen · Hintergrund ziehen: drehen · Rechte Maus: verschieben · Scrollen: Zoom';
resize2();resize3();ed.dirty=true;
}
let flashT=0;
function flash(t){const m=$('msg');m.textContent=t;m.style.display='block';clearTimeout(flashT);flashT=setTimeout(()=>m.style.display='none',2600);}
$('mForm').onclick=()=>setMode('form');
$('mLive').onclick=()=>setMode('live');
const reinflate=()=>{if(rebuild()&&mode!=='live')setMode('live');};
$('bRe').onclick=reinflate;$('bRe2').onclick=reinflate;
document.querySelectorAll('[data-pre]').forEach(b=>b.onclick=()=>{ed.nodes=PRESETS[b.dataset.pre]();ed.closed=true;ed.sel=-1;changed();fitView();if(mode==='live')rebuild();});
$('bClear').onclick=()=>{ed.nodes=[];ed.closed=false;ed.sel=-1;changed();if(mode!=='form')setMode('form');};
document.querySelectorAll('aside input, aside select').forEach(i=>i.addEventListener('input',()=>{readParams();readLook();updateProjector();}));
window.addEventListener('resize',()=>{resize2();resize3();ed.dirty=true;});
readParams();readLook();resize2();resize3();fitView();updateProjector();setMode('form');
let fpsT=performance.now(),fpsN=0,fps=0;
function loop(){
requestAnimationFrame(loop);
if(mode==='form'){draw2();return;}
const dt=1/60;
if(sim){
sim.step(dt,prm);
updateDisp(disp,sim.x,+$('sm').value);smoothAct(disp,sim.act);
// Schwerpunkt für Scheinwerfer und Referenz
let cx=0,cy=0,cz=0;const X=sim.x,n=cloth.N;for(let i=0;i<3*n;i+=3){cx+=X[i];cy+=X[i+1];cz+=X[i+2];}
const cNow=new THREE.Vector3(cx/n,cy/n,cz/n);
if(!centroidInit){cS.copy(cNow);cRef.copy(cNow);centroidInit=true;}else cS.lerp(cNow,0.25);
if(!refCaptured){disp.ref.set(disp.pos);cRef.copy(cS);if(sim.time>2.5&&!sim.grab)refCaptured=true;}
const geo=clothMesh.geometry;geo.attributes.position.needsUpdate=true;geo.attributes.normal.needsUpdate=true;geo.attributes.aAct.needsUpdate=true;geo.attributes.aRef.needsUpdate=true;
if(hair)updateHair();
}
U.uTime.value+=dt*($('pt').value/100)*2.5;
U.uTouchAge.value+=dt;
if(U.uTouchHold.value>0.05){const q=U.uTouch.value.clone().applyMatrix4(U.uContentVP.value);U.uMouse.value.set(q.x*0.5+0.5,q.y*0.5+0.5);}
if(!(sim&&sim.grab))U.uTouchHold.value*=0.93;
updateProjector();
controls.update();
if($('pat').value==='2')renderUser();
// Tiefenkarte aus Sicht des Projektors
const hv=pHelper.visible,bv=pBody.visible;pHelper.visible=pBody.visible=false;
scene.overrideMaterial=depthMat;renderer.setRenderTarget(shadowRT);renderer.setClearColor(0xffffff,1);renderer.clear();renderer.render(scene,pcam);
scene.overrideMaterial=null;renderer.setRenderTarget(null);renderer.setClearColor(0x000000,1);
pHelper.visible=hv;pBody.visible=bv;
renderer.render(scene,cam);
fpsN++;const now=performance.now();if(now-fpsT>500){fps=Math.round(fpsN*1000/(now-fpsT));fpsN=0;fpsT=now;}
if(sim)$('info').textContent=`${cloth.N} Partikel · Höhe ${Math.round(sim.height)} mm · Volumen ${(sim.volume/1e6).toFixed(1)} l · ${fps} fps`;
}
loop();
</script>
</body>
</html>