@rig-c/core 2.62.0 → 2.63.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 CHANGED
@@ -606,7 +606,7 @@ commands take it and what its default is.
606
606
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
607
607
  | `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** — two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices — plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING §3.4.11](docs/AUTHORING.md)) |
608
608
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
609
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box — a reading, not a gate; the exit status never moves. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
609
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
610
610
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
611
611
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
612
612
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -261,6 +261,7 @@ What the flags mean:
261
261
  | `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index, with their world box and setup-pose area. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
262
262
  | `--pass-through` | `render` only: `<cloth>:<leg>[,<cloth>:<leg>…]` — also read where each cloth slot **passes through** its leg slot, and write a `pass-through.json` into each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg's drawn outline by 2 steps or more, each with the depth and the world box. One printed line per pair, and one per frame that passes through. The first name must draw over the second for a frame to be read — the declared order, never both. A reading, not a gate. Every other file is byte for byte what the render writes without it. An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name, and so is `--slot`/`--hide` beside it — **§8.5** |
263
263
  | `--seam` | `render` only: `<slot>:<slot>[,<slot>:<slot>…]` — also read, for each pair, where the two drawings **stop meeting**, and write a `seam.json` into each frame directory: per pair, the frames with a **gap** (background inside the joint, where the two covered together at the setup pose) and the frames with a **ghost** (the overlap band of one showing where, from its partner's point of view, nothing was drawn), each with its cell count, area and world box, and as runs of frames with their times. One printed line per pair; a pair whose drawings share no cell at the setup pose is a **SKIP**. A reading, not a gate: the exit status never moves. Every other file is byte for byte what the render writes without it. An unknown slot, a slot with itself, a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name, and so is `--slot`/`--hide` beside it — **§8.8** |
264
+ | `--overlaps` | `render` only: also read **which slot each frame drew over which**, and write an `overlaps.json` into each frame directory: per frame, every pair of slots where the slot drawn later covers pixels the earlier one drew (drawn at 128 of 255 or more, on the frames' own pixels), with the pixel count and the pixel box. One `OVERLAP` line per pair that covers anything — its frames as runs, how many of the set, and the most pixels and the frame of them — and one line counting the pairs that are layering the motion does not move. Each frame with a moving pair also gets an **outline plate**, `overlaps-f<NNNN>.png`: every slot in a moving pair as its contour in its own colour, dashed where a slot drawn later covers it, named at its top. The render reports what was drawn; who should be in front is the author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Every other file is byte for byte what the render writes without it, and `frames.json` gains each set's `overlaps` record. Refused beside `--slot`/`--hide` — **§8.9** |
264
265
  | `--angle-range` | `render` only: `<bone>:<relative-to>[,<bone>:<relative-to>…]` — also read, for each pair, the range of the first bone's world direction against the second's over each set's frames, and print one line per pair per set: the lowest and the highest angle in degrees, the span, and the frame each was first reached on. A reading, not a gate: nothing is written, and every file is the bytes the render writes without it. An unknown bone (with the bones there are), a bone against itself, a malformed list and a pair named twice are refused by name, and so is a bone with no direction on a frame — **§8.7** |
265
266
  | `--viewport`, `--viewport-from` | `render`: give the box every frame is drawn over instead of fitting one — `--viewport <x,y,w,h>`, a world box, y up, `x,y` its bottom-left corner, at the pixel size `--max` gives; or `--viewport-from <frames.json \| dir>`, an earlier render's grid exactly (its box, scale and pixel size), so two candidates land on one pixel grid. A slot that reaches past the box is named by set and frame, and `frames.json` records the box was given under `pinned`. The two together are refused, and so is `--max` beside `--viewport-from` — **§8.6**. On `check` and `inspect`, `--viewport` pins the box those commands draw over instead |
266
267
  | `--environment` | `render` only: the wind and gravity the rig's physics constraints answer, in time — a schedule file `{ "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }, …] }`, x right and **y down**, each step holding from its `t` until the next. Both posers set each change before the step that poses the first frame at or after its `t`, and draw the same frames, `geometry.json` and `frames.json` (`RE01`). `frames.json` and `geometry.json` record it, and the framing reads it. A malformed file, a half no physics constraint answers, two steps on one frame and a step after every set's last frame are refused by name, exit 2, nothing written; `check` refuses a frame set drawn under one as a reference. Without it every file is the bytes it always was. An inspection of the response the rig states, not a scene — **§8.4** |
@@ -1137,6 +1138,7 @@ bun cli.ts render --candidate path/to/spine --geometry # + geometry.json per
1137
1138
  bun cli.ts render --candidate path/to/spine --folds # + folds.json per set: which mesh triangles turn over, and when
1138
1139
  bun cli.ts render --candidate path/to/spine --pass-through skirt:leg # + pass-through.json: where a cloth goes through a leg
1139
1140
  bun cli.ts render --candidate path/to/spine --seam thigh:shin # + seam.json: where two slots that meet open a gap or show a ghost
1141
+ bun cli.ts render --candidate path/to/spine --overlaps # + overlaps.json and outline plates: which slot each frame drew over which
1140
1142
  bun cli.ts render --candidate path/to/spine --angle-range tail:hip # how far a bone turns against another, per animation
1141
1143
  bun cli.ts render --candidate path/to/spine --viewport-from render/ # an earlier render's grid: two builds, one pixel grid
1142
1144
  bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
@@ -8879,6 +8881,107 @@ same file, and two runs write the same bytes (the selftest's `seam` suite).
8879
8881
  look at a frame it names, draw that set over the box it printed:
8880
8882
  `render --animation <name> --viewport <x,y,w,h> --max 400`.
8881
8883
 
8884
+ ### 8.9 Which slot covers which — `render --overlaps`
8885
+
8886
+ An arm the draw order puts behind the torso simply vanishes from the frames, on a
8887
+ build that passes every assertion and every check, and nothing in the frames names
8888
+ it. `--overlaps` reads it off the frames `render` draws: **who covers whom, on which
8889
+ frames, by how many pixels**. On the selftest's probe (`overlap_probe`,
8890
+ `fixtures/public.ts`: an opaque stage, a still body, and an arm drawn after the body
8891
+ slid along x across it and out the other side):
8892
+
8893
+ ```bash
8894
+ bun cli.ts render --candidate path/to/spine --animation cross --overlaps --max 160 [--skin …] [--fps 12]
8895
+ # rigc render
8896
+ # .. cross 25 frame(s), 2.000s + contact.png + overlaps.json (1 moving pair(s), 11 outline plate(s)) -> …/cross
8897
+ # .. overlap "cross": 2 pair(s) cover on every frame by the same pixels, or with the slot in front drawn wholly on the one behind — layering the motion does not move; overlaps.json lists them
8898
+ # .. overlap "cross": OVERLAP "body" under "arm": frames 7–17 (11 of 25), covered up to 135 px at frame 11
8899
+ ```
8900
+
8901
+ ⭐ **The render reports what was drawn; who should be in front is the author's, and
8902
+ the draw-order key in the motion spec is the remedy** (§4.7, `drawOrder`). The same
8903
+ slide with a key that puts the arm before the body reads `OVERLAP "arm" under
8904
+ "body"` on the same frames and pixels (`OX02`). On `gallery/walk` at `--fps 20 --max
8905
+ 288` the back arm reads `OVERLAP "arm_b" under "torso": frames 0–18 (19 of 19),
8906
+ covered up to 643 px at frame 15`; with `arm_b` moved after `torso` in the rig's
8907
+ slots, that line is gone and `OVERLAP "torso" under "arm_b"` reads the same 643 px at
8908
+ frame 15.
8909
+
8910
+ **Which frames are read.** Every frame of every set drawn, in **that frame's own
8911
+ draw order** — the order its pieces are drawn in, so a draw-order key is honoured.
8912
+
8913
+ **What covering means.** Every piece is rasterised in draw order on the render's own
8914
+ viewport, so a pixel here is a pixel of `f<NNNN>.png`. A pixel is drawn by a slot
8915
+ when the renderer paints it at half opacity or more (128 of 255) — the line every
8916
+ other reading draws outlines at. Each pixel keeps the slots that have drawn it so
8917
+ far; a slot drawing a pixel earlier slots already drew **covers** each of them there,
8918
+ one pixel to each (earlier, later) pair. `under` is the earlier-drawn slot, `over`
8919
+ the later-drawn one. Pairs are by slot name, so `a` under `b` and `b` under `a` —
8920
+ one draw-order key apart — are two pairs.
8921
+
8922
+ **Layering the motion does not move.** A pair that covers on **every** frame, and on
8923
+ each either by the **same** pixel count or with the slot in front drawn **wholly on**
8924
+ the one behind (every pixel `over` draws is one `under` drew first), is static
8925
+ layering: a backdrop under a part that never leaves it, a face drawn over the head
8926
+ that carries it. It is counted on one line and listed in the file, never printed
8927
+ pair by pair. ⚠️ The pixel count alone was measured not to be enough: on
8928
+ `gallery/walk`, a part turning over the backdrop changes its pixel count from frame to
8929
+ frame by rasterisation alone, so none of its 35 pairs read as static and 14 backdrop lines (and the eyes over the head) buried the ones that mattered.
8930
+ Every other pair that covers anything prints one `OVERLAP` line; a pair that never
8931
+ covers prints nothing.
8932
+
8933
+ **Blind spots, stated.** A fringe painted under half opacity covers nothing here
8934
+ although it tints the frame. A slot in front lying wholly on the one behind on every
8935
+ frame reads as layering however it moves across it — an arm swung over the torso
8936
+ without ever leaving it. A slot drawing more than one piece is one slot. And the
8937
+ counts are pixels at the render's `--max`: quote the size beside a figure.
8938
+
8939
+ **The file.** `overlaps.json`, `rigc-overlaps/1`, beside each set's frames:
8940
+ `animation`, `skin` (absent when none was set), `fps`, `reading` (`drawnAlpha` 128,
8941
+ `unit` `"frame pixels"`, and `rule`, the definition above), `frames[]` — every
8942
+ frame, with `index`, `time` and `pairs[]` (`under`, `over`, `px`, and `box`
8943
+ `[x0, y0, x1, y1]`: frame pixels, x right, y down, both ends inclusive) — and
8944
+ `pairs[]`, one per pair that covers on any frame: `under`, `over`, `frames` (how
8945
+ many it covers on), `of` (the set's frames), `runs` (`[[first, last], …]`,
8946
+ inclusive), `most` (`{ index, px }`, the earliest on a tie), `inside` (the slot in
8947
+ front wholly on the one behind on every frame it covers on) and `varies` (`false`
8948
+ for static layering). Both lists are ordered by `under`, then `over`, by code unit.
8949
+
8950
+ **The outline plate.** `overlaps-f<NNNN>.png`, numbered as the frames are, for each
8951
+ frame where a moving pair covers: a white plate of the frame's size; every slot in a
8952
+ moving pair drawn as its **contour** — a drawn pixel with an undrawn neighbour left,
8953
+ right, above or below — in its own colour, in draw order; a contour pixel some slot
8954
+ drawn later also drew is **dashed** (drawn where `(x + y) % 4 < 2`); and the slot's
8955
+ name, spelled as the lines spell it (`arm_b`, not `ARM_B`). Names are placed in draw
8956
+ order and no two touch: each takes the first free box, trying for every contour pixel
8957
+ in raster order (the first is the topmost, the leftmost on a tie) the box above it,
8958
+ below it, to its left and to its right; when none is free, the free spot nearest the
8959
+ topmost pixel, joined to it by a one-pixel leader line; every box clamped inside the
8960
+ plate. A plate with no free spot left prints the name at its first box anyway — the
8961
+ one case two names can meet. A slot keeps one colour across a set's plates (a fixed table of eight,
8962
+ by the slot's place among the set's moving slots in code-unit order); the colour
8963
+ means only "another slot" — the name says which.
8964
+
8965
+ **`frames.json`.** Each set gains `overlaps`: `file`, `pairs` (how many cover on any
8966
+ frame), `static` (how many of those are layering), `plates`, and `moving[]` — each
8967
+ moving pair's `under`, `over`, `frames` and `most` — so a reader that cannot open a
8968
+ plate has the figures.
8969
+
8970
+ **Refused, nothing written:** `--slot`/`--hide` beside the flag. A slot left out
8971
+ covers nothing, so the pairs it is in would vanish and the reading would describe a
8972
+ picture of part of the rig — run `render --overlaps` on the whole rig and the subset
8973
+ without it; both land on one grid.
8974
+
8975
+ **Nothing else moves.** The PNGs, `contact.png` and every other file are byte for
8976
+ byte what the render writes without the flag, and `frames.json` differs only by
8977
+ each set's `overlaps` record (`OX05`); two runs and both posers write the same file
8978
+ and plates (`OX04`).
8979
+
8980
+ **A reading, not a gate.** It never changes the exit status. Whether a covered arm
8981
+ is a defect is yours; when it is, the remedy is a `drawOrder` key in the motion
8982
+ spec (or the slot's place in the rig's `slots`), and the next render says whether
8983
+ the line moved.
8984
+
8882
8985
  ---
8883
8986
 
8884
8987
  ## 9. Checking against the frames — `rigc check`
@@ -11706,6 +11809,7 @@ pixel (`IN09`).
11706
11809
  | Does this part's edge follow that bone? | `--show art,weights:<bone>,mesh --overlay-slot <part> --zoom slot:<part>` | the part's own triangles and the bone's weight over them alone, over the whole painting. Where slots stack — a neck under the face, the hair, a collar — every mesh crossing the box is drawn and the heat is the union of every slot the bone moves, so the part's edge has no pixel of its own until the others are left out. The legend's `meshes[]` gives the part's weight for the bone at every vertex, so "does the top row follow the head" is read as numbers as well as colour |
11707
11810
  | Is this rig's density where its motion is? | `--show art,density --dim` | each region and mesh filled by its **ratio** — its triangles per world unit² over the figure's — on one ramp, log-scaled and centred on 1×: grey is as dense as the figure, pink to magenta denser (4×, 16×), light to dark green sparser (1/4×, 1/16×), clamped past the end stops (the stops are in the legend). A part's area is its posed mesh's own, the sum of its triangles' areas at the pose drawn, not the image's box; a region is two triangles over its quad. Where parts overlap the pixel is the part drawn last, as in the art, so a part under others is read with `--overlay-slot <part>`. The figure's totals are always over every slot the pose shows — the filter picks what is drawn and listed, never what the figure is — and the legend's `density` gives each slot's attachment, kind, vertices, triangles, hull, area, triangles per area, ratio and the bones it is weighted to, in draw order. A measurement, not a verdict: no bar is drawn, and `A13_MESH_BUDGET` stays the one gate on counts |
11708
11811
  | Does a joint tear in this animation? | `render --seam <a>:<b> --animation <name>` (§8.8) | One line per pair: the frames where background opens inside the joint (gap) or the overlap band of one piece shows past its partner (ghost), with the worst frame's cell count and world box; `seam.json` carries every frame and its time, and the runs of frames. A pair whose drawings never overlap is a SKIP, said by name. A reading, not a verdict — the exit status never moves |
11812
+ | Which slot covers which in this animation? | `render --overlaps --animation <name>` (§8.9) | One `OVERLAP "<under>" under "<over>"` line per pair that moves: the frames it covers on, as runs, and the most pixels it covers and the frame of them; one line counting the layering the motion does not move. `overlaps.json` carries every frame's pairs with their pixel count and box, and `overlaps-f<NNNN>.png` draws each covered frame's contours, the covered part dashed. A reading, not a verdict — the draw-order key is the remedy |
11709
11813
  | Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --animation <name> --at <the worst frame's time> --zoom folds` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count, which triangles, their world box at that frame and their setup-pose area. The view fills those triangles at the pose drawn — every pixel whose centre a folded triangle holds — in the colour the legend's `folds` states, and the legend's `folds[]` names, per mesh, the folded triangles by index with the same box and area, and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across, invisible in a picture of the whole mesh: `--zoom folds` frames the picture to the folded triangles' box. A thin sliver still needs pixels — raise `--max` when the fill is a line. A reading, not a verdict |
11710
11814
  | How is this part triangulated? | `--show art,mesh,hull --zoom slot:<slot>` | each mesh's triangle edges, and over them its hull — vertex 0 to 1 to … to `hull − 1` and back. Regions are not drawn: a region is not a mesh. The legend lists every vertex in world units and in pixels |
11711
11815
  | Which is drawn over which? | `--show art,slots` | each slot that shows a region or mesh numbered by its place in the draw order **at the pose drawn** (0 is drawn first), on a white box at the centre of its attachment's box — so a `drawOrder` key (§4.7) shows from the first frame `render` draws it on |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.62.0",
3
+ "version": "2.63.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,8 @@ import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine }
38
38
  import { AngleRangeError, angleRangeLine, type AngleRangeReading, angleRangesOf, type BonePair, readBonePairs } from '../anglerange.ts';
39
39
  import { outsideLine, outsideViewport, pinnedRecord, readViewportBox, viewportOfFramesFile, ViewportError } from '../viewportpin.ts';
40
40
  import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
41
+ import type { Plate } from '../../tools/plate.ts';
42
+ import { OVERLAPS_FILE, OverlapError, overlapLine, overlapPlate, overlapsFileOf, overlapsOf, overlapsPlateFile, overlapsText, plateParticipants, staticLine } from '../overlaps.ts';
41
43
  import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
42
44
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
43
45
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
@@ -704,6 +706,22 @@ export function cmdRender(flags: Record<string, string>): void {
704
706
  }
705
707
  }
