Paint: vector background cels on the kept frames

A sketch, and labelled as one. It exists to test whether the aesthetic holds
when a human draws the background rather than the tracker deriving it, and it
is meant to be replaced by a real paint surface with onion skin and undo. Kept
to one dependency-free module so throwing it away is a delete, not surgery.

Cels are drawn on the frames that get their own drawing and hold until the
next one - the same rule the plate follows, and literally the same lookup, so
the two cannot disagree about what is on screen. Editing always targets the
cel you can see, so you can scrub anywhere and keep drawing.

Pen places vertices and closes on the first one. Edit drags vertices or whole
shapes, shift-click inserts, alt-click removes. Layers stack front-at-top with
per-layer colour, show/hide and reorder. Drag a strip thumbnail onto the canvas
to seed this cel from that one, every layer, as a deep copy - sharing the point
arrays would make two cels silently edit each other.

Two rules are enforced rather than left to discipline:

Colours are PALETTE INDICES, never RGB. Sampling colour from the source is the
one move docs/design.md calls irrecoverable, and a paint tool is exactly where
that discipline would leak, so the picker cannot express a colour outside the
ramp.

Vertices snap to the 320x200 grid. On a hard-edged indexed rasteriser a shape
nudged by 0.4px moves an edge by a whole pixel or not at all depending on where
it happens to land, so sub-pixel vertices shimmer instead of holding still.

Drawings autosave to localStorage per take name. They are the only thing here a
person made by hand; everything else regenerates. Not in the .take export yet.

Also adds serve.py, a no-store dev server. python3 -m http.server sends
Last-Modified and browsers cache ES modules on it hard enough that a reload
serves a stale app.js against a fresh index.html: the new knobs appear, nothing
wires them, no error fires, and it reads as "your feature does not work". That
cost real time this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-24 19:08:07 -04:00
parent 03df81fa0b
commit 9d516a03ac
5 changed files with 558 additions and 3 deletions

345
js/paint.js Normal file
View file

@ -0,0 +1,345 @@
// 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);
}
}
}