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,404 @@
1
+ /**
2
+ * A19, the body (issue #1025, step 4c of #380): every overlay part can draw a
3
+ * transparent pixel — only the base plate may be opaque.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
6
+ * regions are a fact (`../facts/atlas_regions.ts`), the stage is a fact
7
+ * (`../facts/stage.ts`), the region attachments are the skins' fact
8
+ * (`../facts/skin_entries.ts`), and the directory and the rig info are the
9
+ * caller's. Why transparency is not the same thing as an alpha channel (#215)
10
+ * stays above the `check` call in `validate()`.
11
+ */
12
+ import { existsSync } from 'node:fs';
13
+ import { resolve } from 'node:path';
14
+ import type { Verdicts } from '../harness.ts';
15
+ import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
16
+ import type { SkinEntryFacts } from '../facts/skin_entries.ts';
17
+ import type { StageFacts } from '../facts/stage.ts';
18
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
19
+ import { pageFootprint } from '../../atlas.ts';
20
+ import { colourTypeName, NotAPngError, readPngHeader } from '../../png.ts';
21
+ import { type Plate, readPlate } from '../../../tools/plate.ts';
22
+ import type { RigInfo } from '../../types.ts';
23
+
24
+ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
25
+ // A SKIP for the same reason A06's is (#568). This one is invisible under
26
+ // `spine`, where the profile excludes the rule before its body runs — and
27
+ // that is exactly why it was worth finding: `--profile spine-html` reported
28
+ // it, and A27 below, green on an atlas nothing had read.
29
+ if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
30
+ if (atlas.pages.length === 0) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS_PAGE);
31
+ // Every sentence this body prints goes through here, so its end can tell
32
+ // "nothing failed" from "something failed" (issue #1055, at the page loop).
33
+ let failures = 0;
34
+ const fail = (assertion: string, detail: string): void => {
35
+ failures++;
36
+ failed(assertion, detail);
37
+ };
38
+ const stageW = stage.width ?? 0;
39
+ const stageH = stage.height ?? 0;
40
+ // 🔒 **Which image is the base plate is decided ONCE, and the rig's own
41
+ // statement decides it** (issue #770). A cut manifest names its base plate
42
+ // — the part whose window IS the crop — and that needs no stage box; the
43
+ // size of an attachment against the stage is read only when the build
44
+ // names none, because it is then the only statement there is (every build
45
+ // from a rig spec, and `validate <dir>`, which has no rig at all). Both
46
+ // routes below read the same two sets, so they cannot disagree about it.
47
+ //
48
+ // ⚠️ Before this, "at least the stage's size" was the only reading, and a
49
+ // stageless manifest rig came out with no base plate: the packed build was
50
+ // refused for its plate's opaque texels while the loose build of the same
51
+ // rig passed on the file's colour type. The order is a rule rather than a
52
+ // coincidence of the fixtures, where the two readings agree: a stage stated
53
+ // small enough for an overlay to "cover" is exactly where they part.
54
+ const named = input.rig?.basePlates ?? [];
55
+ const basePages = new Set<string>();
56
+ const baseRegions = new Set<string>();
57
+ const exempt = (name: string): void => {
58
+ const region = atlas.findRegion(name);
59
+ if (region) {
60
+ basePages.add(region.page.name);
61
+ baseRegions.add(region.name);
62
+ }
63
+ };
64
+ if (named.length > 0) {
65
+ for (const name of named) exempt(name);
66
+ } else {
67
+ for (const att of regionAttachments) {
68
+ if (stageW && stageH && att.width >= stageW && att.height >= stageH) exempt(att.path || att.name);
69
+ }
70
+ }
71
+ // The escape hatch is named the way it is reachable. With a base plate the
72
+ // rig names, that plate; with none but a stage, the size reading; and with
73
+ // neither, nothing here decides which image is the plate — so the sentence
74
+ // says what would, rather than pointing at a door that is not there.
75
+ const undecided =
76
+ 'Only a base plate may be opaque, and nothing here decides which image that is: this skeleton declares no ' +
77
+ 'stage size to measure one against, and ';
78
+ const exemption =
79
+ named.length > 0
80
+ ? `Only the base plate the rig names (${named.map((name) => JSON.stringify(name)).join(', ')}, the part ` +
81
+ "whose window is the cut manifest's crop) may be opaque."
82
+ : stageW && stageH
83
+ ? `Only the one image big enough to cover the whole stage (${stageW}x${stageH}) may be opaque.`
84
+ : input.rig
85
+ ? `${undecided}the build names no base plate. Either would decide it: give the rig spec a "skeleton" ` +
86
+ 'stage the plate covers, or build from a cut manifest, whose base plate is the part whose window is ' +
87
+ 'the crop.'
88
+ : `${undecided}no rig was given to name one. Either would decide it: state a "skeleton" stage the plate ` +
89
+ 'covers, or validate with the specs it was built from (--rig, --motion and the --manifest, whose base ' +
90
+ 'plate is the part whose window is the crop).';
91
+ // 🚨 Counted per page for the unpacked convention and per REGION on a shared
92
+ // page, and the split is not a convenience (issue #266, follow-up 2). A
93
+ // packed page's own file all but always declares transparency — the gutter
94
+ // and whatever is left over of the page are transparent — so the file-level
95
+ // question is answered "yes" by the packing itself, whatever the parts on it
96
+ // look like. Asking it that way once packs became gateable under this profile
97
+ // would have turned this assertion into a pass that measures nothing, which
98
+ // is the failure mode this file exists to prevent. So a shared page is opened
99
+ // and each region's own rectangle is measured instead.
100
+ const sharedPages = new Map<string, AtlasRegionEntry[]>();
101
+ for (const region of atlas.regions) {
102
+ const on = sharedPages.get(region.page.name);
103
+ if (on) on.push(region);
104
+ else sharedPages.set(region.page.name, [region]);
105
+ }
106
+ // 🔒 **A page whose file is not on disk is a page whose parts were not
107
+ // read, and the body says so rather than passing** (issue #1055). Until
108
+ // this the loop walked past such a page, and on a build with every page
109
+ // file deleted the assertion printed PASS on all 19 of the tree's recipes
110
+ // under `spine-html` having opened nothing. The rule for what it prints
111
+ // instead is `a06.ts`'s, read off what `A17` prints on the same input: A17
112
+ // FAILs naming each missing file, so the report is already red and the file
113
+ // already named; a FAIL here would be the second naming and a PASS a
114
+ // certificate over alpha nobody read. So the body SKIPs, naming the pages it
115
+ // did not read and pointing at A17 — only when it failed nothing else,
116
+ // because `skip()` is per assertion (#705's argument, below) and a FAIL
117
+ // certifies nothing.
118
+ //
119
+ // ⚠️ A page counts only when it carries a part this rule judges, by the
120
+ // predicate the non-PNG branch below uses: a missing page holding nothing
121
+ // but the base plate had nothing for this rule to read either way, so it
122
+ // moves nothing here (the size it was not measured at is `A06`'s to say).
123
+ let pagesWithParts = 0;
124
+ const unread: string[] = [];
125
+ const unreadable: string[] = [];
126
+ for (const page of atlas.pages) {
127
+ const abs = resolve(input.atlasDir, page.name);
128
+ const on = sharedPages.get(page.name) ?? [];
129
+ const carriesPart = on.length > 1 ? on.some((region) => !baseRegions.has(region.name)) : !basePages.has(page.name);
130
+ if (carriesPart) pagesWithParts++;
131
+ if (!existsSync(abs)) {
132
+ if (carriesPart) unread.push(page.name); // A17 names the file; this body says only that it did not read it
133
+ continue;
134
+ }
135
+ // 🔒 **A page that is on disk and cannot be read as a PNG is named once,
136
+ // by `A06`, and this body only says it did not read it** (issue #1064,
137
+ // the other half of #1055's rule). Until this it FAILed here with a
138
+ // second sentence about the same file (#732's `not measured … belongs to
139
+ // A06`), so one unreadable page was two red lines: measured on the tree's
140
+ // 19 recipes with the first page replaced by text bytes, by a PNG cut
141
+ // inside its IDAT and by one with a wrong signature byte, A06 FAILed
142
+ // naming the file on 19 of 19 under both profiles and this rule added its
143
+ // FAIL on the 12 whose first page carries a part (`spine-html`).
144
+ //
145
+ // ⭐ Who names it is read off the two bodies, not chosen. Both ask
146
+ // `readPngHeader` (`pngProblem`, the one reader of a page's identity) the
147
+ // same question about the same file, A06 runs under both profiles and this
148
+ // rule under `spine-html` alone, and A06 FAILs on every page for which it
149
+ // answers with a problem — so on every input that reaches this branch, A06
150
+ // has already named the file and the report is red. A FAIL here would be
151
+ // the second naming and a PASS a certificate over alpha nobody read, so
152
+ // the page joins `#1055`'s unread list, with its own owner beside it.
153
+ //
154
+ // ⚠️ #732 chose a FAIL because `skip()` is per assertion and would delete
155
+ // the verdicts on every other page. Under #1055's rule that argument no
156
+ // longer forces it: the SKIP below is printed only when this body failed
157
+ // nothing else, so the verdicts it would replace are all passes, and its
158
+ // reason says how many pages they covered. With another failure standing,
159
+ // the failures stand and the unreadable page is A06's alone. A page
160
+ // holding nothing but the base plate had nothing for this rule to read
161
+ // either way and moves nothing, exactly as a missing one does.
162
+ //
163
+ // 🔒 **So does a page whose header reads and whose image data does not
164
+ // decode** (issue #1074). It used to reach `readPlate` below and come out
165
+ // as this rule's `threw: cannot decode PNG …` — a stack message as the
166
+ // detail, on 12 of the 19 recipes with a stream broken — because `A06`
167
+ // read the header only. `A06` now asks the decoder's refusing half too
168
+ // (`imageDataProblem`) and fails naming the file under both profiles, so
169
+ // the page is its, by #1064's rule, and joins the same list — at the two
170
+ // places below where this rule decodes (`decoded`), and not before them:
171
+ // the header's fast negative (#215) still refuses a file that has nowhere
172
+ // to keep a clear texel without opening it, which is a verdict about the
173
+ // header and not about the pixels A06 found unreadable (PT13).
174
+ const header = readPngHeader(abs);
175
+ if (header.info === null) {
176
+ if (carriesPart) unreadable.push(page.name); // A06 names the file; this body says only that it did not read it
177
+ continue;
178
+ }
179
+ const decoded = (): Plate | null => {
180
+ try {
181
+ return readPlate(abs);
182
+ } catch (err) {
183
+ // `pngProblem` has accepted the file, so this is the decoder's refusal (`readPlate`'s, #1074): A06 names it.
184
+ if (!(err instanceof NotAPngError)) throw err;
185
+ if (carriesPart) unreadable.push(page.name);
186
+ return null;
187
+ }
188
+ };
189
+ if (on.length > 1) {
190
+ // 🚨 A rotated region is refused by A06 under this profile, so this
191
+ // reading was assumed to be cosmetic — a rectangle printed beside a
192
+ // failure already standing. It is not: the loop below OPENS the
193
+ // rectangle and stops at the first transparent texel, so a rectangle
194
+ // wider than the drawing runs into the transparent gutter, finds its
195
+ // texel there and names nothing. With the transpose applied at 90 only,
196
+ // two fully opaque parts on one page were measured green at
197
+ // `rotate: 270` and red at 0, 90 and 180 — this assertion's own verdict,
198
+ // flipped by the rotation it does not judge (issue #579). The footprint
199
+ // is `pageFootprint`'s, which every other reader of it now calls.
200
+ // 🚨 **The scan counts what it READ, and zero texels read is not a
201
+ // verdict** (issue #705). The `continue` above walks past every
202
+ // coordinate that is not on the page, so a rectangle none of whose
203
+ // texels are on it came out of this loop with `transparent` still
204
+ // false — indistinguishable from a solid drawing — and the sentence
205
+ // below then stated opacity over texels nobody had opened. Measured on
206
+ // a pack shaped like #707's: `part "block" is opaque in every one of
207
+ // its 12x8 texels`, over **0 of 96**, on art carrying 36 clear texels
208
+ // where it was packed. That is the message-as-UI defect in one line —
209
+ // the reader is sent to re-export a part whose alpha was never the
210
+ // problem, and the rectangle that is the problem belongs to A06.
211
+ //
212
+ // ⚠️ It is a FAIL rather than a SKIP, and the report's own shape
213
+ // decides that rather than taste. `skip()` is per ASSERTION, so
214
+ // skipping here would delete the verdicts on every other part of the
215
+ // page — on that same pack the second part is genuinely opaque and is
216
+ // named — and adding a skip BESIDE those failures puts A19 in two of
217
+ // the four buckets `reportLines` adds up, which prints `45 assertions`
218
+ // where the registry holds 44. What is left is a failure that says
219
+ // what was not measured, which is also what "green means measured"
220
+ // requires: a part this rule could not read must not be certified by
221
+ // it.
222
+ const plate = decoded();
223
+ if (plate === null) continue;
224
+ // 🚨 **The scan's coordinates are the atlas's, so a file that is not the
225
+ // declared grid is a non-measurement for every region on it** (issue
226
+ // #715), and #705's clause above does not cover it. That one fires when
227
+ // a rectangle has NO texel on the page; a page whose image is a rescale
228
+ // of the declared size leaves most rectangles partly on it, at
229
+ // coordinates that address a different part of the picture. Measured on
230
+ // a two-region pack at a uniform 0.5: the opaque part's failure
231
+ // DISAPPEARED — the scan found a transparent texel 32 texels away from
232
+ // it and returned — while the other printed #705's sentence over 0 of
233
+ // 256 texels. A verdict and a silence, both about texels nobody located.
234
+ //
235
+ // A FAIL for #705's reason, word for word: `skip()` is per assertion and
236
+ // would delete the verdicts on every other page in the same report.
237
+ if (plate.width !== page.width || plate.height !== page.height) {
238
+ for (const region of on) {
239
+ if (baseRegions.has(region.name)) continue;
240
+ const { width, height } = pageFootprint(region);
241
+ fail(
242
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
243
+ `part "${region.name}" is not measured: this rule opens the page at the coordinates the atlas ` +
244
+ `states, and page "${page.name}" declares ${page.width}x${page.height} over a ` +
245
+ `${plate.width}x${plate.height} image, so the ${width}x${height} rectangle at ${region.x},` +
246
+ `${region.y} is not where "${region.name}"'s texels are on this file and this rule states ` +
247
+ 'nothing about whether it can draw a transparent pixel. The page grid is ' +
248
+ "A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and it names the ratio and how to declare the page " +
249
+ 'honestly. This is renderer policy, and it belongs to --profile spine-html: the default ' +
250
+ '--profile spine does not run this check.',
251
+ );
252
+ }
253
+ continue;
254
+ }
255
+ for (const region of on) {
256
+ if (baseRegions.has(region.name)) continue;
257
+ const { width, height } = pageFootprint(region);
258
+ const declared = width * height;
259
+ let read = 0;
260
+ let transparent = false;
261
+ for (let y = region.y; y < region.y + height && !transparent; y++) {
262
+ for (let x = region.x; x < region.x + width; x++) {
263
+ if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) continue;
264
+ read++;
265
+ if (plate.get(x, y)[3] < 255) {
266
+ transparent = true;
267
+ break;
268
+ }
269
+ }
270
+ }
271
+ if (transparent) continue;
272
+ // The page's size here is the DECODED image's and not the `size:`
273
+ // line's, because it is the bound this scan actually clipped
274
+ // against; where the two disagree A06 says so in its own sentence.
275
+ if (read === 0) {
276
+ fail(
277
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
278
+ `part "${region.name}" is not measured: this rule read 0 of the ${declared} texels of its ` +
279
+ `${width}x${height} rectangle at ${region.x},${region.y} on page "${page.name}", whose image is ` +
280
+ `${plate.width}x${plate.height}, so it states nothing about whether "${region.name}" can draw a ` +
281
+ "transparent pixel. A region's rectangle is A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and one " +
282
+ 'that runs off its page is refused there by name. This is renderer policy, and it belongs to ' +
283
+ '--profile spine-html: the default --profile spine does not run this check.',
284
+ );
285
+ continue;
286
+ }
287
+ // A rectangle partly on the page states the verdict over the texels
288
+ // it read and says how many of the declared ones that was. A whole
289
+ // rectangle prints the sentence it has always printed, to the byte.
290
+ const over =
291
+ read === declared
292
+ ? `every one of its ${width}x${height} texels on shared page "${page.name}"`
293
+ : `every one of the ${read} texels of its ${width}x${height} rectangle at ${region.x},${region.y} ` +
294
+ `that are on shared page "${page.name}", whose image is ${plate.width}x${plate.height} — the ` +
295
+ `other ${declared - read} of the ${declared} it declares are not on the page and are not ` +
296
+ 'measured here';
297
+ fail(
298
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
299
+ `part "${region.name}" is opaque in ${over}, so it would paint a solid rectangle over whatever is ` +
300
+ `drawn behind it. Re-export the part with transparency and pack again. ${exemption} This is ` +
301
+ 'renderer policy, and it belongs to --profile spine-html: the default --profile spine does not run ' +
302
+ 'this check.',
303
+ );
304
+ }
305
+ continue;
306
+ }
307
+ const info = header.info;
308
+ if (basePages.has(page.name)) continue; // the base plate: opaque is correct
309
+ // The header is the FAST NEGATIVE and only that (#215's rule, unchanged):
310
+ // a colour type 0, 2 or 3 file with no tRNS chunk has nowhere to keep a
311
+ // transparent texel, so it is refused without opening it.
312
+ if (!info.hasTransparency) {
313
+ fail(
314
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
315
+ `part image "${page.name}" cannot be transparent anywhere: it is colour type ${info.colourType} ` +
316
+ `(${colourTypeName(info.colourType)}) with no tRNS chunk, so it would paint a solid rectangle over ` +
317
+ 'whatever is drawn behind it. Re-export it with transparency — as RGBA, or as an indexed or greyscale ' +
318
+ `PNG that keeps its tRNS chunk. ${exemption} This is renderer policy, and it belongs to --profile ` +
319
+ 'spine-html: the default --profile spine does not run this check.',
320
+ );
321
+ continue;
322
+ }
323
+ // 🚨 **A header that says "could be transparent" is not an answer, and
324
+ // the texels decide it exactly as they do on a shared page** (issue
325
+ // #777). Until this the loose route stopped here, so a part saved as
326
+ // RGBA passed whether or not any texel used the channel: on the
327
+ // articulated fixture, stageless, an overlay rewritten as colour type 6
328
+ // with 0 of its 16,000 texels below full alpha PASSED loose and was
329
+ // refused by `--pack` of the same rig, over the same texels. The runtime
330
+ // draws those texels, not the file's declaration, so the loose verdict
331
+ // was the false green. The rectangle is the whole decoded image — on a
332
+ // loose page the file IS the part, so no atlas coordinate is read and
333
+ // #705's and #715's non-measurements cannot arise here — and the scan
334
+ // stops at the first clear texel, as the shared page's does.
335
+ const plate = decoded();
336
+ if (plate === null) continue;
337
+ let transparent = false;
338
+ for (let y = 0; y < plate.height && !transparent; y++) {
339
+ for (let x = 0; x < plate.width; x++) {
340
+ if (plate.get(x, y)[3] < 255) {
341
+ transparent = true;
342
+ break;
343
+ }
344
+ }
345
+ }
346
+ if (transparent) continue;
347
+ const holds = info.hasAlpha
348
+ ? `colour type ${info.colourType} (${colourTypeName(info.colourType)}) carries an alpha channel`
349
+ : `colour type ${info.colourType} (${colourTypeName(info.colourType)}) carries a tRNS chunk`;
350
+ fail(
351
+ 'A19_OVERLAY_PNGS_HAVE_ALPHA',
352
+ `part image "${page.name}" is opaque in every one of its ${plate.width}x${plate.height} texels, so it ` +
353
+ `would paint a solid rectangle over whatever is drawn behind it: its file can hold transparency — ${holds} ` +
354
+ '— and no texel uses it. Re-export the part with the transparency it is meant to have. ' +
355
+ `${exemption} This is renderer policy, and it belongs to --profile spine-html: the default --profile ` +
356
+ 'spine does not run this check.',
357
+ );
358
+ }
359
+ if (failures > 0 || unread.length + unreadable.length === 0) return;
360
+ skip('A19_OVERLAY_PNGS_HAVE_ALPHA', unreadAlpha(pagesWithParts, unread, unreadable));
361
+ }
362
+
363
+ /**
364
+ * The SKIP's reason over the pages carrying a part that this rule did not
365
+ * read: `missing` (not on disk, which A17 names — issue #1055) and
366
+ * `unreadable` (on disk and not a PNG rigc can read, which A06 names — issue
367
+ * #1064). With only missing pages it is #1055's sentence to the byte; each
368
+ * group names its own owner, so one file is pointed at the one rule that
369
+ * names it.
370
+ */
371
+ function unreadAlpha(pagesWithParts: number, missing: readonly string[], unreadable: readonly string[]): string {
372
+ const A17 = 'A17_ATLAS_PAGE_FILES_EXIST';
373
+ const A06 = 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG';
374
+ const names = (pages: readonly string[]): string => pages.map((name) => JSON.stringify(name)).join(', ');
375
+ const total = missing.length + unreadable.length;
376
+ if (total === pagesWithParts) {
377
+ if (unreadable.length === 0) {
378
+ return (
379
+ `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
380
+ `${A17} names file by file, and that is not a pass while every part is unread`
381
+ );
382
+ }
383
+ if (missing.length === 0) {
384
+ return (
385
+ `no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part can be read as PNG, ` +
386
+ `which ${A06} names file by file, and that is not a pass while every part is unread`
387
+ );
388
+ }
389
+ return (
390
+ `no part's alpha was read: of the ${pagesWithParts} page file(s) carrying a part, ${missing.length} are not ` +
391
+ `on disk, which ${A17} names file by file, and ${unreadable.length} cannot be read as PNG, which ${A06} ` +
392
+ 'names file by file, and that is not a pass while every part is unread'
393
+ );
394
+ }
395
+ const because = [
396
+ ...(missing.length > 0 ? [`${names(missing)} — because the file is not on disk, which ${A17} names`] : []),
397
+ ...(unreadable.length > 0 ? [`${names(unreadable)} — because the file cannot be read as PNG, which ${A06} names`] : []),
398
+ ].join(', and — ');
399
+ return (
400
+ `${total} of the ${pagesWithParts} page(s) carrying a part were not read — ${because}. Every part on the ` +
401
+ `${pagesWithParts - total} page(s) that were read can draw a transparent pixel, and that is not a pass while ` +
402
+ "a part's alpha is unread"
403
+ );
404
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * A20, the body (issue #1025, step 4c of #380): a weighted mesh's weights
3
+ * cohere — every vertex bound, every weight finite and non-negative, every
4
+ * vertex's weights summing to 1 — and, under `spine-html`, a generated mesh is
5
+ * weighted, binds only bones that move it and binds every bone it declares.
6
+ *
7
+ * Moved out of `src/validate.ts` clause by clause, every clause about the
8
+ * weights themselves moving: a document's mesh names each vertex's bindings,
9
+ * so a vertex count that disagrees with the `uvs`, an empty binding list, a
10
+ * weight that is not finite, below 0 or 0 on a generated mesh, a sum off 1 and
11
+ * a declared bone no vertex binds each have a place to live there (the reader
12
+ * refuses the first three by the record's name). The one clause that does not
13
+ * — a binding's index past the bone array — is about the encoding: the
14
+ * document names a binding's bone, and the index is the emitter's to write.
15
+ * It stays with the round trip, which hands its finding in on the binding
16
+ * (`MeshBinding.encoding`), printed where the body always printed it.
17
+ *
18
+ * The weights are the ones the runtime holds — float32 — and a binding's bone
19
+ * its index in the roster, because the weight-0 clause prints it.
20
+ */
21
+ import type { Verdicts } from '../harness.ts';
22
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
23
+ import { meshKindOf } from '../mesh_kinds.ts';
24
+ import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
25
+ import type { RigInfo } from '../../types.ts';
26
+
27
+ export function a20MeshWeightsCoherent({ fail, skip }: Verdicts, { meshes, bones }: MeshFacts, policy: boolean, rig: RigInfo | undefined): void {
28
+ if (meshes.length === 0) return skip('A20_MESH_WEIGHTS_COHERENT', SKIP_NO_MESH_ATTACHMENT);
29
+ for (const mesh of meshes) {
30
+ // 🚨 Authored geometry is not rigc's to have opinions about. The two
31
+ // policy branches in this assertion are both statements about what a
32
+ // rigc GENERATOR is supposed to produce — "a mesh here is weighted",
33
+ // "a generated mesh binds only bones that move it" — and neither is a
34
+ // fact about Spine or about somebody else's mesh. Applying them to
35
+ // authored geometry failed correct data (issue #44). The coherence
36
+ // rules below the branch are unconditional and still apply.
37
+ const generated = meshKindOf(mesh, rig) !== 'authored';
38
+ if (!mesh.weights) {
39
+ // 📐 PROFILE. An unweighted mesh is perfectly valid Spine — spineboy
40
+ // ships two — and the runtime poses it from the slot bone. What is
41
+ // NOT valid, in any profile, is a weighted mesh whose weights do not
42
+ // cohere, which is everything below this branch. So the requirement
43
+ // that a mesh be weighted at all is the policy half, and it is the
44
+ // only half gated here.
45
+ if (policy && generated) {
46
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" is unweighted; the ring tier drives meshes by bones`);
47
+ }
48
+ continue;
49
+ }
50
+ const perVertex = mesh.weights;
51
+ const expected = mesh.worldVerticesLength / 2;
52
+ if (perVertex.length !== expected) {
53
+ fail(
54
+ 'A20_MESH_WEIGHTS_COHERENT',
55
+ `mesh "${mesh.name}" has weights for ${perVertex.length} vertices but ${expected} uv pairs`,
56
+ );
57
+ continue;
58
+ }
59
+ perVertex.forEach((vertex, i) => {
60
+ if (!vertex.length) fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} has no bones`);
61
+ let sum = 0;
62
+ for (const { bone, weight, encoding } of vertex) {
63
+ if (!Number.isFinite(weight) || weight < 0) {
64
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} has weight ${weight}`);
65
+ }
66
+ // 📐 PROFILE. A weight of exactly 0 is legal, harmless Spine: the
67
+ // runtime accumulates `(…) * weight` (Attachment.js:131), so the
68
+ // binding contributes nothing. The Spine editor writes them — the
69
+ // auto-weighted meshes in 6-arcs, 7-anticipation and 8-follow-through
70
+ // carry dozens, and their vertex weights still sum to 1. Treating one
71
+ // as corruption failed three rungs of the ladder on correct data.
72
+ // In a rigc-GENERATED ring or ribbon it is still a defect: the
73
+ // generator bound a bone that does nothing, which is a bug in the
74
+ // generator and dead work in the runtime's inner loop. So it stays a
75
+ // failure under spine-html and is not one under spine.
76
+ else if (policy && generated && weight === 0) {
77
+ fail(
78
+ 'A20_MESH_WEIGHTS_COHERENT',
79
+ `mesh "${mesh.name}" vertex ${i} is bound to bone index ${bone} at weight 0; a generated mesh binds only bones that move it`,
80
+ );
81
+ }
82
+ // The index's range is the encoding's, kept with the round trip (the header).
83
+ if (encoding !== undefined) fail('A20_MESH_WEIGHTS_COHERENT', encoding);
84
+ sum += weight;
85
+ }
86
+ if (Math.abs(sum - 1) > 1e-3) {
87
+ fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} weights sum to ${sum.toFixed(4)}`);
88
+ }
89
+ });
90
+ // 📐 PROFILE, and the converse of the weight-0 branch above: that one
91
+ // says a generated mesh binds only bones that MOVE it, and this one says
92
+ // every bone it declares moves it. They are halves of one sentence — the
93
+ // bone set the mesh declares is the bone set its weights reference — so
94
+ // they are one assertion rather than two, and neither is a fact about
95
+ // Spine: a declared bone nothing binds loads and renders perfectly.
96
+ //
97
+ // 🚨 It is the one mesh question a vertex cannot answer, which is why
98
+ // every per-vertex rule above was green on the ring that raised it: a
99
+ // rig-spec ring naming two grips bound one of them, and the absent bone
100
+ // appears in no vertex, in no sum and in no index (issue #684). The
101
+ // declaration comes from the compiler's own record of what it bound,
102
+ // which is what the `MESH` report line prints.
103
+ if (policy && generated && rig) {
104
+ const declared = rig.meshDeclaredBones[mesh.slot] || [];
105
+ const bound = new Set<string>();
106
+ for (const vertex of perVertex) {
107
+ for (const { bone } of vertex) {
108
+ const named = bones[bone];
109
+ if (named !== undefined) bound.add(named);
110
+ }
111
+ }
112
+ for (const name of declared) {
113
+ if (bound.has(name)) continue;
114
+ fail(
115
+ 'A20_MESH_WEIGHTS_COHERENT',
116
+ `mesh "${mesh.name}" declares bone "${name}" and none of its ${perVertex.length} vertices binds it; ` +
117
+ `the weights reference ${[...bound].map((n) => `"${n}"`).join(', ')}`,
118
+ );
119
+ }
120
+ }
121
+ }
122
+ }