706
708
 
709
+ // The overlap reading (issue #1480) is its own flag for --folds' reason, and refused beside a subset for
710
+ // --pass-through's: who covers whom is a fact of every slot the rig draws, and a subset drops some of them — a slot
711
+ // left out covers nothing, so the pairs it is in would vanish and the reading would describe a picture of part of the rig.
712
+ const overlaps = flags.overlaps !== undefined;
713
+ if (overlaps) {
714
+ for (const flag of ['slot', 'hide'] as const) {
715
+ if (flags[flag] !== undefined) {
716
+ throw new UsageError(
717
+ `--overlaps takes no --${flag}: it reads which slot covers which as the whole rig draws them, and a subset of the ` +
718
+ `drawn slots would drop the pairs the slots left out are in. Run \`rigc render --overlaps\` on the whole rig for the reading, and --${flag} ${flags[flag]} ` +
719
+ 'without --overlaps for the pictures — both land on the same grid',
720
+ );
721
+ }
722
+ }
723
+ }
724
+
707
725
  // The seam reading (issue #1456) is its own flag for --folds' reason, and refused beside a subset for --pass-through's:
708
726
  // it reads the two slots as the whole rig draws them.
709
727
  const seam = flags.seam;
@@ -913,9 +931,30 @@ export function cmdRender(flags: Record<string, string>): void {
913
931
  }
