@rig-c/core 2.62.0 → 2.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/rigc.cjs +18 -12
- package/docs/AUTHORING.md +130 -3
- package/package.json +1 -1
- package/src/cli/core_commands.ts +182 -13
- package/src/cli/shared.ts +29 -5
- package/src/cli/write_whole.ts +92 -0
- package/src/overlaps.ts +545 -0
- package/src/render_shared.ts +17 -0
- package/tools/font5x7.ts +35 -5
package/src/overlaps.ts
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which slot a frame drew over which — the reading behind `rigc render --overlaps`
|
|
3
|
+
* (issue #1480).
|
|
4
|
+
*
|
|
5
|
+
* ⭐ **A reading, not a gate**, as `--folds` and `--pass-through` are. An agent
|
|
6
|
+
* authoring a motion cannot see its frames: an arm the draw order puts behind
|
|
7
|
+
* the torso simply vanishes from them, on a build that passes everything, and
|
|
8
|
+
* nothing names it. This names it — **who covers whom, on which frames, by how
|
|
9
|
+
* many pixels** — and decides nothing. Who *should* be in front is the author's,
|
|
10
|
+
* stated in the motion's draw-order keys; the render says what was drawn.
|
|
11
|
+
*
|
|
12
|
+
* ## What "covers" means here
|
|
13
|
+
*
|
|
14
|
+
* Per frame, every piece is rasterised **in the frame's own draw order** (the
|
|
15
|
+
* order `Frame.pieces` lists them in, which both posers return in applied draw
|
|
16
|
+
* order, so a draw-order key is honoured) on the render's own viewport, so a
|
|
17
|
+
* pixel here is a pixel of `f<NNNN>.png`. A pixel is **drawn** by a piece when
|
|
18
|
+
* the renderer's own traversal (`rasterisePiece`) paints it at `DRAWN_ALPHA` of
|
|
19
|
+
* 255 or more — the half-opacity line every other reading draws outlines at; no
|
|
20
|
+
* other threshold enters.
|
|
21
|
+
*
|
|
22
|
+
* Each pixel keeps the slots that have drawn it so far, in draw order. When a
|
|
23
|
+
* slot draws a pixel that earlier slots already drew, it **covers** each of them
|
|
24
|
+
* there: pair (earlier, later) gains one pixel, and its box grows to hold it.
|
|
25
|
+
* `under` is the earlier-drawn slot, `over` the later-drawn one — the slot drawn
|
|
26
|
+
* later covers the earlier one, whatever either looks like. Pairs are keyed by
|
|
27
|
+
* slot name, so `a` under `b` and `b` under `a` — one draw-order key apart —
|
|
28
|
+
* are two pairs, never one.
|
|
29
|
+
*
|
|
30
|
+
* ## Layering the motion does not move
|
|
31
|
+
*
|
|
32
|
+
* A pair that covers on **every** frame of the set, and on each either by the
|
|
33
|
+
* **same** pixel count or with the slot in front drawn **wholly on** the one
|
|
34
|
+
* behind (every pixel `over` draws is one `under` drew first — `inside`), is
|
|
35
|
+
* **static layering** (`varies: false`): a backdrop under a part that never
|
|
36
|
+
* leaves it, a face drawn over the head that carries it. It is counted, listed
|
|
37
|
+
* in the file and not printed pair by pair. Every other pair that covers
|
|
38
|
+
* anything is printed (`varies: true`).
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ The pixel count alone was the first rule, and it was measured not to hold
|
|
41
|
+
* its own examples (issue #1480): on `gallery/walk`, a part turning over the
|
|
42
|
+
* backdrop changes its pixel count from frame to frame by rasterisation alone,
|
|
43
|
+
* so no pair of its 35 read as static, and 14 backdrop lines
|
|
44
|
+
* buried the one that mattered. Containment is what "layering" means there.
|
|
45
|
+
*
|
|
46
|
+
* Known blind spots, stated rather than discovered: a pixel is drawn or not at
|
|
47
|
+
* the half-opacity line, so a fringe painted fainter than that covers nothing
|
|
48
|
+
* here although it tints the frame; a slot that draws more than one piece on a
|
|
49
|
+
* frame is one slot, entered at a pixel by the first of its pieces to draw there;
|
|
50
|
+
* and the count is of pixels at the
|
|
51
|
+
* render's `--max`, so the same motion at another size reads other numbers —
|
|
52
|
+
* quote the size beside a figure. A slot in front that lies wholly on the one
|
|
53
|
+
* behind on every frame is layering, however it moves across it — an arm swung
|
|
54
|
+
* over the torso without ever leaving it reads as static.
|
|
55
|
+
*
|
|
56
|
+
* Pure: no clock, no randomness, no runtime. The pieces and pages are handed in
|
|
57
|
+
* by whichever poser drew the frames.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
61
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
62
|
+
import { DRAWN_ALPHA } from './passthrough.ts';
|
|
63
|
+
import { type Piece, projector, rasterisePiece, type Viewport } from './render_shared.ts';
|
|
64
|
+
|
|
65
|
+
/** The reading's file name inside a set's frame directory, and its format tag. */
|
|
66
|
+
export const OVERLAPS_FILE = 'overlaps.json';
|
|
67
|
+
export const OVERLAPS_SPEC = 'rigc-overlaps/1';
|
|
68
|
+
|
|
69
|
+
/** An outline plate's file name for frame `index`: `overlaps-f0007.png`, zero-padded as the frames are. */
|
|
70
|
+
export function overlapsPlateFile(index: number): string {
|
|
71
|
+
return `overlaps-f${String(index).padStart(4, '0')}.png`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A covered area's pixel box `[x0, y0, x1, y1]`: frame pixels, x right, y down, both ends inclusive. */
|
|
75
|
+
export type OverlapBox = [number, number, number, number];
|
|
76
|
+
|
|
77
|
+
/** One sampled frame: its index, its time and the pieces it draws, in draw order. */
|
|
78
|
+
export interface OverlapFrame {
|
|
79
|
+
index: number;
|
|
80
|
+
time: number;
|
|
81
|
+
pieces: readonly Piece[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One pair's figures on one frame. */
|
|
85
|
+
export interface FramePair {
|
|
86
|
+
under: string;
|
|
87
|
+
over: string;
|
|
88
|
+
/** Pixels `over` drew where `under` had already drawn. */
|
|
89
|
+
px: number;
|
|
90
|
+
box: OverlapBox;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One pair's figures over a set. */
|
|
94
|
+
export interface PairTotal {
|
|
95
|
+
under: string;
|
|
96
|
+
over: string;
|
|
97
|
+
/** Frames on which `over` covers `under` by one pixel or more. */
|
|
98
|
+
frames: number;
|
|
99
|
+
/** Frames in the set. */
|
|
100
|
+
of: number;
|
|
101
|
+
/** Those frames as inclusive runs of indices, ascending. */
|
|
102
|
+
runs: Array<[number, number]>;
|
|
103
|
+
/** The frame it covers the most pixels on — the earliest on a tie. */
|
|
104
|
+
most: { index: number; px: number };
|
|
105
|
+
/** Whether, on every frame it covers on, every pixel `over` draws is a pixel `under` drew first: the slot in front lies wholly on the one behind. */
|
|
106
|
+
inside: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* `false` for layering the motion does not move: it covers on every frame of
|
|
109
|
+
* the set, and either by one pixel count on each or with `inside` true.
|
|
110
|
+
*/
|
|
111
|
+
varies: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `overlaps.json`: one frame set's reading. */
|
|
115
|
+
export interface OverlapsFile {
|
|
116
|
+
spec: typeof OVERLAPS_SPEC;
|
|
117
|
+
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
118
|
+
animation: string | null;
|
|
119
|
+
skin?: string;
|
|
120
|
+
fps: number;
|
|
121
|
+
/** What the numbers mean, stated in the file so a reader does not need this module. */
|
|
122
|
+
reading: { drawnAlpha: number; unit: 'frame pixels'; rule: string };
|
|
123
|
+
frames: Array<{ index: number; time: number; pairs: FramePair[] }>;
|
|
124
|
+
pairs: PairTotal[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A page a piece samples that is not among the pages — said by name. */
|
|
128
|
+
export class OverlapError extends Error {}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A planted defect, for the suite's mutants only (`OX` controls in
|
|
132
|
+
* `selftest.ts`). Production passes none.
|
|
133
|
+
*/
|
|
134
|
+
export interface OverlapPlant {
|
|
135
|
+
/** Rasterise the pieces in reverse draw order, so the earlier-drawn slot reads as the one in front. */
|
|
136
|
+
reverse?: boolean;
|
|
137
|
+
/** Count a pixel drawn at any painted alpha rather than at `DRAWN_ALPHA`. */
|
|
138
|
+
anyAlpha?: boolean;
|
|
139
|
+
/** Read every pair as varying, so static layering prints pair by pair. */
|
|
140
|
+
noStatic?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One frame's per-pixel lists of the slots that drew there, in draw order — what both the counts and the plate read. */
|
|
144
|
+
export interface FrameDraw {
|
|
145
|
+
/** Slot names, in the order they first drew on this frame. */
|
|
146
|
+
slots: string[];
|
|
147
|
+
/** Per pixel, row major, the indices into `slots` that drew it, in draw order; `null` where nothing did. */
|
|
148
|
+
lists: Array<number[] | null>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Code-unit order, so one build orders one way on every machine and locale. */
|
|
152
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
153
|
+
|
|
154
|
+
/** Pairs in one order everywhere: by `under`, then by `over`, in code-unit order. */
|
|
155
|
+
const pairOrder = (a: { under: string; over: string }, b: { under: string; over: string }): number => byCodeUnit(a.under, b.under) || byCodeUnit(a.over, b.over);
|
|
156
|
+
|
|
157
|
+
function drawFrame(frame: OverlapFrame, pages: ReadonlyMap<string, Plate>, viewport: Viewport, plant: OverlapPlant): FrameDraw {
|
|
158
|
+
const slots: string[] = [];
|
|
159
|
+
const lists: Array<number[] | null> = new Array<number[] | null>(viewport.width * viewport.height).fill(null);
|
|
160
|
+
const project = projector(viewport);
|
|
161
|
+
const need = plant.anyAlpha ? 1 : DRAWN_ALPHA;
|
|
162
|
+
const pieces = plant.reverse ? [...frame.pieces].reverse() : frame.pieces;
|
|
163
|
+
for (const piece of pieces) {
|
|
164
|
+
const page = pages.get(piece.page);
|
|
165
|
+
if (page === undefined) throw new OverlapError(`frame ${frame.index}: slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
|
|
166
|
+
let id = slots.indexOf(piece.slot);
|
|
167
|
+
if (id < 0) id = slots.push(piece.slot) - 1;
|
|
168
|
+
rasterisePiece(page, piece, project, viewport, (px, py, _r, _g, _b, a) => {
|
|
169
|
+
if (a < need) return;
|
|
170
|
+
const at = py * viewport.width + px;
|
|
171
|
+
const list = lists[at];
|
|
172
|
+
if (list === null) lists[at] = [id];
|
|
173
|
+
else if (!list.includes(id)) list.push(id);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return { slots, lists };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every slot's drawn pixel count on one frame, by name. */
|
|
180
|
+
function drawnPx(draw: FrameDraw): Map<string, number> {
|
|
181
|
+
const counts = new Array<number>(draw.slots.length).fill(0);
|
|
182
|
+
for (const list of draw.lists) if (list !== null) for (const id of list) counts[id]++;
|
|
183
|
+
return new Map(draw.slots.map((name, id) => [name, counts[id]]));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One frame's pairs, off its per-pixel lists: each slot covers every slot listed before it at a pixel. */
|
|
187
|
+
function framePairs(draw: FrameDraw, width: number): FramePair[] {
|
|
188
|
+
const found = new Map<string, FramePair>();
|
|
189
|
+
for (let at = 0; at < draw.lists.length; at++) {
|
|
190
|
+
const list = draw.lists[at];
|
|
191
|
+
if (list === null || list.length < 2) continue;
|
|
192
|
+
const x = at % width;
|
|
193
|
+
const y = (at - x) / width;
|
|
194
|
+
for (let j = 1; j < list.length; j++) {
|
|
195
|
+
for (let i = 0; i < j; i++) {
|
|
196
|
+
const under = draw.slots[list[i]];
|
|
197
|
+
const over = draw.slots[list[j]];
|
|
198
|
+
const key = `${under}\u0000${over}`;
|
|
199
|
+
const pair = found.get(key);
|
|
200
|
+
if (pair === undefined) found.set(key, { under, over, px: 1, box: [x, y, x, y] });
|
|
201
|
+
else {
|
|
202
|
+
pair.px++;
|
|
203
|
+
pair.box = [Math.min(pair.box[0], x), Math.min(pair.box[1], y), Math.max(pair.box[2], x), Math.max(pair.box[3], y)];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [...found.values()].sort(pairOrder);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Ascending indices as inclusive runs: `[3, 4, 5, 9]` → `[[3, 5], [9, 9]]`. */
|
|
212
|
+
function runsOf(indices: readonly number[]): Array<[number, number]> {
|
|
213
|
+
const runs: Array<[number, number]> = [];
|
|
214
|
+
for (const i of indices) {
|
|
215
|
+
const last = runs[runs.length - 1];
|
|
216
|
+
if (last !== undefined && i === last[1] + 1) last[1] = i;
|
|
217
|
+
else runs.push([i, i]);
|
|
218
|
+
}
|
|
219
|
+
return runs;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** What `overlaps.json` states its numbers mean — the one wording the file and the docs share. */
|
|
223
|
+
export const OVERLAPS_RULE =
|
|
224
|
+
"the slot drawn later covers the earlier one: per frame every piece is rasterised in that frame's draw order on the render's own viewport, and a pixel a slot draws where earlier slots already drew counts one for each (earlier, later) pair; under is the earlier-drawn slot, over the later";
|
|
225
|
+
|
|
226
|
+
/** One set's reading, plus what the plates need: the frames' per-pixel lists, kept only for frames a plate is drawn of. */
|
|
227
|
+
export interface OverlapReading {
|
|
228
|
+
frames: OverlapsFile['frames'];
|
|
229
|
+
pairs: PairTotal[];
|
|
230
|
+
/** Per plated frame index, its draw — the frames with a varying pair covering. */
|
|
231
|
+
plated: Map<number, FrameDraw>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One set's reading: `frames` as the sampler returned them (pieces in draw
|
|
236
|
+
* order), `pages` the atlas pages the pieces sample, `viewport` the render's.
|
|
237
|
+
*/
|
|
238
|
+
export function overlapsOf(frames: readonly OverlapFrame[], pages: ReadonlyMap<string, Plate>, viewport: Viewport, plant: OverlapPlant = {}): OverlapReading {
|
|
239
|
+
const draws: FrameDraw[] = [];
|
|
240
|
+
const perFrame: OverlapsFile['frames'] = [];
|
|
241
|
+
const seen = new Map<string, { under: string; over: string; on: Array<{ index: number; px: number; inside: boolean }> }>();
|
|
242
|
+
for (const frame of frames) {
|
|
243
|
+
const draw = drawFrame(frame, pages, viewport, plant);
|
|
244
|
+
draws.push(draw);
|
|
245
|
+
const pairs = framePairs(draw, viewport.width);
|
|
246
|
+
perFrame.push({ index: frame.index, time: frame.time, pairs });
|
|
247
|
+
const drawn = drawnPx(draw);
|
|
248
|
+
for (const p of pairs) {
|
|
249
|
+
const key = `${p.under}\u0000${p.over}`;
|
|
250
|
+
const entry = seen.get(key) ?? { under: p.under, over: p.over, on: [] };
|
|
251
|
+
entry.on.push({ index: frame.index, px: p.px, inside: p.px === drawn.get(p.over) });
|
|
252
|
+
seen.set(key, entry);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const pairs: PairTotal[] = [...seen.values()]
|
|
256
|
+
.map(({ under, over, on }) => {
|
|
257
|
+
let most = on[0];
|
|
258
|
+
for (const o of on) if (o.px > most.px) most = o;
|
|
259
|
+
const inside = on.every((o) => o.inside);
|
|
260
|
+
const still = on.length === frames.length && (inside || on.every((o) => o.px === on[0].px));
|
|
261
|
+
return {
|
|
262
|
+
under,
|
|
263
|
+
over,
|
|
264
|
+
frames: on.length,
|
|
265
|
+
of: frames.length,
|
|
266
|
+
runs: runsOf(on.map((o) => o.index)),
|
|
267
|
+
most: { index: most.index, px: most.px },
|
|
268
|
+
inside,
|
|
269
|
+
varies: plant.noStatic === true || !still,
|
|
270
|
+
};
|
|
271
|
+
})
|
|
272
|
+
.sort(pairOrder);
|
|
273
|
+
const moving = new Set(pairs.filter((p) => p.varies).map((p) => `${p.under}\u0000${p.over}`));
|
|
274
|
+
const plated = new Map<number, FrameDraw>();
|
|
275
|
+
perFrame.forEach((f, i) => {
|
|
276
|
+
if (f.pairs.some((p) => moving.has(`${p.under}\u0000${p.over}`))) plated.set(f.index, draws[i]);
|
|
277
|
+
});
|
|
278
|
+
return { frames: perFrame, pairs, plated };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The file for one frame set. */
|
|
282
|
+
export function overlapsFileOf(animation: string | null, fps: number, skin: string | undefined, reading: OverlapReading): OverlapsFile {
|
|
283
|
+
return {
|
|
284
|
+
spec: OVERLAPS_SPEC,
|
|
285
|
+
animation,
|
|
286
|
+
...(skin === undefined ? {} : { skin }),
|
|
287
|
+
fps,
|
|
288
|
+
reading: { drawnAlpha: DRAWN_ALPHA, unit: 'frame pixels', rule: OVERLAPS_RULE },
|
|
289
|
+
frames: reading.frames,
|
|
290
|
+
pairs: reading.pairs,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The file's text: two-space JSON and a newline, so one build and one request are one byte sequence. */
|
|
295
|
+
export function overlapsText(file: OverlapsFile): string {
|
|
296
|
+
return `${JSON.stringify(file, null, 2)}\n`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** `n` with a comma between thousands, written out rather than asked of a locale: `1840` → `1,840`. */
|
|
300
|
+
export function groupedPx(n: number): string {
|
|
301
|
+
const digits = String(n);
|
|
302
|
+
let out = '';
|
|
303
|
+
for (let i = 0; i < digits.length; i++) {
|
|
304
|
+
if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
|
|
305
|
+
out += digits[i];
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Inclusive runs as the line prints them: `12–41, 50`. */
|
|
311
|
+
function runsText(runs: ReadonlyArray<[number, number]>): string {
|
|
312
|
+
return runs.map(([a, b]) => (a === b ? String(a) : `${a}–${b}`)).join(', ');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Where a line is: the animation, or the setup pose for a skeleton with none. */
|
|
316
|
+
const whereOf = (animation: string | null): string => (animation === null ? 'the setup pose' : JSON.stringify(animation));
|
|
317
|
+
|
|
318
|
+
/** The line for one varying pair: `OVERLAP "<under>" under "<over>": frames 12–41 (30 of 224), covered up to 1,840 px at frame 27`. */
|
|
319
|
+
export function overlapLine(animation: string | null, pair: PairTotal): string {
|
|
320
|
+
return (
|
|
321
|
+
`${whereOf(animation)}: OVERLAP "${pair.under}" under "${pair.over}": frames ${runsText(pair.runs)} (${pair.frames} of ${pair.of}), ` +
|
|
322
|
+
`covered up to ${groupedPx(pair.most.px)} px at frame ${pair.most.index}`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The one line for a set's static layering, or `null` when it has none. */
|
|
327
|
+
export function staticLine(animation: string | null, pairs: readonly PairTotal[]): string | null {
|
|
328
|
+
const k = pairs.filter((p) => !p.varies).length;
|
|
329
|
+
if (k === 0) return null;
|
|
330
|
+
return (
|
|
331
|
+
`${whereOf(animation)}: ${k} pair(s) cover on every frame by the same pixels, or with the slot in front drawn wholly on the one behind — ` +
|
|
332
|
+
`layering the motion does not move; ${OVERLAPS_FILE} lists them`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// the outline plate
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
/** The plate's background: plain white, so every contour colour reads against it. */
|
|
341
|
+
export const PLATE_BACKGROUND: RGBA = [255, 255, 255, 255];
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The contour colours, by a slot's place in its set's participants (every slot
|
|
345
|
+
* in a varying pair, in code-unit order), modulo the table's length — so one
|
|
346
|
+
* slot keeps one colour across a set's plates. Eight saturated hues far apart
|
|
347
|
+
* on the wheel and none near the white background; the table means nothing
|
|
348
|
+
* beyond "a different slot", and the name at each contour says which.
|
|
349
|
+
*/
|
|
350
|
+
export const PLATE_COLOURS: readonly RGBA[] = [
|
|
351
|
+
[214, 39, 40, 255],
|
|
352
|
+
[31, 119, 180, 255],
|
|
353
|
+
[44, 160, 44, 255],
|
|
354
|
+
[148, 103, 189, 255],
|
|
355
|
+
[255, 127, 14, 255],
|
|
356
|
+
[23, 190, 207, 255],
|
|
357
|
+
[140, 86, 75, 255],
|
|
358
|
+
[227, 119, 194, 255],
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A covered contour pixel is drawn when `(x + y) % 4 < 2`: on two, off two,
|
|
363
|
+
* along the diagonals — a dash the eye and a pixel count both tell from a solid
|
|
364
|
+
* line. An uncovered contour pixel is always drawn.
|
|
365
|
+
*/
|
|
366
|
+
export const dashOn = (x: number, y: number): boolean => (x + y) % 4 < 2;
|
|
367
|
+
|
|
368
|
+
/** Whether two label boxes `[x, y, w, h]` touch or sit within one pixel. */
|
|
369
|
+
const crowds = (a: [number, number, number, number], b: [number, number, number, number]): boolean =>
|
|
370
|
+
a[0] < b[0] + b[2] + 1 && b[0] < a[0] + a[2] + 1 && a[1] < b[1] + b[3] + 1 && b[1] < a[1] + a[3] + 1;
|
|
371
|
+
|
|
372
|
+
/** One label the plate drew: the slot's name as printed, its box, the contour pixel it names, and whether a leader line joins them. */
|
|
373
|
+
export interface PlateLabel {
|
|
374
|
+
/** The slot's name, exactly as the lines print it. */
|
|
375
|
+
name: string;
|
|
376
|
+
/** The label's ink box `[x0, y0, x1, y1]`, frame pixels, both ends inclusive — the text's full 5x7 cells. */
|
|
377
|
+
box: OverlapBox;
|
|
378
|
+
/** The contour pixel the label names: the slot's topmost boundary pixel, the leftmost on a tie. */
|
|
379
|
+
anchor: [number, number];
|
|
380
|
+
/** Whether no candidate along the contour was free and the label sits at the nearest free spot, joined by a line. */
|
|
381
|
+
leader: boolean;
|
|
382
|
+
/** Whether no free spot was left on the plate at all, so the label sits at its first candidate and may crowd another — stated, never silent. */
|
|
383
|
+
crowded: boolean;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A planted defect of the plate, for the suite's mutants only (`OX07`, `OX08`
|
|
388
|
+
* in `selftest.ts`). Production passes none.
|
|
389
|
+
*/
|
|
390
|
+
export interface OverlapPlatePlant {
|
|
391
|
+
/** Every label at its first candidate, above the contour's top, whatever it crowds — placement off. */
|
|
392
|
+
atTop?: boolean;
|
|
393
|
+
/** Labels drawn in capitals, as the font once drew every name. */
|
|
394
|
+
upper?: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** An outline plate and the labels on it, as the writer placed them. */
|
|
398
|
+
export interface LabelledPlate {
|
|
399
|
+
plate: Plate;
|
|
400
|
+
/** In draw order, one per participant drawn on the frame. */
|
|
401
|
+
labels: PlateLabel[];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** A one-pixel line from (x0, y0) to (x1, y1), integer steps along the longer axis — no antialiasing, one pixel set per step. */
|
|
405
|
+
function leaderLine(plate: Plate, x0: number, y0: number, x1: number, y1: number, colour: RGBA): void {
|
|
406
|
+
const n = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
|
|
407
|
+
for (let s = 0; s <= n; s++) {
|
|
408
|
+
const x = n === 0 ? x0 : x0 + Math.round(((x1 - x0) * s) / n);
|
|
409
|
+
const y = n === 0 ? y0 : y0 + Math.round(((y1 - y0) * s) / n);
|
|
410
|
+
if (x >= 0 && y >= 0 && x < plate.width && y < plate.height) plate.set(x, y, colour);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The outline plate of one frame: on `PLATE_BACKGROUND`, every slot of
|
|
416
|
+
* `participants` that draws on the frame as its mask boundary — a drawn pixel
|
|
417
|
+
* with an undrawn 4-neighbour, or on the plate's edge — in its colour, in the
|
|
418
|
+
* frame's draw order; a boundary pixel some slot drawn later also drew is
|
|
419
|
+
* covered, and drawn dashed (`dashOn`). Each slot's name is printed as the
|
|
420
|
+
* lines print it, in the case it has.
|
|
421
|
+
*
|
|
422
|
+
* ## Where a label goes (issue #1484)
|
|
423
|
+
*
|
|
424
|
+
* Labels are placed one at a time in the frame's draw order; a label is a box
|
|
425
|
+
* of `textWidth(name)` by `GLYPH_H` pixels, and a box is **free** when it does
|
|
426
|
+
* not touch or sit within one pixel of any box placed before it (`crowds`).
|
|
427
|
+
* Every candidate box is clamped into the plate before it is tried, so no label
|
|
428
|
+
* leaves the plate (a name wider than the plate is the one exception: it starts
|
|
429
|
+
* at x 0 and runs off the right edge). The candidates, in this fixed order:
|
|
430
|
+
*
|
|
431
|
+
* 1. for every boundary pixel of the slot's contour in raster order (top row
|
|
432
|
+
* first, left to right — so the first is the contour's topmost pixel, the
|
|
433
|
+
* leftmost on a tie, which is the label's anchor), the box above it
|
|
434
|
+
* (`GLYPH_H + 1` up, left edge on the pixel), then below it (2 down), then to
|
|
435
|
+
* its left (`2 +` the label's width), then to its right (2 across), the last
|
|
436
|
+
* two centred on the pixel's row;
|
|
437
|
+
* 2. none free: every in-plate position in raster order, and the one whose box
|
|
438
|
+
* centre is nearest the anchor (squared distance, the first in raster order
|
|
439
|
+
* on a tie) wins, joined to the anchor by a one-pixel leader line drawn from
|
|
440
|
+
* the anchor to the nearest pixel of the box, in the slot's colour;
|
|
441
|
+
* 3. no position free at all: the first candidate of step 1, marked `crowded`.
|
|
442
|
+
*
|
|
443
|
+
* The first free candidate wins. No randomness and no search order that
|
|
444
|
+
* depends on anything but the frame: one frame, one plate, on every machine.
|
|
445
|
+
*/
|
|
446
|
+
export function overlapPlateLabelled(reading: OverlapReading, index: number, participants: readonly string[], viewport: Viewport, plant: OverlapPlatePlant = {}): LabelledPlate | null {
|
|
447
|
+
const draw = reading.plated.get(index);
|
|
448
|
+
if (draw === undefined) return null;
|
|
449
|
+
const { width, height } = viewport;
|
|
450
|
+
const plate = new Plate(width, height);
|
|
451
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) plate.set(x, y, PLATE_BACKGROUND);
|
|
452
|
+
const contours: Array<{ name: string; colour: RGBA; boundary: Array<[number, number]> }> = [];
|
|
453
|
+
for (let id = 0; id < draw.slots.length; id++) {
|
|
454
|
+
const name = draw.slots[id];
|
|
455
|
+
const place = participants.indexOf(name);
|
|
456
|
+
if (place < 0) continue;
|
|
457
|
+
const colour = PLATE_COLOURS[place % PLATE_COLOURS.length];
|
|
458
|
+
const has = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < width && y < height && (draw.lists[y * width + x]?.includes(id) ?? false);
|
|
459
|
+
const boundary: Array<[number, number]> = [];
|
|
460
|
+
for (let y = 0; y < height; y++) {
|
|
461
|
+
for (let x = 0; x < width; x++) {
|
|
462
|
+
const list = draw.lists[y * width + x];
|
|
463
|
+
if (list === null) continue;
|
|
464
|
+
const k = list.indexOf(id);
|
|
465
|
+
if (k < 0) continue;
|
|
466
|
+
if (has(x - 1, y) && has(x + 1, y) && has(x, y - 1) && has(x, y + 1)) continue;
|
|
467
|
+
boundary.push([x, y]);
|
|
468
|
+
const covered = k < list.length - 1;
|
|
469
|
+
if (!covered || dashOn(x, y)) plate.set(x, y, colour);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (boundary.length > 0) contours.push({ name, colour, boundary });
|
|
473
|
+
}
|
|
474
|
+
const taken: Array<[number, number, number, number]> = [];
|
|
475
|
+
const labels: PlateLabel[] = [];
|
|
476
|
+
const h = GLYPH_H;
|
|
477
|
+
for (const { name, colour, boundary } of contours) {
|
|
478
|
+
const text = plant.upper ? name.toUpperCase() : name;
|
|
479
|
+
const w = textWidth(text, 1);
|
|
480
|
+
const clamp = (x: number, y: number): [number, number, number, number] => [Math.max(0, Math.min(width - w, x)), Math.max(0, Math.min(height - h, y)), w, h];
|
|
481
|
+
const free = (box: [number, number, number, number]): boolean => !taken.some((t) => crowds(t, box));
|
|
482
|
+
const anchor = boundary[0];
|
|
483
|
+
const first = clamp(anchor[0], anchor[1] - h - 1);
|
|
484
|
+
let box: [number, number, number, number] | null = null;
|
|
485
|
+
let leader = false;
|
|
486
|
+
let crowded = false;
|
|
487
|
+
if (plant.atTop) box = first;
|
|
488
|
+
for (let i = 0; box === null && i < boundary.length; i++) {
|
|
489
|
+
const [cx, cy] = boundary[i];
|
|
490
|
+
const mid = cy - Math.floor(h / 2);
|
|
491
|
+
for (const c of [clamp(cx, cy - h - 1), clamp(cx, cy + 2), clamp(cx - w - 2, mid), clamp(cx + 2, mid)]) {
|
|
492
|
+
if (free(c)) {
|
|
493
|
+
box = c;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (box === null) {
|
|
499
|
+
let best: [number, number, number, number] | null = null;
|
|
500
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
501
|
+
for (let y = 0; y + h <= height; y++) {
|
|
502
|
+
for (let x = 0; x + w <= width; x++) {
|
|
503
|
+
const c: [number, number, number, number] = [x, y, w, h];
|
|
504
|
+
const dx = 2 * x + w - 1 - 2 * anchor[0];
|
|
505
|
+
const dy = 2 * y + h - 1 - 2 * anchor[1];
|
|
506
|
+
const d = dx * dx + dy * dy;
|
|
507
|
+
if (d < bestD && free(c)) {
|
|
508
|
+
best = c;
|
|
509
|
+
bestD = d;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (best !== null) {
|
|
514
|
+
box = best;
|
|
515
|
+
leader = true;
|
|
516
|
+
const lx = Math.max(best[0], Math.min(best[0] + w - 1, anchor[0]));
|
|
517
|
+
const ly = Math.max(best[1], Math.min(best[1] + h - 1, anchor[1]));
|
|
518
|
+
leaderLine(plate, anchor[0], anchor[1], lx, ly, colour);
|
|
519
|
+
} else {
|
|
520
|
+
box = first;
|
|
521
|
+
crowded = true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
taken.push(box);
|
|
525
|
+
plate.text(text, box[0], box[1], 1, colour);
|
|
526
|
+
labels.push({ name: text, box: [box[0], box[1], box[0] + w - 1, box[1] + h - 1], anchor: [anchor[0], anchor[1]], leader, crowded });
|
|
527
|
+
}
|
|
528
|
+
return { plate, labels };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The outline plate of one frame, without its label list — `overlapPlateLabelled`'s picture. */
|
|
532
|
+
export function overlapPlate(reading: OverlapReading, index: number, participants: readonly string[], viewport: Viewport): Plate | null {
|
|
533
|
+
return overlapPlateLabelled(reading, index, participants, viewport)?.plate ?? null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** A set's participants: every slot in a varying pair, in code-unit order — what the plate colours index. */
|
|
537
|
+
export function plateParticipants(pairs: readonly PairTotal[]): string[] {
|
|
538
|
+
const names = new Set<string>();
|
|
539
|
+
for (const p of pairs) {
|
|
540
|
+
if (!p.varies) continue;
|
|
541
|
+
names.add(p.under);
|
|
542
|
+
names.add(p.over);
|
|
543
|
+
}
|
|
544
|
+
return [...names].sort(byCodeUnit);
|
|
545
|
+
}
|
package/src/render_shared.ts
CHANGED
|
@@ -204,6 +204,23 @@ export interface FrameSet {
|
|
|
204
204
|
* taken without.
|
|
205
205
|
*/
|
|
206
206
|
physics?: { stepped: number; fps: number };
|
|
207
|
+
/**
|
|
208
|
+
* The overlap reading, when `render --overlaps` asked for it (issue #1480):
|
|
209
|
+
* the file it wrote, how many slot pairs cover anything on any frame, how
|
|
210
|
+
* many of those are static layering (the same pixels on every frame), how
|
|
211
|
+
* many outline plates were drawn, and every pair that moves — under, over,
|
|
212
|
+
* the frames it covers on and the frame it covers the most pixels on.
|
|
213
|
+
*
|
|
214
|
+
* ⭐ Absent unless asked for, for `skin`'s reason in `FramesSidecar`: a render
|
|
215
|
+
* without the flag stays the bytes it always was.
|
|
216
|
+
*/
|
|
217
|
+
overlaps?: {
|
|
218
|
+
file: string;
|
|
219
|
+
pairs: number;
|
|
220
|
+
static: number;
|
|
221
|
+
plates: number;
|
|
222
|
+
moving: Array<{ under: string; over: string; frames: number; most: { index: number; px: number } }>;
|
|
223
|
+
};
|
|
207
224
|
}
|
|
208
225
|
|
|
209
226
|
export interface FramesSidecar {
|
package/tools/font5x7.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A 5x7 bitmap font
|
|
2
|
+
* A 5x7 bitmap font: uppercase, lowercase, digits and a little punctuation.
|
|
3
3
|
*
|
|
4
4
|
* It exists so a generated plate can burn its own name into its own pixels with
|
|
5
5
|
* no font file, no canvas and no dependency. That matters more than it sounds:
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* for real art, and the strongest form of that is the word PLACEHOLDER sitting
|
|
8
8
|
* in the image itself.
|
|
9
9
|
*
|
|
10
|
-
* Each glyph is 7 rows of 5 bits, MSB = leftmost column.
|
|
10
|
+
* Each glyph is 7 rows of 5 bits, MSB = leftmost column. Lowercase sits in the
|
|
11
|
+
* same 5x7 cell, x-height from row 2, a descender (g j p q y) ending on row 6 —
|
|
12
|
+
* the cell has no eighth row (issue #1484: a slot named `arm_b` reads `arm_b`).
|
|
11
13
|
*/
|
|
12
14
|
const GLYPHS: Record<string, number[]> = {
|
|
13
15
|
A: [0x0e, 0x11, 0x11, 0x1f, 0x11, 0x11, 0x11],
|
|
@@ -36,6 +38,32 @@ const GLYPHS: Record<string, number[]> = {
|
|
|
36
38
|
X: [0x11, 0x11, 0x0a, 0x04, 0x0a, 0x11, 0x11],
|
|
37
39
|
Y: [0x11, 0x11, 0x0a, 0x04, 0x04, 0x04, 0x04],
|
|
38
40
|
Z: [0x1f, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1f],
|
|
41
|
+
a: [0x00, 0x00, 0x0e, 0x01, 0x0f, 0x11, 0x0f],
|
|
42
|
+
b: [0x10, 0x10, 0x16, 0x19, 0x11, 0x11, 0x1e],
|
|
43
|
+
c: [0x00, 0x00, 0x0e, 0x10, 0x10, 0x11, 0x0e],
|
|
44
|
+
d: [0x01, 0x01, 0x0d, 0x13, 0x11, 0x11, 0x0f],
|
|
45
|
+
e: [0x00, 0x00, 0x0e, 0x11, 0x1f, 0x10, 0x0e],
|
|
46
|
+
f: [0x06, 0x09, 0x08, 0x1c, 0x08, 0x08, 0x08],
|
|
47
|
+
g: [0x00, 0x0f, 0x11, 0x11, 0x0f, 0x01, 0x0e],
|
|
48
|
+
h: [0x10, 0x10, 0x16, 0x19, 0x11, 0x11, 0x11],
|
|
49
|
+
i: [0x04, 0x00, 0x0c, 0x04, 0x04, 0x04, 0x0e],
|
|
50
|
+
j: [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0c],
|
|
51
|
+
k: [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12],
|
|
52
|
+
l: [0x0c, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0e],
|
|
53
|
+
m: [0x00, 0x00, 0x1a, 0x15, 0x15, 0x11, 0x11],
|
|
54
|
+
n: [0x00, 0x00, 0x16, 0x19, 0x11, 0x11, 0x11],
|
|
55
|
+
o: [0x00, 0x00, 0x0e, 0x11, 0x11, 0x11, 0x0e],
|
|
56
|
+
p: [0x00, 0x00, 0x1e, 0x11, 0x1e, 0x10, 0x10],
|
|
57
|
+
q: [0x00, 0x00, 0x0d, 0x13, 0x0f, 0x01, 0x01],
|
|
58
|
+
r: [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10],
|
|
59
|
+
s: [0x00, 0x00, 0x0e, 0x10, 0x0e, 0x01, 0x1e],
|
|
60
|
+
t: [0x08, 0x08, 0x1c, 0x08, 0x08, 0x09, 0x06],
|
|
61
|
+
u: [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0d],
|
|
62
|
+
v: [0x00, 0x00, 0x11, 0x11, 0x11, 0x0a, 0x04],
|
|
63
|
+
w: [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0a],
|
|
64
|
+
x: [0x00, 0x00, 0x11, 0x0a, 0x04, 0x0a, 0x11],
|
|
65
|
+
y: [0x00, 0x00, 0x11, 0x11, 0x0f, 0x01, 0x0e],
|
|
66
|
+
z: [0x00, 0x00, 0x1f, 0x02, 0x04, 0x08, 0x1f],
|
|
39
67
|
'0': [0x0e, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0e],
|
|
40
68
|
'1': [0x04, 0x0c, 0x04, 0x04, 0x04, 0x04, 0x0e],
|
|
41
69
|
'2': [0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f],
|
|
@@ -73,8 +101,10 @@ export function textWidth(text: string, scale: number): number {
|
|
|
73
101
|
|
|
74
102
|
/**
|
|
75
103
|
* Rasterise `text` by calling `plot(x, y)` for every lit pixel, with the top
|
|
76
|
-
* left of the first glyph at (x0, y0).
|
|
77
|
-
* so a
|
|
104
|
+
* left of the first glyph at (x0, y0). Text is drawn in the case it is given —
|
|
105
|
+
* the font once upper-cased everything, so a lowercase name printed in capitals
|
|
106
|
+
* (issue #1484). Unknown characters draw as a full block so a typo is loud
|
|
107
|
+
* rather than invisible.
|
|
78
108
|
*/
|
|
79
109
|
export function drawText(
|
|
80
110
|
text: string,
|
|
@@ -84,7 +114,7 @@ export function drawText(
|
|
|
84
114
|
plot: (x: number, y: number) => void,
|
|
85
115
|
): void {
|
|
86
116
|
let cursor = x0;
|
|
87
|
-
for (const ch of text
|
|
117
|
+
for (const ch of text) {
|
|
88
118
|
const glyph = GLYPHS[ch] ?? [0x1f, 0x1f, 0x1f, 0x1f, 0x1f, 0x1f, 0x1f];
|
|
89
119
|
for (let row = 0; row < GLYPH_H; row++) {
|
|
90
120
|
for (let col = 0; col < GLYPH_W; col++) {
|