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:
parent
03df81fa0b
commit
9d516a03ac
5 changed files with 558 additions and 3 deletions
43
README.md
43
README.md
|
|
@ -17,10 +17,15 @@ modern conveniences belong in the workflow, not the output. See
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m http.server 8777 # from this directory
|
python3 serve.py # from this directory, then open 127.0.0.1:8777
|
||||||
# open http://127.0.0.1:8777
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `serve.py`, not `python3 -m http.server`. The latter sends `Last-Modified`
|
||||||
|
and browsers cache ES modules on it hard enough that a reload serves a stale
|
||||||
|
`js/app.js` against a fresh `index.html` — new knobs appear in the markup, nothing
|
||||||
|
wires them, no error is raised, and the symptom reads as "the feature does not
|
||||||
|
work". `serve.py` is the same server with `no-store`.
|
||||||
|
|
||||||
Static files and ES modules — no build step, no dependencies beyond MediaPipe's
|
Static files and ES modules — no build step, no dependencies beyond MediaPipe's
|
||||||
wasm, which is fetched from a CDN on first use.
|
wasm, which is fetched from a CDN on first use.
|
||||||
|
|
||||||
|
|
@ -267,6 +272,40 @@ egg by construction, and no landmark precision fixes that. Hence the photo.
|
||||||
**Save frame 4x** writes the current registered composite as a 1280×800 PNG to
|
**Save frame 4x** writes the current registered composite as a 1280×800 PNG to
|
||||||
draw on.
|
draw on.
|
||||||
|
|
||||||
|
## Paint — background cels
|
||||||
|
|
||||||
|
**A sketch.** It exists to test whether the aesthetic holds when a human draws
|
||||||
|
the background instead of the tracker deriving it, and it is meant to be
|
||||||
|
replaced by a real paint surface with onion skin and undo. It is one dependency-
|
||||||
|
free module, `js/paint.js`, so throwing it away is a delete rather than 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. You
|
||||||
|
can scrub anywhere and keep drawing on the cel you can see; the header says
|
||||||
|
which one you are editing and how far it holds.
|
||||||
|
|
||||||
|
- **pen** — click to place vertices, click the green box on the first one (or
|
||||||
|
<kbd>Enter</kbd> / double-click) to close.
|
||||||
|
- **edit** — click a shape to select, drag a vertex or the whole shape,
|
||||||
|
<kbd>Shift</kbd>-click an edge to insert a vertex, <kbd>Alt</kbd>-click one to
|
||||||
|
remove it, <kbd>Del</kbd> to delete the layer.
|
||||||
|
- **Layers** stack Photoshop-style, front at the top, with per-layer colour,
|
||||||
|
show/hide and reorder.
|
||||||
|
- **Drag a frame** from the strip onto the canvas to seed this cel from that
|
||||||
|
one, every layer, as a deep copy. **Copy previous** does the same for the
|
||||||
|
previous kept frame, which is the case you reach for constantly.
|
||||||
|
|
||||||
|
Two rules are enforced rather than left to discipline. Colours are **palette
|
||||||
|
indices**, so you cannot pick one that is not in the ramp — sampling colour from
|
||||||
|
the source is the one move `docs/design.md` says is irrecoverable. And vertices
|
||||||
|
**snap to the 320×200 grid**, because 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 lands, which shimmers instead of holding.
|
||||||
|
|
||||||
|
Drawings autosave to `localStorage` per take name. They are the only thing in
|
||||||
|
the tool a person made by hand; everything else regenerates. They are not in the
|
||||||
|
`.take` export yet.
|
||||||
|
|
||||||
## Two kinds of sparseness
|
## Two kinds of sparseness
|
||||||
|
|
||||||
Sparseness has two unrelated causes, and conflating them was the original design
|
Sparseness has two unrelated causes, and conflating them was the original design
|
||||||
|
|
|
||||||
43
index.html
43
index.html
|
|
@ -52,6 +52,19 @@
|
||||||
.sw { display:flex; align-items:center; gap:5px; color:var(--dim); font-size:11px; }
|
.sw { display:flex; align-items:center; gap:5px; color:var(--dim); font-size:11px; }
|
||||||
.sw input { width:26px; height:20px; padding:0; border:1px solid var(--line); background:none; }
|
.sw input { width:26px; height:20px; padding:0; border:1px solid var(--line); background:none; }
|
||||||
.legend { color:var(--dim); font-size:11px; margin-top:6px; }
|
.legend { color:var(--dim); font-size:11px; margin-top:6px; }
|
||||||
|
/* paint: a sketch, see js/paint.js */
|
||||||
|
#cv-paint { border:1px solid var(--line); cursor:crosshair; touch-action:none; }
|
||||||
|
#cv-paint.drop { border-color:var(--ok); }
|
||||||
|
#paintlayers { flex:0 0 280px; max-height:600px; overflow:auto; }
|
||||||
|
.lay { display:flex; align-items:center; gap:5px; padding:3px 4px; border-radius:3px;
|
||||||
|
border:1px solid transparent; cursor:pointer; }
|
||||||
|
.lay.sel { border-color:var(--accent); background:#1c2130; }
|
||||||
|
.lay i { width:12px; height:12px; border-radius:2px; border:1px solid #0006; flex:0 0 auto; }
|
||||||
|
.lay b { flex:1; font-weight:400; color:var(--dim); font-size:11px; }
|
||||||
|
.lay.sel b { color:var(--fg); }
|
||||||
|
.lay select { font:inherit; font-size:10px; background:#1c2130; color:var(--dim);
|
||||||
|
border:1px solid var(--line); border-radius:2px; padding:1px 2px; max-width:88px; }
|
||||||
|
.lay button { padding:0 5px; font-size:11px; line-height:18px; }
|
||||||
kbd { background:#1c2130; border:1px solid var(--line); border-radius:2px;
|
kbd { background:#1c2130; border:1px solid var(--line); border-radius:2px;
|
||||||
padding:0 4px; color:var(--fg); font-size:11px; }
|
padding:0 4px; color:var(--fg); font-size:11px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -229,6 +242,36 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>paint — background cels, drawn on the frames that get their own drawing</h2>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<select id="paintTool" title="tool">
|
||||||
|
<option value="pen" selected>pen</option>
|
||||||
|
<option value="edit">edit</option>
|
||||||
|
</select>
|
||||||
|
<select id="paintColor" title="palette colour — indices only, never an RGB value"></select>
|
||||||
|
<button id="btn-celprev">Copy previous</button>
|
||||||
|
<button id="btn-celclear">Clear cel</button>
|
||||||
|
<span class="legend" id="paintframe" style="margin:0"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||||
|
<canvas id="cv-paint"></canvas>
|
||||||
|
<div id="paintlayers"></div>
|
||||||
|
</div>
|
||||||
|
<div class="legend" id="paintinfo"></div>
|
||||||
|
<div class="legend">
|
||||||
|
<b>pen</b> click to place vertices · click the green box on the first one, or
|
||||||
|
<kbd>Enter</kbd> / double-click, to close · <kbd>Esc</kbd> cancels ·
|
||||||
|
<kbd>Backspace</kbd> drops the last point<br>
|
||||||
|
<b>edit</b> click a shape to select · drag a vertex or the shape itself ·
|
||||||
|
<kbd>Shift</kbd>-click an edge inserts a vertex · <kbd>Alt</kbd>-click a vertex
|
||||||
|
removes it · <kbd>Del</kbd> deletes the layer<br>
|
||||||
|
<b>drag a frame</b> from the strip onto the canvas to copy its drawing, every
|
||||||
|
layer. Vertices snap to the 320×200 grid, and colours are palette indices —
|
||||||
|
you cannot pick one that is not in the ramp.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>drawings needed — registered reference per kept frame, with the range it holds</h2>
|
<h2>drawings needed — registered reference per kept frame, with the range it holds</h2>
|
||||||
<div id="sheet"></div>
|
<div id="sheet"></div>
|
||||||
|
|
|
||||||
102
js/app.js
102
js/app.js
|
|
@ -11,6 +11,7 @@ import { extractTeeth } from './interior.js';
|
||||||
import { applySim, offsetRing } from './mathutil.js';
|
import { applySim, offsetRing } from './mathutil.js';
|
||||||
import { writeTake } from './take.js';
|
import { writeTake } from './take.js';
|
||||||
import { synthDense } from './synth.js';
|
import { synthDense } from './synth.js';
|
||||||
|
import { PaintUI, drawCel, cloneCel, newCel } from './paint.js';
|
||||||
|
|
||||||
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
|
const RW = 320, RH = 200, ZOOM = 2, THUMB = 92;
|
||||||
|
|
||||||
|
|
@ -53,6 +54,7 @@ const state = {
|
||||||
teeth: null, // resolved per-frame {show, t} after knobs
|
teeth: null, // resolved per-frame {show, t} after knobs
|
||||||
eyes: null, // resolved per-frame lid rings, shut flags, iris discs
|
eyes: null, // resolved per-frame lid rings, shut flags, iris discs
|
||||||
brows: null, // resolved per-frame brow rings after quantised raise
|
brows: null, // resolved per-frame brow rings after quantised raise
|
||||||
|
cels: new Map(), // kept frame -> hand-painted background layers
|
||||||
eyeSig: null, // raw eye measurement, kept for the gaze readout
|
eyeSig: null, // raw eye measurement, kept for the gaze readout
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -510,7 +512,14 @@ function renderFrame(f, mode = plateMode()) {
|
||||||
PALETTE.map((p) => p.hex));
|
PALETTE.map((p) => p.hex));
|
||||||
} else {
|
} else {
|
||||||
r.clear(IDX.bg);
|
r.clear(IDX.bg);
|
||||||
if (mode === 'oval' || mode === 'oval+photo') r.fillPoly(state.plates[pf], IDX.base);
|
}
|
||||||
|
// Painted cels sit BEHIND the face and hold on the same frames the plate
|
||||||
|
// does - pf is already "the most recent kept frame at or before f", which is
|
||||||
|
// exactly the rule the user draws against: a cel holds until the next frame
|
||||||
|
// that has its own drawing.
|
||||||
|
drawCel(r, state.cels.get(pf), (i) => i);
|
||||||
|
if (mode !== 'posterize' && (mode === 'oval' || mode === 'oval+photo')) {
|
||||||
|
r.fillPoly(state.plates[pf], IDX.base);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eyes run on the CLOCK, not on the mouth lead. The lead is a lip-sync
|
// Eyes run on the CLOCK, not on the mouth lead. The lead is a lip-sync
|
||||||
|
|
@ -641,6 +650,7 @@ function drawAll() {
|
||||||
drawStrip();
|
drawStrip();
|
||||||
drawWorksheet();
|
drawWorksheet();
|
||||||
drawReadout();
|
drawReadout();
|
||||||
|
drawPaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawReadout() {
|
function drawReadout() {
|
||||||
|
|
@ -885,6 +895,8 @@ function drawStrip() {
|
||||||
const tag = document.createElement('span');
|
const tag = document.createElement('span');
|
||||||
tag.textContent = f;
|
tag.textContent = f;
|
||||||
cell.append(cv, tag);
|
cell.append(cv, tag);
|
||||||
|
cell.draggable = true;
|
||||||
|
cell.ondragstart = (ev) => ev.dataTransfer.setData('text/plain', String(f));
|
||||||
cell.onclick = (ev) => {
|
cell.onclick = (ev) => {
|
||||||
seekTo(f);
|
seekTo(f);
|
||||||
if (ev.shiftKey) toggle(f);
|
if (ev.shiftKey) toggle(f);
|
||||||
|
|
@ -1056,6 +1068,7 @@ async function runFrames() {
|
||||||
el('scrub').max = dense.length - 1;
|
el('scrub').max = dense.length - 1;
|
||||||
state.frame = 0;
|
state.frame = 0;
|
||||||
labelExposure();
|
labelExposure();
|
||||||
|
loadCels();
|
||||||
rebuild(true);
|
rebuild(true);
|
||||||
const dur = (dense.length / state.fps).toFixed(2);
|
const dur = (dense.length / state.fps).toFixed(2);
|
||||||
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
|
status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` +
|
||||||
|
|
@ -1078,6 +1091,7 @@ function runSynthetic() {
|
||||||
el('scrub').max = 71;
|
el('scrub').max = 71;
|
||||||
state.frame = 0;
|
state.frame = 0;
|
||||||
labelExposure();
|
labelExposure();
|
||||||
|
loadCels();
|
||||||
rebuild(true);
|
rebuild(true);
|
||||||
status('synthetic — exercises everything below detection', 'ok');
|
status('synthetic — exercises everything below detection', 'ok');
|
||||||
}
|
}
|
||||||
|
|
@ -1254,6 +1268,92 @@ PALETTE.forEach((p) => {
|
||||||
el('palette').append(sw);
|
el('palette').append(sw);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- paint ---------- */
|
||||||
|
|
||||||
|
// The cel being edited is the one ON SCREEN, which is the most recent kept
|
||||||
|
// frame at or before the playhead. You can scrub anywhere and keep drawing on
|
||||||
|
// the cel you can see, rather than having to land exactly on a kept frame.
|
||||||
|
const celFrame = () => (state.dense ? plateIndex(state.frame) : 0);
|
||||||
|
|
||||||
|
const paint = new PaintUI({
|
||||||
|
canvas: el('cv-paint'), list: el('paintlayers'), info: el('paintinfo'),
|
||||||
|
zoom: 3, RW, RH, palette: PALETTE,
|
||||||
|
getCel: () => (state.dense ? (state.cels.get(celFrame()) || []) : []),
|
||||||
|
setCel: (cel) => { if (state.dense) state.cels.set(celFrame(), cel); },
|
||||||
|
celAt: (f) => (state.dense ? state.cels.get(heldFrame(keptSorted(), f)) : null),
|
||||||
|
backing: (cv, z) => {
|
||||||
|
if (!state.dense) {
|
||||||
|
cv.width = RW * z; cv.height = RH * z;
|
||||||
|
const g = cv.getContext('2d');
|
||||||
|
g.fillStyle = '#0d0f16'; g.fillRect(0, 0, cv.width, cv.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
compositeRender(cv, state.frame, z);
|
||||||
|
},
|
||||||
|
onChange: () => { saveCels(); drawPanes(); drawStrip(); drawWorksheet(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
el('paintTool').addEventListener('change', () => {
|
||||||
|
paint.tool = el('paintTool').value;
|
||||||
|
paint.draft = null;
|
||||||
|
paint.render();
|
||||||
|
});
|
||||||
|
el('paintColor').addEventListener('change', () => { paint.color = +el('paintColor').value; });
|
||||||
|
el('btn-celclear').onclick = () => {
|
||||||
|
if (!state.dense) return;
|
||||||
|
state.cels.set(celFrame(), newCel());
|
||||||
|
paint.sel = -1;
|
||||||
|
paint.commit();
|
||||||
|
};
|
||||||
|
el('btn-celprev').onclick = () => {
|
||||||
|
// Copy the previous kept frame's finished drawing onto this one. The same
|
||||||
|
// thing the drag-and-drop does, for the case you reach for constantly.
|
||||||
|
if (!state.dense) return;
|
||||||
|
const kept = keptSorted(), here = celFrame();
|
||||||
|
const prev = kept.filter((k) => k < here).pop();
|
||||||
|
if (prev === undefined) { paint.say('no earlier drawing to copy'); return; }
|
||||||
|
const src = state.cels.get(prev);
|
||||||
|
if (!src || !src.length) { paint.say(`f${prev} has nothing to copy`); return; }
|
||||||
|
state.cels.set(here, cloneCel(src));
|
||||||
|
paint.sel = -1;
|
||||||
|
paint.commit();
|
||||||
|
paint.say(`copied f${prev} — ${src.length} layers`);
|
||||||
|
};
|
||||||
|
|
||||||
|
PALETTE.forEach((p, i) => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = i; o.textContent = p.name;
|
||||||
|
el('paintColor').append(o);
|
||||||
|
});
|
||||||
|
el('paintColor').value = 1;
|
||||||
|
paint.color = 1;
|
||||||
|
|
||||||
|
// Drawings are the only thing here a person made by hand, so losing them to a
|
||||||
|
// reload would be the worst failure in the tool. Everything else regenerates.
|
||||||
|
const celKey = () => `arthur.cels.${el('takename').value || 'line_01'}`;
|
||||||
|
function saveCels() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(celKey(), JSON.stringify([...state.cels]));
|
||||||
|
} catch { /* private window, quota - not worth failing a brush stroke over */ }
|
||||||
|
}
|
||||||
|
function loadCels() {
|
||||||
|
state.cels = new Map();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(celKey());
|
||||||
|
if (raw) state.cels = new Map(JSON.parse(raw).map(([k, v]) => [+k, v]));
|
||||||
|
} catch { /* corrupt or absent: start empty */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPaint() {
|
||||||
|
if (!state.dense) { paint.render(); return; }
|
||||||
|
const kept = keptSorted(), cf = celFrame();
|
||||||
|
const until = (kept[kept.indexOf(cf) + 1] ?? state.dense.length) - 1;
|
||||||
|
el('paintframe').textContent =
|
||||||
|
`drawing cel f${cf}` + (until > cf ? ` — holds to f${until}` : '') +
|
||||||
|
(state.frame !== cf ? ` (playhead f${state.frame})` : '');
|
||||||
|
paint.render();
|
||||||
|
}
|
||||||
|
|
||||||
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
|
// #synth / #frames autorun, so the tool can be driven headlessly for smoke tests
|
||||||
// and deep-linked. Detection needs WebGL; the synthetic path does not.
|
// and deep-linked. Detection needs WebGL; the synthetic path does not.
|
||||||
window.addEventListener('error', (e) => {
|
window.addEventListener('error', (e) => {
|
||||||
|
|
|
||||||
345
js/paint.js
Normal file
345
js/paint.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
serve.py
Executable file
28
serve.py
Executable file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dev server that refuses to cache anything.
|
||||||
|
|
||||||
|
`python3 -m http.server` sends Last-Modified, and browsers cache ES modules on
|
||||||
|
it hard enough that a reload can serve a stale js/app.js against a fresh
|
||||||
|
index.html. That failure is silent and points nowhere near its cause: the new
|
||||||
|
knobs appear in the markup, nothing wires them, no error is raised, and the
|
||||||
|
symptom reads as "the feature you just added does not work".
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
class NoCache(SimpleHTTPRequestHandler):
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header('Cache-Control', 'no-store, must-revalidate')
|
||||||
|
self.send_header('Expires', '0')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
if '304' not in fmt % args:
|
||||||
|
super().log_message(fmt, *args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8777
|
||||||
|
print(f'http://127.0.0.1:{port} (no-store)')
|
||||||
|
ThreadingHTTPServer(('127.0.0.1', port), NoCache).serve_forever()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue