spine-rigc 0.12.0 β†’ 0.13.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
@@ -448,6 +448,7 @@ the art. Its shape is under
448
448
  | --- | --- |
449
449
  | πŸ“˜ **[docs/AUTHORING.md](docs/AUTHORING.md)** | **the format guide, and the one to read before writing a spec.** Both input files field by field with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build β†’ read the report β†’ fix β†’ repeat loop, the map from every named failure to the file that has to change, and the features rigc refuses by name so you do not spend a loop discovering them. It travels **inside the npm package**, at `node_modules/spine-rigc/docs/AUTHORING.md` |
450
450
  | 🎞️ **[docs/MOTION.md](docs/MOTION.md)** | **the key-pose recipe.** How to get two poses, what a pair of poses does and does not fix, the in-betweening rules and where each comes from, and how to spread candidates so a ballot informs. Ships in the package too |
451
+ | πŸ™‚ **[docs/FACE.md](docs/FACE.md)** | **authoring a face.** A blink, a gaze and a 2.5D head turn on plain Spine data: the one line of yaw arithmetic every number in a turn comes from, depth as the parameter you are actually authoring, where to put a grid's columns and the angle at which any grid folds, what foreshortens and what does not, channel allocation before the first key, and the three cliffs with their angles. Also the deform audit gap, demonstrated β€” a folded mesh gates green β€” and the differential check that works today |
451
452
  | πŸ“₯ **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
452
453
  | πŸ€– **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** β€” the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
453
454
  | πŸ”¬ **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
package/docs/AUTHORING.md CHANGED
@@ -1681,8 +1681,20 @@ where the remedy is a line you own. `A35` does **not** refuse it: it is pointed
1681
1681
  other people's files, and a rule stricter than the runtime tells its reader to go
1682
1682
  and break correct data.
1683
1683
 
