346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
|
|
// 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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|