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>LACE:FORM v0.8</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#fff;color:#111;font:12px/1.4 -apple-system,system-ui,Helvetica,Arial,sans-serif}
#app{display:grid;grid-template-columns:310px 1fr;height:100%}
#side{border-right:1px solid #e6e6e6;overflow-y:auto;padding:14px 14px 40px;background:#fff}
#main{display:flex;flex-direction:column;min-width:0;min-height:0}
h1{font-size:13px;letter-spacing:.14em;margin:0 0 12px;font-weight:600}
details{border-top:1px solid #ececec;padding:8px 0}
summary{cursor:pointer;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#555;list-style:none;padding:2px 0}
summary::-webkit-details-marker{display:none}
summary:after{content:"+";float:right;color:#999}
details[open] summary:after{content:"–"}
.row{display:grid;grid-template-columns:1fr 92px 36px;align-items:center;gap:6px;margin:6px 0}
.row label{color:#444}
.row output{text-align:right;color:#888;font-variant-numeric:tabular-nums}
.sel{display:grid;grid-template-columns:1fr 140px;align-items:center;gap:6px;margin:6px 0}
select,button,input[type=number],input[type=text]{font:inherit;border:1px solid #d8d8d8;background:#fff;color:#111;padding:4px 6px;border-radius:3px}
button{cursor:pointer}button:hover{background:#f4f4f4}
button.on{background:#111;color:#fff;border-color:#111}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:24px;margin:0;background:transparent;touch-action:pan-y}
input[type=range]::-webkit-slider-runnable-track{height:2px;background:#cfcfcf;border-radius:1px}
input[type=range]::-moz-range-track{height:2px;background:#cfcfcf;border-radius:1px}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:#111;margin-top:-7px;border:0}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:#111;border:0}
body{touch-action:manipulation;-webkit-text-size-adjust:100%}
.chk{display:flex;gap:6px;align-items:center;margin:6px 0;color:#444}
.btns{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}
#layers .li{padding:6px 8px;border:1px solid #e6e6e6;border-radius:3px;margin:4px 0;cursor:pointer;display:flex;justify-content:space-between;gap:8px;color:#444}
#layers .li.edge{background:#fafafa;border-style:dashed}
#layers .li.on{border-color:#111;color:#111}
#layers .li small{color:#999}
#tabs{display:flex;border-bottom:1px solid #e6e6e6;padding:0 10px}
#tabs button{border:0;border-bottom:2px solid transparent;border-radius:0;padding:10px 14px;color:#777;background:none}
#tabs button.on{color:#111;border-bottom-color:#111}
#stage{flex:1;position:relative;min-height:0;display:flex;align-items:center;justify-content:center;padding:12px 150px 12px 12px;overflow:hidden}
#edtop{padding:8px 12px;border-bottom:1px solid #eee;display:flex;flex-direction:column;gap:6px}
#edbanner{padding:6px 12px;font-size:12px;font-weight:700;letter-spacing:.04em;border-radius:3px;line-height:1.35}
#edbanner small{display:block;font-weight:400;letter-spacing:0;font-size:11px;opacity:.9}
#edbanner .dirty{font-weight:600;margin-left:8px}
#edbanner.draft{background:#111;color:#fff}
#edbanner.live{background:#e8590c;color:#fff}
#edbanner.saved{background:#fff;color:#111;border:2px solid #111}
#stage canvas#cEd.fdraft{border:3px dashed #111}
#stage canvas#cEd.flive{border:3px solid #e8590c}
#stage canvas#cEd.fsaved{border:3px solid #111}
.mrow{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.mrow input[type=text]{width:170px}
.mrow select{font:inherit;padding:4px 6px;border:1px solid #ccc;background:#fff}
#edset,#edset>div{display:flex;flex-wrap:wrap;gap:0 24px}
#edset .row{width:270px;margin:2px 0}#edset .chk{margin:2px 0}
#edbar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
#edbar button,.mrow button{padding:5px 12px}
.lc{border:1px solid #e6e6e6;border-radius:3px;margin:4px 0 8px}
.lcr{display:flex;align-items:center;gap:6px;padding:6px 8px;border-top:1px solid #f0f0f0;flex-wrap:wrap}.lcr:first-child{border-top:0}
.lcr b{width:70px;font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:#888;font-weight:500}
.lcr>span{flex:1;color:#333}.lcr .mid{display:flex;align-items:center;gap:6px;flex:1;min-width:90px}.lcr em{font-style:normal}
.lcr .th{width:34px;height:34px;border:1px solid #e6e6e6}
.lcr button{padding:2px 8px;font-size:11px}
.lcr small{color:#aaa}
.picker{border:1px solid #111;border-radius:3px;padding:6px;margin:4px 0 8px}
.lsec{grid-column:1/-1;font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:#999;margin-top:6px}
.lib{grid-template-columns:repeat(3,1fr)!important}
.lib .it{line-height:1.2}.lib .it.used{border-color:#e8590c}
.lib .cap{display:block;font-size:10px;padding:2px 4px;color:#555;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.lib .use{position:absolute;left:3px;top:3px;background:#e8590c;color:#fff;border-radius:8px;font-style:normal;font-size:10px;line-height:1;padding:2px 5px}
#tabs .sp{flex:1}
#tabs .auto{display:flex;align-items:center;gap:4px;font-size:11px;color:#777;margin-right:8px}
#bUpdate{align-self:center;margin:4px 0;padding:6px 18px;border:1px solid #111;background:#fff;color:#111;font-weight:600}
#bUpdate.dirty{background:#111;color:#fff}
#bUpdate:disabled{opacity:.6}
.lib .it .x{display:none}.lib .it:hover .x{display:block}
@media(pointer:coarse){.lib .it .x{display:block}}
#edbar button,.mrow button{padding:5px 14px}
#lbls{position:absolute;inset:0;pointer-events:none}
#lbls .lb{position:absolute;display:flex;align-items:center;gap:8px;pointer-events:auto;cursor:pointer;transform:translateY(-50%);color:#777;font-size:11px;line-height:1.25}
#lbls .lb i{display:block;width:1px;background:#bbb;align-self:stretch;position:absolute;left:0}
#lbls .lb span{padding-left:10px;white-space:nowrap}
#lbls .lb small{display:block;color:#aaa}
#lbls .lb.on{color:#111}
#lbls .lb.on i{background:#111;width:2px}
#stage canvas.v{max-width:100%;max-height:100%;border:1px solid #eee;background:#fff;touch-action:none}
#cEd{cursor:crosshair}
#info{padding:8px 14px;border-top:1px solid #eee;color:#777;font-size:11px;min-height:30px}
.hdr{margin:12px 0 2px;font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:#999;border-top:1px dashed #e6e6e6;padding-top:8px}
.lib{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:6px 0}
.lib .it{position:relative;border:1px solid #e6e6e6;border-radius:3px;cursor:pointer;background:#fff;padding:0;line-height:0}
.lib .it:hover{border-color:#111}
.lib canvas{width:100%;aspect-ratio:1;display:block}
.lib .x{position:absolute;top:1px;right:1px;line-height:1;font-size:11px;color:#aaa;padding:0 3px;display:none;background:#fff;border-radius:2px}
.lib .it:hover .x{display:block}
.ltabs{display:flex;gap:4px;flex-wrap:wrap}
.ltabs button{padding:3px 7px;font-size:11px;color:#666}
.lname{font-size:11px;color:#777;min-height:15px;margin:2px 0}
.hint{color:#999;font-size:11px;margin:4px 0}
#app{height:100vh;height:100dvh}
@media(pointer:coarse){
html,body{font-size:14px}
button,select,input[type=text],input[type=password]{min-height:40px;font-size:14px;padding:6px 10px}
.row{grid-template-columns:1fr 120px 42px}.sel{grid-template-columns:1fr 160px}
input[type=range]{height:34px}
input[type=range]::-webkit-slider-runnable-track{height:3px}
input[type=range]::-webkit-slider-thumb{width:26px;height:26px;margin-top:-11.5px}
input[type=range]::-moz-range-thumb{width:26px;height:26px}
summary{padding:10px 0;font-size:11px}
#layers .li{padding:11px 10px}
.chk input{width:20px;height:20px}
#tabs button{padding:14px 18px;font-size:14px}
#edbar button,.mrow button,#bUpdate{min-height:42px}
.lib{grid-template-columns:repeat(4,1fr)}
}
@media(max-width:1000px){#app{grid-template-columns:300px 1fr}#stage{padding-right:104px}#lbls .lb{font-size:10px}#lbls .lb small{display:none}}
@media(max-width:800px){#app{grid-template-columns:1fr;grid-template-rows:58% 1fr}#main{order:-1}#side{border-right:0;border-top:1px solid #e6e6e6;padding-bottom:80px}#stage{padding:8px 92px 8px 8px}.lib{grid-template-columns:repeat(5,1fr)}#edbar{flex-wrap:wrap;right:8px}#edbar .hint{display:none}}
@media(max-width:800px) and (orientation:landscape){#app{grid-template-columns:260px 1fr;grid-template-rows:1fr}#main{order:0}#side{border-right:1px solid #e6e6e6;border-top:0}}
</style>
</head>
<body>
<div id="app">
<div id="side">
<h1>LACE:FORM v0.8</h1>
<details open><summary>Schichten</summary>
<div id="layers"></div>
<div class="hint">Rand oben/unten = äußerer Abschluss des Stücks.</div><div class="btns"><button id="lAdd">+ neu</button><button id="lDup">Duplizieren</button><button id="lUp">↑</button><button id="lDn">↓</button><button id="lDel">✕</button></div></details>
<details open><summary>Schicht: Netzgrund + Motiv</summary><div id="lcard"></div><div id="g_layer"></div></details>
<details open><summary>Zeichnungen (Bibliothek)</summary>
<div class="btns"><button id="libNew">+ Neue Zeichnung</button></div>
<div class="ltabs" id="ltabs"></div>
<div class="lib" id="libGrid"></div>
<div class="hint">Klick = zum Bearbeiten im Tab „Motiv“ öffnen (Vorlagen werden dabei kopiert). <b>Zuweisen</b> passiert in der Schicht: Schicht wählen → „Motiv A / B“ → „wählen“. Orange Zahl = in so vielen Schichten genutzt.</div>
<a href="#" id="libRestore" style="display:none;color:#777;font-size:11px">Gelöschte Vorlagen wiederherstellen</a>
<div class="btns"><button id="prjSave">Alles sichern (.json)</button><button id="prjLoad">Laden …</button><input type="file" id="prjFile" accept=".json" hidden></div>
<div class="hint">Alles wird automatisch im Browser gespeichert. „Alles sichern“ zusätzlich nutzen, wenn du die Datei austauschst oder den Browser wechselst.</div></details>
<details open><summary>Stück & Kanten</summary><div id="g_piece"></div></details>
<details open><summary>Fertigung</summary><div id="g_fab"></div></details>
<details id="srvBox"><summary>Server (optional)</summary>
<div class="hint" id="srvStat">Prüfe …</div>
<div id="srvUI" style="display:none">
<div class="sel"><label>Passwort</label><input type="password" id="srvPass" autocomplete="off"></div>
<div class="sel"><label>Name</label><input type="text" id="srvName" value="Spitze 1"></div>
<div class="btns"><button id="srvSave">Auf Server speichern</button><button id="srvRes">+ SVG & PNG ablegen</button></div>
<div class="sel"><label>Auf dem Server</label><select id="srvSel"></select></div>
<div class="btns"><button id="srvLoad">Laden</button><button id="srvDel">Löschen</button><button id="srvRef">↻</button></div>
<div class="chk"><input type="checkbox" id="srvAuto"><label>Änderungen automatisch speichern</label></div>
<div id="srvFiles" class="hint"></div></div></details>
<details open><summary>Export</summary><div id="g_exp"></div>
<div class="btns"><button id="bSVG">SVG</button><button id="bPNG">PNG</button><button id="bRnd">Würfeln</button></div></details>
</div>
<div id="main">
<div id="tabs"><button data-t="pat" class="on">Muster</button><button data-t="ed">Motiv</button><span class="sp"></span><label class="auto"><input type="checkbox" id="autoUpd">auto</label><button id="bUpdate">Update</button></div>
<div id="edtop" style="display:none"><div id="edbanner"></div>
<div class="mrow"><input type="text" id="dName" placeholder="Name der Zeichnung"><select id="dCat"></select><button id="dSave" class="on">Speichern</button><button id="dCopy">Als Kopie speichern</button><button id="dNew">Neu</button></div>
<div id="edset"><div id="g_draft"></div><div id="g_draw"></div></div>
<div id="edbar"><button id="tPen" data-tool="thread">Stift</button><span class="hint">Radierer:</span><button id="tEra0" data-tool="erase" data-sz="0.6">sehr klein</button><button id="tEraS" data-tool="erase" data-sz="1.5">normal</button><button id="tEraL" data-tool="erase" data-sz="6">groß</button><button id="eUndo">Rückgängig</button><button id="eClear">Leeren</button><span class="hint">links zeichnen · rechts radieren</span></div></div>
<div id="stage">
<canvas id="cPat" class="v"></canvas>
<canvas id="cEd" class="v" width="720" height="720" style="display:none"></canvas>
<img id="svgPrev" alt="" style="position:absolute;display:none;border:1px solid #eee;background:#fff"><div id="lbls"></div>
</div>
<div id="info"></div>
</div>
</div>
<script type="module">
const $=s=>document.querySelector(s);
const TAU=Math.PI*2;
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
const lerp=(a,b,t)=>a+(b-a)*t;
function rng32(a){return()=>{a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
let seed=7,tab='pat',pending=null,patDirty=false,prevT=null,genId=0;
// ---------- generische UI ----------
function buildUI(host,spec,obj,onChange){
host.innerHTML='';
for(const it of spec){
const[t,id,label]=it,d=document.createElement('div');
if(t==='h'){d.className='hdr';d.textContent=id;host.appendChild(d);continue}
if(t==='s'){const[,,,mn,mx,st]=it;d.className='row';
d.innerHTML=`<label>${label}</label><input type="range" min="${mn}" max="${mx}" step="${st}" value="${obj[id]}"><output>${obj[id]}</output>`;
const inp=d.querySelector('input'),out=d.querySelector('output');
inp.oninput=()=>{obj[id]=+inp.value;out.textContent=inp.value;onChange(id)}}
else if(t==='c'){d.className='chk';d.innerHTML=`<input type="checkbox" ${obj[id]?'checked':''}><label>${label}</label>`;
d.querySelector('input').onchange=e=>{obj[id]=e.target.checked;onChange(id)}}
else if(t==='t'){d.className='sel';d.innerHTML=`<label>${label}</label><input type="text" value="${obj[id]}">`;
d.querySelector('input').oninput=e=>{obj[id]=e.target.value;onChange(id)}}
else{d.className='sel';d.innerHTML=`<label>${label}</label><select>${it[3].map(o=>`<option value="${o[0]}" ${o[0]===obj[id]?'selected':''}>${o[1]}</option>`).join('')}</select>`;
d.querySelector('select').onchange=e=>{obj[id]=e.target.value;onChange(id)}}
host.appendChild(d)}
}
// ---------- globale Parameter ----------
const P={thread:.47,hmm:200,ppm:8,frameT:true,frameB:true,
minS:.37,minG:.37,island:'bridge',endMode:'trim',endLen:1.6,svgSm:2,svgPrev:false,
tool:'thread',esym:'none',slot:'A',erase:1.5,ghost:true};
const SPEC={
piece:[['s','hmm','Länge der Bänder (mm)',40,400,1],['o','ppm','Auflösung px/mm',[['4','4'],['6','6'],['8','8'],['10','10']]],
['c','frameT','Linie rechts'],['c','frameB','Linie links']],
edge:[['t','name','Name'],['o','shape','Randform',[['scallop','Bögen'],['zig','Zacken'],['wave','Wellen'],['straight','gerade'],['none','kein Rand']]],
['s','scal','Breite einer Form (mm)',4,30,0.5],['s','dep','Tiefe (mm)',0,12,0.2],['s','lw','Randfaden-Stärke ×',0.5,3,0.1],
['s','picot','Picots (wenn kein Randmotiv)',0,1,0.05],
['h','Randmotiv (im Tab „Motiv“ zeichnen)'],['s','msize','Motivgröße (× Formbreite)',0.3,2,0.05],['s','mout','Abstand nach außen (mm)',-4,8,0.1]],
fab:[['s','minS','Min. Steg (mm)',0.2,2,0.01],['s','minG','Min. Lücke (mm)',0.2,2,0.01],['o','island','Lose Teile',[['bridge','verbinden'],['remove','entfernen']]],
['o','endMode','Freie Enden / Stege in der Luft',[['off','nicht prüfen'],['trim','kürzen'],['join','anbinden']]],['s','endLen','Enden bis (mm)',0.5,6,0.1]],
exp:[['s','svgSm','SVG-Glättung',0,5,1],['c','svgPrev','SVG-Vorschau anzeigen']],
draw:[['o','tool','Werkzeug',[['thread','Faden'],['fill','Fläche (Lasso)'],['erase','Radierer']]],
['o','esym','Symmetrie',[['none','keine'],['mx','spiegeln |'],['my','spiegeln —'],['mxy','spiegeln + '],['r3','radial 3'],['r4','radial 4'],['r5','radial 5'],['r6','radial 6'],['r8','radial 8']]],
['s','thread','Fadenstärke Motive (mm)',0.25,2.5,0.01],['s','erase','Radierer-Radius (mm)',0.3,15,0.1],['c','ghost','Nachbarkacheln zeigen']],
layer:[['t','name','Name'],['s','w','Bandbreite (mm)',4,90,0.5],
['o','rapport','Rapport',[['grid','einfach'],['mirror','gespiegelt'],['alt','wechselnd A/B'],['honey','Wabenform'],['log','logarithmisch'],['fib','Fibonacci'],['border','Bordüre (längs)']]],
['s','cell','Zellgröße (mm)',8,80,0.5],['s','scale','Motivgröße',0.4,1.4,0.01],['s','q','Log-Faktor',0.7,0.97,0.01],
['s','rep','Bordüre: Länge einer Wiederholung (mm)',4,60,0.5],['c','bmir','Bordüre: jede 2. Wiederholung spiegeln'],
['h','Netzgrund'],
['o','ground','Netzgrund',[['hex','Wabe'],['diamond','Raute'],['square','Quadrat'],['none','kein Netz']]],
['s','pitch','Maschenweite (mm)',3,10,0.1],['s','gw','Netz-Faden (mm)',0.25,2,0.01],['s','warp','Verzerrung',0,1,0.02],['s','bulge','Rundung',0,1,0.02],
['h','Netz ↔ Motiv'],
['s','halo','Freistellung (mm)',0,3,0.1],['s','fit','Netz schmiegt sich an',0,1,0.05],['s','fitR','Einflussradius (mm)',1,15,0.5],
['s','cont','Konturfaden (mm, 0 = aus)',0,3,0.1],['s','stemR','Steg-Reichweite (mm)',0,5,0.1],['s','stemD','Steg-Dichte',0.3,2.5,0.05],['s','stemW','Steg-Stärke',0.5,2,0.05],
['h','Übergang zur nächsten Schicht'],
['o','tr','Verlauf',[['straight','gerade'],['wave','Welle'],['zig','Zacken'],['arc','Bögen'],['noise','Zufall'],['step','Stufen']]],
['s','tamp','Amplitude (mm)',0,14,0.5],['s','twl','Wellenlänge (mm)',6,100,1],['s','tph','Phase',0,1,0.02],['c','sep','Trennlinie']]
};
function changed(id){
if(id==='tool'||id==='erase')syncTools();
if(id==='tool'||id==='esym'||id==='erase'||id==='ghost'||id==='thread'&&tab==='ed'){drawEditor();if(id==='thread')regen();return}
if(id==='svgPrev'||id==='svgSm'){clearTimeout(prevT);prevT=setTimeout(updatePrev,id==='svgSm'?200:0);return}
regen();
}
// Änderungen markieren nur „veraltet“ – neu berechnet wird per Update-Button (oder optional automatisch)
function regen(){saveSoon();patDirty=true;markDirty();clearTimeout(pending);if(P.autoUpd)pending=setTimeout(doUpdate,500)}
function markDirty(){const b=$('#bUpdate');if(!b)return;b.classList.toggle('dirty',patDirty);b.textContent=patDirty?'Update ●':'Update'}
function doUpdate(){clearTimeout(pending);const b=$('#bUpdate');b.disabled=true;b.textContent='Berechne …';
requestAnimationFrame(()=>setTimeout(()=>{try{generate()}finally{b.disabled=false;markDirty()}},20))}
// ---------- Schichten ----------
const GR={hex:'Wabe',diamond:'Raute',square:'Quadrat',none:'kein'};
const RAP_LABEL={border:'Bordüre',grid:'einfach',mirror:'gespiegelt',alt:'wechselnd',honey:'Wabe',log:'logarithmisch',fib:'Fibonacci'};
const emptyMotif=()=>({s:[],f:[]});
const hasM=m=>m&&(m.s.length||m.f.length);
const layers=[];let sel=0; // sel: Index der Schicht; -1 = Rand oben, -2 = Rand unten
const newEdge=name=>({edge:true,name,shape:'scallop',scal:12,dep:3.6,picot:.4,lw:1.4,msize:1,mout:0,mA:emptyMotif(),mB:emptyMotif()});
const edges=[newEdge('Rand oben'),newEdge('Rand unten')];
function newLayer(o={}){return Object.assign({name:'Schicht',w:30,rapport:'mirror',cell:24,scale:1,q:.88,halo:1,fit:.25,fitR:5,cont:0,stemR:.6,stemD:.8,stemW:1,rep:11,bmir:true,tr:'straight',tamp:2.5,twl:26,tph:0,ground:'hex',pitch:5.5,gw:.37,warp:.5,bulge:.5,sep:true,mA:emptyMotif(),mB:emptyMotif()},o)}
// Entwurfs-Element: ungebunden an eine Schicht, mit eigener Kachel je nach Art
const draft={id:null,name:'Neue Zeichnung',dirty:false,cat:'objekt',rapport:'grid',edge:false,cell:24,w:22,rep:16,scal:12,msize:1,side:'oben',bmir:false,mA:emptyMotif()};
const DRAFT_CATS=[['objekt','Objekt'],['flaeche','Fläche / Basismuster'],['borduere','Rand-Bordüre (Streifen)'],['rand','Randmotiv (äußerer Rand)']];
function syncDraft(){draft.rapport=draft.cat==='borduere'?'border':'grid';draft.edge=draft.cat==='rand'}
const layerAt=i=>i==='draft'?draft:i===-1?edges[0]:i===-2?edges[1]:layers[i];
const curL=()=>draft,slotKey=()=>'mA',curM=()=>draft.mA;
function renderList(){
const h=$('#layers');h.innerHTML='';
const row=(i,html,cls)=>{const d=document.createElement('div');d.className='li'+(i===sel?' on':'')+(cls||'');d.innerHTML=html;d.onclick=()=>{sel=i;pickK=null;renderLayers()};h.appendChild(d)};
const er=E=>`<span>${E.name}</span><small>${{scallop:'Bögen',zig:'Zacken',wave:'Wellen',straight:'gerade',none:'kein Rand'}[E.shape]}${hasM(E.mA)?' · Motiv':''}</small>`;
row(-1,er(edges[0]),' edge');
layers.forEach((L,i)=>row(i,`<span>${i+1}. ${L.name}</span><small>${L.w} mm · ${RAP_LABEL[L.rapport]} · Netz ${GR[L.ground]||'–'}${hasM(L.mA)||hasM(L.mB)?' · Motiv '+(hasM(L.mA)?'A':'')+(hasM(L.mB)?'B':''):''}</small>`));
row(-2,er(edges[1]),' edge');
}
function renderLayers(){
renderList();renderCard();updateBanner();
const L=layerAt(sel);buildUI($('#g_layer'),L.edge?SPEC.edge:SPEC.layer,L,id=>{if(id==='name')renderList();else if(id==='w'||id==='rapport'||id==='shape')renderList();regen()});
}
const act=f=>()=>{if(sel<0)return;f();renderLayers();regen()};
$('#lAdd').onclick=()=>{layers.splice(Math.max(0,sel)+1,0,newLayer({name:'Schicht '+(layers.length+1)}));sel=Math.max(0,sel)+1;renderLayers();regen()};
$('#lDup').onclick=act(()=>{layers.splice(sel+1,0,JSON.parse(JSON.stringify(layerAt(sel))));sel++});
$('#lUp').onclick=act(()=>{if(sel>0){[layers[sel-1],layers[sel]]=[layers[sel],layers[sel-1]];sel--}});
$('#lDn').onclick=act(()=>{if(sel<layers.length-1){[layers[sel+1],layers[sel]]=[layers[sel],layers[sel+1]];sel++}});
$('#lDel').onclick=act(()=>{if(layers.length>1){layers.splice(sel,1);sel=Math.max(0,sel-1)}});
const pathPoly=(c,pts,close)=>{c.moveTo(pts[0][0],pts[0][1]);for(let i=1;i<pts.length;i++)c.lineTo(pts[i][0],pts[i][1]);if(close)c.closePath()};
// ---------- Rauschen ----------
function hash2(x,y,s){let h=Math.imul(x|0,374761393)+Math.imul(y|0,668265263)+Math.imul(s|0,1442695041);h=Math.imul(h^h>>>13,1274126177);return((h^h>>>16)>>>0)/4294967296}
function vnoise(x,y,s){const xi=Math.floor(x),yi=Math.floor(y),fx=x-xi,fy=y-yi,u=fx*fx*(3-2*fx),v=fy*fy*(3-2*fy);
return lerp(lerp(hash2(xi,yi,s),hash2(xi+1,yi,s),u),lerp(hash2(xi,yi+1,s),hash2(xi+1,yi+1,s),u),v)*2-1}
// ---------- Netzgrund: verzogene Wabe / Raute / Quadrat ----------
function buildGround(kind,pitch,ang,warp,grad,bulge,W,H,cx,cy,sd){
if(kind==='none')return null;
const V=[],E=[],vm=new Map(),em=new Set();
const vid=(x,y)=>{const k=Math.round(x*4)+'_'+Math.round(y*4);let i=vm.get(k);if(i===undefined){i=V.length;V.push([x,y]);vm.set(k,i)}return i};
const edge=(a,b)=>{if(a===b)return;const k=a<b?a+'_'+b:b+'_'+a;if(em.has(k))return;em.add(k);E.push([a,b])};
const ext=1+grad*1.6;
if(kind==='hex'){
const a=pitch/Math.sqrt(3),dy=1.5*a,nj=Math.ceil(Hp_(H)*ext/dy/2)+3,ni=Math.ceil(W*ext/pitch/2)+3;
for(let j=-nj;j<=nj;j++)for(let i=-ni;i<=ni;i++){
const X=cx+pitch*(i+((j&1)?.5:0)),Y=cy+dy*j,ids=[];
for(let k=0;k<6;k++){const t=(30+60*k)*Math.PI/180;ids.push(vid(X+a*Math.cos(t),Y+a*Math.sin(t)))}
for(let k=0;k<6;k++)edge(ids[k],ids[(k+1)%6])}
}else{
const t=kind==='square'?0:ang*Math.PI/180,A=kind==='square'?[pitch,0]:[pitch*Math.cos(t),pitch*Math.sin(t)],B=kind==='square'?[0,pitch]:[pitch*Math.cos(t),-pitch*Math.sin(t)];
const M=Math.ceil(Math.hypot(W,H)*ext/Math.max(1,Math.min(Math.hypot(...A),Math.hypot(...B))))+3;
const Pt=(i,j)=>[cx+i*A[0]+j*B[0],cy+i*A[1]+j*B[1]],inb=p=>p[0]>-W*ext&&p[0]<W*(1+ext)&&p[1]>-H*ext&&p[1]<H*(1+ext);
for(let j=-M;j<=M;j++)for(let i=-M;i<=M;i++){const p=Pt(i,j);if(!inb(p))continue;const q=Pt(i+1,j),r=Pt(i,j+1);
const a=vid(p[0],p[1]);edge(a,vid(q[0],q[1]));edge(a,vid(r[0],r[1]))}
}
const R0=Math.hypot(W,H)/2,f=pitch*3.4;
for(const v of V){
const rr=Math.hypot(v[0]-cx,v[1]-cy)/R0,s=1+grad*1.1*rr*rr;
v[0]=cx+(v[0]-cx)*s;v[1]=cy+(v[1]-cy)*s;
v[0]+=warp*pitch*.5*vnoise(v[0]/f,v[1]/f,sd);v[1]+=warp*pitch*.5*vnoise(v[0]/f+17,v[1]/f+31,sd+3)}
const ed=E.map(([a,b])=>{const p=V[a],q=V[b],mx=(p[0]+q[0])/2,my=(p[1]+q[1])/2,dx=q[0]-p[0],dy=q[1]-p[1],l=Math.hypot(dx,dy)||1,o=bulge*l*.35*vnoise(mx/(pitch*2),my/(pitch*2),sd+9);
return[p[0],p[1],mx-dy/l*o,my+dx/l*o,q[0],q[1]]});
const nodes=V.filter(v=>v[0]>-pitch&&v[0]<W+pitch&&v[1]>-pitch&&v[1]<H+pitch);
return{ed:ed.filter(e=>Math.max(e[0],e[4])>-pitch&&Math.min(e[0],e[4])<W+pitch&&Math.max(e[1],e[5])>-pitch&&Math.min(e[1],e[5])<H+pitch),nodes};
}
const Hp_=h=>h;
// ---------- Bordüre ----------
function scallopSide(p0,p1,out,cnt,dep,shape='scallop'){
if(!cnt||dep<.01)return[p0];
const pts=[];
for(let k=0;k<cnt;k++)for(let i=0;i<18;i++){const u=i/18,t=(k+u)/cnt,off=dep*(shape==='zig'?1-Math.abs(2*u-1):shape==='wave'?.5-.5*Math.cos(TAU*u):Math.sqrt(Math.max(0,1-(2*u-1)**2)));
pts.push([p0[0]+(p1[0]-p0[0])*t+out[0]*off,p0[1]+(p1[1]-p0[1])*t+out[1]*off])}
return pts;
}
function rectPoly(x0,y0,x1,y1,on,cH,cV,dep,sg){
const tl=[x0,y0],tr=[x1,y0],br=[x1,y1],bl=[x0,y1];
return[...scallopSide(tl,tr,[0,-sg],on[0]?cH:0,dep),...scallopSide(tr,br,[sg,0],on[1]?cV:0,dep),
...scallopSide(br,bl,[0,sg],on[2]?cH:0,dep),...scallopSide(bl,tl,[-sg,0],on[3]?cV:0,dep)];
}
// ---------- Motiv-Vorlagen (Einheitskachel 0..1, Vektor) ----------
const cr=(cx,cy,r,n=36,a0=0,a1=TAU)=>{const p=[];for(let i=0;i<=n;i++){const a=a0+(a1-a0)*i/n;p.push([cx+r*Math.cos(a),cy+r*Math.sin(a)])}return p};
const spiral=(cx,cy,r0,r1,turns,a0,dir,n=44)=>{const p=[];for(let i=0;i<=n;i++){const t=i/n,a=a0+dir*t*turns*TAU,r=lerp(r0,r1,t);p.push([cx+r*Math.cos(a),cy+r*Math.sin(a)])}return p};
const xfp=(pts,f)=>pts.map(f);
const rotp=(pts,a,cx=.5,cy=.5)=>xfp(pts,([x,y])=>{const c=Math.cos(a),s=Math.sin(a),dx=x-cx,dy=y-cy;return[cx+dx*c-dy*s,cy+dx*s+dy*c]});
const sclp=(pts,k,cx=.5,cy=.5)=>xfp(pts,([x,y])=>[cx+(x-cx)*k,cy+(y-cy)*k]);
const movp=(pts,dx,dy)=>xfp(pts,([x,y])=>[x+dx,y+dy]);
// Blatt-Schlaufe entlang +x ab (0,0)
function loopPts(L,W,n=20){const t=[],b=[];for(let i=0;i<=n;i++){const s=i/n,h=W*Math.pow(Math.sin(Math.PI*Math.pow(s,.8)),.85);t.push([L*s,h]);b.push([L*s,-h])}return t.concat(b.reverse().slice(1,-1)).concat([t[0]])}
const placeP=(pts,x,y,a)=>rotp(movp(pts,x,y),a,x,y);
function line(x0,y0,x1,y1,n=8){const p=[];for(let i=0;i<=n;i++)p.push([lerp(x0,x1,i/n),lerp(y0,y1,i/n)]);return p}
const dotF=(x,y,r)=>cr(x,y,r,12);
function roundRect(x0,y0,x1,y1,r){const p=[];const c=[[x1-r,y0+r,-Math.PI/2],[x1-r,y1-r,0],[x0+r,y1-r,Math.PI/2],[x0+r,y0+r,Math.PI]];
for(const[cx,cy,a]of c)for(let i=0;i<=8;i++){const t=a+(Math.PI/2)*i/8;p.push([cx+r*Math.cos(t),cy+r*Math.sin(t)])}p.push(p[0]);return p}
function rosetteM(k=1){
const s=[],f=[];
f.push(dotF(.5,.5,.03));s.push(cr(.5,.5,.065));
for(let i=0;i<10;i++){const a=TAU*i/10;s.push(placeP(loopPts(.2,.052),.5+.065*Math.cos(a),.5+.065*Math.sin(a),a))}
for(let i=0;i<10;i++){const a=TAU*(i+.5)/10;s.push(placeP(loopPts(.12,.04),.5+.2*Math.cos(a),.5+.2*Math.sin(a),a).map(p=>p))}
s.push(cr(.5,.5,.34));
for(let i=0;i<20;i++){const a=TAU*i/20;f.push(dotF(.5+.385*Math.cos(a),.5+.385*Math.sin(a),.014))}
s.push(cr(.5,.5,.435));
const m={s,f};return k===1?m:scaleMotif(m,k);
}
function scaleMotif(m,k){return{s:m.s.map(p=>sclp(p,k)),f:m.f.map(p=>sclp(p,k))}}
function blueteM(){
const s=[],f=[];
for(let i=0;i<6;i++){const a=TAU*i/6-Math.PI/2,d=.2,cx=.5+d*Math.cos(a),cy=.5+d*Math.sin(a);
const e=[],e2=[];for(let j=0;j<=28;j++){const t=TAU*j/28;e.push([.14*Math.cos(t),.095*Math.sin(t)]);e2.push([.085*Math.cos(t),.045*Math.sin(t)])}
s.push(movp(rotp(e,a,0,0),cx,cy));s.push(movp(rotp(e2,a,0,0),cx,cy));
f.push(dotF(.5+.39*Math.cos(a),.5+.39*Math.sin(a),.017));
s.push(placeP(loopPts(.1,.035),.5+.34*Math.cos(a+TAU/12),.5+.34*Math.sin(a+TAU/12),a+TAU/12))}
s.push(cr(.5,.5,.085));f.push(dotF(.5,.5,.035));
for(let i=0;i<8;i++){const a=TAU*i/8;s.push(line(.5+.09*Math.cos(a),.5+.09*Math.sin(a),.5+.13*Math.cos(a),.5+.13*Math.sin(a),2))}
return{s,f};
}
function faecherM(){
const s=[],f=[],ax=.5,ay=.97;
const n=7;
for(let i=0;i<n;i++){const a=-Math.PI/2+(i-(n-1)/2)*.36,ex=ax+.62*Math.cos(a),ey=ay+.62*Math.sin(a);
const p=[];for(let j=0;j<=16;j++){const t=j/16,bend=Math.sin(Math.PI*t)*.06*(i-(n-1)/2)/3;p.push([lerp(ax,ex,t)+bend,lerp(ay,ey,t)])}
s.push(p);const dir=i%2?1:-1;s.push(spiral(ex,ey-.035,.04,.012,1.4,Math.PI/2,dir,30));
f.push(dotF(lerp(ax,ex,.55),lerp(ay,ey,.55),.014))}
const arc=[];for(let j=0;j<=40;j++){const a=-Math.PI/2-.36*3.3+j*(.36*6.6/40);arc.push([ax+.34*Math.cos(a),ay+.34*Math.sin(a)])}s.push(arc);
const arc2=[];for(let j=0;j<=40;j++){const a=-Math.PI/2-.36*3.3+j*(.36*6.6/40);arc2.push([ax+.5*Math.cos(a),ay+.5*Math.sin(a)])}s.push(arc2);
for(let i=0;i<n-1;i++){const a=-Math.PI/2+(i-(n-1)/2+.5)*.36;f.push(dotF(ax+.42*Math.cos(a),ay+.42*Math.sin(a),.016))}
s.push(cr(ax,ay-.05,.05));
return{s,f};
}
function voluteM(){
const s=[],f=[],st=[];
for(let i=0;i<=30;i++){const y=1-i/30;st.push([.5+.1*Math.sin(TAU*y),y])}s.push(st);
for(const sg of[-1,1]){const cx=.5+sg*.24,cy=.34;s.push(spiral(cx,cy,.015,.15,2.1,sg>0?Math.PI*.9:Math.PI*.1,sg>0?1:-1,60));
const c=[];for(let j=0;j<=12;j++){const t=j/12;c.push([lerp(.5+sg*.07,cx+sg*.0,t)+sg*.05*Math.sin(Math.PI*t),lerp(.6,cy+.15,t)])}s.push(c);
s.push(spiral(.5+sg*.18,.8,.012,.07,1.5,sg>0?0:Math.PI,sg>0?-1:1,40));f.push(dotF(cx,cy,.016))}
return{s,f};
}
function rahmenM(){
const s=[],f=[];
s.push(roundRect(.08,.2,.92,.8,.13));s.push(roundRect(.14,.27,.86,.73,.09));
for(let i=0;i<8;i++){const a=TAU*i/8;s.push(placeP(loopPts(.12,.032),.5+.04*Math.cos(a),.5+.04*Math.sin(a),a))}s.push(cr(.5,.5,.04));f.push(dotF(.5,.5,.016));
for(const sg of[-1,1]){s.push(spiral(.5+sg*.27,.5,.012,.07,1.3,sg>0?Math.PI:0,sg>0?1:-1,30));f.push(dotF(.5+sg*.34,.5,.012))}
s.push(line(.5,0,.5,.2,3),line(.5,.8,.5,1,3),line(0,.5,.08,.5,3),line(.92,.5,1,.5,3));
for(const[x,y]of[[.2,.2],[.8,.2],[.2,.8],[.8,.8]])f.push(dotF(x,y,.012));
return{s,f};
}
function blattM(){
const s=[],f=[];s.push(placeP(loopPts(.62,.15),.5,.8,-Math.PI/2));
s.push(line(.5,.82,.5,.16,12));
for(let i=0;i<6;i++){const y=.7-i*.085,w=.1*Math.sin(Math.PI*(.7-y+.15)/.7)*1.1+.02;s.push(line(.5,y,.5-w,y-.07,3),line(.5,y,.5+w,y-.07,3))}
s.push([[.5,.8],[.5,1]],spiral(.38,.93,.012,.07,1.2,0,-1,30));
return{s,f};
}
function schraegM(){const s=[[[0,.5],[.5,0]],[[0,1],[1,0]],[[.5,1],[1,.5]]];const f=[dotF(.25,.25,.02),dotF(.75,.75,.02),dotF(.25,.75,.02),dotF(.75,.25,.02)];return{s,f}}
function perlenM(){
const s=[[[.5,0],[.5,1]]],f=[];
for(const y of[.25,.75]){f.push(dotF(.5,y,.06));s.push(cr(.5,y,.1))}
for(const sg of[-1,1]){s.push(spiral(.5+sg*.12,.5,.012,.075,1.4,sg>0?Math.PI:0,sg>0?1:-1,34))}
for(const y of[.0,.5,1])f.push(dotF(.5,y,.03));
return{s,f};
}
function rankeM(){
const s=[],f=[],st=[];for(let i=0;i<=40;i++){const y=i/40;st.push([.5+.14*Math.sin(TAU*y),y])}s.push(st);
s.push(placeP(loopPts(.3,.07),.5+.14,.25,-Math.PI*.25));s.push(placeP(loopPts(.3,.07),.5-.14,.75,Math.PI*1.25));
s.push(placeP(loopPts(.18,.045),.5+.14,.25,Math.PI*.15));s.push(placeP(loopPts(.18,.045),.5-.14,.75,Math.PI*.85));
s.push(spiral(.28,.5,.012,.08,1.4,0,1,36));s.push(spiral(.72,.5,.012,.08,1.4,Math.PI,-1,36));
s.push(cr(.5,.5,.03));f.push(dotF(.5,.5,.012));
return{s,f};
}
// ---- Flächen-/Basismuster (nahtlos, Kachel 1×1) ----
function gitterM(){const s=[[[0,0],[1,1]],[[0,1],[1,0]],cr(.5,.5,.16)],f=[dotF(.5,.5,.045),dotF(0,0,.04),dotF(1,0,.04),dotF(0,1,.04),dotF(1,1,.04)];return{s,f}}
function wellenM(){const w=[],w2=[];for(let i=0;i<=40;i++){const t=i/40;w.push([t,.5+.2*Math.sin(TAU*t)]);w2.push([t,.5-.2*Math.sin(TAU*t)])}
return{s:[w,w2],f:[dotF(.25,.5,.03),dotF(.75,.5,.03),dotF(0,.5,.03),dotF(1,.5,.03)]}}
function punkteM(){const s=[cr(.5,.5,.12),cr(0,0,.12,24),cr(1,0,.12,24),cr(0,1,.12,24),cr(1,1,.12,24)],f=[dotF(.5,.5,.04),dotF(0,0,.04),dotF(1,0,.04),dotF(0,1,.04),dotF(1,1,.04)];
for(const[x,y]of[[.5,0],[.5,1],[0,.5],[1,.5]])f.push(dotF(x,y,.025));return{s,f}}
function schuppenM(){const s=[cr(.5,0,.5,28,0,Math.PI),cr(0,.5,.5,28,-Math.PI/2,Math.PI/2),cr(1,.5,.5,28,Math.PI/2,Math.PI*1.5),cr(.5,1,.5,28,Math.PI,TAU)],f=[dotF(.5,.3,.03),dotF(.5,.5,.03)];return{s,f}}
// ---- weitere Basismuster (nahtlos, Kachel 1×1) ----
function kreisgeflechtM(){const s=[cr(0,0,.5),cr(1,0,.5),cr(0,1,.5),cr(1,1,.5),cr(.5,.5,.5),cr(.5,.5,.2,24)],f=[dotF(.5,.5,.04),dotF(.5,0,.03),dotF(0,.5,.03),dotF(1,.5,.03),dotF(.5,1,.03)];return{s,f}}
function ogeeM(){const w=(cx,ph,sg)=>{const o=[];for(let i=0;i<=48;i++){const t=i/48;o.push([cx+sg*.26*Math.sin(TAU*(t+ph)),t])}return o};
const s=[w(.5,0,1),w(.5,0,-1),w(0,.5,1),w(0,.5,-1),w(1,.5,1),w(1,.5,-1)],f=[dotF(.5,0,.028),dotF(.5,.5,.028),dotF(.5,1,.028),dotF(0,0,.028),dotF(1,0,.028),dotF(0,1,.028),dotF(1,1,.028)];
s.push(cr(.5,.25,.07,16),cr(.5,.75,.07,16));return{s,f}}
function pfauM(){const s=[],f=[];for(const r of[.5,.38,.26,.14]){s.push(cr(.5,0,r,28,0,Math.PI),cr(0,.5,r,28,-Math.PI/2,Math.PI/2),cr(1,.5,r,28,Math.PI/2,Math.PI*1.5),cr(.5,1,r,28,Math.PI,TAU))}
for(const[x,y]of[[.5,0],[0,.5],[1,.5],[.5,1]])f.push(dotF(x,y,.035));f.push(dotF(.5,.5,.03));return{s,f}}
function sternM(){const s=[],f=[];const star=[];for(let i=0;i<=16;i++){const a=TAU*i/16-Math.PI/2,r=i%2?.17:.4;star.push([.5+r*Math.cos(a),.5+r*Math.sin(a)])}s.push(star,cr(.5,.5,.09,16));
for(const[x,y]of[[0,0],[1,0],[0,1],[1,1]]){s.push(cr(x,y,.14,18));f.push(dotF(x,y,.035))}
for(const[x,y]of[[.5,0],[.5,1],[0,.5],[1,.5]])f.push(dotF(x,y,.03));f.push(dotF(.5,.5,.03));
s.push(line(.5,.1,.5,0,2),line(.5,.9,.5,1,2),line(.1,.5,0,.5,2),line(.9,.5,1,.5,2));return{s,f}}
function vierpassM(){const s=[],f=[];for(const[dx,dy]of[[-1,-1],[1,-1],[1,1],[-1,1]])s.push(cr(.5+dx*.15,.5+dy*.15,.2,26));
s.push(cr(.5,.5,.07,16));f.push(dotF(.5,.5,.03));
for(const[x,y]of[[0,0],[1,0],[0,1],[1,1]]){f.push(dotF(x,y,.04));s.push(cr(x,y,.09,16))}
for(const[dx,dy,x,y]of[[1,1,0,0],[-1,1,1,0],[-1,-1,1,1],[1,-1,0,1]])s.push(line(x+dx*.1,y+dy*.1,x+dx*.28,y+dy*.28,2));return{s,f}}
function wirbelM(){const s=[],f=[];[[.27,.27],[.73,.27],[.73,.73],[.27,.73]].forEach(([x,y],i)=>{s.push(spiral(x,y,.012,.2,1.6,i*Math.PI/2,i%2?-1:1,48));f.push(dotF(x,y,.018))});
s.push(cr(.5,.5,.06,16));f.push(dotF(.5,.5,.025));return{s,f}}
function chevronM(){const z=(y0,a)=>{const o=[];for(let i=0;i<=8;i++)o.push([i/8,y0+a*(i%2?1:-1)*.15]);return o};
const s=[z(.25,1),z(.25,-1),z(.75,1),z(.75,-1)],f=[];for(let i=0;i<4;i++){f.push(dotF(.125+i*.25,.25,.022),dotF(.125+i*.25,.75,.022))}return{s,f}}
function perlenRautenM(){const s=[[[.5,0],[1,.5],[.5,1],[0,.5],[.5,0]],cr(.5,.5,.14,20),[[.5,0],[.5,.36]],[[.5,1],[.5,.64]],[[0,.5],[.36,.5]],[[1,.5],[.64,.5]]],f=[dotF(.5,.5,.045)];
for(const[x,y]of[[.75,.25],[.75,.75],[.25,.75],[.25,.25],[.5,0],[0,.5],[1,.5],[.5,1],[0,0],[1,0],[0,1],[1,1]])f.push(dotF(x,y,.03));return{s,f}}
function lotusM(){const s=[],f=[];for(let i=0;i<4;i++){const a=TAU*i/4+Math.PI/4,e=[];for(let j=0;j<=26;j++){const t=TAU*j/26;e.push([.13*Math.cos(t),.06*Math.sin(t)])}
s.push(movp(rotp(e,a,0,0),.5+.14*Math.cos(a),.5+.14*Math.sin(a)))}
s.push(cr(.5,.5,.05,14));f.push(dotF(.5,.5,.022));
for(const[x,y,a]of[[0,0,Math.PI/4],[1,0,Math.PI*.75],[1,1,Math.PI*1.25],[0,1,-Math.PI/4]]){s.push(placeP(loopPts(.2,.05),x,y,a));f.push(dotF(x+.24*Math.cos(a),y+.24*Math.sin(a),.02))}
for(const[x,y]of[[.5,0],[.5,1],[0,.5],[1,.5]])f.push(dotF(x,y,.03));return{s,f}}
function spitzbogenM(){const L=[],Rr=[],L2=[],R2=[];for(let i=0;i<=20;i++){const ph=Math.PI/3*i/20,x=1-Math.cos(ph),y=.9-Math.sin(ph);L.push([x,y]);Rr.push([1-x,y]);L2.push([.5+(x-.5)*.62,.9+(y-.9)*.62]);R2.push([.5+(1-x-.5)*.62,.9+(y-.9)*.62])}
const s=[L.concat(Rr.slice().reverse()),L2.concat(R2.slice().reverse()),[[0,.9],[1,.9]]],f=[dotF(.5,.3,.03),dotF(.5,.9,.03),dotF(0,.9,.03),dotF(1,.9,.03)];s.push(cr(.5,.5,.06,14));return{s,f}}
// ---- Bordüren (Kachel: x quer über das Band 0..asp, y längs 0..1) ----
function wellenBM(a=2){const cx=a/2,amp=Math.min(a*.28,.3),w=[],w2=[];for(let i=0;i<=40;i++){const t=i/40;w.push([cx+amp*Math.sin(TAU*t),t]);w2.push([cx-amp*Math.sin(TAU*t),t])}
const s=[w,w2],f=[dotF(cx,0,.04),dotF(cx,.5,.04),dotF(cx,1,.04)];
for(const sg of[-1,1]){s.push(cr(cx+sg*amp*1.45,.25*(sg>0?1:3),.06,16));f.push(dotF(cx+sg*amp*1.45,.25*(sg>0?1:3),.02))}
s.push(line(cx-a*.46,0,cx-a*.46,1,6),line(cx+a*.46,0,cx+a*.46,1,6));return{s,f}}
function zackenBM(a=2){const cx=a/2,hw=a*.38,z=[[cx-hw,0],[cx+hw,.5],[cx-hw,1]],z2=[[cx+hw,0],[cx-hw,.5],[cx+hw,1]];
const s=[z,z2,line(cx-a*.47,0,cx-a*.47,1,6),line(cx+a*.47,0,cx+a*.47,1,6)],f=[dotF(cx,.25,.035),dotF(cx,.75,.035),dotF(cx-hw,.5,.03),dotF(cx+hw,.5,.03)];
s.push(cr(cx,.5,.07,18));return{s,f}}
function kettenBM(a=2){const cx=a/2,r=Math.min(a*.34,.23),s=[cr(cx,.25,r),cr(cx,.75,r),cr(cx,.25,r*.5,20),cr(cx,.75,r*.5,20),line(cx,0,cx,.25-r,2),line(cx,.25+r,cx,.75-r,3),line(cx,.75+r,cx,1,2)],f=[dotF(cx,.25,.03),dotF(cx,.75,.03)];
for(const sg of[-1,1])s.push(spiral(cx+sg*a*.33,.5,.01,.06,1.3,sg>0?Math.PI:0,sg>0?1:-1,28));return{s,f}}
function bogenBM(a=2){const cx=a/2,hw=a*.4,s=[],f=[];
for(const sg of[-1,1]){const p=[];for(let i=0;i<=24;i++){const u=i/24,o=Math.sqrt(Math.max(0,1-(2*u-1)**2));p.push([cx+sg*(hw-hw*.45*o),u])}s.push(p);f.push(dotF(cx+sg*(hw*.35),.5,.025))}
s.push(cr(cx,.5,Math.min(hw*.28,.12),20));f.push(dotF(cx,.5,.03),dotF(cx,0,.03),dotF(cx,1,.03));s.push(line(cx,0,cx,.38,3),line(cx,.62,cx,1,3));return{s,f}}
const rankeB=(a=2)=>{const m=rankeM();return{s:m.s.map(p=>movp(p,(a-1)/2,0)),f:m.f.map(p=>movp(p,(a-1)/2,0))}};
const perlenB=(a=2)=>{const m=perlenM();return{s:m.s.map(p=>movp(p,(a-1)/2,0)),f:m.f.map(p=>movp(p,(a-1)/2,0))}};
// [Name, Funktion(asp), Kategorie]
const PRESETS={
rosette:['Rosette',()=>rosetteM(),'objekt'],bluete:['Blüte',blueteM,'objekt'],faecher:['Fächer',faecherM,'objekt'],volute:['Volute',voluteM,'objekt'],rahmen:['Rahmenfeld',rahmenM,'objekt'],blatt:['Blatt',blattM,'objekt'],
schraeg:['Schrägband',schraegM,'flaeche'],gitter:['Gitter',gitterM,'flaeche'],wellen:['Wellen',wellenM,'flaeche'],punkte:['Ringe & Punkte',punkteM,'flaeche'],schuppen:['Schuppen',schuppenM,'flaeche'],kreisgeflecht:['Kreisgeflecht',kreisgeflechtM,'flaeche'],ogee:['Ogee-Netz',ogeeM,'flaeche'],pfau:['Pfauenschuppen',pfauM,'flaeche'],stern:['Sternraster',sternM,'flaeche'],vierpass:['Vierpass',vierpassM,'flaeche'],wirbel:['Wirbel',wirbelM,'flaeche'],chevron:['Chevron',chevronM,'flaeche'],perlenrauten:['Perlenrauten',perlenRautenM,'flaeche'],lotus:['Lotus-Gitter',lotusM,'flaeche'],spitzbogen:['Spitzbogen',spitzbogenM,'flaeche'],
ranke:['Ranke',rankeB,'borduere'],perlen:['Perlenreihe',perlenB,'borduere'],wellenb:['Wellenband',wellenBM,'borduere'],zackenb:['Zacken',zackenBM,'borduere'],kettenb:['Kette',kettenBM,'borduere'],bogenb:['Bogen',bogenBM,'borduere']};
// ---------- Vektor-Motive: Zeichnen ----------
function strokePath(c,p){
if(p.length<2){c.moveTo(p[0][0],p[0][1]);c.lineTo(p[0][0]+1e-5,p[0][1]);return}
c.moveTo(p[0][0],p[0][1]);
for(let i=1;i<p.length-1;i++)c.quadraticCurveTo(p[i][0],p[i][1],(p[i][0]+p[i+1][0])/2,(p[i][1]+p[i+1][1])/2);
c.lineTo(p[p.length-1][0],p[p.length-1][1]);
}
function paintMotifVec(c,m,lw,col){
c.lineWidth=lw;c.lineCap=c.lineJoin='round';c.strokeStyle=c.fillStyle=col;
for(const f of m.f){c.beginPath();c.moveTo(f[0][0],f[0][1]);for(let i=1;i<f.length;i++)c.lineTo(f[i][0],f[i][1]);c.closePath();c.fill();c.stroke()}
c.beginPath();for(const s of m.s)strokePath(c,s);c.stroke();
}
// ---------- Editor (um 90° gedreht, passend zur Musteransicht) ----------
const cEd=$('#cEd'),ectx=cEd.getContext('2d');
const cBase=document.createElement('canvas');cBase.width=cEd.width;cBase.height=cEd.height;const bctx=cBase.getContext('2d');
const EM=.3;let cur=null,lastU=null,undoStack=[],baseDirty=true,edRaf=0,edRect=null;
// Kachelmaße: normal 1×1; Bordüre: Breite/Länge × 1
const isBorder=L=>L.rapport==='border';
const tileAsp=(L=curL())=>isBorder(L)?Math.max(.2,L.w/L.rep):1;
const edgeSp=E=>P.hmm/Math.max(2,Math.round(P.hmm/E.scal));
const unitMM=(L=curL())=>L.edge?edgeSp(L)*L.msize:isBorder(L)?L.rep:L.cell;
let tmpErase=false;const tool=()=>tmpErase?'erase':P.tool;
function edGeo(){const Tw=tileAsp(),E=Math.max(Tw,1),S=cEd.width/(E+2*EM),c=cEd.width/2;return{Tw,S,e:c+S/2,f:c-S*Tw/2}}
const ET=g=>[0,g.S,-g.S,0,g.e,g.f];
const e2u=e=>{const r=edRect||cEd.getBoundingClientRect(),g=edGeo(),X=(e.clientX-r.left)/r.width*cEd.width,Y=(e.clientY-r.top)/r.height*cEd.height;return[(Y-g.f)/g.S,(g.e-X)/g.S]};
function drawEditor(keep){if(!keep){baseDirty=true;updateBanner()}if(!edRaf)edRaf=requestAnimationFrame(renderEd)}
function renderEd(){
edRaf=0;if(tab!=='ed')return;
const g=edGeo(),S=g.S,L=curL(),m=curM(),tw=P.thread/unitMM(),Tw=g.Tw;
if(baseDirty){baseDirty=false;const c=bctx;c.setTransform(1,0,0,1,0,0);c.fillStyle='#fff';c.fillRect(0,0,cBase.width,cBase.height);
c.setTransform(...ET(g));
if(P.ghost){const bd=isBorder(L),oneD=bd||L.edge;for(let dx=oneD?0:-1;dx<=(oneD?0:1);dx++)for(let dy=-1;dy<=1;dy++){if(!dx&&!dy)continue;c.save();if(bd&&L.bmir&&dy){c.translate(0,dy+.5);c.scale(1,-1);c.translate(0,-.5)}else c.translate(dx*Tw,dy);paintMotifVec(c,m,tw,'#d4d4d4');c.restore()}}
c.lineWidth=1/S;c.strokeStyle='#bbb';c.strokeRect(0,0,Tw,1);
c.strokeStyle='#eee';c.beginPath();c.moveTo(Tw/2,0);c.lineTo(Tw/2,1);c.moveTo(0,.5);c.lineTo(Tw,.5);c.stroke();
if(L.edge){const ux=draft.side==='oben'?1:0;c.strokeStyle='#8aa';c.setLineDash([4/S,4/S]);c.beginPath();c.moveTo(ux,-.3);c.lineTo(ux,1.3);c.stroke();c.setLineDash([])}
paintMotifVec(c,m,tw,'#000')}
ectx.setTransform(1,0,0,1,0,0);ectx.drawImage(cBase,0,0);ectx.setTransform(...ET(g));
if(cur&&tool()==='thread'){ectx.lineWidth=tw;ectx.lineCap=ectx.lineJoin='round';ectx.strokeStyle='#000';
for(const q of symCopies(cur)){ectx.beginPath();ectx.moveTo(q[0][0],q[0][1]);for(let i=1;i<q.length;i++)ectx.lineTo(q[i][0],q[i][1]);if(q.length<2)ectx.lineTo(q[0][0]+1e-5,q[0][1]);ectx.stroke()}}
if(cur&&tool()==='fill'){for(const q of symCopies(cur)){ectx.beginPath();ectx.moveTo(q[0][0],q[0][1]);for(const p of q)ectx.lineTo(p[0],p[1]);ectx.closePath();ectx.fillStyle='rgba(0,0,0,.25)';ectx.fill();ectx.lineWidth=1.5/S;ectx.strokeStyle='#000';ectx.stroke()}}
if(lastU&&tool()==='erase'){ectx.beginPath();ectx.arc(lastU[0],lastU[1],P.erase/unitMM(),0,TAU);ectx.lineWidth=1.5/S;ectx.strokeStyle='#888';ectx.stroke()}
}
function symCopies(pts){
const o=P.esym,out=[pts];if(o==='none')return out;const Tw=tileAsp(),cx=Tw/2;
const mx=p=>p.map(([x,y])=>[Tw-x,y]),my=p=>p.map(([x,y])=>[x,1-y]);
if(o==='mx')out.push(mx(pts));else if(o==='my')out.push(my(pts));else if(o==='mxy')out.push(mx(pts),my(pts),mx(my(pts)));
else if(o[0]==='r'){const n=+o.slice(1);for(let k=1;k<n;k++)out.push(rotp(pts,TAU*k/n,cx,.5))}
return out;
}
function snap(){undoStack.push({i:'draft',k:slotKey(),d:JSON.stringify(curM())});if(undoStack.length>40)undoStack.shift()}
function simplify(p,d=.006){const o=[p[0]];for(let i=1;i<p.length-1;i++){const q=o[o.length-1];if(Math.hypot(p[i][0]-q[0],p[i][1]-q[1])>=d)o.push(p[i])}if(p.length>1)o.push(p[p.length-1]);return o}
function pointInPoly(u,poly){let c=false;for(let i=0,j=poly.length-1;i<poly.length;j=i++){const a=poly[i],b=poly[j];if((a[1]>u[1])!==(b[1]>u[1])&&u[0]<(b[0]-a[0])*(u[1]-a[1])/(b[1]-a[1])+a[0])c=!c}return c}
function eraseAt(u){
const m=curM(),er=P.erase/unitMM(),e2=er*er,ns=[];
for(const s of m.s){let seg=[];
for(let i=0;i<s.length;i++){const p=s[i];
if((p[0]-u[0])**2+(p[1]-u[1])**2<e2){if(seg.length>1)ns.push(seg);seg=[]}
else seg.push(p);
if(i<s.length-1&&seg.length){const q=s[i+1],mx=(p[0]+q[0])/2,my=(p[1]+q[1])/2;if((mx-u[0])**2+(my-u[1])**2<e2){if(seg.length>1)ns.push(seg);seg=[]}}}
if(seg.length>1||(seg.length===1&&s.length===1&&false))ns.push(seg)}
m.s=ns;
m.f=m.f.filter(f=>!(pointInPoly(u,f)||f.some(p=>(p[0]-u[0])**2+(p[1]-u[1])**2<e2)));
}
cEd.addEventListener('contextmenu',e=>e.preventDefault());
cEd.addEventListener('pointerdown',e=>{cEd.setPointerCapture(e.pointerId);edRect=cEd.getBoundingClientRect();tmpErase=e.button===2;const u=e2u(e);lastU=u;
if(tool()==='erase'){snap();eraseAt(u);drawEditor();return}
cur=[u];drawEditor(true)});
cEd.addEventListener('pointermove',e=>{
const evs=(e.getCoalescedEvents&&e.getCoalescedEvents())||[e];if(!evs.length)evs.push(e);
if(tool()==='erase'){for(const ev of evs){lastU=e2u(ev);if(e.buttons)eraseAt(lastU)}drawEditor(!e.buttons);return}
lastU=e2u(e);
if(!cur)return;let moved=false;
for(const ev of evs){const u=e2u(ev),q=cur[cur.length-1];if(Math.hypot(u[0]-q[0],u[1]-q[1])>.0015){cur.push(u);moved=true}}
if(moved)drawEditor(true)});
const endEd=()=>{
const t=tool();tmpErase=false;
if(t==='erase'){markDraft();drawEditor();return}
if(!cur)return;const m=curM();snap();
if(t==='thread'){const p=simplify(cur,.003);for(const q of symCopies(p))m.s.push(q)}
else if(cur.length>=3){const p=simplify(cur,.008);for(const q of symCopies(p))m.f.push(q)}
cur=null;drawEditor();markDraft()};
cEd.addEventListener('pointerup',endEd);cEd.addEventListener('pointercancel',endEd);
$('#eUndo').onclick=()=>{const u=undoStack.pop();if(!u)return;const T=layerAt(u.i);if(!u||!T)return;T[u.k]=JSON.parse(u.d);drawEditor();markDraft()};
$('#eClear').onclick=()=>{snap();const m=curM();m.s=[];m.f=[];drawEditor();markDraft()};
// ---------- Zeichnungs-Arbeitsplatz: unabhängig von Schichten ----------
const DSPEC={
objekt:[['s','cell','Kachelgröße (mm)',8,80,1]],
flaeche:[['s','cell','Kachelgröße (mm)',8,80,1]],
borduere:[['s','w','Breite (mm)',8,60,1],['s','rep','Rapportlänge (mm)',6,60,1],['c','bmir','oben/unten spiegeln']],
rand:[['s','scal','Raster (mm)',4,30,1],['s','msize','Motivgröße',.5,3,.05],['o','side','Vorschau-Seite',[['oben','Rand oben'],['unten','Rand unten']]]]};
function markDraft(){draft.dirty=true;updateBanner();saveSoon()}
function buildDraftUI(){buildUI($('#g_draft'),DSPEC[draft.cat],draft,()=>{drawEditor();markDraft()});
$('#dCat').value=draft.cat;$('#dName').value=draft.name}
function usedBy(id){if(!id)return[];const o=[];
layers.forEach((L,i)=>['A','B'].forEach(k=>{if(L['ref'+k]===id)o.push(`${i+1}·${L.name}${k==='B'?' (B)':''}`)}));
edges.forEach(E=>{if(E.refA===id)o.push(E.name)});return o}
function updateBanner(){
const b=$('#edbanner');if(!b)return;const u=usedBy(draft.id),kind=DRAFT_CATS.find(c=>c[0]===draft.cat)[1],nm=esc(draft.name||'Zeichnung');
const dirty=draft.dirty&&hasM(draft.mA)?' <span class="dirty">● ungespeicherte Änderungen</span>':'';
let cls,frame;
if(!draft.id){cls='draft';frame='fdraft';
b.innerHTML=`NEUE ZEICHNUNG — noch nicht gespeichert${dirty}<small>${kind} · zeichnen, dann „Speichern“. Danach kannst du sie in jeder Schicht unter „Motiv wählen“ benutzen.</small>`}
else if(u.length){cls='live';frame='flive';
b.innerHTML=`IN VERWENDUNG — ${nm}${dirty}<small>${kind} · genutzt in: ${esc(u.join(', '))} · „Speichern“ aktualisiert diese Schichten</small>`}
else{cls='saved';frame='fsaved';
b.innerHTML=`GESPEICHERT, NICHT IN VERWENDUNG — ${nm}${dirty}<small>${kind} · in einer Schicht unter „Motiv wählen“ zuweisen</small>`}
b.className=cls;cEd.classList.remove('fdraft','flive','fsaved');cEd.classList.add(frame)}
function saveDraft(asCopy){
if(!hasM(draft.mA)){$('#info').textContent='Zeichne zuerst etwas, dann speichern.';return}
const name=($('#dName').value||'Zeichnung').trim()||'Zeichnung',data={cat:draft.cat,m:clone(draft.mA),asp:draft.cat==='borduere'?tileAsp(draft):1,cell:draft.cell,msize:draft.msize,scal:draft.scal};
let it=!asCopy&&draft.id?lib.find(x=>x.id===draft.id):null;
if(it)Object.assign(it,data,{name});
else{let n=name,k=2;while(lib.some(x=>x.name===n))n=name+' '+k++;it=Object.assign({id:uid(),name:n},data);lib.push(it);draft.id=it.id}
draft.name=it.name;draft.dirty=false;$('#dName').value=it.name;
syncRefs();if(usedBy(it.id).length)regen();saveSoon();renderLib();renderLayers();updateBanner();
$('#info').textContent='Gespeichert: '+it.name+(usedBy(it.id).length?' · Schichten aktualisiert – Update drücken':'')}
$('#dSave').onclick=()=>saveDraft(false);$('#dCopy').onclick=()=>saveDraft(true);$('#dNew').onclick=()=>newDraft();
$('#dName').oninput=e=>{draft.name=e.target.value;markDraft()};
$('#dCat').innerHTML=DRAFT_CATS.map(c=>`<option value="${c[0]}">${c[1]}</option>`).join('');
$('#dCat').onchange=e=>{draft.cat=e.target.value;syncDraft();buildDraftUI();drawEditor();markDraft()};
// ---------- Zeichnungen (Bibliothek), Zuweisung an Schichten, Speichern ----------
const LS_P='laceform.project.v1',LS_L='laceform.library.v1';
let lib=[],hidden=[],libFilter='alle',pickK=null,saveT=null;
const FILTERS=[['alle','Alle'],['objekt','Objekte'],['flaeche','Flächen'],['borduere','Bordüren'],['rand','Ränder']];
const lsGet=k=>{try{return JSON.parse(localStorage.getItem(k))}catch(e){return null}};
const lsSet=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
const uid=()=>'u'+Date.now().toString(36)+Math.random().toString(36).slice(2,5);
const clone=o=>JSON.parse(JSON.stringify(o));
const projectData=()=>({app:'laceform',v:4,P,seed,layers,edges,library:lib,draft,hidden});
function saveSoon(){clearTimeout(saveT);saveT=setTimeout(()=>{lsSet(LS_P,{v:4,P,seed,layers,edges,draft,hidden});lsSet(LS_L,lib);if(typeof srvAutoSoon==='function')srvAutoSoon()},400)}
function applyProject(o){
if(!o)return;const old=(o.v||1)<3;
if(o.P){Object.assign(P,o.P);if(old){P.thread*=2/3;P.minS*=2/3;P.minG*=2/3}}
if(o.seed)seed=o.seed;
if(o.layers&&o.layers.length){layers.length=0;o.layers.forEach(l=>{const L=newLayer(l);if(old)L.gw*=2/3;layers.push(L)});sel=0}
if(o.edges&&o.edges.length===2)o.edges.forEach((E,k)=>Object.assign(edges[k],newEdge(E.name),E));
else if(o.P&&o.P.edgeL!==undefined){[['edgeL',0],['edgeR',1]].forEach(([key,k])=>{const E=edges[k],p=o.P;E.shape=p[key]==='scallop'?'scallop':'straight';E.scal=p.scal||12;E.dep=(p.sdep??.6)*E.scal*.5;E.picot=p.picot??.4})}
if(o.draft){Object.assign(draft,o.draft);if(!draft.mA)draft.mA=emptyMotif();syncDraft()}
if(Array.isArray(o.hidden))hidden=o.hidden.slice();
if(o.library)for(const it of o.library){if(!it.id)it.id=uid();if(it.cat==='muster')continue;
if(!lib.some(x=>x.id===it.id||(x.name===it.name&&JSON.stringify(x.m)===JSON.stringify(it.m))))lib.push(it)}
}
function rebuildUI(){buildUI($('#g_piece'),SPEC.piece,P,changed);buildUI($('#g_fab'),SPEC.fab,P,changed);buildUI($('#g_draw'),SPEC.draw,P,changed);buildUI($('#g_exp'),SPEC.exp,P,changed);buildDraftUI();renderLayers();renderLib();updateBanner()}
// --- Einträge: eigene (lib) + Vorlagen (PRESETS) ---
let _pre=null;
function presetItems(){if(!_pre){_pre=[];for(const v of Object.values(PRESETS)){const asp=v[2]==='borduere'?2:1;_pre.push({id:'p:'+v[0],name:v[0],cat:v[2],asp,m:v[1](asp),preset:true})}}return _pre}
const findItem=id=>!id?null:id[0]==='p'&&id[1]===':'?presetItems().find(x=>x.id===id):lib.find(x=>x.id===id);
function itemMotif(it,L){if(it.preset){const v=Object.values(PRESETS).find(p=>p[0]===it.name);if(v)return clone(v[1](it.cat==='borduere'?tileAsp(L):1))}return clone(it.m)}
// Schichten übernehmen die Motive ihrer verknüpften Zeichnungen
function syncRefs(){for(const L of[...layers,...edges])for(const k of['A','B']){const r=L['ref'+k];if(!r)continue;const it=findItem(r);if(it)L['m'+k]=itemMotif(it,L);else L['ref'+k]=null}}
function thumb(it){
const c=document.createElement('canvas');c.width=c.height=96;const x=c.getContext('2d'),Tw=it.asp||1,E=Math.max(Tw,1)*1.08,S=96/E;
x.fillStyle='#fff';x.fillRect(0,0,96,96);
x.setTransform(0,S,-S,0,48+S/2,48-S*Tw/2);
x.lineWidth=1/S;x.strokeStyle='#eee';x.strokeRect(0,0,Tw,1);
paintMotifVec(x,it.m,.03,'#111');return c;
}
function itemCard(it,onClick,opts={}){
const d=document.createElement('div'),u=usedBy(it.id).length;d.className='it'+(u?' used':'');d.title=it.name;
d.appendChild(thumb(it));const cap=document.createElement('span');cap.className='cap';cap.textContent=it.name;d.appendChild(cap);
if(u){const b=document.createElement('i');b.className='use';b.textContent=u;b.title='In '+u+' Schicht(en) verwendet';d.appendChild(b)}
d.onclick=onClick;
if(opts.del){const x=document.createElement('span');x.className='x';x.textContent='×';x.title='löschen';x.onclick=ev=>{ev.stopPropagation();delItem(it)};d.appendChild(x)}
return d}
function delItem(it){
if(it.preset){hidden.push(it.name);renderLib();saveSoon();return}
const u=usedBy(it.id);
if(!confirm(`„${it.name}“ löschen?`+(u.length?`\nWird noch genutzt in: ${u.join(', ')}.\nDie Schichten behalten ihre Kopie des Motivs.`:'')))return;
lib=lib.filter(x=>x.id!==it.id);
for(const L of[...layers,...edges])for(const k of['A','B'])if(L['ref'+k]===it.id)L['ref'+k]=null;
if(draft.id===it.id){draft.id=null;draft.dirty=true}
renderLib();renderLayers();saveSoon()}
function renderLib(){
const t=$('#ltabs');t.innerHTML='';
for(const[k,n]of FILTERS){const b=document.createElement('button');b.textContent=n;b.className=libFilter===k?'on':'';b.onclick=()=>{libFilter=k;renderLib()};t.appendChild(b)}
const g=$('#libGrid');g.innerHTML='';const ok=it=>libFilter==='alle'||it.cat===libFilter;
const sec=(title,items,del)=>{const h=document.createElement('div');h.className='lsec';h.textContent=title;g.appendChild(h);
if(!items.length){const e=document.createElement('div');e.className='hint';e.style.gridColumn='1/-1';e.textContent=title==='Meine Zeichnungen'?'Noch keine – oben „+ Neue Zeichnung“.':'–';g.appendChild(e)}
for(const it of items)g.appendChild(itemCard(it,()=>openItem(it),{del}))};
sec('Meine Zeichnungen',lib.filter(ok),true);
sec('Vorlagen',presetItems().filter(x=>ok(x)&&!hidden.includes(x.name)),true);
$('#libRestore').style.display=hidden.length?'':'none';
}
$('#libRestore').onclick=e=>{e.preventDefault();hidden=[];renderLib();saveSoon()};
$('#libNew').onclick=()=>newDraft();
// --- Zeichnung öffnen / neu ---
function openItem(it){
if(draft.dirty&&hasM(draft.mA)&&draft.id!==it.id&&!confirm('Die aktuelle Zeichnung hat ungespeicherte Änderungen. Verwerfen?'))return;
draft.cat=it.cat;syncDraft();
if(it.cat==='borduere'&&it.asp)draft.rep=clamp(Math.round(draft.w/it.asp),6,60);
if(it.cell)draft.cell=it.cell;if(it.msize)draft.msize=it.msize;if(it.scal)draft.scal=it.scal;
draft.mA=it.preset?itemMotif(it,draft):clone(it.m);
draft.id=it.preset?null:it.id;draft.name=it.preset?it.name+' (Kopie)':it.name;draft.dirty=!!it.preset;
undoStack.length=0;buildDraftUI();setTab('ed');drawEditor();saveSoon();
if(it.preset)$('#info').textContent='Vorlagen bleiben unverändert – das ist eine Kopie. Speichern legt sie in „Meine Zeichnungen“ ab.'}
function newDraft(cat){
if(draft.dirty&&hasM(draft.mA)&&!confirm('Die aktuelle Zeichnung hat ungespeicherte Änderungen. Verwerfen?'))return;
Object.assign(draft,{id:null,name:'Neue Zeichnung',mA:emptyMotif(),dirty:false});if(cat){draft.cat=cat;syncDraft()}
undoStack.length=0;buildDraftUI();setTab('ed');drawEditor();saveSoon()}
// --- Schicht-Karte: Netzgrund + Motive, Zuweisen ---
function assign(L,k,it){
L['ref'+k]=it.id;L['m'+k]=itemMotif(it,L);
if(L.edge){if(it.cat==='rand'){L.msize=it.msize||L.msize;if(it.scal)L.scal=it.scal}}
else if(it.cat==='borduere'){L.rapport='border';if(it.asp)L.rep=clamp(Math.round(L.w/it.asp),4,60)}
else{if(isBorder(L))L.rapport='mirror';if(it.cell&&k==='A')L.cell=it.cell}}
function motifName(L,k){const m=L['m'+k];if(!hasM(m))return null;const it=findItem(L['ref'+k]);return it?it.name:'eigenes Motiv'}
function renderCard(){
const host=$('#lcard');if(!host)return;host.innerHTML='';const L=layerAt(sel);if(!L)return;
const card=document.createElement('div');card.className='lc';
const line=(label,html)=>{const d=document.createElement('div');d.className='lcr';d.innerHTML=`<b>${label}</b><span>${html}</span>`;card.appendChild(d);return d};
if(L.edge)line('Rand',`${{scallop:'Bögen',zig:'Zacken',wave:'Wellen',straight:'gerade',none:'kein Rand'}[L.shape]} · ${L.scal} mm`);
else line('Netzgrund',`${GR[L.ground]}${L.ground==='none'?'':' · '+L.pitch+' mm Maschen'} <small>(darunter, trägt alles)</small>`);
for(const k of L.edge?['A']:['A','B']){
const d=document.createElement('div');d.className='lcr';const has=hasM(L['m'+k]),nm=motifName(L,k);
const label=L.edge?'Randmotiv':k==='A'?'Motiv A':'Motiv B';
d.innerHTML=`<b>${label}</b>`;const mid=document.createElement('span');mid.className='mid';
if(has){const c=thumb({m:L['m'+k],asp:isBorder(L)?tileAsp(L):1});c.className='th';mid.appendChild(c)}
const t=document.createElement('em');t.textContent=has?nm:'— leer —';mid.appendChild(t);d.appendChild(mid);
const bt=(txt,f,cls)=>{const b=document.createElement('button');b.textContent=txt;if(cls)b.className=cls;b.onclick=f;d.appendChild(b)};
bt(pickK===k?'schließen':'wählen',()=>{pickK=pickK===k?null:k;renderCard()},pickK===k?'on':'');
if(has){bt('bearbeiten',()=>{let it=findItem(L['ref'+k]);
if(!it){let n=`${L.name} ${k}`,j=2;while(lib.some(x=>x.name===n))n=`${L.name} ${k} ${j++}`;
it={id:uid(),name:n,cat:L.edge?'rand':isBorder(L)?'borduere':'objekt',m:clone(L['m'+k]),asp:isBorder(L)?tileAsp(L):1,cell:L.cell,msize:L.msize,scal:L.scal};lib.push(it);L['ref'+k]=it.id;renderLib();saveSoon()}
openItem(it)});
bt('×',()=>{L['m'+k]=emptyMotif();L['ref'+k]=null;if(pickK===k)pickK=null;regen();renderLayers()})}
card.appendChild(d)}
if(!L.edge)line('Rapport',`${RAP_LABEL[L.rapport]} · ${L.rapport==='border'?'Wiederholung '+L.rep+' mm':'Zelle '+L.cell+' mm'}`);
host.appendChild(card);
if(pickK){const p=document.createElement('div');p.className='picker';
const hd=document.createElement('div');hd.className='hint';hd.textContent=`Zeichnung für ${L.edge?'Randmotiv':'Motiv '+pickK} wählen – ersetzt nur diesen Slot dieser Schicht:`;p.appendChild(hd);
const g=document.createElement('div');g.className='lib';
const cats=L.edge?['rand','objekt']:['objekt','flaeche','borduere'];
const items=[...lib,...presetItems().filter(x=>!hidden.includes(x.name))].filter(x=>cats.includes(x.cat));
for(const it of items)g.appendChild(itemCard(it,()=>{assign(L,pickK,it);pickK=null;regen();renderLayers()}));
if(!items.length)g.innerHTML='<div class="hint" style="grid-column:1/-1">Noch keine passenden Zeichnungen.</div>';
p.appendChild(g);
const nb=document.createElement('button');nb.textContent='+ Neue Zeichnung dafür anlegen';nb.onclick=()=>{pickK=null;newDraft(L.edge?'rand':isBorder(L)?'borduere':'objekt')};p.appendChild(nb);
host.appendChild(p)}
}
$('#prjSave').onclick=()=>dl(new Blob([JSON.stringify(projectData())],{type:'application/json'}),'laceform_projekt.json');
$('#prjLoad').onclick=()=>$('#prjFile').click();
$('#prjFile').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();
r.onload=()=>{try{applyProject(JSON.parse(r.result));rebuildUI();drawEditor();generate()}catch(err){$('#info').textContent='Datei konnte nicht gelesen werden.'}};r.readAsText(f);e.target.value=''};
// ---------- Server-Speicher (optional, benötigt lace_api.php im selben Ordner) ----------
const API='lace_api.php',LS_S='laceform.srv.v1';
const srv={ok:false,auth:false,pass:'',name:'Spitze 1',auto:false};
let srvT=null;
{const o=lsGet(LS_S);if(o)Object.assign(srv,o)}
const srvSet=t=>{$('#srvStat').textContent=t};
const srvStore=()=>lsSet(LS_S,{pass:srv.pass,name:srv.name,auto:srv.auto});
async function api(action,{q='',body}={}){
const r=await fetch(API+'?action='+action+q,{method:body?'POST':'GET',headers:{'X-Pass':srv.pass,...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined});
const t=await r.text();let j;try{j=JSON.parse(t)}catch(e){j={ok:false,error:'Antwort ungültig'}}
if(r.status===401){srv.auth=false;j.error='Passwort falsch'}return j;
}
async function srvInit(){
if(location.protocol==='file:'){srvSet('Nicht verfügbar – Datei wird lokal geöffnet. Lege lace.html und lace_api.php zusammen auf den Server.');return}
try{const r=await fetch(API+'?action=ping'),j=await r.json();
if(!j||j.app!=='laceform')throw 0;srv.ok=true;$('#srvUI').style.display='';
$('#srvPass').value=srv.pass;$('#srvName').value=srv.name;$('#srvAuto').checked=srv.auto;
srvSet(srv.pass?'Verbinde …':'Passwort eingeben.');if(srv.pass)srvRefresh();
}catch(e){srvSet('Kein Server gefunden – lace_api.php fehlt im selben Ordner. Alles läuft weiter lokal im Browser.')}
}
async function srvRefresh(){
const j=await api('list');if(!j.ok){srvSet(j.error||'Fehler');return}
srv.auth=true;srvStore();
$('#srvSel').innerHTML=j.items.length?j.items.map(x=>`<option value="${x.name}">${x.name} · ${new Date(x.time*1000).toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'})}</option>`).join(''):'<option value="">– leer –</option>';
const f=await api('files');
$('#srvFiles').innerHTML=f.ok&&f.items.length?'Ergebnisse (Link öffnen):<br>'+f.items.map(x=>`<a href="${API}?action=get&file=${encodeURIComponent(x.file)}&pass=${encodeURIComponent(srv.pass)}" target="_blank" style="color:#111">${x.file.slice(2)}</a>`).join(' · '):'Noch keine abgelegten Ergebnisse.';
srvSet('Verbunden ✓');
}
async function srvSave(quiet){
srv.name=($('#srvName').value||'Spitze').trim();srv.pass=$('#srvPass').value;srvStore();
const j=await api('save',{body:{name:srv.name,data:projectData()}});
if(j.ok){srvSet(quiet?'Automatisch gespeichert ✓ '+new Date().toLocaleTimeString('de-DE'):'Gespeichert ✓');srv.auth=true;if(!quiet)srvRefresh()}else srvSet(j.error||'Fehler');
}
function srvAutoSoon(){if(!srv.ok||!srv.auto||!srv.pass)return;clearTimeout(srvT);srvT=setTimeout(()=>srvSave(true),4000)}
$('#srvPass').onchange=()=>{srv.pass=$('#srvPass').value;srvStore();srvRefresh()};
$('#srvName').oninput=()=>{srv.name=$('#srvName').value;srvStore()};
$('#srvAuto').onchange=e=>{srv.auto=e.target.checked;srvStore();if(srv.auto)srvAutoSoon()};
$('#srvSave').onclick=()=>srvSave(false);
$('#srvRef').onclick=()=>{srv.pass=$('#srvPass').value;srvRefresh()};
$('#srvLoad').onclick=async()=>{const n=$('#srvSel').value;if(!n)return;const j=await api('load',{q:'&name='+encodeURIComponent(n)});
if(!j.ok){srvSet(j.error||'Fehler');return}
applyProject(j.data);$('#srvName').value=n;srv.name=n;srvStore();rebuildUI();drawEditor();generate();srvSet('Geladen: '+n+' ✓')};
$('#srvDel').onclick=async()=>{const n=$('#srvSel').value;if(!n||!confirm('„'+n+'“ vom Server löschen?'))return;await api('delete',{q:'&name='+encodeURIComponent(n)});srvRefresh()};
$('#srvRes').onclick=async()=>{if(tab!=='pat')setTab('pat');
if(!R_)return;const n=($('#srvName').value||'Spitze').trim();srv.name=n;srv.pass=$('#srvPass').value;srvStore();
const a=await api('savefile',{body:{name:n,ext:'svg',content:maskToSVG(R_)}});
const b=await api('savefile',{body:{name:n,ext:'png',b64:cPat.toDataURL('image/png').split(',')[1]}});
srvSet(a.ok&&b.ok?'SVG & PNG abgelegt ✓':(a.error||b.error||'Fehler'));srvRefresh()};
// ---------- Rapporte: Platzierungen innerhalb einer Schicht (Bandkoordinaten, px) ----------
function fibSub(x,y,w,h,minS,out,flip,hSeg,y0){
let lc=0,pc=0,idx=0;
while(Math.min(w,h)>=minS){
let s,cx,cy;
if(w>=h){s=h;if(lc%2===0){cx=x+s/2;x+=s}else{cx=x+w-s/2}cy=y+h/2;w-=s;lc++}
else{s=w;if(pc%2===0){cy=y+s/2;y+=s}else{cy=y+h-s/2}cx=x+w/2;h-=s;pc++}
out.push({x:cx,y:flip?y0+hSeg-(cy-y0):cy,size:s,rot:idx*Math.PI/2*(flip?-1:1),mx:false,my:flip});idx++}
}
function placements(L,wPx,Hp,ppm){
const out=[],ct=L.cell*ppm,sc=L.scale,rp=L.rapport;
if(rp==='border'){const rep=L.rep*ppm,n=Math.ceil(Hp/rep)+2;for(let k=-1;k<n;k++)out.push({border:true,x:wPx/2,y:rep*(k+.5),size:rep*sc,rot:0,mx:false,my:!!L.bmir&&!!(k&1),useB:false})}
else if(rp==='grid'||rp==='mirror'||rp==='alt'){
const cols=Math.max(1,Math.round(wPx/ct)),cs=wPx/cols,rows=Math.ceil(Hp/cs)+2;
for(let c=0;c<cols;c++)for(let r=-1;r<rows;r++){
const y=cs*(r+.5)+((rp==='alt'&&(c&1))?cs/2:0);
out.push({x:cs*(c+.5),y,size:cs*sc,rot:0,mx:(rp!=='grid')&&!!(c&1),my:rp==='mirror'&&!!(r&1),useB:rp==='alt'&&!!((c+r)&1)})}
}else if(rp==='honey'){
const cols=Math.max(1,Math.round(wPx/ct)),cs=wPx/cols,rh=cs*.866,rows=Math.ceil(Hp/rh)+2;
for(let r=-1;r<rows;r++)for(let c=-1;c<=cols;c++)out.push({x:cs*(c+.5)+((r&1)?cs/2:0),y:rh*r+cs/2,size:cs*sc,rot:0,mx:!!(r&1),my:false,useB:!!((c+r)&1)&&false});
}else if(rp==='log'){
const smax=Math.min(wPx,ct*2.2),smin=Math.max(ct*.3,ppm*3),q=L.q;let s=smax,dir=-1,y=0,k=0;
while(y<Hp+smax){out.push({x:wPx/2,y:y+s/2,size:s*sc,rot:0,mx:!!(k&1),my:false});y+=s;k++;
s*=dir<0?q:1/q;if(s<smin){dir=1;s=smin}if(s>smax){dir=-1;s=smax}}
}else if(rp==='fib'){
const hs=wPx*1.618,minS=Math.max(ct*.3,ppm*4);
for(let k=0;k*hs<Hp+hs;k++)fibSub(0,k*hs,wPx,hs,minS,out,!!(k&1),hs,k*hs);
for(const o of out)o.size*=sc;
}
return out;
}
// ---------- Netz zeichnen ----------
function drawGround(c,G,gw){
if(!G)return;
c.globalCompositeOperation='source-over';c.strokeStyle=c.fillStyle='#fff';c.lineCap=c.lineJoin='round';c.lineWidth=gw;
c.beginPath();for(const e of G.ed){c.moveTo(e[0],e[1]);c.quadraticCurveTo(e[2],e[3],e[4],e[5])}c.stroke();
c.beginPath();for(const n of G.nodes){c.moveTo(n[0]+gw*.72,n[1]);c.arc(n[0],n[1],gw*.72,0,TAU)}c.fill();
}
// Netz, das dem Motiv ausweicht: Kanten werden in Polylinien zerlegt und entlang des Abstandsfeldes weggeschoben
function drawGroundFit(c,G,gw,disp){
c.globalCompositeOperation='source-over';c.strokeStyle=c.fillStyle='#fff';c.lineCap=c.lineJoin='round';c.lineWidth=gw;
c.beginPath();
for(const e of G.ed){const n=8;let first=true;
for(let k=0;k<=n;k++){const t=k/n,u=1-t,x=u*u*e[0]+2*u*t*e[2]+t*t*e[4],y=u*u*e[1]+2*u*t*e[3]+t*t*e[5],p=disp(x,y);
if(first){c.moveTo(p[0],p[1]);first=false}else c.lineTo(p[0],p[1])}}
c.stroke();
c.beginPath();for(const nd of G.nodes){const p=disp(nd[0],nd[1]);c.moveTo(p[0]+gw*.72,p[1]);c.arc(p[0],p[1],gw*.72,0,TAU)}c.fill();
}
const hasMotif=m=>m&&(m.s.length||m.f.length);
// Übergangsprofil zwischen Schichten (Versatz in px an Position y)
function bndOff(L,i,y,ppm,oy0){
if(L.tr==='straight'||!(L.tamp>0))return 0;
const u=((y-oy0)/ppm)/L.twl+L.tph,f=u-Math.floor(u);let v=0;
switch(L.tr){
case'wave':v=Math.sin(TAU*u);break;
case'zig':v=4*Math.abs(f-.5)-1;break;
case'arc':v=2*Math.sqrt(Math.max(0,1-(2*f-1)**2))-1;break;
case'noise':v=Math.max(-1,Math.min(1,vnoise(u*1.3,i*5.7+.5,seed)*1.5));break;
case'step':v=f<.5?-1:1;break}
return v*L.tamp*ppm;
}
// ---------- Generator ----------
const genC=document.createElement('canvas'),gctx=genC.getContext('2d',{willReadFrequently:true});
let R_=null;
function generate(){syncRefs();
const Wtot=layers.reduce((a,L)=>a+L.w,0),[E0,E1]=edges;
const sh=E=>E.shape==='none'?'straight':E.shape,dMM=E=>(E.shape==='none'||E.shape==='straight')?0:E.dep;
const cnt=E=>Math.max(2,Math.round(P.hmm/E.scal)),prMM=E=>(E.shape!=='none'&&E.picot>0)?.7+1.2*E.picot:0;
const outMM=E=>dMM(E)+(hasMotif(E.mA)?Math.max(0,E.mout+(P.hmm/cnt(E))*E.msize):prMM(E)*2);
const mL=1+outMM(E0),mR=1+outMM(E1),mY=1;
const ppm=Math.min(+P.ppm,1800/(Wtot+mL+mR),1800/(P.hmm+2*mY));
const Wp=Math.round((Wtot+mL+mR)*ppm),Hp=Math.round((P.hmm+2*mY)*ppm),px=v=>v*ppm;
genC.width=Wp;genC.height=Hp;const c=gctx;
c.setTransform(1,0,0,1,0,0);c.globalCompositeOperation='source-over';c.fillStyle='#000';c.fillRect(0,0,Wp,Hp);
const minS=px(P.minS),minG=px(P.minG),th=Math.max(minS,px(P.thread)),depL=px(dMM(E0)),depR=px(dMM(E1));
const ox0=mL*ppm,ox1=ox0+Wtot*ppm,oy0=mY*ppm,oy1=oy0+P.hmm*ppm;
const outer=[[ox0,oy0],...scallopSide([ox1,oy0],[ox1,oy1],[1,0],E1.shape==='none'?0:cnt(E1),depR,sh(E1)),[ox1,oy1],...scallopSide([ox0,oy1],[ox0,oy0],[-1,0],E0.shape==='none'?0:cnt(E0),depL,sh(E0))];
// Polygon: oben gerade -> rechts -> unten gerade -> links
c.save();c.beginPath();pathPoly(c,outer,true);c.clip();
let x0=ox0;const bands=[],last=layers.length-1;
const maxAmp=Math.max(0,...layers.map(l=>l.tr==='straight'?0:l.tamp)),ext=Math.ceil(maxAmp*ppm)+3,dep=Math.max(depL,depR);
layers.forEach((L,i)=>{
const w=L.w*ppm,x1=x0+w,bx0=Math.floor(i===0?x0-depL-6:x0)-ext,bx1=Math.ceil(i===last?x1+depR+6:x1)+ext,bw=bx1-bx0;
bands.push({i,name:L.name,x0,x1,rap:L.rapport,w:L.w});
const gw=Math.max(minS,px(L.gw)),gcv=document.createElement('canvas'),mcv=document.createElement('canvas');
gcv.width=mcv.width=bw;gcv.height=mcv.height=Hp;const gcx=gcv.getContext('2d',{willReadFrequently:true}),mcx=mcv.getContext('2d',{willReadFrequently:true});
const pl=placements(L,w,Hp-oy0,ppm),A=L.mA,B=hasMotif(L.mB)?L.mB:null,any=hasMotif(A)||B,Tw=L.w/L.rep;
const tf=(p,fn)=>{mcx.save();mcx.translate(x0-bx0+p.x,oy0+p.y);
if(p.border){mcx.scale(p.size*(p.mx?-1:1),p.size*(p.my?-1:1));mcx.translate(-Tw/2,-.5)}
else{mcx.rotate(p.rot);let mx=p.mx;if(p.useB&&!B)mx=!mx;mcx.scale(mx?-p.size:p.size,p.my?-p.size:p.size);mcx.translate(-.5,-.5)}
fn(p.useB&&B?B:A);mcx.restore()};
if(any)for(const p of pl)tf(p,m=>paintMotifVec(mcx,m,th/p.size,'#fff'));
const integ=any&&L.ground!=='none'&&(L.halo>0||L.fit>0||L.cont>0);
let outCv=null;
if(!integ){
if(L.ground!=='none'){drawGround(gcx,buildGround(L.ground,px(L.pitch),58,L.warp,0,L.bulge,bw,Hp,bw/2,Hp/2,seed+i*17),gw)}
gcx.drawImage(mcv,0,0);outCv=gcv;
}else{
const N=bw*Hp,mI=mcx.getImageData(0,0,bw,Hp).data,inv=new Uint8Array(N);
for(let k=0;k<N;k++)inv[k]=mI[k*4+3]>127?0:1;
const d2=distToZero(inv,bw,Hp),sdf=(x,y)=>Math.sqrt(d2[clamp(y,0,Hp-1)*bw+clamp(x,0,bw-1)]);
const G=buildGround(L.ground,px(L.pitch),58,L.warp,0,L.bulge,bw,Hp,bw/2,Hp/2,seed+i*17);
if(L.fit>0){const R=px(L.fitR),fit=L.fit;
const disp=(x,y)=>{const ix=Math.round(x),iy=Math.round(y);if(ix<1||iy<1||ix>=bw-1||iy>=Hp-1)return[x,y];const d=sdf(ix,iy);if(d<=0||d>=R)return[x,y];
const hs=Math.max(2,Math.round(R/5)),gx=sdf(ix+hs,iy)-sdf(ix-hs,iy),gy=sdf(ix,iy+hs)-sdf(ix,iy-hs),l=Math.hypot(gx,gy);if(l<1e-6)return[x,y];
const push=fit*(R-d)*(R-d)/(2*R);return[x+gx/l*push,y+gy/l*push]};
drawGroundFit(gcx,G,gw,disp)}
else drawGround(gcx,G,gw);
const gI=gcx.getImageData(0,0,bw,Hp).data,hp=px(L.halo),h2=hp*hp,out=new ImageData(bw,Hp),o=out.data;
const gOn=new Uint8Array(N);for(let k=0;k<N;k++)gOn[k]=(inv[k]&&gI[k*4+3]>127&&d2[k]>=h2)?1:0;
{const lc=labelComps(gOn,bw,Hp),minSz=px(L.pitch)*gw*1.8;for(let k=0;k<N;k++)if(gOn[k]&&lc.sizes[lc.lab[k]]<minSz)gOn[k]=0}
const ro=px(L.cont),rw=gw*.5+.5;
for(let k=0;k<N;k++){let on=!inv[k]||gOn[k];if(!on&&ro>0&&Math.abs(Math.sqrt(d2[k])-ro)<rw)on=true;if(on){o[k*4]=o[k*4+1]=o[k*4+2]=255;o[k*4+3]=255}}
const stems=[];
if(hp>0){
const cs=Math.max(5,px(L.pitch)*.45/L.stemD),seen=new Set(),hr=hp+Math.max(1.8,px(L.stemR)),h3=hr*hr;
for(let y=0;y<Hp;y++)for(let x=0;x<bw;x++){const k=y*bw+x;
if(gOn[k]&&d2[k]<h3){const key=((x/cs)|0)*65536+((y/cs)|0);if(seen.has(key))continue;seen.add(key);
const pth=[[x,y]];let X=x,Y=y;for(let s=0;s<160&&sdf(Math.round(X),Math.round(Y))>.8;s++){const ix=Math.round(X),iy=Math.round(Y);const gx=sdf(ix+1,iy)-sdf(ix-1,iy),gy=sdf(ix,iy+1)-sdf(ix,iy-1),l=Math.hypot(gx,gy)||1;X-=gx/l;Y-=gy/l;if(s%3===2)pth.push([X,Y])}
pth.push([X,Y]);stems.push(pth)}}
}
const ocv=document.createElement('canvas');ocv.width=bw;ocv.height=Hp;const ox=ocv.getContext('2d');ox.putImageData(out,0,0);
ox.strokeStyle='#fff';ox.lineWidth=gw*L.stemW;ox.lineCap=ox.lineJoin='round';ox.beginPath();for(const s of stems){ox.moveTo(s[0][0],s[0][1]);for(let k=1;k<s.length;k++)ox.lineTo(s[k][0],s[k][1])}ox.stroke();
outCv=ocv;
}
// Schicht mit wellenförmigem Übergang ausschneiden
c.save();c.beginPath();
const prev=i>0?layers[i-1]:null,ys=[];for(let y=0;y<=Hp;y+=3)ys.push(Math.min(y,Hp));if(ys[ys.length-1]!==Hp)ys.push(Hp);
ys.forEach((y,k)=>{const xx=(prev?x0+bndOff(prev,i-1,y,ppm,oy0):bx0-5);k?c.lineTo(xx,y):c.moveTo(xx,y)});
for(let k=ys.length-1;k>=0;k--){const y=ys[k],xx=(i<last?x1+bndOff(L,i,y,ppm,oy0):bx1+5);c.lineTo(xx,y)}
c.closePath();c.clip();c.drawImage(outCv,bx0,0);c.restore();
if(L.sep&&i<last){c.strokeStyle='#fff';c.lineWidth=th*1.4;c.lineCap=c.lineJoin='round';c.beginPath();
for(let y=oy0;y<=oy1+1;y+=2){const xx=x1+bndOff(L,i,y,ppm,oy0);y===oy0?c.moveTo(xx,y):c.lineTo(xx,y)}c.stroke()}
x0=x1;
});
c.restore();
// Rahmen & Kanten
c.lineCap=c.lineJoin='round';
const edgeDraw=(E,sx,ax0,sg)=>{ // sx: -1 links / +1 rechts
const n=cnt(E),sp=(oy1-oy0)/n,dp=px(dMM(E));
if(E.shape!=='none'){c.strokeStyle='#fff';c.lineWidth=th*E.lw;
const p=scallopSide(sx<0?[ax0,oy1]:[ax0,oy0],sx<0?[ax0,oy0]:[ax0,oy1],[sx,0],n,dp,sh(E));p.push(sx<0?[ax0,oy0]:[ax0,oy1]);c.beginPath();pathPoly(c,p,false);c.stroke()}
if(hasMotif(E.mA)){const size=sp*E.msize,anchor=ax0+sx*(dp+px(E.mout)),X=sx<0?anchor-size:anchor;
for(let k=0;k<n;k++){c.save();c.translate(X,oy0+sp*(k+.5)-size/2);c.scale(size,size);paintMotifVec(c,E.mA,th/size,'#fff');c.restore()}}
else if(E.shape!=='none'&&E.picot>0){const pr=px(prMM(E));c.strokeStyle='#fff';c.lineWidth=Math.max(minS,th*.9);
for(let k=0;k<n;k++){const y=oy0+sp*(k+.5);c.beginPath();c.arc(ax0+sx*(dp+pr*.85),y,pr,0,TAU);c.stroke()}}
};
edgeDraw(E0,-1,ox0);edgeDraw(E1,1,ox1);
c.strokeStyle='#fff';c.lineWidth=th*1.4;
if(P.frameT){c.beginPath();c.moveTo(ox0,oy0);c.lineTo(ox1,oy0);c.stroke()}
if(P.frameB){c.beginPath();c.moveTo(ox0,oy1);c.lineTo(ox1,oy1);c.stroke()}
const wt=c.getImageData(0,0,Wp,Hp).data;
R_=postprocess(wt,Wp,Hp,{minS,minG,gwPx:th,mm:1/ppm});
R_.mm=1/ppm;R_.gwPx=th;R_.id=++genId;patDirty=false;markDirty();
bands.unshift({i:-1,name:E0.name,x0:ox0-Math.max(10,px(outMM(E0))),x1:ox0,sub:'Rand'});bands.push({i:-2,name:E1.name,x0:ox1,x1:ox1+Math.max(10,px(outMM(E1))),sub:'Rand'});R_.bands=bands;
render2d();
}
// ---------- Post-Processing: EDT, Closing, Konnektivität ----------
function distToZero(mask,w,h){ // Quadrat-Distanz jedes Pixels (mask=1) zum nächsten Pixel mit mask=0
const BIG=1e10,f=new Float32Array(w*h),n=Math.max(w,h),col=new Float32Array(n),d=new Float32Array(n),v=new Int32Array(n),z=new Float32Array(n+1);
for(let i=0;i<w*h;i++)f[i]=mask[i]?BIG:0;
const pass=(len,get,set)=>{
for(let q=0;q<len;q++)col[q]=get(q);
let k=0;v[0]=0;z[0]=-1e20;z[1]=1e20;
for(let q=1;q<len;q++){
let s=((col[q]+q*q)-(col[v[k]]+v[k]*v[k]))/(2*q-2*v[k]);
while(s<=z[k]){k--;s=((col[q]+q*q)-(col[v[k]]+v[k]*v[k]))/(2*q-2*v[k])}
k++;v[k]=q;z[k]=s;z[k+1]=1e20}
k=0;for(let q=0;q<len;q++){while(z[k+1]<q)k++;const dq=q-v[k];d[q]=dq*dq+col[v[k]]}
for(let q=0;q<len;q++)set(q,d[q]);
};
for(let x=0;x<w;x++)pass(h,q=>f[q*w+x],(q,val)=>{f[q*w+x]=val});
for(let y=0;y<h;y++)pass(w,q=>f[y*w+q],(q,val)=>{f[y*w+q]=val});
return f;
}
function labelComps(mask,w,h){
const lab=new Int32Array(w*h),st=new Int32Array(w*h),sizes=[0];let n=0;
for(let i=0;i<w*h;i++){if(!mask[i]||lab[i])continue;n++;let sp=0,cnt=0;st[sp++]=i;lab[i]=n;
while(sp){const p=st[--sp];cnt++;const x=p%w,y=(p/w)|0;
if(x>0&&mask[p-1]&&!lab[p-1]){lab[p-1]=n;st[sp++]=p-1}
if(x<w-1&&mask[p+1]&&!lab[p+1]){lab[p+1]=n;st[sp++]=p+1}
if(y>0&&mask[p-w]&&!lab[p-w]){lab[p-w]=n;st[sp++]=p-w}
if(y<h-1&&mask[p+w]&&!lab[p+w]){lab[p+w]=n;st[sp++]=p+w}}
sizes.push(cnt)}
return{lab,n,sizes};
}
// ---------- Freie Enden: Skelett (Zhang-Suen), Stümpfe kürzen / anbinden ----------
function thinSkeleton(mask,w,h){
const N=w*h,sk=new Uint8Array(mask),mark=new Int32Array(N);let stamp=0;
for(let x=0;x<w;x++){sk[x]=0;sk[N-1-x]=0}for(let y=0;y<h;y++){sk[y*w]=0;sk[y*w+w-1]=0}
let cand=[];for(let i=w+1;i<N-w-1;i++)if(sk[i]&&(!sk[i-1]||!sk[i+1]||!sk[i-w]||!sk[i+w]))cand.push(i);
const off8=[-1,1,-w,w,-w-1,-w+1,w-1,w+1];let changed=true;
while(changed&&cand.length){changed=false;
for(let step=0;step<2;step++){
const del=[];
for(const i of cand){if(!sk[i])continue;
const p2=sk[i-w],p3=sk[i-w+1],p4=sk[i+1],p5=sk[i+w+1],p6=sk[i+w],p7=sk[i+w-1],p8=sk[i-1],p9=sk[i-w-1],B=p2+p3+p4+p5+p6+p7+p8+p9;
if(B<2||B>6)continue;
const A=(!p2&&p3?1:0)+(!p3&&p4?1:0)+(!p4&&p5?1:0)+(!p5&&p6?1:0)+(!p6&&p7?1:0)+(!p7&&p8?1:0)+(!p8&&p9?1:0)+(!p9&&p2?1:0);if(A!==1)continue;
if(step===0){if(p2*p4*p6!==0||p4*p6*p8!==0)continue}else{if(p2*p4*p8!==0||p2*p6*p8!==0)continue}
del.push(i)}
if(del.length){changed=true;for(const i of del)sk[i]=0}
stamp++;const next=[];
for(const i of cand)if(sk[i]&&mark[i]!==stamp){mark[i]=stamp;next.push(i)}
for(const i of del)for(const d of off8){const j=i+d;if(sk[j]&&mark[j]!==stamp){mark[j]=stamp;next.push(j)}}
cand=next}}
return sk;
}
function endsPass(mask,W8,bridgeMap,w,h,o){
const N=w*h,sk=thinSkeleton(mask,w,h),dIn=distToZero(mask,w,h),din=i=>Math.sqrt(dIn[i]);
const thinLim=o.gwPx*.5*1.7+1,maxLen=Math.max(3,P.endLen/o.mm),off8=[-1,1,-w,w,-w-1,-w+1,w-1,w+1];
const nb=i=>{let c=0;for(const d of off8)if(sk[i+d])c++;return c};
const adj=(a,b)=>Math.abs(a%w-b%w)<=1&&Math.abs(((a/w)|0)-((b/w)|0))<=1;
const ends=[];for(let i=w+1;i<N-w-1;i++)if(sk[i]&&nb(i)===1)ends.push(i);
let trimmed=0,joined=0;
const erase=(cx,cy,r)=>{const ri=Math.ceil(r);for(let yy=-ri;yy<=ri;yy++)for(let xx=-ri;xx<=ri;xx++){if(xx*xx+yy*yy>r*r)continue;const X=cx+xx,Y=cy+yy;if(X<0||Y<0||X>=w||Y>=h)continue;const k=Y*w+X;mask[k]=0;W8[k]=0}};
for(const e of ends){
if(!mask[e]||din(e)>thinLim)continue;
const path=[e],vis=new Set([e]);let cur=e,junction=false;
while(path.length<=maxLen+2){
const U=[];for(const d of off8){const j=cur+d;if(sk[j]&&!vis.has(j))U.push(j)}
if(!U.length)break;
let pick=U[0];
if(U.length>1){let cl=true;for(let a=0;a<U.length&&cl;a++)for(let b=a+1;b<U.length;b++)if(!adj(U[a],U[b])){cl=false;break}
if(!cl){junction=true;break}
let best=-1;for(const u of U){let s=0;for(const d of off8){const j=u+d;if(sk[j]&&!vis.has(j)&&!U.includes(j))s++}if(s>best){best=s;pick=u}}
for(const u of U)vis.add(u)}
cur=pick;vis.add(cur);path.push(cur)}
if(P.endMode==='trim'){
if(junction&&path.length<=maxLen+1){
const dJ=din(cur),n=path.length;
for(let k=0;k<n;k++){if(n-1-k<=dJ+1.5)continue;const q=path[k];erase(q%w,(q/w)|0,din(q)+.6)}
trimmed++}
}else if(P.endMode==='join'){
const bk=path[Math.min(path.length-1,8)],ex=e%w,ey=(e/w)|0;let dx=ex-bk%w,dy=ey-((bk/w)|0);const dl=Math.hypot(dx,dy);if(dl<1)continue;dx/=dl;dy/=dl;
const st=din(e)+2.5;let bestR=1e9,bx=0,by=0;
for(let a=-60;a<=60;a+=12){const ca=Math.cos(a*Math.PI/180),sa=Math.sin(a*Math.PI/180),rx=dx*ca-dy*sa,ry=dx*sa+dy*ca;
for(let r=st;r<=maxLen&&r<bestR;r+=1){const X=Math.round(ex+rx*r),Y=Math.round(ey+ry*r);if(X<0||Y<0||X>=w||Y>=h)break;if(mask[Y*w+X]){bestR=r;bx=X;by=Y;break}}}
if(bestR<1e9){const br=Math.max(1.2,o.gwPx*.5),len=Math.max(1,Math.hypot(bx-ex,by-ey)),ri=Math.ceil(br);
for(let k=0;k<=len;k++){const X=ex+(bx-ex)*k/len,Y=ey+(by-ey)*k/len;
for(let yy=-ri;yy<=ri;yy++)for(let xx=-ri;xx<=ri;xx++){if(xx*xx+yy*yy>br*br)continue;const X2=Math.round(X)+xx,Y2=Math.round(Y)+yy;if(X2<0||Y2<0||X2>=w||Y2>=h)continue;const ii=Y2*w+X2;if(!mask[ii]){mask[ii]=1;W8[ii]=255;bridgeMap[ii]=1}}}
joined++}}
}
return{trimmed,joined};
}
function postprocess(wt,w,h,o){
const N=w*h,mask=new Uint8Array(N),W8=new Uint8Array(N),THR=70;
for(let i=0;i<N;i++){const v=wt[i*4];if(v>THR){mask[i]=1;W8[i]=v}}
// Closing: zu enge Lücken füllen
let closed=0;const r=o.minG/2;
if(r>.8){
const inv=new Uint8Array(N);for(let i=0;i<N;i++)inv[i]=mask[i]?0:1;
const d1=distToZero(inv,w,h),dil=new Uint8Array(N),r2=r*r;
for(let i=0;i<N;i++)dil[i]=(mask[i]||d1[i]<=r2)?1:0;
const d2=distToZero(dil,w,h);
for(let i=0;i<N;i++)if(!mask[i]&&dil[i]&&d2[i]>r2){mask[i]=1;W8[i]=255;closed++}
}
// Komponenten
let L=labelComps(mask,w,h);const before=L.n;let bridges=0,removed=0;
const strandMin=Math.max(4,o.minS*o.minS*2);
// Rauschen (winzige Inseln) immer entfernen
let big=1;for(let k=2;k<=L.n;k++)if(L.sizes[k]>L.sizes[big])big=k;
for(let i=0;i<N;i++)if(mask[i]&&L.sizes[L.lab[i]]<strandMin&&L.lab[i]!==big){mask[i]=0;W8[i]=0;removed++}
L=labelComps(mask,w,h);
const bridgeMap=new Uint8Array(N);let trimmed=0,joined=0;
if(P.endMode!=='off'){const er=endsPass(mask,W8,bridgeMap,w,h,o);trimmed=er.trimmed;joined=er.joined;L=labelComps(mask,w,h)}
if(L.n>1){
big=1;for(let k=2;k<=L.n;k++)if(L.sizes[k]>L.sizes[big])big=k;
if(P.island==='remove'){for(let i=0;i<N;i++)if(mask[i]&&L.lab[i]!==big){mask[i]=0;W8[i]=0;removed++}}
else{
for(let pass=0;pass<3&&L.n>1;pass++){
const lab=L.lab,nc=L.n,parent=Array.from({length:nc+1},(_,i)=>i),find=a=>{while(parent[a]!==a){parent[a]=parent[parent[a]];a=parent[a]}return a};
const start=new Int32Array(nc+2);for(let i=0;i<N;i++)if(lab[i])start[lab[i]+1]++;
for(let k=1;k<=nc+1;k++)start[k]+=start[k-1];
const fillp=start.slice(),order=new Int32Array(start[nc+1]);
for(let i=0;i<N;i++)if(lab[i])order[fillp[lab[i]]++]=i;
const ids=[];for(let k=1;k<=nc;k++)if(k!==big)ids.push(k);ids.sort((a,b)=>L.sizes[a]-L.sizes[b]);
let groups=nc;const q=new Int32Array(N),seen=new Int32Array(N),par=new Int32Array(N);let stamp=0;
const br=Math.max(1.2,o.gwPx*.5);
for(const cmp of ids){
if(groups<=1)break;stamp++;let qh=0,qt=0;const rc=find(cmp);
for(let t=start[cmp];t<start[cmp+1];t++){const p=order[t];seen[p]=stamp;par[p]=-1;q[qt++]=p}
let hit=-1;
while(qh<qt&&hit<0){const p=q[qh++],x=p%w,y=(p/w)|0;
for(let dN=0;dN<4;dN++){let np=-1;
if(dN===0&&x>0)np=p-1;else if(dN===1&&x<w-1)np=p+1;else if(dN===2&&y>0)np=p-w;else if(dN===3&&y<h-1)np=p+w;
if(np<0||seen[np]===stamp)continue;seen[np]=stamp;par[np]=p;
if(lab[np]&&find(lab[np])!==rc){hit=np;break}
q[qt++]=np}}
if(hit<0)continue;
let s=hit;while(par[s]!==-1)s=par[s];
// gerade Linie s -> hit mit Scheiben stempeln
const sx=s%w,sy=(s/w)|0,tx=hit%w,ty=(hit/w)|0,len=Math.max(1,Math.hypot(tx-sx,ty-sy));
for(let k=0;k<=len;k++){const X=sx+(tx-sx)*k/len,Y=sy+(ty-sy)*k/len,ri=Math.ceil(br);
for(let yy=-ri;yy<=ri;yy++)for(let xx=-ri;xx<=ri;xx++){if(xx*xx+yy*yy>br*br)continue;
const px_=Math.round(X)+xx,py_=Math.round(Y)+yy;if(px_<0||py_<0||px_>=w||py_>=h)continue;
const ii=py_*w+px_;if(!mask[ii]){mask[ii]=1;W8[ii]=255;bridgeMap[ii]=1}}}
parent[rc]=find(lab[hit]);groups--;bridges++;
}
L=labelComps(mask,w,h);big=1;for(let k=2;k<=L.n;k++)if(L.sizes[k]>L.sizes[big])big=k;
}
}
L=labelComps(mask,w,h);
}
let mat=0;for(let i=0;i<N;i++)mat+=mask[i];
return{w,h,mask,W8,bridgeMap,stats:{before,after:L.n,bridges,removed,closed,trimmed,joined,cover:mat/N}};
}
// ---------- Vorschau (90° im Uhrzeigersinn gedreht: Schichten liegen als Streifen übereinander) ----------
const cPat=$('#cPat'),pctx=cPat.getContext('2d'),tmpC=document.createElement('canvas');
const LUT=new Uint32Array(256);for(let i=0;i<256;i++){const v=Math.round(255*(1-Math.pow(i/255,2.4)));LUT[i]=0xff000000|v<<16|v<<8|v}
const BRIDGE=0xff000000|50<<16|80<<8|224;
function render2d(){
const r=R_;if(!r)return;
tmpC.width=r.w;tmpC.height=r.h;
const im=new ImageData(r.w,r.h),px=new Uint32Array(im.data.buffer),n=r.w*r.h,mk=r.mask,W8=r.W8,bm=r.bridgeMap;
for(let i=0;i<n;i++)px[i]=mk[i]?(bm[i]?BRIDGE:LUT[W8[i]]):0xffffffff;
tmpC.getContext('2d').putImageData(im,0,0);
cPat.width=r.h;cPat.height=r.w;pctx.setTransform(0,1,-1,0,r.h,0);pctx.drawImage(tmpC,0,0);pctx.setTransform(1,0,0,1,0,0);
const s=r.stats;
updatePrev();$('#info').textContent=`Zusammenhängende Teile: ${s.after} ${s.after===1?'✓ alles verbunden':'⚠'} · vorher ${s.before} · Verbinder ${s.bridges} (rot)${s.trimmed?` · ${s.trimmed} Stümpfe gekürzt`:''}${s.joined?` · ${s.joined} Enden angebunden`:''} · Material ${(s.cover*100).toFixed(0)} % · ${(r.mm).toFixed(3)} mm/px`;
placeLabels();
}
function lbSub(b){const L=layerAt(b.i);if(!L)return'';const names=(L.edge?['A']:['A','B']).map(k=>motifName(L,k)).filter(Boolean).join(' / ')||'kein Motiv';
if(L.edge)return`<small>Rand · Motiv: ${esc(names)}</small>`;
return`<small>Netz ${GR[L.ground]}${L.ground==='none'?'':' '+L.pitch} · ${RAP_LABEL[L.rapport]}</small><small>Motiv: ${esc(names)}</small>`}
function placeLabels(){
const r=R_,host=$('#lbls');host.innerHTML='';if(!r||tab!=='pat')return;
const st=$('#stage').getBoundingClientRect(),cr=cPat.getBoundingClientRect();if(!cr.width)return;
const k=cr.height/r.w;
for(const b of r.bands){const y0=cr.top-st.top+b.x0*k+2,y1=cr.top-st.top+b.x1*k-2,d=document.createElement('div');
d.className='lb'+(b.i===sel?' on':'');d.style.left=(cr.right-st.left+10)+'px';d.style.top=((y0+y1)/2)+'px';
d.innerHTML=`<i style="height:${Math.max(4,y1-y0)}px;top:50%;margin-top:${-Math.max(4,y1-y0)/2}px"></i><span>${b.i>=0?b.i+1+' · ':''}${b.name}${lbSub(b)}</span>`;
d.onclick=()=>{sel=b.i;renderLayers();placeLabels()};host.appendChild(d)}
}
function updatePrev(){
const im=$('#svgPrev');
if(!P.svgPrev||!R_||tab!=='pat'){im.style.display='none';cPat.style.visibility='';return}
const key=R_.id+'|'+P.svgSm;
if(im._key!==key){im._key=key;const u=URL.createObjectURL(new Blob([maskToSVG(R_)],{type:'image/svg+xml'}));if(im._u)URL.revokeObjectURL(im._u);im._u=u;im.src=u}
const st=$('#stage').getBoundingClientRect(),cr=cPat.getBoundingClientRect();
Object.assign(im.style,{display:'block',left:(cr.left-st.left)+'px',top:(cr.top-st.top)+'px',width:cr.width+'px',height:cr.height+'px'});cPat.style.visibility='hidden'}
window.addEventListener('resize',()=>{placeLabels();updatePrev()});
// ---------- Stift/Radierer ----------
function syncTools(){const er=P.tool==='erase';$('#tPen').classList.toggle('on',P.tool==='thread');$('#tEra0').classList.toggle('on',er&&P.erase<1);$('#tEraS').classList.toggle('on',er&&P.erase>=1&&P.erase<4);$('#tEraL').classList.toggle('on',er&&P.erase>=4)}
for(const b of document.querySelectorAll('#edbar button'))b.onclick=()=>{P.tool=b.dataset.tool;if(b.dataset.sz)P.erase=+b.dataset.sz;buildUI($('#g_draw'),SPEC.draw,P,changed);syncTools();drawEditor()};
// ---------- Tabs ----------
function setTab(t){
tab=t;document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.t===t));
cPat.style.display=t==='pat'?'':'none';cEd.style.display=t==='ed'?'':'none';
$('#edtop').style.display=t==='ed'?'flex':'none';$('#stage').classList.toggle('ed',t==='ed');
if(t==='ed'){$('#svgPrev').style.display='none';cPat.style.visibility='';$('#lbls').innerHTML='';syncTools();drawEditor()}
else{updatePrev();placeLabels()}
}
document.querySelectorAll('#tabs button').forEach(b=>b.onclick=()=>setTab(b.dataset.t));
// ---------- Export ----------
function dl(blob,name){const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),2000)}
$('#bPNG').onclick=()=>cPat.toBlob(b=>dl(b,'lace_pattern.png'));
$('#bRnd').onclick=()=>{seed=(Math.random()*1e6)|0;generate()};
$('#bSVG').onclick=()=>{if(!R_)return;dl(new Blob([maskToSVG(R_)],{type:'image/svg+xml'}),'lace_pattern.svg')};
// Maske -> glatte Vektorkonturen (Marching Squares, Quadratglättung), Ausgabe in mm, 90° gedreht wie die Ansicht
function maskToSVG(r){
const w=r.w,h=r.h,W=w+2,H=h+2,f=new Float32Array(W*H);
for(let y=0;y<h;y++)for(let x=0;x<w;x++)if(r.mask[y*w+x])f[(y+1)*W+x+1]=1;
let g=new Float32Array(W*H);
{const n=P.svgSm|0;if(n===0)g.set(f);else{let a=f,b=new Float32Array(W*H);
for(let it=0;it<n;it++){b.fill(0);for(let y=1;y<H-1;y++)for(let x=1;x<W-1;x++){let s=0;for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++)s+=a[(y+dy)*W+x+dx]*(dx||dy?1:2);b[y*W+x]=s/10}
const t=a;a=b;b=t}
g=a}}
// Kanten-IDs: 2*(y*W+x) = horizontale Kante (x,y)-(x+1,y), +1 = vertikale (x,y)-(x,y+1)
const pts=new Map(),adj=new Map(),T=.5;
const ept=(id)=>{let p=pts.get(id);if(p)return p;const v=id&1,k=id>>1,x=k%W,y=(k/W)|0;
const a=g[k],b=v?g[k+W]:g[k+1],t=(T-a)/(b-a);p=v?[x,y+t]:[x+t,y];pts.set(id,p);return p};
const link=(i,j)=>{(adj.get(i)||adj.set(i,[]).get(i)).push(j);(adj.get(j)||adj.set(j,[]).get(j)).push(i)};
for(let y=0;y<H-1;y++)for(let x=0;x<W-1;x++){const k=y*W+x,a=g[k]>T?1:0,b=g[k+1]>T?1:0,c=g[k+W+1]>T?1:0,d=g[k+W]>T?1:0,m=a|b<<1|c<<2|d<<3;
if(m===0||m===15)continue;
const top=2*k,left=2*k+1,right=2*(k+1)+1,bot=2*(k+W);
switch(m){
case 1:case 14:link(top,left);break;case 2:case 13:link(top,right);break;case 3:case 12:link(left,right);break;
case 4:case 11:link(right,bot);break;case 6:case 9:link(top,bot);break;case 7:case 8:link(left,bot);break;
case 5:case 10:{const cen=(g[k]+g[k+1]+g[k+W]+g[k+W+1])/4>T;
if((m===5)===cen){link(top,right);link(left,bot)}else{link(top,left);link(right,bot)}}}}
const seen=new Set(),loops=[];
for(const [start,nb] of adj){if(seen.has(start))continue;const loop=[];let prev=-1,curId=start;
while(curId!==undefined&&!seen.has(curId)){seen.add(curId);loop.push(ept(curId));const n=adj.get(curId);const nx=n[0]!==prev&&!seen.has(n[0])?n[0]:(n.length>1&&!seen.has(n[1])?n[1]:undefined);prev=curId;curId=nx}
if(loop.length>=3)loops.push(loop)}
const rdp=(p,eps)=>{ // Douglas-Peucker für geschlossene Schleife: an zwei Ankern aufteilen
const n=p.length;let far=0,fd=0;for(let i=1;i<n;i++){const d=Math.hypot(p[i][0]-p[0][0],p[i][1]-p[0][1]);if(d>fd){fd=d;far=i}}
const run=(a,b,out)=>{let md=0,mi=-1;const[ax,ay]=p[a],[bx,by]=p[b],dx=bx-ax,dy=by-ay,l=Math.hypot(dx,dy)||1e-9;
for(let i=a+1;i<b;i++){const d=Math.abs((p[i][0]-ax)*dy-(p[i][1]-ay)*dx)/l;if(d>md){md=d;mi=i}}
if(md>eps){run(a,mi,out);run(mi,b,out)}else out.push(p[b])};
const o1=[p[0]],o2=[p[far]];run(0,far,o1);run(far,n-1,o2);o2.push(p[0]);return o1.concat(o2.slice(1,-1))};
const mm=r.mm,R=(x,y)=>[(h-(y-1)-.5)*mm,((x-1)+.5)*mm]; // gedreht
const fx=v=>(Math.round(v*1000)/1000).toString();
let d='';
for(const lp of loops){const q=rdp(lp,.12+.1*(P.svgSm|0)).map(p=>R(p[0],p[1]));const n=q.length;if(n<3)continue;
const mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2],m0=mid(q[n-1],q[0]);
d+=`M${fx(m0[0])} ${fx(m0[1])}`;
for(let i=0;i<n;i++){const nx=q[(i+1)%n],m=mid(q[i],nx);d+=`Q${fx(q[i][0])} ${fx(q[i][1])} ${fx(m[0])} ${fx(m[1])}`}
d+='Z'}
const Wmm=h*mm,Hmm=w*mm;
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${fx(Wmm)}mm" height="${fx(Hmm)}mm" viewBox="0 0 ${fx(Wmm)} ${fx(Hmm)}"><path d="${d}" fill="#000" fill-rule="evenodd"/></svg>`;
}
// ---------- Start: Schichten wie in der Vorlage ----------
layers.push(
newLayer({name:'Saum',w:22,rapport:'mirror',cell:22,ground:'hex',pitch:6.5,tr:'wave',tamp:1.5,twl:22}),
newLayer({name:'Feld',w:62,rapport:'alt',cell:31,ground:'hex',pitch:6}),
newLayer({name:'Saum',w:22,rapport:'mirror',cell:22,ground:'hex',pitch:6.5,sep:false}));
{const sp=lsGet(LS_P),sl=lsGet(LS_L);if(Array.isArray(sl))lib=sl.filter(x=>x.cat!=='muster');lib.forEach(x=>{if(!x.id)x.id=uid()});if(sp&&sp.layers)applyProject(sp)}
P.tool=P.tool||'thread';
syncDraft();rebuildUI();
$('#autoUpd').checked=!!P.autoUpd;$('#autoUpd').onchange=e=>{P.autoUpd=e.target.checked;saveSoon();if(P.autoUpd&&patDirty)doUpdate()};
$('#bUpdate').onclick=doUpdate;
if(innerWidth<900)document.querySelectorAll('#side details').forEach((d,i)=>{if(i>=4)d.open=false});
generate();srvInit();
window.__lace={P,draft,doUpdate,get dirty(){return patDirty},layers,generate,setTab,drawEditor,maskToSVG,get R(){return R_},get lib(){return lib},get sel(){return sel},set sel(v){sel=v;renderLayers()}};
</script>
</body>
</html>