index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Kirigami Editor</title>
<style>
:root{--line:#e7e7e7;--txt:#111;--mute:#8a8a8a;--glue:#e8572a;--mtn:#2f6fe0;--val:#e0503a}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:var(--txt);font:12px/1.4 system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;overflow:hidden}
#app{display:grid;grid-template-columns:310px 1fr 1fr;grid-template-rows:100%;height:100vh;height:100dvh}
#panel{border-right:1px solid var(--line);overflow-y:auto;padding:16px 16px 48px;background:#fff}
.view{position:relative;min-width:0;min-height:0;overflow:hidden}
#v2d{border-right:1px solid var(--line)}
#v2d canvas{display:block;width:100%;height:100%;touch-action:none}
#v2d.cut canvas{cursor:crosshair}
#v3d canvas{display:block;touch-action:none}
h1{font-size:13px;letter-spacing:.14em;font-weight:600;margin:0}
.sub{color:var(--mute);margin:2px 0 6px}
#stats{color:var(--mute);font-variant-numeric:tabular-nums;margin-bottom:8px}
#restart{width:100%;margin:0 0 10px;padding:8px 10px;background:#111;color:#fff}
#restart:hover{background:#333}
details{border-top:1px solid var(--line);padding:6px 0 8px}
summary{cursor:pointer;font-weight:600;letter-spacing:.08em;text-transform:uppercase;font-size:10.5px;list-style:none;padding:5px 0}
summary::-webkit-details-marker{display:none}
summary::after{content:"+";float:right;color:var(--mute)}
details[open] summary::after{content:"–"}
.row{display:grid;grid-template-columns:100px 1fr 58px;align-items:center;gap:8px;margin:5px 0}
.row.two{grid-template-columns:100px 1fr}
.row label{color:#444}
.row output{text-align:right;color:var(--mute);font-variant-numeric:tabular-nums;white-space:nowrap}
input[type=range]{width:100%;accent-color:#111;margin:0}
select{width:100%;font:inherit;padding:3px 4px;border:1px solid var(--line);background:#fff;border-radius:4px;color:#111}
input[type=color]{width:100%;height:22px;border:1px solid var(--line);padding:0 2px;background:#fff;border-radius:4px}
input[type=checkbox]{accent-color:#111;margin:0;justify-self:start}
.btns{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}
button{font:inherit;border:1px solid #111;background:#fff;color:#111;padding:5px 9px;border-radius:4px;cursor:pointer}
button:hover{background:#f4f4f4}
button.on{background:#111;color:#fff}
button.ghost{border-color:var(--line)}
.note{color:var(--mute);margin:2px 0 6px}
.mod{border:1px solid var(--line);border-radius:6px;padding:4px 8px 6px;margin:8px 0;position:relative}
.mod .x{position:absolute;right:6px;top:3px;color:var(--mute);cursor:pointer;font-size:14px;line-height:1}
.mod .x:hover{color:#111}
.mod .row:first-of-type{margin-right:16px}
.tag{position:absolute;left:14px;top:12px;font-size:10.5px;letter-spacing:.12em;color:var(--mute);text-transform:uppercase;pointer-events:none}
.hint{position:absolute;left:14px;bottom:12px;color:var(--mute);pointer-events:none;max-width:62%}
.legend{position:absolute;right:14px;bottom:12px;display:flex;gap:12px;color:var(--mute);pointer-events:none;font-size:11px}
.legend i{display:inline-block;width:18px;height:0;border-top:2px solid;vertical-align:middle;margin-right:5px}
#cutBtn{position:absolute;right:12px;top:8px;letter-spacing:.08em;font-weight:600;font-size:11px}
#toggle{display:none;position:fixed;left:12px;top:10px;z-index:40;background:#fff}
@media (max-width:1100px){
#app{grid-template-columns:300px 1fr;grid-template-rows:1fr 1fr}
#panel{grid-column:1;grid-row:1 / span 2}
#v2d{grid-column:2;grid-row:1;border-right:0;border-bottom:1px solid var(--line)}
#v3d{grid-column:2;grid-row:2}
}
@media (max-width:700px){
#app{grid-template-columns:1fr;grid-template-rows:1fr 1fr}
#v2d,#v3d{grid-column:1}
#panel{position:fixed;top:0;bottom:0;left:0;width:min(340px,92vw);z-index:30;transform:translateX(-102%);transition:transform .25s ease;box-shadow:0 0 40px rgba(0,0,0,.08);border-right:1px solid var(--line);padding-top:48px}
body.open #panel{transform:none}
#toggle{display:block}
.legend{display:none}
.tag{left:auto;right:14px;top:auto;bottom:12px}
}
</style>
</head>
<body>
<button id="toggle">Parameter</button>
<div id="app">
<div id="panel">
<h1>KIRIGAMI v0.4</h1>
<div class="sub">Form aus Falzgeometrie · Keile verkleben · Cuts im Rapport</div>
<div id="stats"></div>
<button id="restart">↻ Simulation neu starten</button>
<div id="controls"></div>
</div>
<div class="view" id="v2d">
<canvas id="c2d"></canvas>
<div class="tag">2D · Zuschnitt</div>
<button id="cutBtn">CUTMODE</button>
<div class="hint" id="hint2d"></div>
<div class="legend">
<span><i style="border-color:#111"></i>Schnitt</span>
<span><i style="border-color:var(--glue)"></i>Klebekante</span>
<span><i style="border-color:var(--mtn);border-top-style:dashed"></i>Berg</span>
<span><i style="border-color:var(--val);border-top-style:dotted"></i>Tal</span>
</div>
</div>
<div class="view" id="v3d">
<div class="tag">3D · Simulation</div>
<div class="hint">Am Blatt ziehen: greifen · daneben ziehen: drehen</div>
</div>
</div>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script>
<script type="module">
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
const W3 = 0.01; // Welt-Einheiten pro mm (1 Einheit = 100 mm)
/* ---------- Materialien (Stärke in mm) ---------- */
const MATS = {
papier80: {name:'Papier 80 g/m²', thick:.1, stretch:1, bend:.8, fold:.9, front:'#f7f5f0', back:'#efebe2', rough:.92, clear:0, metal:0, opacity:1, nmType:'fiber', nmStr:.35},
karton: {name:'Zeichenkarton 250 g', thick:.3, stretch:1, bend:.85, fold:.85, front:'#fbfaf6', back:'#f1eee6', rough:.88, clear:0, metal:0, opacity:1, nmType:'fiber', nmStr:.3},
graupappe: {name:'Graupappe 1 mm', thick:1, stretch:1, bend:.9, fold:.6, front:'#a39d92', back:'#958f84', rough:.95, clear:0, metal:0, opacity:1, nmType:'grain', nmStr:.6},
wellpappe: {name:'Wellpappe 3 mm', thick:3, stretch:1, bend:.9, fold:.45, front:'#c9a273', back:'#bb9466', rough:.95, clear:0, metal:0, opacity:1, nmType:'ribs', nmStr:.6},
transparent:{name:'Transparentpapier', thick:.06, stretch:1, bend:.7, fold:.95, front:'#f3f5f6', back:'#eef1f2', rough:.55, clear:.2, metal:0, opacity:.55, nmType:'fiber', nmStr:.15},
naturfaser: {name:'Naturfaserpapier', thick:.15, stretch:.97, bend:.7, fold:.75, front:'#e7dcc3', back:'#ddd1b6', rough:.97, clear:0, metal:0, opacity:1, nmType:'fiber', nmStr:1},
tyvek: {name:'Tyvek', thick:.15, stretch:.95, bend:.5, fold:.6, front:'#fbfbfb', back:'#f3f3f3', rough:.8, clear:0, metal:0, opacity:1, nmType:'clouds',nmStr:.5},
pet: {name:'PET-Folie 0,2 mm', thick:.2, stretch:1, bend:.9, fold:.35, front:'#e8f0f5', back:'#e8f0f5', rough:.12, clear:1, metal:0, opacity:.35, nmType:'clouds',nmStr:0},
ppmatt: {name:'PP-Folie matt', thick:.3, stretch:.98, bend:.8, fold:.5, front:'#f1f1ec', back:'#f1f1ec', rough:.5, clear:.2, metal:0, opacity:.75, nmType:'grain', nmStr:.15},
silikon: {name:'Silikonfolie', thick:.5, stretch:.35, bend:.05, fold:.15, front:'#f2d6cb', back:'#f2d6cb', rough:.35, clear:.4, metal:0, opacity:.85, nmType:'clouds',nmStr:.1},
furnier: {name:'Holzfurnier', thick:.6, stretch:1, bend:.9, fold:.3, front:'#d9b68b', back:'#cfab7f', rough:.7, clear:.15, metal:0, opacity:1, nmType:'wood', nmStr:.7},
alu: {name:'Alu-kaschiert', thick:.1, stretch:1, bend:.7, fold:1, front:'#d8dcdf', back:'#f5f3ee', rough:.3, clear:0, metal:.9, opacity:1, nmType:'grain', nmStr:.15},
};
/* ---------- Parameter ---------- */
const P = {
gen:'darts',
form:1, foldAngle:110, glue:1, res:3,
// Keile
dN:8, dW:.6, dTip:.35, dR:150, dIn:.08, dRows:8, dField:'oval', dAmt:.4, dFreq:3, dPleat:false,
// Fächer
fanOpen:300, fanN:24, fanR:150, fanIn:.2, fanRows:6, fanTwist:0, fanSeam:true,
// Schräge Concertina
conN:16, conW:20, conH:200, conTilt:8, conMode:'alt', conFreq:1, conRing:false,
// Zickzack
miuCols:12, miuRows:8, miuA:22, miuB:22, miuS:8, miuField:'radial', miuAmt:.6, miuFreq:1,
// Ringe
rgN:8, rgSeg:48, rgR:150, rgIn:.12,
// Hypar
hpN:8, hpSeg:6, hpR:150, hpIn:.1,
// Kugelsegmente
goN:10, goRows:12, goH:240, goCap:.06, goClose:true,
// Cuts
cutOn:true, cutMode:'panel', cutEvery:1, cutOffset:false, cutDX:30, cutDY:30, cutSize:.6, cutRot:0,
// Material / Aussehen
mat:'karton', ...MATS.karton, flat:false, nmScale:1.5,
gravity:0, pin:'none', iters:20, damp:.05, showField:false,
};
delete P.name;
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const FIELD=[['const','Konstant'],['x','Verlauf X'],['y','Verlauf Y'],['radial','Radial'],['waveY','Welle Y'],['waveX','Welle X'],['saddle','Sattel']];
function shape(mode,u,v,f){
switch(mode){
case 'x': return 2*u-1;
case 'y': return 2*v-1;
case 'radial': return 1-2*Math.min(1,Math.hypot(u-.5,v-.5)/.7071);
case 'waveY': return Math.sin(Math.PI*2*f*v);
case 'waveX': return Math.sin(Math.PI*2*f*u);
case 'saddle': return (2*u-1)*(2*v-1);
default: return 0;
}
}
function bendEff(){ const f=Math.pow(Math.max(.02,P.thick)/.2,1.5); return 1-Math.pow(1-clamp(P.bend,0,.999),f); }
/* ---------- Modifikatoren (pro Falz) ----------
u = Position entlang der Falzfolge (Falz-Nr. / Anzahl), v = Position quer bzw. entlang des Falzes */
const TARGETS={
angle:['Faltwinkel','mul'], hole:['Lochgröße','mul'],
spacing:['Falzabstand','mul'], length:['Länge / Radius','mul'], twist:['Drall','add',60], tilt:['Neigung','add',30],
zigzag:['Zickzack','mul'], rowh:['Reihenhöhe','mul'], dartw:['Keilbreite','mul'], tip:['Keilspitze','mul'],
ringw:['Ringabstand','mul'], gorew:['Segmentbreite','mul']
};
const GEN_TARGETS={
darts:['angle','dartw','tip','length','hole'], fan:['angle','spacing','length','twist','hole'],
con:['angle','spacing','length','tilt','hole'], miura:['angle','zigzag','spacing','rowh','hole'],
rings:['angle','ringw','length','hole'], hypar:['angle','ringw','length','hole'], gores:['angle','gorew','length','hole']
};
const MOD_SHAPES=[['const','Konstant'],['bump','Einzelne Falze'],['u','Verlauf über Falze'],['v','Verlauf entlang Falz'],['radial','Radial'],
['waveU','Welle über Falze'],['waveV','Welle entlang Falz'],['band','Band entlang Falz'],['rand','Zufall pro Falz'],['noise','Rauschen']];
let mods=[];
function hash(n,seed){ const x=Math.sin(n*127.1+seed*311.7)*43758.5453; return x-Math.floor(x); }
function vnoise(x,y,seed){
const xi=Math.floor(x), yi=Math.floor(y), fx=x-xi, fy=y-yi, s=t=>t*t*(3-2*t), h=(a,b)=>hash(a*57+b*131,seed);
const a=h(xi,yi),b=h(xi+1,yi),c=h(xi,yi+1),d=h(xi+1,yi+1), u=s(fx), v=s(fy);
return (a*(1-u)+b*u)*(1-v)+(c*(1-u)+d*u)*v;
}
function mshape(m,u,v){
switch(m.shape){
case 'u': return 2*u-1;
case 'v': return 2*v-1;
case 'radial': return 1-2*Math.min(1,Math.hypot(u-.5,v-.5)/.7071);
case 'waveU': return Math.sin(2*Math.PI*(m.freq*u+m.pos));
case 'waveV': return Math.sin(2*Math.PI*(m.freq*v+m.pos));
case 'bump': { const d=(u-m.pos)/Math.max(.005,m.width); return Math.exp(-d*d*2); }
case 'band': { const d=(v-m.pos)/Math.max(.005,m.width); return Math.exp(-d*d*2); }
case 'rand': return hash(Math.round(u*4096),m.seed)*2-1;
case 'noise': return vnoise(u*m.freq*4,v*m.freq*4,m.seed)*2-1;
default: return 1;
}
}
function activeMods(t){ const ok=GEN_TARGETS[P.gen]; return mods.filter(m=>m.target===t&&ok.includes(t)); }
function Mm(t,u,v){ let f=1; for(const m of activeMods(t)) f*=1+m.amt*mshape(m,u,v); return Math.max(.02,f); }
function Ma(t,u,v){ let s=0; const sc=TARGETS[t][2]||1; for(const m of activeMods(t)) s+=m.amt*sc*mshape(m,u,v); return s; }
const angleAt=(u,v)=>clamp(P.foldAngle*Mm('angle',u,v),0,170);
/* ---------- Generatoren: Paneel-Raster mit beliebigen Eckpunkten ---------- */
function makeG(PI,PJ){
return {PI,PJ, cx:new Float64Array((PI+1)*(PJ+1)), cy:new Float64Array((PI+1)*(PJ+1)),
rem:new Uint8Array(PI*PJ), col:new Int8Array((PI+1)*PJ), colW:new Float32Array((PI+1)*PJ),
row:new Int8Array(PI*(PJ+1)), rowW:new Float32Array(PI*(PJ+1)), glues:[]};
}
const ci=(G,i,j)=>i*(G.PJ+1)+j;
function setC(G,i,j,x,y){ const k=ci(G,i,j); G.cx[k]=x; G.cy[k]=y; }
function crease(G,type,i,j,s,w){ if(type==='col'){ G.col[i*G.PJ+j]=s; G.colW[i*G.PJ+j]=w; } else { G.row[j*G.PI+i]=s; G.rowW[j*G.PI+i]=w; } }
function cumul(n,f){ const w=[]; let s=0; for(let k=0;k<n;k++){ const x=f(k); w.push(x); s+=x; } const out=[0]; let a=0; for(let k=0;k<n;k++){ a+=w[k]; out.push(a/s); } return out; }
function genDarts(){
const N=Math.max(3,Math.round(P.dN)), R=P.dR, Rin=R*P.dIn, rt=Math.max(Rin+R*.03,R*P.dTip);
const rowsIn=Math.max(1,Math.round(P.dRows*(rt-Rin)/(R-Rin))), rowsOut=Math.max(1,Math.round(P.dRows)-rowsIn), PJ=rowsIn+rowsOut;
const G=makeG(3*N,PJ), sec=2*Math.PI/N;
const rAt=j=>j<=rowsIn?Rin+(rt-Rin)*j/rowsIn:rt+(R-rt)*(j-rowsIn)/rowsOut;
const ds=a=>{ switch(P.dField){ case 'oval':return Math.cos(2*a); case 'wave':return Math.sin(P.dFreq*a); case 'side':return Math.cos(a); default:return 0; } };
for(let k=0;k<=N;k++){
const kk=k%N, thS=Math.PI/2+k*sec, thC=thS+sec/2, uC=(kk+.5)/N;
const Wk=Math.min(.9*sec,P.dW*sec*clamp(1+P.dAmt*ds(thC-Math.PI/2),0,2)*Mm('dartw',uC,.5));
const rtk=clamp(rt*Mm('tip',uC,.5),Rin+R*.02,R*.95);
const sB=Mm('length',kk/N,1), sD=Mm('length',uC,1);
for(let j=0;j<=PJ;j++){ const r=rAt(j), h=Wk/2*Math.max(0,(r-rtk)/(R-rtk));
setC(G,3*k,j,r*sB*Math.cos(thS),r*sB*Math.sin(thS));
if(k<N){ setC(G,3*k+1,j,r*sD*Math.cos(thC-h),r*sD*Math.sin(thC-h)); setC(G,3*k+2,j,r*sD*Math.cos(thC+h),r*sD*Math.sin(thC+h)); } }
if(k<N){ for(let j=0;j<PJ;j++) G.rem[(3*k+1)*PJ+j]=1; G.glues.push([3*k+1,3*k+2,0,PJ]); }
if(P.dPleat&&k>0&&k<N) for(let j=0;j<PJ;j++) crease(G,'col',3*k,j,(k%2)?1:-1,1);
}
G.glues.push([0,3*N,0,PJ]);
return G;
}
function genFan(){
const N=Math.max(2,Math.round(P.fanN)), PJ=Math.max(1,Math.round(P.fanRows)), G=makeG(N,PJ);
const open=P.fanOpen*Math.PI/180, R=P.fanR, Rin=R*P.fanIn, tw=P.fanTwist*Math.PI/180;
const t=cumul(N,i=>Mm('spacing',(i+.5)/N,.5));
for(let i=0;i<=N;i++){ const u=i/N, Ri=R*Mm('length',u,1), twi=tw+Ma('twist',u,.5)*Math.PI/180;
for(let j=0;j<=PJ;j++){ const r=Rin+(Ri-Rin)*j/PJ, th=Math.PI/2-open/2+open*t[i]+twi*(j/PJ); setC(G,i,j,r*Math.cos(th),r*Math.sin(th)); } }
for(let i=1;i<N;i++) for(let j=0;j<PJ;j++) crease(G,'col',i,j,(i%2)?1:-1,1);
if(P.fanSeam) G.glues.push([0,N,0,PJ]);
return G;
}
function genCon(){
const N=Math.max(2,Math.round(P.conN)), PJ=4, G=makeG(N,PJ), H=P.conH;
const w=[]; for(let i=0;i<N;i++) w.push(P.conW*Mm('spacing',(i+.5)/N,.5));
const xs=[0]; for(let i=0;i<N;i++) xs.push(xs[i]+w[i]);
const base=i=>{ const T=P.conTilt;
if(P.conMode==='same') return T;
if(P.conMode==='grad') return T*(2*i/N-1);
if(P.conMode==='wave') return T*Math.sin(2*Math.PI*P.conFreq*i/N);
return T*((i%2)?1:-1); };
for(let i=0;i<=N;i++){
const u=i/N, Hi=H*Mm('length',u,.5), wn=Math.min(w[Math.max(0,i-1)],w[Math.min(N-1,i)]);
const tmax=.9*wn/Hi, t=clamp(Math.tan((base(i)+Ma('tilt',u,.5))*Math.PI/180),-tmax,tmax);
for(let j=0;j<=PJ;j++){ const y=(j/PJ-.5)*Hi; setC(G,i,j,xs[i]+t*y,y); } }
for(let i=1;i<N;i++) for(let j=0;j<PJ;j++) crease(G,'col',i,j,(i%2)?1:-1,1);
if(P.conRing){ G.glues.push([0,N,0,PJ]); G.wrap=true; }
return G;
}
function genMiura(){
const NI=Math.max(2,Math.round(P.miuCols)), NJ=Math.max(2,Math.round(P.miuRows)), G=makeG(NI,NJ);
const xs=[0]; for(let i=0;i<NI;i++) xs.push(xs[i]+P.miuA*Mm('spacing',(i+.5)/NI,.5));
const bh=[]; for(let j=0;j<NJ;j++) bh.push(P.miuB*Mm('rowh',.5,(j+.5)/NJ));
const ys=[0]; for(let j=0;j<NJ;j++) ys.push(ys[j]+bh[j]);
for(let i=0;i<=NI;i++) for(let j=0;j<=NJ;j++){
const lim=.8*Math.min(bh[Math.max(0,j-1)],bh[Math.min(NJ-1,j)]);
const S=clamp(P.miuS*(1+P.miuAmt*shape(P.miuField,i/NI,j/NJ,P.miuFreq))*Mm('zigzag',i/NI,j/NJ),0,lim);
setC(G,i,j,xs[i],ys[j]+((i%2)?S:0));
}
for(let j=1;j<NJ;j++) for(let i=0;i<NI;i++) crease(G,'row',i,j,(j%2)?1:-1,1);
for(let i=1;i<NI;i++) for(let j=0;j<NJ;j++) crease(G,'col',i,j,((j%2)?1:-1)*((i%2)?1:-1),.25);
return G;
}
// konzentrische Ringe (rund oder quadratisch) – Falze sind die Ringe selbst
function genRingLike(square){
const PJ=Math.max(2,Math.round(square?P.hpN:P.rgN)), N=square?8*Math.max(1,Math.round(P.hpSeg)):Math.max(12,Math.round(P.rgSeg));
const R=square?P.hpR:P.rgR, Rin=R*(square?P.hpIn:P.rgIn), G=makeG(N,PJ);
const t=cumul(PJ,j=>Mm('ringw',.5,(j+.5)/PJ));
for(let i=0;i<=N;i++){ const th=2*Math.PI*i/N, c=Math.cos(th), s=Math.sin(th), q=square?1/Math.max(Math.abs(c),Math.abs(s)):1;
for(let j=0;j<=PJ;j++){ const r=(Rin+(R-Rin)*t[j])*q*Mm('length',(i%N)/N,j/PJ); setC(G,i,j,r*c,r*s); } }
for(let j=1;j<PJ;j++) for(let i=0;i<N;i++) crease(G,'row',i,j,(j%2)?1:-1,1);
if(square){ const k=N/8; for(let m=0;m<4;m++){ const i=k*(2*m+1); for(let j=0;j<PJ;j++) crease(G,'col',i,j,(j%2)?1:-1,1); } }
G.glues.push([0,N,0,PJ]);
return G;
}
// Kugelsegmente (Globus-Gores): Streifen am Äquator verbunden, Seiten verkleben
function genGores(){
const N=Math.max(3,Math.round(P.goN)), PJ=2*Math.max(2,Math.round(P.goRows/2)), H=P.goH, cap=P.goCap, G=makeG(2*N-1,PJ);
const g=[]; for(let k=0;k<N;k++) g.push(2*H/N*Mm('gorew',(k+.5)/N,.5));
let x=0;
for(let k=0;k<N;k++){ const xc=x+g[k]/2, hk=Mm('length',(k+.5)/N,.5);
for(let j=0;j<=PJ;j++){ const t=-1+2*j/PJ, y=t*(1-cap)*H/2*hk, w=g[k]/2*Math.cos(Math.PI*t*(1-cap)/2);
setC(G,2*k,j,xc-w,y); setC(G,2*k+1,j,xc+w,y); }
x+=g[k];
if(k<N-1){ for(let j=0;j<PJ;j++) G.rem[(2*k+1)*PJ+j]=1; G.glues.push([2*k+1,2*k+2,0,PJ]); }
}
if(P.goClose){ G.glues.push([0,2*N-1,0,PJ]); G.wrap=true; }
return G;
}
const GENS={darts:genDarts,fan:genFan,con:genCon,miura:genMiura,rings:()=>genRingLike(false),hypar:()=>genRingLike(true),gores:genGores};
/* ---------- Cuts: Lochformen im Rapport ---------- */
let shapes=[], selShape=-1;
function sampleClosed(pts,per){
const n=pts.length, out=[];
for(let k=0;k<n;k++){ const p0=pts[(k-1+n)%n],p1=pts[k],p2=pts[(k+1)%n],p3=pts[(k+2)%n];
for(let s=0;s<per;s++){ const t=s/per,t2=t*t,t3=t2*t;
out.push([0.5*((2*p1[0])+(-p0[0]+p2[0])*t+(2*p0[0]-5*p1[0]+4*p2[0]-p3[0])*t2+(-p0[0]+3*p1[0]-3*p2[0]+p3[0])*t3),
0.5*((2*p1[1])+(-p0[1]+p2[1])*t+(2*p0[1]-5*p1[1]+4*p2[1]-p3[1])*t2+(-p0[1]+3*p1[1]-3*p2[1]+p3[1])*t3)]); } }
return out;
}
function bilin(G,i,j,a,b){
const p=ci(G,i,j),q=ci(G,i+1,j),r=ci(G,i+1,j+1),s=ci(G,i,j+1);
return [(1-a)*(1-b)*G.cx[p]+a*(1-b)*G.cx[q]+a*b*G.cx[r]+(1-a)*b*G.cx[s],
(1-a)*(1-b)*G.cy[p]+a*(1-b)*G.cy[q]+a*b*G.cy[r]+(1-a)*b*G.cy[s]];
}
function invBilin(G,i,j,x,y){
let a=.5,b=.5;
for(let it=0;it<12;it++){
const [fx,fy]=bilin(G,i,j,a,b), e=1e-4, [ax,ay]=bilin(G,i,j,a+e,b), [bx,by]=bilin(G,i,j,a,b+e);
const j11=(ax-fx)/e,j12=(bx-fx)/e,j21=(ay-fy)/e,j22=(by-fy)/e, det=j11*j22-j12*j21; if(Math.abs(det)<1e-12) break;
const rx=x-fx, ry=y-fy; a+=(j22*rx-j12*ry)/det; b+=(-j21*rx+j11*ry)/det;
}
return [a,b];
}
// Instanzen = Kacheln des Rapports. Jede hat map (lokal 0..1 → mm), inv, u/v für Modifikatoren
function cutTiles(G){
const out=[]; if(!P.cutOn) return out;
if(P.cutMode==='panel'){
const e=Math.max(1,Math.round(P.cutEvery));
for(let j=0;j<G.PJ;j+=e){ const off=(P.cutOffset&&e>1&&(j/e)%2)?Math.floor(e/2):0;
for(let i=off;i<G.PI;i+=e){ if(G.rem[i*G.PJ+j]) continue;
out.push({key:'p'+i+','+j, u:(i+.5)/G.PI, v:(j+.5)/G.PJ, map:(a,b)=>bilin(G,i,j,a,b), inv:(x,y)=>invBilin(G,i,j,x,y)}); } }
} else {
const bb=G.bb, dx=P.cutDX, dy=P.cutDY, bw=bb.maxx-bb.minx||1, bh=bb.maxy-bb.miny||1;
let r=0;
for(let ty=bb.miny-dy;ty<bb.maxy+dy;ty+=dy,r++){ const off=(P.cutOffset&&r%2)?dx/2:0;
for(let tx=bb.minx-dx+off;tx<bb.maxx+dx;tx+=dx){ const X0=tx,Y0=ty;
out.push({key:'g'+Math.round(tx)+','+Math.round(ty), u:(tx+dx/2-bb.minx)/bw, v:(ty+dy/2-bb.miny)/bh,
map:(a,b)=>[X0+a*dx,Y0+b*dy], inv:(x,y)=>[(x-X0)/dx,(y-Y0)/dy]}); } }
}
return out;
}
const rotC=(p,ang,sc)=>{ const c=Math.cos(ang),s=Math.sin(ang),x=(p[0]-.5)*sc,y=(p[1]-.5)*sc; return [.5+c*x-s*y,.5+s*x+c*y]; };
function tileScale(t){ return P.cutSize*Mm('hole',clamp(t.u,0,1),clamp(t.v,0,1)); }
function toLocal(t,x,y){ const q=t.inv(x,y), sc=tileScale(t); const c=Math.cos(-P.cutRot*Math.PI/180), s=Math.sin(-P.cutRot*Math.PI/180);
const dx=q[0]-.5, dy=q[1]-.5; return [.5+(c*dx-s*dy)/sc,.5+(s*dx+c*dy)/sc]; }
function cutPolys(G){
const tiles=cutTiles(G), polys=[];
for(const t of tiles){ const sc=tileScale(t), ang=P.cutRot*Math.PI/180;
shapes.forEach((sh,si)=>{ if(sh.pts.length<3) return;
const pts=sampleClosed(sh.pts.map(p=>rotC(p,ang,sc)),10).map(p=>t.map(p[0],p[1]));
const verts=sh.pts.map(p=>{ const q=rotC(p,ang,sc); return t.map(q[0],q[1]); });
let x0=1e9,x1=-1e9,y0=1e9,y1=-1e9; for(const [x,y] of pts){ x0=Math.min(x0,x); x1=Math.max(x1,x); y0=Math.min(y0,y); y1=Math.max(y1,y); }
polys.push({tile:t, si, pts, verts, bb:[x0,y0,x1,y1]}); }); }
return polys;
}
function inPoly(pts,x,y){ let c=false; for(let i=0,j=pts.length-1;i<pts.length;j=i++){ const [xi,yi]=pts[i],[xj,yj]=pts[j];
if(((yi>y)!==(yj>y))&&(x<(xj-xi)*(y-yi)/(yj-yi)+xi)) c=!c; } return c; }
function nearestOnPoly(pts,x,y){ let bd=1e18,bx=0,by=0;
for(let i=0,j=pts.length-1;i<pts.length;j=i++){ const [ax,ay]=pts[j],[cx,cy]=pts[i], ex=cx-ax, ey=cy-ay, l=ex*ex+ey*ey||1e-12;
const t=clamp(((x-ax)*ex+(y-ay)*ey)/l,0,1), px=ax+t*ex, py=ay+t*ey, d=(px-x)**2+(py-y)**2; if(d<bd){bd=d;bx=px;by=py;} }
return [bx,by,Math.sqrt(bd)]; }
/* ---------- Netz + Constraints ---------- */
let topo=null, foldT=0, paused=false, grab=null;
function build(reset){
const G=GENS[P.gen](), n=Math.max(1,Math.round(P.res)), PI=G.PI, PJ=G.PJ, NI=PI*n, NJ=PJ*n;
let area=0;
for(let i=0;i<PI;i++) for(let j=0;j<PJ;j++){ if(G.rem[i*PJ+j]) continue;
const a=ci(G,i,j),b=ci(G,i+1,j),c=ci(G,i+1,j+1),d=ci(G,i,j+1), X=G.cx, Y=G.cy;
area+=(X[a]*Y[b]-X[b]*Y[a])+(X[b]*Y[c]-X[c]*Y[b])+(X[c]*Y[d]-X[d]*Y[c])+(X[d]*Y[a]-X[a]*Y[d]); }
if(area<0) for(let k=0;k<G.cx.length;k++) G.cx[k]=-G.cx[k];
let minx=1e9,maxx=-1e9,miny=1e9,maxy=-1e9;
for(let k=0;k<G.cx.length;k++){ minx=Math.min(minx,G.cx[k]); maxx=Math.max(maxx,G.cx[k]); miny=Math.min(miny,G.cy[k]); maxy=Math.max(maxy,G.cy[k]); }
G.bb={minx,maxx,miny,maxy}; const mx=(minx+maxx)/2, my=(miny+maxy)/2, bw=maxx-minx||1, bh=maxy-miny||1;
const nodeXY=(I,J)=>{ const i=Math.min(Math.floor(I/n),PI-1), j=Math.min(Math.floor(J/n),PJ-1); return bilin(G,i,j,(I-i*n)/n,(J-j*n)/n); };
// Zellen: 0 = Material, 1 = Paneel entfernt, 2 = Cut
const polys=cutPolys(G); G.polys=polys;
const cellR=new Uint8Array(NI*NJ);
for(let I=0;I<NI;I++) for(let J=0;J<NJ;J++){
if(G.rem[Math.floor(I/n)*PJ+Math.floor(J/n)]){ cellR[I*NJ+J]=1; continue; }
if(!polys.length) continue;
const [x,y]=nodeXY(I+.5,J+.5);
for(const p of polys){ const b=p.bb; if(x<b[0]||x>b[2]||y<b[1]||y>b[3]) continue; if(inPoly(p.pts,x,y)){ cellR[I*NJ+J]=2; break; } }
}
const cellEx=(I,J)=>I>=0&&J>=0&&I<NI&&J<NJ&&cellR[I*NJ+J]===0;
const cellCut=(I,J)=>I>=0&&J>=0&&I<NI&&J<NJ&&cellR[I*NJ+J]===2;
const nid=new Int32Array((NI+1)*(NJ+1)).fill(-1), key=(I,J)=>I*(NJ+1)+J;
const nI=[],nJ=[],rx=[],ry=[]; let np=0;
for(let I=0;I<=NI;I++) for(let J=0;J<=NJ;J++){
if(!(cellEx(I-1,J-1)||cellEx(I,J-1)||cellEx(I-1,J)||cellEx(I,J))) continue;
let [x,y]=nodeXY(I,J);
// Knoten am Lochrand auf die Kurve ziehen
if(polys.length&&(cellCut(I-1,J-1)||cellCut(I,J-1)||cellCut(I-1,J)||cellCut(I,J))){
const [x2,y2]=nodeXY(Math.min(I+1,NI),J), [x3,y3]=nodeXY(I,Math.min(J+1,NJ)), hcell=Math.min(Math.hypot(x2-x,y2-y)||1e9,Math.hypot(x3-x,y3-y)||1e9);
let best=null;
for(const p of polys){ const b=p.bb; if(x<b[0]-hcell||x>b[2]+hcell||y<b[1]-hcell||y>b[3]+hcell) continue;
const q=nearestOnPoly(p.pts,x,y); if(!best||q[2]<best[2]) best=q; }
if(best&&best[2]<.45*hcell){ x=best[0]; y=best[1]; }
}
nid[key(I,J)]=np++; nI.push(I); nJ.push(J); rx.push(x); ry.push(y);
}
const warm=!reset&&topo&&topo.NI===NI&&topo.NJ===NJ;
const pos=new Float32Array(np*3), inv=new Float32Array(np).fill(1), uv=new Float32Array(np*2);
const dmax=Math.hypot(bw,bh)/2;
for(let k=0;k<np;k++){
const ox=warm?topo.nid[key(nI[k],nJ[k])]:-1;
if(ox>=0){ pos[k*3]=topo.pos[ox*3]; pos[k*3+1]=topo.pos[ox*3+1]; pos[k*3+2]=topo.pos[ox*3+2]; }
else if(G.wrap){ // geschlossene Streifen starten als Zylinder, damit sie sich zur Röhre/Kugel schließen statt flach zu stapeln
const a=2*Math.PI*(rx[k]-minx)/bw, Rc=bw/(2*Math.PI)*W3; pos[k*3]=Rc*Math.sin(a); pos[k*3+1]=(ry[k]-my)*W3; pos[k*3+2]=Rc*Math.cos(a); }
else { const dx=rx[k]-mx, dy=ry[k]-my; pos[k*3]=dx*W3; pos[k*3+1]=dy*W3; pos[k*3+2]=.02*(1-(dx*dx+dy*dy)/(dmax*dmax)); }
uv[k*2]=rx[k]/100; uv[k*2+1]=ry[k]/100;
}
const prev=new Float32Array(pos);
const L2=(a,b)=>Math.hypot(rx[a]-rx[b],ry[a]-ry[b])*W3;
const tris=[];
for(let I=0;I<NI;I++) for(let J=0;J<NJ;J++){
if(!cellEx(I,J)) continue;
const a=nid[key(I,J)], b=nid[key(I+1,J)], c=nid[key(I+1,J+1)], d=nid[key(I,J+1)];
if(L2(a,c)<=L2(b,d)) tris.push(a,b,c, a,c,d); else tris.push(a,b,d, b,c,d);
}
const em=new Map(), sA=[],sB=[],sR=[];
for(let t=0;t<tris.length;t+=3){
const v=[tris[t],tris[t+1],tris[t+2]];
for(let e=0;e<3;e++){ const a=v[e], b=v[(e+1)%3], o=v[(e+2)%3], k=a<b?a*np+b:b*np+a;
const ent=em.get(k);
if(!ent){ em.set(k,{h0:a,h1:b,o1:o,o2:-1}); sA.push(a); sB.push(b); sR.push(L2(a,b)); }
else ent.o2=o; }
}
const hA=[],hB=[],hC=[],hD=[],hS=[],hW=[],hU=[],hV=[];
for(const e of em.values()){
if(e.o2<0) continue;
const a=e.h0,b=e.h1; let s=0,w=0;
if(nI[a]===nI[b]&&nI[a]%n===0&&Math.abs(nJ[a]-nJ[b])===1){ const i=nI[a]/n, j=Math.floor(Math.min(nJ[a],nJ[b])/n); s=G.col[i*PJ+j]; w=G.colW[i*PJ+j]; }
else if(nJ[a]===nJ[b]&&nJ[a]%n===0&&Math.abs(nI[a]-nI[b])===1){ const j=nJ[a]/n, i=Math.floor(Math.min(nI[a],nI[b])/n); s=G.row[j*PI+i]; w=G.rowW[j*PI+i]; }
hA.push(a);hB.push(b);hC.push(e.o1);hD.push(e.o2);hS.push(s);hW.push(s?w:0);
hU.push((nI[a]+nI[b])/2/NI); hV.push((nJ[a]+nJ[b])/2/NJ);
}
const gA=[],gB=[],gR=[];
for(const [la,lb,j0,j1] of G.glues) for(let J=j0*n;J<=j1*n;J++){
const a=nid[key(la*n,J)], b=nid[key(lb*n,J)]; if(a<0||b<0||a===b) continue;
gA.push(a); gB.push(b); gR.push(L2(a,b)); }
topo={G,n,NI,NJ,np,nid,pos,prev,inv,uv,tris,
sA:Int32Array.from(sA),sB:Int32Array.from(sB),sR:Float32Array.from(sR),
hA:Int32Array.from(hA),hB:Int32Array.from(hB),hC:Int32Array.from(hC),hD:Int32Array.from(hD),
hS:Int8Array.from(hS),hW:Float32Array.from(hW),hU:Float32Array.from(hU),hV:Float32Array.from(hV),hT:new Float32Array(hA.length),
gA:Int32Array.from(gA),gB:Int32Array.from(gB),gR:Float32Array.from(gR),
rx:Float32Array.from(rx),ry:Float32Array.from(ry),floorY:-(bh/2)*W3-.35};
foldT=warm?1:0; grab=null;
applyPins(); updateTargets(); makeGeometry(); updateStats(); draw2D();
if(!warm) frameCamera();
}
function updateTargets(){
const T=topo; if(!T) return;
const g=P.form*foldT;
for(let h=0;h<T.hA.length;h++) T.hT[h]=T.hS[h]?T.hS[h]*angleAt(T.hU[h],T.hV[h])*g*Math.PI/180:0;
}
function applyPins(){
const T=topo; if(!T) return; T.inv.fill(1);
if(P.pin==='center'){ const bb=T.G.bb, cx=(bb.minx+bb.maxx)/2, cy=(bb.miny+bb.maxy)/2;
let bd=1e9; for(let k=0;k<T.np;k++) bd=Math.min(bd,Math.hypot(T.rx[k]-cx,T.ry[k]-cy));
for(let k=0;k<T.np;k++) if(Math.hypot(T.rx[k]-cx,T.ry[k]-cy)<=bd+12) T.inv[k]=0; }
if(P.pin==='edge'){ let mn=1e9; for(let k=0;k<T.np;k++) mn=Math.min(mn,T.rx[k]); for(let k=0;k<T.np;k++) if(T.rx[k]<mn+.5) T.inv[k]=0; }
if(grab) T.inv[grab.id]=0;
}
/* ---------- Simulation (PBD: Stäbe + Scharniere + Klebung) ---------- */
function step(){
const T=topo; if(!T) return;
const pos=T.pos, prv=T.prev, inv=T.inv, np=T.np;
const dt=1/60, g=-P.gravity*12*dt*dt, dmp=1-P.damp;
for(let k=0;k<np;k++){ if(inv[k]===0) continue; const o=k*3;
for(let d=0;d<3;d++){ const x=pos[o+d], v=(x-prv[o+d])*dmp; prv[o+d]=x; pos[o+d]=x+v; }
pos[o+1]+=g; }
const it=P.iters, kk=x=>1-Math.pow(1-clamp(x,0,.999),1/it);
const ks=kk(P.stretch), kp=kk(bendEff()), kf=kk(P.fold), kfP=kk(P.fold*.25), kg=kk(.995);
const sA=T.sA,sB=T.sB,sR=T.sR, ns=sA.length;
const hA=T.hA,hB=T.hB,hC=T.hC,hD=T.hD,hW=T.hW,hT=T.hT, nh=hA.length;
const gA=T.gA,gB=T.gB,gR=T.gR, ng=gA.length, gl=1-clamp(P.glue*P.form*foldT,0,1);
for(let n=0;n<it;n++){
for(let s=0;s<ns;s++){
const a=sA[s], b=sB[s], wa=inv[a], wb=inv[b], w=wa+wb; if(w===0) continue;
const ao=a*3, bo=b*3, dx=pos[bo]-pos[ao], dy=pos[bo+1]-pos[ao+1], dz=pos[bo+2]-pos[ao+2];
const d=Math.sqrt(dx*dx+dy*dy+dz*dz); if(d<1e-9) continue;
const c=ks*(d-sR[s])/(d*w);
pos[ao]+=wa*c*dx; pos[ao+1]+=wa*c*dy; pos[ao+2]+=wa*c*dz;
pos[bo]-=wb*c*dx; pos[bo+1]-=wb*c*dy; pos[bo+2]-=wb*c*dz;
}
for(let h=0;h<nh;h++){
const k=hW[h]===0?kp:(hW[h]>=1?kf:kfP);
const i3=hA[h], i4=hB[h], i1=hC[h], i2=hD[h];
const w1=inv[i1],w2=inv[i2],w3=inv[i3],w4=inv[i4];
const p1=i1*3,p2=i2*3,p3=i3*3,p4=i4*3;
const ex=pos[p4]-pos[p3], ey=pos[p4+1]-pos[p3+1], ez=pos[p4+2]-pos[p3+2], el=Math.sqrt(ex*ex+ey*ey+ez*ez); if(el<1e-9) continue;
const a1x=pos[p1]-pos[p3],a1y=pos[p1+1]-pos[p3+1],a1z=pos[p1+2]-pos[p3+2];
const b1x=pos[p1]-pos[p4],b1y=pos[p1+1]-pos[p4+1],b1z=pos[p1+2]-pos[p4+2];
const a2x=pos[p2]-pos[p4],a2y=pos[p2+1]-pos[p4+1],a2z=pos[p2+2]-pos[p4+2];
const b2x=pos[p2]-pos[p3],b2y=pos[p2+1]-pos[p3+1],b2z=pos[p2+2]-pos[p3+2];
const N1x=a1y*b1z-a1z*b1y, N1y=a1z*b1x-a1x*b1z, N1z=a1x*b1y-a1y*b1x;
const N2x=a2y*b2z-a2z*b2y, N2y=a2z*b2x-a2x*b2z, N2z=a2x*b2y-a2y*b2x;
const q1=N1x*N1x+N1y*N1y+N1z*N1z, q2=N2x*N2x+N2y*N2y+N2z*N2z; if(q1<1e-16||q2<1e-16) continue;
const r1=Math.sqrt(q1), r2=Math.sqrt(q2);
const n1x=N1x/r1,n1y=N1y/r1,n1z=N1z/r1, n2x=N2x/r2,n2y=N2y/r2,n2z=N2z/r2;
const cs=n1x*n2x+n1y*n2y+n1z*n2z;
const sn=((n1y*n2z-n1z*n2y)*ex+(n1z*n2x-n1x*n2z)*ey+(n1x*n2y-n1y*n2x)*ez)/el;
let C=Math.atan2(sn,cs)-hT[h]; if(C>Math.PI) C-=2*Math.PI; if(C<-Math.PI) C+=2*Math.PI;
if(Math.abs(C)<1e-6) continue;
const f1=el/q1, f2=el/q2;
const da=(b1x*ex+b1y*ey+b1z*ez)/el, db=(a2x*ex+a2y*ey+a2z*ez)/el, dc=(a1x*ex+a1y*ey+a1z*ez)/el, dd=(b2x*ex+b2y*ey+b2z*ez)/el;
const g1x=-f1*N1x,g1y=-f1*N1y,g1z=-f1*N1z;
const g2x=-f2*N2x,g2y=-f2*N2y,g2z=-f2*N2z;
const g3x=-(da*N1x/q1+db*N2x/q2), g3y=-(da*N1y/q1+db*N2y/q2), g3z=-(da*N1z/q1+db*N2z/q2);
const g4x=(dc*N1x/q1+dd*N2x/q2), g4y=(dc*N1y/q1+dd*N2y/q2), g4z=(dc*N1z/q1+dd*N2z/q2);
const den=w1*(g1x*g1x+g1y*g1y+g1z*g1z)+w2*(g2x*g2x+g2y*g2y+g2z*g2z)+w3*(g3x*g3x+g3y*g3y+g3z*g3z)+w4*(g4x*g4x+g4y*g4y+g4z*g4z);
if(den<1e-12) continue;
const s=-k*C/den;
pos[p1]+=s*w1*g1x; pos[p1+1]+=s*w1*g1y; pos[p1+2]+=s*w1*g1z;
pos[p2]+=s*w2*g2x; pos[p2+1]+=s*w2*g2y; pos[p2+2]+=s*w2*g2z;
pos[p3]+=s*w3*g3x; pos[p3+1]+=s*w3*g3y; pos[p3+2]+=s*w3*g3z;
pos[p4]+=s*w4*g4x; pos[p4+1]+=s*w4*g4y; pos[p4+2]+=s*w4*g4z;
}
for(let q=0;q<ng;q++){
const a=gA[q], b=gB[q], wa=inv[a], wb=inv[b], w=wa+wb; if(w===0) continue;
const ao=a*3, bo=b*3, dx=pos[bo]-pos[ao], dy=pos[bo+1]-pos[ao+1], dz=pos[bo+2]-pos[ao+2];
const d=Math.sqrt(dx*dx+dy*dy+dz*dz), tgt=gR[q]*gl; if(d<1e-9||d<=tgt) continue;
const c=kg*(d-tgt)/(d*w);
pos[ao]+=wa*c*dx; pos[ao+1]+=wa*c*dy; pos[ao+2]+=wa*c*dz;
pos[bo]-=wb*c*dx; pos[bo+1]-=wb*c*dy; pos[bo+2]-=wb*c*dz;
}
if(P.gravity>0){ const fy=T.floorY; for(let k=0;k<np;k++){ const o=k*3;
if(pos[o+1]<fy){ pos[o+1]=fy; prv[o]=pos[o]-(pos[o]-prv[o])*.3; prv[o+2]=pos[o+2]-(pos[o+2]-prv[o+2])*.3; } } }
}
}
/* ---------- 3D ---------- */
const v3d=document.getElementById('v3d');
const renderer=new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(2,devicePixelRatio));
renderer.outputColorSpace=THREE.SRGBColorSpace;
renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.05;
renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
v3d.prepend(renderer.domElement);
const scene=new THREE.Scene(); scene.background=new THREE.Color('#ffffff');
const camera=new THREE.PerspectiveCamera(38,1,.01,100); camera.position.set(2.2,1.6,3.8);
const controls=new OrbitControls(camera,renderer.domElement); controls.enableDamping=true;
scene.add(new THREE.HemisphereLight(0xffffff,0xdcd8cf,1.1));
const sun=new THREE.DirectionalLight(0xffffff,2.2); sun.position.set(2.5,4,3); sun.castShadow=true;
sun.shadow.mapSize.set(2048,2048); Object.assign(sun.shadow.camera,{left:-3.5,right:3.5,top:3.5,bottom:-3.5,near:.1,far:15});
sun.shadow.bias=-.0004; sun.shadow.normalBias=.02; scene.add(sun);
const fillL=new THREE.DirectionalLight(0xffffff,.6); fillL.position.set(-3,1,-2); scene.add(fillL);
const ground=new THREE.Mesh(new THREE.PlaneGeometry(40,40),new THREE.ShadowMaterial({opacity:.1}));
ground.rotation.x=-Math.PI/2; ground.receiveShadow=true; scene.add(ground);
/* Noise-Normal-Map (kachelbar) */
function makeNormalTex(type){
const S=256, h=new Float32Array(S*S);
const pn=(x,y,per,seed)=>{ const xi=Math.floor(x), yi=Math.floor(y), fx=x-xi, fy=y-yi, s=t=>t*t*(3-2*t), w=a=>((a%per)+per)%per;
const H=(a,b)=>hash(w(a)*57+w(b)*131,seed), a=H(xi,yi), b=H(xi+1,yi), c=H(xi,yi+1), d=H(xi+1,yi+1), u=s(fx), v=s(fy);
return (a*(1-u)+b*u)*(1-v)+(c*(1-u)+d*u)*v; };
const fbm=(u,v,fx,fy,oct,seed)=>{ let s=0,a=1,t=0; for(let o=0;o<oct;o++){ const kx=fx<<o, ky=fy<<o; s+=a*pn(u*kx,v*ky,kx,ky===kx?kx:ky,seed+o); t+=a; a*=.5; } return s/t; };
const pn2=(u,v,kx,ky,seed)=>{ // anisotrop kachelbar
const x=u*kx, y=v*ky, xi=Math.floor(x), yi=Math.floor(y), fx=x-xi, fy=y-yi, s=t=>t*t*(3-2*t);
const H=(a,b)=>hash(((a%kx)+kx)%kx*57+((b%ky)+ky)%ky*131,seed);
const a=H(xi,yi), b=H(xi+1,yi), c=H(xi,yi+1), d=H(xi+1,yi+1), uu=s(fx), vv=s(fy);
return (a*(1-uu)+b*uu)*(1-vv)+(c*(1-uu)+d*uu)*vv; };
const aniso=(u,v,kx,ky,oct,seed)=>{ let s=0,a=1,t=0; for(let o=0;o<oct;o++){ s+=a*pn2(u,v,kx<<o,ky<<o,seed+o); t+=a; a*=.55; } return s/t; };
for(let y=0;y<S;y++) for(let x=0;x<S;x++){ const u=x/S, v=y/S; let val;
switch(type){
case 'grain': val=aniso(u,v,16,16,4,3)*.6+aniso(u,v,64,64,2,9)*.4; break;
case 'clouds': val=aniso(u,v,4,4,5,5); break;
case 'ribs': val=.5+.5*Math.sin(2*Math.PI*(u*16+.08*aniso(u,v,4,4,3,2))); break;
case 'wood': val=.5+.5*Math.sin(2*Math.PI*(v*6+1.6*aniso(u,v,2,8,4,7))); val=val*.8+aniso(u,v,4,64,2,11)*.2; break;
default: val=aniso(u,v,4,48,4,1)*.7+aniso(u,v,32,32,2,4)*.3; // Fasern
}
h[y*S+x]=val; }
const c=document.createElement('canvas'); c.width=c.height=S; const g=c.getContext('2d'), img=g.createImageData(S,S);
for(let y=0;y<S;y++) for(let x=0;x<S;x++){
const dx=(h[y*S+(x+1)%S]-h[y*S+(x-1+S)%S])*2.5, dy=(h[((y+1)%S)*S+x]-h[((y-1+S)%S)*S+x])*2.5;
const l=Math.hypot(dx,dy,1), k=(y*S+x)*4;
img.data[k]=(-dx/l*.5+.5)*255; img.data[k+1]=(-dy/l*.5+.5)*255; img.data[k+2]=(1/l*.5+.5)*255; img.data[k+3]=255; }
g.putImageData(img,0,0);
const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.colorSpace=THREE.NoColorSpace; return t;
}
const matF=new THREE.MeshPhysicalMaterial({side:THREE.FrontSide,clearcoatRoughness:.25});
const matB=new THREE.MeshPhysicalMaterial({side:THREE.BackSide,clearcoatRoughness:.25});
matF.shadowSide=THREE.DoubleSide;
let nmTex=null, nmTypeCur=null;
let geo=new THREE.BufferGeometry(), posAttr=null;
const meshF=new THREE.Mesh(geo,matF), meshB=new THREE.Mesh(geo,matB);
for(const m of [meshF,meshB]){ m.receiveShadow=true; m.frustumCulled=false; scene.add(m); }
meshF.castShadow=true;
function makeGeometry(){
geo.dispose(); geo=new THREE.BufferGeometry();
posAttr=new THREE.BufferAttribute(topo.pos,3); posAttr.setUsage(THREE.DynamicDrawUsage);
geo.setAttribute('position',posAttr);
geo.setAttribute('uv',new THREE.BufferAttribute(topo.uv,2));
geo.setIndex(topo.tris); geo.computeVertexNormals();
meshF.geometry=geo; meshB.geometry=geo;
ground.position.y=topo.floorY-.01;
}
function frameCamera(){
if(!topo) return; const p=topo.pos; let cx=0,cy=0,cz=0; for(let k=0;k<topo.np;k++){ cx+=p[k*3]; cy+=p[k*3+1]; cz+=p[k*3+2]; }
cx/=topo.np; cy/=topo.np; cz/=topo.np; let r=0; for(let k=0;k<topo.np;k++) r=Math.max(r,Math.hypot(p[k*3]-cx,p[k*3+1]-cy,p[k*3+2]-cz));
const dir=camera.position.clone().sub(controls.target).normalize();
controls.target.set(cx,cy,cz); const th=Math.tan(camera.fov*Math.PI/360)*Math.min(1,camera.aspect); camera.position.copy(controls.target).addScaledVector(dir,Math.max(.8,r/th*1.25));
}
let lastFlat=null, lastTrans=null;
function applyLook(){
if(nmTypeCur!==P.nmType){ if(nmTex) nmTex.dispose(); nmTex=makeNormalTex(P.nmType); nmTypeCur=P.nmType; matF.normalMap=nmTex; matB.normalMap=nmTex; matF.needsUpdate=matB.needsUpdate=true; }
nmTex.repeat.set(P.nmScale,P.nmScale);
for(const m of [matF,matB]){ m.roughness=P.rough; m.metalness=P.metal; m.clearcoat=P.clear; m.opacity=P.opacity; m.normalScale.set(P.nmStr,P.nmStr); }
matF.color.set(P.front); matB.color.set(P.back);
const tr=P.opacity<.999;
if(tr!==lastTrans||P.flat!==lastFlat){
for(const m of [matF,matB]){ m.transparent=tr; m.depthWrite=!tr||P.opacity>.6; m.flatShading=P.flat; m.needsUpdate=true; }
lastTrans=tr; lastFlat=P.flat;
}
draw2D();
}
function resize3D(){ const w=v3d.clientWidth, h=v3d.clientHeight; if(!w||!h) return; renderer.setSize(w,h); camera.aspect=w/h; camera.updateProjectionMatrix(); }
const ray=new THREE.Raycaster(), ndc=new THREE.Vector2(), tmpV=new THREE.Vector3();
function setNdc(e){ const r=renderer.domElement.getBoundingClientRect(); ndc.set(((e.clientX-r.left)/r.width)*2-1,-((e.clientY-r.top)/r.height)*2+1); }
renderer.domElement.addEventListener('pointerdown',e=>{
if(!topo||e.button!==0) return;
setNdc(e); ray.setFromCamera(ndc,camera); geo.computeBoundingSphere();
const hit=ray.intersectObjects([meshF,meshB],false)[0]; if(!hit) return;
const f=hit.face; let best=-1, bd=1e9;
for(const id of [f.a,f.b,f.c]){ const d=tmpV.set(topo.pos[id*3],topo.pos[id*3+1],topo.pos[id*3+2]).distanceToSquared(hit.point); if(d<bd){bd=d;best=id;} }
controls.enabled=false;
const plane=new THREE.Plane().setFromNormalAndCoplanarPoint(camera.getWorldDirection(new THREE.Vector3()).negate(),hit.point);
grab={id:best, plane, off:new THREE.Vector3(topo.pos[best*3],topo.pos[best*3+1],topo.pos[best*3+2]).sub(hit.point)};
topo.inv[best]=0;
renderer.domElement.setPointerCapture(e.pointerId);
},{capture:true});
renderer.domElement.addEventListener('pointermove',e=>{
if(!grab||!topo) return;
setNdc(e); ray.setFromCamera(ndc,camera);
if(!ray.ray.intersectPlane(grab.plane,tmpV)) return;
tmpV.add(grab.off); const o=grab.id*3;
topo.pos[o]=tmpV.x; topo.pos[o+1]=tmpV.y; topo.pos[o+2]=tmpV.z;
topo.prev[o]=tmpV.x; topo.prev[o+1]=tmpV.y; topo.prev[o+2]=tmpV.z;
});
const release=()=>{ if(!grab) return; grab=null; applyPins(); controls.enabled=true; };
renderer.domElement.addEventListener('pointerup',release);
renderer.domElement.addEventListener('pointercancel',release);
/* ---------- 2D-Zuschnitt + CUTMODE ---------- */
const v2d=document.getElementById('v2d'), c2d=document.getElementById('c2d'), ctx=c2d.getContext('2d'), hint2d=document.getElementById('hint2d');
let view={s:1,ox:0,oy:0,minx:0,miny:0}, cutMode=false, draft=null, drag=null, hoverPt=null, hoverKey=null;
function resize2D(){ const r=c2d.getBoundingClientRect(), d=devicePixelRatio||1; c2d.width=Math.max(1,r.width*d); c2d.height=Math.max(1,r.height*d); draw2D(); }
const toS=(x,y)=>[view.ox+(x-view.minx)*view.s, view.oy-(y-view.miny)*view.s];
const toMM=(sx,sy)=>[(sx-view.ox)/view.s+view.minx, (view.oy-sy)/view.s+view.miny];
function patternLines(){
const G=topo.G, PI=G.PI, PJ=G.PJ, ex=(i,j)=>i>=0&&j>=0&&i<PI&&j<PJ&&!G.rem[i*PJ+j];
const C=(i,j)=>[G.cx[ci(G,i,j)],G.cy[ci(G,i,j)]];
const glueSeg=new Map();
for(const [a,b,j0,j1] of G.glues) for(let j=j0;j<j1;j++){ glueSeg.set(a+','+j,b); glueSeg.set(b+','+j,a); }
const out={cuts:[],glue:[],mtn:[],val:[],mtnP:[],valP:[]};
const same=(p,q)=>Math.hypot(p[0]-q[0],p[1]-q[1])<1e-3;
for(let i=0;i<=PI;i++) for(let j=0;j<PJ;j++){
const L=ex(i-1,j), R=ex(i,j), seg=[...C(i,j),...C(i,j+1)];
if(L&&R){ const s=G.col[i*PJ+j]; if(s){ const w=G.colW[i*PJ+j]>=1; (s>0?(w?out.mtn:out.mtnP):(w?out.val:out.valP)).push(seg); } }
else if(L||R){
const pb=glueSeg.get(i+','+j);
if(pb!==undefined){ if(same(C(i,j),C(pb,j))&&same(C(i,j+1),C(pb,j+1))) continue; out.glue.push(seg); }
else out.cuts.push(seg);
}
}
for(let j=0;j<=PJ;j++) for(let i=0;i<PI;i++){
const B=ex(i,j-1), T=ex(i,j), seg=[...C(i,j),...C(i+1,j)];
if(B&&T){ const s=G.row[j*PI+i]; if(s){ const w=G.rowW[j*PI+i]>=1; (s>0?(w?out.mtn:out.mtnP):(w?out.val:out.valP)).push(seg); } }
else if(B||T) out.cuts.push(seg);
}
return out;
}
function materialPath(){
const G=topo.G, PI=G.PI, PJ=G.PJ, path=new Path2D();
for(let i=0;i<PI;i++) for(let j=0;j<PJ;j++){ if(G.rem[i*PJ+j]) continue;
const q=[[i,j],[i+1,j],[i+1,j+1],[i,j+1]].map(([a,b])=>toS(G.cx[ci(G,a,b)],G.cy[ci(G,a,b)]));
path.moveTo(...q[0]); path.lineTo(...q[1]); path.lineTo(...q[2]); path.lineTo(...q[3]); path.closePath(); }
return path;
}
function draw2D(){
if(!topo) return;
const d=devicePixelRatio||1, W=c2d.width/d, H=c2d.height/d, G=topo.G, bb=G.bb;
ctx.setTransform(d,0,0,d,0,0); ctx.clearRect(0,0,W,H);
const m=44, bw=bb.maxx-bb.minx||1, bh=bb.maxy-bb.miny||1;
const s=Math.max(.05,Math.min((W-2*m)/bw,(H-2*m-16)/bh));
view={s, ox:(W-bw*s)/2, oy:(H+bh*s)/2-8, minx:bb.minx, miny:bb.miny};
const PI=G.PI, PJ=G.PJ, mat=materialPath();
ctx.fillStyle=P.front; ctx.fill(mat); ctx.strokeStyle=P.front; ctx.lineWidth=.8; ctx.stroke(mat);
if(P.showField){
for(let i=0;i<PI;i++) for(let j=0;j<PJ;j++){ if(G.rem[i*PJ+j]) continue;
const q=[[i,j],[i+1,j],[i+1,j+1],[i,j+1]].map(([a,b])=>toS(G.cx[ci(G,a,b)],G.cy[ci(G,a,b)]));
ctx.fillStyle=`rgba(47,111,224,${(angleAt((i+.5)/PI,(j+.5)/PJ)/170*.4).toFixed(3)})`;
ctx.beginPath(); ctx.moveTo(...q[0]); ctx.lineTo(...q[1]); ctx.lineTo(...q[2]); ctx.lineTo(...q[3]); ctx.closePath(); ctx.fill(); }
}
const L=patternLines();
const draw=(list,col,dash,lw,alpha)=>{ ctx.globalAlpha=alpha; ctx.strokeStyle=col; ctx.setLineDash(dash); ctx.lineWidth=lw; ctx.beginPath();
for(const [x0,y0,x1,y1] of list){ ctx.moveTo(...toS(x0,y0)); ctx.lineTo(...toS(x1,y1)); } ctx.stroke(); ctx.globalAlpha=1; ctx.setLineDash([]); };
draw(L.mtnP,'#2f6fe0',[4,4],1,.4); draw(L.valP,'#e0503a',[2,3],1,.4);
draw(L.mtn,'#2f6fe0',[6,4],1.3,1); draw(L.val,'#e0503a',[2,3],1.3,1);
// Löcher (nur innerhalb des Materials)
const polys=G.polys||[];
if(polys.length){
ctx.save(); ctx.clip(mat);
for(const p of polys){ ctx.beginPath(); p.pts.forEach(([x,y],k)=>k?ctx.lineTo(...toS(x,y)):ctx.moveTo(...toS(x,y))); ctx.closePath();
ctx.fillStyle='#fff'; ctx.fill(); ctx.strokeStyle=(cutMode&&p.si===selShape)?'#e8572a':'#111'; ctx.lineWidth=(cutMode&&p.si===selShape)?1.8:1.2; ctx.stroke(); }
ctx.restore();
}
draw(L.cuts,'#111',[],1.4,1); draw(L.glue,'#e8572a',[],1.8,1);
if(cutMode){
ctx.fillStyle='#fff'; ctx.strokeStyle='#111'; ctx.lineWidth=1;
for(const p of polys){ if(p.tile.key!==hoverKey) continue; for(const [x,y] of p.verts){ const [sx,sy]=toS(x,y); ctx.fillRect(sx-3,sy-3,6,6); ctx.strokeRect(sx-3,sy-3,6,6); } }
if(draft&&topo){
const t=draft.tile, sc=tileScale(t), ang=P.cutRot*Math.PI/180;
const pts=draft.pts.map(p=>{ const q=rotC(p,ang,sc); return toS(...t.map(q[0],q[1])); });
ctx.strokeStyle='#e8572a'; ctx.lineWidth=1.5; ctx.setLineDash([4,3]); ctx.beginPath();
pts.forEach((p,k)=>k?ctx.lineTo(...p):ctx.moveTo(...p)); if(hoverPt) ctx.lineTo(...hoverPt); ctx.stroke(); ctx.setLineDash([]);
for(const [sx,sy] of pts){ ctx.fillStyle='#e8572a'; ctx.fillRect(sx-3,sy-3,6,6); }
}
}
ctx.fillStyle='#8a8a8a'; ctx.font='11px system-ui,sans-serif'; ctx.textAlign='center';
ctx.fillText(`${Math.round(bw)} × ${Math.round(bh)} mm`,W/2,view.oy+18);
hint2d.textContent=cutMode?(draft?'Punkte setzen · auf ersten Punkt klicken oder Enter schließt · Esc bricht ab'
:(P.cutMode==='panel'?'Klicken zeichnet eine Lochform – sie wird in jede Paneelmitte gesetzt':'Klicken zeichnet eine Lochform – sie wiederholt sich im Raster')+' · Punkte der Kachel unter dem Cursor ziehen · Form anklicken + Entf löscht'):'';
}
function setCutMode(on){ cutMode=on; draft=null; drag=null; document.getElementById('cutBtn').classList.toggle('on',on); v2d.classList.toggle('cut',on); if(on) document.body.classList.remove('open'); draw2D(); }
document.getElementById('cutBtn').addEventListener('click',()=>setCutMode(!cutMode));
function tileAt(x,y){
const G=topo.G, tiles=cutTiles(G);
if(P.cutMode==='panel'){
for(const t of tiles){ const [a,b]=t.inv(x,y); if(a>=-.02&&a<=1.02&&b>=-.02&&b<=1.02) return t; }
let best=null,bd=1e9; for(const t of tiles){ const [cx,cy]=t.map(.5,.5), d=Math.hypot(cx-x,cy-y); if(d<bd){bd=d;best=t;} } return best;
}
for(const t of tiles){ const [a,b]=t.inv(x,y); if(a>=0&&a<1&&b>=0&&b<1) return t; }
return tiles[0]||null;
}
let rbT=0, lastRb=0;
function rebuildSoon(ms=40){ clearTimeout(rbT); rbT=setTimeout(()=>{ lastRb=performance.now(); build(false); },ms); }
function closeDraft(){ if(draft&&draft.pts.length>=3){
if(P.cutMode==='panel'){ // pro Paneel: Form in die Paneelmitte setzen, damit sie nicht über Falze ragt
let cx=0,cy=0; for(const p of draft.pts){ cx+=p[0]; cy+=p[1]; } cx/=draft.pts.length; cy/=draft.pts.length;
draft.pts=draft.pts.map(p=>[p[0]-cx+.5,p[1]-cy+.5]); }
shapes.push({pts:draft.pts}); selShape=shapes.length-1; renderCutList(); } draft=null; rebuildSoon(); }
c2d.addEventListener('pointerdown',e=>{
if(!cutMode||!topo) return;
const r=c2d.getBoundingClientRect(), sx=e.clientX-r.left, sy=e.clientY-r.top, [x,y]=toMM(sx,sy);
if(draft){
const t=draft.tile, sc=tileScale(t), ang=P.cutRot*Math.PI/180, q=rotC(draft.pts[0],ang,sc), [fx,fy]=toS(...t.map(q[0],q[1]));
if(draft.pts.length>=3&&Math.hypot(fx-sx,fy-sy)<10){ closeDraft(); return; }
draft.pts.push(toLocal(t,x,y)); draw2D(); return;
}
const polys=topo.G.polys||[];
for(const p of polys){ if(p.tile.key!==hoverKey) continue; for(let k=0;k<p.verts.length;k++){ const [vx,vy]=toS(...p.verts[k]);
if(Math.hypot(vx-sx,vy-sy)<9){ drag={tile:p.tile,si:p.si,k}; selShape=p.si; c2d.setPointerCapture(e.pointerId); draw2D(); return; } } }
for(const p of polys){ const b=p.bb; if(x<b[0]||x>b[2]||y<b[1]||y>b[3]) continue; if(inPoly(p.pts,x,y)){ selShape=p.si; renderCutList(); draw2D(); return; } }
const t=tileAt(x,y); if(!t) return;
draft={tile:t, pts:[toLocal(t,x,y)]}; selShape=-1; draw2D();
});
c2d.addEventListener('pointermove',e=>{
if(!cutMode||!topo) return;
const r=c2d.getBoundingClientRect(), sx=e.clientX-r.left, sy=e.clientY-r.top;
if(drag){ const [x,y]=toMM(sx,sy); shapes[drag.si].pts[drag.k]=toLocal(drag.tile,x,y);
topo.G.polys=cutPolys(topo.G); draw2D();
if(performance.now()-lastRb>160) rebuildSoon(0); return; }
if(draft){ hoverPt=[sx,sy]; draw2D(); return; }
const [x,y]=toMM(sx,sy), t=tileAt(x,y), k=t?t.key:null; if(k!==hoverKey){ hoverKey=k; draw2D(); }
});
c2d.addEventListener('pointerup',()=>{ if(drag){ drag=null; rebuildSoon(); } });
c2d.addEventListener('dblclick',()=>{ if(draft) closeDraft(); });
window.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT') return;
if(!cutMode) return;
if(e.key==='Enter'&&draft){ closeDraft(); }
else if(e.key==='Escape'){ if(draft){ draft=null; draw2D(); } else setCutMode(false); }
else if((e.key==='Delete'||e.key==='Backspace')&&!draft&&selShape>=0){ e.preventDefault(); shapes.splice(selShape,1); selShape=-1; renderCutList(); rebuildSoon(); }
});
/* ---------- Export ---------- */
function download(name,text,type){ const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }
function exportSVG(){
const bb=topo.G.bb, W=bb.maxx-bb.minx, H=bb.maxy-bb.miny, L=patternLines();
const X=x=>(x-bb.minx).toFixed(2), Y=y=>(bb.maxy-y).toFixed(2);
const ln=l=>l.map(([x0,y0,x1,y1])=>`<line x1="${X(x0)}" y1="${Y(y0)}" x2="${X(x1)}" y2="${Y(y1)}"/>`).join('');
const holes=(topo.G.polys||[]).map(p=>`<path d="M${p.pts.map(([x,y])=>X(x)+' '+Y(y)).join(' L')} Z"/>`).join('');
const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="${W.toFixed(1)}mm" height="${H.toFixed(1)}mm" viewBox="0 0 ${W.toFixed(2)} ${H.toFixed(2)}">
<g id="schnitt" fill="none" stroke="#000000" stroke-width="0.2">${ln(L.cuts)}</g>
<g id="loecher" fill="none" stroke="#000000" stroke-width="0.2">${holes}</g>
<g id="klebekante" fill="none" stroke="#e8572a" stroke-width="0.2">${ln(L.glue)}</g>
<g id="berg" fill="none" stroke="#2f6fe0" stroke-width="0.2" stroke-dasharray="3 2">${ln(L.mtn)}${ln(L.mtnP)}</g>
<g id="tal" fill="none" stroke="#e0503a" stroke-width="0.2" stroke-dasharray="1 1">${ln(L.val)}${ln(L.valP)}</g>
</svg>`;
download('kirigami_zuschnitt.svg',svg,'image/svg+xml');
}
function exportOBJ(){
const T=topo; let s='# kirigami v0.4\n';
for(let k=0;k<T.np;k++) s+=`v ${T.pos[k*3].toFixed(5)} ${T.pos[k*3+1].toFixed(5)} ${T.pos[k*3+2].toFixed(5)}\n`;
for(let k=0;k<T.np;k++) s+=`vt ${T.uv[k*2].toFixed(4)} ${T.uv[k*2+1].toFixed(4)}\n`;
for(let t=0;t<T.tris.length;t+=3){ const a=T.tris[t]+1,b=T.tris[t+1]+1,c=T.tris[t+2]+1; s+=`f ${a}/${a} ${b}/${b} ${c}/${c}\n`; }
download('kirigami_form.obj',s,'text/plain');
}
function updateStats(){
const L=patternLines(), len=l=>l.reduce((a,[x0,y0,x1,y1])=>a+Math.hypot(x1-x0,y1-y0),0), bb=topo.G.bb;
const fl=len(L.mtn)+len(L.val)+len(L.mtnP)+len(L.valP);
document.getElementById('stats').textContent=`${Math.round(bb.maxx-bb.minx)} × ${Math.round(bb.maxy-bb.miny)} mm · Falz ${(fl/1000).toFixed(2)} m · Klebenaht ${(len(L.glue)/2000).toFixed(2)} m · ${(topo.G.polys||[]).length} Löcher`;
}
/* ---------- UI ---------- */
const ui={}, root=document.getElementById('controls');
const el=(t,c)=>{ const e=document.createElement(t); if(c) e.className=c; return e; };
function section(title,open){ const d=el('details'); if(open) d.open=true; const s=el('summary'); s.textContent=title; d.appendChild(s); root.appendChild(d); return d; }
function slider(par,label,key,min,max,step,fmt,cb,obj=P){
const row=el('div','row'), l=el('label'), inp=el('input'), out=el('output');
l.textContent=label; inp.type='range'; inp.min=min; inp.max=max; inp.step=step; inp.value=obj[key];
const upd=()=>out.textContent=fmt?fmt(obj[key]):obj[key];
inp.addEventListener('input',()=>{ obj[key]=parseFloat(inp.value); upd(); cb&&cb(); });
row.append(l,inp,out); par.appendChild(row); if(obj===P) ui[key]=()=>{ inp.value=P[key]; upd(); }; upd();
}
function select(par,label,key,opts,cb,obj=P){
const row=el('div','row two'), l=el('label'), s=el('select'); l.textContent=label;
for(const [v,t] of opts){ const o=el('option'); o.value=v; o.textContent=t; s.appendChild(o); }
s.value=obj[key]; s.addEventListener('change',()=>{ obj[key]=s.value; cb&&cb(); });
row.append(l,s); par.appendChild(row); if(obj===P) ui[key]=()=>s.value=P[key];
}
function color(par,label,key,cb){
const row=el('div','row two'), l=el('label'), c=el('input'); l.textContent=label; c.type='color'; c.value=P[key];
c.addEventListener('input',()=>{ P[key]=c.value; cb&&cb(); }); row.append(l,c); par.appendChild(row); ui[key]=()=>c.value=P[key];
}
function check(par,label,key,cb){
const row=el('div','row two'), l=el('label'), c=el('input'); l.textContent=label; c.type='checkbox'; c.checked=P[key];
c.addEventListener('change',()=>{ P[key]=c.checked; cb&&cb(); }); row.append(l,c); par.appendChild(row); ui[key]=()=>c.checked=P[key];
}
function buttons(par,list){ const b=el('div','btns'); list.forEach(([t,fn,cls])=>{ const x=el('button',cls||''); x.textContent=t; x.addEventListener('click',()=>fn(x)); b.appendChild(x); }); par.appendChild(b); return b; }
const mm=v=>Math.round(v)+' mm', deg=v=>Math.round(v)+'°', f2=v=>(+v).toFixed(2), pct=v=>Math.round(v*100)+'%', int=v=>Math.round(v);
const rebuild=()=>rebuildSoon(40);
const retarget=()=>{ updateTargets(); draw2D(); };
const GEN_NOTE={
darts:'Keile werden ausgeschnitten und verklebt – das schließt Umfang und erzeugt Kuppel, Schale oder Ellipsoid.',
fan:'Strahlenförmige Falze. Mit geschlossener Naht entsteht ein gefalteter Kegel bzw. eine Krone, Drall dreht die Falze zur Spirale.',
con:'Concertina mit schräg gestellten Falzen: wechselnde Neigung krümmt zum Bogen, gleiche Neigung verdreht zur Spirale.',
miura:'Zickzack-Falzung: wo der Zickzack stärker ist, faltet sie sich stärker zusammen – Verläufe erzeugen Kuppel oder Sattel.',
rings:'Konzentrische Ringfalze, abwechselnd Berg/Tal. Die Fläche verwindet sich von selbst zum Sattel.',
hypar:'Konzentrische Quadrate mit Diagonalfalzen – faltet sich zum hyperbolischen Paraboloid.',
gores:'Kugelsegmente wie bei einem Globus: am Äquator verbunden, Seiten verkleben zur Kugel. Mit Modifikatoren zum Ei oder Ellipsoid.'
};
let s=section('Generator',true);
select(s,'Typ','gen',[['darts','Keile (Kuppel)'],['gores','Kugelsegmente'],['fan','Fächer'],['con','Schräge Concertina'],['miura','Zickzack (Miura)'],['rings','Ringe (Sattel)'],['hypar','Hypar (Quadrate)']],()=>{ renderGen(); renderMods(); build(true); });
const genNote=el('div','note'); s.appendChild(genNote);
const genBox=el('div'); s.appendChild(genBox);
function renderGen(){
genBox.innerHTML=''; genNote.textContent=GEN_NOTE[P.gen]; const b=genBox;
if(P.gen==='darts'){
slider(b,'Keile','dN',3,24,1,int,rebuild); slider(b,'Keilbreite','dW',.05,.95,.01,pct,rebuild);
slider(b,'Keilspitze','dTip',.05,.9,.01,pct,rebuild); slider(b,'Radius','dR',60,300,1,mm,rebuild);
slider(b,'Innenloch','dIn',.02,.5,.01,pct,rebuild); slider(b,'Ringe','dRows',4,16,1,int,rebuild);
select(b,'Breitenverlauf','dField',[['const','Gleich'],['oval','Oval'],['side','Einseitig'],['wave','Welle']],rebuild);
slider(b,'Verlaufsstärke','dAmt',-1,1,.01,f2,rebuild); slider(b,'Wellen','dFreq',1,8,1,int,rebuild);
check(b,'Falze zwischen Keilen','dPleat',rebuild);
} else if(P.gen==='gores'){
slider(b,'Segmente','goN',3,24,1,int,rebuild); slider(b,'Höhe (Meridian)','goH',80,400,1,mm,rebuild);
slider(b,'Ringe','goRows',4,24,2,int,rebuild); slider(b,'Polkappe','goCap',.02,.4,.01,pct,rebuild);
check(b,'Rundum schließen','goClose',rebuild);
} else if(P.gen==='fan'){
slider(b,'Öffnung','fanOpen',60,360,1,deg,rebuild); slider(b,'Falze','fanN',4,64,2,int,rebuild);
slider(b,'Radius','fanR',60,300,1,mm,rebuild); slider(b,'Innenradius','fanIn',.05,.8,.01,pct,rebuild);
slider(b,'Ringe','fanRows',2,12,1,int,rebuild); slider(b,'Drall','fanTwist',-60,60,1,deg,rebuild);
check(b,'Naht schließen','fanSeam',rebuild);
} else if(P.gen==='con'){
slider(b,'Falze','conN',4,40,1,int,rebuild); slider(b,'Paneelbreite','conW',8,60,1,mm,rebuild);
slider(b,'Höhe','conH',40,400,1,mm,rebuild); slider(b,'Neigung','conTilt',0,40,.5,deg,rebuild);
select(b,'Neigungsmuster','conMode',[['alt','Wechselnd (Bogen)'],['same','Gleich (Spirale)'],['grad','Verlauf'],['wave','Welle']],rebuild);
slider(b,'Wellen','conFreq',.5,6,.5,f2,rebuild); check(b,'Ring schließen','conRing',rebuild);
} else if(P.gen==='miura'){
slider(b,'Spalten','miuCols',4,24,2,int,rebuild); slider(b,'Reihen','miuRows',2,16,1,int,rebuild);
slider(b,'Spaltenbreite','miuA',10,50,1,mm,rebuild); slider(b,'Reihenhöhe','miuB',10,50,1,mm,rebuild);
slider(b,'Zickzack','miuS',0,30,.5,mm,rebuild); select(b,'Verlauf','miuField',FIELD,rebuild);
slider(b,'Verlaufsstärke','miuAmt',-1,1,.01,f2,rebuild); slider(b,'Frequenz','miuFreq',.5,4,.5,f2,rebuild);
} else if(P.gen==='rings'){
slider(b,'Ringfalze','rgN',3,20,1,int,rebuild); slider(b,'Segmente','rgSeg',24,96,4,int,rebuild);
slider(b,'Radius','rgR',60,300,1,mm,rebuild); slider(b,'Innenloch','rgIn',.03,.6,.01,pct,rebuild);
} else {
slider(b,'Quadrate','hpN',3,20,1,int,rebuild); slider(b,'Segmente/Seite','hpSeg',1,8,1,v=>int(v*2),rebuild);
slider(b,'Größe','hpR',60,300,1,mm,rebuild); slider(b,'Innenloch','hpIn',.03,.6,.01,pct,rebuild);
}
}
s=section('Modifikatoren pro Falz',true);
s.appendChild(Object.assign(el('div','note'),{textContent:'Optionale Parameter, die einzelne Falze oder Bereiche verändern – mehrere kombinierbar.'}));
const modBox=el('div'); s.appendChild(modBox);
buttons(s,[['+ Modifikator',()=>{ mods.push({target:'angle',shape:'bump',amt:.5,freq:2,pos:.5,width:.1,seed:1}); renderMods(); retarget(); }]]);
function renderMods(){
modBox.innerHTML='';
const allowed=GEN_TARGETS[P.gen];
mods.forEach((m,mi)=>{
if(!allowed.includes(m.target)) m.target='angle';
const card=el('div','mod'), x=el('span','x'); x.textContent='×'; x.title='Entfernen';
x.addEventListener('click',()=>{ const geo=m.target!=='angle'; mods.splice(mi,1); renderMods(); geo?rebuild():retarget(); });
card.appendChild(x);
const cb=()=>m.target==='angle'?retarget():rebuild();
select(card,'Parameter','target',allowed.map(t=>[t,TARGETS[t][0]]),()=>{ renderMods(); rebuild(); retarget(); },m);
select(card,'Muster','shape',MOD_SHAPES,()=>{ renderMods(); cb(); },m);
slider(card,'Stärke','amt',-1,1,.01,f2,cb,m);
if(['waveU','waveV','noise'].includes(m.shape)) slider(card,'Frequenz','freq',.5,12,.5,f2,cb,m);
if(['bump','band','waveU','waveV'].includes(m.shape)) slider(card,m.shape.startsWith('wave')?'Phase':'Position','pos',0,1,.005,f2,cb,m);
if(['bump','band'].includes(m.shape)) slider(card,'Breite','width',.01,.5,.005,f2,cb,m);
if(['rand','noise'].includes(m.shape)) slider(card,'Seed','seed',1,99,1,int,cb,m);
modBox.appendChild(card);
});
if(!mods.length) modBox.appendChild(Object.assign(el('div','note'),{textContent:'Noch keine Modifikatoren.'}));
}
s=section('Formung',true);
slider(s,'Formgrad','form',0,1,.01,pct,retarget);
slider(s,'Faltwinkel','foldAngle',0,170,1,deg,retarget);
slider(s,'Verkleben','glue',0,1,.01,pct);
slider(s,'Netzauflösung','res',1,6,1,int,()=>{ clearTimeout(rbT); rbT=setTimeout(()=>build(true),40); });
s=section('Cutmode · Löcher im Rapport',true);
s.appendChild(Object.assign(el('div','note'),{textContent:'Formen im 2D-Fenster mit CUTMODE zeichnen oder hier als Vorlage einfügen. Jede Form wiederholt sich in jeder Rapportkachel.'}));
check(s,'Löcher aktiv','cutOn',rebuild);
select(s,'Rapport','cutMode',[['panel','Pro Paneel (folgt Falz)'],['grid','Raster in mm']],()=>{ draft=null; renderCutList(); rebuild(); });
const cutBox=el('div'); s.appendChild(cutBox);
const presetBtns=buttons(s,[]);
const PRESETS={
'Kreis':()=>Array.from({length:8},(_,k)=>[.5+.3*Math.cos(k*Math.PI/4),.5+.3*Math.sin(k*Math.PI/4)]),
'Tropfen':()=>[[.5,.84],[.62,.6],[.66,.42],[.58,.24],[.5,.2],[.42,.24],[.34,.42],[.38,.6]],
'Blob':()=>Array.from({length:7},(_,k)=>{ const r=.22+Math.random()*.16, a=k*2*Math.PI/7; return [.5+r*Math.cos(a),.5+r*Math.sin(a)]; }),
'Schlitz':()=>[[.08,.5],[.3,.54],[.7,.54],[.92,.5],[.7,.46],[.3,.46]],
'Bohne':()=>[[.2,.55],[.35,.75],[.6,.72],[.8,.6],[.75,.4],[.55,.38],[.45,.3],[.25,.35]],
};
for(const name in PRESETS){ const b=el('button','ghost'); b.textContent='+ '+name; b.addEventListener('click',()=>{ shapes.push({pts:PRESETS[name]()}); selShape=shapes.length-1; renderCutList(); rebuild(); }); presetBtns.appendChild(b); }
const shapeInfo=el('div','note'); s.appendChild(shapeInfo);
buttons(s,[['Auswahl löschen',()=>{ if(selShape>=0){ shapes.splice(selShape,1); selShape=-1; renderCutList(); rebuild(); } },'ghost'],['Alle löschen',()=>{ shapes=[]; selShape=-1; renderCutList(); rebuild(); },'ghost']]);
function renderCutList(){
cutBox.innerHTML='';
if(P.cutMode==='panel'){ slider(cutBox,'Jedes n-te','cutEvery',1,6,1,int,rebuild); check(cutBox,'Versatz','cutOffset',rebuild); }
else { slider(cutBox,'Rapport X','cutDX',8,120,1,mm,rebuild); slider(cutBox,'Rapport Y','cutDY',8,120,1,mm,rebuild); check(cutBox,'Versatz','cutOffset',rebuild); }
slider(cutBox,'Größe','cutSize',.05,1.6,.01,pct,rebuild);
slider(cutBox,'Drehung','cutRot',-180,180,1,deg,rebuild);
shapeInfo.textContent=shapes.length?`${shapes.length} Form${shapes.length>1?'en':''} im Rapport${selShape>=0?` · Form ${selShape+1} ausgewählt`:''}`:'Noch keine Lochform.';
}
s=section('Material',true);
select(s,'Preset','mat',Object.entries(MATS).map(([k,v])=>[k,v.name]),()=>{
const m=MATS[P.mat]; for(const k in m){ if(k==='name') continue; P[k]=m[k]; ui[k]&&ui[k](); } applyLook(); });
slider(s,'Materialstärke','thick',.05,3,.01,v=>(+v).toFixed(2)+' mm');
slider(s,'Biegesteifigkeit','bend',0,1,.01,f2);
slider(s,'Falzgedächtnis','fold',0,1,.01,f2);
slider(s,'Dehnsteifigkeit','stretch',.2,1,.01,f2);
s=section('Aussehen',false);
color(s,'Vorderseite','front',applyLook);
color(s,'Rückseite','back',applyLook);
slider(s,'Rauheit','rough',0,1,.01,f2,applyLook);
slider(s,'Glanzlack','clear',0,1,.01,f2,applyLook);
slider(s,'Metall','metal',0,1,.01,f2,applyLook);
slider(s,'Deckkraft','opacity',.1,1,.01,f2,applyLook);
select(s,'Struktur','nmType',[['fiber','Fasern'],['grain','Körnung'],['clouds','Wolkig'],['ribs','Rippen'],['wood','Maserung']],applyLook);
slider(s,'Strukturtiefe','nmStr',0,2,.01,f2,applyLook);
slider(s,'Strukturmaßstab','nmScale',.25,6,.05,f2,applyLook);
check(s,'Facetten','flat',applyLook);
s=section('Simulation',true);
slider(s,'Schwerkraft','gravity',0,1,.01,f2);
select(s,'Fixierung','pin',[['none','Keine'],['center','Mitte'],['edge','Linke Kante']],applyPins);
slider(s,'Iterationen','iters',4,40,1,int);
slider(s,'Dämpfung','damp',0,.2,.005,f2);
check(s,'Winkel in 2D zeigen','showField',draw2D);
buttons(s,[
['Pause',b=>{ paused=!paused; b.classList.toggle('on',paused); }],
['Ansicht zentrieren',()=>frameCamera(),'ghost'],
['SVG',exportSVG,'ghost'],
['OBJ',exportOBJ,'ghost'],
]);
document.getElementById('restart').addEventListener('click',()=>{ paused=false; build(true); });
const tgl=document.getElementById('toggle');
tgl.addEventListener('click',()=>{ const o=document.body.classList.toggle('open'); tgl.textContent=o?'Schließen':'Parameter'; });
/* ---------- Start ---------- */
renderGen(); renderMods(); renderCutList();
const ro=new ResizeObserver(()=>{ resize3D(); resize2D(); });
ro.observe(v3d); ro.observe(v2d);
resize3D();
build(true);
applyLook();
resize2D();
(function loop(){
requestAnimationFrame(loop);
if(topo){
if(!paused) step();
if(foldT<1){ foldT=Math.min(1,foldT+1/90); updateTargets(); }
posAttr.needsUpdate=true; geo.computeVertexNormals();
}
controls.update(); renderer.render(scene,camera);
})();
</script>
</body>
</html>