914
932
  }
915
933
  }
916
- return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings };
934
+ // The overlap reading, off the very pieces the frames are drawn from, in their draw order, on the frames' own
935
+ // viewport — one per set — and its outline plates, drawn here so a refusal leaves nothing written.
936
+ const overlapReadings = new Map<string, { file: ReturnType<typeof overlapsFileOf>; plates: Map<number, Plate> }>();
937
+ if (overlaps) {
938
+ for (const [name, frames] of sampled) {
939
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
940
+ try {
941
+ const reading = overlapsOf(frames, pages, viewport);
942
+ const participants = plateParticipants(reading.pairs);
943
+ const plates = new Map<number, Plate>();
944
+ for (const index of reading.plated.keys()) {
945
+ const plate = overlapPlate(reading, index, participants, viewport);
946
+ if (plate !== null) plates.set(index, plate);
947
+ }
948
+ overlapReadings.set(name, { file: overlapsFileOf(animation, fps, skin, reading), plates });
949
+ } catch (err) {
950
+ if (err instanceof OverlapError) throw new UsageError(`--overlaps, ${JSON.stringify(name)}: ${err.message}`);
951
+ throw err;
952
+ }
953
+ }
954
+ }
955
+ return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings };
917
956
  });
918
- const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings } = posed.value;
957
+ const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings } = posed.value;
919
958
  console.log(` .. poser ${posed.note}`);
