index.html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>INFLATE v1.0</title>
<style>
:root{
color-scheme: light;
--bg:#fff; --fg:#111; --mute:#8c8c8c; --line:#ebebeb; --acc:#ff2d95;
box-sizing:border-box;
padding-top:env(safe-area-inset-top,0px);
padding-bottom:env(safe-area-inset-bottom,0px);
}
html{scroll-padding-top:env(safe-area-inset-top,0px)}
html,body{height:100%;margin:0;background:#fff;color:var(--fg);
font:12px/1.45 "Helvetica Neue",Helvetica,Arial,system-ui,sans-serif;-webkit-text-size-adjust:100%}
*{box-sizing:border-box}
#app{height:100%;display:grid;grid-template-rows:auto minmax(0,1fr) auto}
header{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:10px 14px;border-bottom:1px solid var(--line)}
header .name{font-weight:600;margin-right:10px;letter-spacing:.01em}
header .sp,.lay .sp{width:1px;height:16px;background:var(--line);margin:0 6px}
button{font:inherit;color:var(--fg);background:#fff;border:1px solid #d9d9d9;border-radius:3px;padding:4px 10px;cursor:pointer}
button:hover{border-color:#111}
button.on{border-color:#111;background:#f3f3f3}
button:focus-visible,input:focus-visible{outline:2px solid var(--acc);outline-offset:1px}
main{display:grid;grid-template-columns:1fr 1fr 250px;min-height:0}
section{position:relative;min-height:0;min-width:0;overflow:hidden}
section+section{border-left:1px solid var(--line)}
canvas{display:block;touch-action:none}
#c2{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair}
#c3{position:absolute;inset:0}
#c3 canvas{width:100%;height:100%;cursor:grab}
.tag{position:absolute;left:14px;top:10px;color:var(--mute);pointer-events:none}
.hint{position:absolute;left:14px;bottom:10px;right:110px;color:var(--mute);pointer-events:none}
.lay{position:absolute;right:12px;top:6px;display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px;max-width:78%}
footer{display:flex;flex-wrap:wrap;align-items:center;gap:8px 22px;padding:10px 14px;border-top:1px solid var(--line)}
#st{color:#444;flex:1 1 100%;min-height:17px}
#msg{color:var(--mute);margin-left:6px}
#msg.err{color:var(--acc)}
aside{border-left:1px solid var(--line);padding:14px;overflow:auto;min-height:0}
aside h2{font-size:12px;font-weight:600;margin:0 0 12px}
aside h2:not(:first-child){margin-top:22px;padding-top:16px;border-top:1px solid var(--line)}
.f{margin-bottom:14px}
.f .lab{display:flex;justify-content:space-between;margin-bottom:4px}
.f .lab b{font-weight:600}
.f input[type=range]{display:block;width:100%;margin:0;accent-color:#111}
.f.inline{display:flex;justify-content:space-between;align-items:center}
.f input[type=color]{width:34px;height:22px;padding:0;border:1px solid #d9d9d9;border-radius:3px;background:#fff;cursor:pointer}
.chk{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.note{color:var(--mute);margin:0}
.tag.b{top:auto;bottom:10px}
button.dark{position:absolute;left:14px;top:10px;z-index:2;background:#111;color:#fff;border-color:#111;display:flex;align-items:center}
button.dark:hover{background:#333}
button.dark .dot{display:none;width:7px;height:7px;border-radius:50%;background:var(--acc);margin-left:8px}
button.dark.dirty .dot{display:inline-block}
@media (max-width:760px){
main{grid-template-columns:1fr;grid-template-rows:1fr 1fr auto}
section+section{border-left:0;border-top:1px solid var(--line)}
aside{border-left:0;border-top:1px solid var(--line)}
}
</style>
</head>
<body>
<div id="app">
<header>
<span class="name">INFLATE v1.0</span>
<button id="tSpline" class="on" title="Punkte setzen">Spline</button>
<button id="tFree" title="Freihand zeichnen">Freihand</button>
<span class="sp"></span>
<button data-p="kreis">Kreis</button>
<button data-p="viereck">Viereck</button>
<button data-p="kapsel">Kapsel</button>
<span class="sp"></span>
<button id="bUndo">Zurück</button>
<button id="bNew">Leeren</button>
<span class="sp"></span>
<button id="bSvg">SVG exportieren</button>
<span id="msg"></span>
</header>
<main>
<section id="l">
<canvas id="c2"></canvas>
<div class="tag" id="tag2"></div>
<div class="lay">
<button id="lyT" class="on" title="Obere Lage bearbeiten">Oben</button>
<button id="lyB" title="Untere Lage bearbeiten">Unten</button>
<span class="sp"></span>
<button id="cpl" class="on" title="Gekoppelt: Ein angefasster Knoten zieht seinen Partner auf der anderen Lage deckungsgleich mit. Entkoppelt: Nur die gewählte Lage bewegt sich.">Gekoppelt</button>
<button id="alg" title="Die andere Lage deckungsgleich mit der gewählten machen">Angleichen</button>
<button id="lnb" title="Segmentlängen und Abweichung zur anderen Lage anzeigen">Längen</button>
</div>
<div class="hint" id="hint"></div>
</section>
<section id="r">
<div id="c3"></div>
<button id="bRe" class="dark" title="Simulation mit dem aktuellen Zuschnitt neu starten">Neu aufpumpen<span class="dot"></span></button>
<div class="tag b">Ziehen zum Drehen, Scrollen zum Zoomen</div>
</section>
<aside>
<h2>Aussehen</h2>
<div class="f inline"><span>Farbe</span><input type="color" id="col" value="#33383d"></div>
<div class="f"><div class="lab"><span>Glanz</span><b id="glv"></b></div><input type="range" id="gl" min="0" max="100" step="1" value="55"></div>
<div class="f"><div class="lab"><span>Metall</span><b id="mev"></b></div><input type="range" id="me" min="0" max="100" step="1" value="0"></div>
<label class="chk"><input type="checkbox" id="ck1"> Dehnung zeigen</label>
<h2>Verhalten</h2>
<div class="f"><div class="lab"><span>Druck</span><b id="prv"></b></div><input type="range" id="pr" min="0" max="15" step="0.1" value="4"></div>
<div class="f"><div class="lab"><span>Steifigkeit</span><b id="etv"></b></div><input type="range" id="et" min="0" max="100" step="1" value="26"></div>
<p class="note">Steifigkeit ist E·t des Materials. Folie liegt bei wenigen N/mm, Gewebe bei einigen hundert.</p>
<h2>Netz</h2>
<div class="f"><div class="lab"><span>Auflösung</span><b id="rsv"></b></div><input type="range" id="rs" min="0" max="100" step="1" value="53"></div>
<label class="chk"><input type="checkbox" id="ck2"> Netz zeigen</label>
<p class="note">Feineres Netz rechnet genauer, aber langsamer. Gilt ab dem nächsten Aufpumpen.</p>
</aside>
</main>
<footer><div id="st"></div></footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/delaunator@5.0.1/delaunator.min.js"></script>
<script>
/* ============================ CORE ============================ */
// Einheiten: mm, N, N/mm (Membransteifigkeit E*t), Druck in N/mm² (1 kPa = 0.001)
// Jede Form hat zwei Lagen mit gleicher Knotenzahl: pts (oben) und pb (unten). Knoten i oben gehört zu Knoten i unten.
const DT = 0.02, SUB = 20, DAMP = 0.996;
function cr(p0, p1, p2, p3, t){
const t2 = t*t, t3 = t2*t;
return [
0.5*(2*p1.x + (-p0.x+p2.x)*t + (2*p0.x-5*p1.x+4*p2.x-p3.x)*t2 + (-p0.x+3*p1.x-3*p2.x+p3.x)*t3),
0.5*(2*p1.y + (-p0.y+p2.y)*t + (2*p0.y-5*p1.y+4*p2.y-p3.y)*t2 + (-p0.y+3*p1.y-3*p2.y+p3.y)*t3)
];
}
function splinePoly(P, sub){ // geschlossener Catmull-Rom
const n = P.length, out = [];
for (let i = 0; i < n; i++)
for (let k = 0; k < sub; k++) out.push(cr(P[(i-1+n)%n], P[i], P[(i+1)%n], P[(i+2)%n], k/sub));
return out;
}
function splinePolyOpen(P, sub){ // offener Catmull-Rom, Enden geklemmt
const n = P.length;
if (n < 2) return P.map(p => [p.x, p.y]);
const out = [];
for (let i = 0; i < n-1; i++)
for (let k = 0; k < sub; k++) out.push(cr(P[Math.max(i-1,0)], P[i], P[i+1], P[Math.min(i+2,n-1)], k/sub));
out.push([P[n-1].x, P[n-1].y]);
return out;
}
function ptAt(P, closed, i){ const n = P.length; return closed ? P[((i%n)+n)%n] : P[Math.max(0, Math.min(n-1, i))]; }
function segCount(P, closed){ return closed ? P.length : Math.max(0, P.length-1); }
function segPoly(P, closed, k, sub){ // Polylinie des Segments k zwischen Knoten k und k+1, beide Enden inklusive
const p0 = ptAt(P,closed,k-1), p1 = ptAt(P,closed,k), p2 = ptAt(P,closed,k+1), p3 = ptAt(P,closed,k+2), out = [];
for (let j = 0; j <= sub; j++) out.push(cr(p0,p1,p2,p3,j/sub));
return out;
}
function plLen(pl){ let L = 0; for (let i = 1; i < pl.length; i++) L += Math.hypot(pl[i][0]-pl[i-1][0], pl[i][1]-pl[i-1][1]); return L; }
function plAt(pl, L, f){ // Punkt bei Bogenlängenanteil f
const t = f*L; let acc = 0;
for (let i = 1; i < pl.length; i++){
const d = Math.hypot(pl[i][0]-pl[i-1][0], pl[i][1]-pl[i-1][1]);
if (acc + d >= t || i === pl.length-1){ const u = d > 0 ? Math.max(0, Math.min(1, (t-acc)/d)) : 0; return [pl[i-1][0]+(pl[i][0]-pl[i-1][0])*u, pl[i-1][1]+(pl[i][1]-pl[i-1][1])*u]; }
acc += d;
}
return pl[pl.length-1];
}
// Tastet beide Lagen segmentweise ab, mit gleich vielen Punkten je Segment. So bleibt die Zuordnung Knoten zu Knoten erhalten.
function sampleShape(PT, PB, closed, h){
const nS = segCount(PT, closed), T = [], B = [];
for (let k = 0; k < nS; k++){
const a = segPoly(PT,closed,k,24), b = segPoly(PB,closed,k,24), la = plLen(a), lb = plLen(b);
const n = Math.max(1, Math.round((la+lb)/2/h));
for (let j = 0; j < n; j++){ T.push(plAt(a,la,j/n)); B.push(plAt(b,lb,j/n)); }
}
if (!closed){ const e = PT.length-1; T.push([PT[e].x, PT[e].y]); B.push([PB[e].x, PB[e].y]); }
const kt = [], kb = [], min = 0.45*h, d2 = (p,q) => Math.hypot(p[0]-q[0], p[1]-q[1]);
for (let i = 0; i < T.length; i++){
if (kt.length){
const j = kt.length-1;
if (d2(T[i],kt[j]) < min || d2(B[i],kb[j]) < min){
if (!closed && i === T.length-1){ kt.pop(); kb.pop(); } else continue;
}
}
kt.push(T[i]); kb.push(B[i]);
}
if (closed) while (kt.length > 6){
const l = kt.length-1;
if (d2(kt[l],kt[0]) < min || d2(kb[l],kb[0]) < min){ kt.pop(); kb.pop(); } else break;
}
return {t: kt, b: kb};
}
function perim(p){let L=0;for(let i=0;i<p.length;i++){const q=p[(i+1)%p.length];L+=Math.hypot(q[0]-p[i][0],q[1]-p[i][1])}return L}
function polyLen(p){let L=0;for(let i=1;i<p.length;i++)L+=Math.hypot(p[i][0]-p[i-1][0],p[i][1]-p[i-1][1]);return L}
function resampleN(poly, M){ // geschlossen
const n = poly.length, seg = []; let L = 0;
for (let i = 0; i < n; i++){ const q = poly[(i+1)%n]; const d = Math.hypot(q[0]-poly[i][0], q[1]-poly[i][1]); seg.push(d); L += d; }
const step = L/M, out = []; let i = 0, acc = 0;
for (let m = 0; m < M; m++){
const target = m*step;
while (acc + seg[i] < target && i < n-1){ acc += seg[i]; i++; }
const f = seg[i] > 0 ? (target-acc)/seg[i] : 0, q = poly[(i+1)%n];
out.push([poly[i][0] + (q[0]-poly[i][0])*f, poly[i][1] + (q[1]-poly[i][1])*f]);
}
return out;
}
function resampleOpen(poly, M){ // offen, beide Enden bleiben
const n = poly.length, cum = [0];
for (let i = 1; i < n; i++) cum.push(cum[i-1] + Math.hypot(poly[i][0]-poly[i-1][0], poly[i][1]-poly[i-1][1]));
const L = cum[n-1], out = []; let i = 0;
for (let m = 0; m < M; m++){
const t = L*m/(M-1);
while (i < n-2 && cum[i+1] < t) i++;
const f = cum[i+1] > cum[i] ? (t-cum[i])/(cum[i+1]-cum[i]) : 0;
out.push([poly[i][0] + (poly[i+1][0]-poly[i][0])*f, poly[i][1] + (poly[i+1][1]-poly[i][1])*f]);
}
return out;
}
function polyArea(p){let a=0;for(let i=0;i<p.length;i++){const q=p[(i+1)%p.length];a+=p[i][0]*q[1]-q[0]*p[i][1]}return a/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 segDist(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(a[0]+t*dx-px,a[1]+t*dy-py)}
function segInt(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++){const a=p[i],b=p[(i+1)%n];for(let j=i+2;j<n;j++){if(i===0&&j===n-1)continue;if(segInt(a,b,p[j],p[(j+1)%n]))return true}}return false}
function loopsIntersect(A,B){for(let i=0;i<A.length;i++){const a=A[i],b=A[(i+1)%A.length];for(let j=0;j<B.length;j++)if(segInt(a,b,B[j],B[(j+1)%B.length]))return true}return false}
// G = {B: {t,b}, H: [{t,b}], W: [{t,b}]}. t und b sind Punktlisten gleicher Länge (oben und unten).
// Das Netz wird auf dem oberen Layout trianguliert. Die untere Lage bekommt dieselbe Topologie, ihre
// Ruhelängen kommen aus dem unteren Layout (Verschiebungsfeld: Nahtpunkte bekannt, Innenpunkte geglättet).
// Punkte auf Kontur, Ausschnittrand und Naht gehören beiden Lagen (verschweißt).
function buildModel(G, h){
const Bt = G.B.t, Bb = G.B.b, Ht = G.H.map(x => x.t), Hb = G.H.map(x => x.b), M0 = Bt.length;
let x0=1e9, x1=-1e9, y0=1e9, y1=-1e9;
for (const p of Bt){ x0=Math.min(x0,p[0]); x1=Math.max(x1,p[0]); y0=Math.min(y0,p[1]); y1=Math.max(y1,p[1]); }
const pts = Bt.slice(), ptsB = Bb.slice(), chains = [{s: 0, n: M0, closed: true}], segs = [];
const addSegs = (s,n,closed) => { for (let i = 0; i < n-1; i++) segs.push([s+i, s+i+1]); if (closed) segs.push([s+n-1, s]); };
addSegs(0, M0, true);
const inHole = (x,y) => { for (const hp of Ht) if (inPoly(x,y,hp)) return true; return false; };
for (let i = 0; i < Ht.length; i++){
const s = pts.length;
for (let j = 0; j < Ht[i].length; j++){ pts.push(Ht[i][j]); ptsB.push(Hb[i][j]); }
chains.push({s, n: Ht[i].length, closed: true}); addSegs(s, Ht[i].length, true);
}
const loops = [Bt, ...Ht];
const dLoops = (x,y) => { let d = 1e9; for (const L of loops) for (let i = 0; i < L.length; i++){ const dd = segDist(x,y,L[i],L[(i+1)%L.length]); if (dd < d) d = dd; } return d; };
for (const w of G.W){
const s = pts.length;
for (let j = 0; j < w.t.length; j++){
const p = w.t[j];
if (!inPoly(p[0],p[1],Bt) || inHole(p[0],p[1])) continue;
if (dLoops(p[0],p[1]) < 0.5*h) continue;
let ok = true;
for (let i = 0; i < s; i++) if (Math.hypot(pts[i][0]-p[0], pts[i][1]-p[1]) < 0.45*h){ ok = false; break; }
if (ok){ pts.push(p); ptsB.push(w.b[j]); }
}
const cnt = pts.length - s;
if (cnt < 2){ pts.length = s; ptsB.length = s; continue; }
chains.push({s, n: cnt, closed: false}); addSegs(s, cnt, false);
}
const Ms = pts.length, dB = new Array(Ms).fill(0), rowH = h*0.8660254;
const sp = segs.map(([a,b]) => [pts[a], pts[b]]);
const dAll = (x,y,stop) => { let d = 1e9; for (const [a,b] of sp){ const dd = segDist(x,y,a,b); if (dd < d){ d = dd; if (d < stop) return d; } } return d; };
let r = 0;
for (let y = y0 + rowH*0.5; y < y1; y += rowH, r++){
for (let x = x0 + h*0.25 + ((r&1) ? h*0.5 : 0); x < x1; x += h){
if (!inPoly(x,y,Bt) || inHole(x,y)) continue;
const d = dAll(x,y,0.62*h);
if (d < 0.62*h) continue;
pts.push([x,y]); dB.push(d);
}
}
const K = pts.length, nI = K - Ms, n = K + nI;
const co = new Float64Array(2*K);
for (let i = 0; i < K; i++){ co[2*i] = pts[i][0]; co[2*i+1] = pts[i][1]; }
const tr = new Delaunator(co).triangles;
const adj = new Set();
for (const [a,b] of segs) adj.add(a < b ? a*K+b : b*K+a);
const okE = (a,b) => adj.has(a < b ? a*K+b : b*K+a) ||
(inPoly((pts[a][0]+pts[b][0])/2, (pts[a][1]+pts[b][1])/2, Bt) && !inHole((pts[a][0]+pts[b][0])/2, (pts[a][1]+pts[b][1])/2));
const T = [], es = new Set();
for (let i = 0; i < tr.length; i += 3){
let a = tr[i], b = tr[i+1], c = tr[i+2];
const gx = (pts[a][0]+pts[b][0]+pts[c][0])/3, gy = (pts[a][1]+pts[b][1]+pts[c][1])/3;
if (!inPoly(gx,gy,Bt) || inHole(gx,gy)) continue;
if (!okE(a,b) || !okE(b,c) || !okE(c,a)) continue;
const ny = (pts[b][1]-pts[a][1])*(pts[c][0]-pts[a][0]) - (pts[b][0]-pts[a][0])*(pts[c][1]-pts[a][1]);
if (Math.abs(ny) < 1e-6) continue;
if (ny < 0){ const t = b; b = c; c = t; }
T.push(a,b,c);
for (const [u,v] of [[a,b],[b,c],[c,a]]) es.add(u < v ? u*K+v : v*K+u);
}
// Unteres Layout: Verschiebung an den Nahtpunkten bekannt, innen per Gewichtung und Glättung über das Netz
const dx = new Float64Array(K), dy = new Float64Array(K);
let maxd = 0;
for (let k = 0; k < Ms; k++){ dx[k] = ptsB[k][0]-pts[k][0]; dy[k] = ptsB[k][1]-pts[k][1]; maxd = Math.max(maxd, Math.abs(dx[k]), Math.abs(dy[k])); }
if (maxd > 1e-6 && K > Ms){
for (let k = Ms; k < K; k++){
let sw = 0, sx = 0, sy = 0;
for (let j = 0; j < Ms; j++){ const ex = pts[j][0]-pts[k][0], ey = pts[j][1]-pts[k][1], w = 1/(ex*ex+ey*ey+1e-6); sw += w; sx += w*dx[j]; sy += w*dy[j]; }
dx[k] = sx/sw; dy[k] = sy/sw;
}
const nbr = Array.from({length: K}, () => []);
for (const key of es){ const a = Math.floor(key/K), b = key % K; nbr[a].push(b); nbr[b].push(a); }
for (let sweep = 0; sweep < 60; sweep++)
for (let k = Ms; k < K; k++){
const L = nbr[k]; if (!L.length) continue;
let sx = 0, sy = 0; for (const j of L){ sx += dx[j]; sy += dy[j]; }
dx[k] = sx/L.length; dy[k] = sy/L.length;
}
}
const pbf = new Array(K);
for (let k = 0; k < K; k++) pbf[k] = k < Ms ? ptsB[k] : [pts[k][0]+dx[k], pts[k][1]+dy[k]];
// Startlage: Mittel beider Layouts, um den Schwerpunkt der Außenkontur zentriert
let cx = 0, cy = 0;
for (let k = 0; k < M0; k++){ cx += (pts[k][0]+pbf[k][0])/2; cy += (pts[k][1]+pbf[k][1])/2; }
cx /= M0; cy /= M0;
let ax0=1e9, ax1=-1e9, ay0=1e9, ay1=-1e9;
for (let k = 0; k < M0; k++){
const X = (pts[k][0]+pbf[k][0])/2, Y = (pts[k][1]+pbf[k][1])/2;
ax0=Math.min(ax0,X); ax1=Math.max(ax1,X); ay0=Math.min(ay0,Y); ay1=Math.max(ay1,Y);
}
const bi = k => k < Ms ? k : k + nI;
const pos = new Float64Array(3*n);
for (let k = 0; k < K; k++){
const X = (pts[k][0]+pbf[k][0])/2-cx, Z = (pts[k][1]+pbf[k][1])/2-cy;
pos[3*k] = X; pos[3*k+2] = Z;
if (k >= Ms){ const t = 0.15*Math.min(dB[k],30); pos[3*k+1] = t; pos[3*bi(k)] = X; pos[3*bi(k)+2] = Z; pos[3*bi(k)+1] = -t; }
}
const ea = [], eb = [], rs = [];
for (const key of es){
const a = Math.floor(key/K), b = key % K;
ea.push(a, bi(a)); eb.push(b, bi(b));
rs.push(Math.hypot(pts[a][0]-pts[b][0], pts[a][1]-pts[b][1]), Math.hypot(pbf[a][0]-pbf[b][0], pbf[a][1]-pbf[b][1]));
}
const tri = [];
for (let i = 0; i < T.length; i += 3){
const a = T[i], b = T[i+1], c = T[i+2];
tri.push(a,b,c, bi(a), bi(c), bi(b));
}
const E = rs.length, scnt = new Float32Array(n);
for (let e = 0; e < E; e++){ scnt[ea[e]]++; scnt[eb[e]]++; }
let At = Math.abs(polyArea(Bt)), Ab = Math.abs(polyArea(Bb));
for (const hp of Ht) At -= Math.abs(polyArea(hp));
for (const hp of Hb) Ab -= Math.abs(polyArea(hp));
const seamIdx = []; for (const [a,b] of segs) seamIdx.push(a,b);
return {
h, M0, Ms, K, n, cx, cy, W0: ax1-ax0, D0: ay1-ay0, At, Ab, chains, seamIdx: Uint32Array.from(seamIdx),
pos, vel: new Float64Array(3*n), prev: new Float64Array(3*n), frc: new Float64Array(3*n),
ea: Int32Array.from(ea), eb: Int32Array.from(eb), rest: Float64Array.from(rs),
tri: Int32Array.from(tri), sacc: new Float32Array(n), scnt
};
}
function stepSim(m, P, Et){
const {n, pos, vel, prev, frc, ea, eb, rest, tri} = m, E = rest.length, T = tri.length/3;
const at = (1/(0.866*Et))/(DT*DT), inv = DAMP/DT, kP = P/6, n3 = 3*n;
for (let s = 0; s < SUB; s++){
frc.fill(0);
if (P > 0) for (let t = 0; t < T; t++){
const a = tri[3*t]*3, b = tri[3*t+1]*3, c = tri[3*t+2]*3;
const ux = pos[b]-pos[a], uy = pos[b+1]-pos[a+1], uz = pos[b+2]-pos[a+2];
const 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)*kP, ny = (uz*vx-ux*vz)*kP, nz = (ux*vy-uy*vx)*kP;
frc[a]+=nx; frc[a+1]+=ny; frc[a+2]+=nz;
frc[b]+=nx; frc[b+1]+=ny; frc[b+2]+=nz;
frc[c]+=nx; frc[c+1]+=ny; frc[c+2]+=nz;
}
for (let i = 0; i < n3; i++){ vel[i] += DT*frc[i]; prev[i] = pos[i]; pos[i] += DT*vel[i]; }
for (let e = 0; e < E; e++){
const a = ea[e]*3, b = eb[e]*3;
let dx = pos[a]-pos[b], dy = pos[a+1]-pos[b+1], dz = pos[a+2]-pos[b+2];
const L = Math.sqrt(dx*dx+dy*dy+dz*dz) || 1e-9;
const k = -(L-rest[e])/(2+at)/L;
dx*=k; dy*=k; dz*=k;
pos[a]+=dx; pos[a+1]+=dy; pos[a+2]+=dz;
pos[b]-=dx; pos[b+1]-=dy; pos[b+2]-=dz;
}
for (let i = 0; i < n3; i++) vel[i] = (pos[i]-prev[i])*inv;
}
}
function analyse(m){
const {pos, tri, ea, eb, rest, M0, sacc} = m, E = rest.length;
let V = 0, ymin = 1e9, ymax = -1e9;
for (let t = 0; t < tri.length; t += 3){
const a = tri[t]*3, b = tri[t+1]*3, c = tri[t+2]*3;
V += (pos[a]*(pos[b+1]*pos[c+2]-pos[b+2]*pos[c+1]) + pos[a+1]*(pos[b+2]*pos[c]-pos[b]*pos[c+2]) + pos[a+2]*(pos[b]*pos[c+1]-pos[b+1]*pos[c]))/6;
for (const q of [a,b,c]){ if (pos[q+1] < ymin) ymin = pos[q+1]; if (pos[q+1] > ymax) ymax = pos[q+1]; }
}
let bx0=1e9, bx1=-1e9, bz0=1e9, bz1=-1e9;
for (let i = 0; i < M0; i++){ bx0=Math.min(bx0,pos[3*i]); bx1=Math.max(bx1,pos[3*i]); bz0=Math.min(bz0,pos[3*i+2]); bz1=Math.max(bz1,pos[3*i+2]); }
sacc.fill(0);
let sum = 0, mx = 0;
for (let e = 0; e < E; e++){
const a = ea[e]*3, b = eb[e]*3;
const L = Math.hypot(pos[a]-pos[b], pos[a+1]-pos[b+1], pos[a+2]-pos[b+2]);
const s = (L-rest[e])/rest[e];
sum += Math.abs(s); if (s > mx) mx = s;
sacc[ea[e]] += Math.abs(s); sacc[eb[e]] += Math.abs(s);
}
return {vol: V, h: ymax-ymin, w: bx1-bx0, d: bz1-bz0, mean: sum/E, max: mx};
}
// Bereitet die gezeichneten Formen auf (Validierung inklusive). Gibt {G, h, error} zurück.
function prepare(shapes, N){
const o = shapes[0];
if (!o || !o.closed || o.pts.length < 3) return {error: 'Zuerst eine geschlossene Außenkontur zeichnen.'};
const inner = shapes.slice(1).filter(sh => sh.done);
const holesS = inner.filter(sh => sh.closed && sh.pts.length >= 3);
const weldsS = inner.filter(sh => !sh.closed && sh.pts.length >= 2);
const area = key => { let a = Math.abs(polyArea(splinePoly(o[key], 12))); for (const sh of holesS) a -= Math.abs(polyArea(splinePoly(sh[key], 12))); return a; };
const aT = area('pts'), aB = area('pb');
if (Math.min(aT, aB) < 3000) return {error: 'Die Form ist zu klein.'};
const h = Math.min(40, Math.max(2.5, Math.sqrt(((aT+aB)/2)/((N || 1100)*0.866))));
const B = sampleShape(o.pts, o.pb, true, h);
if (B.t.length < 12) return {error: 'Die Form ist zu klein.'};
const H = holesS.map(sh => sampleShape(sh.pts, sh.pb, true, h));
const W = weldsS.map(sh => sampleShape(sh.pts, sh.pb, false, h));
for (const lay of ['t', 'b']){
const pre = lay === 'b' ? 'Unten: ' : '', Bl = B[lay];
if (selfIntersects(Bl)) return {error: pre + 'Die Außenkontur überschneidet sich. Bitte einen Punkt verschieben.'};
for (let i = 0; i < H.length; i++){
const Hi = H[i][lay];
if (Hi.length < 8 || Math.abs(polyArea(Hi)) < 6*h*h) return {error: pre + 'Ein Ausschnitt ist zu klein.'};
if (selfIntersects(Hi)) return {error: pre + 'Ein Ausschnitt überschneidet sich selbst.'};
if (Hi.some(p => !inPoly(p[0],p[1],Bl)) || loopsIntersect(Bl, Hi)) return {error: pre + 'Ein Ausschnitt liegt nicht vollständig innerhalb der Außenkontur.'};
for (let j = 0; j < i; j++){
const Hj = H[j][lay];
if (loopsIntersect(Hi, Hj) || inPoly(Hi[0][0],Hi[0][1],Hj) || inPoly(Hj[0][0],Hj[0][1],Hi)) return {error: pre + 'Ausschnitte überlappen sich.'};
}
}
}
return {G: {B, H, W}, h};
}
/* ============================ /CORE ============================ */
/* ============================ UI ============================ */
const $ = s => document.querySelector(s);
const S = {shapes: [], tool: 'spline', layer: 'top', coupled: true, lens: false, pressure: 4, Et: 6, strain: false, wire: false, N: 1100};
let model = null, Pcur = 0, fitted = false;
const history = [];
let sel = [];
const isSel = (si,pi) => sel.some(k => k.si === si && k.pi === pi);
const LK = () => S.layer === 'top' ? 'pts' : 'pb'; // aktive Lage
const OK = () => S.layer === 'top' ? 'pb' : 'pts'; // andere Lage
const clone = p => ({x: p.x, y: p.y});
const mkShape = (pts, closed, done) => ({pts, pb: pts.map(clone), closed, done});
if (typeof THREE === 'undefined' || typeof Delaunator === 'undefined'){
$('#msg').textContent = 'Bibliotheken (three.js, delaunator) konnten nicht geladen werden.'; $('#msg').className = 'err';
}
function msg(t, err){ const m = $('#msg'); m.textContent = t || ''; m.className = err ? 'err' : ''; }
/* ---------- Verlauf ---------- */
function snap(){ history.push(JSON.stringify(S.shapes)); if (history.length > 60) history.shift(); }
function undo(){
if (!history.length) return;
S.shapes = JSON.parse(history.pop()); drag = null; freeRaw = null; sel = [];
msg(''); commit();
}
let dirty = false;
function setDirty(v){ dirty = v; $('#bRe').classList.toggle('dirty', v); }
function commit(auto){
sel = sel.filter(k => S.shapes[k.si] && S.shapes[k.si].pts[k.pi]);
updateHint();
if (!model || auto === true) scheduleBuild(20); else setDirty(true);
}
function deleteSelected(){
sel = sel.filter(k => S.shapes[k.si] && S.shapes[k.si].pts[k.pi]);
if (!sel.length) return;
snap();
const by = {};
sel.forEach(k => { (by[k.si] = by[k.si] || []).push(k.pi); });
for (const si of Object.keys(by).map(Number).sort((a,b) => b-a)){
const sh = S.shapes[si];
for (const pi of by[si].sort((a,b) => b-a)){ sh.pts.splice(pi, 1); sh.pb.splice(pi, 1); } // beide Lagen
if (sh.pts.length <= 1) S.shapes.splice(si, 1);
else if (sh.closed && sh.pts.length < 3) sh.closed = false;
}
sel = []; msg(''); commit();
}
const active = () => { const l = S.shapes[S.shapes.length-1]; return l && !l.done ? l : null; };
function finishActive(){
const a = active(); if (!a) return;
const idx = S.shapes.indexOf(a);
snap();
if (idx === 0){ if (a.pts.length >= 3){ a.closed = true; a.done = true; } else S.shapes.pop(); }
else { if (a.pts.length >= 2) a.done = true; else S.shapes.pop(); }
commit();
}
function cancelActive(){ const a = active(); if (!a) return; snap(); S.shapes.pop(); commit(); }
const shapePoly = (sh, key, sub) => sh.closed ? splinePoly(sh[key], sub) : splinePolyOpen(sh[key], sub);
const layersDiffer = () => S.shapes.some(sh => sh.pts.some((p,i) => Math.hypot(p.x-sh.pb[i].x, p.y-sh.pb[i].y) > 0.05));
/* ---------- 2D ---------- */
const c2 = $('#c2'), ctx = c2.getContext('2d');
let cw = 1, ch = 1, dpr = 1, drag = null, dragSnapped = false, freeRaw = null, cursor = null;
const vs = () => Math.min(cw/640, ch/480);
const w2s = (x,y) => [cw/2 + x*vs(), ch/2 + y*vs()];
function evW(e){ const r = c2.getBoundingClientRect(), s = vs(); return {x: (e.clientX-r.left-cw/2)/s, y: (e.clientY-r.top-ch/2)/s}; }
function evS(e){ const r = c2.getBoundingClientRect(); return [e.clientX-r.left, e.clientY-r.top]; }
function hitPoint(e){
const [sx,sy] = evS(e);
for (let si = S.shapes.length-1; si >= 0; si--)
for (let pi = 0; pi < S.shapes[si][LK()].length; pi++){
const p = S.shapes[si][LK()][pi], [px,py] = w2s(p.x, p.y);
if (Math.hypot(px-sx, py-sy) < 10) return {si, pi};
}
return null;
}
// nächster Punkt auf einer Kurve der aktiven Lage: Formindex, Segment, Parameter t im Segment
function nearCurve(e){
const [sx,sy] = evS(e); let best = 9, res = null;
S.shapes.forEach((sh, si) => {
const P = sh[LK()]; if (P.length < 2) return;
for (let k = 0; k < segCount(P, sh.closed); k++){
const pl = segPoly(P, sh.closed, k, 10).map(p => w2s(p[0],p[1]));
for (let j = 0; j < 10; j++){
const a = pl[j], b = pl[j+1], dx = b[0]-a[0], dy = b[1]-a[1];
const u = Math.max(0, Math.min(1, ((sx-a[0])*dx + (sy-a[1])*dy)/(dx*dx+dy*dy || 1)));
const d = Math.hypot(a[0]+u*dx-sx, a[1]+u*dy-sy);
if (d < best){ best = d; res = {si, seg: k, t: (j+u)/10}; }
}
}
});
return res;
}
function pointOnLayer(sh, key, seg, t){
const P = sh[key], c = sh.closed, q = cr(ptAt(P,c,seg-1), ptAt(P,c,seg), ptAt(P,c,seg+1), ptAt(P,c,seg+2), t);
return {x: q[0], y: q[1]};
}
c2.addEventListener('pointerdown', e => {
c2.setPointerCapture(e.pointerId);
const p = evW(e), hit = hitPoint(e), act = active();
if (hit){
const sh = S.shapes[hit.si];
if (sh === act && hit.pi === 0 && sh.pts.length >= 3){ snap(); sh.closed = true; sh.done = true; sel = []; commit(); return; }
if (e.shiftKey){
if (isSel(hit.si, hit.pi)) sel = sel.filter(k => !(k.si === hit.si && k.pi === hit.pi)); else sel.push(hit);
return;
}
if (!isSel(hit.si, hit.pi)) sel = [hit];
drag = hit; dragSnapped = false; return;
}
if (e.shiftKey){
const nc = nearCurve(e);
if (nc){
snap();
const sh = S.shapes[nc.si], a = pointOnLayer(sh, LK(), nc.seg, nc.t);
const b = S.coupled ? clone(a) : pointOnLayer(sh, OK(), nc.seg, nc.t); // Partnerknoten sitzt am selben Kurvenparameter
sh[LK()].splice(nc.seg+1, 0, a); sh[OK()].splice(nc.seg+1, 0, b);
sel = []; commit(); return;
}
}
sel = [];
if (S.tool === 'free'){ freeRaw = [p]; return; }
snap();
if (act){ act.pts.push(p); act.pb.push(clone(p)); }
else S.shapes.push(mkShape([p], false, false));
updateHint();
});
c2.addEventListener('pointermove', e => {
cursor = evW(e);
if (drag){
if (!dragSnapped){ snap(); dragSnapped = true; }
const q = evW(e), sh = S.shapes[drag.si];
sh[LK()][drag.pi] = q;
if (S.coupled) sh[OK()][drag.pi] = clone(q); // gekoppelt: Partnerknoten wird deckungsgleich
} else if (freeRaw){
const p = evW(e), q = freeRaw[freeRaw.length-1];
if (Math.hypot(p.x-q.x, p.y-q.y)*vs() > 3) freeRaw.push(p);
}
});
c2.addEventListener('pointerup', () => {
if (drag){ const moved = dragSnapped; drag = null; if (moved) commit(); }
if (freeRaw){ const r = freeRaw; freeRaw = null; finishFree(r); }
});
c2.addEventListener('pointerleave', () => { cursor = null; });
c2.addEventListener('dblclick', e => {
const hit = hitPoint(e), act = active();
if (!hit) return;
const sh = S.shapes[hit.si];
if (sh === act && hit.pi === sh.pts.length-1){ finishActive(); return; }
sel = [hit]; deleteSelected();
});
window.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'){ e.preventDefault(); undo(); return; }
if (e.key === 'Delete' || e.key === 'Backspace'){ if (sel.length){ e.preventDefault(); deleteSelected(); } return; }
if (e.key === 'Enter') finishActive();
if (e.key === 'Escape') cancelActive();
});
function finishFree(raw){
let L = 0; for (let i = 1; i < raw.length; i++) L += Math.hypot(raw[i].x-raw[i-1].x, raw[i].y-raw[i-1].y);
if (L*vs() < 60 || raw.length < 5) return;
let p = raw.map(q => [q.x, q.y]);
for (let pass = 0; pass < 3; pass++){
p = p.map((q,i) => { let sx=0, sy=0, c=0; for (let k = -2; k <= 2; k++){ const r = p[i+k]; if (r){ sx+=r[0]; sy+=r[1]; c++; } } return [sx/c, sy/c]; });
}
const sc = vs(), first = S.shapes.length === 0;
const endD = Math.hypot(p[0][0]-p[p.length-1][0], p[0][1]-p[p.length-1][1])*sc;
const closed = first || endD < 30;
snap();
let pts;
if (closed) pts = resampleN(p, Math.max(6, Math.min(40, Math.round(perim(p)*sc/34))));
else pts = resampleOpen(p, Math.max(2, Math.min(40, Math.round(polyLen(p)*sc/34)+1)));
S.shapes.push(mkShape(pts.map(q => ({x: q[0], y: q[1]})), closed, true));
commit();
}
function draw2(){
const c = ctx; c.setTransform(dpr,0,0,dpr,0,0); c.clearRect(0,0,cw,ch);
const s = vs(), lk = LK(), ok = OK(), differ = layersDiffer();
c.lineWidth = 1;
for (let x = -400; x <= 400; x += 50){ c.strokeStyle = x % 100 === 0 ? '#eeeeee' : '#f6f6f6'; const X = cw/2 + x*s; c.beginPath(); c.moveTo(X,0); c.lineTo(X,ch); c.stroke(); }
for (let y = -400; y <= 400; y += 50){ c.strokeStyle = y % 100 === 0 ? '#eeeeee' : '#f6f6f6'; const Y = ch/2 + y*s; c.beginPath(); c.moveTo(0,Y); c.lineTo(cw,Y); c.stroke(); }
const path = (poly, closed) => { c.beginPath(); poly.forEach((p,i) => { const q = w2s(p[0],p[1]); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); }); if (closed) c.closePath(); };
const o = S.shapes[0];
// aktive Lage: Flächen
if (o && o.closed && o[lk].length >= 3){ path(splinePoly(o[lk], 10), true); c.fillStyle = S.layer === 'top' ? '#f8f8f8' : '#f1f1f1'; c.fill(); }
S.shapes.forEach((sh, i) => { if (i > 0 && sh.closed && sh[lk].length >= 3){ path(splinePoly(sh[lk], 10), true); c.fillStyle = '#fff'; c.fill(); } });
// andere Lage als leicht transparenter Schatten
if (differ){
if (o && o.closed && o[ok].length >= 3){ path(splinePoly(o[ok], 10), true); c.fillStyle = 'rgba(255,45,149,0.06)'; c.fill(); }
c.strokeStyle = 'rgba(17,17,17,0.28)'; c.lineWidth = 1.3;
S.shapes.forEach(sh => { if (sh.done && sh[ok].length >= 2){ path(shapePoly(sh, ok, 10), sh.closed); c.stroke(); } });
}
// aktive Lage: Linien
c.strokeStyle = '#111'; c.lineWidth = 1.5;
S.shapes.forEach(sh => {
if (sh.closed && sh[lk].length >= 3){ path(splinePoly(sh[lk], 10), true); c.stroke(); }
else if (sh.done && sh[lk].length >= 2){ c.setLineDash([7,4]); path(splinePolyOpen(sh[lk], 10), false); c.stroke(); c.setLineDash([]); }
});
if (model && !dirty){ // Endkontur nach dem Aufpumpen (Einlaufen sichtbar)
c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.setLineDash([5,4]);
for (const ch_ of model.chains){
c.beginPath();
for (let i = 0; i < ch_.n; i++){ const k = ch_.s + i, q = w2s(model.pos[3*k]+model.cx, model.pos[3*k+2]+model.cy); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); }
if (ch_.closed) c.closePath();
c.stroke();
}
c.setLineDash([]);
}
const act = active();
if (act && act[lk].length >= 1){
const idx = S.shapes.indexOf(act);
if (act[lk].length >= 3){
c.strokeStyle = '#cfcfcf'; c.lineWidth = 1; c.setLineDash([4,4]);
path(idx === 0 ? splinePoly(act[lk], 10) : splinePolyOpen(act[lk], 10), idx === 0); c.stroke(); c.setLineDash([]);
}
c.strokeStyle = '#111'; c.lineWidth = 1.2; c.beginPath();
act[lk].forEach((p,i) => { const q = w2s(p.x,p.y); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); });
if (cursor && S.tool === 'spline'){ const q = w2s(cursor.x,cursor.y); c.lineTo(q[0],q[1]); }
c.stroke();
}
// Partnerknoten der anderen Lage und ihre Verbindungslinien
if (differ){
S.shapes.forEach(sh => sh[lk].forEach((p,i) => {
const r = sh[ok][i]; if (!r || Math.hypot(p.x-r.x, p.y-r.y) < 0.3) return;
const a = w2s(p.x,p.y), b = w2s(r.x,r.y);
c.strokeStyle = 'rgba(255,45,149,0.55)'; c.lineWidth = 1; c.beginPath(); c.moveTo(a[0],a[1]); c.lineTo(b[0],b[1]); c.stroke();
c.fillStyle = 'rgba(17,17,17,0.35)'; c.beginPath(); c.arc(b[0], b[1], 2.5, 0, Math.PI*2); c.fill();
}));
}
// Segmentlängen
if (S.lens){
c.font = '10px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.lineJoin = 'round';
S.shapes.forEach(sh => {
if (!sh.done || sh[lk].length < 2) return;
for (let k = 0; k < segCount(sh[lk], sh.closed); k++){
const pa = segPoly(sh[lk], sh.closed, k, 10), pb = segPoly(sh[ok], sh.closed, k, 10);
const La = plLen(pa), Lb = plLen(pb), d = Lb > 0 ? (La-Lb)/Lb : 0, bad = Math.abs(d) >= 0.005;
const m = pa[5], t0 = pa[4], t1 = pa[6];
let nx = -(t1[1]-t0[1]), ny = t1[0]-t0[0]; const nl = Math.hypot(nx,ny) || 1; nx /= nl; ny /= nl;
const q = w2s(m[0], m[1]), X = q[0]+nx*10, Y = q[1]+ny*10;
const txt = Math.round(La) + (bad ? ' (' + (d > 0 ? '+' : '−') + fmt(Math.abs(d)*100, 1) + ' %)' : '');
c.lineWidth = 3; c.strokeStyle = '#fff'; c.strokeText(txt, X, Y);
c.fillStyle = bad ? '#ff2d95' : '#8c8c8c'; c.fillText(txt, X, Y);
}
});
c.textBaseline = 'alphabetic';
}
S.shapes.forEach((sh, si) => sh[lk].forEach((p,i) => {
const q = w2s(p.x,p.y), on = isSel(si, i);
if (sh === act && i === 0 && sh[lk].length >= 3){ c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.beginPath(); c.arc(q[0], q[1], 9, 0, Math.PI*2); c.stroke(); }
c.fillStyle = on ? '#ff2d95' : '#fff'; c.strokeStyle = on ? '#ff2d95' : '#111'; c.lineWidth = 1;
const r = on ? 4.5 : 3.5;
c.beginPath(); c.rect(q[0]-r, q[1]-r, 2*r, 2*r); c.fill(); c.stroke();
}));
if (freeRaw && freeRaw.length > 1){
c.strokeStyle = '#ff2d95'; c.lineWidth = 1.5; c.beginPath();
freeRaw.forEach((p,i) => { const q = w2s(p.x,p.y); i ? c.lineTo(q[0],q[1]) : c.moveTo(q[0],q[1]); }); c.stroke();
}
const bx = cw - 14, by = ch - 16; c.strokeStyle = '#8c8c8c'; c.fillStyle = '#8c8c8c'; c.lineWidth = 1;
c.beginPath(); c.moveTo(bx-100*s, by); c.lineTo(bx, by); c.moveTo(bx-100*s, by-4); c.lineTo(bx-100*s, by+4); c.moveTo(bx, by-4); c.lineTo(bx, by+4); c.stroke();
c.font = '11px "Helvetica Neue",Helvetica,Arial,sans-serif'; c.textAlign = 'right'; c.fillText('100 mm', bx, by-8);
}
/* ---------- Formen ---------- */
const sq = (n, a, b, p) => Array.from({length: n}, (_,i) => { const t = i/n*Math.PI*2, c = Math.cos(t), s = Math.sin(t); return {x: a*Math.sign(c)*Math.pow(Math.abs(c), p), y: b*Math.sign(s)*Math.pow(Math.abs(s), p)}; });
const PRESETS = {
kreis: () => Array.from({length: 12}, (_,i) => { const t = i/12*Math.PI*2; return {x: 150*Math.cos(t), y: 150*Math.sin(t)}; }),
viereck: () => sq(24, 140, 140, 0.25),
kapsel: () => sq(20, 200, 65, 0.5)
};
document.querySelectorAll('[data-p]').forEach(b => b.addEventListener('click', () => {
snap(); S.shapes = [mkShape(PRESETS[b.dataset.p](), true, true)]; sel = []; fitted = false; msg(''); commit(true);
}));
function setTool(t){
finishActive();
S.tool = t; $('#tSpline').classList.toggle('on', t === 'spline'); $('#tFree').classList.toggle('on', t === 'free'); updateHint();
}
$('#tSpline').onclick = () => setTool('spline');
$('#tFree').onclick = () => setTool('free');
$('#bUndo').onclick = undo;
$('#bNew').onclick = () => { snap(); S.shapes = []; sel = []; clearModel(); msg(''); updateHint(); };
$('#bRe').onclick = () => build(true);
/* ---------- Lagen ---------- */
function setLayer(l){
S.layer = l; sel = []; drag = null;
$('#lyT').classList.toggle('on', l === 'top'); $('#lyB').classList.toggle('on', l === 'bottom');
updateHint();
}
function syncLens(){ $('#lnb').classList.toggle('on', S.lens); }
function setCoupled(v){
S.coupled = v;
$('#cpl').classList.toggle('on', v); $('#cpl').textContent = v ? 'Gekoppelt' : 'Entkoppelt';
if (!v){ S.lens = true; syncLens(); }
updateHint();
}
$('#lyT').onclick = () => setLayer('top');
$('#lyB').onclick = () => setLayer('bottom');
$('#cpl').onclick = () => setCoupled(!S.coupled);
$('#lnb').onclick = () => { S.lens = !S.lens; syncLens(); };
$('#alg').onclick = () => { snap(); S.shapes.forEach(sh => { sh[OK()] = sh[LK()].map(clone); }); commit(); };
function updateHint(){
const o = S.shapes[0], act = active(); let t;
if (!o) t = S.tool === 'free' ? 'Außenkontur freihand ziehen, sie schließt sich von selbst.' : 'Außenkontur zeichnen: Klicken setzt Punkte, ein Klick auf den ersten Punkt schließt die Form.';
else if (act && S.shapes.indexOf(act) === 0) t = 'Ersten Punkt anklicken oder Enter drücken, um die Form zu schließen.';
else if (act) t = 'Enter oder Doppelklick auf den letzten Punkt beendet die Linie als Naht. Ein Klick auf den ersten Punkt schließt sie als Ausschnitt.';
else if (S.tool === 'free') t = 'Innen freihand ziehen: endet der Strich nahe am Start, wird es ein Ausschnitt, sonst eine Naht. Punkt anklicken und Entf drücken löscht ihn.';
else t = 'Innen zeichnen: eine offene Linie ist eine Naht, eine geschlossene Form ein Ausschnitt. Punkt anklicken (Shift für mehrere) und Entf löscht ihn. Shift+Klick auf eine Kurve fügt einen Punkt ein.';
if (o && !S.coupled) t = 'Entkoppelt: Es bewegt sich nur die Lage ' + (S.layer === 'top' ? 'oben' : 'unten') + '. ' + t;
$('#hint').textContent = t;
$('#tag2').textContent = 'Zuschnitt ' + (S.layer === 'top' ? 'oben' : 'unten') + ' in mm' + (layersDiffer() ? ', die andere Lage als Schatten' : '');
}
/* ---------- SVG-Export ---------- */
function bez(pts, closed){
const n = pts.length, f = v => v.toFixed(2);
const g = i => closed ? pts[(i+n)%n] : pts[Math.max(0, Math.min(n-1, i))];
let d = 'M' + f(pts[0].x) + ' ' + f(pts[0].y);
for (let i = 0; i < (closed ? n : n-1); i++){
const p0 = g(i-1), p1 = g(i), p2 = g(i+1), p3 = g(i+2);
d += 'C' + f(p1.x+(p2.x-p0.x)/6) + ' ' + f(p1.y+(p2.y-p0.y)/6) + ' ' + f(p2.x-(p3.x-p1.x)/6) + ' ' + f(p2.y-(p3.y-p1.y)/6) + ' ' + f(p2.x) + ' ' + f(p2.y);
}
return d + (closed ? 'Z' : '');
}
function buildSVG(){
const o = S.shapes[0];
if (!o || !o.closed || o.pts.length < 3) return null;
const list = S.shapes.filter(sh => sh.done && sh.pts.length >= (sh.closed ? 3 : 2));
const differ = layersDiffer();
const layers = differ ? [['oben', 'pts'], ['unten', 'pb']] : [['', 'pts']];
const m = 5, gap = 20, f = v => v.toFixed(2);
let curX = m, maxH = 0, body = '';
for (const [name, key] of layers){
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
for (const sh of list) for (const p of shapePoly(sh, key, 16)){ x0 = Math.min(x0,p[0]); x1 = Math.max(x1,p[0]); y0 = Math.min(y0,p[1]); y1 = Math.max(y1,p[1]); }
const tx = curX - x0, ty = m - y0;
const tp = sh => sh[key].map(p => ({x: p.x+tx, y: p.y+ty}));
const cuts = list.filter(sh => sh.closed).map(sh => ' <path d="' + bez(tp(sh), true) + '"/>').join('\n');
const welds = list.filter(sh => !sh.closed).map(sh => ' <path d="' + bez(tp(sh), false) + '"/>').join('\n');
const pre = name ? name + '-' : '';
body += (name ? ' <g id="' + name + '">\n' : ' <g>\n') +
' <g id="' + pre + 'schnitt" fill="none" stroke="#000000" stroke-width="0.3">\n' + cuts + '\n </g>\n' +
(welds ? ' <g id="' + pre + 'naht" fill="none" stroke="#0000ff" stroke-width="0.3">\n' + welds + '\n </g>\n' : '') +
' </g>\n';
curX += (x1-x0) + gap; maxH = Math.max(maxH, y1-y0);
}
const W = curX - gap + m, H = maxH + 2*m;
const note = differ ? ' <!-- oben und unten: beide in Ansicht von oben gezeichnet. Zum Zuschneiden das untere Teil bei Bedarf spiegeln. -->\n' : ' <!-- Beide Lagen haben dasselbe Muster: zweimal zuschneiden. -->\n';
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
'<svg xmlns="http://www.w3.org/2000/svg" width="' + f(W) + 'mm" height="' + f(H) + 'mm" viewBox="0 0 ' + f(W) + ' ' + f(H) + '">\n' + note + body + '</svg>\n';
}
async function saveFile(name, text, mime){
try {
const dl = (window.claude && typeof window.claude.use === 'function') ? await window.claude.use('downloads') : null;
if (dl){ await dl.save({filename: name, data: text}); return 'saved'; }
} catch (err) { if (err && err.code === 'declined') return 'declined'; }
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], {type: mime || 'text/plain'})); a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
return 'anchor';
}
$('#bSvg').onclick = () => {
const svg = buildSVG();
if (!svg){ msg('Erst eine geschlossene Außenkontur zeichnen.', true); return; }
msg(''); saveFile('inflate-zuschnitt.svg', svg, 'image/svg+xml').then(r => { if (r === 'saved') msg('SVG gespeichert.'); });
};
/* ---------- 3D ---------- */
let renderer, scene, camera, mesh, wire, seam, floor, geo, colArr, mat;
let az = 0.6, el = 0.5, dist = 700;
function makeEnv(){
const c = document.createElement('canvas'); c.width = 512; c.height = 256;
const g = c.getContext('2d'), gr = g.createLinearGradient(0,0,0,256);
gr.addColorStop(0, '#ffffff'); gr.addColorStop(0.5, '#e4e4e4'); gr.addColorStop(1, '#b4b4b4');
g.fillStyle = gr; g.fillRect(0,0,512,256);
g.fillStyle = '#ffffff';
g.fillRect(40,50,110,50); g.fillRect(250,30,140,44); g.fillRect(420,70,60,80); g.fillRect(170,120,70,22);
g.fillStyle = '#9a9a9a'; g.fillRect(0,150,512,10);
const tex = new THREE.CanvasTexture(c); tex.mapping = THREE.EquirectangularReflectionMapping;
const pm = new THREE.PMREMGenerator(renderer), rt = pm.fromEquirectangular(tex);
tex.dispose(); pm.dispose();
return rt.texture;
}
if (typeof THREE !== 'undefined'){
renderer = new THREE.WebGLRenderer({antialias: true});
renderer.setClearColor(0xffffff, 1);
$('#c3').appendChild(renderer.domElement);
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(35, 1, 1, 20000);
scene.environment = makeEnv();
scene.add(new THREE.HemisphereLight(0xffffff, 0xdcdcdc, 0.3));
const dl = new THREE.DirectionalLight(0xffffff, 0.45); dl.position.set(200, 500, 300); scene.add(dl);
const dl2 = new THREE.DirectionalLight(0xffffff, 0.15); dl2.position.set(-300, -200, -200); scene.add(dl2);
floor = new THREE.GridHelper(1600, 32, 0xe4e4e4, 0xf0f0f0); scene.add(floor);
mat = new THREE.MeshStandardMaterial({vertexColors: true, roughness: 0.45, metalness: 0, side: THREE.DoubleSide, envMapIntensity: 0.9});
const rd = renderer.domElement; let dragging = false, lx = 0, ly = 0;
rd.addEventListener('pointerdown', e => { dragging = true; lx = e.clientX; ly = e.clientY; rd.setPointerCapture(e.pointerId); rd.style.cursor = 'grabbing'; });
rd.addEventListener('pointermove', e => { if (!dragging) return; az -= (e.clientX-lx)*0.006; el = Math.max(-1.5, Math.min(1.5, el + (e.clientY-ly)*0.006)); lx = e.clientX; ly = e.clientY; });
rd.addEventListener('pointerup', () => { dragging = false; rd.style.cursor = 'grab'; });
rd.addEventListener('wheel', e => { e.preventDefault(); dist *= Math.exp(e.deltaY*0.001); dist = Math.max(80, Math.min(6000, dist)); }, {passive: false});
}
function camUpdate(){
camera.position.set(dist*Math.cos(el)*Math.sin(az), dist*Math.sin(el), dist*Math.cos(el)*Math.cos(az));
camera.lookAt(0,0,0);
}
function clearModel(){
model = null; setDirty(false);
if (!scene) return;
for (const o of [mesh, wire, seam]) if (o){ scene.remove(o); if (o !== mesh && o.material) o.material.dispose(); if (o.geometry && o !== mesh && o !== wire) o.geometry.dispose(); }
if (geo) geo.dispose();
mesh = wire = seam = geo = null;
}
function install(m){
clearModel();
model = m; Pcur = 0;
geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(m.pos), 3));
colArr = new Float32Array(3*m.n).fill(1);
geo.setAttribute('color', new THREE.BufferAttribute(colArr, 3));
geo.setIndex(new THREE.BufferAttribute(Uint32Array.from(m.tri), 1));
mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; scene.add(mesh);
wire = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({color: 0x000000, wireframe: true, transparent: true, opacity: 0.12}));
wire.frustumCulled = false; wire.visible = S.wire; scene.add(wire);
const sg = new THREE.BufferGeometry();
sg.setAttribute('position', geo.attributes.position);
sg.setIndex(new THREE.BufferAttribute(m.seamIdx, 1));
seam = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({color: 0x111111})); seam.frustumCulled = false; scene.add(seam);
floor.position.y = -0.36*Math.max(m.W0, m.D0);
if (!fitted){ dist = Math.max(m.W0, m.D0)*2.3; fitted = true; }
applyMat(); setDirty(false);
}
/* ---------- Build ---------- */
let bt = 0;
function scheduleBuild(d){ clearTimeout(bt); bt = setTimeout(build, d); }
function build(manual){
if (!renderer) return;
const o = S.shapes[0];
if (!o || !o.closed){ clearModel(); if (manual === true) msg('Zuerst eine geschlossene Außenkontur zeichnen.', true); return; }
const r = prepare(S.shapes, S.N);
if (r.error){ msg(r.error, true); return; }
msg('');
install(buildModel(r.G, r.h));
}
/* ---------- Werte ---------- */
const fmt = (v, d) => v.toLocaleString('de-DE', {minimumFractionDigits: d, maximumFractionDigits: d});
function updateStats(){
if (!model){ $('#st').textContent = ''; return; }
const a = analyse(model);
const shW = (1 - a.w/model.W0)*100, shD = (1 - a.d/model.D0)*100;
const cut = Math.abs(model.At-model.Ab)/model.At > 0.005 ? 'oben ' + fmt(model.At/100, 0) + ' / unten ' + fmt(model.Ab/100, 0) + ' cm²' : '2 × ' + fmt(model.At/100, 0) + ' cm²';
$('#st').textContent =
'Volumen ' + fmt(a.vol/1e6, 1) + ' l. Höhe ' + fmt(a.h, 0) + ' mm. ' +
'Einlaufen ' + fmt(shW, 1) + ' % breit, ' + fmt(shD, 1) + ' % tief. ' +
'Dehnung Ø ' + fmt(a.mean*100, 1) + ' %, max ' + fmt(a.max*100, 1) + ' %. ' +
'Zuschnitt ' + cut + '. Netz ' + fmt(model.h, 1) + ' mm Kante.';
if (S.strain){
const ref = Math.max(0.005, a.max*0.8);
for (let i = 0; i < model.n; i++){
const s = model.scnt[i] ? Math.max(0, Math.min(1, (model.sacc[i]/model.scnt[i])/ref)) : 0;
colArr[3*i] = 1; colArr[3*i+1] = 1 - 0.82*s; colArr[3*i+2] = 1 - 0.42*s;
}
geo.attributes.color.needsUpdate = true;
}
}
/* ---------- Regler ---------- */
function applyMat(){
if (!mat) return;
mat.color.set(S.strain ? '#ffffff' : $('#col').value);
mat.roughness = 0.9 - 0.87*(+$('#gl').value/100);
mat.metalness = +$('#me').value/100;
mat.needsUpdate = true;
}
function readSliders(){
S.pressure = +$('#pr').value; $('#prv').textContent = fmt(S.pressure, 1) + ' kPa';
S.Et = Math.pow(1000, +$('#et').value/100);
$('#etv').textContent = fmt(S.Et, S.Et < 10 ? 1 : 0) + ' N/mm';
$('#glv').textContent = $('#gl').value + ' %'; $('#mev').textContent = $('#me').value + ' %';
S.N = Math.round(250*Math.pow(16, +$('#rs').value/100)/10)*10; $('#rsv').textContent = '≈ ' + S.N;
applyMat();
}
for (const id of ['#pr', '#et', '#col', '#gl', '#me', '#rs']) $(id).addEventListener('input', readSliders);
$('#rs').addEventListener('input', () => { if (model) setDirty(true); });
$('#ck1').addEventListener('change', e => {
S.strain = e.target.checked;
if (!S.strain && geo){ colArr.fill(1); geo.attributes.color.needsUpdate = true; }
applyMat(); updateStats();
});
$('#ck2').addEventListener('change', e => { S.wire = e.target.checked; if (wire) wire.visible = S.wire; });
readSliders();
/* ---------- Größe & Loop ---------- */
function resize(){
dpr = Math.min(window.devicePixelRatio || 1, 2);
const l = $('#l').getBoundingClientRect();
cw = l.width; ch = l.height;
c2.width = Math.max(1, Math.round(cw*dpr)); c2.height = Math.max(1, Math.round(ch*dpr));
if (renderer){
const r = $('#r').getBoundingClientRect();
renderer.setPixelRatio(dpr); renderer.setSize(r.width, r.height, false);
renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%';
camera.aspect = r.width/Math.max(1, r.height); camera.updateProjectionMatrix();
}
}
new ResizeObserver(resize).observe($('#l'));
new ResizeObserver(resize).observe($('#r'));
window.addEventListener('resize', resize);
let frame = 0;
function loop(){
requestAnimationFrame(loop);
if (model && renderer){
Pcur += (S.pressure - Pcur)*0.03; if (Math.abs(S.pressure - Pcur) < 1e-3) Pcur = S.pressure;
stepSim(model, Pcur*1e-3, S.Et);
geo.attributes.position.array.set(model.pos); geo.attributes.position.needsUpdate = true;
geo.computeVertexNormals();
if (frame % 8 === 0) updateStats();
}
if (frame % 15 === 0) updateHint();
draw2();
if (renderer){ camUpdate(); renderer.render(scene, camera); }
frame++;
}
resize(); updateHint();
S.shapes = [mkShape(PRESETS.kreis(), true, true)]; build();
loop();
</script>
</body>
</html>