rig-c 0.0.0-stage → 2.20.4

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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
@@ -0,0 +1,2958 @@
1
+ /**
2
+ * What a render is, whichever poser draws it — everything `./render.ts` held
3
+ * that does not link spine-core (issue #1052, step 4e of #380).
4
+ *
5
+ * Moved unchanged: the frame-set contract (`frames.json`, the contact sheet),
6
+ * the posing seam and its samplers, the candidate a render or a check loads
7
+ * and its poser choice, the geometry export, texture substitution, the framing
8
+ * and the rasteriser. `./render.ts` keeps what names the runtime — spine-core's
9
+ * implementation of the seam, loading a Spine export, the atlas class — and
10
+ * re-exports every name below that it exported before, so a dependant's import
11
+ * resolves where it always did. See `./render.ts`'s header for what the
12
+ * rasteriser draws and the conventions it owns; they are this file's now.
13
+ *
14
+ * ⭐ Where a function here reaches an input only the runtime can read — a
15
+ * Spine export, `--poser spine`, a fallback the poser line names, a skeleton
16
+ * spine-core already parsed — it asks the seam (`./spine_side.ts`) for the
17
+ * Spine side rather than importing it. The local functions under *the Spine
18
+ * side, through the seam* below carry the names the bodies always called, so
19
+ * the bodies read as they did; `./render.ts` registers the implementations
20
+ * when it is loaded. An entry that never loads it links nothing of the
21
+ * runtime here, and such an input is refused by name (`SpineRuntimeError`).
22
+ */
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { dirname, join, resolve } from 'node:path';
25
+ import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
26
+ import { pageFootprint, parseAtlasText } from './atlas.ts';
27
+ import { CoreInputError } from './core/index.ts';
28
+ import { computeUvs } from './core/uvs.ts';
29
+ import { ATLAS_SCALE_LINE, MODEL_DOCUMENT_FILE } from './model.ts';
30
+ import {
31
+ coreDocumentFacts,
32
+ corePoser,
33
+ SlotSubsetError,
34
+ subsetOver,
35
+ type CoreDocumentFacts,
36
+ type SubsetRoster,
37
+ } from './render_core.ts';
38
+ import { spinePosingFor, type SpineSkeletonData } from './spine_side.ts';
39
+ import { walkTimelines } from './timelines.ts';
40
+ import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // the Spine side, through the seam (issue #1052)
44
+ // ---------------------------------------------------------------------------
45
+ //
46
+ // ⭐ The names `./render.ts` gives these, so the bodies below call what they
47
+ // always called; each asks the seam for the side `./render.ts` registered and
48
+ // is refused by name (`SpineRuntimeError`) where nothing did. The first call
49
+ // on every path that reaches the runtime is `requireSpineRuntime`, so the
50
+ // refusal names the input and why it needed the runtime, as #1014 says it.
51
+
52
+ /** A skeleton spine-core parsed — opaque here (`SpineSkeletonData`); `./render.ts` reads it as the runtime's `SkeletonData`. */
53
+ type SkeletonData = SpineSkeletonData;
54
+
55
+ /** What the seam's refusals name a parsed skeleton handed straight to a sampler as. */
56
+ const PARSED_SKELETON = 'a skeleton spine-core parsed';
57
+ const PARSED_WHY = 'handed over already parsed';
58
+
59
+ /** Touch the runtime once, before anything is parsed through it, and refuse by name when it cannot be used. */
60
+ function requireSpineRuntime(label: string, why: string): void {
61
+ spinePosingFor(label, why).requireRuntime(label, why);
62
+ }
63
+
64
+ /** The skeleton parsed against its atlas through the runtime, a pair it cannot load being `refuse`'s refusal. */
65
+ function spineSkeletonData(skeletonText: string, atlasText: string, refuse: (runtime: string) => Error): SkeletonData {
66
+ return spinePosingFor(PARSED_SKELETON, 'loaded through its atlas').skeletonData(skeletonText, atlasText, refuse);
67
+ }
68
+
69
+ /** The facts as spine-core loaded them. */
70
+ function spineFacts(data: SkeletonData, atlasText: string | null): SkeletonFacts {
71
+ return spinePosingFor(PARSED_SKELETON, PARSED_WHY).facts(data, atlasText);
72
+ }
73
+
74
+ /** spine-core's poser over a parsed skeleton. */
75
+ function spinePoser(data: SkeletonData): Poser {
76
+ return spinePosingFor(PARSED_SKELETON, PARSED_WHY).poser(data);
77
+ }
78
+
79
+ /** The skin roster of a parsed skeleton, as the runtime flags it. */
80
+ function skinRosterOf(data: SkeletonData): SkinRoster {
81
+ return spinePosingFor(PARSED_SKELETON, PARSED_WHY).skinRoster(data);
82
+ }
83
+
84
+ /** The page names an atlas declares, as the runtime's atlas reader reads them. */
85
+ function atlasPageNames(atlasText: string): string[] {
86
+ return spinePosingFor('an atlas', "read through spine-core's atlas reader").atlasPageNames(atlasText);
87
+ }
88
+
89
+ /** An atlas's pages and regions as the runtime reads them, for a substitution. */
90
+ function spineSubstitution(atlasText: string): { pages: string[]; regions: Map<string, SubstituteRegion> } {
91
+ return spinePosingFor('a --texture-from atlas', "read through spine-core's atlas reader, for a candidate spine-core poses").substitution(atlasText);
92
+ }
93
+
94
+ /** `./render.ts`'s choice of posers over a skeleton it parsed itself (`candidatePosers`). */
95
+ export { choosePosers, pairRefusal, regionKey };
96
+
97
+ /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
98
+ export const BACKGROUND: RGBA = [232, 232, 232, 255];
99
+ /** Padding around the union bounding box, as a fraction of its long side. */
100
+ export const PAD = 0.04;
101
+ /**
102
+ * Directory a skeleton with no animation writes its one frame into.
103
+ *
104
+ * It cannot collide with an animation's directory, because an animation named
105
+ * `setup` would have to live in a skeleton that has at least one animation, and
106
+ * this name is only ever used when there are none.
107
+ */
108
+ export const SETUP_POSE_DIR = 'setup';
109
+ /**
110
+ * The sampling rate the ladder's briefs are written against.
111
+ *
112
+ * It is a constant rather than a bare `12` in the default because it is also the
113
+ * rate at which the directory name says nothing: a rung rendered at the protocol
114
+ * rate writes `<animation>/`, and any other rate writes `<animation>@<fps>fps/`.
115
+ */
116
+ export const PROTOCOL_FPS = 12;
117
+ /** The rate the framing box is measured at, whatever `--fps` writes frames at. */
118
+ export const FRAMING_FPS = 60;
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // the frame-set sidecar
122
+ // ---------------------------------------------------------------------------
123
+ //
124
+ // ⭐ A rendered frame set is a picture of a world box, and the box used to be
125
+ // nowhere. That cost two things. An author measuring a distance in pixels had no
126
+ // way to turn it into the units a rig is authored in except by finding something
127
+ // of a known size in the shot; and nothing could render a SECOND skeleton onto
128
+ // the same pixel grid, because the grid was a number that existed only inside one
129
+ // run of `render_reference.ts`. `frames.json` writes it down.
130
+
131
+ /** The sidecar's file name and format tag. */
132
+ export const FRAMES_SIDECAR = 'frames.json';
133
+ export const FRAMES_SPEC = 'rigc-frames/1';
134
+
135
+ /**
136
+ * The contact sheet beside a frame set, and the one number its layout needs.
137
+ *
138
+ * ⭐ A sheet is **part of the frame set**, not an illustration of it: a long shot
139
+ * commits a couple of stills and folds every sampled frame into one PNG, so for
140
+ * such a set the sheet is the only picture of the 309 frames in between, and
141
+ * `check` compares against its tiles (issue #36). That makes the layout a
142
+ * contract between two programs — `bench/render_reference.ts` writes the grid and
143
+ * `src/check.ts` reads it — so the column count lives here rather than in either.
144
+ *
145
+ * The tile SIZE is deliberately not here. It is a `--tile` choice per run, and a
146
+ * reader can measure it exactly off the sheet's own dimensions given the frame
147
+ * count and the column count (`check`'s `sheetGeometry` does), so recording it
148
+ * would be a second definition of something already written down in pixels.
149
+ */
150
+ export const SHEET_COLUMNS = 8;
151
+ /** The sheet's file name inside a frame directory. */
152
+ export const SHEET_FILE = 'contact.png';
153
+ /** One pixel of rule between tiles, and one around the outside. */
154
+ export const SHEET_GAP = 1;
155
+ /** Default long side of one contact-sheet tile, in pixels. */
156
+ export const SHEET_TILE = 128;
157
+ /** The rule between tiles, and the frame number drawn in each. */
158
+ export const SHEET_RULE: RGBA = [176, 176, 176, 255];
159
+ export const SHEET_LABEL: RGBA = [96, 96, 96, 255];
160
+
161
+ /** One rendered frame directory: which animation, at what rate, and what is on disk. */
162
+ export interface FrameSet {
163
+ /** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
164
+ dir: string;
165
+ /** The animation these frames show, or `null` for a skeleton with none. */
166
+ animation: string | null;
167
+ fps: number;
168
+ /** How many frames the animation sampled to at this rate. */
169
+ sampled: number;
170
+ /** How many were actually written (a stride writes fewer). */
171
+ written: number;
172
+ stride: number;
173
+ /**
174
+ * The last sampled frame's time, in seconds.
175
+ *
176
+ * ⚠️ Which indices are on disk is deliberately NOT recorded here. The
177
+ * directory is the only author of that fact, and a second copy of it in this
178
+ * file could only ever be the stale one.
179
+ */
180
+ duration: number;
181
+ }
182
+
183
+ export interface FramesSidecar {
184
+ spec: string;
185
+ example?: string;
186
+ rung?: string;
187
+ skeleton?: string;
188
+ /**
189
+ * The skin these frames were posed under, when one was asked for (issue #571).
190
+ *
191
+ * ⭐ **Absent is not `"default"`.** A render with no skin sets none — every
192
+ * slot resolves through `SkeletonData.defaultSkin` alone — and a frame set
193
+ * written before this field existed says nothing either, so the two are the
194
+ * same fact on disk and the field is omitted for both. That is what keeps
195
+ * every frame set in this repository byte-identical across this change, and it
196
+ * is why `check` can refuse a mismatch it can SEE (`skin` present and
197
+ * different, or present where the run asked for none) and can only NOTE the
198
+ * one it cannot (`skin` absent while the run asked for one).
199
+ */
200
+ skin?: string;
201
+ /**
202
+ * The slots these frames draw, when `render --slot` narrowed them to a subset
203
+ * (issue #835) — in the skeleton's draw order, whatever order they were named in.
204
+ *
205
+ * ⭐ Absent on a render of every slot, for the reason `skin` is: that is what
206
+ * every frame set written before this field existed says too, so the whole-rig
207
+ * render stays byte-identical and the key's presence is the claim. A frame set
208
+ * carrying this or `hidden` is a picture of PART of the rig, and `check` refuses
209
+ * it as a reference by name rather than scoring a whole candidate against it.
210
+ */
211
+ slots?: string[];
212
+ /** The slots these frames leave out, when `render --hide` named them — see `slots`. */
213
+ hidden?: string[];
214
+ /** The colour the frames were cleared to, straight RGBA 0..255. */
215
+ background: RGBA;
216
+ viewport: {
217
+ /** World box, y up, matching Spine's own coordinates. */
218
+ x: number;
219
+ y: number;
220
+ width: number;
221
+ height: number;
222
+ /** Frame pixels per world unit. */
223
+ scale: number;
224
+ pixelWidth: number;
225
+ pixelHeight: number;
226
+ };
227
+ sets: FrameSet[];
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // posing
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /** What every drawable has in common, whatever shape it is. */
235
+ export interface PieceCommon {
236
+ /**
237
+ * World-space vertex positions, `x, y` per vertex.
238
+ *
239
+ * ⭐ The one field the framing code reads, and the reason it is spelled the
240
+ * same on both shapes: a union over "every posed point" is a loop over this
241
+ * array in steps of two, and it does not need to know whether four numbers are
242
+ * a rectangle's corners or two hundred are a mesh's hull.
243
+ */
244
+ world: number[];
245
+ /** Slot colour x attachment colour, straight alpha, 0..1. */
246
+ tint: [number, number, number, number];
247
+ /**
248
+ * The slot's **dark** colour, 0..1 — the other half of Spine's two-colour
249
+ * tint, and absent on every slot that does not carry one.
250
+ *
251
+ * ⚠️ Absent rather than black, and that is the whole of why it is optional.
252
+ * `(0, 0, 0)` is a real dark colour and the identity of the blend, so the two
253
+ * spellings paint the same pixels — but the runtime distinguishes them
254
+ * (`SlotPose.darkColor` is `null` for a slot with no `dark`, and `Slot`'s
255
+ * constructor never allocates one), and a piece that carried a black default
256
+ * would take the two-colour path for every slot in every frame this
257
+ * repository renders. `undefined` is what keeps the arithmetic below off the
258
+ * ordinary case (issue #690).
259
+ *
260
+ * Three channels, not four: the format writes `dark` as `rrggbb` and
261
+ * `RGBA2Timeline` stores three dark channels. The alpha a shader reads on the
262
+ * dark colour is not a colour at all — see `tintChannel`.
263
+ */
264
+ dark?: [number, number, number];
265
+ /** The slot this was drawn for — what per-slot tracking is keyed by. */
266
+ slot: string;
267
+ /** The atlas page name this samples, so a multi-page atlas resolves. */
268
+ page: string;
269
+ /**
270
+ * What a **texture-only** substitution needs to re-seat this piece on another
271
+ * atlas — see `PieceTexture`.
272
+ *
273
+ * Absent unless `piecesOf` was asked for it, because it is a second copy of the
274
+ * UVs and every posed frame of every set is held in memory at once.
275
+ */
276
+ texture?: PieceTexture;
277
+ /**
278
+ * The page-UV rectangle this piece may sample, and no further — see `UvWindow`.
279
+ *
280
+ * Absent on a piece posed from its own atlas: its UVs cover its own region's
281
+ * rectangle exactly, so there is nothing to fence off. It is set by
282
+ * `substituteTexture`, where the piece's geometry spans an area of the original
283
+ * drawing that the substituting atlas may have trimmed away.
284
+ */
285
+ uvWindow?: UvWindow;
286
+ }
287
+
288
+ /**
289
+ * One piece's texture coordinates in the **original drawing's** own space, plus
290
+ * the name of the region it came from.
291
+ *
292
+ * ## Why original-art space and not the page's
293
+ *
294
+ * Page UVs are useless for substitution: they name texels in *this* atlas, and
295
+ * two atlases pack the same drawing at different places, at different scales, and
296
+ * possibly rotated or trimmed. What survives a repack is the position **within the
297
+ * drawing** — the coordinate an artist would point at — so that is the space a
298
+ * substitution goes through. `(0, 0)` is the untrimmed drawing's top-left corner
299
+ * and `(1, 1)` its bottom-right, which is the convention `spine-core`'s own
300
+ * `MeshAttachment.computeUVs` reads its `regionUVs` in; going through it is what
301
+ * lets `substituteTexture` reuse the runtime's rotation and trim arithmetic
302
+ * instead of holding a second opinion about it.
303
+ */
304
+ export interface PieceTexture {
305
+ /** The atlas region this piece samples, by the name its atlas gives it. */
306
+ region: string;
307
+ /** Original-art coordinates, `u, v` per vertex, parallel to `uvs`. */
308
+ artUvs: number[];
309
+ /**
310
+ * A clipped piece only (`Mesh.source`): the original-art UVs of each drawn
311
+ * triangle's SOURCE triangle, six numbers per drawn triangle, parallel to
312
+ * `Mesh.source.uvs` — what `substituteTexture` re-seats the source map with.
313
+ */
314
+ sourceArtUvs?: number[];
315
+ }
316
+
317
+ /** A page-UV rectangle outside which a piece samples nothing. */
318
+ export interface UvWindow {
319
+ u0: number;
320
+ v0: number;
321
+ u1: number;
322
+ v1: number;
323
+ }
324
+
325
+ /** Options for `piecesOf` and the samplers that call it. */
326
+ export interface PoseOptions {
327
+ /** Also record each piece's original-art UVs — see `PieceTexture`. */
328
+ texture?: boolean;
329
+ /**
330
+ * Also record every bone's world transform — see `BoneSnapshot` and
331
+ * `Frame.bones`. Off by default: nothing that draws needs it, and the
332
+ * one instrument that does (`bonedist.ts`) needs it on every frame.
333
+ */
334
+ bones?: boolean;
335
+ /**
336
+ * Also record every slot's attachment geometry, whole — see `AttachmentPose`
337
+ * and `Frame.attachments` (issue #864). Off by default for `bones`' reason:
338
+ * nothing that draws reads it, and `render --geometry` is the one caller.
339
+ *
340
+ * ⭐ **It is not filtered by `slots`/`hidden` and not cut by a clip.** Those
341
+ * are statements about which pixels are drawn; the geometry is a statement
342
+ * about where the pose put each attachment, and it is the same pose whatever
343
+ * a picture of it leaves out.
344
+ */
345
+ geometry?: boolean;
346
+ /**
347
+ * Pose under this skin, by the name the skeleton declares for it.
348
+ *
349
+ * ⭐ Absent means **no skin is set at all**, which is spine-core's own initial
350
+ * state (`Skeleton.skin` is null) and resolves every slot through
351
+ * `SkeletonData.defaultSkin` alone. That is not the same claim as "the default
352
+ * skin was chosen": it is the absence of a choice, and the two are spelled
353
+ * differently everywhere this travels — the frames sidecar omits the field
354
+ * rather than writing `"default"` into it (issue #571).
355
+ *
356
+ * ⚠️ Read by the SAMPLERS, never by `piecesOf`, which is handed a skeleton
357
+ * somebody else already posed; handing it one is refused by name rather than
358
+ * ignored, because a skin quietly dropped here is exactly the silence this
359
+ * whole flag exists to remove.
360
+ */
361
+ skin?: string;
362
+ /**
363
+ * Draw only these slots, by name (issue #835). `hidden` is the same statement
364
+ * the other way round, and the two together are refused.
365
+ *
366
+ * ⭐ **It is a filter on what is DRAWN, never on what is framed.**
367
+ * `framingViewport` takes both off before it samples, so a frame with `head`
368
+ * hidden sits on exactly the pixel grid of the frame with it and the two
369
+ * overlay — which is the whole use of the picture: *which part is this pixel*
370
+ * is answered by the difference between two frames of one grid, and a subset
371
+ * re-framed to its own extent would have no second frame to differ from.
372
+ *
373
+ * ⚠️ Applied in `piecesOf`, where the pieces are collected, and resolved there
374
+ * against the posed skeleton's own slots and skin — see `slotSubsetOf` — so a
375
+ * name that draws nothing is refused by name rather than quietly matching no
376
+ * piece.
377
+ */
378
+ slots?: string[];
379
+ /** Draw every slot but these — see `slots`. */
380
+ hidden?: string[];
381
+ /**
382
+ * Pose every attachment whole, with no clipping attachment applied — set by
383
+ * `framingViewport` and by nothing that draws.
384
+ *
385
+ * ⭐ **The framing box counts what a clip removes**, for the reason it counts
386
+ * what `--slot`/`--hide` leave out: the box is a property of the shot, and a
387
+ * clip is a statement about which pixels of it are drawn. Framed on the
388
+ * clipped geometry, a rig's viewport would move the moment a clip is added or
389
+ * keyed, and every frame set already on disk for it — `frames.json`'s world
390
+ * box, the grid `check` compares on — would stop describing the frames a
391
+ * second render writes.
392
+ */
393
+ unclipped?: boolean;
394
+ }
395
+
396
+ /**
397
+ * Why a slot subset cannot be drawn — `./render_core.ts` declares it, so the
398
+ * spine-core poser and the core poser throw one class (issue #968).
399
+ */
400
+ export { SlotSubsetError };
401
+
402
+ /**
403
+ * A slot subset resolved against a skeleton: which half was asked for, and the
404
+ * names in the skeleton's **draw order** rather than the order they were typed.
405
+ *
406
+ * Draw order because the names are a set and the sidecar records them: `--hide
407
+ * b,a` and `--hide a,b` are one picture, and a sidecar whose bytes depended on
408
+ * the spelling would make two identical frame sets differ.
409
+ */
410
+ export interface SlotSubset {
411
+ mode: 'slots' | 'hidden';
412
+ names: string[];
413
+ }
414
+
415
+ /**
416
+ * One bone's world transform in one posed frame.
417
+ *
418
+ * ⚠️ Read off `spine-core`'s own `BonePose` and derived by its own routines —
419
+ * `getWorldRotationX`, `getWorldScaleX` and friends — rather than recomputed
420
+ * from `a b c d` here. A second opinion about what a bone's world rotation *is*
421
+ * is exactly what an instrument comparing two skeletons must not carry: it
422
+ * would show up as a difference between the two rigs.
423
+ */
424
+ export interface BoneSnapshot {
425
+ name: string;
426
+ /** World origin. */
427
+ worldX: number;
428
+ worldY: number;
429
+ /**
430
+ * The world matrix's linear part, `[a b][c d]`. **Complete**: rotation, scale
431
+ * and shear all live in these four numbers, and they are dimensionless — they
432
+ * map a local offset to a world offset, both in world units.
433
+ */
434
+ a: number;
435
+ b: number;
436
+ c: number;
437
+ d: number;
438
+ /** The direction the bone points, in degrees CCW. */
439
+ rotationX: number;
440
+ /** The y axis's own direction — the pair with `rotationX` is where shear shows. */
441
+ rotationY: number;
442
+ /** Magnitudes, always positive. */
443
+ scaleX: number;
444
+ scaleY: number;
445
+ }
446
+
447
+ export interface Quad extends PieceCommon {
448
+ kind: 'region';
449
+ /** World-space corners, in spine-core's region order: bl, ul, ur, br (verified against computeWorldVertices — the 2026-09-03 run reconstructed this from measurement after the old comment cost it days). */
450
+ world: number[];
451
+ /** Page UVs for the same four corners. */
452
+ uvs: ArrayLike<number>;
453
+ }
454
+
455
+ /**
456
+ * A posed mesh attachment: world vertices, page UVs, and the triangulation.
457
+ *
458
+ * The vertices arrive from `MeshAttachment.computeWorldVertices`, which is the
459
+ * runtime's own routine and therefore the only place the weighting and deform
460
+ * arithmetic lives. Reimplementing either here would give `check` a second
461
+ * opinion about where a vertex is, and a second opinion is exactly what a gate
462
+ * must not have.
463
+ */
464
+ export interface Mesh extends PieceCommon {
465
+ kind: 'mesh';
466
+ /** Page UVs, `u, v` per vertex, parallel to `world`. */
467
+ uvs: ArrayLike<number>;
468
+ /** Vertex index triplets. */
469
+ triangles: ArrayLike<number>;
470
+ /**
471
+ * A piece a clip cut only: for each drawn triangle, in triangle order, the
472
+ * SOURCE triangle it was cut from — its three world corners and their page
473
+ * UVs, six numbers each per drawn triangle (`ClipSource`). The rasteriser
474
+ * samples such a triangle's pixels at the source triangle's affine UV map,
475
+ * so the picture does not depend on which convex pieces the clipper cut
476
+ * (issue #964). Absent on every piece no clip cut, whose path is unchanged.
477
+ */
478
+ source?: ClipSource;
479
+ }
480
+
481
+ /** The source triangles of a clipped piece's drawn triangles — see `Mesh.source`. */
482
+ export interface ClipSource {
483
+ /** Three world corners (`x, y` each) per drawn triangle. */
484
+ world: number[];
485
+ /** The page UVs of those corners, parallel to `world`. */
486
+ uvs: number[];
487
+ }
488
+
489
+ /** One drawable in a posed frame. */
490
+ export type Piece = Quad | Mesh;
491
+
492
+ export interface Frame {
493
+ /** Index within the sampled sequence — the number in `f0000.png`. */
494
+ index: number;
495
+ time: number;
496
+ /**
497
+ * Everything the frame draws, in draw order.
498
+ *
499
+ * Named `pieces` rather than `quads` since meshes joined it: a mesh is not a
500
+ * quad, and a field that says otherwise is the kind of name a reader trusts
501
+ * and then indexes `world[6]` through.
502
+ */
503
+ pieces: Piece[];
504
+ /**
505
+ * Every bone's world transform at this frame — present only when
506
+ * `PoseOptions.bones` asked for it, so a renderer neither pays for it nor
507
+ * sees a field it would have to ignore.
508
+ *
509
+ * ⭐ It rides on `Frame` rather than being sampled by a loop of its own so
510
+ * that the ladder's stage 3 and the reference frames step a skeleton through
511
+ * **one** recipe. `sampleAnimation`'s stepping order — `state.update`,
512
+ * `state.apply`, `skeleton.update`, `updateWorldTransform(Physics.update)`,
513
+ * and `Physics.reset` on the first frame alone — is a sequence two
514
+ * implementations would drift on, and a per-frame pose comparison that
515
+ * drifted from the renderer would report the drift as a difference between
516
+ * the two rigs.
517
+ */
518
+ bones?: BoneSnapshot[];
519
+ /**
520
+ * Every slot's attachment as the pose left it, in draw order — present only
521
+ * when `PoseOptions.geometry` asked for it (issue #864).
522
+ *
523
+ * ⚠️ Not `pieces` again. A piece is what gets DRAWN: `--slot`/`--hide` remove
524
+ * pieces and a clip replaces one with the clipper's own triangle list, whose
525
+ * vertices are not the attachment's and are not numbered like them. A
526
+ * consumer comparing a triangle's edges across frames needs vertex `i` to be
527
+ * the same vertex in every frame, so these are the attachment's own vertices,
528
+ * whole, for every slot that shows a region or a mesh.
529
+ *
530
+ * Read off the same skeleton at the same step as `pieces` and `bones`, which
531
+ * is what puts it on render's frame grid by construction rather than by a
532
+ * second derivation of it.
533
+ */
534
+ attachments?: AttachmentPose[];
535
+ }
536
+
537
+ /**
538
+ * One slot's region or mesh attachment in one posed frame (issue #864).
539
+ *
540
+ * `vertices` come from the runtime's own `computeWorldVertices` over the whole
541
+ * attachment — the call `pieceOf` makes, through the one helper both share —
542
+ * so skinning and deform live in spine-core and nowhere here.
543
+ */
544
+ export interface AttachmentPose {
545
+ slot: string;
546
+ /** The attachment's own name, which is what a deform or attachment timeline keys. */
547
+ attachment: string;
548
+ /**
549
+ * World positions, `x, y` per vertex, **y up**. A region's four corners are in
550
+ * spine-core's order — bottom-left, top-left, top-right, bottom-right — and a
551
+ * mesh's vertices in the attachment's own order, so index `i` names the same
552
+ * vertex in every frame.
553
+ */
554
+ vertices: number[];
555
+ /** Slot colour x attachment colour, straight alpha, 0..1 — the piece's `tint`, by the same arithmetic. */
556
+ color: [number, number, number, number];
557
+ }
558
+
559
+ /** Where the world sits in a frame: the four world numbers plus the scale. */
560
+ export interface Viewport {
561
+ minX: number;
562
+ minY: number;
563
+ maxX: number;
564
+ maxY: number;
565
+ /** Frame pixels per world unit. */
566
+ scale: number;
567
+ /** Frame size in pixels. */
568
+ width: number;
569
+ height: number;
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // what a render and a check read off a candidate besides its pose (issue #1014)
574
+ // ---------------------------------------------------------------------------
575
+ //
576
+ // ⭐ `render` and `check` read a handful of facts off the candidate before and
577
+ // beside the pose: the animation and skin names their flags are checked
578
+ // against, the slot subset's roster, whether a stage is declared, the bone
579
+ // tree `check` draws its chains from, the skin roster the framing reads, and
580
+ // the atlas pages the rasteriser samples. Until issue #1014 every one of them
581
+ // came off the skeleton spine-core had parsed, so a rigc build the core poses
582
+ // still loaded and ran the runtime before its poser was chosen. Now the choice
583
+ // comes first (`loadCandidate`), and a build the core poses reads each fact
584
+ // where it is written: the names, the tree, the subset's roster and the stage
585
+ // off the skeleton's own JSON (`skeletonFacts`), the pages off rigc's atlas
586
+ // reader, and the skin roster off the model document (`coreSkinRoster` in
587
+ // `./render_core.ts`). Every one of those readings was measured equal to the
588
+ // spine-core reading it replaces on every input the tree carries — the
589
+ // nineteen built corpus rows and the twelve editor exports (the PR of #1014
590
+ // carries the counts). A Spine export, `--poser spine` and a fallback the
591
+ // poser line names load spine-core as before and read every fact off it.
592
+ //
593
+ // Since issue #1020 a build the core poses reads what its model document
594
+ // states off the document (`coreFacts`): the bone tree, the slot list and the
595
+ // subset's roster, and the page images by the names its `pages` section gives
596
+ // — so with a `rigc-compiled/2` document the atlas file is not needed at all.
597
+ // Since issue #1026 a `rigc-compiled/3` document also states what only the
598
+ // Spine files held — the order the animations and skins are listed in (the
599
+ // emitter's, not the model's), the stage, and each page's `scale:` line — so
600
+ // a build the core poses reads every fact off its document. A `/2` or `/1`
601
+ // document's reader still takes those off `skeleton.json` and the atlas, and
602
+ // the poser line says so (`unstatedClause`).
603
+
604
+ /** What `render` and `check` read off a candidate's skeleton besides its pose. */
605
+ export interface SkeletonFacts {
606
+ /** Every animation name, in the skeleton's own order. */
607
+ readonly animations: readonly string[];
608
+ /** Every skin name, in the skeleton's own order. */
609
+ readonly skins: readonly string[];
610
+ /** Whether the header states a numeric `width` and `height` — a setup stage (issue #714). */
611
+ readonly declaresStage: boolean;
612
+ /** Every bone in declaration order, and its parent's name. */
613
+ readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
614
+ /** Every slot in declaration order, and the bone it hangs from. */
615
+ readonly slots: ReadonlyArray<{ name: string; bone: string }>;
616
+ /** `slotSubsetOf` over this skeleton — refused by `SlotSubsetError`. */
617
+ subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
618
+ /**
619
+ * The `scale:` lines the candidate's atlas declares (`atlasScales`), or, for
620
+ * a build posed from a `rigc-compiled/3` document, the ones its pages state
621
+ * (issue #1026); `null` where neither was read — a `/2` or `/1` build drawn
622
+ * with its atlas gone (issue #1020). `check`'s texture note reads it.
623
+ */
624
+ readonly atlasScales: readonly number[] | null;
625
+ }
626
+
627
+ type JsonObject = Record<string, unknown>;
628
+
629
+ /** A JSON value as an object, or an empty one where it is not one. */
630
+ function objectOf(value: unknown): JsonObject {
631
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
632
+ }
633
+
634
+ /** A JSON value as a list of objects, or an empty list where it is not one. */
635
+ function objectsOf(value: unknown): JsonObject[] {
636
+ return Array.isArray(value) ? value.map(objectOf) : [];
637
+ }
638
+
639
+ /**
640
+ * The same facts off the skeleton's own JSON — a candidate the core poses
641
+ * (issue #1014), whose skeleton spine-core never loads.
642
+ *
643
+ * Each is the file's own statement, read in the file's order: the animation
644
+ * names are the keys of `animations` (the order the parser iterates them), the
645
+ * skins `skins[].name` (the default skin the one named `default`), a slot's
646
+ * carriers the skins whose `attachments` give it at least one entry, the tree
647
+ * `bones[]` and `slots[]`, and the stage `skeleton.width`/`height`. The core
648
+ * poses only a rigc build whose `skeleton.json` hashes to the digest its model
649
+ * document records, so the file read here is the one `build` wrote and the
650
+ * gate round-tripped.
651
+ */
652
+ export function skeletonFacts(skeletonText: string, atlasText: string | null): SkeletonFacts {
653
+ const root = objectOf(JSON.parse(skeletonText));
654
+ const skins = objectsOf(root.skins);
655
+ const slots = objectsOf(root.slots).map((slot) => ({ name: String(slot.name), bone: String(slot.bone) }));
656
+ const header = objectOf(root.skeleton);
657
+ const roster: SubsetRoster = {
658
+ declared: slots.map((slot) => slot.name),
659
+ carriers: (slot) => skins.filter((skin) => Object.keys(objectOf(objectOf(skin.attachments)[slot])).length > 0).map((skin) => String(skin.name)),
660
+ defaultSkin: skins.some((skin) => skin.name === 'default') ? 'default' : null,
661
+ };
662
+ return {
663
+ atlasScales: atlasText === null ? null : atlasScales(atlasText),
664
+ animations: Object.keys(objectOf(root.animations)),
665
+ skins: skins.map((skin) => String(skin.name)),
666
+ declaresStage: typeof header.width === 'number' && typeof header.height === 'number',
667
+ bones: objectsOf(root.bones).map((bone) => ({ name: String(bone.name), parent: typeof bone.parent === 'string' ? bone.parent : null })),
668
+ slots,
669
+ subset: (opts, skin) => subsetOver(roster, opts, skin),
670
+ };
671
+ }
672
+
673
+ /**
674
+ * Each animation's duration off the skeleton's own JSON, in the file's order —
675
+ * what `rosterDifference` holds a model document's durations to without
676
+ * loading the skeleton through spine-core (issue #1014).
677
+ *
678
+ * ⚠️ Skeleton JSON carries no duration, so this is the parser's derivation of
679
+ * one, and it is stated as measured rather than as read: the LAST key time of
680
+ * each timeline, the largest of them, held as a float32 — equal to
681
+ * spine-core's `Animation.duration` on every animation of every input the tree
682
+ * carries (the PR of #1014). The timelines are the ones `walkTimelines` walks,
683
+ * the walk `A05` and `A12` stand on, so a group it did not descend would be
684
+ * missing from both.
685
+ */
686
+ function skeletonDurations(root: JsonObject): Array<{ name: string; duration: number }> {
687
+ return Object.entries(objectOf(root.animations)).map(([name, animation]) => {
688
+ let duration = 0;
689
+ walkTimelines({ animations: { [name]: animation } }, (_path, _kind, _timeline, keys) => {
690
+ if (keys.length === 0) return;
691
+ const last = objectOf(keys[keys.length - 1]);
692
+ duration = Math.max(duration, Math.fround(typeof last.time === 'number' ? last.time : 0));
693
+ });
694
+ return { name, duration };
695
+ });
696
+ }
697
+
698
+ /**
699
+ * Every page named, read from `atlasDir` by that name — the images a candidate
700
+ * is drawn from, whichever poser draws it. A page that is not there is
701
+ * `absent`'s refusal, handed the page's absolute path (issues #1033, #1042):
702
+ * left to `readPlate`, it surfaced as an ENOENT and a stack. A page that is
703
+ * there and is not a PNG says so itself (`readPlate`).
704
+ */
705
+ function pagesAt(names: readonly string[], atlasDir: string, absent: (page: string) => Error): Map<string, Plate> {
706
+ const pages = new Map<string, Plate>();
707
+ for (const name of names) {
708
+ const path = join(atlasDir, name);
709
+ if (!existsSync(path)) throw absent(resolve(path));
710
+ pages.set(name, readPlate(path));
711
+ }
712
+ return pages;
713
+ }
714
+
715
+ /**
716
+ * A candidate with no atlas beside it that has to be read through one —
717
+ * refused naming the file and why (issue #1020).
718
+ *
719
+ * A rigc build whose model document states where each region sits on its page
720
+ * (`rigc-compiled/2`, the `pages` section) is drawn by the core without its
721
+ * atlas. Everything else reads one: a Spine export, `--poser spine`, a
722
+ * `rigc-compiled/1` document, and a build the core refuses and spine-core
723
+ * draws instead. A class of its own so `cli.ts` refuses it as an invocation
724
+ * (exit 2, nothing written), as it refuses a missing atlas on an export.
725
+ */
726
+ export class CandidateAtlasError extends Error {}
727
+
728
+ /** The refusal for a candidate that has to be read through an atlas it does not have. */
729
+ function atlasAbsent(atlasPath: string, label: string, why: string): CandidateAtlasError {
730
+ return new CandidateAtlasError(
731
+ `nothing at ${atlasPath}: ${label} is drawn through its atlas (${why}). ` +
732
+ 'Only a rigc build whose skeleton.model.json is a rigc-compiled/2 or /3 document, posed by the core, is drawn ' +
733
+ 'without one — it states where each region sits on its page',
734
+ );
735
+ }
736
+
737
+ /**
738
+ * A candidate whose skeleton spine-core cannot load against the atlas beside
739
+ * it — refused naming both files, why the runtime was drawing them, and what
740
+ * the runtime could not resolve, in its own words (issue #1033).
741
+ *
742
+ * On a rigc build this is a directory whose files are not one build: a
743
+ * `skeleton.json` or a `skeleton.atlas` from another build put beside this
744
+ * one's `skeleton.model.json`. The core refuses the pair first (the digest, or
745
+ * the atlas's pages, on the poser line's reason), the fallback hands it to
746
+ * spine-core, and the runtime stops at the first name it cannot resolve —
747
+ * which surfaced as its own uncaught error and a stack. A class of its own so
748
+ * `cli.ts` refuses it as an invocation (exit 2, nothing written), as it
749
+ * refuses a missing atlas on an export: the files the command was pointed at
750
+ * have to change, not the rig.
751
+ *
752
+ * Since issue #1042 it is also `bonedist`'s (and `bench --bones`'), on either
753
+ * side (`loadPosedSkeleton`), and the refusal for a page a candidate is drawn
754
+ * from that is not there on every poser's path — the core's included, where a
755
+ * build moved whole away from where it was built died on `readPlate`'s ENOENT.
756
+ */
757
+ export class CandidatePairError extends Error {}
758
+
759
+ /** Where a candidate's files sit, as the refusals below name them: the atlas, and the directory when it holds a model document. */
760
+ function pairPlace(paths: { skeleton: string; atlas: string } | null): { atlas: string; build: string | null } {
761
+ const dir = paths === null ? null : dirname(resolve(paths.skeleton));
762
+ return { atlas: paths === null ? 'the atlas handed over with it' : resolve(paths.atlas), build: dir !== null && existsSync(join(dir, MODEL_DOCUMENT_FILE)) ? dir : null };
763
+ }
764
+
765
+ /** What a directory whose files are not one build is told to do. */
766
+ function notOneBuild(dir: string): string {
767
+ return (
768
+ `${dir} is not one build: ${MODEL_DOCUMENT_FILE}, skeleton.json and skeleton.atlas are written together by one \`rigc build\`, ` +
769
+ "and these are not one build's — build it again, or put that build's own files back beside each other"
770
+ );
771
+ }
772
+
773
+ /**
774
+ * The refusal for a skeleton the runtime could not load against its atlas:
775
+ * `runtime` is the runtime's own message, and `does` what spine-core was
776
+ * loading it to do — `draws` for `render` and `check`, `poses` for a command
777
+ * that reads the posed bones and draws nothing (`bonedist`, issue #1042).
778
+ */
779
+ function pairRefusal(
780
+ label: string,
781
+ paths: { skeleton: string; atlas: string } | null,
782
+ why: string,
783
+ runtime: string,
784
+ does: 'draws' | 'poses' = 'draws',
785
+ ): CandidatePairError {
786
+ const { atlas, build } = pairPlace(paths);
787
+ const head = `${label} does not load against ${atlas}: spine-core ${does} this pair (${why}) and could not resolve it — ${JSON.stringify(runtime)}. `;
788
+ return new CandidatePairError(head + (build === null ? 'The skeleton and the atlas are not one pair: the atlas has to be the one the skeleton was exported or built with' : notOneBuild(build)));
789
+ }
790
+
791
+ /**
792
+ * The refusal for a page a candidate is drawn from that is not there — the
793
+ * page's path is relative to the directory of the file that names it, so the
794
+ * file was written somewhere else. What the last clause may claim depends on
795
+ * what is known about the directory:
796
+ *
797
+ * - an export (no model document): only that the page has to be there;
798
+ * - a rigc build whose files the core REFUSED (issue #1033): an atlas copied
799
+ * from another build's directory, and the directory is not one build — the
800
+ * core's own reason, on the poser line's words, says so;
801
+ * - a rigc build the core ACCEPTED, or one `--poser spine` never asked the
802
+ * core about (issue #1042): the build was moved or copied away from where it
803
+ * was built. "Not one build" would be a guess there, and on a build moved
804
+ * whole — measured — a false one.
805
+ *
806
+ * `by` is the file that names the page (`null`: the atlas), `draws` what is
807
+ * drawn from it.
808
+ */
809
+ function pageRefusal(
810
+ page: string,
811
+ paths: { skeleton: string; atlas: string } | null,
812
+ why: string,
813
+ reading: { by: string | null; draws: string; refusedBuild: boolean },
814
+ ): CandidatePairError {
815
+ const { atlas, build } = pairPlace(paths);
816
+ // The poser line's reason, unless it is only the document's path — which the sentence has just named.
817
+ const because = why === reading.by ? '' : ` (${why})`;
818
+ const head =
819
+ `nothing at ${page}: ${reading.by ?? atlas} names it as a page, and ${reading.draws}${because}. ` +
820
+ `A page path is relative to the ${reading.by === null ? "atlas's own" : "build's"} directory`;
821
+ const tail =
822
+ build === null
823
+ ? ', and the page has to be there'
824
+ : reading.refusedBuild
825
+ ? `, so an atlas copied from another build's directory names pages that are not here. ${notOneBuild(build)}`
826
+ : `, so a build moved or copied away from the directory it was built in names pages that are not here — ` +
827
+ 'build it again where it is, or build it with --copy-images, which writes its pages beside it';
828
+ return new CandidatePairError(head + tail);
829
+ }
830
+
831
+ /** A candidate as `render` and `check` read it: the posers, the facts and the pages (issue #1014). */
832
+ export interface Candidate {
833
+ choice: PoserChoice;
834
+ facts: SkeletonFacts;
835
+ pages: Map<string, Plate>;
836
+ }
837
+
838
+ /**
839
+ * Load a candidate for `render` or `check`, choosing its poser FIRST (issue
840
+ * #1014): a rigc build the core poses reads its facts off its model document
841
+ * and its own JSON (`coreFacts`), its page images by the names the
842
+ * document's `pages` gives (a `rigc-compiled/1` document's, by its atlas's)
843
+ * and its skin roster off the document, and spine-core is never loaded for
844
+ * it; `input.atlasText` is `null` where the atlas file is not there, which only
845
+ * a `rigc-compiled/2` document the core poses is drawn without (issue #1020 —
846
+ * anything else is refused, `CandidateAtlasError`); anything else — a Spine export,
847
+ * `--poser spine`, a candidate handed over as text (`paths` null, `unplaced`
848
+ * the reason) — is loaded through spine-core as `posableFromText` always
849
+ * loaded it. The choice's spine-core poser stays unloaded until something
850
+ * reads it, which on a rigc build is a fallback the poser line names.
851
+ *
852
+ * `--poser core` on an input that cannot carry it is NOT refused here: the
853
+ * caller says it where it always has (`refuseUnchosen`), after the flags that
854
+ * refuse before it.
855
+ */
856
+ export function loadCandidate(
857
+ input: { skeletonText: string; atlasText: string | null; atlasDir: string; label: string },
858
+ paths: { skeleton: string; atlas: string } | null,
859
+ forced: PoserName | undefined,
860
+ options: { make?: MakeCorePoser; unplaced?: string } = {},
861
+ ): Candidate {
862
+ let data: SkeletonData | null = null;
863
+ let choice: PoserChoice | null = null;
864
+ // `null` is an atlas file that is not there (issue #1020): a candidate the core draws from its document needs none, and anything read through one is refused naming the file before the runtime is touched.
865
+ const atlasText = (why: string): string => {
866
+ if (input.atlasText === null) throw atlasAbsent(paths?.atlas ?? '(no atlas)', input.label, why);
867
+ return input.atlasText;
868
+ };
869
+ // A pair the runtime cannot load — a skeleton.json or an atlas from another build beside this one's document — is
870
+ // refused by name rather than surfacing the runtime's own throw and stack (issue #1033); `spineSkeletonData` is
871
+ // where the catch is.
872
+ // Under `--poser core` the runtime is loaded only for the facts the flags are checked against, and nothing would be
873
+ // drawn through it: the refusal that is true there is the flag's, the one `refuseUnchosen` would have said next.
874
+ const refusing = (refusal: CandidatePairError): Error =>
875
+ forced === 'core' && choice !== null && choice.core === null ? new PoserChoiceError(`--poser core: ${choice.why}`) : refusal;
876
+ const spineLoad = (text: string, why: string): SkeletonData =>
877
+ spineSkeletonData(input.skeletonText, text, (runtime) => refusing(pairRefusal(input.label, paths, why, runtime)));
878
+ const spineData = (): SkeletonData => {
879
+ if (data === null) {
880
+ const why = choice === null || choice.core !== null ? 'a fallback from the core poser' : choice.why;
881
+ const text = atlasText(why);
882
+ requireSpineRuntime(input.label, why);
883
+ data = spineLoad(text, why);
884
+ }
885
+ return data;
886
+ };
887
+ const chosen =
888
+ paths === null
889
+ ? { choice: posersOver(forced, null, forced === 'spine' ? '--poser spine' : (options.unplaced ?? 'no path to find a model document beside'), spineData), document: null }
890
+ : choosePosers(paths.skeleton, paths.atlas, input.atlasText, forced, spineData, options.make ?? corePoser);
891
+ choice = chosen.choice;
892
+ if (choice.core !== null && chosen.document !== null) {
893
+ // A rigc-compiled/2 document names its pages; a /1 document is posed only with its atlas beside it, which names them (`choosePosers`).
894
+ const named = chosen.document.pageNames;
895
+ const names = named ?? parseAtlasText(atlasText(choice.why)).pages.map((page) => page.name);
896
+ // A page the build names that is not here is a build moved away from where it was built (issue #1042): the
897
+ // core accepted these files as one build, so the refusal does not say they are not one.
898
+ const by = named === null || paths === null ? null : join(dirname(resolve(paths.skeleton)), MODEL_DOCUMENT_FILE);
899
+ const reading = { by, draws: 'the core draws this build from it', refusedBuild: false };
900
+ const accepted = choice.why;
901
+ const pages = pagesAt(names, input.atlasDir, (page) => pageRefusal(page, paths, accepted, reading));
902
+ return { choice, facts: coreFacts(input.skeletonText, input.atlasText, choice.core, chosen.document), pages };
903
+ }
904
+ const text = atlasText(choice.why);
905
+ requireSpineRuntime(input.label, choice.why);
906
+ // `posableFromText`'s two steps, in its order — every page the atlas declares, then the skeleton — each refusing
907
+ // by name where the pair is not one: a page that is not there (`pageRefusal`), a skeleton the runtime cannot load
908
+ // against the atlas (`spineLoad`). The directory is called "not one build" only where the core refused its files —
909
+ // `--poser spine` never asked the core, and a build moved whole is one build whose pages are elsewhere.
910
+ const reading = { by: null, draws: 'spine-core draws this pair through that atlas', refusedBuild: forced !== 'spine' };
911
+ const why = choice.why;
912
+ const pages = pagesAt(
913
+ atlasPageNames(text),
914
+ input.atlasDir,
915
+ (page) => refusing(pageRefusal(page, paths, why, reading)),
916
+ );
917
+ data = spineLoad(text, choice.why);
918
+ return { choice, facts: spineFacts(data, text), pages };
919
+ }
920
+
921
+ /**
922
+ * A candidate's facts when the core poses it (issue #1020): what the model
923
+ * document states, read from it — the bone tree and the slot list (the core
924
+ * poser's own, which `rosterDifference` held to the Spine file's before the
925
+ * core was chosen) and the slot subset's roster (`coreDocumentFacts`). Since
926
+ * issue #1026 a `rigc-compiled/3` document states the rest too — the order
927
+ * the animations and skins are listed in (the editor's, `editorOrder`),
928
+ * whether a stage is declared (`stage`) and its pages' `scale:` lines — and
929
+ * nothing is read off `skeleton.json` or the atlas. A `/2` or `/1` document
930
+ * states none of those, so they are read where they were before: the orders
931
+ * and the stage off the skeleton's own JSON, the scale lines off the atlas
932
+ * (`null` with it gone) — and the poser line says so (`unstatedClause`).
933
+ */
934
+ function coreFacts(skeletonText: string, atlasText: string | null, poser: Poser, document: CoreDocumentFacts): SkeletonFacts {
935
+ const unstated = (): Pick<SkeletonFacts, 'atlasScales' | 'animations' | 'skins' | 'declaresStage'> => {
936
+ const spine = skeletonFacts(skeletonText, atlasText);
937
+ return { atlasScales: spine.atlasScales, animations: spine.animations, skins: spine.skins, declaresStage: spine.declaresStage };
938
+ };
939
+ const read = document.stated === null ? unstated() : { ...document.stated, atlasScales: document.stated.scales };
940
+ return {
941
+ atlasScales: read.atlasScales,
942
+ animations: read.animations,
943
+ skins: read.skins,
944
+ declaresStage: read.declaresStage,
945
+ bones: poser.bones,
946
+ slots: poser.slots,
947
+ subset: (opts, skin) => subsetOver(document.subset, opts, skin),
948
+ };
949
+ }
950
+
951
+ // ---------------------------------------------------------------------------
952
+ // the posing seam (issue #965, step 3a of #380)
953
+ // ---------------------------------------------------------------------------
954
+ //
955
+ // ⭐ Everything this file reads off a posed skeleton goes through one
956
+ // interface, `Poser`, and the samplers below are written against it alone. The
957
+ // implementation behind it today is spine-core's (`spinePoser`, further down);
958
+ // the core-backed poser of issue #968 is a SECOND implementation of the same
959
+ // interface, handed to the same samplers, rather than a rewrite of them. What
960
+ // the interface fixes is exactly what a consumer of a posed frame reads:
961
+ //
962
+ // - the setup pose, under a skin or under none;
963
+ // - an animation's frames at `i/fps` for `i = 0..count`, one continuous
964
+ // trajectory stepped once per frame (the stepping recipe is the
965
+ // implementation's; the schedule — which `i`, what `count`, what time a
966
+ // frame is filed under — is the sampler's, and stays here);
967
+ // - per posed frame: the pieces in draw order (world vertices, page UVs,
968
+ // triangles, tint, dark, page, clip output), the bone snapshots, and every
969
+ // slot's whole attachment geometry;
970
+ // - the rest table, and the names a geometry file and a slot subset read.
971
+ //
972
+ // The framing samples are not a fourth entry: `framingViewport` is the same
973
+ // animation entry at `FRAMING_FPS` with the clip off.
974
+ //
975
+ // 🔒 What stays spine-core's and outside the seam, deliberately: an export's
976
+ // atlas (`posableFromText`'s pages, and `substituteTexture`'s region lookup on
977
+ // anything spine-core poses — a rigc build the core poses reads both through
978
+ // rigc's own reader since issue #1020) and `posedNumbersOf`, which `validate.ts` calls on a spine-core
979
+ // skeleton it stepped itself (A10's runtime supplier), so the round trip keeps its spine-core
980
+ // entry whatever poses the renders.
981
+
982
+ /** What a sampler hands a posed frame's draw walk — `PoseOptions` with the subset already resolved. */
983
+ export interface DrawOptions {
984
+ /** The slots drawn, resolved against the posed skeleton (`slotSubsetOf`); `undefined` draws every slot. */
985
+ subset: SlotSubset | undefined;
986
+ /** Every attachment whole, no clip applied — the framing box's reading. */
987
+ unclipped: boolean;
988
+ /** Also record each piece's original-art UVs — see `PieceTexture`. */
989
+ texture: boolean;
990
+ }
991
+
992
+ /**
993
+ * One posed moment, readable only while the `Poser` call that produced it is
994
+ * running: an implementation may step one skeleton in place, so a reader takes
995
+ * what it needs before the next frame is posed.
996
+ */
997
+ export interface Posed {
998
+ /** The drawables in draw order — see `piecesOf` for the walk and the clip. */
999
+ pieces(draw: DrawOptions): Piece[];
1000
+ /** Every bone's world transform, in the skeleton's declaration order. */
1001
+ bones(): BoneSnapshot[];
1002
+ /** Every slot's region or mesh attachment, whole, in the posed draw order. */
1003
+ attachments(): AttachmentPose[];
1004
+ }
1005
+
1006
+ /**
1007
+ * The posing seam: one skeleton, posed on demand.
1008
+ *
1009
+ * Named for what it does rather than for the runtime behind it, because the
1010
+ * point of the name is that there are two: `spinePoser` today, the core's at
1011
+ * #968. Every sampler, the framing, the geometry export and the non-finite
1012
+ * sentence take one (or a `SkeletonData`, which they wrap in `spinePoser`).
1013
+ */
1014
+ export interface Poser {
1015
+ /** Every animation, in declaration order, with its duration in seconds. */
1016
+ readonly animations: ReadonlyArray<{ name: string; duration: number }>;
1017
+ /** Every bone in declaration order, and its parent's name. */
1018
+ readonly bones: ReadonlyArray<{ name: string; parent: string | null }>;
1019
+ /** Every slot in declaration order, and the bone it hangs from. */
1020
+ readonly slots: ReadonlyArray<{ name: string; bone: string }>;
1021
+ /** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
1022
+ subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
1023
+ /** The setup pose under `skin` (absent: no skin set at all). */
1024
+ setup(skin: string | undefined): Posed;
1025
+ /**
1026
+ * Animation `name` under `skin`, not looping: `visit(i, posed)` for every
1027
+ * `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
1028
+ */
1029
+ animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
1030
+ /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
1031
+ rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
1032
+ }
1033
+
1034
+ /**
1035
+ * What every sampler takes: a poser, or spine-core's parsed skeleton, which is
1036
+ * posed through `spinePoser` — the seam's (`./render.ts` declares the same
1037
+ * union over the runtime's own `SkeletonData`).
1038
+ */
1039
+ export type PoseSource = Poser | SkeletonData;
1040
+
1041
+ /**
1042
+ * Whether a pose source is a `Poser` — told by the seam's own entries rather
1043
+ * than by `instanceof SkeletonData`, which reads the runtime's class and so
1044
+ * reached spine-core on every sample a rigc build took through the core
1045
+ * (issue #1014). A parsed skeleton carries none of the three.
1046
+ */
1047
+ function isPoser(source: PoseSource): source is Poser {
1048
+ const seam = source as Partial<Poser>;
1049
+ return typeof seam.setup === 'function' && typeof seam.animation === 'function' && typeof seam.rest === 'function';
1050
+ }
1051
+
1052
+ function poserOf(source: PoseSource): Poser {
1053
+ return isPoser(source) ? source : spinePoser(source);
1054
+ }
1055
+
1056
+ /** A sampler's `PoseOptions` as a draw walk reads them, the subset resolved once, on the first frame posed. */
1057
+ function drawResolver(poser: Poser, opts: PoseOptions | undefined): () => DrawOptions {
1058
+ let draw: DrawOptions | undefined;
1059
+ return () => {
1060
+ draw ??= { subset: poser.subset(opts, opts?.skin), unclipped: opts?.unclipped === true, texture: opts?.texture === true };
1061
+ return draw;
1062
+ };
1063
+ }
1064
+
1065
+ /** One frame off one posed moment, with what `opts` asked to record beside the pieces. */
1066
+ function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, opts: PoseOptions | undefined): Frame {
1067
+ return {
1068
+ index,
1069
+ time,
1070
+ pieces: posed.pieces(draw),
1071
+ ...(opts?.bones ? { bones: posed.bones() } : {}),
1072
+ ...(opts?.geometry ? { attachments: posed.attachments() } : {}),
1073
+ };
1074
+ }
1075
+
1076
+ /**
1077
+ * Sample one animation at a fixed rate and collect the posed pieces per frame.
1078
+ *
1079
+ * Frame `i` is the pose at `i/fps`, for `i = 0..round(duration·fps)` — the
1080
+ * schedule is this function's; how a pose reaches `i/fps` is the poser's.
1081
+ */
1082
+ export function sampleAnimation(source: PoseSource, name: string, fps: number, opts?: PoseOptions): Frame[] {
1083
+ const poser = poserOf(source);
1084
+ const animation = poser.animations.find((a) => a.name === name);
1085
+ if (!animation) {
1086
+ throw new Error(
1087
+ `no animation "${name}" in this skeleton; it has [${poser.animations.map((a) => a.name).join(', ') || 'none'}]`,
1088
+ );
1089
+ }
1090
+ const step = 1 / fps;
1091
+ const count = Math.round(animation.duration * fps);
1092
+ const draw = drawResolver(poser, opts);
1093
+ const frames: Frame[] = [];
1094
+ poser.animation(name, opts?.skin, fps, count, (i, posed) => frames.push(frameOf(i, i * step, posed, draw(), opts)));
1095
+ return frames;
1096
+ }
1097
+
1098
+ /**
1099
+ * The setup pose as a single frame — what a skeleton with **no animation at all**
1100
+ * looks like.
1101
+ *
1102
+ * ⭐ Not a degenerate case to be tolerated: a static rig is a deliverable. The
1103
+ * ladder's first rung ships one (`1-weight-and-mass`'s second export), and its
1104
+ * whole content is the setup pose.
1105
+ */
1106
+ export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
1107
+ const poser = poserOf(source);
1108
+ const posed = poser.setup(opts?.skin);
1109
+ return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
1110
+ }
1111
+
1112
+ /**
1113
+ * Every animation of one skeleton at one rate, keyed by the name its frames are
1114
+ * filed under. A skeleton with no animation at all contributes its setup pose
1115
+ * under `SETUP_POSE_DIR`.
1116
+ */
1117
+ export function sampleAll(source: PoseSource, fps: number, opts?: PoseOptions): Map<string, Frame[]> {
1118
+ const poser = poserOf(source);
1119
+ const out = new Map<string, Frame[]>();
1120
+ if (poser.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(poser, opts));
1121
+ else for (const animation of poser.animations) out.set(animation.name, sampleAnimation(poser, animation.name, fps, opts));
1122
+ return out;
1123
+ }
1124
+
1125
+ // ---------------------------------------------------------------------------
1126
+ // which poser a render poses through (issue #968, step 3d of #380)
1127
+ // ---------------------------------------------------------------------------
1128
+ //
1129
+ // ⭐ Two implementations of the seam, chosen by what the input carries and
1130
+ // never guessed: a rigc build writes `skeleton.model.json` beside the Spine
1131
+ // pair, and that document is what the core poses (`./render_core.ts`); a
1132
+ // Spine export (`bench/reference/*`, `examples/*/export/*`) has none and
1133
+ // poses through spine-core. The choice, and the reason for it, is returned
1134
+ // beside the result so the caller can say it (`render` prints it as its
1135
+ // `poser` line) — a render that fell back without saying so would be a
1136
+ // second opinion about the pose that nobody could see.
1137
+
1138
+ /** Which implementation of the seam posed a render: rigc's own core, or spine-core. */
1139
+ export type PoserName = 'core' | 'spine';
1140
+
1141
+ /** The `--poser` spellings, in the order the usage lists them. */
1142
+ export const POSER_NAMES: readonly PoserName[] = ['core', 'spine'];
1143
+
1144
+ /**
1145
+ * A `--poser` the input cannot carry — `--poser core` on a Spine export, or on
1146
+ * a build whose document the core refuses. A class of its own so `cli.ts`
1147
+ * refuses it as a usage error (exit 2, nothing written).
1148
+ */
1149
+ export class PoserChoiceError extends Error {}
1150
+
1151
+ /** Both posers for one input, and why the core one is or is not there. */
1152
+ export interface PoserChoice {
1153
+ /** What `--poser` asked for, or `undefined` for the input's own choice. */
1154
+ forced: PoserName | undefined;
1155
+ /** The core poser, or `null` when the input cannot carry it (`why`). */
1156
+ core: Poser | null;
1157
+ /** For a core poser, the document it poses; otherwise why there is none. */
1158
+ why: string;
1159
+ /**
1160
+ * spine-core's poser over the same skeleton — loaded the first time it is
1161
+ * read (issue #1014), which on a rigc build the core poses is a fallback the
1162
+ * poser line names, or never.
1163
+ */
1164
+ readonly spine: Poser;
1165
+ /**
1166
+ * The skin roster behind `poser` (`SkinRoster`): the model document's for
1167
+ * the core poser (`coreSkinRoster`), the parsed skeleton's for spine-core's
1168
+ * (`skinRosterOf`) — so a render the core poses reads its framing's roster
1169
+ * without loading the runtime (issue #1014).
1170
+ */
1171
+ rosterOf(poser: Poser): SkinRoster;
1172
+ }
1173
+
1174
+ /** What builds the core poser — `corePoser`, or a planted copy the suite passes (`RC02`, `CH01`). */
1175
+ export type MakeCorePoser = (modelText: string, atlasText: string, where: string, skeleton: { path: string; bytes: Uint8Array }) => Poser;
1176
+
1177
+ /** A skeleton's three rosters, as `rosterDifference` compares them. */
1178
+ interface SkeletonRosters {
1179
+ bones: ReadonlyArray<{ name: string; parent: string | null }>;
1180
+ slots: ReadonlyArray<{ name: string; bone: string }>;
1181
+ animations: ReadonlyArray<{ name: string; duration: number }>;
1182
+ /** Every skin name, in the skeleton's order — the roster `coreSkinRoster` names skins in. */
1183
+ skins: readonly string[];
1184
+ }
1185
+
1186
+ /**
1187
+ * The rosters off the skeleton's own JSON (issue #1014): its bones, slots and
1188
+ * skins as `skeletonFacts` reads them, and its animations' durations as
1189
+ * `skeletonDurations` derives them. Before #1014 these were spine-core's parse
1190
+ * of the same file; the two were measured equal on every input the tree
1191
+ * carries.
1192
+ */
1193
+ function skeletonRosters(skeletonText: string): SkeletonRosters {
1194
+ const facts = skeletonFacts(skeletonText, null);
1195
+ return { bones: facts.bones, slots: facts.slots, animations: skeletonDurations(objectOf(JSON.parse(skeletonText))), skins: facts.skins };
1196
+ }
1197
+
1198
+ /**
1199
+ * The first way a model document's rosters differ from the Spine skeleton they
1200
+ * sit beside, or `null` — a document from another build would pose another
1201
+ * rig in the same files' name, so it is refused rather than drawn.
1202
+ */
1203
+ function rosterDifference(core: Poser, skeleton: SkeletonRosters): string | null {
1204
+ const bones = (list: ReadonlyArray<{ name: string; parent: string | null }>): string => list.map((b) => `${b.name}<${b.parent ?? ''}`).join('|');
1205
+ const slots = (list: ReadonlyArray<{ name: string; bone: string }>): string => list.map((x) => `${x.name}@${x.bone}`).join('|');
1206
+ if (bones(core.bones) !== bones(skeleton.bones)) return 'the bones (names, parents or order) differ';
1207
+ if (slots(core.slots) !== slots(skeleton.slots)) return 'the slots (names, bones or draw order) differ';
1208
+ const animations = (list: ReadonlyArray<{ name: string; duration: number }>): string =>
1209
+ list.map((a) => `${a.name}=${a.duration}`).sort().join('|');
1210
+ if (animations(core.animations) !== animations(skeleton.animations)) return 'the animations (names or durations) differ';
1211
+ return null;
1212
+ }
1213
+
1214
+ /**
1215
+ * A `PoserChoice` over a core poser (or none) and a skeleton spine-core loads
1216
+ * only when something reads its side — `spine`, or the roster behind it.
1217
+ */
1218
+ function posersOver(
1219
+ forced: PoserName | undefined,
1220
+ core: { poser: Poser; roster: SkinRoster } | null,
1221
+ why: string,
1222
+ spineData: () => SkeletonData,
1223
+ ): PoserChoice {
1224
+ let spine: Poser | null = null;
1225
+ let spineRoster: SkinRoster | null = null;
1226
+ return {
1227
+ forced,
1228
+ core: core === null ? null : core.poser,
1229
+ why,
1230
+ get spine(): Poser {
1231
+ spine ??= spinePoser(spineData());
1232
+ return spine;
1233
+ },
1234
+ rosterOf: (poser) => {
1235
+ if (core !== null && poser === core.poser) return core.roster;
1236
+ spineRoster ??= skinRosterOf(spineData());
1237
+ return spineRoster;
1238
+ },
1239
+ };
1240
+ }
1241
+
1242
+ /**
1243
+ * The choice `candidatePosers` and `loadCandidate` share, refusing nothing:
1244
+ * the core poser when `skeleton.model.json` sits beside the skeleton, the
1245
+ * skeleton's bytes hash to the digest the document records (`spine.sha256`:
1246
+ * it is the file that build wrote, not one edited after it), the atlas is the
1247
+ * one beside it too, the core reads both and the document's rosters are the
1248
+ * skeleton's — read off the skeleton's own JSON (`skeletonRosters`), so
1249
+ * choosing loads nothing through spine-core (issue #1014).
1250
+ */
1251
+ function choosePosers(
1252
+ skeletonPath: string,
1253
+ atlasPath: string,
1254
+ /** The atlas file's text, or `null` when there is no file at `atlasPath` (issue #1020). */
1255
+ atlasText: string | null,
1256
+ forced: PoserName | undefined,
1257
+ spineData: () => SkeletonData,
1258
+ make: MakeCorePoser,
1259
+ ): { choice: PoserChoice; document: CoreDocumentFacts | null } {
1260
+ const dir = dirname(resolve(skeletonPath));
1261
+ const modelPath = join(dir, MODEL_DOCUMENT_FILE);
1262
+ let core: { poser: Poser; roster: SkinRoster } | null = null;
1263
+ let document: CoreDocumentFacts | null = null;
1264
+ let why: string;
1265
+ if (forced === 'spine') why = '--poser spine';
1266
+ else if (!existsSync(modelPath)) why = `no ${MODEL_DOCUMENT_FILE} beside ${resolve(skeletonPath)} — a Spine export, not a rigc build`;
1267
+ else if (dirname(resolve(atlasPath)) !== dir) {
1268
+ why =
1269
+ `the atlas ${resolve(atlasPath)} is not beside ${modelPath}: the document's region trims are its own build's ` +
1270
+ "atlas's, and the core would pose them against another one's pages";
1271
+ } else {
1272
+ try {
1273
+ const modelText = readFileSync(modelPath, 'utf8');
1274
+ const bytes = readFileSync(skeletonPath);
1275
+ // No atlas is `''`: a rigc-compiled/2 document draws from its own `pages`, and a /1 document is refused by name (`placementOf`). An atlas that is there is held to the document's `pages` (#1016) and refused, naming the first difference, when it is not the one the build wrote.
1276
+ const candidate = make(modelText, atlasText ?? '', modelPath, { path: resolve(skeletonPath), bytes });
1277
+ const rosters = skeletonRosters(bytes.toString('utf8'));
1278
+ const differs = rosterDifference(candidate, rosters);
1279
+ if (differs === null) {
1280
+ document = coreDocumentFacts(modelText, modelPath, rosters.skins);
1281
+ core = { poser: candidate, roster: document.roster };
1282
+ // The poser line names where the placement came from when it is not the document's own (issue #1020): a /1 document states none, and the core reads it from the atlas beside it.
1283
+ // And, since issue #1026, where the orders, the stage and the scale lines came from when the document does not state them: a /2 or /1 document's are read off the files beside it.
1284
+ const unstated = document.stated === null ? unstatedClause(resolve(skeletonPath), atlasText === null ? null : resolve(atlasPath)) : '';
1285
+ why =
1286
+ document.pageNames === null
1287
+ ? `${modelPath} — a ${document.spec} document, which does not state where each region sits on its page: that is read from ${resolve(atlasPath)}; nor ${unstated}`
1288
+ : document.stated === null
1289
+ ? `${modelPath} — a ${document.spec} document, which does not state ${unstated}`
1290
+ : modelPath;
1291
+ } else why = `${modelPath} does not describe ${resolve(skeletonPath)}: ${differs}`;
1292
+ } catch (err) {
1293
+ if (!(err instanceof CoreInputError)) throw err;
1294
+ // The reader names the document itself (`readModel`'s `where`), so a message that already starts with its path is not given it twice.
1295
+ why = `the core refused ${err.message.startsWith(`${modelPath}: `) ? err.message : `${modelPath}: ${err.message}`}`;
1296
+ }
1297
+ }
1298
+ return { choice: posersOver(forced, core, why, spineData), document: core === null ? null : document };
1299
+ }
1300
+
1301
+ /**
1302
+ * What a `rigc-compiled/2` or `/1` document does not state and a render or a
1303
+ * check reads off the files beside it instead (issue #1026), as the poser line
1304
+ * says it: the order the skeleton lists its skins and animations in and its
1305
+ * stage, off `skeleton`, and its pages' `scale:` lines, off `atlas` — or not
1306
+ * at all where no atlas is beside it (issue #1020).
1307
+ */
1308
+ function unstatedClause(skeleton: string, atlas: string | null): string {
1309
+ return (
1310
+ `the order its skins and animations are listed in or its stage, read from ${skeleton}, ` +
1311
+ `or its pages' scale: lines, ${atlas === null ? 'not read — no atlas is beside it' : `read from ${atlas}`}`
1312
+ );
1313
+ }
1314
+
1315
+ /**
1316
+ * `--poser core` on an input that cannot carry it, refused by name
1317
+ * (`PoserChoiceError`) — said by the caller where its refusals have always
1318
+ * stood, after the flags that refuse before it. Returns the choice otherwise.
1319
+ */
1320
+ export function refuseUnchosen(choice: PoserChoice): PoserChoice {
1321
+ if (choice.forced === 'core' && choice.core === null) throw new PoserChoiceError(`--poser core: ${choice.why}`);
1322
+ return choice;
1323
+ }
1324
+
1325
+ /**
1326
+ * `run` through the chosen poser: the core one when there is one, and
1327
+ * spine-core otherwise — or when the core refuses the input partway
1328
+ * (`CoreInputError`, e.g. a clip polygon that is not simple), in
1329
+ * which case `run` starts again from nothing on spine-core and `note` names the
1330
+ * refusal. Under `--poser core` that refusal is a `PoserChoiceError` instead.
1331
+ * `run` must write nothing: a fallback re-runs it whole. It is handed the
1332
+ * skin roster behind the poser it runs (`PoserChoice.rosterOf`), so a run
1333
+ * through the core reads nothing through spine-core (issue #1014).
1334
+ */
1335
+ export function throughPoser<T>(choice: PoserChoice, run: (poser: Poser, roster: SkinRoster) => T): { value: T; poser: PoserName; note: string } {
1336
+ const through = (poser: Poser): T => run(poser, choice.rosterOf(poser));
1337
+ if (choice.core !== null) {
1338
+ try {
1339
+ return { value: through(choice.core), poser: 'core', note: `rigc core — ${choice.why}` };
1340
+ } catch (err) {
1341
+ if (!(err instanceof CoreInputError)) throw err;
1342
+ if (choice.forced === 'core') throw new PoserChoiceError(`--poser core: the core refused this input: ${err.message}`);
1343
+ return { value: through(choice.spine), poser: 'spine', note: `spine-core — the core refused this input: ${err.message}` };
1344
+ }
1345
+ }
1346
+ return { value: through(choice.spine), poser: 'spine', note: `spine-core — ${choice.why}` };
1347
+ }
1348
+
1349
+ // ---------------------------------------------------------------------------
1350
+ // the geometry export — `render --geometry` (issue #864)
1351
+ // ---------------------------------------------------------------------------
1352
+ //
1353
+ // ⭐ A frame set is pixels, and two judgements a consumer that does not link
1354
+ // spine-core wants to make are not about pixels: how far a mesh triangle is
1355
+ // stretched over its rest shape, and whether a region holds still in its own
1356
+ // bone's frame. Both need the numbers the pose was drawn FROM. So `render` writes
1357
+ // them beside the pictures, off the very `Frame`s it drew — one call, one frame
1358
+ // grid, one viewport — rather than a second command re-deriving any of the three.
1359
+
1360
+ /** The export's file name inside an animation's frame directory, and its format tag. */
1361
+ export const GEOMETRY_FILE = 'geometry.json';
1362
+ export const GEOMETRY_SPEC = 'rigc-geometry/1';
1363
+ /** Stated in the file, so a reader cannot take the numbers for frame pixels. */
1364
+ export const GEOMETRY_COORDINATES = 'spine world, y up, world units';
1365
+
1366
+ /** A geometry file's frame: `Frame` reduced to the numbers the export promises. */
1367
+ export interface GeometryFrame {
1368
+ index: number;
1369
+ time: number;
1370
+ bones: GeometryBone[];
1371
+ attachments: AttachmentPose[];
1372
+ }
1373
+
1374
+ /** One bone's world transform: `world = [a b; c d]·local + (worldX, worldY)`. */
1375
+ export interface GeometryBone {
1376
+ name: string;
1377
+ a: number;
1378
+ b: number;
1379
+ c: number;
1380
+ d: number;
1381
+ worldX: number;
1382
+ worldY: number;
1383
+ }
1384
+
1385
+ /**
1386
+ * One attachment's rest geometry and its topology — the half of a stretch ratio
1387
+ * no frame carries.
1388
+ *
1389
+ * ⭐ **Rest is the setup pose's bones with no deform**, taken for every
1390
+ * attachment any frame of the file shows — including one the setup pose does not
1391
+ * show, which a slot only swaps to later. For an attachment the setup pose does
1392
+ * show, these vertices are the `setup` entry's own, bit for bit: both come off
1393
+ * one skeleton posed by `setupPosed`.
1394
+ */
1395
+ export interface AttachmentRest {
1396
+ slot: string;
1397
+ attachment: string;
1398
+ kind: 'region' | 'mesh';
1399
+ vertices: number[];
1400
+ /** Vertex index triplets. A region's are the runtime's own `0 1 2 2 3 0`. */
1401
+ triangles: number[];
1402
+ /** A mesh's hull vertex count — the first `hull` vertices, as the format's `hull` field counts them. */
1403
+ hull?: number;
1404
+ /** A mesh's `uvs`, `u, v` per vertex over the untrimmed drawing, y down — the attachment's own, not a page's. */
1405
+ uvs?: number[];
1406
+ }
1407
+
1408
+ export interface GeometryFile {
1409
+ spec: string;
1410
+ coordinates: string;
1411
+ /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
1412
+ animation: string | null;
1413
+ skin?: string;
1414
+ fps: number;
1415
+ /** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
1416
+ viewport: FramesSidecar['viewport'];
1417
+ /** Every bone in the skeleton's declaration order, and its parent's name. */
1418
+ bones: Array<{ name: string; parent: string | null }>;
1419
+ /**
1420
+ * Every slot in the skeleton's declaration order, and the bone it hangs from —
1421
+ * which is the bone whose frame "still in its own bone's frame" is read in.
1422
+ */
1423
+ slots: Array<{ name: string; bone: string }>;
1424
+ rest: AttachmentRest[];
1425
+ /** The setup pose, sampled the way `sampleSetupPose` samples it. */
1426
+ setup: Omit<GeometryFrame, 'index' | 'time'>;
1427
+ frames: GeometryFrame[];
1428
+ }
1429
+
1430
+ /**
1431
+ * A pose holding a number that is not finite — refused by the bone or the vertex
1432
+ * that holds it, and the value, rather than drawn, framed or written.
1433
+ *
1434
+ * Two callers throw it, with one sentence between them (`nonFiniteSentence`):
1435
+ * the geometry export, because `JSON.stringify` writes `NaN` and `Infinity` as
1436
+ * `null` and a consumer would read a hole in the geometry as a vertex at
1437
+ * nothing; and `framingViewport`, because a box over an infinite vertex is no
1438
+ * box at all (issue #873). Before that second caller the framing answered `null`
1439
+ * for it, which `render` prints as "posed no drawable attachment" — true of a
1440
+ * skeleton that draws nothing and false of this one, whose attachment is there
1441
+ * and posed to a number no picture can hold.
1442
+ */
1443
+ export class GeometryError extends Error {}
1444
+
1445
+ /** `frames.json`'s viewport block for `v` — the one spelling both files use. */
1446
+ export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
1447
+ return {
1448
+ x: v.minX,
1449
+ y: v.minY,
1450
+ width: v.maxX - v.minX,
1451
+ height: v.maxY - v.minY,
1452
+ scale: v.scale,
1453
+ pixelWidth: v.width,
1454
+ pixelHeight: v.height,
1455
+ };
1456
+ }
1457
+
1458
+ /**
1459
+ * The geometry file for one frame set — `frames` exactly as a sampler returned
1460
+ * them with `{ bones: true, geometry: true }`, which is what makes its grid the
1461
+ * frame set's own.
1462
+ *
1463
+ * Refused by `GeometryError`, naming the frame, the slot, the attachment and the
1464
+ * vertex (or the bone), when any number in it is not finite.
1465
+ */
1466
+ export function geometryFileOf(
1467
+ source: PoseSource,
1468
+ animation: string | null,
1469
+ fps: number,
1470
+ frames: Frame[],
1471
+ viewport: Viewport,
1472
+ skin: string | undefined,
1473
+ ): GeometryFile {
1474
+ const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
1475
+ if (frame.bones === undefined || frame.attachments === undefined) {
1476
+ throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
1477
+ }
1478
+ return {
1479
+ bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
1480
+ attachments: frame.attachments,
1481
+ };
1482
+ };
1483
+ const posed = frames.map((frame) => ({
1484
+ index: frame.index,
1485
+ time: frame.time,
1486
+ ...geometryFrame(frame, `frame ${frame.index}`),
1487
+ }));
1488
+ const poser = poserOf(source);
1489
+ const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
1490
+ const setup = geometryFrame(setupFrame, 'the setup pose');
1491
+ const file: GeometryFile = {
1492
+ spec: GEOMETRY_SPEC,
1493
+ coordinates: GEOMETRY_COORDINATES,
1494
+ animation,
1495
+ ...(skin === undefined ? {} : { skin }),
1496
+ fps,
1497
+ viewport: sidecarViewport(viewport),
1498
+ bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
1499
+ slots: poser.slots.map(({ name, bone }) => ({ name, bone })),
1500
+ rest: poser.rest(skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
1501
+ setup,
1502
+ frames: posed,
1503
+ };
1504
+ refuseNonFinite(file);
1505
+ return file;
1506
+ }
1507
+
1508
+ /**
1509
+ * How a sampled frame is named in that sentence: the animation, the frame's
1510
+ * index at the rate it was sampled and its time — or the bare index for the one
1511
+ * frame of a skeleton with no animation, whose setup pose is checked first.
1512
+ */
1513
+ function frameWhere(animation: string | null, index: number, time: number, fps: number): string {
1514
+ if (animation === null) return `frame ${index}`;
1515
+ return `animation ${JSON.stringify(animation)} frame ${index} at ${fps} fps (t=${time.toFixed(4)}s)`;
1516
+ }
1517
+
1518
+ /** One sampled frame's numbers, under the name the sentence gives it. */
1519
+ interface NamedPose {
1520
+ where: string;
1521
+ attachments: readonly PosedVertices[];
1522
+ bones: readonly WorldTransform[];
1523
+ }
1524
+
1525
+ /**
1526
+ * ⭐ **The one derivation of the non-finite sentence** (issue #873): the setup
1527
+ * pose, then every frame in order, then the rest table. The export and the
1528
+ * framing both reach it, so `render` and `render --geometry` refuse one planted
1529
+ * overflow in the same words.
1530
+ *
1531
+ * The setup pose first because a setup bone that overflowed is named there as
1532
+ * the BONE, and the rest table — the setup's bones with no deform — would name
1533
+ * the same fault one step on, as a vertex. Rest is still checked, last: it holds
1534
+ * attachments the setup pose does not show.
1535
+ */
1536
+ function nonFiniteSentence(
1537
+ setup: Pick<NamedPose, 'attachments' | 'bones'>,
1538
+ frames: readonly NamedPose[],
1539
+ rest: readonly PosedVertices[],
1540
+ ): string | null {
1541
+ const first = firstNonFinite('the setup pose', setup.attachments, setup.bones);
1542
+ if (first !== null) return first;
1543
+ for (const frame of frames) {
1544
+ const found = firstNonFinite(frame.where, frame.attachments, frame.bones);
1545
+ if (found !== null) return found;
1546
+ }
1547
+ return firstNonFinite('the rest table', rest, []);
1548
+ }
1549
+
1550
+ /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
1551
+ function refuseNonFinite(file: GeometryFile): void {
1552
+ const sentence = nonFiniteSentence(
1553
+ file.setup,
1554
+ file.frames.map((frame) => ({ ...frame, where: frameWhere(file.animation, frame.index, frame.time, file.fps) })),
1555
+ file.rest,
1556
+ );
1557
+ if (sentence !== null) throw new GeometryError(sentence);
1558
+ }
1559
+
1560
+ /**
1561
+ * Where a skeleton's pose is not finite, as the sentence the geometry export
1562
+ * refuses on — or `null` when every bone and vertex of it is finite.
1563
+ *
1564
+ * Each set is sampled at its own rate, with its bones and whole attachments —
1565
+ * `animation: null` is the setup pose alone — so the frame it names is a frame of
1566
+ * the caller's own grid. It samples again rather than taking the caller's
1567
+ * frames, because a caller that only draws sampled no bones; it is run only
1568
+ * once the caller has found a number that is not finite, so a finite pose never
1569
+ * pays for it.
1570
+ */
1571
+ export function nonFinitePoseOf(
1572
+ source: PoseSource,
1573
+ skin: string | undefined,
1574
+ sets: ReadonlyArray<{ animation: string | null; fps: number }>,
1575
+ ): string | null {
1576
+ const poser = poserOf(source);
1577
+ const opts: PoseOptions = { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true };
1578
+ // Every attachment list the frames showed, for the rest table's roster.
1579
+ const shown: AttachmentPose[][] = [];
1580
+ const named = (frame: Frame, where: string): NamedPose => {
1581
+ if (frame.bones === undefined || frame.attachments === undefined) {
1582
+ throw new Error(`${where} was sampled without { bones: true, geometry: true }`);
1583
+ }
1584
+ shown.push(frame.attachments);
1585
+ return { where, attachments: frame.attachments, bones: frame.bones };
1586
+ };
1587
+ const setup = named(sampleSetupPose(poser, opts)[0], 'the setup pose');
1588
+ const frames = sets.flatMap(({ animation, fps }) =>
1589
+ animation === null
1590
+ ? []
1591
+ : sampleAnimation(poser, animation, fps, opts).map((frame) =>
1592
+ named(frame, frameWhere(animation, frame.index, frame.time, fps)),
1593
+ ),
1594
+ );
1595
+ // Not read unless the setup pose and every frame are finite: `restOf` poses
1596
+ // what they show, and would be posing the same overflow a third time.
1597
+ const found = nonFiniteSentence(setup, frames, []);
1598
+ if (found !== null) return found;
1599
+ return nonFiniteSentence({ attachments: [], bones: [] }, [], poser.rest(skin, shown));
1600
+ }
1601
+
1602
+ /**
1603
+ * The file's text: a JSON object whose header fields sit one per line and whose
1604
+ * `rest` entries and `frames` sit one per line each, so a diff of two exports
1605
+ * names the frame that moved.
1606
+ *
1607
+ * Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
1608
+ * them — the shortest decimal that reads back as the same double, fixed by the
1609
+ * language rather than a locale — so a vertex in the file IS the runtime's
1610
+ * vertex, not a rounding of it.
1611
+ */
1612
+ export function geometryText(file: GeometryFile): string {
1613
+ const lines: string[] = [];
1614
+ const entries = Object.entries(file);
1615
+ entries.forEach(([key, value], i) => {
1616
+ const comma = i + 1 < entries.length ? ',' : '';
1617
+ if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
1618
+ if (value.length === 0) {
1619
+ lines.push(` ${JSON.stringify(key)}: []${comma}`);
1620
+ return;
1621
+ }
1622
+ lines.push(` ${JSON.stringify(key)}: [`);
1623
+ value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
1624
+ lines.push(` ]${comma}`);
1625
+ return;
1626
+ }
1627
+ lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
1628
+ });
1629
+ return `{\n${lines.join('\n')}\n}\n`;
1630
+ }
1631
+
1632
+ /**
1633
+ * The name two atlases have to agree on for a substitution to find a region.
1634
+ *
1635
+ * Trimmed, because `TextureAtlas` names a region after the raw line it was read
1636
+ * from — so the same region in a file written with CRLF and one without would be
1637
+ * two different strings, and a substitution would report every region unmatched
1638
+ * for a reason that is invisible in both files. The index is folded in because a
1639
+ * sequence packs several regions under one name and `findRegion` returns only the
1640
+ * first of them.
1641
+ */
1642
+ function regionKey(region: { name: string; index: number }): string {
1643
+ return `${region.name.trim()}#${region.index}`;
1644
+ }
1645
+
1646
+ /**
1647
+ * Page names of a substituting atlas carry this prefix, so an own page and a
1648
+ * substituted page that happen to share a filename cannot be taken for each other.
1649
+ */
1650
+ export const SUBSTITUTE_PAGE = 'texture-from:';
1651
+
1652
+ /**
1653
+ * One region of a substituting atlas, as `substituteTexture` reads it: where
1654
+ * it sits on which page, and the mapping from the drawing's own coordinates
1655
+ * onto it (`MeshAttachment.computeUVs`'s job).
1656
+ */
1657
+ export interface SubstituteRegion {
1658
+ page: { name: string; width: number; height: number };
1659
+ x: number;
1660
+ y: number;
1661
+ width: number;
1662
+ height: number;
1663
+ degrees: number;
1664
+ /** Art-space UVs (`u, v` per vertex) mapped onto this region of its page, in doubles. */
1665
+ pageUvs(art: readonly number[]): number[];
1666
+ }
1667
+
1668
+ /** An atlas whose texels can stand in for another's — see `substituteTexture`. */
1669
+ export interface TextureSubstitution {
1670
+ /** Prefixed page name → the page, ready to merge into a render's page map. */
1671
+ pages: Map<string, Plate>;
1672
+ /** The atlas's regions, by the key both sides agree on — see `regionKey`. */
1673
+ regions: Map<string, SubstituteRegion>;
1674
+ /** Every `scale:` the atlas text declares, in the order the pages declare them. */
1675
+ scales: number[];
1676
+ }
1677
+
1678
+ /**
1679
+ * Who reads a substituting atlas (issue #1020): `rigc` — `parseAtlasText` and
1680
+ * `./core/uvs.ts`'s `computeUvs`, for a candidate the core poses, so a rigc
1681
+ * build's `check --texture-from` reads nothing through spine-core — or
1682
+ * `spine`, the runtime's `TextureAtlas` and `MeshAttachment.computeUVs`, for
1683
+ * everything spine-core poses (an export keeps the reading it always had).
1684
+ *
1685
+ * The two were measured to agree: the region list (`TextureAtlas.regions` is
1686
+ * file order, and so is `parseAtlasText`'s, the pairing the selftest holds on
1687
+ * every corpus atlas) and the mapping (`computeUvs`, bit for bit in doubles
1688
+ * over 6,000 calls — its header). The PR of #1020 carries the substituted
1689
+ * frames, pixel for pixel, on the corpus rows that exercise it.
1690
+ */
1691
+ export type SubstitutionReader = 'rigc' | 'spine';
1692
+
1693
+ /** Load an atlas and its pages as a substitution source, read by `reader` — see `SubstitutionReader`. */
1694
+ export function textureSubstitutionFromText(atlasText: string, atlasDir: string, reader: SubstitutionReader = 'spine'): TextureSubstitution {
1695
+ const regions = new Map<string, SubstituteRegion>();
1696
+ const names: string[] = [];
1697
+ if (reader === 'rigc') {
1698
+ for (const page of parseAtlasText(atlasText).pages) {
1699
+ names.push(page.name);
1700
+ const at = { name: page.name, width: page.width, height: page.height };
1701
+ for (const region of page.regions) {
1702
+ regions.set(regionKey(region), { page: at, x: region.x, y: region.y, width: region.width, height: region.height, degrees: region.degrees, pageUvs: (art) => computeUvs(region, at, art) });
1703
+ }
1704
+ }
1705
+ } else {
1706
+ // spine-core's `TextureAtlas` and `MeshAttachment.computeUVs`, through the seam (`spineSubstitution`, `./render.ts`).
1707
+ const read = spineSubstitution(atlasText);
1708
+ names.push(...read.pages);
1709
+ for (const [key, region] of read.regions) regions.set(key, region);
1710
+ }
1711
+ const pages = new Map<string, Plate>();
1712
+ for (const name of names) {
1713
+ if (name.startsWith(SUBSTITUTE_PAGE)) {
1714
+ throw new Error(`atlas page "${name}" starts with the reserved prefix ${JSON.stringify(SUBSTITUTE_PAGE)}`);
1715
+ }
1716
+ pages.set(SUBSTITUTE_PAGE + name, readPlate(join(atlasDir, name)));
1717
+ }
1718
+ return { pages, regions, scales: atlasScales(atlasText) };
1719
+ }
1720
+
1721
+ /**
1722
+ * The `scale:` values an atlas text declares, read off the text.
1723
+ *
1724
+ * ⚠️ Off the text, and reluctantly: `TextureAtlas` drops the field (its page
1725
+ * reader silently ignores every key it has no handler for), because `scale:` is
1726
+ * an instruction to whoever *imports* the pack — "the artwork was this much
1727
+ * bigger than these texels" — and a runtime has nothing to do with it. It is
1728
+ * nevertheless the one line that says a pack is coarser than the drawing it came
1729
+ * from, which is exactly the fact a reader of an MAE needs (issue #171), so it is
1730
+ * read here rather than left unreported.
1731
+ *
1732
+ * Narrow on purpose: an indented `scale:` line inside a page block, and nothing
1733
+ * else. It is not a second parser for the format and must not grow into one.
1734
+ * The line's pattern is `ATLAS_SCALE_LINE` (`src/model.ts`), which the model
1735
+ * document reads a page's own `scale:` with (issue #1026) — one pattern, so the
1736
+ * figure `check` reports off an atlas and off a document cannot drift apart.
1737
+ */
1738
+ export function atlasScales(atlasText: string): number[] {
1739
+ const out: number[] = [];
1740
+ for (const line of atlasText.split(/\r\n|\r|\n/)) {
1741
+ const m = ATLAS_SCALE_LINE.exec(line);
1742
+ if (!m) continue;
1743
+ const value = Number(m[1]);
1744
+ if (Number.isFinite(value)) out.push(value);
1745
+ }
1746
+ return out;
1747
+ }
1748
+
1749
+ /**
1750
+ * The page rectangle a region occupies, as UVs — the fence `substituteTexture`
1751
+ * puts around a substituted piece.
1752
+ *
1753
+ * ⚠️ Derived from `region.x/y/width/height` and its rotation rather than read off
1754
+ * `region.u2/v2`, and that is not fastidiousness: `TextureAtlas` transposes a
1755
+ * rotated region's rectangle when computing `u2/v2` **only at `degrees === 90`**,
1756
+ * so at 180 and 270 those two numbers describe a rectangle the page does not have.
1757
+ * (The same gap is why `RegionAttachment.computeUVs` draws a 270-packed region
1758
+ * wrong, which is what `--atlas` was measuring on rung 7 — issue #199.)
1759
+ * `region.u/v` are always `x/pageWidth, y/pageHeight` and are used as they are; the
1760
+ * size is `pageFootprint`'s, which is the region's own transposed for a quarter
1761
+ * turn — what the atlas format means by `bounds` on a rotated region. That
1762
+ * derivation was written out here, and in three other places that wanted the same
1763
+ * rectangle; two of them had it wrong at 270 (issue #579), so it is one function
1764
+ * now and this is one of its callers.
1765
+ */
1766
+ function windowOf(region: SubstituteRegion): UvWindow {
1767
+ const rect = pageFootprint(region);
1768
+ const page = region.page;
1769
+ return {
1770
+ u0: region.x / page.width,
1771
+ v0: region.y / page.height,
1772
+ u1: (region.x + rect.width) / page.width,
1773
+ v1: (region.y + rect.height) / page.height,
1774
+ };
1775
+ }
1776
+
1777
+ /**
1778
+ * The same posed frame, drawn from another atlas's **texels only**.
1779
+ *
1780
+ * ## 🔒 What is and is not substituted, and why that is the whole point
1781
+ *
1782
+ * `world` is copied across untouched — every vertex, both shapes — so the
1783
+ * substituted frame draws the candidate's own geometry and nothing else. Only
1784
+ * `page` and `uvs` change, and they change through the drawing's own coordinates
1785
+ * (`PieceTexture`), so the same point of the artwork lands at the same world
1786
+ * position on both sides. What is left between the two renders is a difference of
1787
+ * **texels**: the same shapes, in the same places, filtered from a different
1788
+ * source.
1789
+ *
1790
+ * That is what `rigc check --atlas <the frames' own atlas>` was being used for and
1791
+ * is not: pointing `--atlas` at another atlas re-loads the skeleton against it, and
1792
+ * a region attachment's quad is derived from the region rectangle, so a `rotate:`
1793
+ * or a trim in the substituting pack moves the geometry too. Measured on rung 7,
1794
+ * whose pack is `rotate: 270` and trimmed: that swap sends the reported MAE **up**
1795
+ * on every set, which a texture floor cannot do — a coarser texture can only
1796
+ * explain error, never add it (issue #199).
1797
+ *
1798
+ * ## The window
1799
+ *
1800
+ * A trimmed pack keeps only the drawing's opaque sub-rectangle, while the
1801
+ * candidate's own quad spans the whole drawing. Art-space coordinates outside what
1802
+ * the pack kept map to page texels **belonging to whatever was packed next door**,
1803
+ * so the substituted piece is fenced to its own rectangle (`UvWindow`) and draws
1804
+ * nothing outside it. That is the faithful answer rather than a convenience: a
1805
+ * packer trims only fully transparent border, so outside the rectangle the drawing
1806
+ * *is* empty.
1807
+ */
1808
+ export function substituteTexture(
1809
+ frame: Frame,
1810
+ into: TextureSubstitution,
1811
+ ): { frame: Frame; unmatched: string[] } {
1812
+ const unmatched: string[] = [];
1813
+ const pieces: Piece[] = [];
1814
+ for (const piece of frame.pieces) {
1815
+ const texture = piece.texture;
1816
+ const region = texture ? (into.regions.get(texture.region) ?? null) : null;
1817
+ if (!texture || !region) {
1818
+ unmatched.push(texture ? texture.region : piece.slot);
1819
+ pieces.push(piece);
1820
+ continue;
1821
+ }
1822
+ // The mapping from the drawing's coordinates into a page's, which is where
1823
+ // `rotate:` (all four of them) and the trim offsets are handled: spine-core's
1824
+ // own `MeshAttachment.computeUVs`, or `./core/uvs.ts`'s `computeUvs`, measured
1825
+ // equal to it bit for bit — never a third opinion about the atlas format
1826
+ // (`SubstitutionReader`).
1827
+ const uvs = region.pageUvs(texture.artUvs);
1828
+ // A clipped piece's source map is re-seated the same way, through the drawing's own coordinates (`Mesh.source`).
1829
+ if (piece.kind === 'mesh' && piece.source !== undefined) {
1830
+ if (texture.sourceArtUvs === undefined) throw new Error(`slot "${piece.slot}": a clipped piece carries no original-art UVs for its source triangles`);
1831
+ const sourceUvs = region.pageUvs(texture.sourceArtUvs);
1832
+ pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region), source: { world: piece.source.world, uvs: sourceUvs } });
1833
+ continue;
1834
+ }
1835
+ pieces.push({ ...piece, page: SUBSTITUTE_PAGE + region.page.name, uvs, uvWindow: windowOf(region) });
1836
+ }
1837
+ return { frame: { ...frame, pieces }, unmatched };
1838
+ }
1839
+
1840
+ // ---------------------------------------------------------------------------
1841
+ // framing
1842
+ // ---------------------------------------------------------------------------
1843
+
1844
+ /**
1845
+ * The world-space box every posed vertex of these frames fits inside.
1846
+ *
1847
+ * `world.length` rather than a literal 8: a region contributes its four corners
1848
+ * and a mesh every one of its vertices, and the loop does not need to know which
1849
+ * it is holding.
1850
+ */
1851
+ export function unionBounds(frameSets: Iterable<Frame[]>): { minX: number; minY: number; maxX: number; maxY: number } {
1852
+ let minX = Infinity;
1853
+ let minY = Infinity;
1854
+ let maxX = -Infinity;
1855
+ let maxY = -Infinity;
1856
+ for (const frames of frameSets) {
1857
+ for (const frame of frames) {
1858
+ for (const piece of frame.pieces) {
1859
+ for (let i = 0; i < piece.world.length; i += 2) {
1860
+ minX = Math.min(minX, piece.world[i]);
1861
+ maxX = Math.max(maxX, piece.world[i]);
1862
+ minY = Math.min(minY, piece.world[i + 1]);
1863
+ maxY = Math.max(maxY, piece.world[i + 1]);
1864
+ }
1865
+ }
1866
+ }
1867
+ }
1868
+ return { minX, minY, maxX, maxY };
1869
+ }
1870
+
1871
+ /**
1872
+ * The opaque sub-rectangle of one quad's region, in the quad's own `(s, t)`.
1873
+ *
1874
+ * `(0,0)` is the region's bottom-left corner and `(1,1)` its top-right, so a trim
1875
+ * of `{0, 0, 1, 1}` is a region whose art fills it and anything smaller is the
1876
+ * transparent margin the art was exported with.
1877
+ */
1878
+ export interface RegionTrim {
1879
+ minS: number;
1880
+ minT: number;
1881
+ maxS: number;
1882
+ maxT: number;
1883
+ }
1884
+
1885
+ /**
1886
+ * Where a quad's artwork actually is, as opposed to where its rectangle is.
1887
+ *
1888
+ * ⭐ This is what stops an invisible margin from being able to move anything. A
1889
+ * region attachment's quad is the whole PNG, transparent border included, so two
1890
+ * exports of the same drawing with different margins pose to different quads and
1891
+ * frame themselves differently — which is how rung 5 reported MAE 39.00 for a rig
1892
+ * whose every key was right (issue #34). Trimming to the opaque texels makes the
1893
+ * box a property of the drawing.
1894
+ *
1895
+ * Alpha above zero rather than the rasteriser's coverage threshold, deliberately:
1896
+ * this is the box that has to CONTAIN the drawing, and a box that is a texel too
1897
+ * generous costs nothing while one that is a texel short clips.
1898
+ *
1899
+ * `cache` is keyed by page and region rectangle, because a scan per quad per frame
1900
+ * would be a scan per quad per frame.
1901
+ */
1902
+ export function regionTrim(page: Plate, quad: Quad, cache: Map<string, RegionTrim | null>): RegionTrim | null {
1903
+ const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
1904
+ const key = `${quad.page}|${ubr},${vbr},${ubl},${vbl},${uul},${vul}`;
1905
+ const seen = cache.get(key);
1906
+ if (seen !== undefined) return seen;
1907
+
1908
+ const ox = ubl * page.width;
1909
+ const oy = vbl * page.height;
1910
+ const ex = [(ubr - ubl) * page.width, (vbr - vbl) * page.height];
1911
+ const ey = [(uul - ubl) * page.width, (vul - vbl) * page.height];
1912
+ const det = ex[0] * ey[1] - ex[1] * ey[0];
1913
+ if (Math.abs(det) < 1e-9) {
1914
+ cache.set(key, null);
1915
+ return null;
1916
+ }
1917
+ const corners = [
1918
+ [ox, oy],
1919
+ [ox + ex[0], oy + ex[1]],
1920
+ [ox + ey[0], oy + ey[1]],
1921
+ [ox + ex[0] + ey[0], oy + ex[1] + ey[1]],
1922
+ ];
1923
+ const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
1924
+ const x1 = Math.min(page.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
1925
+ const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
1926
+ const y1 = Math.min(page.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
1927
+
1928
+ let minS = Infinity;
1929
+ let minT = Infinity;
1930
+ let maxS = -Infinity;
1931
+ let maxT = -Infinity;
1932
+ for (let y = y0; y <= y1; y++) {
1933
+ for (let x = x0; x <= x1; x++) {
1934
+ if (page.get(x, y)[3] === 0) continue;
1935
+ const rx = x + 0.5 - ox;
1936
+ const ry = y + 0.5 - oy;
1937
+ const s = (rx * ey[1] - ry * ey[0]) / det;
1938
+ const t = (ex[0] * ry - ex[1] * rx) / det;
1939
+ if (s < 0 || s > 1 || t < 0 || t > 1) continue;
1940
+ if (s < minS) minS = s;
1941
+ if (s > maxS) maxS = s;
1942
+ if (t < minT) minT = t;
1943
+ if (t > maxT) maxT = t;
1944
+ }
1945
+ }
1946
+ const trim = Number.isFinite(minS) ? { minS, minT, maxS, maxT } : null;
1947
+ cache.set(key, trim);
1948
+ return trim;
1949
+ }
1950
+
1951
+ /**
1952
+ * The world box every piece's **artwork** fits inside, over these frames.
1953
+ *
1954
+ * The same union as `unionBounds`, taken over the trimmed rectangles instead of
1955
+ * the quads. It is a starting box for `check`'s framing and nothing more — the
1956
+ * framing itself is fitted on rendered pixels — but the start has to be free of
1957
+ * transparent margins too, or the path the fit takes still depends on them.
1958
+ *
1959
+ * ⚠️ **A mesh contributes its raw vertices and is not trimmed.** The trim exists
1960
+ * because a region attachment's quad is the whole PNG, transparent border and
1961
+ * all, so its corners sit where no pixel is. A mesh's hull is authored *onto the
1962
+ * drawing* — that is what makes it a mesh — so its vertices already are where the
1963
+ * artwork is, and there is no rectangle to invert a margin out of. Passing a
1964
+ * triangle fan through the rectangle trim would not be a better estimate of the
1965
+ * same box; it would be a different box, computed from a rectangle the mesh does
1966
+ * not have.
1967
+ */
1968
+ export function trimmedUnionBounds(
1969
+ frameSets: Iterable<Frame[]>,
1970
+ pages: Map<string, Plate>,
1971
+ ): { minX: number; minY: number; maxX: number; maxY: number } {
1972
+ const cache = new Map<string, RegionTrim | null>();
1973
+ let minX = Infinity;
1974
+ let minY = Infinity;
1975
+ let maxX = -Infinity;
1976
+ let maxY = -Infinity;
1977
+ const see = (x: number, y: number): void => {
1978
+ if (x < minX) minX = x;
1979
+ if (x > maxX) maxX = x;
1980
+ if (y < minY) minY = y;
1981
+ if (y > maxY) maxY = y;
1982
+ };
1983
+ for (const frames of frameSets) {
1984
+ for (const frame of frames) {
1985
+ for (const piece of frame.pieces) {
1986
+ if (piece.kind === 'mesh') {
1987
+ for (let i = 0; i < piece.world.length; i += 2) see(piece.world[i], piece.world[i + 1]);
1988
+ continue;
1989
+ }
1990
+ const quad = piece;
1991
+ const [brx, bry, blx, bly, ulx, uly] = quad.world;
1992
+ const trim = regionTrim(pageFor(pages, quad), quad, cache);
1993
+ if (!trim) {
1994
+ for (let i = 0; i < 8; i += 2) see(quad.world[i], quad.world[i + 1]);
1995
+ continue;
1996
+ }
1997
+ const ex = [brx - blx, bry - bly];
1998
+ const ey = [ulx - blx, uly - bly];
1999
+ for (const [s, t] of [
2000
+ [trim.minS, trim.minT],
2001
+ [trim.maxS, trim.minT],
2002
+ [trim.minS, trim.maxT],
2003
+ [trim.maxS, trim.maxT],
2004
+ ]) {
2005
+ see(blx + s * ex[0] + t * ey[0], bly + s * ex[1] + t * ey[1]);
2006
+ }
2007
+ }
2008
+ }
2009
+ }
2010
+ return { minX, minY, maxX, maxY };
2011
+ }
2012
+
2013
+ /**
2014
+ * A pose that drew vertices and cannot be framed, because every one of them sits
2015
+ * at one point (issue #997) — refused by the slots that drew, the bone each hangs
2016
+ * from and, where those bones are unposed, the skins that would pose them.
2017
+ *
2018
+ * A `GeometryError`, because it is the same family as the non-finite pose of
2019
+ * #873: a picture that has no size is as unwritable as a vertex at Infinity. It
2020
+ * is a class of its own because the reader's next step is different — there the
2021
+ * rig posed a number no picture can hold, here the invocation posed the rig under
2022
+ * a skin that leaves every drawn bone without a world transform, and `--skin` is
2023
+ * usually the whole fix — so `cli.ts` exits 2 on it, as it does on the other
2024
+ * framing refusal, *nothing to draw*.
2025
+ *
2026
+ * Before it the framing answered a viewport over the one point: `maxSide / 0` is
2027
+ * Infinity, `0 · Infinity` is NaN, and `render` wrote a 0×0 frame set with exit
2028
+ * 0, its summary saying `NaNxNaNpx` and `frames.json` a viewport of width 0 and
2029
+ * scale `null`.
2030
+ */
2031
+ export class UnframeablePoseError extends GeometryError {}
2032
+
2033
+ /**
2034
+ * Which bones a skin leaves unposed, and which skins there are — the rig
2035
+ * structure the unframeable sentence names a skin from (issue #997). Not a
2036
+ * poser's: the `Poser` seam carries no skin roster, and both posers pose the one
2037
+ * Spine file this is read from (the core's document is bound to it by hash).
2038
+ * Two readings stand behind it — the runtime's (`skinRosterOf`) and the model
2039
+ * document's (`coreSkinRoster` in `./render_core.ts`, issue #1014), measured
2040
+ * to name the same bones under every skin — and `PoserChoice.rosterOf` hands
2041
+ * each poser its own.
2042
+ */
2043
+ export interface SkinRoster {
2044
+ /** Every skin, in declaration order. */
2045
+ readonly skins: readonly string[];
2046
+ /** The bones left unposed under `skin` (absent: no skin set) — inactive, or below an inactive bone. */
2047
+ unposedUnder(skin: string | undefined): Set<string>;
2048
+ }
2049
+
2050
+ /** The roster behind a pose source: its own when it is Spine data, else the one the caller passed. */
2051
+ function rosterBehind(source: PoseSource, roster: SkinRoster | undefined): SkinRoster | undefined {
2052
+ return isPoser(source) ? roster : skinRosterOf(source);
2053
+ }
2054
+
2055
+ /**
2056
+ * ⭐ **The one derivation of "a drawn slot on an unposed bone"** (issues #997,
2057
+ * #1000): the names of the slots of `slots` whose bone `roster` says `skin`
2058
+ * leaves unposed — inactive itself, or below an inactive bone. Both posers draw
2059
+ * such a slot through the zero matrix, so it draws no pixel and every vertex of
2060
+ * it sits at the origin. `framingViewport` takes these slots off the box
2061
+ * (#1000) and `unframeableSentence` refuses a pose that drew nothing else
2062
+ * (#997), from this one set.
2063
+ *
2064
+ * Empty without a roster: a bare `Poser` carries no skin structure, and what
2065
+ * cannot be read is not guessed — every slot then counts, as it did before.
2066
+ */
2067
+ export function slotsOnUnposedBones(
2068
+ slots: ReadonlyArray<{ name: string; bone: string }>,
2069
+ skin: string | undefined,
2070
+ roster: SkinRoster | undefined,
2071
+ ): Set<string> {
2072
+ if (roster === undefined) return new Set();
2073
+ const unposed = roster.unposedUnder(skin);
2074
+ return new Set(slots.filter((slot) => unposed.has(slot.bone)).map((slot) => slot.name));
2075
+ }
2076
+
2077
+ /** How many drawn slots a refusal names before it says how many more there are. */
2078
+ const UNFRAMEABLE_NAMED = 3;
2079
+
2080
+ /**
2081
+ * ⭐ **The one derivation of the unframeable sentence** (issue #997): `null`
2082
+ * when the vertices of `frameSets` span a box with extent, and otherwise the
2083
+ * sentence `UnframeablePoseError` carries. `framingViewport` and `check` both
2084
+ * reach it, so `render`, `render --geometry`, `check` and a library caller
2085
+ * refuse one rig in the same words.
2086
+ *
2087
+ * Two sentences, told apart by what the reader must change:
2088
+ *
2089
+ * - **every drawn slot hangs from a bone the skin leaves unposed** — inactive
2090
+ * itself or below an inactive bone (`unposedBones`). Both posers draw such a
2091
+ * slot through the zero matrix, so every vertex lands on the origin. Each
2092
+ * bone is named with the skins that pose it, read off the runtime's own
2093
+ * `active` flag under each skin rather than restating Spine's rule here.
2094
+ * - otherwise **every drawn vertex sits at one point** — the bones posed, and
2095
+ * collapsed their attachments (a world scale of 0 does).
2096
+ *
2097
+ * `roster` is the skin structure of the Spine file the poser poses
2098
+ * (`skinRosterOf`), which is what says whether a bone is unposed
2099
+ * (`slotsOnUnposedBones`, the set the framing box leaves out) and which skins
2100
+ * pose it. Without it the first sentence cannot be told from the second, and
2101
+ * the second is what is said.
2102
+ *
2103
+ * Distinct from *nothing to draw*: that is a skeleton that posed no vertex at
2104
+ * all, and its fix is art; this one posed vertices, and they have no place.
2105
+ */
2106
+ export function unframeableSentence(
2107
+ frameSets: ReadonlyArray<readonly Frame[]>,
2108
+ slots: ReadonlyArray<{ name: string; bone: string }>,
2109
+ skin: string | undefined,
2110
+ roster: SkinRoster | undefined,
2111
+ ): string | null {
2112
+ const box = unionBounds(frameSets.map((frames) => [...frames]));
2113
+ if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) return null;
2114
+ if (box.maxX - box.minX !== 0 || box.maxY - box.minY !== 0) return null;
2115
+ const drew = new Set<string>();
2116
+ for (const frames of frameSets) {
2117
+ for (const frame of frames) for (const piece of frame.pieces) if (piece.world.length > 0) drew.add(piece.slot);
2118
+ }
2119
+ // Declaration order, so the sentence does not depend on which frame drew first.
2120
+ const drawn = slots.filter((slot) => drew.has(slot.name));
2121
+ const under = skin === undefined ? 'under no skin' : `under skin ${JSON.stringify(skin)}`;
2122
+ const counted = `${drawn.length} drawn slot${drawn.length === 1 ? '' : 's'}`;
2123
+ const more = drawn.length > UNFRAMEABLE_NAMED ? `; and ${drawn.length - UNFRAMEABLE_NAMED} more` : '';
2124
+ const named = (describe: (slot: { name: string; bone: string }) => string): string =>
2125
+ drawn.slice(0, UNFRAMEABLE_NAMED).map(describe).join('; ') + more;
2126
+ if (roster !== undefined) {
2127
+ const offBox = slotsOnUnposedBones(slots, skin, roster);
2128
+ if (drawn.length > 0 && drawn.every((slot) => offBox.has(slot.name))) {
2129
+ const bySkin = roster.skins.map((name) => ({ name, unposed: roster.unposedUnder(name) }));
2130
+ const posers = (bone: string): string => {
2131
+ const names = bySkin.filter((k) => !k.unposed.has(bone)).map((k) => JSON.stringify(k.name));
2132
+ if (names.length === 0) return 'which no skin poses';
2133
+ return `which skin${names.length === 1 ? '' : 's'} ${names.join(', ')} pose${names.length === 1 ? 's' : ''}`;
2134
+ };
2135
+ return (
2136
+ `${under}, every drawn slot hangs from a bone that skin leaves unposed — ${counted}: ` +
2137
+ named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}, ${posers(slot.bone)}`) +
2138
+ ' — so no drawn vertex has a world transform and the frame would be 0x0: pose it under a skin that poses ' +
2139
+ 'those bones (`--skin`), or name the bones in the skin it is posed under'
2140
+ );
2141
+ }
2142
+ }
2143
+ return (
2144
+ `${under}, every drawn vertex sits at the one point (${box.minX}, ${box.minY}) — ${counted}: ` +
2145
+ named((slot) => `slot ${JSON.stringify(slot.name)} on bone ${JSON.stringify(slot.bone)}`) +
2146
+ ' — so the posed box has no extent and the frame would be 0x0: the bones those slots hang from collapse ' +
2147
+ 'every vertex onto it (a world scale of 0 does)'
2148
+ );
2149
+ }
2150
+
2151
+ /** A box being widened over vertices: `minX`…`maxY`, empty at ±Infinity. */
2152
+ interface FramingBox {
2153
+ minX: number;
2154
+ minY: number;
2155
+ maxX: number;
2156
+ maxY: number;
2157
+ }
2158
+
2159
+ /** `box` widened over the `[x, y, …]` pairs of `world` — `unionBounds`'s arithmetic, in its order of reading. */
2160
+ function extendBox(box: FramingBox, world: ArrayLike<number>): void {
2161
+ for (let i = 0; i < world.length; i += 2) {
2162
+ box.minX = Math.min(box.minX, world[i]);
2163
+ box.maxX = Math.max(box.maxX, world[i]);
2164
+ box.minY = Math.min(box.minY, world[i + 1]);
2165
+ box.maxY = Math.max(box.maxY, world[i + 1]);
2166
+ }
2167
+ }
2168
+
2169
+ /**
2170
+ * How `framingViewport` is handed its frame sets: `sample` called for each of
2171
+ * `animations` (`null` the setup pose of a skeleton with none), the sets
2172
+ * yielded in that order. The framing reads each set whole before it asks for
2173
+ * the next.
2174
+ */
2175
+ export type FramingSets = (sample: (animation: string | null) => Frame[], animations: ReadonlyArray<string | null>) => Iterable<Frame[]>;
2176
+
2177
+ /** Each set sampled only when the framing asks for it, so the one before it is no longer held (issue #1180). */
2178
+ export const ONE_SET_AT_A_TIME: FramingSets = function* (sample, animations) {
2179
+ for (const animation of animations) yield sample(animation);
2180
+ };
2181
+
2182
+ /**
2183
+ * The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
2184
+ * scaled so the long side is `maxSide` pixels.
2185
+ *
2186
+ * Measuring the box densely and once makes the framing a property of the SHOT,
2187
+ * so every rate of one skeleton lands on the same pixels.
2188
+ *
2189
+ * `null` means the skeleton posed no vertex at all — nothing to draw. A pose
2190
+ * holding a vertex at Infinity or NaN is refused by a `GeometryError` naming the
2191
+ * bone or vertex and its value (issue #873), never answered `null`. A pose whose
2192
+ * every vertex sits at one point is refused by an `UnframeablePoseError`
2193
+ * (`unframeableSentence`, issue #997), never framed at a scale of Infinity.
2194
+ *
2195
+ * The box is over the slots that POSE (issue #1000): a drawn slot whose bone
2196
+ * the skin leaves unposed (`slotsOnUnposedBones`) draws no pixel — both posers
2197
+ * put it through the zero matrix — so its vertices at the origin are not part
2198
+ * of the shot, and counting them framed every frame of a rig that carries one
2199
+ * to a point nothing is drawn at (256x94 where the posed slot alone gives
2200
+ * 256x55). When every drawn slot is such a slot there is no posed box at all,
2201
+ * and that is #997's refusal, never an empty or invented one.
2202
+ *
2203
+ * `roster` is the skin roster behind a `Poser` source (`skinRosterOf`) — what
2204
+ * says which bones the skin leaves unposed, and lets the refusal name the skins
2205
+ * that pose one. Spine data as the source is its own. A bare `Poser` with no
2206
+ * roster cannot tell an unposed bone from a posed one, so every drawn slot
2207
+ * counts there; every CLI caller passes the roster, so both posers frame alike.
2208
+ *
2209
+ * ⭐ One animation's frames at a time (issue #1180). The box is a minimum and
2210
+ * a maximum per axis, which no order of reading changes, so each animation's
2211
+ * frames are read into a box per slot and released before the next animation
2212
+ * is sampled; the box over the slots that pose is the union of theirs. Held
2213
+ * all at once — every animation at 60 fps — they were the largest single
2214
+ * holder of a render's heap: on the production rig whose core-poser render
2215
+ * peaked highest, 961 MiB retained at the framing's high-water under the core
2216
+ * poser and 293 MiB under spine-core's. `RC42` reads that no animation's
2217
+ * frames are read once the next one is sampled; `sets` is a plant's way in.
2218
+ */
2219
+ export function framingViewport(
2220
+ source: PoseSource,
2221
+ maxSide: number,
2222
+ opts?: PoseOptions,
2223
+ rosterGiven?: SkinRoster,
2224
+ sets: FramingSets = ONE_SET_AT_A_TIME,
2225
+ ): Viewport | null {
2226
+ const poser = poserOf(source);
2227
+ // The skin belongs here as much as in the frames: the union box is over the
2228
+ // attachments that POSE, and two skins fill a slot with art of different sizes
2229
+ // in different places. Framing one skin's shot with another skin's box would
2230
+ // put the difference between two skins into every measurement taken in it.
2231
+ //
2232
+ // ⭐ A slot subset is the opposite case, and is taken off (issue #835): what
2233
+ // `--slot`/`--hide` leave out still counts toward the box, so a frame with a
2234
+ // part hidden lands on the pixel grid of the frame with it and the two overlay.
2235
+ // A subset framed to its own extent would move every pixel it kept.
2236
+ //
2237
+ // A clip is taken off for the same reason (issue #844): what it removes still
2238
+ // counts toward the box, so adding or keying a mask moves no pixel it leaves
2239
+ // drawn — see `PoseOptions.unclipped`.
2240
+ // Neither the bone snapshots nor the geometry export frame anything, and
2241
+ // both would be taken at `FRAMING_FPS` for every animation only to be dropped.
2242
+ const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
2243
+ const framed: PoseOptions = { ...whole, unclipped: true };
2244
+ const sample = (animation: string | null): Frame[] =>
2245
+ animation === null ? sampleSetupPose(poser, framed) : sampleAnimation(poser, animation, FRAMING_FPS, framed);
2246
+ const animations: Array<string | null> = poser.animations.length === 0 ? [null] : poser.animations.map((a) => a.name);
2247
+ // Per slot, in the order a slot first drew: the box over its vertices and whether it drew one. Min and max select, so the
2248
+ // union over any grouping of the same vertices is the same four numbers — NaN and the sign of a zero included.
2249
+ const bySlot = new Map<string, { box: FramingBox; drew: boolean }>();
2250
+ for (const frames of sets(sample, animations)) {
2251
+ for (const frame of frames) {
2252
+ for (const piece of frame.pieces) {
2253
+ let slot = bySlot.get(piece.slot);
2254
+ if (slot === undefined) {
2255
+ slot = { box: { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, drew: false };
2256
+ bySlot.set(piece.slot, slot);
2257
+ }
2258
+ if (piece.world.length > 0) slot.drew = true;
2259
+ extendBox(slot.box, piece.world);
2260
+ }
2261
+ }
2262
+ }
2263
+ // ⚠️ Two different reasons a box is not finite, told apart here and nowhere
2264
+ // else (issue #873). A skeleton that posed no vertex at all has nothing to
2265
+ // draw, and that is `null`. One that posed a vertex at Infinity or NaN has a
2266
+ // drawable attachment in a place no box can hold — so it is refused by the
2267
+ // bone or vertex and its value, in the geometry export's own sentence. Before
2268
+ // this, the first case's `null` covered both, and a single overflowing bone
2269
+ // among finite ones reached neither: its box was finite on one side, and
2270
+ // `render` wrote a NaN-by-NaN frame set with exit 0.
2271
+ if (![...bySlot.values()].some((slot) => slot.drew)) return null;
2272
+ // ⭐ The box is over the slots that pose (issue #1000). A drawn slot on a bone
2273
+ // the skin leaves unposed is taken off — it draws no pixel — unless nothing
2274
+ // else drew, in which case every slot is kept so the pose reaches #997's
2275
+ // refusal below exactly as it did before this: the same inputs, the same
2276
+ // sentence, never an empty box framed to something.
2277
+ const roster = rosterBehind(source, rosterGiven);
2278
+ const offBox = slotsOnUnposedBones(poser.slots, framed.skin, roster);
2279
+ const posed = [...bySlot].filter(([name]) => !offBox.has(name));
2280
+ const posedOnly = posed.some(([, slot]) => slot.drew);
2281
+ const box: FramingBox = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
2282
+ for (const [, slot] of posedOnly ? posed : [...bySlot]) {
2283
+ box.minX = Math.min(box.minX, slot.box.minX);
2284
+ box.maxX = Math.max(box.maxX, slot.box.maxX);
2285
+ box.minY = Math.min(box.minY, slot.box.minY);
2286
+ box.maxY = Math.max(box.maxY, slot.box.maxY);
2287
+ }
2288
+ if (![box.minX, box.minY, box.maxX, box.maxY].every(Number.isFinite)) {
2289
+ const found = nonFinitePoseOf(
2290
+ poser,
2291
+ framed.skin,
2292
+ poser.animations.length === 0
2293
+ ? [{ animation: null, fps: FRAMING_FPS }]
2294
+ : poser.animations.map((a) => ({ animation: a.name, fps: FRAMING_FPS })),
2295
+ );
2296
+ // Reaching here with nothing found would mean the pieces and the whole
2297
+ // attachments disagree about one pose — a defect here, said as one.
2298
+ if (found === null) {
2299
+ throw new Error(
2300
+ `the framing box is not finite (${box.minX}, ${box.minY}, ${box.maxX}, ${box.maxY}) and no bone or ` +
2301
+ 'vertex of the pose is — the drawn pieces and the attachments were posed differently',
2302
+ );
2303
+ }
2304
+ throw new GeometryError(found);
2305
+ }
2306
+ // A finite box over one point (issue #997): every drawn slot on a bone the
2307
+ // skin leaves unposed, or every vertex collapsed. Framed, it is a scale of
2308
+ // Infinity and a frame of NaN by NaN pixels, written as 0x0 with exit 0.
2309
+ // Only a box with no extent can be refused, and only then are the frames
2310
+ // sampled again to be read whole — by the sentence's one derivation.
2311
+ if (box.maxX - box.minX === 0 && box.maxY - box.minY === 0) {
2312
+ const whole = animations.map(sample);
2313
+ const boxed = posedOnly ? whole.map((frames) => frames.map((frame) => ({ ...frame, pieces: frame.pieces.filter((piece) => !offBox.has(piece.slot)) }))) : whole;
2314
+ const unframeable = unframeableSentence(boxed, poser.slots, framed.skin, roster);
2315
+ if (unframeable !== null) throw new UnframeablePoseError(unframeable);
2316
+ }
2317
+ const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
2318
+ return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
2319
+ }
2320
+
2321
+ /** A viewport over an explicit world box, scaled so its long side is `maxSide`. */
2322
+ export function viewportFor(minX: number, minY: number, maxX: number, maxY: number, maxSide: number): Viewport {
2323
+ const scale = maxSide / Math.max(maxX - minX, maxY - minY);
2324
+ return {
2325
+ minX,
2326
+ minY,
2327
+ maxX,
2328
+ maxY,
2329
+ scale,
2330
+ width: Math.max(1, Math.round((maxX - minX) * scale)),
2331
+ height: Math.max(1, Math.round((maxY - minY) * scale)),
2332
+ };
2333
+ }
2334
+
2335
+ /**
2336
+ * A viewport over an explicit world box whose pixel size is already known.
2337
+ *
2338
+ * This is the shape `check` needs: the frames on disk fix the pixel size, and
2339
+ * re-deriving it from the box would round to a different integer and silently
2340
+ * shift every measurement by up to half a pixel.
2341
+ */
2342
+ export function viewportOfSize(
2343
+ minX: number,
2344
+ minY: number,
2345
+ width: number,
2346
+ height: number,
2347
+ scale: number,
2348
+ pixelWidth: number,
2349
+ pixelHeight: number,
2350
+ ): Viewport {
2351
+ return { minX, minY, maxX: minX + width, maxY: minY + height, scale, width: pixelWidth, height: pixelHeight };
2352
+ }
2353
+
2354
+ /** World (y up) to frame pixels (y down). The only place that conversion lives. */
2355
+ export function projector(v: Viewport): (wx: number, wy: number) => [number, number] {
2356
+ return (wx, wy) => [(wx - v.minX) * v.scale, (v.maxY - wy) * v.scale];
2357
+ }
2358
+
2359
+ // ---------------------------------------------------------------------------
2360
+ // rasterising
2361
+ // ---------------------------------------------------------------------------
2362
+
2363
+ /**
2364
+ * One colour channel of one texel, tinted — the whole of what a slot's colours
2365
+ * do to a pixel.
2366
+ *
2367
+ * With no dark colour this is the multiply it always was, to the bit: `dark`
2368
+ * absent returns `sample * light` and nothing else, which is why every frame in
2369
+ * this repository renders byte for byte as it did before two-colour tinting
2370
+ * existed.
2371
+ *
2372
+ * With one, the light colour multiplies the texel and the dark colour fills in
2373
+ * what the texel leaves behind, so a black region can be tinted to any colour
2374
+ * while its bright parts keep the light tint. [official] — spine-ts's own
2375
+ * two-colour fragment shader, `spine-ts/spine-webgl/src/Shader.ts`
2376
+ * (`newTwoColoredTextured`), read at branch `4.3` of `EsotericSoftware/spine-runtimes`:
2377
+ *
2378
+ * gl_FragColor.a = texColor.a * v_light.a;
2379
+ * gl_FragColor.rgb = ((texColor.a - 1.0) * v_dark.a + 1.0 - texColor.rgb) * v_dark.rgb
2380
+ * + texColor.rgb * v_light.rgb;
2381
+ *
2382
+ * ⚠️ `v_dark.a` in that line is **not a colour channel** — it is the
2383
+ * premultiplied-alpha flag, which is why the dark colour is six hex digits in
2384
+ * the file and four bytes on the vertex. `SkeletonRendererCore` packs it as such:
2385
+ * `darkColor = 0xff000000 | …` on the `pma` branch and `darkColor = (r << 16) |
2386
+ * (g << 8) | b` — alpha byte **zero** — on the other. This rasteriser composites
2387
+ * **straight** alpha (see `premultiplied` below), so the flag is 0 and the shader
2388
+ * reduces to the two terms this function computes. Reading `dark.a` out of the
2389
+ * file here would be reading a flag as a colour.
2390
+ *
2391
+ * The clamp is on the dark path only, for the same reason: `sample * light` is
2392
+ * already inside the range whenever `light` is, and a clamp on that path would
2393
+ * be a change to pixels nothing asked to change.
2394
+ */
2395
+ function tintChannel(sample: number, light: number, dark: number | undefined): number {
2396
+ if (dark === undefined) return sample * light;
2397
+ const mixed = sample * light + (255 - sample) * dark;
2398
+ return mixed < 0 ? 0 : mixed > 255 ? 255 : mixed;
2399
+ }
2400
+
2401
+ /**
2402
+ * Walk the destination pixels one affine quad covers, sampling the page.
2403
+ *
2404
+ * The quad is an affine image of the region's rectangle, so a destination pixel
2405
+ * maps back to a (s, t) inside it by inverting one 2x2 — no perspective divide,
2406
+ * no triangle split. `emit` is called for every covered pixel whose composited
2407
+ * alpha clears the coverage threshold, which is what makes "draw it" and
2408
+ * "measure where it landed" the same traversal rather than two that can drift.
2409
+ */
2410
+ export function rasteriseQuad(
2411
+ page: Plate,
2412
+ quad: Quad,
2413
+ project: (wx: number, wy: number) => [number, number],
2414
+ clip: { width: number; height: number },
2415
+ emit: (px: number, py: number, r: number, g: number, b: number, a: number) => void,
2416
+ ): void {
2417
+ // spine-core's region order is br, bl, ul, ur.
2418
+ const [brx, bry, blx, bly, ulx, uly] = quad.world;
2419
+ const bl = project(blx, bly);
2420
+ const br = project(brx, bry);
2421
+ const ul = project(ulx, uly);
2422
+ const ex = [br[0] - bl[0], br[1] - bl[1]];
2423
+ const ey = [ul[0] - bl[0], ul[1] - bl[1]];
2424
+ const det = ex[0] * ey[1] - ex[1] * ey[0];
2425
+ if (Math.abs(det) < 1e-9) return; // degenerate: zero scale, nothing to draw
2426
+ const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
2427
+
2428
+ const corners = [bl, br, ul, [br[0] + ey[0], br[1] + ey[1]]];
2429
+ const minX = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
2430
+ const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
2431
+ const minY = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
2432
+ const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
2433
+
2434
+ for (let py = minY; py <= maxY; py++) {
2435
+ for (let px = minX; px <= maxX; px++) {
2436
+ const rx = px + 0.5 - bl[0];
2437
+ const ry = py + 0.5 - bl[1];
2438
+ const s = (rx * ey[1] - ry * ey[0]) / det;
2439
+ const t = (ex[0] * ry - ex[1] * rx) / det;
2440
+ if (s < 0 || s > 1 || t < 0 || t > 1) continue;
2441
+ const u = ubl + s * (ubr - ubl) + t * (uul - ubl);
2442
+ const v = vbl + s * (vbr - vbl) + t * (vul - vbl);
2443
+ if (outsideWindow(quad.uvWindow, u, v)) continue;
2444
+ const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
2445
+ const alpha = sample[3] * quad.tint[3];
2446
+ if (alpha <= 0.5) continue;
2447
+ emit(
2448
+ px,
2449
+ py,
2450
+ Math.round(tintChannel(sample[0], quad.tint[0], quad.dark?.[0])),
2451
+ Math.round(tintChannel(sample[1], quad.tint[1], quad.dark?.[1])),
2452
+ Math.round(tintChannel(sample[2], quad.tint[2], quad.dark?.[2])),
2453
+ Math.round(alpha),
2454
+ );
2455
+ }
2456
+ }
2457
+ }
2458
+
2459
+ /** A destination pixel and the straight-alpha colour a piece put there. */
2460
+ export type EmitPixel = (px: number, py: number, r: number, g: number, b: number, a: number) => void;
2461
+
2462
+ /**
2463
+ * Slack on a `UvWindow`'s edges, in page UVs.
2464
+ *
2465
+ * The window's bounds *are* the region rectangle's own UVs, and a piece's
2466
+ * interpolated UV reaches them exactly at its edge — so the test has to admit
2467
+ * equality, and a bare `<` would drop a boundary pixel whenever the arithmetic
2468
+ * lands a bit under. A billionth of a page is far below a texel and far above the
2469
+ * error of two multiplies.
2470
+ */
2471
+ const WINDOW_SLACK = 1e-9;
2472
+
2473
+ /**
2474
+ * Is this texel outside the rectangle its piece is allowed to sample?
2475
+ *
2476
+ * `undefined` is the ordinary case — a piece posed from its own atlas has no
2477
+ * window — and answers `false` without arithmetic, which keeps this off the cost
2478
+ * of every reference frame ever rendered.
2479
+ */
2480
+ function outsideWindow(window: UvWindow | undefined, u: number, v: number): boolean {
2481
+ if (window === undefined) return false;
2482
+ return (
2483
+ u < window.u0 - WINDOW_SLACK ||
2484
+ u > window.u1 + WINDOW_SLACK ||
2485
+ v < window.v0 - WINDOW_SLACK ||
2486
+ v > window.v1 + WINDOW_SLACK
2487
+ );
2488
+ }
2489
+
2490
+ /**
2491
+ * Is this edge a top or a left one, for the winding `rasteriseMesh` normalises to?
2492
+ *
2493
+ * Derived rather than copied, because the answer depends on the sign convention
2494
+ * of the edge function and the direction of y. With `edge(p) = dx·(py−y0) −
2495
+ * dy·(px−x0)` and y pointing **down**, the triangle `(0,0) → (1,0) → (0,1)` has
2496
+ * positive area, and its horizontal edge `(0,0) → (1,0)` — `dx > 0`, `dy = 0` —
2497
+ * is the one along its top. Its `(0,1) → (0,0)` edge — `dy < 0`, going up — is
2498
+ * the one down its left.
2499
+ *
2500
+ * What actually makes the rule watertight needs neither of those facts: the two
2501
+ * triangles sharing an edge traverse it in opposite directions, so `dy < 0` holds
2502
+ * for exactly one of them, and when `dy` is 0 for both, `dx > 0` holds for
2503
+ * exactly one. Every shared edge is therefore claimed once. Getting the
2504
+ * orientation right on top of that is what keeps the classic meaning — a pixel
2505
+ * centre on a boundary belongs to the triangle below-right of it.
2506
+ */
2507
+ function isTopLeftEdge(dx: number, dy: number): boolean {
2508
+ return dy < 0 || (dy === 0 && dx > 0);
2509
+ }
2510
+
2511
+ /**
2512
+ * Walk the destination pixels one posed mesh covers, sampling the page.
2513
+ *
2514
+ * Each triangle is filled independently with barycentric UV interpolation and no
2515
+ * perspective divide — a Spine mesh is a flat 2D deformation, so its UVs are
2516
+ * affine in screen space and there is no `w` to divide by. The winding is
2517
+ * normalised per triangle (a mesh's triangles are not guaranteed to agree, and a
2518
+ * bone with negative scale flips them all anyway), and the top-left rule then
2519
+ * makes every interior edge belong to exactly one of the two triangles that
2520
+ * share it.
2521
+ *
2522
+ * `emit` has the same contract as `rasteriseQuad`'s — every covered pixel whose
2523
+ * composited alpha clears the same 0.5 threshold — so "draw it" and "measure
2524
+ * where it landed" stay one traversal for meshes exactly as they are for regions.
2525
+ */
2526
+ export function rasteriseMesh(
2527
+ page: Plate,
2528
+ mesh: Mesh,
2529
+ project: (wx: number, wy: number) => [number, number],
2530
+ clip: { width: number; height: number },
2531
+ emit: EmitPixel,
2532
+ ): void {
2533
+ const count = mesh.world.length / 2;
2534
+ // Project once per vertex, not once per triangle: an interior vertex of a
2535
+ // 40-vertex hull belongs to half a dozen triangles, and projecting it six times
2536
+ // invites six answers the moment anything about `project` stops being exact.
2537
+ const px = new Float64Array(count);
2538
+ const py = new Float64Array(count);
2539
+ for (let i = 0; i < count; i++) {
2540
+ const [x, y] = project(mesh.world[i * 2], mesh.world[i * 2 + 1]);
2541
+ px[i] = x;
2542
+ py[i] = y;
2543
+ }
2544
+
2545
+ for (let t = 0; t + 2 < mesh.triangles.length; t += 3) {
2546
+ let i0 = mesh.triangles[t];
2547
+ let i1 = mesh.triangles[t + 1];
2548
+ const i2 = mesh.triangles[t + 2];
2549
+ let area = (px[i1] - px[i0]) * (py[i2] - py[i0]) - (py[i1] - py[i0]) * (px[i2] - px[i0]);
2550
+ if (area === 0) continue; // degenerate: a zero-height triangle covers nothing
2551
+ if (area < 0) {
2552
+ const swap = i0;
2553
+ i0 = i1;
2554
+ i1 = swap;
2555
+ area = -area;
2556
+ }
2557
+
2558
+ const x0 = px[i0];
2559
+ const y0 = py[i0];
2560
+ const x1 = px[i1];
2561
+ const y1 = py[i1];
2562
+ const x2 = px[i2];
2563
+ const y2 = py[i2];
2564
+ const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
2565
+ const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(x0, x1, x2)));
2566
+ const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
2567
+ const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(y0, y1, y2)));
2568
+ if (maxX < minX || maxY < minY) continue;
2569
+
2570
+ // Edge `k` is the one opposite vertex `k`, so its edge function IS the
2571
+ // unnormalised barycentric weight of that vertex.
2572
+ const topLeft0 = isTopLeftEdge(x2 - x1, y2 - y1);
2573
+ const topLeft1 = isTopLeftEdge(x0 - x2, y0 - y2);
2574
+ const topLeft2 = isTopLeftEdge(x1 - x0, y1 - y0);
2575
+
2576
+ const u0 = mesh.uvs[i0 * 2];
2577
+ const v0 = mesh.uvs[i0 * 2 + 1];
2578
+ const u1 = mesh.uvs[i1 * 2];
2579
+ const v1 = mesh.uvs[i1 * 2 + 1];
2580
+ const u2 = mesh.uvs[i2 * 2];
2581
+ const v2 = mesh.uvs[i2 * 2 + 1];
2582
+
2583
+ // A clipped piece (`Mesh.source`, issue #964): the UV of a pixel is the SOURCE triangle's affine map, in doubles from its own
2584
+ // projected corners and page UVs, so which convex pieces the clipper cut changes no pixel. Coverage is still this triangle's.
2585
+ const src = mesh.source === undefined ? null : sourceMap(mesh.source, t / 3, project);
2586
+
2587
+ for (let y = minY; y <= maxY; y++) {
2588
+ const sy = y + 0.5;
2589
+ for (let x = minX; x <= maxX; x++) {
2590
+ const sx = x + 0.5;
2591
+ const w0 = (x2 - x1) * (sy - y1) - (y2 - y1) * (sx - x1);
2592
+ if (topLeft0 ? w0 < 0 : w0 <= 0) continue;
2593
+ const w1 = (x0 - x2) * (sy - y2) - (y0 - y2) * (sx - x2);
2594
+ if (topLeft1 ? w1 < 0 : w1 <= 0) continue;
2595
+ const w2 = (x1 - x0) * (sy - y0) - (y1 - y0) * (sx - x0);
2596
+ if (topLeft2 ? w2 < 0 : w2 <= 0) continue;
2597
+
2598
+ let u: number;
2599
+ let v: number;
2600
+ if (src === null) {
2601
+ const b0 = w0 / area;
2602
+ const b1 = w1 / area;
2603
+ const b2 = w2 / area;
2604
+ u = b0 * u0 + b1 * u1 + b2 * u2;
2605
+ v = b0 * v0 + b1 * v1 + b2 * v2;
2606
+ } else [u, v] = src(sx, sy);
2607
+ if (outsideWindow(mesh.uvWindow, u, v)) continue;
2608
+ const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
2609
+ const alpha = sample[3] * mesh.tint[3];
2610
+ if (alpha <= 0.5) continue;
2611
+ emit(
2612
+ x,
2613
+ y,
2614
+ Math.round(tintChannel(sample[0], mesh.tint[0], mesh.dark?.[0])),
2615
+ Math.round(tintChannel(sample[1], mesh.tint[1], mesh.dark?.[1])),
2616
+ Math.round(tintChannel(sample[2], mesh.tint[2], mesh.dark?.[2])),
2617
+ Math.round(alpha),
2618
+ );
2619
+ }
2620
+ }
2621
+ }
2622
+ }
2623
+
2624
+ /**
2625
+ * The UV map of a clipped piece's drawn triangle `t`: its source triangle's
2626
+ * affine map (`Mesh.source`), from the three projected source corners and their
2627
+ * page UVs, in doubles — the barycentric weights of the pixel centre in the
2628
+ * projected source triangle times the corners' UVs. The same for every
2629
+ * decomposition of the source triangle, which is the point (issue #964).
2630
+ */
2631
+ function sourceMap(source: ClipSource, t: number, project: (wx: number, wy: number) => [number, number]): (sx: number, sy: number) => [number, number] {
2632
+ const o = t * 6;
2633
+ const [ax, ay] = project(source.world[o], source.world[o + 1]);
2634
+ const [bx, by] = project(source.world[o + 2], source.world[o + 3]);
2635
+ const [cx, cy] = project(source.world[o + 4], source.world[o + 5]);
2636
+ const uv = source.uvs;
2637
+ const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
2638
+ return (sx, sy) => {
2639
+ const wa = ((cx - bx) * (sy - by) - (cy - by) * (sx - bx)) / area;
2640
+ const wb = ((ax - cx) * (sy - cy) - (ay - cy) * (sx - cx)) / area;
2641
+ const wc = ((bx - ax) * (sy - ay) - (by - ay) * (sx - ax)) / area;
2642
+ return [wa * uv[o] + wb * uv[o + 2] + wc * uv[o + 4], wa * uv[o + 1] + wb * uv[o + 3] + wc * uv[o + 5]];
2643
+ };
2644
+ }
2645
+
2646
+ /**
2647
+ * Rasterise whichever shape this piece is.
2648
+ *
2649
+ * ⭐ Every caller that used to reach for `rasteriseQuad` goes through here, so
2650
+ * "what counts as a covered pixel" has one definition for both shapes — which is
2651
+ * what lets `frameGeometry`, the framing box and the drawn frame agree about a
2652
+ * mesh without any of them knowing what a triangle is.
2653
+ */
2654
+ export function rasterisePiece(
2655
+ page: Plate,
2656
+ piece: Piece,
2657
+ project: (wx: number, wy: number) => [number, number],
2658
+ clip: { width: number; height: number },
2659
+ emit: EmitPixel,
2660
+ ): void {
2661
+ if (piece.kind === 'mesh') rasteriseMesh(page, piece, project, clip, emit);
2662
+ else rasteriseQuad(page, piece, project, clip, emit);
2663
+ }
2664
+
2665
+ /** Blit one piece onto the plate, source-over. */
2666
+ export function blitPiece(
2667
+ dst: Plate,
2668
+ page: Plate,
2669
+ piece: Piece,
2670
+ project: (wx: number, wy: number) => [number, number],
2671
+ ): void {
2672
+ rasterisePiece(page, piece, project, dst, (px, py, r, g, b, a) => dst.blend(px, py, [r, g, b, a]));
2673
+ }
2674
+
2675
+ /** The four texels one bilinear tap reads, and the fractions between them. */
2676
+ interface Taps {
2677
+ c00: RGBA;
2678
+ c10: RGBA;
2679
+ c01: RGBA;
2680
+ c11: RGBA;
2681
+ fx: number;
2682
+ fy: number;
2683
+ }
2684
+
2685
+ /**
2686
+ * The four texels around `(x, y)`, CLAMPED at the page edge.
2687
+ *
2688
+ * ⚠️ The clamp is load-bearing beyond this function: `src/atlas.ts` sizes the
2689
+ * gutter between packed regions against the fact that one tap reaches exactly one
2690
+ * texel, and `gallery/portrait`'s lid runs its art flush to its own window
2691
+ * because a clamped tap has no transparent neighbour to reach into. Widening the
2692
+ * tap is not a local change.
2693
+ */
2694
+ function taps(page: Plate, x: number, y: number): Taps {
2695
+ const x0 = Math.floor(x);
2696
+ const y0 = Math.floor(y);
2697
+ const at = (ix: number, iy: number): RGBA => {
2698
+ const cx = Math.max(0, Math.min(page.width - 1, ix));
2699
+ const cy = Math.max(0, Math.min(page.height - 1, iy));
2700
+ return page.get(cx, cy);
2701
+ };
2702
+ 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 };
2703
+ }
2704
+
2705
+ /** One channel of a bilinear tap: lerp along x on both rows, then between them. */
2706
+ function lerpTap(v00: number, v10: number, v01: number, v11: number, fx: number, fy: number): number {
2707
+ const top = v00 + (v10 - v00) * fx;
2708
+ const bottom = v01 + (v11 - v01) * fx;
2709
+ return top + (bottom - top) * fy;
2710
+ }
2711
+
2712
+ /**
2713
+ * Sample a straight-alpha page bilinearly — interpolating in PREMULTIPLIED space.
2714
+ *
2715
+ * ⭐ **Why the premultiply.** The source is straight alpha, so a transparent
2716
+ * texel beside the art is `(0, 0, 0, 0)`: its colour is not a colour, it is the
2717
+ * absence of one. Averaging R, G and B against it pulls the sample toward black
2718
+ * while alpha only drops part of the way, and the difference between those two
2719
+ * rates IS a dark rim, one pixel wide, drawn over whatever is behind the part.
2720
+ * Weighting each colour by its own alpha and dividing the sum back out gives the
2721
+ * transparent texel no vote in the colour, which is the whole of the fix: two
2722
+ * parts of one colour, overlapping, come out that colour. Measured before the
2723
+ * fix at −60/255 between two parts sharing one flat field, and −31/255 down
2724
+ * `gallery/portrait`'s forehead — issue #292.
2725
+ *
2726
+ * ⭐ **Why alpha is computed the old way, and why equal alpha short-circuits.**
2727
+ * `rasteriseQuad` and `rasteriseMesh` gate coverage on `alpha > 0.5`, so the
2728
+ * alpha arithmetic decides WHICH pixels are drawn — and through `frameGeometry`,
2729
+ * the framing box every reference frame was rendered inside. `lerpTap` on the
2730
+ * alpha channel is therefore the original expression, unchanged, not an
2731
+ * algebraically equal rearrangement: equal-but-rearranged is a last-bit
2732
+ * difference, and a last bit either side of 0.5 is a pixel.
2733
+ *
2734
+ * For the same reason the equal-alpha case returns early. When all four taps
2735
+ * carry one alpha, premultiplying by it and dividing it back out is the identity
2736
+ * — so the straight path is not an approximation there, it is the same number,
2737
+ * and taking it reproduces the five committed rungs BIT for bit rather than
2738
+ * merely closely. What moves is exactly the mixed-alpha tap: the edges, where the
2739
+ * rim was.
2740
+ */
2741
+ export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
2742
+ const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
2743
+ const a = lerpTap(c00[3], c10[3], c01[3], c11[3], fx, fy);
2744
+ if (c00[3] === c10[3] && c00[3] === c01[3] && c00[3] === c11[3]) {
2745
+ return [
2746
+ lerpTap(c00[0], c10[0], c01[0], c11[0], fx, fy),
2747
+ lerpTap(c00[1], c10[1], c01[1], c11[1], fx, fy),
2748
+ lerpTap(c00[2], c10[2], c01[2], c11[2], fx, fy),
2749
+ a,
2750
+ ];
2751
+ }
2752
+ // Every tap is transparent in some proportion that sums to nothing: there is no
2753
+ // colour to recover and no pixel to draw (both callers gate on alpha anyway).
2754
+ if (a <= 0) return [0, 0, 0, 0];
2755
+ const out: [number, number, number, number] = [0, 0, 0, a];
2756
+ for (let c = 0; c < 3; c++) {
2757
+ const pm = lerpTap(c00[c] * c00[3], c10[c] * c10[3], c01[c] * c01[3], c11[c] * c11[3], fx, fy);
2758
+ // Bounded by 255 in exact arithmetic — the weighted mean of the taps' colours
2759
+ // cannot exceed their maximum — so the clamp absorbs float error only. It is
2760
+ // here rather than trusted because `Plate`'s store is a `Uint8Array`, which
2761
+ // WRAPS: 256 would land as a black pixel in the brightest part of the art.
2762
+ out[c] = Math.min(255, pm / a);
2763
+ }
2764
+ return out;
2765
+ }
2766
+
2767
+ /**
2768
+ * The same tap, each channel interpolated independently — the arithmetic
2769
+ * `bilinear` used until #292, kept as the CONTROL that fix is measured against.
2770
+ *
2771
+ * 🚫 **Nothing in `src/` calls this, and nothing in `src/` should.** It had one
2772
+ * production caller until #306: `src/pose.ts`'s `errBilinear`, on the argument
2773
+ * that `materialPlate`'s fourth channel is a material mask rather than opacity.
2774
+ * That argument was wrong in the direction that mattered — the mask is exactly
2775
+ * the weight the colour wanted, because a texel with no material carries the
2776
+ * background's colour and not the part's — so `errBilinear` now takes
2777
+ * `bilinear` too, and the only importer left is `selftest.ts`.
2778
+ *
2779
+ * ⭐ It lives here rather than in the suite so the control shares `taps` — the
2780
+ * edge clamp above — with the sampler it is a control for. A hand copy in the
2781
+ * test file would drift from it silently, and then `SM01` would be comparing the
2782
+ * fix against something that is not what the renderer used to do.
2783
+ * `SM08_NO_PRODUCTION_MODULE_READS_THE_STRAIGHT_TAP` is what keeps the first
2784
+ * paragraph true rather than merely written down.
2785
+ */
2786
+ export function bilinearChannels(page: Plate, x: number, y: number): [number, number, number, number] {
2787
+ const { c00, c10, c01, c11, fx, fy } = taps(page, x, y);
2788
+ const out: [number, number, number, number] = [0, 0, 0, 0];
2789
+ for (let c = 0; c < 4; c++) out[c] = lerpTap(c00[c], c10[c], c01[c], c11[c], fx, fy);
2790
+ return out;
2791
+ }
2792
+
2793
+ export function fill(plate: Plate, colour: RGBA): void {
2794
+ for (let y = 0; y < plate.height; y++) for (let x = 0; x < plate.width; x++) plate.set(x, y, colour);
2795
+ }
2796
+
2797
+ /** Look a page up by name, with a failure that names what the atlas did declare. */
2798
+ export function pageFor(pages: Map<string, Plate>, piece: Piece): Plate {
2799
+ const page = pages.get(piece.page);
2800
+ if (!page) {
2801
+ throw new Error(
2802
+ `slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`,
2803
+ );
2804
+ }
2805
+ return page;
2806
+ }
2807
+
2808
+ /** One frame, composited over `background`, at the viewport's pixel size. */
2809
+ export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: Viewport, background: RGBA): Plate {
2810
+ const plate = new Plate(viewport.width, viewport.height);
2811
+ fill(plate, background);
2812
+ const project = projector(viewport);
2813
+ for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
2814
+ return plate;
2815
+ }
2816
+
2817
+ /**
2818
+ * Every frame of one animation as one labelled grid, row major.
2819
+ *
2820
+ * Not decoration: rung 3's subject is *spacing* — how far a thing travels
2821
+ * between two consecutive frames — and that is a comparison across frames. A
2822
+ * reader flipping through 65 separate files is comparing against memory.
2823
+ *
2824
+ * ⭐ It lives here rather than beside either caller because the layout is a
2825
+ * CONTRACT: `bench/render_reference.ts` writes the grid, `rigc render` writes the
2826
+ * same grid for a user's own build, and `src/check.ts` reads a sheet's tiles back
2827
+ * out of it (issue #36). Three programs reading one geometry is one definition or
2828
+ * it is a bug waiting for the day two of them are edited apart.
2829
+ */
2830
+ export function contactSheet(frames: Frame[], pages: Map<string, Plate>, viewport: Viewport, tile: number): Plate {
2831
+ const tileScale = tile / Math.max(viewport.width, viewport.height);
2832
+ const tileW = Math.max(1, Math.round(viewport.width * tileScale));
2833
+ const tileH = Math.max(1, Math.round(viewport.height * tileScale));
2834
+ const columns = Math.min(SHEET_COLUMNS, frames.length);
2835
+ const rows = Math.ceil(frames.length / columns);
2836
+ const sheet = new Plate(columns * (tileW + SHEET_GAP) + SHEET_GAP, rows * (tileH + SHEET_GAP) + SHEET_GAP);
2837
+ fill(sheet, SHEET_RULE);
2838
+ const base = projector(viewport);
2839
+ frames.forEach((frame, i) => {
2840
+ const col = i % columns;
2841
+ const row = Math.floor(i / columns);
2842
+ const ox = col * (tileW + SHEET_GAP) + SHEET_GAP;
2843
+ const oy = row * (tileH + SHEET_GAP) + SHEET_GAP;
2844
+ const plate = new Plate(tileW, tileH);
2845
+ fill(plate, BACKGROUND);
2846
+ const project = (wx: number, wy: number): [number, number] => {
2847
+ const [px, py] = base(wx, wy);
2848
+ return [px * tileScale, py * tileScale];
2849
+ };
2850
+ for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
2851
+ plate.text(String(i), 2, 2, 1, SHEET_LABEL);
2852
+ for (let y = 0; y < tileH; y++) for (let x = 0; x < tileW; x++) sheet.set(ox + x, oy + y, plate.get(x, y));
2853
+ });
2854
+ return sheet;
2855
+ }
2856
+
2857
+ /** Where one thing landed in a frame, in frame pixels. */
2858
+ export interface Footprint {
2859
+ /** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */
2860
+ pixels: number;
2861
+ cx: number;
2862
+ cy: number;
2863
+ minX: number;
2864
+ minY: number;
2865
+ maxX: number;
2866
+ maxY: number;
2867
+ }
2868
+
2869
+ export const EMPTY_FOOTPRINT: Footprint = { pixels: 0, cx: 0, cy: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 };
2870
+
2871
+ /** Where a frame's pixels went: the coverage mask, and each slot's own footprint. */
2872
+ export interface FrameGeometry {
2873
+ /** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
2874
+ coverage: Uint8Array;
2875
+ footprints: Map<string, Footprint>;
2876
+ /**
2877
+ * Which owner drew each pixel last, or `-1` — `null` unless `owners` was given.
2878
+ *
2879
+ * "Last" is the composite's own rule: pieces arrive in draw order, so the owner
2880
+ * left in a pixel is the one you would see there. That is deliberately the
2881
+ * opposite of `footprints`, which measures each slot on its own pixels
2882
+ * *ignoring* what covers it — a footprint answers "where is this part", and
2883
+ * this mask answers "whose part is this pixel", and only the second one can be
2884
+ * a partition.
2885
+ */
2886
+ owner: Int32Array | null;
2887
+ }
2888
+
2889
+ /**
2890
+ * Rasterise one frame for measurement rather than for looking at: which pixels
2891
+ * it covers, and where each slot landed.
2892
+ *
2893
+ * ⚠️ A slot's footprint is measured on the pixels **that slot draws**, ignoring
2894
+ * what is drawn over it. That is deliberate. A slot hidden behind another still
2895
+ * has a position, and it is the position the rig gives it; measuring it on the
2896
+ * composite would report the occluder's geometry instead and call the rig wrong
2897
+ * for being covered up. What the composite costs is on the reference side, where
2898
+ * an occluded part merges into its occluder's component — and that is what the
2899
+ * matcher reports as ambiguity rather than as drift.
2900
+ */
2901
+ export function frameGeometry(
2902
+ frame: Frame,
2903
+ pages: Map<string, Plate>,
2904
+ viewport: Viewport,
2905
+ /** Slot name → owner id, when the caller also wants the per-pixel owner mask. */
2906
+ owners?: Map<string, number>,
2907
+ ): FrameGeometry {
2908
+ const coverage = new Uint8Array(viewport.width * viewport.height);
2909
+ const owner = owners === undefined ? null : new Int32Array(viewport.width * viewport.height).fill(-1);
2910
+ const footprints = new Map<string, Footprint>();
2911
+ const project = projector(viewport);
2912
+ for (const piece of frame.pieces) {
2913
+ const owned = owners === undefined ? -1 : (owners.get(piece.slot) ?? -1);
2914
+ let weight = 0;
2915
+ let sx = 0;
2916
+ let sy = 0;
2917
+ let minX = Infinity;
2918
+ let minY = Infinity;
2919
+ let maxX = -Infinity;
2920
+ let maxY = -Infinity;
2921
+ rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
2922
+ coverage[py * viewport.width + px] = 1;
2923
+ if (owner !== null && owned >= 0) owner[py * viewport.width + px] = owned;
2924
+ const w = a / 255;
2925
+ weight += w;
2926
+ sx += (px + 0.5) * w;
2927
+ sy += (py + 0.5) * w;
2928
+ if (px < minX) minX = px;
2929
+ if (px > maxX) maxX = px;
2930
+ if (py < minY) minY = py;
2931
+ if (py > maxY) maxY = py;
2932
+ });
2933
+ const previous = footprints.get(piece.slot);
2934
+ const here: Footprint =
2935
+ weight === 0
2936
+ ? EMPTY_FOOTPRINT
2937
+ : { pixels: weight, cx: sx / weight, cy: sy / weight, minX, minY, maxX: maxX + 1, maxY: maxY + 1 };
2938
+ // A slot shows one attachment at a time, so this only merges when a caller
2939
+ // hands us a frame with two pieces on one slot; merging is still the honest
2940
+ // answer, and it keeps the map keyed by slot the way the report reads it.
2941
+ footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
2942
+ }
2943
+ return { coverage, footprints, owner };
2944
+ }
2945
+
2946
+ function mergeFootprints(a: Footprint, b: Footprint): Footprint {
2947
+ if (b.pixels === 0) return a;
2948
+ const pixels = a.pixels + b.pixels;
2949
+ return {
2950
+ pixels,
2951
+ cx: (a.cx * a.pixels + b.cx * b.pixels) / pixels,
2952
+ cy: (a.cy * a.pixels + b.cy * b.pixels) / pixels,
2953
+ minX: Math.min(a.minX, b.minX),
2954
+ minY: Math.min(a.minY, b.minY),
2955
+ maxX: Math.max(a.maxX, b.maxX),
2956
+ maxY: Math.max(a.maxY, b.maxY),
2957
+ };
2958
+ }