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,245 @@
1
+ /**
2
+ * A06, the body (issue #1025, step 4c of #380): every page the atlas names is
3
+ * the size its PNG is, every region lies inside its page, and — under
4
+ * `spine-html` — no page claims premultiplied alpha and no region is turned.
5
+ * The clause that refused two regions on one page overlapping is
6
+ * `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` since issue #1099 (`./a49.ts`).
7
+ *
8
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
9
+ * regions are a fact (`../facts/atlas_regions.ts`), the directory is the
10
+ * caller's, and the profile's policy switch is passed in. The `check` call —
11
+ * and the argument for the SKIP guards it shares with A17 and A19 (#568, #608)
12
+ * — stays in `validate()`.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import type { Verdicts } from '../harness.ts';
17
+ import type { AtlasRegionFacts } from '../facts/atlas_regions.ts';
18
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
19
+ import { pageFootprint, pageGridSentence } from '../../atlas.ts';
20
+ import { readPngHeader } from '../../png.ts';
21
+ import { imageDataProblem } from '../../../tools/plate.ts';
22
+
23
+ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, input: { atlasDir: string }, policy: boolean): void {
24
+ if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
25
+ if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
26
+ // Every sentence this body prints goes through here, so the end of the body
27
+ // can tell "nothing failed" from "something failed" (issue #1055, below).
28
+ let failures = 0;
29
+ const fail = (assertion: string, detail: string): void => {
30
+ failures++;
31
+ failed(assertion, detail);
32
+ };
33
+ // 🔒 **A page whose file is not on disk is a page whose size was not
34
+ // measured, and the body says so rather than passing** (issue #1055). Until
35
+ // this the loop below walked past such a page and the assertion came out
36
+ // PASS — on a build with every page file deleted, a PASS over a size clause
37
+ // that had opened nothing, measured on 33 of the 38 recipe-and-profile
38
+ // pairs of the tree's 19 recipes (the other five failed clauses that read
39
+ // only the atlas's numbers: overlap and rotation, under `spine-html`). What the body does instead is decided by what `A17` prints on
40
+ // the same input, so that one missing file is named once, by the rule whose
41
+ // subject it is: A17 FAILs naming every such file and its path, which keeps
42
+ // the report red; this body adds no FAIL of its own about the file (a
43
+ // second naming), and it does not PASS (a certificate over a size nobody
44
+ // read). It SKIPs with a reason that names the pages it did not read and
45
+ // points at A17 — `unreadSizes` below, at both of the body's exits.
46
+ //
47
+ // ⚠️ Only when nothing else here failed. `skip()` is per assertion, and a
48
+ // skip beside failures puts A06 in two of the report's buckets (the #705
49
+ // argument in `a19.ts`); and a FAIL certifies nothing, so the unread page
50
+ // is A17's alone there. ⚠️ Not the tree's other multi-clause rule either
51
+ // (`../reasons.ts`, #580: a multi-clause assertion SKIPs only when EVERY
52
+ // clause had nothing to measure). That rule is about a clause whose subject
53
+ // list is EMPTY — zero is a count, and a count was measured. A page the
54
+ // atlas declares is a member of the size clause's subject that is present
55
+ // and was not read, and the PASS this replaces claimed it was.
56
+ const unread: string[] = [];
57
+ const unreadSizes = (): void => {
58
+ if (failures > 0 || unread.length === 0) return;
59
+ const total = atlas.pages.length;
60
+ skip(
61
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
62
+ unread.length === total
63
+ ? `no page's size was measured: none of the ${total} page file(s) the atlas declares is on disk, which ` +
64
+ "A17_ATLAS_PAGE_FILES_EXIST names file by file. The clauses that read only the atlas's own numbers found " +
65
+ "nothing to fail, and that is not a pass while every page's size is unread"
66
+ : `the size of ${unread.length} of the ${total} page(s) the atlas declares was not measured — ` +
67
+ `${unread.map((name) => JSON.stringify(name)).join(', ')} — because the file is not on disk, which ` +
68
+ `A17_ATLAS_PAGE_FILES_EXIST names. The ${total - unread.length} page(s) that were read, and the clauses ` +
69
+ "that read only the atlas's own numbers, found nothing to fail, and that is not a pass while a page's size is unread",
70
+ );
71
+ };
72
+ for (const page of atlas.pages) {
73
+ const abs = resolve(input.atlasDir, page.name);
74
+ if (!existsSync(abs)) {
75
+ unread.push(page.name); // A17 names the file; this body says only that it did not read it
76
+ continue;
77
+ }
78
+ // 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
79
+ // #732). The reader's throw used to reach this rule's catch and print as
80
+ // `threw: not a PNG (bad signature)`: the verdict right, the sentence a
81
+ // stack message that named neither what the file was nor what rigc reads.
82
+ // `pngProblem` ([`src/png.ts`](png.ts)) is the one sentence every reader
83
+ // of a page states; this rule prefixes the size the atlas declares, the
84
+ // value the file would have had to carry.
85
+ //
86
+ // 🔒 **And this is the one line that names it** (issue #1064). A file's
87
+ // readability is this rule's subject — it runs under both profiles and
88
+ // asks `pngProblem` first, so on every input where `A19` would find the
89
+ // same page unreadable, this FAIL is already printed. `A19` therefore
90
+ // SKIPs naming the page and pointing here when it failed nothing else,
91
+ // and says nothing about the page when it did: one unreadable file, one
92
+ // line, the way #1055 made one missing file A17's alone.
93
+ //
94
+ // ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
95
+ // header carries its dimensions in a fixed field, and reading them would
96
+ // print a number no oracle in this tree has checked (rigc links no WebP
97
+ // reader to compare a parse against), about a page that is refused here
98
+ // either way — and measured, the variant that PASSED a WebP page whose
99
+ // size agreed built green under the default profile and wrote a
100
+ // directory that `render` then refused.
101
+ const header = readPngHeader(abs);
102
+ if (header.problem !== null) {
103
+ fail(
104
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
105
+ `page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so the ` +
106
+ `size was not measured: ${header.problem}`,
107
+ );
108
+ }
109
+ // 🔒 **And a page whose header reads and whose image data does not decode
110
+ // is this rule's too** (issue #1074), for the reason #1064 made the
111
+ // unreadable header its: this is the one reader of every page that runs
112
+ // under both profiles. Measured before this, on the tree's 19 recipes with
113
+ // the first page's compressed stream overwritten past its first half,
114
+ // this rule PASSED on 19 of 19 (it read the header only) and `A19` alone
115
+ // named the file, as `threw: cannot decode PNG …`, on the 12 whose first
116
+ // page carries a part. On the 7 gallery rigs, whose first page is the base
117
+ // plate `A19` does not scan, nothing did: both rules PASSED under both
118
+ // profiles, and the default build wrote a directory `render` then died on.
119
+ //
120
+ // ⚠️ The stream is inflated, not decoded: `imageDataProblem` is the
121
+ // decoder's own refusing half and nothing after it — 68.9 ms over the 19
122
+ // builds' pages against `validate()`'s 286.2 ms under `spine`, where a
123
+ // whole decode would be 459.6 ms. A page whose data does not decode has no
124
+ // size worth comparing, so the grid clause is not asked of it: one file,
125
+ // one line.
126
+ const undecodable = header.info === null ? null : imageDataProblem(readFileSync(abs), abs);
127
+ if (undecodable !== null) {
128
+ fail(
129
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
130
+ `page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so ` +
131
+ `nothing on it was measured: ${undecodable}`,
132
+ );
133
+ }
134
+ const info = undecodable === null ? header.info : null;
135
+ const onPage = atlas.regions.filter((region) => region.page.name === page.name);
136
+ const gridSaid = info === null ? null : pageGridSentence(page, info, onPage);
137
+ if (gridSaid !== null) {
138
+ // 🔒 **Still validity, and now for a measured reason rather than an
139
+ // inherited one** (issue #715). The card that opened this expected the
140
+ // clause to move behind the profile switch once the runtime's mapping
141
+ // was measured — it is declared-size-relative, so a rescaled page
142
+ // draws. What settles it the other way is that the sentence below can
143
+ // name a repair the FORMAT already provides (`scale:`), and that two of
144
+ // the three readers a wrong grid breaks are rigc's own and run under
145
+ // both profiles. The sentence, and the one derivation of the ratio under
146
+ // it, is `pageGridSentence` in [`src/atlas.ts`](atlas.ts): the compiler's
147
+ // region lift states the same one when it is asked for texels on such a
148
+ // page (issue #750), so the two cannot come to describe one page two ways.
149
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', gridSaid);
150
+ }
151
+ // 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
152
+ // CONVENTION, not the atlas format's rules — a packed page with
153
+ // `rotate: 90` is what the Spine packer produces and every official example
154
+ // ships one. Under `spine` an atlas is judged only on whether its declared
155
+ // size matches the file it names.
156
+ if (policy && page.pma) {
157
+ fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `page "${page.name}" claims premultiplied alpha; parts are straight alpha`);
158
+ }
159
+ }
160
+ // 🔒 **A region's rectangle lies inside the page it names, and that is
161
+ // VALIDITY** (issue #694). The clause is not new — it has read the same four
162
+ // numbers since issue #266 — but it stood inside the policy half below, and
163
+ // behind a guard that skipped any page carrying one region that covered it.
164
+ // Both fences were wrong for it. A rectangle that leaves its page is not a
165
+ // convention somebody else may hold differently: `u2 > 1` samples whatever
166
+ // the wrap mode returns, which is never the drawing the pack was made of, so
167
+ // the part draws garbage or nothing at all. Measured on a pack shaped like
168
+ // the two production atlases that found this — a page declaring 2048x256
169
+ // with regions at `2274,0 980x200` and `0,252 100x258` — every atlas
170
+ // assertion passed under the default profile and the first of the two parts
171
+ // drew 0 of the 10,517 pixels it draws when the same rig is built loose.
172
+ //
173
+ // ⭐ The tool's other half already said so, which is what settles where the
174
+ // clause belongs. `resolveFromAtlas` ([`src/compile.ts`](compile.ts)) refuses
175
+ // exactly this rectangle as a `CompileError` under every profile when a pack
176
+ // arrives through `--atlas-in`, so while it was policy here the compiler and
177
+ // the gate disagreed about the same four numbers — and the gate is the only
178
+ // half that a skeleton and a pack somebody else made ever reach.
179
+ //
180
+ // ⚠️ It is stated over `atlas.regions` rather than per page group, because
181
+ // the question is about one region and its own page and needs no neighbour:
182
+ // a page carrying a single full-page region is measured too, and answers
183
+ // trivially. The rectangle is `pageFootprint`'s and nobody else's here
184
+ // (issue #579): what `TextureAtlas` transposes at 90 and not at 270 is
185
+ // `u2`/`v2`, a UV pair `MeshAttachment.computeUVs` never reads for an atlas
186
+ // region, and the page rectangle is a different quantity — transposed at
187
+ // BOTH quarter turns. A region that fits only *because* it is turned is
188
+ // inside its page, and this clause says so.
189
+ for (const region of atlas.regions) {
190
+ const foot = pageFootprint(region);
191
+ if (
192
+ region.x < 0 ||
193
+ region.y < 0 ||
194
+ region.x + foot.width > region.page.width ||
195
+ region.y + foot.height > region.page.height
196
+ ) {
197
+ fail(
198
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
199
+ `region "${region.name}" occupies ${region.x},${region.y} ${foot.width}x${foot.height} of page ` +
200
+ `"${region.page.name}", which is ${region.page.width}x${region.page.height} — a region that runs off ` +
201
+ 'its page samples texels that are not there',
202
+ );
203
+ }
204
+ }
205
+ if (!policy) return unreadSizes();
206
+ // ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2), and
207
+ // since issue #1099 the tiling half is not this assertion's. It held that no
208
+ // two regions on one page overlap — the check that makes shared-page
209
+ // sampling well defined — and it was stated over rectangles, which is the
210
+ // right statement for a region attachment and the wrong one for a mesh: a
211
+ // mesh draws its hull, and `--pack-shape polygon` puts a neighbour inside a
212
+ // mesh's rectangle wherever the hull is not. The clause is now
213
+ // `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` ([`./a49.ts`](a49.ts)), which
214
+ // refuses a pair only where the rectangles overlap AND what the two regions
215
+ // draw does — so every pair this clause refused where neither region is a
216
+ // readable mesh hull (an alias, a sequence frame, a rotated region, two
217
+ // rectangles planted over the same texels) is refused there, by the same two
218
+ // rectangles, under the same profile.
219
+ //
220
+ // ⚠️ Its sibling — every region inside the page it names — moved above and
221
+ // out of this profile in issue #694, and the two are not symmetrical. That
222
+ // one is broken for every consumer; the overlap is a statement about what a
223
+ // pack MEANS, and the corpus is the evidence rather than the taste:
224
+ // measured over the ten atlases in `examples/`, 0 of 132 regions are off
225
+ // their page and 49 pairs on four of those pages overlap, editor-exported
226
+ // and correct. That is why A49 is a renderer rule.
227
+ //
228
+ // ⚠️ Rotation stays refused either way, and that is not the same clause: it
229
+ // is about rigc's own packer never turning a region, which is a statement
230
+ // about what rigc WRITES. It stopped being a statement about what rigc can
231
+ // read in issue #570 — `extractRegion` now lifts a rotated region back off
232
+ // its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
233
+ // must not be re-merged: an artifact under the renderer's own rulebook that
234
+ // rigc did not pack is still a foreign artifact, whatever rigc can measure.
235
+ for (const region of atlas.regions) {
236
+ if (region.degrees !== 0) {
237
+ fail(
238
+ 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
239
+ `region "${region.name}" is rotated; rigc's own packer never turns a region (see PACK_NO_ROTATE in ` +
240
+ 'src/atlas.ts), so this atlas came from somewhere else',
241
+ );
242
+ }
243
+ }
244
+ unreadSizes();
245
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A07, restated over the emitted text (issue #1060): the clause
3
+ * `validate()` runs in `src/validate.ts`, word for word, over the atlas text — the
4
+ * text rigc wrote, read by rigc's own reader rather than by spine-core's. The
5
+ * round trip's copy is unchanged and runs in `cli.ts build`; this one runs in
6
+ * the gate of the entry that links none of the runtime
7
+ * (`../emitted/index.ts`), and the selftest's `RC28` holds the two to the
8
+ * same lines on every recipe and on this assertion's mutants.
9
+ */
10
+ import type { Verdicts } from '../harness.ts';
11
+ import { SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
12
+
13
+ export function a07AtlasTextShape({ fail, skip }: Verdicts, atlasText: string): void {
14
+ const atlasLines = atlasText.replace(/\n$/, '').split('\n');
15
+ const atlasPageLines = atlasLines.filter((line) => line.trim().length > 0).length;
16
+ if (atlasPageLines === 0) {
17
+ // One of the exported reason constants, not a sentence of its own: the
18
+ // static-rig suite's arithmetic (S50, #580) counts a rule as skipped
19
+ // for want of a SUBJECT only when its reason is one of those constants.
20
+ return skip('A07_ATLAS_TEXT_SHAPE', SKIP_NO_ATLAS_PAGE);
21
+ }
22
+ // A blank line at either END of the file is named as that end (issues #803,
23
+ // #810), and a run of them is one finding stating its length. They are taken
24
+ // off both ends before the walk below, so the walk sees the page blocks and
25
+ // the blank lines between them and nothing else: a blank line after the last
26
+ // region is not a page block, and "consecutive blank lines" and "the last
27
+ // page block declares no region" are about blocks. `TextureAtlas` reads a
28
+ // blank at either end as nothing (`TextureAtlas.js:98-100` skips the leading
29
+ // run, `:116-118` ends a page block on each blank line), and rigc writes
30
+ // neither (`writeAtlasText`, and `--atlas-in` through `canonicalAtlasShape`),
31
+ // so a file that has one was written by something else and is refused.
32
+ let leading = 0;
33
+ while (leading < atlasLines.length && atlasLines[leading].trim().length === 0) leading++;
34
+ if (leading > 0) {
35
+ fail('A07_ATLAS_TEXT_SHAPE', `line 1: the file begins with ${leading === 1 ? 'a blank line' : `${leading} blank lines`}`);
36
+ }
37
+ let trailing = 0;
38
+ while (trailing < atlasLines.length - leading && atlasLines[atlasLines.length - 1 - trailing].trim().length === 0) trailing++;
39
+ const blockEnd = atlasLines.length - trailing;
40
+ let expectPage = true;
41
+ let sawRegionForPage = false;
42
+ for (let i = leading; i < blockEnd; i++) {
43
+ const line = atlasLines[i];
44
+ if (line.trim().length === 0) {
45
+ if (expectPage) fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: consecutive blank lines`);
46
+ else if (!sawRegionForPage) {
47
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: blank line before this page had any region`);
48
+ }
49
+ expectPage = true;
50
+ sawRegionForPage = false;
51
+ continue;
52
+ }
53
+ if (expectPage) {
54
+ expectPage = false;
55
+ continue; // page name line
56
+ }
57
+ if (line.includes(':')) continue; // key: value line
58
+ // A bare non-key line is a region name, and it is used untrimmed.
59
+ if (line !== line.trim()) {
60
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${i + 1}: region name has stray whitespace: ${JSON.stringify(line)}`);
61
+ }
62
+ sawRegionForPage = true;
63
+ }
64
+ if (!sawRegionForPage) fail('A07_ATLAS_TEXT_SHAPE', 'the last page block declares no region');
65
+ if (trailing > 0) {
66
+ fail('A07_ATLAS_TEXT_SHAPE', `line ${blockEnd + 1}: the file ends with ${trailing === 1 ? 'a blank line' : `${trailing} blank lines`}`);
67
+ }
68
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A08, the body (issue #1025, step 4c of #380): every region an attachment
3
+ * resolves through is a region the atlas has, spelled exactly.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the atlas's
6
+ * region names and the skin entries' lookups are a fact
7
+ * (`../facts/region_joins.ts`) rather than a `TextureAtlas` built here and the
8
+ * raw JSON's walk. Why the rule runs before the round trip, and what A00 does
9
+ * about a miss it names, is argued above the `check` call in `validate()`,
10
+ * which stays there.
11
+ *
12
+ * `pathsWithNoRegion` is filled, not read: every lookup this body refuses for
13
+ * want of a region, which `validate()`'s A00 reads to defer to this rule rather
14
+ * than restate the miss. The model side hands a set nobody reads.
15
+ */
16
+ import type { Verdicts } from '../harness.ts';
17
+ import type { RegionJoinFacts } from '../facts/region_joins.ts';
18
+ import { SKIP_NO_ATTACHMENT_REGION_JOIN } from '../reasons.ts';
19
+
20
+ export function a08RegionNamesMatchAttachments({ fail, skip }: Verdicts, facts: RegionJoinFacts, pathsWithNoRegion: Set<string>): void {
21
+ if (facts.regionNames === null) {
22
+ return skip(
23
+ 'A08_REGION_NAMES_MATCH_ATTACHMENTS',
24
+ 'the atlas text does not parse, so there are no region names to join against (A00 owns that failure)',
25
+ );
26
+ }
27
+ const regionNames = new Set(facts.regionNames);
28
+ if (facts.joins === null) {
29
+ return skip('A08_REGION_NAMES_MATCH_ATTACHMENTS', 'the skeleton JSON did not parse (A00 owns that failure)');
30
+ }
31
+ let joined = 0;
32
+ for (const join of facts.joins) {
33
+ // A `sequence` the walk will not guess at: say nothing rather than invent
34
+ // a region name. A00 still has the last word on it.
35
+ if (join.lookups === null) continue;
36
+ for (const lookup of join.lookups) {
37
+ joined++;
38
+ const at = `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`;
39
+ const present = regionNames.has(lookup);
40
+ if (!present) pathsWithNoRegion.add(lookup);
41
+ if (lookup !== lookup.trim()) {
42
+ fail(
43
+ 'A08_REGION_NAMES_MATCH_ATTACHMENTS',
44
+ `${at}: attachment "${join.name}" resolves through path ${JSON.stringify(lookup)}, which has stray ` +
45
+ `whitespace — the atlas is matched on the exact string, and ${
46
+ present
47
+ ? 'the region it finds carries the same padding'
48
+ : regionNames.has(lookup.trim())
49
+ ? `the region this atlas has is ${JSON.stringify(lookup.trim())}, without it`
50
+ : 'no region of this atlas carries it'
51
+ }`,
52
+ );
53
+ } else if (!present) {
54
+ // Not a guess and not a repair — a region the atlas DOES hold that
55
+ // differs from the wanted name only by case or padding. It is
56
+ // reported because it was measured, and where there is none the
57
+ // sentence says nothing at all.
58
+ const near = [...regionNames].find((r) => r.trim().toLowerCase() === lookup.toLowerCase());
59
+ fail(
60
+ 'A08_REGION_NAMES_MATCH_ATTACHMENTS',
61
+ `${at}: attachment "${join.name}" wants region "${lookup}", which this atlas does not have${
62
+ near === undefined ? '' : ` — it does have ${JSON.stringify(near)}`
63
+ }. Either the skeleton's "path" or the atlas region name is the one that moved`,
64
+ );
65
+ }
66
+ }
67
+ }
68
+ for (const region of regionNames) {
69
+ if (region !== region.trim()) {
70
+ fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `atlas region ${JSON.stringify(region)} has stray whitespace`);
71
+ }
72
+ }
73
+ if (joined === 0 && regionNames.size === 0) {
74
+ return skip('A08_REGION_NAMES_MATCH_ATTACHMENTS', SKIP_NO_ATTACHMENT_REGION_JOIN);
75
+ }
76
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A09, the body (issue #1025, cut 4c-3 of step 4c of #380): the compiled
3
+ * duration is the declared duration (rule 4).
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the
6
+ * animations, their durations and their timelines' durations as one fact
7
+ * (`../facts/animation_durations.ts`) where it read the loaded skeleton's
8
+ * `animations`, `findAnimation` and each timeline's `getDuration()`. The
9
+ * declared durations are the caller's on both sides, as the rig info is. The
10
+ * `check` call stays in `validate()`.
11
+ */
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { AnimationDurationFacts } from '../facts/animation_durations.ts';
14
+ import { SKIP_NO_DECLARED_DURATION } from '../reasons.ts';
15
+ import { float32Step } from '../../timelines.ts';
16
+
17
+ /** One frame at 60 fps: how far a last key may fall short of the declared end before the declared duration reads as wrong. */
18
+ const FRAME = 1 / 60;
19
+
20
+ export function a09AnimationDurationMatchesSpec({ fail, skip }: Verdicts, facts: AnimationDurationFacts, declaredDurations: Record<string, number> | undefined): void {
21
+ // Without the spec there is no declared duration to compare the compiled
22
+ // one against, and returning here used to count as a PASS — a gate saying
23
+ // it checked something it never looked at.
24
+ if (!declaredDurations) {
25
+ return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', 'no motion spec supplied, so no declared duration to compare against');
26
+ }
27
+ // Same trap one level down. A **static rig** — a skeleton that exists to
28
+ // be posed and carries no animation at all, which is what
29
+ // `1-weight-and-mass`'s second export is — declares nothing and loads
30
+ // nothing, so both loops below iterate zero times and the assertion
31
+ // reported PASS. That is the vacuous green this report is built to refuse:
32
+ // there is no duration here, and saying so is the honest answer.
33
+ if (Object.keys(declaredDurations).length === 0 && facts.animations.length === 0) {
34
+ return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', SKIP_NO_DECLARED_DURATION);
35
+ }
36
+ for (const [name, declared] of Object.entries(declaredDurations)) {
37
+ const anim = facts.animations.find((a) => a.name === name);
38
+ if (!anim) {
39
+ fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `spec declares animation "${name}" but the skeleton has none`);
40
+ continue;
41
+ }
42
+ // Two arms, and the asymmetry is the point.
43
+ //
44
+ // UNDERSHOOT is R7's question — is the declared duration wrong? An
45
+ // animation may hold its final pose, so a last key a little before the
46
+ // end is ordinary and a frame of that is slack.
47
+ //
48
+ // OVERSHOOT is a different question with a different tolerance.
49
+ // `anim.duration` IS the largest key time (`SkeletonJson.ts:1261` takes
50
+ // the max over every timeline's own duration), so a loaded duration past
51
+ // the declared one means a KEY is past it — and nothing that plays the
52
+ // animation for the duration it declares will ever reach that key. Rung
53
+ // 6 lost a one-frame attachment reveal to a key 3.4e-5 s past the end,
54
+ // 1/500 of FRAME, which this comparison read as agreement (issue #54).
55
+ // `compile.ts` refuses that per timeline now; this is the same rule held
56
+ // against a skeleton the compiler never saw.
57
+ // The slack is one float32 step at the declared duration, and it is the
58
+ // same function the compiler's Rule 4 refuses on (`float32Step`, in
59
+ // `timelines.ts`, which says why it is a step of the float and no longer
60
+ // a fixed 1e-6 plus one).
61
+ const slack = float32Step(declared);
62
+ const past = anim.duration - declared;
63
+ if (past > slack) {
64
+ const late = anim.timelineDurations.filter((d) => d - declared > slack).length;
65
+ fail(
66
+ 'A09_ANIMATION_DURATION_MATCHES_SPEC',
67
+ `animation "${name}" has ${late} timeline(s) keyed past the declared duration ${declared}s — ` +
68
+ `the last key is at ${anim.duration}s, ${past.toFixed(6)}s late, so nothing ever samples it`,
69
+ );
70
+ } else if (declared - anim.duration > FRAME) {
71
+ fail(
72
+ 'A09_ANIMATION_DURATION_MATCHES_SPEC',
73
+ `animation "${name}" loaded duration ${anim.duration}s, spec declares ${declared}s`,
74
+ );
75
+ }
76
+ }
77
+ for (const anim of facts.animations) {
78
+ if (!(anim.name in declaredDurations)) {
79
+ fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `skeleton has animation "${anim.name}" with no spec entry`);
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * A10, the body (issue #1025, cut 4c-5a of step 4c of #380): no pose the
3
+ * skeleton is stepped through holds a number that is not finite, or a bone
4
+ * with no inheritance mode.
5
+ *
6
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
7
+ * `BoneTimelineFacts` (`../facts/bone_timelines.ts`) for the `inherit` keys
8
+ * where it read the skeleton JSON, and `SteppedPoseFacts`
9
+ * (`../facts/stepped_poses.ts`) where it posed a spine-core skeleton itself:
10
+ * the setup pose, each animation's looping walk, and a bone's mode posed at a
11
+ * key — which `validate()` takes from spine-core and the model side from
12
+ * rigc's core. The scan of a pose is `firstNonFinite` (`../../nonfinite.ts`),
13
+ * the sentence `render` refuses on, called rather than restated. The argument
14
+ * for each clause is above the `check` call, which stays in `validate()`.
15
+ */
16
+ import type { Verdicts } from '../harness.ts';
17
+ import type { BoneTimelineFacts } from '../facts/bone_timelines.ts';
18
+ import type { SteppedFrame, SteppedPoseFacts } from '../facts/stepped_poses.ts';
19
+ import { SKIP_NO_POSE } from '../reasons.ts';
20
+ import { isObj } from '../values.ts';
21
+ import { firstNonFinite } from '../../nonfinite.ts';
22
+ import { BONE_INHERIT_KNOWN } from '../../rig.ts';
23
+
24
+ /** The steps A10 takes through every animation: the walk is `max(duration, 1) / STEP_FRAMES` long per step. */
25
+ export const STEP_FRAMES = 120;
26
+
27
+ export function a10NoNanAfterStepping({ fail, skip, stats }: Verdicts, boneTimelines: BoneTimelineFacts, facts: SteppedPoseFacts): void {
28
+ if (facts.steppedAnimations.length === 0 && facts.boneCount === 0) {
29
+ skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_POSE);
30
+ return;
31
+ }
32
+
33
+ // -- the bone's inheritance mode, posed at every `inherit` key
34
+ let unresolved = 0;
35
+ for (const { animation: animName, bone: boneName, timelines } of boneTimelines.boneTimelines) {
36
+ if (!isObj(timelines) || !Array.isArray(timelines.inherit)) continue;
37
+ if (!facts.hasAnimation(animName) || !facts.hasBone(boneName)) continue;
38
+ for (const rawKey of timelines.inherit as unknown[]) {
39
+ if (!isObj(rawKey)) continue;
40
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
41
+ const posed = facts.posedInherit(animName, boneName, time);
42
+ if (posed === null) continue;
43
+ unresolved++;
44
+ fail(
45
+ 'A10_NO_NAN_AFTER_STEPPING',
46
+ `animation "${animName}" bone "${boneName}" inherit (t=${time}): the bone poses inheritance mode ` +
47
+ `${String(posed)} — the key spells ${JSON.stringify(rawKey.inherit)}, which the runtime's mode lookup ` +
48
+ `does not resolve (${BONE_INHERIT_KNOWN}), so no mode applies until the next key and the bone keeps ` +
49
+ 'the world rotation, scale and shear it had',
50
+ );
51
+ }
52
+ }
53
+ if (unresolved > 0) return;
54
+
55
+ // -- the whole world transform, at the setup pose and every stepped frame
56
+ if (facts.steppedAnimations.length === 0) stats.nanStepping = 'skipped';
57
+ const atRest = facts.setup();
58
+ const atSetup = firstNonFinite('the setup pose', atRest.drawn, atRest.bones);
59
+ if (atSetup !== null) {
60
+ fail('A10_NO_NAN_AFTER_STEPPING', atSetup);
61
+ return;
62
+ }
63
+
64
+ /**
65
+ * What a posed frame shows that the world-transform scan does not read: a
66
+ * bone posing no inheritance mode, and a slot colour that is not finite.
67
+ * `where` leads the sentence — the animation's name at a stepped frame,
68
+ * as it always has, or `the setup pose` on a skeleton with no animation.
69
+ */
70
+ const poseDefect = (where: string, frame: SteppedFrame): string | null => {
71
+ for (const bone of frame.bones) {
72
+ if (bone.inherit !== null) {
73
+ return (
74
+ `${where}: bone "${bone.name}" poses inheritance mode ${bone.inherit} — its setup ` +
75
+ `spells inherit ${facts.statedInherit(bone.name)}, which the ` +
76
+ `runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
77
+ 'are never computed — they stay 0 and everything the bone carries collapses to a point'
78
+ );
79
+ }
80
+ }
81
+ for (const slot of frame.slots) {
82
+ if (!slot.colour.every(Number.isFinite)) return `${where}: slot "${slot.name}" colour is non-finite`;
83
+ if (slot.dark !== null && !slot.dark.every(Number.isFinite)) {
84
+ return `${where}: slot "${slot.name}" dark colour is non-finite`;
85
+ }
86
+ }
87
+ return null;
88
+ };
89
+
90
+ if (facts.steppedAnimations.length === 0) {
91
+ const atRestDefect = poseDefect('the setup pose', atRest);
92
+ if (atRestDefect !== null) fail('A10_NO_NAN_AFTER_STEPPING', atRestDefect);
93
+ return;
94
+ }
95
+ for (const anim of facts.steppedAnimations) {
96
+ const step = Math.max(anim.duration, 1) / STEP_FRAMES;
97
+ const frames = facts.walk(anim.name, step, STEP_FRAMES);
98
+ for (let i = 0; i < STEP_FRAMES; i++) {
99
+ const frame = frames[i];
100
+ const found = firstNonFinite(
101
+ `animation ${JSON.stringify(anim.name)} frame ${i + 1} of ${STEP_FRAMES} (t=${((i + 1) * step).toFixed(4)}s)`,
102
+ frame.drawn,
103
+ frame.bones,
104
+ );
105
+ if (found !== null) {
106
+ fail('A10_NO_NAN_AFTER_STEPPING', found);
107
+ return;
108
+ }
109
+ const defect = poseDefect(anim.name, frame);
110
+ if (defect !== null) {
111
+ fail('A10_NO_NAN_AFTER_STEPPING', defect);
112
+ return;
113
+ }
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A11, the body (issue #1025, step 4c of #380): no clipping attachment — the
3
+ * renderer skips them silently. A `renderer` rule, so `spine` never runs it.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the count is
6
+ * a fact (`../facts/skin_entries.ts`).
7
+ */
8
+ import type { Verdicts } from '../harness.ts';
9
+ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
10
+
11
+ export function a11NoClippingAttachments({ fail }: Verdicts, { clippingCount }: SkinEntryFacts): void {
12
+ if (clippingCount > 0) {
13
+ fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
14
+ }
15
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A12, the body (issue #1025, cut 4c-4 of #380): no dark colour and no
3
+ * two-colour timeline — both parsed, then silently ignored by spine-html.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the slots
6
+ * are a fact (`../facts/skeleton_roster.ts`) and the slot timelines are A45's
7
+ * (`../facts/slot_colour.ts`), rather than the raw JSON and `walkTimelines`
8
+ * over it. The path printed is the one `walkTimelines` built for a slot
9
+ * timeline, `<animation>.slots.<slot>.<timeline>`, and only a timeline whose
10
+ * keys are a list is one it visited.
11
+ */
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { SkeletonRosterFacts } from '../facts/skeleton_roster.ts';
14
+ import type { SlotTimelines } from '../facts/slot_colour.ts';
15
+
16
+ export function a12NoDarkColor({ fail }: Verdicts, { slots }: SkeletonRosterFacts, slotTimelines: readonly SlotTimelines[]): void {
17
+ for (const slot of slots) {
18
+ if (slot.dark) {
19
+ fail('A12_NO_DARK_COLOR', `slot "${slot.name}" declares a dark colour; the renderer ignores it`);
20
+ }
21
+ }
22
+ for (const { animation, slot, timelines } of slotTimelines) {
23
+ for (const [name, keys] of Object.entries(timelines)) {
24
+ if (!Array.isArray(keys)) continue;
25
+ if (name === 'rgba2' || name === 'rgb2') {
26
+ fail('A12_NO_DARK_COLOR', `${animation}.slots.${slot}.${name}: two-colour timeline "${name}" is silently ignored by the renderer`);
27
+ }
28
+ }
29
+ }
30
+ }