920
959
  if (environment !== undefined) {
921
960
  const response = environmentResponse(skeletonText);
@@ -969,6 +1008,11 @@ export function cmdRender(flags: Record<string, string>): void {
969
1008
  if (passReading !== undefined) writeFileSync(join(dir, PASS_THROUGH_FILE), passThroughText(passReading));
970
1009
  const seamReading = seamReadings.get(name);
971
1010
  if (seamReading !== undefined) writeFileSync(join(dir, SEAM_FILE), seamText(seamReading));
1011
+ const overlapReading = overlapReadings.get(name);
1012
+ if (overlapReading !== undefined) {
1013
+ writeFileSync(join(dir, OVERLAPS_FILE), overlapsText(overlapReading.file));
1014
+ for (const [index, plate] of overlapReading.plates) plate.writePng(join(dir, overlapsPlateFile(index)));
1015
+ }
972
1016
  const duration = frames[frames.length - 1].time;
973
1017
  const physics = physicsDeclared.length === 0 ? undefined : physicsSteppedOf(frames, fps);
974
1018
  sets.push({
@@ -980,6 +1024,7 @@ export function cmdRender(flags: Record<string, string>): void {
980
1024
  stride: 1,
981
1025
  duration,
982
1026
  ...(physics === undefined ? {} : { physics: { stepped: physics.stepped.length, fps } }),
1027
+ ...(overlapReading === undefined ? {} : { overlaps: overlapsRecord(overlapReading.file, overlapReading.plates.size) }),
983
1028
  });
984
1029
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
985
1030
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
@@ -988,7 +1033,11 @@ export function cmdRender(flags: Record<string, string>): void {
988
1033
  const passNote = passReading === undefined ? '' : ` + ${PASS_THROUGH_FILE} (${passing} of ${passReading.pairs.length} pair(s) pass through)`;
989
1034
  const opening = seamReading?.pairs.filter((p) => p.opens.length > 0).length ?? 0;
990
1035
  const seamNote = seamReading === undefined ? '' : ` + ${SEAM_FILE} (${opening} of ${seamReading.pairs.length} pair(s) open)`;
991
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}`;
1036
+ const overlapNote =
1037
+ overlapReading === undefined
1038
+ ? ''
1039
+ : ` + ${OVERLAPS_FILE} (${overlapReading.file.pairs.filter((p) => p.varies).length} moving pair(s), ${overlapReading.plates.size} outline plate(s))`;
1040
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}${overlapNote}`;
992
1041
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
993
1042
  if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
994
1043
  for (const mesh of foldReading?.meshes ?? []) {
@@ -1004,6 +1053,11 @@ export function cmdRender(flags: Record<string, string>): void {
1004
1053
  for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
1005
1054
  }
1006
1055
  for (const pair of seamReading?.pairs ?? []) console.log(` .. seam ${seamLine(seamReading?.animation ?? null, fps, pair)}`);
1056
+ if (overlapReading !== undefined) {
1057
+ const still = staticLine(overlapReading.file.animation, overlapReading.file.pairs);
1058
+ if (still !== null) console.log(` .. overlap ${still}`);
1059
+ for (const pair of overlapReading.file.pairs) if (pair.varies) console.log(` .. overlap ${overlapLine(overlapReading.file.animation, pair)}`);
1060
+ }
1007
1061
  for (const reading of angleReadings.get(name) ?? []) {
1008
1062
  console.log(` .. angle ${angleRangeLine(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, reading)}`);
1009
1063
  }
@@ -1036,6 +1090,21 @@ export function cmdRender(flags: Record<string, string>): void {
1036
1090
  console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
1037
1091
  }
1038
1092
 
1093
+ /**
1094
+ * `frames.json`'s record of one set's overlap reading (issue #1480), so a reader that cannot open a plate still has
1095
+ * the figures: the file, how many pairs cover anything, how many of them are static layering, how many plates were
1096
+ * drawn, and every pair that moves with its frame count and its most pixels — the figures its line prints.
1097
+ */
1098
+ export function overlapsRecord(file: ReturnType<typeof overlapsFileOf>, plates: number): NonNullable<FrameSet['overlaps']> {
1099
+ return {
1100
+ file: OVERLAPS_FILE,
1101
+ pairs: file.pairs.length,
1102
+ static: file.pairs.filter((p) => !p.varies).length,
1103
+ plates,
1104
+ moving: file.pairs.filter((p) => p.varies).map((p) => ({ under: p.under, over: p.over, frames: p.frames, most: p.most })),
1105
+ };
1106
+ }
1107
+
1039
1108
  /** `render`'s line for a rig that declares no physics constraint (issue #1467): nothing was stepped because nothing could be. */
1040
1109
  export const PHYSICS_NONE_LINE = 'physics: none in this rig — it declares no physics constraint, so no frame steps one';
1041
1110
 
package/src/cli/shared.ts CHANGED
@@ -40,6 +40,7 @@ import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
40
40
  import { FOLDS_FILE } from '../folds.ts';
41
41
  import { DRAWN_ALPHA, PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
42
42
  import { SEAM_FILE } from '../seam.ts';
43
+ import { OVERLAPS_FILE } from '../overlaps.ts';
43
44
  import { readViewportBox, ViewportError } from '../viewportpin.ts';
44
45
  import { parseJsonWithPosition } from '../json-position.ts';
45
46
  import { RUNG_IDS } from '../ladder.ts';
@@ -146,7 +147,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
146
147
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
147
148
  * halves together by reading `--help` rather than by naming a flag.
148
149
  */
149
- const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'accept-skeleton-differences', 'keep-pages', 'dim']);
150
+ const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'overlaps', 'accept-skeleton-differences', 'keep-pages', 'dim']);
150
151
 
151
152
  /**
152
153
  * The flags a command is allowed to spell more than once.
@@ -1052,6 +1053,14 @@ const FLAG_MEANINGS: Record<string, string> = {
1052
1053
  'world units, one texel of the coarser drawing to a step — each with the depth and the world box. One line per pair, and one per frame that passes through. ' +
1053
1054
  'The order is the declaration: the first name must draw over the second for a frame to be read. A reading, not a gate. ' +
1054
1055
  'An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name. Not with --slot/--hide',
1056
+ overlaps:
1057
+ `also read which slot each frame drew over which, and write ${OVERLAPS_FILE} into each frame directory: per frame, every pair ` +
1058
+ `of slots where the slot drawn later covers pixels the earlier one drew (drawn at ${DRAWN_ALPHA} of 255 or more, on the frames' own ` +
1059
+ 'pixels), with the pixel count and the pixel box. One OVERLAP line per pair that covers anything — the frames, and the most pixels ' +
1060
+ 'and the frame of them — and one line counting the pairs that cover the same pixels on every frame (layering the motion does not move). ' +
1061
+ 'Each frame with a moving pair also gets an outline plate, overlaps-f<NNNN>.png: every slot in a moving pair as its contour in its own ' +
1062
+ 'colour, dashed where a slot drawn later covers it, named at its top. The render reports what was drawn; who should be in front is the ' +
1063
+ "author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
1055
1064
  seam:
1056
1065
  `also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
1057
1066
  'per pair, the frames with a gap — background inside the hull of the band the two drawings share at the setup pose, carried to the frame by both — ' +
@@ -1733,10 +1742,11 @@ export const COMMANDS: CommandDoc[] = [
1733
1742
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1734
1743
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1735
1744
  'rigc render … --seam <slot>:<slot>[,…] (the frames where two slots that meet open a gap or show a ghost)',
1745
+ 'rigc render … --overlaps (which slot each frame drew over which, by name, with an outline plate)',
1736
1746
  'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1737
1747
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1738
1748
  ],
1739
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'angle-range', 'environment', 'poser', 'out'],
1749
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'environment', 'poser', 'out'],
1740
1750
  overrides: {
1741
1751
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1742
1752
  fps: {
@@ -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++) {