@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.
@@ -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
+ }
@@ -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, uppercase + digits + a little punctuation.
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). Unknown characters draw as a full block
77
- * so a typo is loud rather than invisible.
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.toUpperCase()) {
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++) {