// Vector cel painting: flat polygons on the frames that get their own drawing. // // THIS IS A SKETCH. It exists to test whether the aesthetic holds when a human // draws the background rather than the tracker deriving it, and it is expected // to be replaced by a real paint surface with onion skin, undo and a proper // tool model. Deliberately kept to one module with no dependencies on the rest // of the pipeline so that throwing it away is a delete rather than a surgery. // // The data model is the part worth keeping: // // cel = Layer[] index 0 is the BACK of the stack, last is the front // Layer = { name, color, pts, hidden } // // `color` is a PALETTE INDEX, never an RGB value - the one rule from // docs/design.md that a paint tool could most easily break. You cannot pick a // colour here that is not already in the take's ramp. // // Points are integers in 320x200 raster space. Snapping is not a convenience: // sub-pixel vertices on a hard-edged indexed rasteriser move an edge by a whole // pixel or not at all depending on where the polygon happens to land, so a // shape nudged by 0.4px shimmers instead of holding still. export const newCel = () => []; export const cloneCel = (cel) => (cel || []).map((l) => ({ name: l.name, color: l.color, hidden: !!l.hidden, pts: l.pts.map((p) => ({ x: p.x, y: p.y })), })); // Fill every visible layer back to front. Same rasteriser the derived parts // use, so a painted shape and a traced one cannot look different. export function drawCel(raster, cel, idxOf) { if (!cel) return; for (const l of cel) { if (l.hidden || l.pts.length < 3) continue; raster.fillPoly(l.pts, idxOf(l.color)); } } const pointInPoly = (pts, x, y) => { let inside = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { if ((pts[i].y > y) !== (pts[j].y > y) && x < ((pts[j].x - pts[i].x) * (y - pts[i].y)) / (pts[j].y - pts[i].y) + pts[i].x) inside = !inside; } return inside; }; // Distance from p to segment ab, and where along it the foot falls. function segDist(p, a, b) { const vx = b.x - a.x, vy = b.y - a.y; const len = vx * vx + vy * vy; const t = len ? Math.max(0, Math.min(1, ((p.x - a.x) * vx + (p.y - a.y) * vy) / len)) : 0; const cx = a.x + t * vx, cy = a.y + t * vy; return { d: Math.hypot(p.x - cx, p.y - cy), t }; } export class PaintUI { constructor(opts) { this.canvas = opts.canvas; // where the cel is drawn and edited this.list = opts.list; // layer stack DOM host this.info = opts.info; // status line this.zoom = opts.zoom || 3; this.RW = opts.RW; this.RH = opts.RH; this.palette = opts.palette; // [{name,hex}], index is the colour this.getCel = opts.getCel; // () => Layer[] for the frame being edited this.setCel = opts.setCel; // (Layer[]) => void this.celAt = opts.celAt; // (frame) => Layer[] for drag-to-seed this.backing = opts.backing; // (canvas, zoom) => void, paints what is under this.onChange = opts.onChange; // commit + redraw everything else this.tool = 'pen'; this.color = 1; this.sel = -1; // selected layer index this.draft = null; // in-progress pen path this.drag = null; this.bind(); } /* ---- geometry ---- */ at(ev) { const r = this.canvas.getBoundingClientRect(); // Snap to the raster grid. See the note at the top of the file. return { x: Math.round(((ev.clientX - r.left) / r.width) * this.RW), y: Math.round(((ev.clientY - r.top) / r.height) * this.RH), }; } hit(p) { const cel = this.getCel(); const near = 3; // Vertices of the SELECTED layer win over everything, so a vertex sitting // under another shape stays grabbable instead of selecting the shape on top. if (this.sel >= 0 && cel[this.sel]) { const pts = cel[this.sel].pts; for (let i = 0; i < pts.length; i++) { if (Math.hypot(pts[i].x - p.x, pts[i].y - p.y) <= near) return { layer: this.sel, vertex: i }; } for (let i = 0; i < pts.length; i++) { const s = segDist(p, pts[i], pts[(i + 1) % pts.length]); if (s.d <= near / 2) return { layer: this.sel, edge: i }; } } // Then shapes, front to back, so clicking picks what you can see. for (let i = cel.length - 1; i >= 0; i--) { if (!cel[i].hidden && cel[i].pts.length >= 3 && pointInPoly(cel[i].pts, p.x, p.y)) { return { layer: i }; } } return null; } /* ---- editing ---- */ commit() { this.onChange(); this.render(); } addLayer(pts) { const cel = this.getCel().slice(); cel.push({ name: `shape ${cel.length + 1}`, color: this.color, hidden: false, pts }); this.setCel(cel); this.sel = cel.length - 1; this.commit(); } mutate(fn) { const cel = cloneCel(this.getCel()); fn(cel); this.setCel(cel); this.commit(); } move(from, to) { if (to < 0 || to >= this.getCel().length) return; this.mutate((cel) => { cel.splice(to, 0, cel.splice(from, 1)[0]); }); this.sel = to; this.render(); } bind() { const c = this.canvas; c.addEventListener('mousedown', (ev) => { ev.preventDefault(); const p = this.at(ev); if (this.tool === 'pen') { if (!this.draft) { this.draft = [p]; this.render(); return; } const first = this.draft[0]; // Closing on the first point is how you finish; three points minimum, // because a two-point "polygon" fills nothing and looks like a bug. if (this.draft.length >= 3 && Math.hypot(first.x - p.x, first.y - p.y) <= 3) { const pts = this.draft; this.draft = null; this.addLayer(pts); return; } this.draft.push(p); this.render(); return; } const h = this.hit(p); if (!h) { this.sel = -1; this.render(); return; } this.sel = h.layer; if (h.vertex !== undefined) { if (ev.altKey) { // Never below a triangle. if (this.getCel()[h.layer].pts.length > 3) { this.mutate((cel) => cel[h.layer].pts.splice(h.vertex, 1)); } return; } this.drag = { kind: 'vertex', layer: h.layer, vertex: h.vertex }; this.render(); return; } if (h.edge !== undefined && ev.shiftKey) { this.mutate((cel) => cel[h.layer].pts.splice(h.edge + 1, 0, { x: p.x, y: p.y })); this.drag = { kind: 'vertex', layer: h.layer, vertex: h.edge + 1 }; return; } this.drag = { kind: 'shape', layer: h.layer, from: p }; this.render(); }); window.addEventListener('mousemove', (ev) => { if (!this.drag) return; const p = this.at(ev); const d = this.drag; const cel = this.getCel(); if (d.kind === 'vertex') { const v = cel[d.layer].pts[d.vertex]; if (v.x === p.x && v.y === p.y) return; v.x = p.x; v.y = p.y; } else { const dx = p.x - d.from.x, dy = p.y - d.from.y; if (!dx && !dy) return; for (const q of cel[d.layer].pts) { q.x += dx; q.y += dy; } d.from = p; } this.render(); }); window.addEventListener('mouseup', () => { if (!this.drag) return; this.drag = null; this.commit(); }); c.addEventListener('dblclick', (ev) => { ev.preventDefault(); if (this.tool === 'pen' && this.draft && this.draft.length >= 3) { const pts = this.draft; this.draft = null; this.addLayer(pts); } }); // Seed from another frame: drag a strip thumbnail onto the canvas and take // a copy of whatever that frame is showing, every layer. Copy, never // reference - two cels sharing a layer object would edit each other and the // reason would be invisible. c.addEventListener('dragover', (ev) => { ev.preventDefault(); c.classList.add('drop'); }); c.addEventListener('dragleave', () => c.classList.remove('drop')); c.addEventListener('drop', (ev) => { ev.preventDefault(); c.classList.remove('drop'); const f = +ev.dataTransfer.getData('text/plain'); if (!Number.isFinite(f)) return; const src = this.celAt(f); if (!src || !src.length) { this.say(`f${f} has nothing to copy`); return; } this.setCel(cloneCel(src)); this.sel = -1; this.commit(); this.say(`seeded from f${f} — ${src.length} layers`); }); window.addEventListener('keydown', (ev) => { if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'SELECT') return; if (!this.focused) return; if (ev.key === 'Escape' && this.draft) { this.draft = null; this.render(); ev.preventDefault(); } else if (ev.key === 'Enter' && this.draft && this.draft.length >= 3) { const pts = this.draft; this.draft = null; this.addLayer(pts); ev.preventDefault(); } else if (ev.key === 'Backspace' && this.draft) { this.draft.pop(); if (!this.draft.length) this.draft = null; this.render(); ev.preventDefault(); } else if ((ev.key === 'Delete' || ev.key === 'Backspace') && this.sel >= 0) { this.mutate((cel) => cel.splice(this.sel, 1)); this.sel = -1; ev.preventDefault(); } }); // The paint canvas takes the keyboard only while the pointer is over it, so // the main window's frame stepping keeps working everywhere else. c.addEventListener('mouseenter', () => { this.focused = true; }); c.addEventListener('mouseleave', () => { this.focused = false; }); } say(msg) { if (this.info) this.info.textContent = msg; } /* ---- drawing ---- */ render() { const z = this.zoom, c = this.canvas; this.backing(c, z); // the composited frame, cels included const g = c.getContext('2d'); const cel = this.getCel(); g.lineWidth = 1; cel.forEach((l, i) => { if (l.pts.length < 2) return; const on = i === this.sel; // Unselected outlines stay faint: they are there so you can find a shape // to click, not so you can read them. g.strokeStyle = l.hidden ? '#f8717166' : (on ? '#fbbf24' : '#ffffff33'); g.beginPath(); l.pts.forEach((p, k) => (k ? g.lineTo(p.x * z, p.y * z) : g.moveTo(p.x * z, p.y * z))); g.closePath(); g.stroke(); if (!on) return; g.fillStyle = '#fbbf24'; for (const p of l.pts) g.fillRect(p.x * z - 2, p.y * z - 2, 5, 5); }); if (this.draft) { g.strokeStyle = '#4ade80'; g.beginPath(); this.draft.forEach((p, k) => (k ? g.lineTo(p.x * z, p.y * z) : g.moveTo(p.x * z, p.y * z))); g.stroke(); g.fillStyle = '#4ade80'; for (const p of this.draft) g.fillRect(p.x * z - 2, p.y * z - 2, 5, 5); // The closing target, so "click here to finish" is visible rather than // something you have to know. if (this.draft.length >= 3) { g.strokeStyle = '#4ade80'; g.strokeRect(this.draft[0].x * z - 4, this.draft[0].y * z - 4, 9, 9); } } this.renderList(); } renderList() { const cel = this.getCel(); this.list.innerHTML = ''; // Top of the list is the FRONT of the stack, the way a layers panel reads. for (let i = cel.length - 1; i >= 0; i--) { const l = cel[i]; const row = document.createElement('div'); row.className = 'lay' + (i === this.sel ? ' sel' : ''); const sw = document.createElement('select'); this.palette.forEach((p, k) => { const o = document.createElement('option'); o.value = k; o.textContent = p.name; sw.append(o); }); sw.value = l.color; sw.onchange = () => this.mutate((cc) => { cc[i].color = +sw.value; }); const chip = document.createElement('i'); chip.style.background = this.palette[l.color] ? this.palette[l.color].hex : '#f0f'; const nm = document.createElement('b'); nm.textContent = l.name; nm.onclick = () => { this.sel = i; this.render(); }; const btn = (txt, title, fn) => { const b = document.createElement('button'); b.textContent = txt; b.title = title; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; }; row.append(chip, sw, nm, btn('↑', 'forward', () => this.move(i, i + 1)), btn('↓', 'back', () => this.move(i, i - 1)), btn(l.hidden ? '◻' : '◼', 'show/hide', () => this.mutate((cc) => { cc[i].hidden = !cc[i].hidden; })), btn('✕', 'delete', () => { this.mutate((cc) => cc.splice(i, 1)); this.sel = -1; })); row.onclick = () => { this.sel = i; this.render(); }; this.list.append(row); } if (!cel.length) { const e = document.createElement('div'); e.className = 'legend'; e.textContent = 'no layers — draw with the pen, or drag a frame here to copy its drawing'; this.list.append(e); } } }