1684
- πŸ–ΌοΈ **Worked example: [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash)** β€” a 9-vertex ball squashed about
1685
- its contact point, with the two affine transforms its keys were derived from.
1684
+ πŸ–ΌοΈ **Worked examples, and they use a deform for two different things.**
1685
+ [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) β€” a 9-vertex ball squashed about its contact point,
1686
+ with the two affine transforms its keys were derived from.
1687
+ [`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait) β€” a 2.5D head turn, where the keys are the
1688
+ **projection of a yaw** rather than a squash: two grid meshes whose columns are
1689
+ placed to sample a cosine, every offset one line of arithmetic, and a measured
1690
+ account of the angle past which the mesh folds.
1691
+
1692
+ πŸ“˜ **[FACE.md](FACE.md) is the recipe for that second case**, and it is where the
1693
+ grid questions this section leaves to its reader are answered: where to put the
1694
+ columns, why `hull: 0` is the honest declaration on a grid, the closed form for
1695
+ the angle at which any column pair folds β€” and the fact that a folded key passes
1696
+ `A35` and every other assertion, so the arithmetic has to be checked before the
1697
+ build.
1686
1698
 
1687
1699
  ---
1688
1700
 
package/docs/FACE.md ADDED
@@ -0,0 +1,942 @@
1
+ # Authoring a face β€” a turn, a gaze and a blink on plain Spine data
2
+
3
+ **Read this when the request is a head rather than a body.** It is written for an
4
+ agent that has been handed a drawn face β€” or has to draw one β€” and asked for the
5
+ moves a portrait makes: it breathes, it blinks, its eyes move, and it **turns a
6
+ few degrees off axis**. The last one is the reason this page exists. It is the
7
+ move a second format is usually bought for, and it is authorable here, out of an
8
+ ordinary `deform` timeline plus per-part parallax, at a cost this page prices
9
+ before you spend it.
10
+
11
+ [AUTHORING.md](AUTHORING.md) is the format β€” read it first and keep it open; this
12
+ page never restates a field it documents, and
13
+ [AUTHORING Β§4.11](AUTHORING.md) is the `deform` timeline field by field.
14
+ [MOTION.md](MOTION.md) is the recipe for
15
+ *movement* β€” timing, easing, anticipation, follow-through, the offset table β€” and
16
+ everything in it applies to a face unchanged. This page is the part neither has:
17
+ **a face's own geometry**, and what a projection costs when the spec can only
18
+ hold its results.
19
+
20
+ - The `deform` timeline, field by field, and the six things rigc refuses in it:
21
+ **AUTHORING Β§4.11**
22
+ - Timing, easing, arcs, anticipation, follow-through, the per-bone offset table:
23
+ **MOTION Β§3** β€” a blink and a gaze are ordinary MOTION.md work
24
+ - Candidate spreading and the ballot: **MOTION Β§4–§5**. Nothing on this page
25
+ replaces a person's eye
26
+ - A skeleton somebody else authored, and moving a pivot inside it:
27
+ [INGEST.md](INGEST.md)
28
+ - The worked example every number below comes from:
29
+ [`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait),
30
+ and its measurement half,
31
+ [`FINDINGS.md`](https://github.com/firejune/rigc/tree/main/gallery/portrait/FINDINGS.md)
32
+
33
+ 🚨 **Nothing in this toolchain measures what a `deform` key does, and that is the
34
+ one gap you have to author around.** The setup geometry is measured and printed β€”
35
+ coverage, overshoot, hole. The *deformed* geometry is not. A key that turns a
36
+ mesh inside out gates green β€” **26 PASS on `--profile spine-html`, the same as
37
+ the good build, and the same coverage line reporting the setup pose at
38
+ 100.00%.** Β§9.2 demonstrates that with three builds and Β§9.3 gives you the
39
+ differential audit that works today;
40
+ [issue #296](https://github.com/firejune/rigc/issues/296) is the instrument that
41
+ would close it. Reference it, do not wait for it.
42
+
43
+ πŸ“ **Where the numbers on this page come from.** Every figure marked **derived**
44
+ is re-computed from the closed form in Β§1 and reproduces to the digits printed.
45
+ Every figure marked **measured** was read off the shipped artifact by the
46
+ `gallery/portrait` record and carries its scope in the line. Nothing here is a
47
+ prediction, and nothing here is a pass bar.
48
+
49
+ ---
50
+
51
+ ## 0. The normal form
52
+
53
+ **A face request normalises to a depth list plus MOTION.md.** That is the whole
54
+ internal shape:
55
+
56
+ | What arrived | What it becomes |
57
+ | --- | --- |
58
+ | a face that **blinks**, **breathes**, **looks around** | ordinary MOTION.md tracks. A lid, a chest, an iris. Nothing on this page is needed |
59
+ | a face that **turns** | a list of `(x, z)` β€” every part's position across the screen and its **depth** β€” plus one line of arithmetic evaluated at each of them |
60
+ | a face that turns **far** (a three-quarter view) | β›” a different rig, and Β§8 says where the line is. Not a format problem: a parts-and-labour problem |
61
+
62
+ ⭐ **The turn is the only part of a face that is not already MOTION.md's job**,
63
+ and it is 90% of this page. A blink is a translating plate (Β§6); a gaze is
64
+ MOTION Β§3.7's offset table applied to an eye (Β§7). If the request does not
65
+ contain a turn, read Β§6 and Β§7 and stop.
66
+
67
+ The one thing that is genuinely new: a turn is **not a pose you can key**. It is
68
+ a projection, so its values are not chosen, they are *evaluated* β€” and the
69
+ authoring is transcription rather than judgement. Which means the failure mode is
70
+ not "it reads badly", it is **"one number is wrong and nothing can see it"**.
71
+ That is what Β§3 and Β§9 are for.
72
+
73
+ ---
74
+
75
+ ## 1. The one line, and that it **is** one line
76
+
77
+ Treat the face as painted on a cylinder standing on the skull's vertical axis. A
78
+ yaw of `t` about that axis carries a point at `(x, z)` β€” `x` across the screen,
79
+ `z` toward the viewer β€” to `xΒ·cos t βˆ’ zΒ·sin t`, so the shift a part takes is
80
+
81
+ ```
82
+ dx = xΒ·(cos t βˆ’ 1) βˆ’ zΒ·sin t
83
+ \____________/ \______/
84
+ the head depth times sin t:
85
+ narrowing to the WHOLE of the move
86
+ cos t of its
87
+ width (2.2% at 12Β°)
88
+ ```
89
+
90
+ **That is the entire model.** Everything else on this page is that expression
91
+ evaluated somewhere, and the two terms are worth reading separately:
92
+
93
+ - **`βˆ’zΒ·sin t` is proportional to depth.** A part further forward travels
94
+ further. That *is* parallax, and it is why a fringe moves more than a face and
95
+ why hair behind the axis moves the **other way** (Β§2).
96
+ - **`xΒ·(cos t βˆ’ 1)` pulls both edges inward by the same amount** β€” the head
97
+ narrowing. It is second order and it is small: at 12Β° it is 2.2%, which is 3.5
98
+ units at the silhouette against a 35-unit centre shift.
99
+
100
+ At the 12Β° the worked example ships (**derived**):
101
+
102
+ ```
103
+ t = 0.209440 rad cos t βˆ’ 1 = βˆ’0.021852 sin t = 0.207912
104
+ ```
105
+
106
+ β‡’ **A reader who has this line derives every number in a turn and never reaches
107
+ for a hand-tuned table.** That matters more than it sounds: a hand-tuned face
108
+ table has no property you can check, and a derived one has exactly one β€” it
109
+ agrees with the line, or it does not.
110
+
111
+ πŸ“Œ **Author `t` in degrees in your notes and radians nowhere.** The spec holds
112
+ neither; it holds the millimetre-level results. Write the angle in a comment
113
+ beside the part list, because the angle is the only input a later reader can use
114
+ to re-derive the file, and Β§9 is what happens when they cannot.
115
+
116
+ ---
117
+
118
+ ## 2. Depth is the parameter you are actually authoring
119
+
120
+ 🚨 **A part list for a face is not a list of drawings. It is a list of
121
+ `(x, z)`.** And here is the sharp edge:
122
+
123
+ > **`x` is in the file. `z` is not.** Neither spec has a depth field, and on the
124
+ > worked example
125
+ >
126
+ > ```bash
127
+ > grep -c '"z"' gallery/portrait/rig.json gallery/portrait/motion.json # 0 and 0
128
+ > ```
129
+ >
130
+ > The bones carry `x` (`eye_l` at `βˆ’62`, `nose` at `0`), the rig carries the mesh
131
+ > columns' `x`, and every `z` that produced every number lives **only in the
132
+ > README beside them**.
133
+
134
+ β‡’ **Write the depth table down somewhere a reader will find it, because the
135
+ toolchain will not keep it for you.** In the worked example that is a table in
136
+ the example's own README. In a project, put it beside the rig. This is
137
+ [#294](https://github.com/firejune/rigc/issues/294) seen from the authoring side:
138
+ the spec can hold a projection's results but cannot state the projection.
139
+
140
+ **The depths in the worked example**, and what each one is doing (**derived**
141
+ column: `dx` at 12Β°):
142
+
143
+ | Part | `x` | `z` | `dx` | What the depth is |
144
+ | --- | --- | --- | --- | --- |
145
+ | face centre (skull surface) | 0 | **170** | βˆ’35.345 | `R`, the cylinder radius. Every other number is relative to this |
146
+ | fringe centre | 0 | **196** | βˆ’40.751 | 26 **in front** of the skull. The one number in the rig that exists only to make parallax |
147
+ | `nose` | 0 | **192** | βˆ’39.919 | protrudes 22. The only feature in front of the surface |
148
+ | `eye_l` / `eye_r` | βˆ“62 | 150 | βˆ’29.832 / βˆ’32.542 | 20 **below** the surface β€” a socket is a hollow |
149
+ | `brow_l` / `brow_r` | βˆ“62 | 158 | βˆ’31.495 / βˆ’34.205 | 12 below. A brow sits proud of its socket |
150
+ | `mouth` | 0 | 166 | βˆ’34.513 | 4 below. Almost on the surface |
151
+ | `hairmass` (back hair) | 0 | **βˆ’55** | **+11.435** | its centroid is 55 **behind** the axis |
152
+ | `lock_l` / `lock_r` | βˆ“150 | 100 | βˆ’17.513 / βˆ’24.069 | off-axis and shallow, so they travel about half as far as the centre |
153
+ | `ahoge` (cowlick) | βˆ’23 | 20 | βˆ’3.656 | almost on the axis, so it barely moves |
154
+
155
+ ⭐ **The sign flip is the strongest single depth cue you can buy.** `hairmass` at
156
+ `z = βˆ’55` makes `βˆ’zΒ·sin t` positive: as the face swings left, the back of the
157
+ head swings **right**. It is one track, one number, and it is what separates a
158
+ turn from a slide more than any mesh work does.
159
+
160
+ ⚠️ **Get the depths right and the parallax is free; get one wrong and nothing
161
+ complains.** A depth is not measurable from the art β€” it is a decision about a
162
+ shape the drawing only implies. Two readings that help:
163
+
164
+ 1. **Depth is what the drawing overlaps for.** A part drawn *over* another is
165
+ usually in front of it, and the slot order already records that (AUTHORING R4).
166
+ Depth is the same ordering with a magnitude attached, so **derive the sign from
167
+ the draw order and argue only about the size.**
168
+ 2. **The stand-off is the number to state out loud.** The fringe's 26 is not a
169
+ measurement of anything; it is how much parallax the shot wanted. At 12Β° it
170
+ buys `26Β·sin 12Β° = 5.406` units of extra travel β€” **derived**, and the record
171
+ measured 5.406 on the artifact. If the fringe does not read as separate,
172
+ this is the one number to move, and moving it moves nothing else.
173
+
174
+ ---
175
+
176
+ ## 3. One shared shift, then residuals
177
+
178
+ β›” **Do not key each feature's whole `dx`.** Put a bone at the face plate's own
179
+ origin, key the part every feature shares onto that one bone, and let each
180
+ feature key only what is left:
181
+
182
+ ```
183
+ faceshift.translatex = βˆ’RΒ·sin t ← βˆ’35.345 at 12Β°, R = 170
184
+
185
+ residual(x, z) = dx(x, z) βˆ’ (βˆ’RΒ·sin t)
186
+ = xΒ·(cos t βˆ’ 1) + (R βˆ’ z)Β·sin t
187
+ \_______/
188
+ the part's depth BELOW
189
+ the skull surface
190
+ ```
191
+
192
+ ⭐ **So a feature's own track is nothing but its depth below the surface, times
193
+ `sin t`.** That is the sentence this page exists to produce.
194
+
195
+ **The features of the worked example** (**derived**, and every value matches the
196
+ shipped `motion.json` to the last digit printed there):
197
+
198
+ | Bone | `x` | `z` | `dx` | residual | `scalex` |
199
+ | --- | --- | --- | --- | --- | --- |
200
+ | `eye_l` (far) | βˆ’62 | 150 | βˆ’29.832 | **5.513** | 0.8922 |
201
+ | `eye_r` (near) | 62 | 150 | βˆ’32.542 | **2.803** | 1.0641 |
202
+ | `brow_l` | βˆ’62 | 158 | βˆ’31.495 | 3.850 | 0.8966 |
203
+ | `brow_r` | 62 | 158 | βˆ’34.205 | 1.140 | 1.0597 |
204
+ | `nose` | 0 | 192 | βˆ’39.919 | **βˆ’4.574** | 0.9781 |
205
+ | `mouth` | 0 | 166 | βˆ’34.513 | 0.832 | 0.9781 |
206
+
207
+ 🚨 **The reason to do it this way is that it makes a wrong number visible.** A
208
+ residual is **1–6 units**; a total is **30–40**. Nobody can eyeball an error in
209
+ the second, and everybody can eyeball one in the first β€” a residual with the
210
+ wrong sign, or one an order of magnitude off its neighbours, is obvious in a
211
+ column of six. β‡’ **The shared-shift split is an auditing decision before it is a
212
+ rigging one**, and given Β§9 that is the whole argument for it.
213
+
214
+ ⭐ **The nose is the diagnostic.** It is the only **negative** residual on the
215
+ face, because it is the only feature in front of the surface: it protrudes 22, and
216
+ `22Β·sin 12Β° = 4.57` is exactly how much further left it goes than the cheek it
217
+ sits on (**derived**: βˆ’4.574). β‡’ **If the nose's residual is not negative, the
218
+ depths are wrong.** It is the cheapest check on this page and it is arithmetic,
219
+ not a render.
220
+
221
+ πŸ“Œ **`faceshift` has to be its own bone, and not the head bone.** The head bone
222
+ is the head mesh's own slot bone, and the mesh deform already carries that
223
+ plate's motion; a translate there would move the plate **twice**. Make
224
+ `faceshift` a child of `head` that carries only the features β€” the worked
225
+ example's chain is `headroll β†’ head β†’ faceshift β†’ {eyes, brows, nose, mouth}`,
226
+ which `rigc explain` prints as a parent column (Β§9.2).
227
+
228
+ ⭐ **The same reasoning puts the head's pivot one link above the mesh.** A head
229
+ rotates about the top of the neck, not about the middle of its own face, so the
230
+ roll belongs on a `headroll` bone above `head`. There is a rule that enforces it
231
+ under `--profile spine-html`: `A15_IDLE_NO_MESH_BONE_KEYS` refuses an `idle` that
232
+ keys a bone driving a mesh β€” its own slot bone or its control bone β€” because that
233
+ renderer must never idle-skip a mesh. Keying the pivot one link up satisfies it,
234
+ and **the rig that satisfies the assertion is the better rig anyway.**
235
+
236
+ ---
237
+
238
+ ## 4. The mesh β€” where the columns go is the whole decision
239
+
240
+ Two meshes and **40 vertices** carried a head turn in the worked example, which
241
+ cuts against the expectation that a face mesh needs hundreds. The reason is
242
+ structural: **a yaw moves nothing vertically**, so the rows are along for the
243
+ ride and only the column count buys anything.
244
+
245
+ ```
246
+ head 340 Γ— 380 plate, R = 170 hair_bang 372 Γ— 168 plate, R = 196
247
+ 5 columns Γ— 5 rows = 25 vertices 5 columns Γ— 3 rows = 15 vertices
248
+ 32 triangles 16 triangles
249
+ columns x = βˆ’162, βˆ’120, 0, 120, 162 columns x = βˆ’170, βˆ’120, 0, 120, 170
250
+ rows y = 180, 90, 0, βˆ’90, βˆ’180 rows y = 80, 0, βˆ’80
251
+ z = √(RΒ² βˆ’ xΒ²) z = √(RΒ² βˆ’ xΒ²)
252
+ ```
253
+
254
+ Each column's offset is `dx` at its own `(x, z)`, and **every row gets the same
255
+ value**, so a `deform` key is one row of five repeated down the grid with a `0`
256
+ for every `y`:
257
+
258
+ ```json
259
+ { "t": 0.62, "fromVertex": 0, "vertices": [
260
+ -7.175, 0, -22.414, 0, -35.345, 0, -27.658, 0, -14.255, 0,
261
+ -7.175, 0, -22.414, 0, -35.345, 0, -27.658, 0, -14.255, 0,
262
+ … three more identical rows … ], "ease": "swell" }
263
+ ```
264
+
265
+ ⚠️ **`R` is the radius of the cylinder a part is painted on, and it is not always
266
+ the plate's half-width.** For the head plate the two coincide (`340/2 = 170`). For
267
+ the fringe they do **not**: its plate is 372 wide (half-width 186) but its `R` is
268
+ **196**, because 196 is where the fringe *sits* β€” 26 in front of the skull. β‡’
269
+ Read `R` off the depth table, never off the PNG.
270
+
271
+ ### 4.1 Uneven columns, and it costs nothing
272
+
273
+ 🚨 **The columns are not evenly spaced, and that is the trick.** `βˆ’162, βˆ’120, 0,
274
+ 120, 162` puts them **dense near the silhouette and sparse in the middle**, which
275
+ is the sampling a cosine needs: the centre of the face travels `RΒ·sin t` and the
276
+ edges barely travel at all, so all the *variation* is at the edges. Five evenly
277
+ spaced columns spend their resolution where nothing happens.
278
+
279
+ What the grid then does to the drawing is a **non-uniform horizontal
280
+ redistribution** (**derived** at 12Β°, widest row):
281
+
282
+ | Band, from the far edge | Rest width | At 12Β° | Ratio |
283
+ | --- | --- | --- | --- |
284
+ | βˆ’162 β†’ βˆ’120 | 42.0 | 26.76 | **0.637** |
285
+ | βˆ’120 β†’ 0 | 120.0 | 107.07 | 0.892 |
286
+ | 0 β†’ 120 | 120.0 | 127.69 | 1.064 |
287
+ | 120 β†’ 162 | 42.0 | 55.40 | **1.319** |
288
+
289
+ The far side compresses to 64%, the near side stretches to 132%, and the ink
290
+ inside each band compresses and stretches with it. **That gradient is what makes
291
+ it read as a turn instead of a slide.** The whole-head narrowing, by contrast, is
292
+ the cheap part: ink edge to ink edge, `309 Β· cos 12Β° = 302.2` (**derived**; the
293
+ record measured 309.0 β†’ 302.2 on the artifact).
294
+
295
+ ⭐ **It is one decision, made once, in the setup geometry, and every deform key
296
+ after it is better for free.** There is no cost side to this trade.
297
+
298
+ ### 4.2 The silhouette is a tangent, not a mark β€” and refining makes it worse
299
+
300
+ This is the most misleading thing about a face plate, and the counter-intuitive
301
+ result of the whole experiment.
302
+
303
+ A column at `x` sits at depth `z = √(RΒ² βˆ’ xΒ²)`. Project two adjacent columns and
304
+ ask when they **swap order** β€” when the mesh turns inside out:
305
+
306
+ ```
307
+ x₁·cos t βˆ’ z₁·sin t = xβ‚‚Β·cos t βˆ’ zβ‚‚Β·sin t
308
+
309
+ Ξ”x x₁ βˆ’ xβ‚‚
310
+ β‡’ tan ΞΈ_fold = ── = ───────── over ADJACENT columns
311
+ Ξ”z z₁ βˆ’ zβ‚‚
312
+ ```
313
+
314
+ and a grid folds at the **minimum** of that over its pairs. As the gap closes,
315
+ `Ξ”x/Ξ”z β†’ z/|x|`, so the limit for a column is `atan(z / |x|)` β€” a property of the
316
+ **continuous surface**, not of the mesh.
317
+
318
+ **Re-derived from that formula** (and each agrees with a bisection search on the
319
+ shipped column table to 0.01Β°):
320
+
321
+ | Columns | Folds at | The pair that folds |
322
+ | --- | --- | --- |
323
+ | 5 β€” `Β±162, Β±120, 0` (shipped) | **31.37Β°** | `βˆ’162, βˆ’120` |
324
+ | 7 β€” `Β±145` added | **24.56Β°** | `βˆ’162, βˆ’145` |
325
+ | 7 β€” `Β±155` added | **20.95Β°** | `βˆ’162, βˆ’155` |
326
+ | 9 β€” `Β±155, Β±145` added | **20.95Β°** | `βˆ’162, βˆ’155` |
327
+ | 13 β€” uniform, every 27 units | 27.54Β° | `βˆ’162, βˆ’135` |
328
+ | continuous limit at `x = βˆ’162` | **17.65Β°** | β€” |
329
+
330
+ 🚨 **A denser face mesh is not a safer face mesh.** Refining near the silhouette
331
+ drives `Ξ”x/Ξ”z` toward the tangent limit *from above*, so every column you add out
332
+ there **lowers** the angle at which the mesh inverts. A coarse grid survives past
333
+ 17.65Β° only because it does not *sample* there β€” it crushes instead of folding.
334
+
335
+ ⚠️ **And the fold angle is set by the outermost gap, not by the column count.**
336
+ The 5-, 7- and 9-column rows above make that concrete: the 9-column grid folds at
337
+ **exactly** the same 20.95Β° as the 7-column one, because both contain the pair
338
+ `(βˆ’162, βˆ’155)` and the `Β±145` column changes nothing. β‡’ Counting vertices tells
339
+ you nothing about this failure; **look at the outermost two columns.**
340
+
341
+ ⭐ **The rule, and it is an identity rather than a rule of thumb.** Put the
342
+ outermost column at
343
+
344
+ ```
345
+ |x|outer = R Β· cos ΞΈmax β‡’ its tangent limit is EXACTLY ΞΈmax
346
+ ```
347
+
348
+ because `z = RΒ·sin ΞΈ` there and `atan(z/|x|) = ΞΈ`. So you do not tune this: you
349
+ **pick the ceiling first and the column position falls out.** For `R = 170`
350
+ (**derived**):
351
+
352
+ | ΞΈmax you want | Outermost column at |
353
+ | --- | --- |
354
+ | 12Β° | 166.3 |
355
+ | 16Β° | 163.4 |
356
+ | 20Β° | 159.7 |
357
+ | 26Β° | 152.8 |
358
+
359
+ The shipped grid's 162 gives 17.65Β°, comfortably above the 12Β° it ships and just
360
+ above the 16Β° Β§8 calls the instrument's ceiling. β‡’ **Then let the last band be a
361
+ single wide one** β€” that is the direction that buys safety, and it is the
362
+ opposite of refining.
363
+
364
+ ### 4.3 `hull` is `0`, and the vertex order is why
365
+
366
+ ⚠️ **A grid's perimeter is 16 of its 25 vertices and they are not a prefix of any
367
+ row-major order.** Declaring a hull would mean re-ordering the vertex list
368
+ perimeter-first β€” and then **a reader could not count the deform table against
369
+ the grid**, which is the one property that makes 160 transcribed floats
370
+ auditable at all. `0` is the honest declaration. AUTHORING Β§3.4 has what a hull
371
+ buys and the worked example's FINDINGS.md prices what omitting it costs in an
372
+ editor round trip.
373
+
374
+ ⚠️ **A large `MESH` overshoot on a face is not a defect.** A rectangular grid
375
+ over an oval face has transparent corners, and the line says so:
376
+
377
+ ```
378
+ MESH head authored 25 vertices / 32 triangles (budget 32) bones=[head]
379
+ attachments=[head] covers 100.00% of the art, reaching 95.90px past it
380
+ ```
381
+
382
+ **`covers 100.00%` is what matters** β€” nothing of the drawing is outside the
383
+ triangles. The 95.90px is the corner. A mesh that hugged the silhouette instead
384
+ would have to be a `contour`, which cannot be a grid and has **no interior
385
+ vertices to redistribute** β€” so it cannot carry a turn at all.
386
+
387
+ ---
388
+
389
+ ## 5. What foreshortens, and what does not
390
+
391
+ A feature is a rigid drawing sitting on a curved surface, so it also has to
392
+ **narrow** as its patch of surface turns away. That is a bone `scalex`:
393
+
394
+ ```
395
+ scaleX = cos(Ξ± βˆ’ t) / cos Ξ± where Ξ± = atan2(x, z)
396
+ ```
397
+
398
+ The far eye narrows to 89%, the near eye widens to 106% (**derived**: 0.8922 and
399
+ 1.0641). Parts on the axis get `cos t = 0.9781` and can therefore **share one
400
+ track** β€” the nose and mouth do, through a `groups` entry, and it is the only
401
+ place in the worked example's turn where two parts can (Β§7).
402
+
403
+ ### 🚨 The iris does not foreshorten, and this is the finding
404
+
405
+ `iris` and `spark` are children of `eye`, so they inherit the socket's `scalex` β€”
406
+ and **a circular iris under `scalex 0.89` is an ellipse.** That reads as *a
407
+ drawing squashed sideways*, not as a head turned, and it is the first thing to
408
+ break as the angle grows.
409
+
410
+ It is also wrong on the physics. If the character keeps looking at the camera
411
+ through the turn, her eyeball counter-rotates by the same angle the head yawed,
412
+ so the iris stays square-on and stays **circular**. β‡’ **The socket foreshortens;
413
+ the pupil does not.**
414
+
415
+ The fix is one reciprocal per side, on two groups (**derived**):
416
+
417
+ ```
418
+ look_l: [iris_l, spark_l] scalex = 1 / 0.8922 = 1.1208
419
+ look_r: [iris_r, spark_r] scalex = 1 / 1.0641 = 0.9398
420
+ ```
421
+
422
+ ⭐ **Their positions still ride the socket, and that is the part that makes it
423
+ correct rather than a hack.** A bone's scale moves its children's local
424
+ translation, so the highlight at local `(βˆ’11, +11)` under the far socket's 0.8922
425
+ lands at `βˆ’9.81` β€” it slides 1.19 units inward, toward the surface it reflects
426
+ off (**derived**). Only the *shape* is held.
427
+
428
+ πŸ“Š **Measured effect on the cliff, by the worked example's own sweep β€” a
429
+ looked-at judgement over seven renders, not a computed figure:** without the
430
+ counter-scale the turn stops reading at about **18Β°**; with it, about **26Β°**.
431
+ Eight degrees of usable range for two tracks, and the record reports nothing else
432
+ in the experiment came close to that ratio.
433
+
434
+ ⚠️ **A `clipping` attachment would lift the iris's travel ceiling and you cannot
435
+ have one.** Spine has them (AUTHORING Β§3.4) but `A11_NO_CLIPPING_ATTACHMENTS`
436
+ refuses one under `--profile spine-html`, so a rig carrying one builds on only one
437
+ of the two profiles. The ceiling is then geometric: in the worked example the
438
+ iris's ink ring has radius 28.5 against a socket opening 36 half-wide, so
439
+ `36 βˆ’ 28.5 = 7.5` units is as far as it can travel before it crosses its own lash.
440
+ β‡’ **Compute that ceiling from the art before keying a gaze**; the first draft of
441
+ the worked example keyed 9 and had to come back to 7.
442
+
443
+ ---
444
+
445
+ ## 6. A blink is a continuous channel, not a swap
446
+
447
+ **Two ways to blink, and both are right somewhere:**
448
+
449
+ | | An `attachment` swap | A translating lid plate |
450
+ | --- | --- | --- |
451
+ | what it is | two drawings, `eyes` and `eyes_shut`, stepped | one plate, one `translatey`, 65 units down its own bone |
452
+ | costs | 1 extra PNG | 2 PNGs, 2 bones, 2 slots |
453
+ | where it is right | a mascot, a stylised blink, anything whose shut eye is a **different drawing** rather than a covered one | a portrait |
454
+
455
+ ⭐ **Choose the swap for a mascot and the channel for a face**, and the reasons
456
+ are all timing:
457
+
458
+ - **A swap has no shape.** A real blink is fast shut and slow open. The worked
459
+ example is `0.07 s` down and `0.16 s` open β€” a **1 : 2.3** asymmetry, easing
460
+ into the close and out of the open. A stepped timeline has one frame of
461
+ transition and no curve to put an asymmetry in.
462
+ - **A swap has no partial.** A half-blink, a sleepy lid, a lid that rides the
463
+ gaze β€” every one of them is a *fraction of the same channel*, and none of them
464
+ is a third drawing.
465
+ - **A swap has to dodge the frame grid.** A stepped key exactly on a sample time
466
+ can be missed by a player accumulating `1/fps`, which is why
467
+ [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash)'s
468
+ blink key sits at `0.399999` (AUTHORING Β§4.5). **A continuous channel does not
469
+ care where the samples land.**
470
+
471
+ ⚠️ **Two constraints on the lid's own drawing, and both are art decisions the rig
472
+ cannot express.**
473
+
474
+ 1. **Fade its top edge out, and size the fade in pixels.** A flat plate of skin
475
+ translating down a forehead has a visible edge; fading its top 30 pixels
476
+ removes it. β›” **Do not write that fade proportionally.** In the worked example
477
+ it was first authored as `offset 0.34` in `objectBoundingBox` units β€” 34% of
478
+ *whatever height the plate happened to be* β€” and when the plate grew from 88 to
479
+ 112 to cover the eye, the faded band grew with it and stopped covering the top
480
+ of the shut eye. In `userSpaceOnUse` with `y2="30"` the height becomes a free
481
+ variable and the coverage becomes a stated one: `106 βˆ’ 30 = 76` opaque pixels
482
+ above the lash, against a 70-unit eye.
483
+ 2. ~~**Run its sides flush to the window edge.**~~ βœ… **No longer needed.** This
484
+ was a workaround for [#292](https://github.com/firejune/rigc/issues/292):
485
+ `rigc render`'s bilinear filter sampled straight alpha, so **every
486
+ atlas-region edge drew a one-pixel dark rim** over what was behind it, and
487
+ running the art flush to its window gave the sampler nothing dark to reach
488
+ into. The sampler now interpolates premultiplied and a transparent texel gets
489
+ no vote in the colour, so **a lid plate may have whatever margin its drawing
490
+ wants.** Β§9.1 keeps the record of how the rim was found, because the lesson
491
+ there β€” that scene work exercises a renderer where game-part work does not β€”
492
+ outlives this particular defect.
493
+
494
+ πŸ“Œ **Blink both lids on one `groups` track.** An L/R offset of one frame was
495
+ tried in the worked example and rejected: at 25 fps it does not read as a soft
496
+ blink, it reads as a **wink**. β‡’ MOTION Β§3.7's offset table is about a chain
497
+ hanging off a driver, and two lids are not that β€” they are one event.
498
+
499
+ ---
500
+
501
+ ## 7. Channel allocation, before the first key
502
+
503
+ 🚨 **Scene direction wants to layer β€” an idle that keeps running under a
504
+ triggered gaze, under a turn β€” and two animations keying the same bone property
505
+ are BLENDED, not summed.** So the animations have to divide the rig up front. The
506
+ worked example's table, which is the shape of thing to write before authoring
507
+ anything:
508
+
509
+ | Channel | `idle` | `gaze` | `turn` |
510
+ | --- | --- | --- | --- |
511
+ | `torso` scale, `chest` translatey | βœ” | | |
512
+ | `lids` translatey | βœ” | | |
513
+ | `brows` translatey | βœ” | βœ” | |
514
+ | `irises` / `sparks` translate | | βœ” | |
515
+ | `head` + `hair_bang` mesh deform | | | βœ” |
516
+ | `faceshift`, feature translatex / scalex | | | βœ” |
517
+ | `hairmass` translatex | | | βœ” |
518
+ | `lock_l` / `lock_r` / `ahoge` | rotate | rotate | translatex |
519
+ | `headroll` rotate | βœ” | βœ” | βœ” |
520
+ | `neck` | rotate | | translatex |
521
+
522
+ Three collisions survive there: `headroll` rotate in all three, `brows`
523
+ translatey in two, and the locks' rotate in two. ⚠️ **On plain Spine the fixes
524
+ are ordinary β€” `MixBlend.add` on the layered track, or splitting a bone into a
525
+ stack (`headroll_idle` under `headroll_scene`) β€” but both are runtime or rig
526
+ decisions the motion spec cannot express, so nothing warns an author that two of
527
+ their animations will fight.**
528
+
529
+ β›” **And the cost is real, so name it rather than discovering it by shipping.** In
530
+ the worked example `idle` keys **nothing** on the iris, on purpose, even though a
531
+ completely still eye reads as a mannequin. The iris is `gaze`'s channel; an
532
+ `idle` drift plus a `gaze` on a second track would be two animations holding two
533
+ opinions about where she is looking. **That is a loss, it was chosen, and it is
534
+ written down.**
535
+
536
+ β‡’ **The gaze itself is then pure MOTION Β§3.7** β€” a chain of offsets, and the only
537
+ face-specific part is which bone leads. The worked example's ordering
538
+ (**measured** off its own key times):
539
+
540
+ | Track | Extreme at | What it is |
541
+ | --- | --- | --- |
542
+ | `irises` translate | **0.22 s** | the eyes lead. `(7, βˆ’3)` |
543
+ | `sparks` translate | 0.22 s | `(2.8, βˆ’1.2)` β€” **40% of the iris distance**, arriving at the *same time*. A specular highlight is fixed to the light, not to the eyeball, so it lags in **distance** and not in time |
544
+ | `brows` translatey | 0.34 s | +1.8, which turns a flick of the eyes into interest |
545
+ | `headroll` translatex + rotate | **0.40 s** | the head follows **+12% of the duration after the eyes**. A rigid 3.4-unit slide and a βˆ’1.2Β° roll |
546
+ | `lock_l` / `lock_r` / `ahoge` rotate | 0.72 / 0.76 / 0.82 s | **+21% / +24% / +28% after the head**, each with one overshoot crossing of opposite sign |
547
+
548
+ 🩹 **The head's follow there is a rigid slide plus a roll, not a small yaw, and
549
+ that is the transcription cost showing through.** A head following a gaze really
550
+ does yaw a few degrees; three degrees of yaw would be a third pair of `deform`
551
+ keys β€” 80 more hand-written numbers β€” for a motion a viewer reads as *"her head
552
+ moved a little"*. It is the right call at a draft budget and the wrong one at a
553
+ scene-direction budget. Same trade in the turn's anticipation: MOTION Β§3.6 asks
554
+ for a counter-move, and the worked example's is a **βˆ’0.9Β° counter-roll on the
555
+ neck bone rather than a counter-yaw**, because a yaw anticipation is another
556
+ 50-float head key plus 30 on the fringe for a tenth of a second.
557
+
558
+ ⭐ **A roll channel is worth having for a second reason: it is where a turn's arc
559
+ comes from.** A yaw shift is a straight horizontal line and `translatex` draws
560
+ exactly that (MOTION Β§3.5). A 1.6Β° roll on a bone at the top of the neck bends
561
+ every feature's path into an arc, because a rotation carries its descendants on a
562
+ circle for free.
563
+
564
+ ---
565
+
566
+ ## 8. The three cliffs, and picking a construction from the turn you need
567
+
568
+ Three separate failures at three different angles. β‡’ **Read this before the art
569
+ is drawn**, because two of the three are answered by *parts*, and parts are the
570
+ expensive thing to change.
571
+
572
+ **The sweep** β€” the worked example's `turn` re-derived at seven angles, built,
573
+ rendered and looked at. The geometry columns are **derived**; the last column is
574
+ the record's looked-at judgement:
575
+
576
+ | yaw | centre shift | far band 42 β†’ | ratio | 9-unit ink β†’ | reads as a turn? |
577
+ | --- | --- | --- | --- | --- | --- |
578
+ | 8Β° | 23.7 | 32.0 | 0.762 | 6.9 | yes, gently |
579
+ | **12Β° (shipped)** | **35.3** | **26.8** | **0.637** | **5.7** | **yes** |
580
+ | 16Β° | 46.9 | 21.4 | 0.509 | 4.6 | yes |
581
+ | 20Β° | 58.1 | 15.9 | 0.379 | 3.4 | marginal |
582
+ | 24Β° | 69.1 | 10.4 | 0.247 | 2.2 | no β€” the eyes have stopped being eyes |
583
+ | 28Β° | 79.8 | 4.7 | 0.113 | 1.0 | no β€” the far outline is gone |
584
+ | 32Β° | 90.1 | βˆ’0.9 | βˆ’0.021 | β€” | **the mesh has folded** |
585
+
586
+ **Failure 1 β€” the iris goes elliptical, at ~18Β°, and it is fixable.** Β§5. Two
587
+ reciprocal `scalex` tracks buy 8Β° of range. Do this one always; it is the best
588
+ ratio in the experiment.
589
+
590
+ **Failure 2 β€” a `scalex` cannot rotate an almond, at ~26Β°, and it is not
591
+ fixable.** The eye *socket* goes next. A real eye at 26Β° does not narrow
592
+ uniformly: its far corner disappears behind the nose bridge, its lash line
593
+ rotates, its lid wraps. `scalex` does exactly one of those things, so the far eye
594
+ becomes *a thin version of a front-facing eye* and the near eye's lash stretches
595
+ into a wide flat slab (**derived** socket scales: 0.892/1.064 at 12Β°, 0.745/1.082
596
+ at 24Β°, 0.629/1.067 at 32Β° β€” note the near side barely moves past 20Β°, which is
597
+ why the stretch stops looking like foreshortening). β‡’ **Past roughly 26Β° the eyes
598
+ need their own deform meshes** β€” socket, lash and lid as a 3–4 column grid each β€”
599
+ and that is where the vertex count stops being 40. The fringe tips (a rigid plate
600
+ that should be splaying) and the neck go in the same band for the same reason.
601
+
602
+ **Failure 3 β€” the mesh folds, and refining it makes this worse.** Β§4.2, and it is
603
+ the one to design around rather than discover.
604
+
605
+ 🩹 **The neck is the honest fudge, and label yours the same way.** A neck twists:
606
+ its top follows the head almost entirely and its base hardly at all. A single
607
+ rigid plate can only take an average β€” the worked example takes **28%** of the
608
+ head's shift (`βˆ’10` against `βˆ’35.345`), which is **the one number in its turn that
609
+ is not derived**, chosen as the value at which the chin stopped hanging off the
610
+ throat at 12Β°. A turn that had to read at 20Β° would need the neck to be its own
611
+ mesh with its own column table.
612
+
613
+ ### The verdict on angle
614
+
615
+ ⭐ **A 5-column grid with bone-scaled features is a 0–16Β° instrument, comfortable
616
+ at 12Β°.** For a standing portrait that is enough: an idle turn, a glance away, a
617
+ lean into frame. **A 30–45Β° three-quarter turn is a different rig** β€” per-eye
618
+ meshes, a meshed neck, probably a second art layer for the far cheek β€” and it is
619
+ not a format problem, it is a parts-and-labour problem.
620
+
621
+ πŸ“Š **What one held yaw actually costs**, from the shipped `motion.json`
622
+ (**derived** by counting it):
623
+
624
+ | Animation | Duration | Tracks | Track keys | Deform entries | Deform keys | Hand-written floats |
625
+ | --- | --- | --- | --- | --- | --- | --- |
626
+ | `idle` | 3.2 s | 9 | 37 | 0 | 0 | 0 |
627
+ | `gaze` | 1.5 s | 8 | 32 | 0 | 0 | 0 |
628
+ | **`turn`** | 2.2 s | **20** | **81** | 2 | 8 | **160** |
629
+
630
+ β‡’ **`idle` and `gaze` cost what an ordinary MOTION.md shot costs. The entire
631
+ difficulty is the turn, and it is transcription rather than complexity:** of those
632
+ 160 floats there are **five distinct values** (one per column, repeated down five
633
+ rows), **25 of the 50 slots in a head key are structurally `0`** because a yaw has
634
+ no vertical component, and **19 of the 20 tracks carry one value each**. Fix
635
+ [#294](https://github.com/firejune/rigc/issues/294) and
636
+ [#295](https://github.com/firejune/rigc/issues/295) and a turn is a dozen lines.
637
+
638
+ ⚠️ **`groups` buys almost nothing on a face, and that is structural rather than
639
+ an oversight.** The usual lever for cutting track count is a `groups` entry keying
640
+ several bones identically β€” but **every part needing a different number is what
641
+ parallax means.** Of the worked example's 20 turn tracks exactly one is a group
642
+ for that reason (`axis`, the nose and mouth, both on the axis and therefore both
643
+ `cos t`), plus the two reciprocal groups Β§5 needs. Sixteen of the rest are two
644
+ properties on six sibling bones with identical times, identical easings and six
645
+ different values: [#295](https://github.com/firejune/rigc/issues/295).
646
+
647
+ ---
648
+
649
+ ## 9. Looking at it, and the audit gap
650
+
651
+ ### 9.1 The looking protocol is three scales, not one
652
+
653
+ | Scale | What it is for |
654
+ | --- | --- |
655
+ | **the contact sheet** (~0.45Γ—) | whether the **motion** reads. Spacing is a comparison *across* frames, so the grid is the only place to see it |
656
+ | **1:1** | whether the **drawing arrived** |
657
+ | **3–4Γ— on the eyes** | because that is where a reader will look, and a face has no other equivalent |
658
+
659
+ πŸ“Š **Four of the five art defects in the worked example were invisible at contact
660
+ sheet scale** and all four were found at 1:1 or better: the dark seam
661
+ ([#292](https://github.com/firejune/rigc/issues/292)), the lid's fade letting a
662
+ shut eye's lash show through as a grey smudge, the iris crossing its own lash at
663
+ the gaze extreme, and a forehead highlight turning the lid's soft edge into a
664
+ tonal step.
665
+
666
+ ⚠️ **A portrait is the first rig whose plates are *supposed* to overlap
667
+ invisibly**, which is why #292 stayed latent through four gallery examples: every
668
+ part in them carries an ink outline at its edge, and a dark rim on a dark line
669
+ cannot be seen. β‡’ **Scene work exercises a renderer where game-part work does
670
+ not.** Expect to find renderer defects on your first face, and check a suspicious
671
+ edge against a **region build** before reading a single vertex β€” which is the
672
+ next item.
673
+
674
+ ⭐ **When a mesh is the new thing in a shot, build the region version and diff it
675
+ before suspecting the mesh.** The worked example's first seam suspect was the
676
+ grid: faint vertical lines down the forehead, at what looked like column
677
+ positions. Building the same rig with both meshes replaced by plain regions and
678
+ diffing the rest frame settled it in one command β€” worst channel difference 1,
679
+ zero pixels differing β€” so the mesh was innocent and the lines were art
680
+ compositing. **Two minutes, and it halves the search space.**
681
+
682
+ πŸ“Œ **Draw the face before you rig it.** The worked example took **seven art
683
+ passes before a `rig.json` existed** β€” brows twice, hair silhouette, fringe
684
+ depth, neck length, garment mass, choker, proportions. That order is not
685
+ fastidiousness: **the head plate's half-width *is* `R`, and `R` is in every one of
686
+ the thirty derived numbers.** Rigging first means re-deriving all of them every
687
+ time the plate changes width.
688
+
689
+ ⚠️ **And expression lives in ink weight before it lives in shape.** The worked
690
+ example's brows read as a scowl for two iterations and the brows were not the
691
+ problem β€” two eyes whose upper lash is heaviest at the **inner** corner read as
692
+ angry whatever the brow above them does. Worth knowing before spending a pass on
693
+ the wrong part.
694
+
695
+ ### 9.2 🚨 What nothing measures β€” three builds, all green
696
+
697
+ **The setup geometry is measured; the deformed geometry is not.** Here is that
698
+ claim as three builds of the same rig, each command runnable verbatim from a
699
+ clean checkout. Start from the good one:
700
+
701
+ ```bash
702
+ bun install # once
703
+
704
+ bun cli.ts build --rig gallery/portrait/rig.json \
705
+ --motion gallery/portrait/motion.json \
706
+ --out gallery/portrait/build --profile spine-html
707
+ bun cli.ts render --candidate gallery/portrait/build --fps 25 --max 640 \
708
+ --out gallery/portrait/render
709
+ ```
710
+
711
+ ```
712
+ MESH head authored 25 vertices / 32 triangles (budget 32) bones=[head]
713
+ attachments=[head] covers 100.00% of the art, reaching 95.90px past it
714
+ … 26 PASS, 13 SKIP
715
+ ```
716
+
717
+ Now break the projection two ways. Both scripts write a variant motion spec
718
+ beside the originals and touch nothing in the repository:
719
+
720
+ ```bash
721
+ # (a) INVERT ONE BAND: give the two far columns each other's shift.
722
+ # The far side now STRETCHES 1.363 where it should compress to 0.637 β€”
723
+ # the head reads as turning the other way at its own edge.
724
+ bun -e '
725
+ const m = await Bun.file("gallery/portrait/motion.json").json();
726
+ const d = m.animations.turn.deform.find(x => x.slot === "head");
727
+ const row = [-22.414, 0, -7.175, 0, -35.345, 0, -27.658, 0, -14.255, 0];
728
+ for (const k of d.keys) if (k.vertices) k.vertices = [...row, ...row, ...row, ...row, ...row];
729
+ await Bun.write("/tmp/swapped.motion.json", JSON.stringify(m, null, 2));
730
+ '
731
+ bun cli.ts build --rig gallery/portrait/rig.json --motion /tmp/swapped.motion.json \
732
+ --out /tmp/swapped --profile spine-html
733
+
734
+ # (b) FOLD IT: evaluate the same closed form at 40Β°, past the 31.37Β° of Β§4.2,
735
+ # so the two far columns swap order and the mesh turns inside out.
736
+ bun -e '
737
+ const m = await Bun.file("gallery/portrait/motion.json").json();
738
+ const d = m.animations.turn.deform.find(x => x.slot === "head");
739
+ const D = Math.PI / 180, R = 170, t = 40 * D;
740
+ const row = [-162, -120, 0, 120, 162].flatMap(x => {
741
+ const z = Math.sqrt(R * R - x * x);
742
+ return [+(x * (Math.cos(t) - 1) - z * Math.sin(t)).toFixed(3), 0];
743
+ });
744
+ for (const k of d.keys) if (k.vertices) k.vertices = [...row, ...row, ...row, ...row, ...row];
745
+ await Bun.write("/tmp/folded.motion.json", JSON.stringify(m, null, 2));
746
+ '
747
+ bun cli.ts build --rig gallery/portrait/rig.json --motion /tmp/folded.motion.json \
748
+ --out /tmp/folded --profile spine-html
749
+ ```
750
+
751
+ **What comes back from both:**
752
+
753
+ | | good | (a) one band inverted | (b) mesh folded |
754
+ | --- | --- | --- | --- |
755
+ | `--profile spine-html` | 26 PASS / 13 SKIP | **26 PASS / 13 SKIP** | **26 PASS / 13 SKIP** |
756
+ | `A35_DEFORM_KEYS_FIT_THE_ATTACHMENT` | PASS | **PASS** | **PASS** |
757
+ | the `MESH` coverage line | 100.00%, 95.90px past | **byte-identical** | **byte-identical** |
758
+
759
+ 🚨 **All three are green, and the coverage line is the same string in all three,
760
+ because it reports the SETUP pose.** `A35` checks that a deform run *fits* its
761
+ attachment β€” an honest and useful check, and orthogonal to whether the numbers in
762
+ it mean anything.
763
+
764
+ `rigc explain` is the instrument that prints every other timeline's actual values,
765
+ and on a deform it prints the shape of the run rather than the run:
766
+
767
+ ```bash
768
+ bun cli.ts explain --rig gallery/portrait/rig.json \
769
+ --motion gallery/portrait/motion.json --out /tmp/explain
770
+ ```
771
+
772
+ ```
773
+ default/head/head.deform 4 key(s)
774
+ t=0 back to the setup pose bezier[4]
775
+ t=0.62 deform[0..50] 25 pair(s) bezier[4]
776
+ t=1.5 deform[0..50] 25 pair(s) bezier[4]
777
+ t=2.2 back to the setup pose linear
778
+ ```
779
+
780
+ β‡’ **`25 pair(s)` is the whole of what any instrument in this toolchain will tell
781
+ you about a face turn.** Compare a scalar track two lines up in the same report,
782
+ which prints `value=-35.345`. That asymmetry **is**
783
+ [#296](https://github.com/firejune/rigc/issues/296): its proposed
784
+ `A39_DEFORM_KEEPS_TRIANGLE_WINDING` has no legitimate counter-example and would
785
+ have handed the worked example Β§4.2's fold angles without seven renders.
786
+
787
+ ### 9.3 The audit that works today, and exactly what it cannot do
788
+
789
+ `rigc check` renders a candidate onto reference frames' own pixel grid and
790
+ compares (INGEST Β§1.4). Point it at a render of a build you already trust and it
791
+ **does** see a wrong deform:
792
+
793
+ ```bash
794
+ bun cli.ts check --candidate gallery/portrait/build --frames gallery/portrait/render/turn@25fps
795
+ bun cli.ts check --candidate /tmp/swapped --frames gallery/portrait/render/turn@25fps
796
+ bun cli.ts check --candidate /tmp/folded --frames gallery/portrait/render/turn@25fps
797
+ ```
798
+
799
+ | Candidate | MAE mean | worst | at |
800
+ | --- | --- | --- | --- |
801
+ | the build the frames came from | **0.00** | 0.00 | β€” |
802
+ | (a) one band inverted | **0.20** | 0.38 | **f0016** |
803
+ | (b) mesh folded | **2.07** | 3.67 | **f0016** |
804
+
805
+ ⭐ **Both defects land on `f0016`, which is the frame the turn arrives on** β€” the
806
+ `worst at` column points straight at the moment, which is what makes this worth
807
+ running at all.
808
+
809
+ 🚨 **And now the three limits, because this is the instrument you will be tempted
810
+ to call an audit:**
811
+
812
+ 1. **It is differential.** It measures a candidate against **a render of another
813
+ build**, so it catches a *regression* and cannot validate a *first authoring*.
814
+ There is no reference for a face nobody has drawn yet.
815
+ 2. **A wrong projection is a whisper in the aggregate.** Inverting a whole band β€”
816
+ the far edge stretching to **1.363** where it should compress to **0.637**, so
817
+ the head's own edge turns the wrong way β€” moves the mean MAE by **0.20 of
818
+ 255**. Nothing about that number says *"the winding"*; you have to already
819
+ suspect it.
820
+ 3. **The `slot drift` column cannot see it at all.** It was `1.2 px "lid_r"` in
821
+ **all three** runs above, unchanged, because drift is attributed per **slot**
822
+ and a folded head mesh is entirely inside one slot.
823
+
824
+ β‡’ **So the honest procedure until #296 exists:** derive every number from Β§1,
825
+ check the **signs** (Β§3's nose test) and the **fold angle** (Β§4.2's formula)
826
+ arithmetically before you build, render, **look at three scales**, and keep a
827
+ render of the last build you trusted so `check` has something to be differential
828
+ against.
829
+
830
+ ---
831
+
832
+ ## 10. The worked example
833
+
834
+ [`gallery/portrait`](https://github.com/firejune/rigc/tree/main/gallery/portrait)
835
+ is this page on real art β€” **22 parts, 27 bones, 22 slots, 2 meshes, 40
836
+ vertices**, three animations, and a README that derives every number rather than
837
+ listing it. Its
838
+ [`FINDINGS.md`](https://github.com/firejune/rigc/tree/main/gallery/portrait/FINDINGS.md)
839
+ is the measurement half: what it cost, the seven-angle sweep, the five tool gaps
840
+ it filed.
841
+
842
+ ```bash
843
+ bun install # once
844
+
845
+ bun cli.ts build --rig gallery/portrait/rig.json \
846
+ --motion gallery/portrait/motion.json \
847
+ --out gallery/portrait/build
848
+ bun cli.ts render --candidate gallery/portrait/build --fps 25 --max 640 \
849
+ --out gallery/portrait/render
850
+ bun cli.ts preview --candidate gallery/portrait/build \
851
+ --out gallery/portrait/preview.html
852
+
853
+ # do the cycles close on the poses they opened with?
854
+ bun gallery/loop_seam.ts gallery/portrait/render/idle@25fps
855
+ bun gallery/loop_seam.ts gallery/portrait/render/gaze@25fps
856
+ bun gallery/loop_seam.ts gallery/portrait/render/turn@25fps
857
+ ```
858
+
859
+ `build`, `render` and `preview` are not committed; the specs and the 22 part PNGs
860
+ are, and those commands regenerate the rest. **The frames to look at:**
861
+
862
+ | Frame | What it is |
863
+ | --- | --- |
864
+ | `render/turn@25fps/f0016.png` | the yaw arrives. **Look at this one at 1:1** β€” a contact sheet cannot show you whether the face turned or merely slid |
865
+ | `render/turn@25fps/f0000.png` | rest, for the comparison. The pair is the whole example |
866
+ | `render/idle@25fps/f0028.png` | the blink, shut |
867
+ | `render/gaze@25fps/f0015.png` | the gaze, held |
868
+
869
+ **What those commands print** β€” re-run verbatim for this page, from a checkout
870
+ with no `build/`, `render/` or `preview.html` in that directory:
871
+
872
+ | Command | What came back |
873
+ | --- | --- |
874
+ | `build --profile spine` | green β€” **18 PASS, 7 SKIP**, 14 excluded by profile |
875
+ | `build --profile spine-html` | green β€” **26 PASS, 13 SKIP**, including `A13_MESH_BUDGET` and `A15_IDLE_NO_MESH_BONE_KEYS` |
876
+ | both `MESH` lines | `head` **100.00%** covered, reaching 95.90px past the art; `hair_bang` 100.00%, 55.22px |
877
+ | `render --fps 25 --max 640` | **81 + 39 + 56 frames**, 478Γ—640, three contact sheets |
878
+ | `loop_seam.ts` Γ—3 | **0 / 255**, **0 of 305 920 pixels** differing, for all three |
879
+ | `preview` | one 415 KiB HTML file, 22 pages embedded as data URIs |
880
+
881
+ πŸ“Š **Figures this page took from the record rather than re-deriving**, because
882
+ they need the artifact's own pixels: the blink's occlusion (hiding the whole eye
883
+ assembly at the shut hold changes **0 of 305 920** pixels; positive control at
884
+ rest moves **8 183**), the per-edge narrowing displacements, the `spine-core`
885
+ agreement of every posed column and scale with Β§1's line to **under 0.001 px**,
886
+ and the Web Player interop pass (**0 console errors, 0 page exceptions**). Note
887
+ what the first of those cost: hiding one slot at a time needs a throwaway script,
888
+ because `setup: { "slot": null }` crashes with a raw `TypeError`
889
+ ([#293](https://github.com/firejune/rigc/issues/293)).
890
+
891
+ ⭐ **Vela is a second cast member and that was deliberate**, against the gallery's
892
+ own rule that its examples share one drawing. A 2.5D turn reads off four things: a
893
+ brow that frames an eye, an **iris and a highlight as separate parts**, hair in
894
+ **layers** that can lag the skull, and a cheek-to-jaw silhouette with a landmark
895
+ in it to foreshorten. The gallery's mascot has a muzzle, and a muzzle points
896
+ wherever the head points β€” so a mascot's turn is a bone rotation and nothing
897
+ else, which is precisely the move this page is not about. β‡’ **If the art you were
898
+ handed has no landmark to foreshorten, a turn will not read no matter how the
899
+ mesh is built**, and that is worth saying to the user before you build it.
900
+
901
+ ---
902
+
903
+ ## 11. Non-goals β€” stated, so nobody proposes them as gaps
904
+
905
+ 🚫 **No command generates a turn, and none should until the spec can state a
906
+ projection.** Β§1 is one line of arithmetic; a `rigc yaw --degrees 12` would be
907
+ guessing at every depth in Β§2 on the user's behalf, and depth is the parameter
908
+ the *author* is choosing. What the toolchain owes is that the file is checkable,
909
+ that you can look, and that a person can choose. **The generator that would
910
+ help is [#294](https://github.com/firejune/rigc/issues/294) β€” a way to *say* the
911
+ model in the spec β€” not a command that invents one.**
912
+
913
+ 🚫 **No pass bar for a face, and nothing here to hang one on.** MOTION.md's
914
+ banner applies unchanged: `build` says a file is valid, `render` and `preview`
915
+ let you look, `vote` lets a person choose. The angles in Β§8 are where a
916
+ construction **stopped reading for one viewer looking at one drawing**, not
917
+ thresholds.
918
+
919
+ 🚫 **No claim that 12° is the right angle for any request.** It is the angle the
920
+ worked example ships, chosen to sit comfortably inside a 5-column grid's
921
+ 17.65Β° tangent limit. Β§4.2 is how to pick your own, and picking it **first** is
922
+ the entire point of that section.
923
+
924
+ ⚠️ **Not a Live2D comparison, and not a recommendation between formats.** What
925
+ the worked example measured is that a portrait turn is authorable on plain Spine
926
+ 4.3 at draft quality β€” nothing outside the format, no plugin, no runtime patch β€”
927
+ and that the **split is authoring cost rather than runtime capability**. The cost
928
+ is [#294](https://github.com/firejune/rigc/issues/294) and
929
+ [#295](https://github.com/firejune/rigc/issues/295). Whether to pay it is a
930
+ project's decision and this page does not make it.
931
+
932
+ 🚫 **No per-eye mesh recipe.** §8 says the eyes need their own deform meshes past
933
+ about 26Β°, and nobody has built that here. The column-placement arithmetic in
934
+ Β§4.2 applies to any grid over any curved patch, so the tangent limit is the part
935
+ that carries over; **what a lash line and a wrapping lid need is unmeasured, and
936
+ this page does not guess.**
937
+
938
+ 🚫 **No expression system, no visemes, no phoneme mapping.** A face that *acts* is
939
+ a different document and a different measurement. Everything here is one head
940
+ turning, blinking and looking β€” and the one general lesson that might carry into
941
+ that work is Β§7's: **allocate the channels before the first key, because Spine
942
+ blends and does not add.**
package/docs/MOTION.md CHANGED
@@ -25,6 +25,10 @@ toolchain and this page does not invent one.
25
25
  - The parts of that picture `pose` refuses because something is drawn over them,
26
26
  once a first candidate exists: **AUTHORING Β§12** (`rigc chainfit`). It reports the
27
27
  `rotate` key value each answer implies, which is the form this recipe wants them in
28
+ - If the movement is a **face** β€” a blink, a gaze, or a head turning off axis β€”
29
+ everything on this page still applies, and [FACE.md](FACE.md) is the geometry it
30
+ does not have: a turn is a projection rather than a pose, so its values are
31
+ evaluated rather than chosen
28
32
  - If what you were handed is a **compiled skeleton** rather than loose parts β€” reading
29
33
  it, transcribing it into specs, re-pivoting it, extending it with an animation:
30
34
  [INGEST.md](INGEST.md)
@@ -985,6 +989,12 @@ things a candidate differs by, and a movement that does not read when rigid will
985
989
  rescued by deforming it. β‡’ Land the rigid movement, choose between rigid candidates,
986
990
  then propose deform as its own spread.
987
991
 
992
+ ⚠️ **The one case where the deform is not an axis but the movement itself is a face
993
+ turning off axis**, because a rigid candidate of it does not exist β€” a yaw *is* the
994
+ redistribution. [FACE.md](FACE.md) is that case, and it also states what nothing here
995
+ measures: a `deform` key that folds a mesh inside out gates green on every assertion,
996
+ so a face's arithmetic has to be checked before the build rather than after the render.
997
+
988
998
  🚫 **No frame rate anywhere in either spec file.** `render --fps` is a sampling rate for
989
999
  looking; times in a motion spec are seconds (Β§3.3).
990
1000
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Rig compiler for Spine β€” declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "docs/PROMPTING.md",
45
45
  "docs/MOTION.md",
46
46
  "docs/INGEST.md",
47
+ "docs/FACE.md",
47
48
  "NOTICE.md"
48
49
  ],
49
50
  "publishConfig": {
package/src/compile.ts CHANGED
@@ -205,6 +205,24 @@ function rgbaHex(v: number[]): string {
205
205
  return v.map(channelHex).join('');
206
206
  }
207
207
 
208
+ /**
209
+ * Is this `motion.setup` entry the object shape AUTHORING Β§4.2 documents?
210
+ *
211
+ * An array is excluded on purpose: `typeof [] === 'object'` and a member read on
212
+ * one is silently `undefined`, which is the same false green a bare string gives.
213
+ */
214
+ function isSetupEntry(v: unknown): boolean {
215
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
216
+ }
217
+
218
+ /** What a rejected `setup` entry actually was, for the refusal to name. */
219
+ function describeSetupEntry(v: unknown): string {
220
+ if (v === null) return 'null';
221
+ if (Array.isArray(v)) return 'an array';
222
+ if (typeof v === 'string') return `the string ${JSON.stringify(v)}`;
223
+ return `a ${typeof v}`;
224
+ }
225
+
208
226
  /**
209
227
  * Bone timeline shapes: which JSON fields a key carries, and their defaults.
210
228
  *
@@ -1113,6 +1131,30 @@ export function compile(opts: CompileOptions): CompileResult {
1113
1131
  if (!names.length) continue;
1114
1132
 
1115
1133
  const setup = motion.setup?.[rigSlot.name];
1134
+ // ⚠️ `motion` is a CAST, not a parse (`readJson<MotionSpec>`), so the type
1135
+ // says what a correct file holds and not what this one does. A `setup` entry
1136
+ // that is not an object gets past every `!== undefined` guard below and then
1137
+ // reads a member off it β€” and the two ways to write one are both on the
1138
+ // shortest path to the documented feature (issue #293):
1139
+ //
1140
+ // "lid_l": null the natural elision of `{ "attachment": null }`, by
1141
+ // a reader who has just learned `attachment` may be
1142
+ // null. Used to crash with a raw TypeError.
1143
+ // "lid_l": "plate" the attachment name where its wrapper belongs. Far
1144
+ // worse: `.attachment` on a string is `undefined`, so
1145
+ // it compiled GREEN and hid the slot β€” the exact
1146
+ // opposite of what was asked, stated nowhere.
1147
+ //
1148
+ // One guard for both, because the second is why this cannot be a `!== null`
1149
+ // check: a silent wrong is worse than a crash, and the same line makes both.
1150
+ if (setup !== undefined && !isSetupEntry(setup)) {
1151
+ throw new CompileError(
1152
+ `${motionPath}: \`setup."${rigSlot.name}"\` is ${describeSetupEntry(setup)}; a setup entry is an object ` +
1153
+ `of \`{ attachment?: string | null, color?: [r, g, b, a] }\` β€” to show nothing there write ` +
1154
+ `\`"${rigSlot.name}": { "attachment": null }\`, and to show an attachment write ` +
1155
+ `\`"${rigSlot.name}": { "attachment": "<name>" }\``,
1156
+ );
1157
+ }
1116
1158
  if (setup !== undefined && rigSlot.attachment !== undefined) {
1117
1159
  throw new CompileError(
1118
1160
  `slot "${rigSlot.name}" has a setup attachment in the rig spec AND in the motion spec; the setup pose has one author`,
package/src/pose.ts CHANGED
@@ -54,10 +54,18 @@
54
54
  import { existsSync, readdirSync, statSync } from 'node:fs';
55
55
  import { basename, join, resolve } from 'node:path';
56
56
  import { Plate, readPlate } from '../tools/plate.ts';
57
- // `bilinear` rather than a second sampler written here: how a pixel is read
58
- // between two pixel centres is exactly the kind of thing two implementations
59
- // drift on, and `check` already measures with this one.
60
- import { bilinear } from './render.ts';
57
+ // The rasteriser's sampler rather than a second one written here: how a pixel is
58
+ // read between two pixel centres is exactly the kind of thing two
59
+ // implementations drift on.
60
+ //
61
+ // ⚠️ `bilinearChannels`, NOT `bilinear`. `bilinear` interpolates in
62
+ // premultiplied space, which is correct for an atlas and wrong for what this
63
+ // file samples: `materialPlate` hands it the frame's own RGB with alpha rewritten
64
+ // to mean "how much material is here", so the two are decoupled by construction
65
+ // and `errBilinear` combines them itself. Issue #292 moved `bilinear` to the
66
+ // premultiplied form and left this call on the channel-independent one β€” the
67
+ // objective's own semantics, and unchanged fitting numbers with it.
68
+ import { bilinearChannels } from './render.ts';
61
69
 
62
70
  export class PoseError extends Error {}
63
71
 
@@ -428,7 +436,7 @@ export function readBackground(frame: Plate): PoseBackground {
428
436
  * The frame as the objective reads it: the frame's own RGB, with alpha rewritten
429
437
  * to mean **how much material is here** rather than how opaque the file is.
430
438
  *
431
- * Keeping it in a `Plate` is what lets the pyramid, `bilinear` and the nearest
439
+ * Keeping it in a `Plate` is what lets the pyramid, `bilinearChannels` and the nearest
432
440
  * lookup all be the ones this repository already has.
433
441
  */
434
442
  export function materialPlate(frame: Plate, background: PoseBackground): { plate: Plate; share: number } {
@@ -568,7 +576,7 @@ export function errNearest(level: Level, x: number, y: number, pr: number, pg: n
568
576
  /** Same, sampled between pixel centres β€” what the refinement stages measure with. */
569
577
  export function errBilinear(level: Level, plate: Plate, x: number, y: number, pr: number, pg: number, pb: number): number {
570
578
  if (x < 0 || y < 0 || x >= level.width || y >= level.height) return 1;
571
- const [fr, fg, fb, fa] = bilinear(plate, x - 0.5, y - 0.5);
579
+ const [fr, fg, fb, fa] = bilinearChannels(plate, x - 0.5, y - 0.5);
572
580
  const m = fa / 255;
573
581
  if (m <= 0) return 1;
574
582
  const d = (Math.abs(fr - pr) + Math.abs(fg - pg) + Math.abs(fb - pb)) / 765;
package/src/render.ts CHANGED
@@ -23,13 +23,20 @@
23
23
  * real runtime would. Each triangle is then filled with barycentric UV
24
24
  * interpolation.
25
25
  *
26
- * ⭐ **Sampling is bilinear on both paths, and the source is straight alpha.**
27
- * One filter rather than two is not a detail: `check` measures a candidate
28
- * against reference frames, and a mesh triangle sampled nearest against a
29
- * reference sampled bilinear would put a filter difference into the residual
30
- * where only a rig difference belongs. Bilinear rather than nearest because the
31
- * region path was already bilinear and the five committed rungs are rendered
32
- * with it β€” see `bilinear`.
26
+ * ⭐ **Sampling is bilinear on both paths, and the source is straight alpha β€”
27
+ * so the interpolation is premultiplied.** One filter rather than two is not a
28
+ * detail: `check` measures a candidate against reference frames, and a mesh
29
+ * triangle sampled nearest against a reference sampled bilinear would put a
30
+ * filter difference into the residual where only a rig difference belongs.
31
+ * Bilinear rather than nearest because the region path was already bilinear and
32
+ * the five committed rungs are rendered with it.
33
+ *
34
+ * Straight alpha is a property of the SOURCE, not a licence to average it
35
+ * channel by channel: a transparent texel's `(0, 0, 0, 0)` is the absence of a
36
+ * colour, and giving it a vote drew a dark rim along every region edge β€” over
37
+ * the top of whatever was behind the part, and into `check`'s residual on the
38
+ * candidate side. `bilinear` weights each colour by its own alpha and divides
39
+ * back out; see it for what that does and does not move (issue #292).
33
40
  *
34
41
  * ⚠️ **Region rasterising is untouched by the mesh path**, deliberately. A region
35
42
  * could be drawn as two triangles and very nearly the same pixels would come out;
@@ -1260,29 +1267,118 @@ export function blitPiece(
1260
1267
  rasterisePiece(page, piece, project, dst, (px, py, r, g, b, a) => dst.blend(px, py, [r, g, b, a]));
1261
1268
  }
1262
1269
 
1263
- export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
1270
+ /** The four texels one bilinear tap reads, and the fractions between them. */
1271
+ interface Taps {
1272
+ c00: RGBA;
1273
+ c10: RGBA;
1274
+ c01: RGBA;
1275
+ c11: RGBA;
1276
+ fx: number;
1277
+ fy: number;
1278
+ }
1279
+
1280
+ /**
1281
+ * The four texels around `(x, y)`, CLAMPED at the page edge.
1282
+ *
1283
+ * ⚠️ The clamp is load-bearing beyond this function: `src/atlas.ts` sizes the
1284
+ * gutter between packed regions against the fact that one tap reaches exactly one
1285
+ * texel, and `gallery/portrait`'s lid runs its art flush to its own window
1286
+ * because a clamped tap has no transparent neighbour to reach into. Widening the
1287
+ * tap is not a local change.
1288
+ */
1289
+ function taps(page: Plate, x: number, y: number): Taps {
1264
1290
  const x0 = Math.floor(x);
1265
1291
  const y0 = Math.floor(y);
1266
- const fx = x - x0;
1267
- const fy = y - y0;
1268
1292
  const at = (ix: number, iy: number): RGBA => {
1269
1293
  const cx = Math.max(0, Math.min(page.width - 1, ix));
1270
1294
  const cy = Math.max(0, Math.min(page.height - 1, iy));
1271
1295
  return page.get(cx, cy);
1272
1296
  };
1273
- const c00 = at(x0, y0);
1274
- const c10 = at(x0 + 1, y0);
1275
- const c01 = at(x0, y0 + 1);
1276
- const c11 = at(x0 + 1, y0 + 1);
1277
- const out: [number, number, number, number] = [0, 0, 0, 0];
1278
- for (let c = 0; c < 4; c++) {
1279
- const top = c00[c] + (c10[c] - c00[c]) * fx;
1280
- const bottom = c01[c] + (c11[c] - c01[c]) * fx;
1281
- out[c] = top + (bottom - top) * fy;
1297
+ return { c00: at(x0, y0), c10: at(x0 + 1, y0), c01: at(x0, y0 + 1), c11: at(x0 + 1, y0 + 1), fx: x - x0, fy: y - y0 };
1298
+ }
1299
+
1300
+ /** One channel of a bilinear tap: lerp along x on both rows, then between them. */
1301
+ function lerpTap(v00: number, v10: number, v01: number, v11: number, fx: number, fy: number): number {
1302
+ const top = v00 + (v10 - v00) * fx;
1303
+ const bottom = v01 + (v11 - v01) * fx;
1304
+ return top + (bottom - top) * fy;
1305
+ }
1306
+
1307
+ /**
1308
+ * Sample a straight-alpha page bilinearly β€” interpolating in PREMULTIPLIED space.
1309
+ *
1310
+ * ⭐ **Why the premultiply.** The source is straight alpha, so a transparent
1311
+ * texel beside the art is `(0, 0, 0, 0)`: its colour is not a colour, it is the
1312
+ * absence of one. Averaging R, G and B against it pulls the sample toward black
1313
+ * while alpha only drops part of the way, and the difference between those two
1314
+ * rates IS a dark rim, one pixel wide, drawn over whatever is behind the part.
1315
+ * Weighting each colour by its own alpha and dividing the sum back out gives the
1316
+ * transparent texel no vote in the colour, which is the whole of the fix: two
1317
+ * parts of one colour, overlapping, come out that colour. Measured before the
1318
+ * fix at βˆ’60/255 between two parts sharing one flat field, and βˆ’31/255 down
1319
+ * `gallery/portrait`'s forehead β€” issue #292.
1320
+ *
1321
+ * ⭐ **Why alpha is computed the old way, and why equal alpha short-circuits.**
1322
+ * `rasteriseQuad` and `rasteriseMesh` gate coverage on `alpha > 0.5`, so the
1323
+ * alpha arithmetic decides WHICH pixels are drawn β€” and through `frameGeometry`,
1324
+ * the framing box every reference frame was rendered inside. `lerpTap` on the
1325
+ * alpha channel is therefore the original expression, unchanged, not an
1326
+ * algebraically equal rearrangement: equal-but-rearranged is a last-bit
1327
+ * difference, and a last bit either side of 0.5 is a pixel.
1328
+ *
1329
+ * For the same reason the equal-alpha case returns early. When all four taps
1330
+ * carry one alpha, premultiplying by it and dividing it back out is the identity
1331
+ * β€” so the straight path is not an approximation there, it is the same number,
1332
+ * and taking it reproduces the five committed rungs BIT for bit rather than
1333
+ * merely closely. What moves is exactly the mixed-alpha tap: the edges, where the
1334
+ * rim was.
1335
+ */
1336
+ export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
1337
+ const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
1338
+ const a = lerpTap(c00[3], c10[3], c01[3], c11[3], fx, fy);
1339
+ if (c00[3] === c10[3] && c00[3] === c01[3] && c00[3] === c11[3]) {
1340
+ return [
1341
+ lerpTap(c00[0], c10[0], c01[0], c11[0], fx, fy),
1342
+ lerpTap(c00[1], c10[1], c01[1], c11[1], fx, fy),
1343
+ lerpTap(c00[2], c10[2], c01[2], c11[2], fx, fy),
1344
+ a,
1345
+ ];
1346
+ }
1347
+ // Every tap is transparent in some proportion that sums to nothing: there is no
1348
+ // colour to recover and no pixel to draw (both callers gate on alpha anyway).
1349
+ if (a <= 0) return [0, 0, 0, 0];
1350
+ const out: [number, number, number, number] = [0, 0, 0, a];
1351
+ for (let c = 0; c < 3; c++) {
1352
+ const pm = lerpTap(c00[c] * c00[3], c10[c] * c10[3], c01[c] * c01[3], c11[c] * c11[3], fx, fy);
1353
+ // Bounded by 255 in exact arithmetic β€” the weighted mean of the taps' colours
1354
+ // cannot exceed their maximum β€” so the clamp absorbs float error only. It is
1355
+ // here rather than trusted because `Plate`'s store is a `Uint8Array`, which
1356
+ // WRAPS: 256 would land as a black pixel in the brightest part of the art.
1357
+ out[c] = Math.min(255, pm / a);
1282
1358
  }
1283
1359
  return out;
1284
1360
  }
1285
1361
 
1362
+ /**
1363
+ * The same tap, each channel interpolated independently.
1364
+ *
1365
+ * ⚠️ For a plate whose fourth channel is **not opacity**. `src/pose.ts`'s
1366
+ * `materialPlate` keeps the frame's own RGB and rewrites alpha to mean "how much
1367
+ * material is here", so its colour and its fourth channel are decoupled by
1368
+ * construction β€” premultiplying by it would be a colour-space correction applied
1369
+ * to something that is not in that colour space. `errBilinear` reads its two
1370
+ * answers separately and combines them itself.
1371
+ *
1372
+ * Nothing that samples an ATLAS should call this. The rasteriser's source is
1373
+ * straight alpha and wants `bilinear`.
1374
+ */
1375
+ export function bilinearChannels(page: Plate, x: number, y: number): [number, number, number, number] {
1376
+ const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
1377
+ const out: [number, number, number, number] = [0, 0, 0, 0];
1378
+ for (let c = 0; c < 4; c++) out[c] = lerpTap(c00[c], c10[c], c01[c], c11[c], fx, fy);
1379
+ return out;
1380
+ }
1381
+
1286
1382
  export function fill(plate: Plate, colour: RGBA): void {
1287
1383
  for (let y = 0; y < plate.height; y++) for (let x = 0; x < plate.width; x++) plate.set(x, y, colour);
1288
1384
  }