rig-c 0.0.0-stage → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1191 -0
  185. package/src/meshquality.ts +2051 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1444 -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
package/src/repack.ts ADDED
@@ -0,0 +1,495 @@
1
+ /**
2
+ * repack — a packed build's own output lifted back into parts, and the two
3
+ * comparisons that say a repack of it lost nothing (issue #1169).
4
+ *
5
+ * `rigc repack <build>` (`src/cli/repack.ts`) is three steps a consumer that
6
+ * keeps only `skeleton.json`, `skeleton.atlas` and the page PNGs worked out by
7
+ * hand: cut every region off its page by its atlas bounds, `ingest` the
8
+ * skeleton with `--art loose`, `build --pack` from the two specs and the cut
9
+ * parts. This module is the half of that which is not a command: what the
10
+ * atlas has to look like for the cut to be exact (`atlasRefusals`), the cut
11
+ * itself (`liftAtlas`, which is `extractRegion` per region), and the two
12
+ * comparisons the command makes before it writes anything — every region
13
+ * lifted off the NEW pages against the same region lifted off the OLD ones
14
+ * (`regionDifferences`), and the rebuilt skeleton against the input's
15
+ * (`skeletonDifferences`). The third check is the gate, which is `build`'s and
16
+ * is not restated here.
17
+ *
18
+ * ## What "exact" means, and why each refusal is one
19
+ *
20
+ * A region is lifted to `originalWidth x originalHeight` with its kept
21
+ * rectangle at its trim offset and the rest transparent (`extractRegion`), so
22
+ * a turned region (`rotate: 90`, `180`, `270`) and a region trimmed of
23
+ * whitespace (`offsets:`) come off the page as the drawing the runtime draws —
24
+ * `PKR02` holds that mapping against spine-core's sampling on every corpus
25
+ * region. rigc's packer then places that drawing unturned and untrimmed, which
26
+ * is a change of PLACEMENT only: the pack owns placement.
27
+ *
28
+ * What it cannot carry is anything the repacked atlas would state differently
29
+ * besides placement, because `writeAtlasText` writes exactly `size`, `filter:
30
+ * Linear, Linear` and `pma: false` per page and `bounds`, `offsets` and
31
+ * `rotate: 0` per region. So a page that states anything else — `scale:` other
32
+ * than 1 (the texels are coarser than the drawings, and the repacked page would
33
+ * say they are not), `pma: true` (the texels are premultiplied, and the
34
+ * repacked page would say they are straight), another `filter`, a `format` or
35
+ * a `repeat` — and a region that states `index`, `split` or `pad` are refused
36
+ * by name: the repack would drop or contradict a fact the input states. A
37
+ * region the atlas names twice, or two names one case-insensitive file system
38
+ * writes to one file, cannot be lifted to one part per name. A region whose
39
+ * rectangle leaves its page, whose offsets leave its drawing, or whose page
40
+ * PNG is missing or not the size the atlas declares, would be lifted from
41
+ * texels the atlas does not describe.
42
+ *
43
+ * 🔒 Every refusal is collected before the first file is written, and the
44
+ * command writes nothing when there is one. Nothing here guesses: there is no
45
+ * "close enough" cut.
46
+ *
47
+ * No clock, no randomness, no network, no spine-core. It reads the pages it is
48
+ * pointed at (`readPlate`), as `compile` reads the parts.
49
+ */
50
+ import { type AtlasPage, type AtlasRegion, extractRegion, footprintCell, packFootprints, pageFootprint, type ParsedAtlas, readEntry } from './atlas.ts';
51
+ import { Plate, readPlate } from '../tools/plate.ts';
52
+ import { existsSync, mkdirSync } from 'node:fs';
53
+ import { dirname, join, resolve } from 'node:path';
54
+
55
+ /**
56
+ * A repack refused: an input it cannot lift exactly, a repack that lost
57
+ * something, or an invocation that contradicts itself. `status` is the exit
58
+ * code: 1 when a file was not what a repack needs (the invocation was fine),
59
+ * 2 when the invocation has to change.
60
+ */
61
+ export class RepackError extends Error {
62
+ constructor(
63
+ message: string,
64
+ readonly status: 1 | 2 = 1,
65
+ ) {
66
+ super(message);
67
+ this.name = 'RepackError';
68
+ }
69
+ }
70
+
71
+ /** The `mkdtemp` prefix of a repack's work directory under `tmpdir()`. */
72
+ export const REPACK_WORK_PREFIX = 'rigc-repack-';
73
+
74
+ /** One `key: values` line, as `readEntry` reads it. */
75
+ interface Entry {
76
+ key: string;
77
+ values: string[];
78
+ }
79
+
80
+ /** One page's field lines and its regions' — the keys `parseAtlasText` drops included. */
81
+ interface PageFields {
82
+ page: AtlasPage;
83
+ fields: Entry[];
84
+ regions: Array<{ raw: string; fields: Entry[] }>;
85
+ }
86
+
87
+ /**
88
+ * Every page's and every region's field lines, walked the way `parseAtlasText`
89
+ * walks them — from each page's name line, entries until one that is not, then
90
+ * a region per name line until a blank line — with its `readEntry`. Held to
91
+ * the parse: a page whose walked regions are not the parsed ones, by count and
92
+ * raw name, is an internal error rather than a reading.
93
+ */
94
+ function atlasFields(parsed: ParsedAtlas): PageFields[] {
95
+ const { lines } = parsed;
96
+ const at = (i: number): string | null => (i < lines.length ? lines[i] : null);
97
+ return parsed.pages.map((page) => {
98
+ let i = page.nameLine + 1;
99
+ const fields: Entry[] = [];
100
+ for (let e = readEntry(at(i)); e !== null; e = readEntry(at(++i))) fields.push(e);
101
+ const regions: PageFields['regions'] = [];
102
+ for (;;) {
103
+ const line = at(i);
104
+ if (line === null || line.trim().length === 0) break;
105
+ const regionFields: Entry[] = [];
106
+ for (let e = readEntry(at(++i)); e !== null; e = readEntry(at(++i))) regionFields.push(e);
107
+ regions.push({ raw: line, fields: regionFields });
108
+ }
109
+ const walked = regions.map((r) => r.raw).join('\n');
110
+ if (regions.length !== page.regions.length || walked !== page.regions.map((r) => r.name).join('\n')) {
111
+ throw new Error(`internal: page "${page.name}" walked ${regions.length} region(s) and parsed ${page.regions.length}`);
112
+ }
113
+ return { page, fields, regions };
114
+ });
115
+ }
116
+
117
+ /** What `writeAtlasText` states on every page, beside `size`: a page stating these and nothing else is one rigc's packer could have written. */
118
+ const PAGE_FIELDS_WRITTEN: Readonly<Record<string, string>> = { filter: 'Linear, Linear', pma: 'false' };
119
+
120
+ /** The region keys whose values are placement or the drawing's own size — the pack's to change. */
121
+ const REGION_PLACEMENT_KEYS = new Set(['bounds', 'xy', 'size', 'offsets', 'offset', 'orig', 'rotate']);
122
+
123
+ /** The `rotate:` values the format defines, and the reading `extractRegion` gives each. */
124
+ const ROTATE_VALUES = new Set(['true', 'false', '0', '90', '180', '270']);
125
+
126
+ /** Why a page field is refused, or null when the repacked atlas states the same thing. */
127
+ function pageFieldRefusal(page: AtlasPage, entry: Entry): string | null {
128
+ const value = entry.values.join(', ');
129
+ if (entry.key === 'size') return null;
130
+ if (entry.key === 'scale') {
131
+ return Number(entry.values[0]) === 1
132
+ ? null
133
+ : `page "${page.name}" states scale: ${value} — its texels are coarser than the drawings the skeleton sizes, and rigc's packer writes every page at scale 1, so the repacked atlas would state that they are not; the format's scale line has no spelling in a rigc pack`;
134
+ }
135
+ if (entry.key === 'pma') {
136
+ return entry.values[0] === 'false'
137
+ ? null
138
+ : `page "${page.name}" states pma: ${value} — its texels are premultiplied by alpha, and rigc's packer writes pma: false, so the same bytes on a repacked page would be read as straight colour and blended differently`;
139
+ }
140
+ const written = PAGE_FIELDS_WRITTEN[entry.key];
141
+ if (written !== undefined && written === value) return null;
142
+ return written !== undefined
143
+ ? `page "${page.name}" states ${entry.key}: ${value}, and rigc's packer writes ${entry.key}: ${written} — the repacked atlas would state another ${entry.key}`
144
+ : `page "${page.name}" states ${entry.key}: ${value}, a line rigc's packer does not write — the repacked atlas would drop it`;
145
+ }
146
+
147
+ /** Why a region field is refused, or null when it is placement the pack owns. */
148
+ function regionFieldRefusal(page: AtlasPage, name: string, entry: Entry): string | null {
149
+ const value = entry.values.join(', ');
150
+ if (entry.key === 'rotate') {
151
+ return ROTATE_VALUES.has(entry.values[0] ?? '')
152
+ ? null
153
+ : `region "${name}" on page "${page.name}" states rotate: ${value}, which is not one of true, false, 0, 90, 180, 270 — the turns the lift is measured at`;
154
+ }
155
+ if (REGION_PLACEMENT_KEYS.has(entry.key)) return null;
156
+ if (entry.key === 'index') {
157
+ return `region "${name}" on page "${page.name}" states index: ${value} — a frame of a numbered series, and rigc's packer writes no index line, so the repacked atlas would drop which frame it is`;
158
+ }
159
+ return `region "${name}" on page "${page.name}" states ${entry.key}: ${value}, a line rigc's packer does not write${entry.key === 'split' || entry.key === 'pad' ? ' (a nine-patch region)' : ''} — the repacked atlas would drop it`;
160
+ }
161
+
162
+ /**
163
+ * Why a region name cannot be the file a lifted part is written to, or null.
164
+ * The part is `<lift>/<name>.png`, which is the `image` `ingest --art loose`
165
+ * names for the attachment that draws the region, so the name is a relative
166
+ * path below the lift directory and nothing else.
167
+ */
168
+ function nameRefusal(raw: string, page: AtlasPage): string | null {
169
+ const name = raw.trim();
170
+ if (raw !== name) return `region ${JSON.stringify(raw)} on page "${page.name}" carries whitespace around its name, which the runtime keeps and an attachment's path does not`;
171
+ const segments = name.split('/');
172
+ if (name.includes('\\') || name.startsWith('/') || segments.some((s) => s === '' || s === '.' || s === '..')) {
173
+ return `region "${name}" on page "${page.name}" is not a relative file name below the lift directory (an empty, "." or ".." segment, a leading "/" or a "\\"), so its part has no file to be written to`;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** Why a region's rectangle cannot be lifted off `plate` exactly, or null. */
179
+ function geometryRefusal(page: AtlasPage, region: AtlasRegion): string | null {
180
+ const name = region.name.trim();
181
+ const foot = pageFootprint(region);
182
+ const where = `region "${name}" on page "${page.name}"`;
183
+ if (region.width <= 0 || region.height <= 0) return `${where} keeps no texel: bounds ${region.width}x${region.height}`;
184
+ if (region.x < 0 || region.y < 0 || region.x + foot.width > page.width || region.y + foot.height > page.height) {
185
+ return (
186
+ `${where} occupies ${foot.width}x${foot.height} at ${region.x},${region.y}, which leaves the ${page.width}x${page.height} page ` +
187
+ `(right edge ${region.x + foot.width}, bottom edge ${region.y + foot.height}) — the lift would read texels the page does not have`
188
+ );
189
+ }
190
+ if (region.offsetX < 0 || region.offsetY < 0 || region.offsetX + region.width > region.originalWidth || region.offsetY + region.height > region.originalHeight) {
191
+ return (
192
+ `${where} keeps ${region.width}x${region.height} at offset ${region.offsetX},${region.offsetY} of a ${region.originalWidth}x${region.originalHeight} drawing, ` +
193
+ 'which does not fit inside it — the lifted part would lose the texels that fall outside'
194
+ );
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** A page of an input atlas: where its PNG is, and the texels — `null` where the refusal says why. */
200
+ export interface InputPage {
201
+ page: AtlasPage;
202
+ path: string;
203
+ plate: Plate | null;
204
+ }
205
+
206
+ /**
207
+ * Every reason this atlas cannot be lifted exactly, in file order — empty when
208
+ * it can — and the pages it read. `atlasDir` is the directory page names
209
+ * resolve against. Reads every page PNG once (a missing one is a refusal), so
210
+ * the lift that follows reuses `pages`.
211
+ */
212
+ export function atlasRefusals(parsed: ParsedAtlas, atlasDir: string): { refusals: string[]; pages: InputPage[] } {
213
+ const refusals: string[] = [];
214
+ if (parsed.regions.length === 0) refusals.push('the atlas names no region, so there is nothing to lift or pack');
215
+ const pages: InputPage[] = [];
216
+ const firstPage = new Map<string, string>();
217
+ const folded = new Map<string, string>();
218
+ for (const { page, fields, regions } of atlasFields(parsed)) {
219
+ const path = resolve(atlasDir, page.name);
220
+ let plate: Plate | null = null;
221
+ if (!existsSync(path)) refusals.push(`page "${page.name}" is not on disk: nothing at ${path}`);
222
+ else {
223
+ plate = readPlate(path);
224
+ if (plate.width !== page.width || plate.height !== page.height) {
225
+ refusals.push(
226
+ `page "${page.name}" declares size ${page.width}x${page.height} and ${path} is ${plate.width}x${plate.height} — ` +
227
+ 'the atlas addresses its regions on the declared grid, so the lift would read other texels',
228
+ );
229
+ }
230
+ }
231
+ pages.push({ page, path, plate });
232
+ for (const entry of fields) {
233
+ const why = pageFieldRefusal(page, entry);
234
+ if (why !== null) refusals.push(why);
235
+ }
236
+ regions.forEach(({ raw, fields: regionFields }, k) => {
237
+ const region = page.regions[k];
238
+ const name = raw.trim();
239
+ const unsafe = nameRefusal(raw, page);
240
+ if (unsafe !== null) refusals.push(unsafe);
241
+ const before = firstPage.get(name);
242
+ if (before !== undefined) {
243
+ refusals.push(`region "${name}" is named twice — on page "${before}" and on page "${page.name}" — so one part per name cannot carry both`);
244
+ } else {
245
+ firstPage.set(name, page.name);
246
+ const lower = name.toLowerCase();
247
+ const other = folded.get(lower);
248
+ if (other !== undefined) {
249
+ refusals.push(`regions "${other}" and "${name}" differ only in case, and a case-insensitive file system writes their two parts to one file`);
250
+ } else folded.set(lower, name);
251
+ }
252
+ for (const entry of regionFields) {
253
+ const why = regionFieldRefusal(page, name, entry);
254
+ if (why !== null) refusals.push(why);
255
+ }
256
+ const geometry = geometryRefusal(page, region);
257
+ if (geometry !== null) refusals.push(geometry);
258
+ });
259
+ }
260
+ return { refusals, pages };
261
+ }
262
+
263
+ /** Every region the atlas names, lifted, in file order, by its (trimmed) name. */
264
+ export interface Lift {
265
+ parts: Map<string, Plate>;
266
+ /** How many were turned on their page, and how many were trimmed of whitespace — what the command line says it read. */
267
+ turned: number;
268
+ trimmed: number;
269
+ }
270
+
271
+ /**
272
+ * Lift every region off `pages` — `atlasRefusals`' pages, which it found
273
+ * nothing wrong with — and, when `liftDir` is given, write each part to
274
+ * `<liftDir>/<name>.png`. One `extractRegion` per region, and nothing else.
275
+ */
276
+ export function liftAtlas(pages: readonly InputPage[], liftDir?: string): Lift {
277
+ const parts = new Map<string, Plate>();
278
+ let turned = 0;
279
+ let trimmed = 0;
280
+ for (const { page, plate } of pages) {
281
+ if (plate === null) throw new Error(`internal: page "${page.name}" was lifted with no texels; atlasRefusals names it`);
282
+ for (const region of page.regions) {
283
+ const name = region.name.trim();
284
+ const part = extractRegion(plate, region);
285
+ parts.set(name, part);
286
+ if (region.degrees !== 0) turned++;
287
+ if (region.width !== region.originalWidth || region.height !== region.originalHeight) trimmed++;
288
+ if (liftDir !== undefined) {
289
+ const file = join(liftDir, `${name}.png`);
290
+ mkdirSync(dirname(file), { recursive: true });
291
+ part.writePng(file);
292
+ }
293
+ }
294
+ }
295
+ return { parts, turned, trimmed };
296
+ }
297
+
298
+ /**
299
+ * The texels of a region a page keeps as that region's own, as a test over
300
+ * the region's drawing (x right, y down from its top-left) — or `null` for
301
+ * every texel of its rectangle. Under `--pack-shape polygon` a region only
302
+ * meshes draw owns its footprint (the mesh's hull and triangles, dilated by
303
+ * the padding) and a neighbour may sit in the rest of its rectangle by design;
304
+ * every other region owns its rectangle.
305
+ */
306
+ export type OwnedTexels = (region: string) => ((x: number, y: number) => boolean) | null;
307
+
308
+ /**
309
+ * The owned texels of a `polygon` pack, by the packer's own two functions:
310
+ * `packFootprints` over the skeleton the pack was made for, and `footprintCell`
311
+ * at the pack's padding — the set `packAtlas` wrote each region's values into
312
+ * last (`extrudeOwned`), so nothing else on the page is that region's.
313
+ */
314
+ export function polygonOwnedTexels(skeletonText: string, parts: ReadonlyMap<string, Plate>, padding: number): OwnedTexels {
315
+ const footprints = packFootprints(skeletonText, (region) => {
316
+ const part = parts.get(region);
317
+ return part === undefined ? undefined : { width: part.width, height: part.height };
318
+ });
319
+ return (region) => {
320
+ const footprint = footprints.get(region);
321
+ const part = parts.get(region);
322
+ if (footprint === undefined || footprint === null || part === undefined) return null;
323
+ const cell = footprintCell(part.width, part.height, padding, footprint);
324
+ if (cell.whole) return null;
325
+ return (x, y) => cell.mask[(y + padding) * cell.width + (x + padding)] === 1;
326
+ };
327
+ }
328
+
329
+ /**
330
+ * Every region of `before` against the region of the same name in `after`,
331
+ * over the texels `owned` says the new page keeps as its own (every texel when
332
+ * it is not given): the count of identical ones, how many were compared over
333
+ * a footprint rather than the whole rectangle, and every difference — a name
334
+ * one side lacks, another size, or the first texel apart with both values —
335
+ * in `before`'s order.
336
+ */
337
+ export function regionDifferences(
338
+ before: ReadonlyMap<string, Plate>,
339
+ after: ReadonlyMap<string, Plate>,
340
+ owned?: OwnedTexels,
341
+ ): { identical: number; byFootprint: number; differences: string[] } {
342
+ const differences: string[] = [];
343
+ let identical = 0;
344
+ let byFootprint = 0;
345
+ for (const [name, old] of before) {
346
+ const next = after.get(name);
347
+ if (next === undefined) {
348
+ differences.push(
349
+ `region "${name}" is not in the repacked atlas — a rebuild packs the regions this skeleton's attachments draw and no other, so a region ` +
350
+ 'nothing here draws (an atlas several skeletons share, a leftover) does not come back',
351
+ );
352
+ continue;
353
+ }
354
+ if (old.width !== next.width || old.height !== next.height) {
355
+ differences.push(`region "${name}" lifts ${old.width}x${old.height} off the input's pages and ${next.width}x${next.height} off the repacked ones`);
356
+ continue;
357
+ }
358
+ const own = owned?.(name) ?? null;
359
+ if (own !== null) byFootprint++;
360
+ const at = firstTexelApart(old, next, own);
361
+ if (at === null) {
362
+ identical++;
363
+ continue;
364
+ }
365
+ const [x, y] = at;
366
+ differences.push(
367
+ `region "${name}": texel ${x},${y}${own === null ? '' : ' (inside the footprint it owns)'} is ${old.get(x, y).join(',')} off the input's pages and ${next.get(x, y).join(',')} off the repacked ones`,
368
+ );
369
+ }
370
+ for (const name of after.keys()) if (!before.has(name)) differences.push(`region "${name}" is in the repacked atlas and not in the input's`);
371
+ return { identical, byFootprint, differences };
372
+ }
373
+
374
+ /** The first texel (x, y) at which two plates of one size differ, in row order, among those `own` keeps (all, when null), or null. */
375
+ function firstTexelApart(a: Plate, b: Plate, own: ((x: number, y: number) => boolean) | null): [number, number] | null {
376
+ for (let i = 0; i < a.data.length; i++) {
377
+ if (a.data[i] !== b.data[i]) {
378
+ const texel = Math.floor(i / 4);
379
+ const x = texel % a.width;
380
+ const y = Math.floor(texel / a.width);
381
+ if (own === null || own(x, y)) return [x, y];
382
+ }
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /** A JSON path as the skeleton spells it: `skeleton.x`, `bones[3].rotation`, `skins[0].attachments["a b"]`. */
388
+ function pathOf(parent: string, key: string | number): string {
389
+ if (typeof key === 'number') return `${parent}[${key}]`;
390
+ const plain = /^[A-Za-z_$][\w$-]*$/.test(key);
391
+ return parent === '' ? (plain ? key : `[${JSON.stringify(key)}]`) : plain ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
392
+ }
393
+
394
+ /** A value for a difference line, short. */
395
+ function shown(value: unknown): string {
396
+ const text = JSON.stringify(value);
397
+ return text.length > 80 ? `${text.slice(0, 77)}...` : text;
398
+ }
399
+
400
+ /**
401
+ * Where two parsed skeletons differ, as `path: X in the input, Y in the
402
+ * rebuild` lines — at most `limit` of them, and the total. Objects by key
403
+ * (a key one side lacks is a difference, and so is the same keys in another
404
+ * order, because the order is in the bytes), arrays by index, numbers and
405
+ * strings by value.
406
+ */
407
+ export function skeletonDifferences(input: unknown, rebuilt: unknown, limit: number): { total: number; lines: string[] } {
408
+ const lines: string[] = [];
409
+ let total = 0;
410
+ const note = (line: string): void => {
411
+ total++;
412
+ if (lines.length < limit) lines.push(line);
413
+ };
414
+ const walk = (a: unknown, b: unknown, path: string): void => {
415
+ const at = path === '' ? 'the top level' : path;
416
+ if (Array.isArray(a) && Array.isArray(b)) {
417
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
418
+ if (i >= a.length) note(`${pathOf(path, i)}: absent from the input, ${shown(b[i])} in the rebuild`);
419
+ else if (i >= b.length) note(`${pathOf(path, i)}: ${shown(a[i])} in the input, absent from the rebuild`);
420
+ else walk(a[i], b[i], pathOf(path, i));
421
+ }
422
+ return;
423
+ }
424
+ if (isObject(a) && isObject(b)) {
425
+ const keysA = Object.keys(a);
426
+ const keysB = Object.keys(b);
427
+ for (const key of keysA) {
428
+ if (!(key in b)) note(`${pathOf(path, key)}: ${shown(a[key])} in the input, absent from the rebuild`);
429
+ else walk(a[key], b[key], pathOf(path, key));
430
+ }
431
+ for (const key of keysB) if (!(key in a)) note(`${pathOf(path, key)}: absent from the input, ${shown(b[key])} in the rebuild`);
432
+ const shared = keysA.filter((k) => k in b);
433
+ const sharedB = keysB.filter((k) => k in a);
434
+ if (shared.join('\n') !== sharedB.join('\n')) note(`${at}: keys in the order ${shown(shared)} in the input and ${shown(sharedB)} in the rebuild`);
435
+ return;
436
+ }
437
+ if (!Object.is(a, b) && JSON.stringify(a) !== JSON.stringify(b)) note(`${at}: ${shown(a)} in the input, ${shown(b)} in the rebuild`);
438
+ };
439
+ walk(input, rebuilt, '');
440
+ return { total, lines };
441
+ }
442
+
443
+ function isObject(v: unknown): v is Record<string, unknown> {
444
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
445
+ }
446
+
447
+ /** The first line two texts differ on, 1-based, with both lines — for two skeletons whose values agree and whose bytes do not. */
448
+ export function firstLineApart(a: string, b: string): string {
449
+ const la = a.split('\n');
450
+ const lb = b.split('\n');
451
+ for (let i = 0; i < Math.max(la.length, lb.length); i++) {
452
+ if (la[i] !== lb[i]) return `line ${i + 1}: ${shown(la[i] ?? '(end of file)')} in the input, ${shown(lb[i] ?? '(end of file)')} in the rebuild`;
453
+ }
454
+ return 'no line differs';
455
+ }
456
+
457
+ /** The region fields the model document's `pages` and the parsed atlas both state, compared one by one. */
458
+ const DOCUMENT_REGION_FIELDS = ['x', 'y', 'width', 'height', 'offsetX', 'offsetY', 'originalWidth', 'originalHeight', 'degrees'] as const;
459
+
460
+ /**
461
+ * Where the atlas disagrees with the `pages` of the model document `build`
462
+ * wrote beside it — the record of where each region was placed, written with
463
+ * the pair and only after the same gate. `null` when the document states no
464
+ * `pages` array; otherwise the regions compared and every disagreement.
465
+ */
466
+ export function documentDisagreements(pagesValue: unknown, parsed: ParsedAtlas): { regions: number; lines: string[] } | null {
467
+ if (!Array.isArray(pagesValue)) return null;
468
+ const lines: string[] = [];
469
+ let regions = 0;
470
+ if (pagesValue.length !== parsed.pages.length) lines.push(`the document states ${pagesValue.length} page(s) and the atlas ${parsed.pages.length}`);
471
+ parsed.pages.forEach((page, p) => {
472
+ const docPage: unknown = pagesValue[p];
473
+ if (!isObject(docPage)) return;
474
+ if (docPage.name !== page.name) lines.push(`page ${p + 1} is "${page.name}" in the atlas and ${shown(docPage.name)} in the document`);
475
+ for (const key of ['width', 'height'] as const) {
476
+ if (docPage[key] !== page[key]) lines.push(`page "${page.name}" ${key}: ${page[key]} in the atlas, ${shown(docPage[key])} in the document`);
477
+ }
478
+ const docRegions = Array.isArray(docPage.regions) ? docPage.regions : [];
479
+ if (docRegions.length !== page.regions.length) lines.push(`page "${page.name}" holds ${page.regions.length} region(s) in the atlas and ${docRegions.length} in the document`);
480
+ page.regions.forEach((region, k) => {
481
+ const docRegion: unknown = docRegions[k];
482
+ if (!isObject(docRegion)) return;
483
+ regions++;
484
+ const name = region.name.trim();
485
+ if (typeof docRegion.name !== 'string' || docRegion.name.trim() !== name) {
486
+ lines.push(`page "${page.name}" region ${k + 1} is "${name}" in the atlas and ${shown(docRegion.name)} in the document`);
487
+ return;
488
+ }
489
+ for (const field of DOCUMENT_REGION_FIELDS) {
490
+ if (docRegion[field] !== region[field]) lines.push(`region "${name}" ${field}: ${region[field]} in the atlas, ${shown(docRegion[field])} in the document`);
491
+ }
492
+ });
493
+ });
494
+ return { regions, lines };
495
+ }