Files
crumbmissions/crumbblocks/bezier.html
2025-12-23 20:52:00 +01:00

629 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>OneLiner Painter • Bezier Pen + Editor</title>
<style>
:root{ --bg:#0b0b10; --panel:#12121a; --muted:#9aa3b2; --accent:#62d3a4; --accent2:#ffd166; --danger:#ff5c5c; }
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg);color:#e7eaf0;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial}
header,footer{padding:12px 16px;background:var(--panel);border-bottom:1px solid #1e2230}
footer{border-top:1px solid #1e2230;border-bottom:none;font-size:.9rem;color:var(--muted)}
h1{font-size:1.05rem;margin:0}
.wrap{display:grid;grid-template-columns:380px 1fr;gap:16px;padding:16px}
@media (max-width:1100px){.wrap{grid-template-columns:1fr}}
.panel{background:var(--panel);border:1px solid #1e2230;border-radius:12px;padding:14px;display:grid;gap:12px}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.col{display:grid;gap:10px}
label{font-size:.9rem;color:#cfd5e3}
input[type="text"],textarea,select{width:100%;padding:10px;border-radius:10px;border:1px solid #2a3042;background:#0f121a;color:#e7eaf0}
input[type="color"]{width:48px;height:36px;border:none;background:none}
.btn{appearance:none;border:none;border-radius:10px;padding:10px 12px;background:#1f2636;color:#e7eaf0;cursor:pointer}
.btn:hover{background:#28314a}
.btn.accent{background:var(--accent);color:#0b1114;font-weight:600}
.btn.warn{background:var(--danger);color:#0b0b10}
.chip{display:inline-flex;align-items:center;gap:8px;background:#0f121a;border:1px dashed #2a3042;border-radius:10px;padding:6px 10px}
.kbd{padding:1px 6px;border-radius:8px;background:#1e2333;color:#cfd5e3;font-family:ui-monospace,Menlo,Monaco,monospace}
ul#strokeList{list-style:none;margin:0;padding:0;display:grid;gap:8px;max-height:240px;overflow:auto}
li.strokeItem{display:flex;align-items:center;gap:8px;background:#0f121a;border:1px solid #2a3042;border-radius:10px;padding:8px}
.canvasWrap{position:relative;background:#0f121a;border:1px solid #1e2230;border-radius:12px;overflow:hidden}
svg{display:block;width:100%;height:100%;background:#0f121a;touch-action:none}
.badge{font-size:.75rem;color:#0b1114;background:var(--accent);border-radius:999px;padding:2px 8px}
.muted{color:var(--muted)}
.sline{fill:none;stroke-linecap:round;stroke-linejoin:round}
.markerStart{fill:#35c759;stroke:none}
.markerStop{fill:#ff3b30;stroke:none}
.anchor{fill:#31e07b;stroke:#0b0b10;stroke-width:2;cursor:grab; pointer-events:all}
.handle{fill:#ffe08a;stroke:#0b0b10;stroke-width:2;cursor:grab; pointer-events:all}
.handleLine{stroke:#ffe08a;stroke-width:2.5;stroke-dasharray:4 4; pointer-events:none}
.ghost{fill:none;stroke:#ffffff55;stroke-width:2;stroke-dasharray:6 8}
#bezierUI{ pointer-events:none }
.selected{filter:drop-shadow(0 0 4px #ffd166)}
.hint{font-size:.85rem;color:#cfd5e3;line-height:1.3}
</style>
</head>
<body>
<header>
<h1>OneLiner Painter • <span class="badge">Bezier-Pen</span> + Editor • Replay • SVG/PNG/JSON</h1>
</header>
<div class="wrap">
<aside class="panel">
<h2 style="margin:0;font-size:1rem">Werkzeug</h2>
<div class="row">
<label class="chip"><input type="checkbox" id="sparkMode"> <span>Funken (Shift)</span></label>
<label class="chip"><input type="checkbox" id="bitMode"> <span>Bits „1“</span></label>
<label class="chip"><input type="checkbox" id="markers"> <span>Start/Stop</span></label>
<label class="chip"><input type="checkbox" id="frameOn" checked> <span>Crumblines-Frame</span></label>
</div>
<div class="row">
<label>Strichstärke <input type="range" id="stroke" min="4" max="28" step="1" value="12"></label>
<label>Sampler
<select id="sampler">
<option value="raw">Raw Polyline</option>
<option value="quad">Quadratic</option>
<option value="cubic" selected>CatmullRom → Cubic</option>
</select>
</label>
</div>
<div class="row">
<label>Farbe A <input type="color" id="colorA" value="#62d3a4"></label>
<label>Farbe B <input type="color" id="colorB" value="#ffd166"></label>
<label>Funken <input type="color" id="colorSpark" value="#e7eaf0"></label>
</div>
<div class="row">
<label class="chip"><input type="radio" name="mode" value="A" checked> <span>Stroke A</span></label>
<label class="chip"><input type="radio" name="mode" value="B"> <span>Stroke B</span></label>
<label class="chip"><input type="radio" name="mode" value="GRAD"> <span>Verlauf A→B</span></label>
</div>
<div class="col" style="margin-top:6px">
<strong>BezierPen (Anker setzen)</strong>
<div class="row">
<label class="chip"><input type="checkbox" id="penMode"> <span>Pen aktiv</span></label>
<button class="btn" id="finishPen" disabled>Fertig (↵)</button>
<span class="muted">Shift: Winkel snap • Alt: Ecke • ⌫: Letzten Anchor löschen</span>
</div>
</div>
<div class="col" style="margin-top:6px">
<strong>BezierEditor</strong>
<div class="row">
<label class="chip"><input type="checkbox" id="editBezier"> <span>Bearbeiten</span></label>
<label class="chip"><input type="checkbox" id="linkHandles" checked> <span>SpiegelGriffe</span></label>
<button class="btn" id="toBezier">Auswahl → Bezier</button>
<button class="btn" id="splitHandles">Anker: Split</button>
<button class="btn warn" id="delAnchor" disabled>Anker löschen</button>
</div>
<p class="hint">Klicke einen Stroke → <em>Bearbeiten</em>. Doppelklick auf Pfad fügt einen Anker. <span class="kbd">L</span> toggelt SpiegelGriffe.</p>
</div>
<div>
<strong>Strokes</strong>
<ul id="strokeList" aria-label="Strich-Liste"></ul>
</div>
<div class="row">
<button class="btn" id="undo">Letzten Stroke löschen</button>
<button class="btn warn" id="clear">Alles löschen</button>
</div>
<div class="row">
<button class="btn" id="replay">▶︎ Replay</button>
<button class="btn" id="copySVG">SVG kopieren</button>
<button class="btn" id="downloadSVG">SVG speichern</button>
<button class="btn" id="downloadPNG">PNG speichern</button>
<button class="btn accent" id="copyJSON">MotionJSON kopieren</button>
<button class="btn" id="downloadJSON">JSON speichern</button>
</div>
</aside>
<main class="canvasWrap" aria-label="Zeichenfläche">
<svg id="stage" viewBox="0 0 1200 800" role="img" aria-labelledby="svgTitle svgDesc">
<title id="svgTitle">Omi Omega OneLiner</title>
<desc id="svgDesc">a→Spirale→y; Bits 1+1; Funken.</desc>
<defs>
<linearGradient id="gradA2B" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#62d3a4" />
<stop offset="50%" stop-color="#62d3a4" />
<stop offset="51%" stop-color="#ffd166" />
<stop offset="100%" stop-color="#ffd166" />
</linearGradient>
</defs>
<rect id="frameRect" x="16.5" y="16.5" width="1167" height="767" fill="none" stroke="rgba(255,255,255,.2)" stroke-width="1.5" stroke-dasharray="6 10"/>
<g id="art">
<g id="strokes"></g>
<g id="bits"></g>
<g id="sparks" stroke="#e7eaf0" stroke-width="8" stroke-linecap="round"></g>
<g id="markersG"></g>
<path id="ghost" class="ghost" d=""/>
<g id="bezierUI"></g>
</g>
</svg>
</main>
</div>
<footer>
<span>Neu: richtiger BezierPen wie in Illustrator/Inkscape (Anker setzen & ziehen).</span>
</footer>
<script>
(function(){
const stage = document.getElementById('stage');
const strokesG= document.getElementById('strokes');
const bitsG = document.getElementById('bits');
const sparksG = document.getElementById('sparks');
const markersG= document.getElementById('markersG');
const ghost = document.getElementById('ghost');
const bezierUI= document.getElementById('bezierUI');
const frameRect = document.getElementById('frameRect');
const strokeList = document.getElementById('strokeList');
// Controls
const sparkMode = document.getElementById('sparkMode');
const bitMode = document.getElementById('bitMode');
const markersCb = document.getElementById('markers');
const frameOnCb = document.getElementById('frameOn');
const strokeInp = document.getElementById('stroke');
const samplerSel= document.getElementById('sampler');
const colorAInp = document.getElementById('colorA');
const colorBInp = document.getElementById('colorB');
const colorSInp = document.getElementById('colorSpark');
const penModeCb = document.getElementById('penMode');
const finishPenBtn = document.getElementById('finishPen');
const editBezierCb = document.getElementById('editBezier');
const linkHandlesCb= document.getElementById('linkHandles');
const toBezierBtn = document.getElementById('toBezier');
const splitBtn = document.getElementById('splitHandles');
const delAnchorBtn = document.getElementById('delAnchor');
const undoBtn = document.getElementById('undo');
const clearBtn = document.getElementById('clear');
const copySVGBtn = document.getElementById('copySVG');
const dlSVGBtn = document.getElementById('downloadSVG');
const dlPNGBtn = document.getElementById('downloadPNG');
const copyJSONBtn = document.getElementById('copyJSON');
const dlJSONBtn = document.getElementById('downloadJSON');
const replayBtn = document.getElementById('replay');
let mode = 'A';
document.querySelectorAll('input[name="mode"]').forEach(r=>{
r.addEventListener('change', ()=>{ mode = document.querySelector('input[name="mode"]:checked').value; });
});
// State
let drawing = false;
let curStroke = null;
const strokes = [];
let t0 = null;
let selectedStroke = null;
let selectedAnchor = {stroke:null, idx:-1};
let dragging = null; // edit-drag
// Pen state
let penActive = false;
let penStroke = null; // {isBezier:true, anchors:[], pathEl,...}
let penDragging = false;
let placingFirst = false;
let placingNext = false;
let penStartPt = null;
function now(){ return performance.now(); }
function svgPoint(evt){
const pt = stage.createSVGPoint();
pt.x = evt.clientX; pt.y = evt.clientY;
const p = pt.matrixTransform(stage.getScreenCTM().inverse());
return {x: Math.round(p.x), y: Math.round(p.y)};
}
function createPathEl(){
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
p.setAttribute('class','sline');
p.setAttribute('stroke-width', strokeInp.value);
p.addEventListener('pointerdown', (e)=>{
if(editBezierCb.checked && !penActive){ e.stopPropagation(); const s = strokes.find(st=>st.pathEl===p); if(s){ selectStroke(s); drawBezierUI(); } }
});
strokesG.appendChild(p);
return p;
}
function applyStrokeStyle(s){
if(s.mode==='A'){ s.pathEl.setAttribute('stroke', colorAInp.value); }
else if(s.mode==='B'){ s.pathEl.setAttribute('stroke', colorBInp.value); }
else { s.pathEl.setAttribute('stroke','url(#gradA2B)'); }
}
function pathFromAnchors(A){
if(!A || A.length<2) return '';
let d = `M ${A[0].x} ${A[0].y}`;
for(let i=0;i<A.length-1;i++){
const a=A[i], b=A[i+1];
d += ` C ${a.h2.x} ${a.h2.y}, ${b.h1.x} ${b.h1.y}, ${b.x} ${b.y}`;
}
return d;
}
function listRefresh(){
strokeList.innerHTML = '';
strokes.forEach((s,i)=>{
const li = document.createElement('li'); li.className='strokeItem';
const sw = document.createElement('input'); sw.type='range'; sw.min=4; sw.max=28; sw.step=1; sw.value=s.width; sw.title='Breite';
sw.addEventListener('input',()=>{ s.width=+sw.value; s.pathEl.setAttribute('stroke-width', s.width); });
const sel = document.createElement('select');
['A','B','GRAD'].forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; if(s.mode===v) o.selected=true; sel.appendChild(o); });
sel.addEventListener('change',()=>{ s.mode=sel.value; applyStrokeStyle(s); });
const edit = document.createElement('button'); edit.className='btn'; edit.textContent='Bearbeiten';
edit.addEventListener('click',()=>{ selectStroke(s); editBezierCb.checked = true; drawBezierUI(); });
const del = document.createElement('button'); del.className='btn'; del.textContent='✕';
del.addEventListener('click',()=>{ removeStroke(i); });
const meta = document.createElement('span'); meta.className='muted';
meta.textContent = `#${s.id}${s.isBezier? 'Bezier' : (s.points?.length||0)+' pts'}`;
li.append('Stroke', sel, sw, edit, del, meta);
strokeList.appendChild(li);
});
}
function removeStroke(idx){
const s = strokes[idx]; if(!s) return;
s.pathEl.remove(); if(s.startMarker) s.startMarker.remove(); if(s.stopMarker) s.stopMarker.remove();
if(selectedStroke===s){ selectedStroke=null; clearBezierUI(); }
strokes.splice(idx,1);
listRefresh();
}
function selectStroke(s){
selectedStroke = s;
strokes.forEach(st=> st.pathEl.classList.toggle('selected', st===s));
}
// -------- Freehand --------
function toPathD(s){
if(s.isBezier) return pathFromAnchors(s.anchors);
const pts = s.points;
const smp = s.sampler || samplerSel.value;
if(pts.length===0) return '';
if(smp==='raw'){
let d = `M ${pts[0].x} ${pts[0].y}`;
for(let i=1;i<pts.length;i++){ d+=` L ${pts[i].x} ${pts[i].y}`; } return d;
} else if(smp==='cubic'){
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); const P=(i)=>pts[ clamp(i,0,pts.length-1) ];
let d=`M ${pts[0].x} ${pts[0].y}`;
for(let i=0;i<pts.length-1;i++){
const p0=P(i-1), p1=P(i), p2=P(i+1), p3=P(i+2);
const c1x = p1.x + (p2.x - p0.x)/6, c1y = p1.y + (p2.y - p0.y)/6;
const c2x = p2.x - (p3.x - p1.x)/6, c2y = p2.y - (p3.y - p1.y)/6;
d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
} return d;
} else {
let d = `M ${pts[0].x} ${pts[0].y}`;
for(let i=1;i<pts.length-1;i++){ const p0=pts[i], p1=pts[i+1]; const mx=(p0.x+p1.x)/2, my=(p0.y+p1.y)/2; d += ` Q ${p0.x} ${p0.y} ${mx} ${my}`; }
const last=pts[pts.length-1]; d += ` L ${last.x} ${last.y}`; return d;
}
}
function updatePath(s){ s.pathEl.setAttribute('d', toPathD(s)); s.pathEl.setAttribute('stroke-width', s.width); }
function polyLen(pts){ let L=0; for(let i=1;i<pts.length;i++){ const dx=pts[i].x-pts[i-1].x, dy=pts[i].y-pts[i-1].y; L += Math.hypot(dx,dy);} return L; }
function startStroke(e){
if(penActive || editBezierCb.checked){ return; }
if(bitMode.checked){ placeBit(e); return; }
if(sparkMode.checked || e.shiftKey){ placeSpark(e); return; }
const t = now();
const pt = svgPoint(e);
const pathEl = createPathEl();
curStroke = { id: Date.now()%1e7, mode, colorA: colorAInp.value, colorB: colorBInp.value, width:+strokeInp.value, points:[pt], pathEl, start:t, duration:0, length:0, sampler:samplerSel.value, isBezier:false };
applyStrokeStyle(curStroke); updatePath(curStroke);
if(markersCb.checked){ curStroke.startMarker = mark(pt.x, pt.y, 'start'); }
drawing = true;
}
stage.addEventListener('pointermove', (e)=>{
if(!drawing || !curStroke) return;
const p = svgPoint(e);
const last = curStroke.points[curStroke.points.length-1];
if(!last || Math.hypot(p.x-last.x, p.y-last.y) > 2){
curStroke.points.push(p);
updatePath(curStroke);
}
});
window.addEventListener('pointerup', ()=>{
if(drawing && curStroke){
const pts = curStroke.points;
curStroke.duration = 0;
curStroke.length = polyLen(pts);
if(markersCb.checked){ const last=pts[pts.length-1]; curStroke.stopMarker = mark(last.x,last.y,'stop'); }
strokes.push(curStroke); curStroke = null; listRefresh();
}
drawing=false;
});
function mark(x,y,type){
const r = 7; const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('cx',x); c.setAttribute('cy',y); c.setAttribute('r',r);
c.setAttribute('class', type==='start'?'markerStart':'markerStop');
markersG.appendChild(c); return c;
}
function placeSpark(e){
const p = svgPoint(e);
const dx = 10, dy = -10;
const l = document.createElementNS('http://www.w3.org/2000/svg','line');
l.setAttribute('x1', p.x); l.setAttribute('y1', p.y);
l.setAttribute('x2', p.x+dx); l.setAttribute('y2', p.y+dy);
l.setAttribute('stroke', colorSInp.value);
l.setAttribute('stroke-width', Math.max(2, Math.round(parseInt(strokeInp.value,10)*0.66)));
l.setAttribute('stroke-linecap','round');
sparksG.appendChild(l);
}
function placeBit(e){
const p = svgPoint(e);
const h = Math.max(18, parseInt(strokeInp.value,10)*1.2);
const w = Math.max(6, parseInt(strokeInp.value,10)*0.8);
const line1 = document.createElementNS('http://www.w3.org/2000/svg','line');
line1.setAttribute('x1', p.x); line1.setAttribute('y1', p.y-h/2);
line1.setAttribute('x2', p.x); line1.setAttribute('y2', p.y+h/2);
line1.setAttribute('stroke', colorBInp.value);
line1.setAttribute('stroke-width', w);
line1.setAttribute('stroke-linecap', 'round');
bitsG.appendChild(line1);
}
// Editor helpers
function clearBezierUI(){ bezierUI.innerHTML=''; selectedAnchor={stroke:null, idx:-1}; delAnchorBtn.disabled = true; }
function drawBezierUI(){
clearBezierUI();
const s = selectedStroke;
if(!editBezierCb.checked || !s || !s.isBezier) return;
s.anchors.forEach((a,idx)=>{
bezierUI.append(line(a.x, a.y, a.h1.x, a.h1.y, 'handleLine'));
bezierUI.append(line(a.x, a.y, a.h2.x, a.h2.y, 'handleLine'));
const h1 = circle(a.h1.x, a.h1.y, 5, 'handle', (e)=>startDrag('h1', s, idx, e));
const h2 = circle(a.h2.x, a.h2.y, 5, 'handle', (e)=>startDrag('h2', s, idx, e));
bezierUI.append(h1, h2);
const an = circle(a.x, a.y, 6.5, 'anchor', (e)=>{ startDrag('anchor', s, idx, e) });
an.addEventListener('dblclick', (e)=>{ e.stopPropagation(); insertAnchorAt(s, idx); });
an.addEventListener('pointerdown', ()=>{ selectedAnchor={stroke:s,idx}; delAnchorBtn.disabled=false; });
bezierUI.append(an);
});
}
function line(x1,y1,x2,y2,cls){ const l = document.createElementNS('http://www.w3.org/2000/svg','line'); l.setAttribute('x1',x1); l.setAttribute('y1',y1); l.setAttribute('x2',x2); l.setAttribute('y2',y2); l.setAttribute('class',cls); return l; }
function circle(x,y,r,cls,onDown){
const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('cx',x); c.setAttribute('cy',y); c.setAttribute('r',r);
c.setAttribute('class',cls);
c.style.touchAction = 'none';
if(onDown) c.addEventListener('pointerdown', (e)=>{ e.stopPropagation(); onDown(e); });
return c;
}
function startDrag(type, s, idx, ev){
ev.preventDefault();
const a = s.anchors[idx];
const p0 = svgPoint(ev);
const dragging = {type, stroke:s, idx, ox:p0.x, oy:p0.y, pid:ev.pointerId};
try { stage.setPointerCapture(ev.pointerId); } catch(e){}
const move = (e)=>{
const pos = svgPoint(e);
const dx = pos.x - dragging.ox, dy = pos.y - dragging.oy;
dragging.ox = pos.x; dragging.oy = pos.y;
if(type==='anchor'){
a.x+=dx; a.y+=dy; a.h1.x+=dx; a.h1.y+=dy; a.h2.x+=dx; a.h2.y+=dy;
}else if(type==='h1'){
a.h1.x+=dx; a.h1.y+=dy;
if(linkHandlesCb.checked && !a.split){
a.h2.x = a.x - (a.h1.x - a.x);
a.h2.y = a.y - (a.h1.y - a.y);
}
}else if(type==='h2'){
a.h2.x+=dx; a.h2.y+=dy;
if(linkHandlesCb.checked && !a.split){
a.h1.x = a.x - (a.h2.x - a.x);
a.h1.y = a.y - (a.h2.y - a.y);
}
}
s.pathEl.setAttribute('d', pathFromAnchors(s.anchors));
drawBezierUI();
};
const up = ()=>{
try { stage.releasePointerCapture(ev.pointerId); } catch(_) {}
window.removeEventListener('pointermove', move);
};
window.addEventListener('pointermove', move, {passive:true});
window.addEventListener('pointerup', up, {once:true});
}
function insertAnchorAt(s, idx){
const A = s.anchors; if(idx>=A.length-1) return;
const a=A[idx], b=A[idx+1];
const mid = {x:(a.x+b.x)/2, y:(a.y+b.y)/2};
const dir = {x:(b.x-a.x), y:(b.y-a.y)};
const hLen = {x:dir.x*0.33, y:dir.y*0.33};
const newA = { x: mid.x, y: mid.y,
h1:{x: mid.x - hLen.x*0.5, y: mid.y - hLen.y*0.5},
h2:{x: mid.x + hLen.x*0.5, y: mid.y + hLen.y*0.5},
split:false };
A.splice(idx+1,0,newA);
s.pathEl.setAttribute('d', pathFromAnchors(A));
drawBezierUI();
}
delAnchorBtn.addEventListener('click', ()=>{
const s=selectedStroke, idx=selectedAnchor.idx;
if(!s || idx<0) return;
if(s.anchors.length<=2) return;
s.anchors.splice(idx,1);
selectedAnchor={stroke:null,idx:-1};
delAnchorBtn.disabled=true;
s.pathEl.setAttribute('d', pathFromAnchors(s.anchors));
drawBezierUI();
});
toBezierBtn.addEventListener('click', ()=>{
if(selectedStroke && !selectedStroke.isBezier){
const s=selectedStroke; const pts=s.points;
if(!pts || pts.length<2) return;
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); const P=(i)=>pts[ clamp(i,0,pts.length-1) ];
const A=[];
for(let i=0;i<pts.length-1;i++){
const p0=P(i-1), p1=P(i), p2=P(i+1), p3=P(i+2);
const c1 = {x: p1.x + (p2.x - p0.x)/6, y: p1.y + (p2.y - p0.y)/6};
const c2 = {x: p2.x - (p3.x - p1.x)/6, y: p2.y - (p3.y - p1.y)/6};
if(i===0){ A.push({x:p1.x,y:p1.y,h1:{x:p1.x,y:p1.y},h2:{x:c1.x,y:c1.y},split:false}); }
else { A[A.length-1].h2 = {x:c1.x,y:c1.y}; }
A.push({x:p2.x,y:p2.y,h1:{x:c2.x,y:c2.y},h2:{x:p2.x,y:p2.y},split:false});
}
s.anchors=A; s.isBezier=true;
s.pathEl.setAttribute('d', pathFromAnchors(A));
listRefresh(); drawBezierUI();
}
});
splitBtn.addEventListener('click', ()=>{
if(selectedStroke && selectedAnchor.idx>=0){ const a=selectedStroke.anchors[selectedAnchor.idx]; a.split=!a.split; drawBezierUI(); }
});
window.addEventListener('keydown', (e)=>{
if((e.key==='Delete'||e.key==='Backspace') && penActive){ penBackspace(); }
if((e.key==='Delete'||e.key==='Backspace') && editBezierCb.checked){ delAnchorBtn.click(); }
if(e.key==='l' || e.key==='L'){ linkHandlesCb.checked = !linkHandlesCb.checked; }
if(e.key==='Enter' && penActive){ finishPen(); }
});
// Pen tool
penModeCb.addEventListener('change', ()=>{
const on = penModeCb.checked;
if(on){ editBezierCb.checked=false; penActive=true; ghost.setAttribute('d',''); deselectAll(); }
else { penActive=false; cancelPen(); }
finishPenBtn.disabled = !on;
});
finishPenBtn.addEventListener('click', finishPen);
function startPen(e){
const p = svgPoint(e);
if(!penStroke){
penStroke = { id: Date.now()%1e7, mode, width:+strokeInp.value, colorA: colorAInp.value, colorB: colorBInp.value,
isBezier:true, anchors:[], pathEl:createPathEl() };
applyStrokeStyle(penStroke);
const a = {x:p.x,y:p.y,h1:{x:p.x,y:p.y},h2:{x:p.x,y:p.y},split:false};
penStroke.anchors.push(a);
placingFirst=true; penDragging=true; penStartPt=p;
try{ stage.setPointerCapture(e.pointerId); }catch(_){}}
else {
const a = {x:p.x,y:p.y,h1:{x:p.x,y:p.y},h2:{x:p.x,y:p.y},split:false};
penStroke.anchors.push(a);
placingNext=true; penDragging=true;
try{ stage.setPointerCapture(e.pointerId); }catch(_){}}
}
function movePen(e){
if(!penStroke || !penDragging) return;
const p = svgPoint(e);
const A = penStroke.anchors;
if(placingFirst){
const start=A[0];
let v = {x: p.x - start.x, y: p.y - start.y};
if(e.shiftKey){
const ang=Math.atan2(v.y,v.x), step=Math.PI/12; const a=Math.round(ang/step)*step; const L=Math.hypot(v.x,v.y); v={x:Math.cos(a)*L,y:Math.sin(a)*L};
}
start.h2 = {x:start.x+v.x, y:start.y+v.y};
} else if(placingNext){
const last=A[A.length-1], prev=A[A.length-2];
let v = {x: p.x - last.x, y: p.y - last.y};
if(e.shiftKey){
const ang=Math.atan2(v.y,v.x), step=Math.PI/12; const a=Math.round(ang/step)*step; const L=Math.hypot(v.x,v.y); v={x:Math.cos(a)*L,y:Math.sin(a)*L};
}
last.h1 = {x:last.x+v.x, y:last.y+v.y};
if(e.altKey){ last.split=true; last.h2={x:last.x,y:last.y}; }
else { last.split=false; last.h2 = {x:last.x - v.x, y:last.y - v.y}; }
// previous outgoing 1/3 along segment
const seg = {x:last.x - prev.x, y:last.y - prev.y};
const L = Math.hypot(seg.x,seg.y); if(L>0){
const n={x:seg.x/L,y:seg.y/L}; const k=L/3;
prev.h2 = {x: prev.x + n.x*k, y: prev.y + n.y*k};
}
}
ghost.setAttribute('d', pathFromAnchors(A));
penStroke.pathEl.setAttribute('d', pathFromAnchors(A));
applyStrokeStyle(penStroke);
penStroke.pathEl.setAttribute('stroke-width', penStroke.width);
}
function endPen(e){ penDragging=false; placingFirst=false; placingNext=false; ghost.setAttribute('d',''); }
function finishPen(){ if(!penStroke) return; strokes.push(penStroke); listRefresh(); penStroke=null; penStartPt=null; ghost.setAttribute('d',''); }
function cancelPen(){ if(penStroke){ penStroke.pathEl.remove(); } penStroke=null; penStartPt=null; ghost.setAttribute('d',''); }
function penBackspace(){ if(!penStroke) return; const A=penStroke.anchors; if(A.length<=1){ cancelPen(); } else { A.pop(); penStroke.pathEl.setAttribute('d', pathFromAnchors(A)); } }
// Stage routing
stage.addEventListener('pointerdown', (e)=>{ if(penModeCb.checked) startPen(e); else startStroke(e); });
stage.addEventListener('pointermove', (e)=>{ if(penModeCb.checked) movePen(e); });
window.addEventListener('pointerup', (e)=>{ if(penModeCb.checked) endPen(e); });
// Misc
function exportSVGString(){
frameRect.style.display = frameOnCb.checked ? 'block' : 'none';
const clone = stage.cloneNode(true);
const ui = clone.querySelector('#bezierUI'); if(ui) ui.remove();
const gh = clone.querySelector('#ghost'); if(gh) gh.remove();
const s = new XMLSerializer().serializeToString(clone);
return `<?xml version="1.0" encoding="UTF-8"?>\n${s}`;
}
function motionJSON(){
const items = strokes.map(s=>{
const o = { id:s.id, mode:s.mode, colorA:s.colorA, colorB:s.colorB, width:s.width, sampler:s.sampler || 'cubic' };
if(s.isBezier){
o.isBezier = true;
o.anchors = s.anchors.map(a=>({x:a.x,y:a.y,h1:{x:a.h1.x,y:a.h1.y},h2:{x:a.h2.x,y:a.h2.y},split:!!a.split}));
}else{
o.points = (s.points||[]).map(p=>({x:p.x,y:p.y}));
}
return o;
});
return JSON.stringify({strokes:items}, null, 2);
}
document.getElementById('copySVG').addEventListener('click', async ()=>{ const svg=exportSVGString(); await navigator.clipboard.writeText(svg); alert('SVG kopiert'); });
document.getElementById('downloadSVG').addEventListener('click', ()=>{ const svg=exportSVGString(); const blob=new Blob([svg],{type:'image/svg+xml'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='omi-omega-oneliner.svg'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1500); });
document.getElementById('downloadPNG').addEventListener('click', ()=>{ const svg=exportSVGString(); svgToPng(svg, 'omi-omega-oneliner.png'); });
document.getElementById('copyJSON').addEventListener('click', async ()=>{ const js=motionJSON(); await navigator.clipboard.writeText(js); alert('MotionJSON kopiert'); });
document.getElementById('downloadJSON').addEventListener('click', ()=>{ const js=motionJSON(); const blob=new Blob([js],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='omi-omega_motion.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1500); });
document.getElementById('replay').addEventListener('click', ()=>{
if(!strokes.length) return;
strokes.forEach(s=> s.pathEl.setAttribute('d',''));
const total=1500; const t0=performance.now();
const step=()=>{
const t=performance.now()-t0;
strokes.forEach(s=>{
if(!s.isBezier) return;
const A=s.anchors, n=A.length-1; const k=Math.max(1, Math.floor((t/total)*n));
let d=`M ${A[0].x} ${A[0].y}`;
for(let i=0;i<k && i<n;i++){ const a=A[i], b=A[i+1]; d+=` C ${a.h2.x} ${a.h2.y}, ${b.h1.x} ${b.h1.y}, ${b.x} ${b.y}`; }
s.pathEl.setAttribute('d', d); applyStrokeStyle(s); s.pathEl.setAttribute('stroke-width', s.width);
});
if(t<total) requestAnimationFrame(step); else { strokes.forEach(updatePath); }
};
requestAnimationFrame(step);
});
function svgToPng(svgString, filename){
const img = new Image();
const svgBlob = new Blob([svgString], {type:'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(svgBlob);
img.onload = function(){
const canvas = document.createElement('canvas');
const vb = stage.viewBox.baseVal;
canvas.width = vb.width; canvas.height = vb.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob)=>{
const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=filename; a.click();
});
URL.revokeObjectURL(url);
};
img.src = url;
}
// Basic toggles
frameRect.style.display = document.getElementById('frameOn').checked ? 'block' : 'none';
document.getElementById('frameOn').addEventListener('change', (e)=>{ frameRect.style.display = e.target.checked ? 'block' : 'none'; });
document.getElementById('undo').addEventListener('click', ()=>{ if(strokes.length){ removeStroke(strokes.length-1); }});
document.getElementById('clear').addEventListener('click', ()=>{ strokes.length=0; strokesG.innerHTML=''; bitsG.innerHTML=''; sparksG.innerHTML=''; markersG.innerHTML=''; ghost.setAttribute('d',''); clearBezierUI(); listRefresh(); });
function deselectAll(){ selectedStroke=null; strokes.forEach(st=> st.pathEl.classList.remove('selected')); clearBezierUI(); }
})();</script>
</body>
</html>