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
package/src/atlas.ts ADDED
@@ -0,0 +1,2870 @@
1
+ /**
2
+ * The atlas as a data structure: read one, pack one, write one.
3
+ *
4
+ * Until issue #4 rigc had exactly one atlas shape — one part, one page, region
5
+ * covers the page — and one function that wrote it (`buildAtlasText` in
6
+ * [`src/compile.ts`](compile.ts)). That is why the npm keyword `atlas` set an
7
+ * expectation the tool did not meet: nine loose PNGs compiled to `pages=9
8
+ * regions=9`, which is correct, valid, and not what anybody means by an atlas.
9
+ *
10
+ * This module holds the three pieces that were missing, and nothing else:
11
+ *
12
+ * * `parseAtlasText` — the format read back in, so an atlas somebody else
13
+ * packed can be an INPUT (`build --atlas-in`);
14
+ * * `packAtlas` — loose part PNGs arranged onto shared pages (`build --pack`);
15
+ * * `writeAtlasText` — the one emitter for both shapes, so the unpacked
16
+ * default and a packed page are written by the same code.
17
+ *
18
+ * ## 🚨 Two invariants this file exists to keep
19
+ *
20
+ * **Packing is an OUTPUT arrangement, never an input contract.** Sizes are still
21
+ * measured from the loose PNGs by `readPngInfo` before anything here runs, the
22
+ * skeleton is compiled from those measurements, and `--pack` changes only where
23
+ * the bytes sit on a page. A packed build's `skeleton.json` is byte-identical to
24
+ * the unpacked one's — that is a property of this split, and the selftest
25
+ * asserts it rather than trusting it.
26
+ *
27
+ * **A packed region is a lossless copy.** Nothing here resamples, scales, trims
28
+ * or rotates: a region's pixels are written to the page unchanged, and the
29
+ * selftest lifts every region back off its page and compares it byte for byte
30
+ * against the loose PNG (`PK02`). See `extrudeCell` for the one thing that has to
31
+ * be ADDED to the page for the render to agree as well, and `PACK_NO_ROTATE` for
32
+ * the field this deliberately does not use.
33
+ *
34
+ * Under `shape: 'polygon'` (issue #1099) the copy is lossless where a region
35
+ * can be SAMPLED: two rectangles may overlap where neither region draws, so a
36
+ * mesh region's rectangle outside its hull may carry a neighbour's texels, and
37
+ * the claim the selftest holds there is that every texel within a tap of what
38
+ * a region draws is its own (`PK79`). See `packAtlas`, *`shape: 'polygon'`*.
39
+ *
40
+ * ⚠️ **The rendered pictures are equal to within one least significant bit, not
41
+ * bit-for-bit, and the difference is arithmetic rather than texels.** Measured: 0
42
+ * to 480 channel samples of 7 to 21 million on the three public fixtures, worst
43
+ * difference **1**, against 22,000 to 60,000 samples and a worst difference of
44
+ * **77** when the gutter is removed — and byte-identical on eleven of the
45
+ * repository's thirteen rigs across 1,101 frames. The two that are not are the
46
+ * two with meshes.
47
+ *
48
+ * 🔬 **Where that last bit is lost, measured (issue #266, follow-up 1).** This
49
+ * paragraph used to say the cause was rigc's own sampling coordinate — `fl(regionX
50
+ * + fl(s · width))` failing to be exact once `regionX > 0` — and that the repair
51
+ * was to sample in region-local coordinates and add the integer page origin to the
52
+ * tap indices. **Both halves are wrong, and the second is unreachable.** Poses of
53
+ * the loose and the packed build of one skeleton, compared coordinate by
54
+ * coordinate as `u · pageWidth − regionX` against the loose `u · pageWidth`:
55
+ *
56
+ * * on every **region** attachment the difference is **exactly 0**. `regionX`,
57
+ * `regionWidth` and `pageWidth` are integers and the page is a power of two,
58
+ * so `u · pageWidth` recovers `regionX + localTexel` with nothing lost —
59
+ * which is why the eleven region-only rigs are already byte-identical, and
60
+ * why `PK18` can assert exactness rather than a bound. That holds for the
61
+ * default power-of-two page only: `pageEdges: 'free'` gives it up, and the
62
+ * region attachments of a free page sit at the mesh's bound of 1 (`PK72`,
63
+ * and `packAtlas`, *free*);
64
+ * * on a **mesh** it is **not** 0 — 88 of 88 coordinates on `gallery/squash`'s
65
+ * ball, worst 3.15e-5 texels — because `spine-core` stores mesh page UVs in a
66
+ * **`Float32Array`**. `MeshAttachment.updateRegion` computes `u +
67
+ * regionUVs[i] · width` and rounds the whole thing to float32, so the low bits
68
+ * of the scaled coordinate are gone **before rigc reads the array**. The
69
+ * counterfactual settles which term does it: the same region at page origin
70
+ * `x = 0` still lands 9.5e-7 texels off the loose value, so it is the `f32(u ·
71
+ * regionWidth / pageWidth)` scaling and not the origin addition.
72
+ *
73
+ * ⇒ **No change to [`src/render.ts`](render.ts) can recover it**, because it reads
74
+ * `piece.uvs` and the information is not in there. The only routes are for rigc to
75
+ * re-derive mesh page UVs from `MeshAttachment.regionUVs` in double precision —
76
+ * a second opinion about the runtime's own trim and rotation mapping, which
77
+ * `src/render.ts` refuses by name (see `artUvsOf`) — or one part per page, which
78
+ * is the unpacked convention. And the first would be worse than the residual: the
79
+ * renderer is the yardstick `check` measures a candidate against *because* it
80
+ * draws what a runtime draws, and a runtime playing this atlas gets the float32
81
+ * numbers. Making two rigc renders agree by disagreeing with the runtime is the
82
+ * wrong trade. So the bound stays a bound, `PK18` attributes it, and the follow-up
83
+ * is closed as measured rather than done.
84
+ *
85
+ * ## Why a second parser for a format `spine-core` already parses
86
+ *
87
+ * `src/compile.ts` must stay independent of the runtime — that is what keeps the
88
+ * compiler and the gate from checking each other's assumptions — so the importer
89
+ * cannot reach for `TextureAtlas`. The reader below therefore states, for every
90
+ * field the format has, what the runtime reads it as — the page and region
91
+ * fields of `dist/TextureAtlas.js` — including the readings that look like
92
+ * mistakes and are not: the page name is trimmed and a region name is the RAW
93
+ * line, a blank line closes a page block, an entry holds at most four values,
94
+ * and `originalWidth/Height` fall back to `width/height` only when BOTH are
95
+ * zero.
96
+ *
97
+ * A second opinion about a format is a liability, so it is measured rather than
98
+ * asserted: `PKR01` parses every `.atlas` in the example corpus with both this
99
+ * reader and `spine-core`'s `TextureAtlas` and compares every page's name, size
100
+ * and `pma` and every region's eleven fields — 10 atlas files, 132 regions, 3
101
+ * of them rotated, 0 fields apart when this was written (issue #1015). If they
102
+ * ever disagree, that control goes red and this file is wrong.
103
+ */
104
+ import { CompileError } from './errors.ts';
105
+ import { Plate, readPlate } from '../tools/plate.ts';
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // reading
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** One region of a parsed atlas, in `TextureAtlasRegion`'s own field names. */
112
+ export interface AtlasRegion {
113
+ /**
114
+ * The region's name, exactly as `TextureAtlas` takes it: the raw line,
115
+ * UNTRIMMED. Every consumer that joins on a name has to trim it itself, and
116
+ * `regionKey` in [`src/render.ts`](render.ts) is the precedent — a file
117
+ * written with CRLF would otherwise name different regions from the same text.
118
+ */
119
+ name: string;
120
+ /** Page-space left edge of the packed rectangle, x right from the page's left. */
121
+ x: number;
122
+ /** Page-space top edge, y DOWN from the page's top. */
123
+ y: number;
124
+ /**
125
+ * The kept rectangle's width, in the DRAWING's orientation.
126
+ *
127
+ * ⚠️ Not the page footprint. At `rotate: 90` / `270` the rectangle on the page
128
+ * is `height x width`; `TextureAtlas` transposes for `u2/v2` at 90 and not at
129
+ * 270, which is a bug in the runtime and the reason every reader here derives
130
+ * the rectangle rather than reading those two numbers. `pageFootprint` below
131
+ * is that derivation, spelled once. This field is the atlas's own meaning of
132
+ * `bounds`, untouched.
133
+ */
134
+ width: number;
135
+ height: number;
136
+ /** Trim offset from the drawing's LEFT edge. */
137
+ offsetX: number;
138
+ /** Trim offset from the drawing's BOTTOM edge — art space runs the other way. */
139
+ offsetY: number;
140
+ /** The untrimmed drawing's size: what an attachment's width/height means. */
141
+ originalWidth: number;
142
+ originalHeight: number;
143
+ /** 0, 90, 180 or 270. A `rotate: true` line reads as 90, which is the format's older spelling. */
144
+ degrees: number;
145
+ /** Sequence index, or 0 for a region that is not part of one. */
146
+ index: number;
147
+ }
148
+
149
+ /** One page of a parsed atlas, with the regions that sit on it. */
150
+ export interface AtlasPage {
151
+ /** The page name, trimmed — the image path as seen from the atlas file. */
152
+ name: string;
153
+ /** Index into the text's line array of the line the name was read from. */
154
+ nameLine: number;
155
+ width: number;
156
+ height: number;
157
+ pma: boolean;
158
+ /**
159
+ * The page's `scale:` line — how much SMALLER these texels are than the
160
+ * drawings they were packed from — or `1` when the page declares none.
161
+ *
162
+ * ⚠️ The second field on this interface that `TextureAtlas` does not have, and
163
+ * for the same kind of reason as `nameLine`: the runtime drops `scale:` because
164
+ * an attachment's size comes out of the skeleton JSON (`region.width =
165
+ * map.width * scale` in `SkeletonJson`, no atlas involved), so a player never
166
+ * needs it. An IMPORTER does. `--atlas-in` derives an attachment's size from
167
+ * the region when the rig spec declares none, and the region's
168
+ * `originalWidth/Height` are in the page's own texels — at `scale: 0.5` they
169
+ * are half the drawing. Reading the line here is what lets the importer state
170
+ * the drawing's size instead of the pack's (issue #267); dropping it is what
171
+ * made an imported `scale: 0.5` pack halve every attachment in silence.
172
+ *
173
+ * `atlasScales` in [`src/render.ts`](render.ts) reads the same field off the
174
+ * raw text for the MAE report, and the selftest holds the two readers to the
175
+ * same answer on every corpus atlas.
176
+ */
177
+ scale: number;
178
+ regions: AtlasRegion[];
179
+ }
180
+
181
+ export interface ParsedAtlas {
182
+ pages: AtlasPage[];
183
+ /** Every region on every page, in file order — `TextureAtlas.regions`'s order. */
184
+ regions: AtlasRegion[];
185
+ /** The text split the way `TextureAtlas` splits it, for `rewritePageNames`. */
186
+ lines: string[];
187
+ }
188
+
189
+ /**
190
+ * The rectangle a region occupies **on its page** — which is not the rectangle
191
+ * its `bounds:` line states.
192
+ *
193
+ * `bounds:` is the kept rectangle in the DRAWING's orientation, so a packer that
194
+ * turned the drawing a quarter turn to fit it wrote `width x height` for a
195
+ * region that covers `height x width` of the page. 180 turns nothing: the
196
+ * footprint is the drawing's own way round at 0 and at 180, and transposed at 90
197
+ * and at 270.
198
+ *
199
+ * ## Why this is one function and was four (issue #579)
200
+ *
201
+ * Four readers in this tree want exactly this rectangle — `windowOf` in
202
+ * [`src/render.ts`](render.ts) fences a substituted piece with it,
203
+ * `resolveFromAtlas` in [`src/compile.ts`](compile.ts) refuses a region that
204
+ * runs off its page by it, and `A06` and `A19` in [`src/validate.ts`](validate.ts)
205
+ * measure a shared page's tiling and open one region's own texels with it — and
206
+ * two of the four transposed at 90 **only**, under a comment that read *"spine-core
207
+ * transposes a region's extent at 90 and not at 270 when it derives the UVs, so
208
+ * the rectangle ON THE PAGE follows the same rule"*.
209
+ *
210
+ * The premise is true and the conclusion does not follow, because the two are
211
+ * different quantities:
212
+ *
213
+ * * what `TextureAtlas` transposes at 90 and not at 270 is `u2`/`v2`
214
+ * (spine-core 4.3.13, `dist/TextureAtlas.js:162-171`) — so at 270 those two
215
+ * numbers do describe a rectangle the page does not have;
216
+ * * but `MeshAttachment.computeUVs` (`dist/attachments/MeshAttachment.js:118-162`)
217
+ * never reads `u2`/`v2` for an atlas region. It branches on `degrees` and
218
+ * derives the span from `originalWidth`/`originalHeight`, transposed at 90
219
+ * **and** at 270 alike. That is the routine that says where a region's texels
220
+ * are, and `extractRegion` below is its inverse.
221
+ *
222
+ * ⚠️ The consequence was not confined to a printed number, which is what the
223
+ * card assumed. A19 opens a shared page and scans one region's own rectangle for
224
+ * a transparent texel: at 270 it scanned `width x height` where the drawing
225
+ * occupies `height x width`, ran off the part into the transparent gutter, found
226
+ * its texel there and **named nothing**. Two fully opaque parts on one turned
227
+ * page — the exact defect A19 exists for — were measured green at `rotate: 270`
228
+ * and red at 0, 90 and 180.
229
+ *
230
+ * Structurally typed rather than taking `AtlasRegion`, because two of the four
231
+ * callers hold spine-core's `TextureAtlasRegion` instead and this file
232
+ * deliberately does not link the runtime.
233
+ */
234
+ export function pageFootprint(region: { width: number; height: number; degrees: number }): {
235
+ width: number;
236
+ height: number;
237
+ } {
238
+ const turned = region.degrees === 90 || region.degrees === 270;
239
+ return turned
240
+ ? { width: region.height, height: region.width }
241
+ : { width: region.width, height: region.height };
242
+ }
243
+
244
+ /**
245
+ * `TextureAtlasReader.readEntry`, to the value.
246
+ *
247
+ * Returns the number of values, 0 when the line is blank or carries no colon —
248
+ * which is the signal the caller uses to decide "this is a name line, not a
249
+ * field". Four values maximum, because that is where the runtime stops (`if (i
250
+ * === 4) return 4`) and a fifth would silently mean something here that it does
251
+ * not mean there.
252
+ *
253
+ * Exported for `src/repack.ts` (issue #1169), which reads the keys a page and a
254
+ * region state — the ones this parser drops (`filter`, `format`, `repeat`,
255
+ * `split`, `pad`) included — with this function rather than a second spelling
256
+ * of the format, and holds what it walked to `parseAtlasText`'s regions.
257
+ */
258
+ export function readEntry(line: string | null): { key: string; values: string[] } | null {
259
+ if (line === null) return null;
260
+ const trimmed = line.trim();
261
+ if (trimmed.length === 0) return null;
262
+ const colon = trimmed.indexOf(':');
263
+ if (colon === -1) return null;
264
+ const key = trimmed.slice(0, colon).trim();
265
+ const values: string[] = [];
266
+ let lastMatch = colon + 1;
267
+ for (;;) {
268
+ const comma = trimmed.indexOf(',', lastMatch);
269
+ if (comma === -1) {
270
+ values.push(trimmed.slice(lastMatch).trim());
271
+ break;
272
+ }
273
+ values.push(trimmed.slice(lastMatch, comma).trim());
274
+ lastMatch = comma + 1;
275
+ if (values.length === 4) break;
276
+ }
277
+ return { key, values };
278
+ }
279
+
280
+ /** `parseInt` with the runtime's own tolerance: a bad number reads as NaN there too. */
281
+ function int(text: string | undefined): number {
282
+ return parseInt(text ?? '', 10);
283
+ }
284
+
285
+ /**
286
+ * Read an atlas file's text into pages and regions.
287
+ *
288
+ * The format as the runtime reads it, field for field, and deliberately a dull
289
+ * reader — every branch below states a reading `PKR01` holds against
290
+ * `TextureAtlas`'s parse of the corpus (see the header). Two additions, both of
291
+ * them fields a PLAYER has no use for and an IMPORTER does: `nameLine`, which
292
+ * `rewritePageNames` needs, and the page's `scale:`, which is what turns a
293
+ * region's texels back into the drawing's own size (`AtlasPage.scale`).
294
+ */
295
+ export function parseAtlasText(text: string): ParsedAtlas {
296
+ const lines = text.split(/\r\n|\r|\n/);
297
+ let at = 0;
298
+ const readLine = (): string | null => (at >= lines.length ? null : lines[at++]);
299
+
300
+ const pages: AtlasPage[] = [];
301
+ const regions: AtlasRegion[] = [];
302
+
303
+ let line = readLine();
304
+ // Ignore empty lines before the first entry.
305
+ while (line !== null && line.trim().length === 0) line = readLine();
306
+ // Header entries, which the runtime silently ignores. A first line with no
307
+ // colon IS the first page name and ends this loop without being consumed.
308
+ while (line !== null && line.trim().length > 0 && readEntry(line) !== null) line = readLine();
309
+
310
+ let page: AtlasPage | null = null;
311
+ for (;;) {
312
+ if (line === null) break;
313
+ if (line.trim().length === 0) {
314
+ page = null;
315
+ line = readLine();
316
+ continue;
317
+ }
318
+ if (!page) {
319
+ page = { name: line.trim(), nameLine: at - 1, width: 0, height: 0, pma: false, scale: 1, regions: [] };
320
+ for (;;) {
321
+ line = readLine();
322
+ const entry = readEntry(line);
323
+ if (entry === null) break;
324
+ if (entry.key === 'size') {
325
+ page.width = int(entry.values[0]);
326
+ page.height = int(entry.values[1]);
327
+ } else if (entry.key === 'pma') {
328
+ page.pma = entry.values[0] === 'true';
329
+ } else if (entry.key === 'scale') {
330
+ // The one key this reader takes that the runtime's `pageFields` does
331
+ // not — see `AtlasPage.scale`. A value that is not a positive finite
332
+ // number leaves the default of 1 rather than poisoning every size
333
+ // derived from it: `scale: 0` would divide the pack into infinity, and
334
+ // a page whose own scale line is unreadable is a page whose texels are
335
+ // the only measurement left.
336
+ const value = Number(entry.values[0]);
337
+ if (Number.isFinite(value) && value > 0) page.scale = value;
338
+ }
339
+ // `format`, `filter` and `repeat` are read by the runtime into fields no
340
+ // consumer here has; they pass through `rewritePageNames` untouched.
341
+ }
342
+ pages.push(page);
343
+ continue;
344
+ }
345
+ const region: AtlasRegion = {
346
+ name: line,
347
+ x: 0,
348
+ y: 0,
349
+ width: 0,
350
+ height: 0,
351
+ offsetX: 0,
352
+ offsetY: 0,
353
+ originalWidth: 0,
354
+ originalHeight: 0,
355
+ degrees: 0,
356
+ index: 0,
357
+ };
358
+ for (;;) {
359
+ line = readLine();
360
+ const entry = readEntry(line);
361
+ if (entry === null) break;
362
+ switch (entry.key) {
363
+ case 'xy':
364
+ region.x = int(entry.values[0]);
365
+ region.y = int(entry.values[1]);
366
+ break;
367
+ case 'size':
368
+ region.width = int(entry.values[0]);
369
+ region.height = int(entry.values[1]);
370
+ break;
371
+ case 'bounds':
372
+ region.x = int(entry.values[0]);
373
+ region.y = int(entry.values[1]);
374
+ region.width = int(entry.values[2]);
375
+ region.height = int(entry.values[3]);
376
+ break;
377
+ case 'offset':
378
+ region.offsetX = int(entry.values[0]);
379
+ region.offsetY = int(entry.values[1]);
380
+ break;
381
+ case 'orig':
382
+ region.originalWidth = int(entry.values[0]);
383
+ region.originalHeight = int(entry.values[1]);
384
+ break;
385
+ case 'offsets':
386
+ region.offsetX = int(entry.values[0]);
387
+ region.offsetY = int(entry.values[1]);
388
+ region.originalWidth = int(entry.values[2]);
389
+ region.originalHeight = int(entry.values[3]);
390
+ break;
391
+ case 'rotate':
392
+ if (entry.values[0] === 'true') region.degrees = 90;
393
+ else if (entry.values[0] !== 'false') region.degrees = int(entry.values[0]);
394
+ break;
395
+ case 'index':
396
+ region.index = int(entry.values[0]);
397
+ break;
398
+ default:
399
+ break; // an unknown field becomes names/values in the runtime; nothing here reads them
400
+ }
401
+ }
402
+ // BOTH zero, not either: a region 40 wide and 0 tall keeps its declared zero.
403
+ if (region.originalWidth === 0 && region.originalHeight === 0) {
404
+ region.originalWidth = region.width;
405
+ region.originalHeight = region.height;
406
+ }
407
+ page.regions.push(region);
408
+ regions.push(region);
409
+ }
410
+
411
+ return { pages, regions, lines };
412
+ }
413
+
414
+ /**
415
+ * The region an attachment draws under a name, and the page it sits on —
416
+ * `TextureAtlas.findRegion` and `region.page`, as the loader resolves a
417
+ * region, a mesh and each frame of a series (issue #967).
418
+ *
419
+ * The FIRST region of that name in file order: an atlas naming two regions
420
+ * alike draws the first (measured through spine-core 4.3.13's loader, whose
421
+ * attachment drew the first of two regions named `seq`, and whose `index:`
422
+ * lines did not enter the lookup — a series' frame is found by its NAME,
423
+ * `frameRegionName` in [`src/core/uvs.ts`](core/uvs.ts)). The name is matched
424
+ * exactly as `parseAtlasText` keeps it, the raw line: a region line `art `
425
+ * is not found as `art`, as the runtime does not find it.
426
+ *
427
+ * Returned as a function over the parsed atlas so the core — which reads no
428
+ * file and links nothing — takes it as an input (`UvLookup`); the page UVs
429
+ * themselves are the core's (`regionPageUvs`, `computeUvs`), measured there.
430
+ */
431
+ export function atlasRegionLookup(parsed: ParsedAtlas): (name: string) => { page: AtlasPage; region: AtlasRegion } | null {
432
+ const first = new Map<string, { page: AtlasPage; region: AtlasRegion }>();
433
+ for (const page of parsed.pages) {
434
+ for (const region of page.regions) if (!first.has(region.name)) first.set(region.name, { page, region });
435
+ }
436
+ return (name) => first.get(name) ?? null;
437
+ }
438
+
439
+ /**
440
+ * The same atlas text with every page's name line replaced.
441
+ *
442
+ * This is how `--atlas-in` emits: the imported atlas passes through verbatim —
443
+ * every field, every region, every page, in its own order — and only the page
444
+ * NAMES move, because they are paths and the file has been re-anchored to a new
445
+ * directory. Its blank lines are then put in rigc's own shape by
446
+ * `canonicalAtlasShape` (issue #803); this function leaves them as they were. Rewriting by line index rather than by
447
+ * re-serialising is the point: a re-serialiser would have to understand every
448
+ * field it re-emits, and the ones it did not understand would quietly vanish
449
+ * (`scale:` is the expensive example — [`atlasScales`](render.ts) reports it, and
450
+ * a pack that is coarser than its drawings would stop saying so).
451
+ *
452
+ * `rename` is handed the page's INDEX as well as its name, because a name is not
453
+ * a key: nothing in the format forbids two pages spelling the same path, and a
454
+ * caller that has already decided one new name per page (`copyAtlasPages` in
455
+ * [`emit.ts`](emit.ts) assigns the copies' filenames in page order) would then
456
+ * hand both of them the first decision. The index is the page's identity here;
457
+ * the name is data.
458
+ */
459
+ export function rewritePageNames(parsed: ParsedAtlas, rename: (name: string, index: number) => string): string {
460
+ const out = parsed.lines.slice();
461
+ parsed.pages.forEach((page, index) => {
462
+ out[page.nameLine] = rename(page.name, index);
463
+ });
464
+ return out.join('\n');
465
+ }
466
+
467
+ /**
468
+ * The same atlas text in the whitespace shape `writeAtlasText` writes: no blank
469
+ * line before the first entry, exactly one between two blocks, none after the
470
+ * last, and one trailing newline. Every non-blank line is kept byte for byte and
471
+ * in its own order — only blank lines are dropped or collapsed — so a text
472
+ * already in that shape comes back unchanged.
473
+ *
474
+ * This is what `--atlas-in` re-emits (issue #803). A pack's blank lines are its
475
+ * packer's, not its content: a 3.8-era packer begins every file with one, and
476
+ * `A07_ATLAS_TEXT_SHAPE` — which checks the text rigc writes — refuses a blank
477
+ * line before the first page block, two side by side, and one after the last
478
+ * page block, each by its own sentence. What makes dropping them safe is what
479
+ * they mean to the reader that owns the format: in `TextureAtlas` a run of
480
+ * blank lines ends a page block exactly as one blank line does, and a run before
481
+ * the first page is read as nothing. Measured through the runtime, `"\n" + text`, `"\n\n" + text`
482
+ * and `text` load to the same pages and regions (`PKR63`, `PKR64`).
483
+ *
484
+ * ⚠️ One reading is NOT the same, and it moves toward rigc's: the runtime's
485
+ * leading-blank loop (`while (line && …)`) stops on an empty string, so a file
486
+ * that opens `"\n"` and then a header entry (`key: value` before the first page
487
+ * name) reads the header as a page name there, while `parseAtlasText` — which the
488
+ * compile took its geometry from — skips the blank and reads it as a header.
489
+ * Emitting without the blank makes the file say to the runtime what rigc measured.
490
+ *
491
+ * A blank line is one the runtime reads as blank: `trim()` is empty. One that
492
+ * carries only spaces is written as the empty line, because that is the blank
493
+ * line's one spelling in `writeAtlasText`.
494
+ */
495
+ export function canonicalAtlasShape(text: string): string {
496
+ const out: string[] = [];
497
+ for (const line of text.split(/\r\n|\r|\n/)) {
498
+ if (line.trim().length > 0) out.push(line);
499
+ else if (out.length > 0 && out[out.length - 1] !== '') out.push('');
500
+ }
501
+ while (out.length > 0 && out[out.length - 1] === '') out.pop();
502
+ return out.length === 0 ? '' : `${out.join('\n')}\n`;
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // writing
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /** A region as the emitter states it: everything `writeAtlasText` puts on the page. */
510
+ export interface EmitRegion {
511
+ name: string;
512
+ x: number;
513
+ y: number;
514
+ width: number;
515
+ height: number;
516
+ offsetX: number;
517
+ offsetY: number;
518
+ originalWidth: number;
519
+ originalHeight: number;
520
+ }
521
+
522
+ export interface EmitPage {
523
+ name: string;
524
+ width: number;
525
+ height: number;
526
+ regions: EmitRegion[];
527
+ }
528
+
529
+ /**
530
+ * ⛔ The packer never rotates, so `rotate: 0` is a fact rather than a field.
531
+ *
532
+ * The format supports `rotate: 90` and the Spine packer uses it — every official
533
+ * example that has a tall thin part ships one. rigc's does not, for reasons that
534
+ * are about honesty rather than difficulty:
535
+ *
536
+ * * `artUvsOf` ([`src/render.ts`](render.ts)) already refuses a rotated region,
537
+ * because `RegionAttachment.computeUVs` assigns a different corner order at
538
+ * 90° and `TextureAtlas` transposes `u2/v2` at 90 and not at 270. A packer
539
+ * that emitted rotation would be writing atlases its own `--atlas`
540
+ * substitution cannot read;
541
+ * * the whole feature is gated on how closely the packed render matches the
542
+ * unpacked one, and a transposed region is sampled through a different
543
+ * mapping — the gate would then be measuring the mapping rather than the pack.
544
+ *
545
+ * Rotation buys page area on a set of parts whose aspect ratios differ a lot. It
546
+ * is not free and it is not implemented; a page that runs out of room spills to a
547
+ * second page instead.
548
+ *
549
+ * 🔬 **Measured for issue #860, and held back on the measurement.** A pack that
550
+ * turned regions `rotate: 90` lifted every one of them back byte for byte through
551
+ * `extractRegion` (22 of 22 and 20 of 20 on two painting rigs, 12 turned on each),
552
+ * and rigc's renderer drew the turned region attachments with 0 differing pixels
553
+ * against the unturned pack. But on neither rig did a turn shrink the page: both
554
+ * were already on the smallest power-of-two page their area allows, and a turn
555
+ * that buys no area only moves bytes. Shipping it would also need two clauses
556
+ * changed that say rigc never turns a region — `A06`'s under `spine-html`
557
+ * ([`src/validate.ts`](validate.ts)) and `artUvsOf`
558
+ * ([`src/render.ts`](render.ts)), which returns no art UVs for a turned region
559
+ * attachment, so `check`'s texture substitution would report it unmatched.
560
+ *
561
+ * 📏 **Measured again for issue #866, on twelve region sets, and held back again.**
562
+ * The sets are the two painting rigs above and ten production rigs of 20 to 22
563
+ * parts. Each was packed by this file's own search at padding 2 twice: as
564
+ * shipped, and with every cell also tried turned under the same BSSF score
565
+ * (ties go to the unturned cell). The shipped search reproduced the page every
566
+ * one of the twelve atlases was written at. Area change from turning:
567
+ *
568
+ * | set | `pot` | `pot` + turn | `free` | `free` + turn |
569
+ * | --- | --- | --- | --- | --- |
570
+ * | 22-part painting | 1024x2048 | 0.00 % | 1888x697 | −1.14 % |
571
+ * | 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
572
+ * | P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
573
+ * | P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
574
+ * | P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
575
+ * | P4 | 1024x2048 | 0.00 % | 864x1346 | −6.16 % |
576
+ * | P5 | 2048x2048 | 0.00 % | 1344x1768 | −13.37 % |
577
+ * | P6 | 1024x2048 | 0.00 % | 800x1934 | −1.62 % |
578
+ * | P7 | 512x2048 | 0.00 % | 1024x928 | −0.05 % |
579
+ * | P8 | 512x2048 | 0.00 % | 608x1250 | −4.96 % |
580
+ * | P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
581
+ * | P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
582
+ *
583
+ * Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
584
+ * it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
585
+ * noise: the nearest other width on the 32-px grid the search then used moves
586
+ * P5's unturned page by 18.13 %, and searching every width instead of every
587
+ * 32nd finds an unturned P5 page 10.22 % smaller with nothing turned. With every
588
+ * width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
589
+ * (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
590
+ * packer still never turns a region.
591
+ *
592
+ * ⚠️ The `free` column and the 18.13 % are measurements of the search as it was
593
+ * on that day, a 32-px width grid. Issue #872 replaced it with every width (see
594
+ * `FREE_EDGE_STEP`), so today's `free` pages for these sets are the every-width
595
+ * ones the paragraph above compares against, and the 5.05 % is the turn gain
596
+ * measured on the search that ships.
597
+ */
598
+ export const PACK_NO_ROTATE = 0;
599
+
600
+ /**
601
+ * The atlas text for these pages — the ONE emitter, for both atlas shapes.
602
+ *
603
+ * Two text-shape traps are load-bearing (A07 checks both): a region name is the
604
+ * RAW line, so it carries no indentation, and a blank line closes a page block,
605
+ * so there is none between a page header and its regions. Exactly one blank line
606
+ * sits BETWEEN pages and none trails the last.
607
+ *
608
+ * The unpacked default goes through here too (`buildAtlasText` builds one page
609
+ * per image and calls this), which is what makes "the defaults change nothing" a
610
+ * property of one function instead of a promise made by two.
611
+ *
612
+ * ⭐ **No pages is the empty FILE, not a blank line** (issue #608). A compile that
613
+ * measured no art — a rig whose skins fill no slot with anything that needs a
614
+ * page — used to come out of here as `"\n"`, because `[].join('\n')` is `''` and
615
+ * the trailing newline was appended unconditionally. That one byte contradicts
616
+ * the paragraph above it: a blank line is the separator that sits BETWEEN page
617
+ * blocks, so a file consisting of one is a separator with nothing on either side.
618
+ * `A07_ATLAS_TEXT_SHAPE` reads a file with no non-blank line as having no page
619
+ * block and reports SKIP, and refuses a blank line that trails a page block as
620
+ * `the file ends with a blank line`.
621
+ *
622
+ * The runtime cannot tell the two apart — `new TextureAtlas('')`,
623
+ * `new TextureAtlas('\n')` and `new TextureAtlas('\n\n')` all come back with
624
+ * `pages.length === 0` and `regions.length === 0`, and the constructor
625
+ * (`TextureAtlas.js:97-174`) has no `throw` in it at all — so the runtime is no
626
+ * help in choosing, and the choice is made on what the text SAYS. Zero bytes has
627
+ * exactly one reading; a blank line has two, and the wrong one is the one A07 was
628
+ * built to catch.
629
+ */
630
+ export function writeAtlasText(pages: EmitPage[]): string {
631
+ if (pages.length === 0) return '';
632
+ const lines: string[] = [];
633
+ pages.forEach((page, i) => {
634
+ if (i > 0) lines.push(''); // exactly one blank line BETWEEN pages
635
+ lines.push(page.name);
636
+ lines.push(`size: ${page.width}, ${page.height}`);
637
+ lines.push('filter: Linear, Linear');
638
+ lines.push('pma: false');
639
+ for (const region of page.regions) {
640
+ lines.push(region.name);
641
+ lines.push(`bounds: ${region.x}, ${region.y}, ${region.width}, ${region.height}`);
642
+ lines.push(`offsets: ${region.offsetX}, ${region.offsetY}, ${region.originalWidth}, ${region.originalHeight}`);
643
+ lines.push(`rotate: ${PACK_NO_ROTATE}`);
644
+ }
645
+ });
646
+ return `${lines.join('\n')}\n`;
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // packing
651
+ // ---------------------------------------------------------------------------
652
+
653
+ /**
654
+ * What `--page-size` defaults to: the largest edge a page may have.
655
+ *
656
+ * 2048 rather than the Spine packer's 1024, and the corpus is the reason. A
657
+ * ceiling of 1024 refuses four of the thirteen rigs in this repository whose art
658
+ * is on disk — `1-weight-and-mass`'s `ground-bg` is 1251x394 and `6-arcs`'s
659
+ * `platform` is 1064x396, because the shipped examples pack at `scale: 0.5` and
660
+ * the loose sources are twice the packed size. A default that cannot pack the
661
+ * project's own examples is the wrong default.
662
+ *
663
+ * It costs nothing on small rigs: this is a CEILING, and `packAtlas` writes the
664
+ * smallest power-of-two page the set actually fits (a two-part rig gets 1024x256,
665
+ * not 2048x2048). 2048 is also inside every GL implementation's guaranteed
666
+ * maximum texture size that any consumer of a Spine atlas runs on.
667
+ */
668
+ export const DEFAULT_PAGE_SIZE = 2048;
669
+ /** What `--padding` defaults to. See `extrudeCell` for why it is not 0 and not 1. */
670
+ export const DEFAULT_PADDING = 2;
671
+
672
+ /**
673
+ * What a packed page's edges may be — `--page-edges`. See `packAtlas`, *Page size*.
674
+ *
675
+ * `pot` is the default and the only value until issue #860: both edges are powers
676
+ * of two. `free` lets the width be any whole number of pixels and the height
677
+ * whatever the placement needs. What `free` costs is measured rather than
678
+ * conceded: a region attachment's sampling coordinate stops being exact and moves
679
+ * to `PK05`'s bound of one least significant bit, the bound a mesh already had.
680
+ */
681
+ export const PAGE_EDGES = ['pot', 'free'] as const;
682
+ export type PageEdges = (typeof PAGE_EDGES)[number];
683
+ export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
684
+ /**
685
+ * The step a `free` page's width is tried on: **1, every width** (issue #872).
686
+ * See `smallestFreePageFor` for the search and `packAtlas`, *Page size*, for
687
+ * what it buys.
688
+ *
689
+ * It was 32 until #872, and the grid was the cost. On twelve region sets (two
690
+ * painting rigs and ten production rigs of 20 to 22 parts) the 32-px grid's page
691
+ * was larger than the every-width page on eleven, by up to 11.38 % (P5: 1344x1768
692
+ * against 1427x1495). Finer fixed steps and coarse-to-fine searches were measured
693
+ * against the every-width page too, and none of them is safe: the area as a
694
+ * function of the width has minima one pixel wide, so a step-4 grid refined
695
+ * over ±3 px of its best four widths still misses one (the fetched
696
+ * `5-squash-and-stretch` atlas: 867x415 against 863x415), and the coarse-to-fine
697
+ * search that was exact on all twelve sets missed by 0.82 % on the first
698
+ * held-out atlas it met. Every width is exact by construction; what keeps it
699
+ * cheap is the two bounds `smallestFreePageFor` prunes with, and they are exact
700
+ * too.
701
+ *
702
+ * Kept as a named constant rather than deleted because it is importable, and a
703
+ * reader of the old value should find the new one rather than a missing name.
704
+ */
705
+ export const FREE_EDGE_STEP = 1;
706
+
707
+ /**
708
+ * What a packed region's rectangle may share with its neighbours' — `--pack-shape`
709
+ * (issue #1099, stage 1 of #1093). See `packAtlas`, *`shape: 'polygon'`*.
710
+ *
711
+ * `rect` is the default and is the packer exactly as it was before the option
712
+ * existed: no two cells overlap. `polygon` packs every region whose every
713
+ * attachment is a mesh by that mesh's emitted hull, so two rectangles may
714
+ * overlap where neither region's footprint is; a region attachment's footprint
715
+ * stays its rectangle, because it draws its whole quad.
716
+ */
717
+ export const PACK_SHAPES = ['rect', 'polygon'] as const;
718
+ export type PackShape = (typeof PACK_SHAPES)[number];
719
+ export const DEFAULT_PACK_SHAPE: PackShape = 'rect';
720
+
721
+ /**
722
+ * Where a `polygon` candidate may put a cell against the free rectangle it is
723
+ * placed in (issue #1104) — `packOnePageByFootprint`, *Candidates*.
724
+ *
725
+ * `box` puts the cell's owned box on the free rectangle's corner — the only
726
+ * rule before #1104. The other three may also put the cell's own corner there
727
+ * (`packOnePageByFootprint`, *Candidates*):
728
+ *
729
+ * * `box-or-cell` — on every free rectangle;
730
+ * * `box-else-cell` — only on a free rectangle where the owned box's anchor
731
+ * would put the cell off the page;
732
+ * * `best-of` — the footprint pass run with `box` and with `box-or-cell` on
733
+ * the same page and the better kept (more cells, then the higher bottom
734
+ * edge, then `box`), as `placePass` keeps the better of `rect` and a
735
+ * footprint pass.
736
+ *
737
+ * No single rule is never-larger than `box` — `box-or-cell` packs `PK78`'s
738
+ * wedge set 7.2 % larger — so `polygon` does not pick one: it packs the whole
739
+ * set as `rect`, under `box` and under `box-else-cell` and keeps the least Σ
740
+ * page area (`POLYGON_CANDIDATE_RULES`, `packAtlas`). `box-else-cell` is the
741
+ * third candidate on measurement: over 96 packs (seeds 1100–1124, the tree's
742
+ * 21 sets, the wedge and spill sets, `pot` and `free`) the choice with it sums
743
+ * to 212,607,001 texels, with `best-of` 214,407,755 and with `box-or-cell` the
744
+ * same. `best-of` is never worse than `box` on one page, but against
745
+ * `box-else-cell` it gives the smaller pack on 12 of the 27 sets where the two
746
+ * differ and the larger on 15 — neither dominates, and the sum decides.
747
+ *
748
+ * The CLI never sets one rule; `PackOptions.footprintAnchors` is the instrument's and the plant's.
749
+ */
750
+ export const FOOTPRINT_ANCHORS = ['box', 'box-or-cell', 'box-else-cell', 'best-of'] as const;
751
+ export type FootprintAnchors = (typeof FOOTPRINT_ANCHORS)[number];
752
+ /** The rule a footprint search uses when it is given none — `freePageSearch`'s and `footprintPass`'s callers outside `packAtlas`. */
753
+ export const DEFAULT_FOOTPRINT_ANCHORS: FootprintAnchors = 'box';
754
+ /** The footprint rules `polygon` packs under besides `rect`, in the order a tie goes to (`packAtlas`). */
755
+ export const POLYGON_CANDIDATE_RULES: readonly FootprintAnchors[] = ['box', 'box-else-cell'];
756
+ /** Which whole pack a `polygon` pack kept: `rect`'s, or one footprint rule's. */
757
+ export type PackCandidate = 'rect' | FootprintAnchors;
758
+
759
+ /**
760
+ * The part of a region its attachments draw, in the region's own texels — x
761
+ * right and y down from the drawing's top-left corner, so `(u · width, v ·
762
+ * height)` for a mesh UV `(u, v)`. Each polygon is a flat `[x0, y0, x1, y1, …]`
763
+ * loop, closed from its last vertex back to its first: a mesh's hull loop, and
764
+ * each of its triangles. Absent on a `PackInput`, the footprint is the whole
765
+ * rectangle, which is what every region attachment's is.
766
+ */
767
+ export interface PackFootprint {
768
+ polygons: ReadonlyArray<readonly number[]>;
769
+ }
770
+
771
+ /** The part a page is packed from: its region name and its pixels. */
772
+ export interface PackInput {
773
+ region: string;
774
+ /** Absolute path, for the message that names a part the pack could not fit. */
775
+ absPath: string;
776
+ width: number;
777
+ height: number;
778
+ /**
779
+ * What the region's attachments draw (`packFootprints`), read only under
780
+ * `shape: 'polygon'`. Absent means the rectangle.
781
+ */
782
+ footprint?: PackFootprint;
783
+ }
784
+
785
+ export interface PackOptions {
786
+ /** Largest page edge. The page actually written is the smallest power of two that holds the pack. */
787
+ pageSize?: number;
788
+ /** Gutter each region reserves on every side. */
789
+ padding?: number;
790
+ /** Page filenames are `<stem>.png`, `<stem>2.png`, … — libgdx's own numbering. */
791
+ pageStem?: string;
792
+ /** What the page's edges may be (default `pot`). See `PAGE_EDGES`. */
793
+ pageEdges?: PageEdges;
794
+ /** What two rectangles may share (default `rect`). See `PACK_SHAPES`. */
795
+ shape?: PackShape;
796
+ /**
797
+ * One footprint rule alone instead of `polygon`'s choice between whole packs
798
+ * (`FOOTPRINT_ANCHORS`) — for `tools/pack_anchor.ts` and the selftest's plant
799
+ * (`PK97`); the CLI never sets it. Absent, `polygon` keeps the least Σ page
800
+ * area of `rect` and `POLYGON_CANDIDATE_RULES`.
801
+ */
802
+ footprintAnchors?: FootprintAnchors;
803
+ /** Where to count what every search the pack runs placed (`PassTally`) — the controls' and the instrument's cost reading. */
804
+ tally?: PassTally;
805
+ /**
806
+ * `false` runs every footprint pass of every search to its end — issue
807
+ * #1102's early stop off, for the selftest's plant (`PK95`) only. The pages are
808
+ * the same either way (`PK94`); only the cost differs.
809
+ */
810
+ stopAtMiss?: boolean;
811
+ /**
812
+ * `false` splits every band of every cell a footprint pass places over the
813
+ * whole free list — issue #1165's partition off (`splitFreeByCell`), for the
814
+ * selftest's plant (`PK106`) only. The pages are the same either way; only
815
+ * the cost differs.
816
+ */
817
+ partition?: boolean;
818
+ }
819
+
820
+ /** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
821
+ export interface Placement {
822
+ region: string;
823
+ page: number;
824
+ x: number;
825
+ y: number;
826
+ width: number;
827
+ height: number;
828
+ }
829
+
830
+ export interface PackedPage {
831
+ /** The page's filename, which is also its name in the atlas text. */
832
+ name: string;
833
+ width: number;
834
+ height: number;
835
+ /** The page's pixels, ready to `writePng`. */
836
+ plate: Plate;
837
+ /** Fraction of the page area the regions themselves cover, 0..1. */
838
+ occupancy: number;
839
+ }
840
+
841
+ export interface PackResult {
842
+ pages: PackedPage[];
843
+ /** Every placement, in the packer's own (sorted) order. */
844
+ placements: Placement[];
845
+ /** The atlas text for the packed pages. */
846
+ atlasText: string;
847
+ padding: number;
848
+ /** The shape the pack was made under — what the `pack:` line's last field states. */
849
+ shape: PackShape;
850
+ /** The whole pack that was kept: `rect` under `shape: 'rect'`, and under `polygon` the candidate of least Σ page area (`packAtlas`). */
851
+ candidate: PackCandidate;
852
+ }
853
+
854
+ /** A free rectangle in the MaxRects free list. */
855
+ interface Rect {
856
+ x: number;
857
+ y: number;
858
+ w: number;
859
+ h: number;
860
+ }
861
+
862
+ /**
863
+ * The largest power of two that is at most `n`.
864
+ *
865
+ * `--page-size 1000` therefore means 512, not 1024: the flag is a ceiling the
866
+ * page may not exceed, and page edges are powers of two (see `packAtlas`). A
867
+ * value that is already a power of two passes through unchanged, which is every
868
+ * value anybody types.
869
+ */
870
+ function floorPowerOfTwo(n: number): number {
871
+ let p = 1;
872
+ while (p * 2 <= n) p *= 2;
873
+ return p;
874
+ }
875
+
876
+ /**
877
+ * The smallest power-of-two page that holds these cells, and where they land on
878
+ * it — or `null` when they do not fit `maxEdge x maxEdge` at all.
879
+ *
880
+ * Every power-of-two pair up to the maximum is tried in order of increasing
881
+ * area, then increasing width, so the answer is a total order and two packs of
882
+ * the same set choose the same page. Powers of two are not decoration:
883
+ * `region.x / page.width` is the coordinate every texel is read through, and a
884
+ * power-of-two denominator makes that division exact in binary floating point.
885
+ *
886
+ * ⭐ One search, two callers, and that is the point. It picks the single page a
887
+ * set that fits gets, and it picks each SPILLED page's own size — so "the page
888
+ * written is the smallest that holds what is on it" is one rule with one
889
+ * implementation rather than a rule and an exception.
890
+ */
891
+ function smallestPageFor(
892
+ cells: Array<{ w: number; h: number }>,
893
+ maxEdge: number,
894
+ pageEdges: PageEdges,
895
+ shapes?: readonly CellShape[],
896
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
897
+ tally?: PassTally,
898
+ stopAtMiss = true,
899
+ partition = true,
900
+ ): { width: number; height: number; rects: Rect[] } | null {
901
+ if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss, partition);
902
+ const edges: number[] = [];
903
+ for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
904
+ const candidates: Array<{ w: number; h: number }> = [];
905
+ for (const w of edges) for (const h of edges) candidates.push({ w, h });
906
+ candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
907
+ for (const candidate of candidates) {
908
+ const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally, partition });
909
+ if (attempt.some((r) => r === null)) continue;
910
+ return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
911
+ }
912
+ return null;
913
+ }
914
+
915
+ /**
916
+ * The smallest `free` page that holds these cells, and where they land on it —
917
+ * or `null` when they do not fit `maxEdge x maxEdge` at all.
918
+ *
919
+ * The rule, which `docs/AUTHORING.md` §0.1 states in the same words:
920
+ *
921
+ * * the candidate WIDTHS are every whole width from the widest cell up to
922
+ * `maxEdge` (`FREE_EDGE_STEP` is 1);
923
+ * * at each width the cells are placed by the same MaxRects pass as a `pot`
924
+ * page, on a page `maxEdge` tall, and the HEIGHT is the bottom edge of the
925
+ * lowest cell — what that placement needs, not rounded;
926
+ * * the page with the least area wins, then the squarer (smaller |width −
927
+ * height|), then the narrower. Every candidate has a distinct width, so that
928
+ * last key makes the order total and the answer a function of the cells.
929
+ *
930
+ * ⚠️ The height is read off one placement rather than searched for, and that is
931
+ * a definition, not an approximation of one: MaxRects' success is not monotonic
932
+ * in the page height (a shorter page changes which free rectangle scores best),
933
+ * so "the least height that fits" is not a quantity a bisection could find, and a
934
+ * definition that could not be computed exactly would not be deterministic.
935
+ *
936
+ * ⭐ **Two bounds make every width cheap, and neither can change the answer**
937
+ * (issue #872). Both compare against the best page found so far, and both are
938
+ * strict, so a width that could still TIE the best — and win on the squarer or
939
+ * narrower key — is always placed to the end:
940
+ *
941
+ * * a width is not placed at all when the cells' own area exceeds
942
+ * `width x maxEdge` (it cannot fit), or when `width x` the tallest cell
943
+ * already exceeds the best area (no page at that width is shorter than its
944
+ * tallest cell);
945
+ * * a placement is abandoned the moment its lowest cell's bottom edge makes
946
+ * `width x bottom` exceed the best area. The bottom edge only grows as cells
947
+ * are added, so the page that placement would have finished is larger still.
948
+ *
949
+ * Neither changes a placement that runs to the end — the pass is the `pot` pass,
950
+ * on the same page, and abandoning it only stops it early — so the winner is the
951
+ * every-width winner, and `PK75` compares the two on every set it builds. What
952
+ * they save, measured on the twelve sets: 391,854 cell placements for an
953
+ * unbounded every-width search, 47,978 bounded, against 12,361 for the retired
954
+ * 32-px grid.
955
+ */
956
+ function smallestFreePageFor(
957
+ cells: Array<{ w: number; h: number }>,
958
+ maxEdge: number,
959
+ shapes?: readonly CellShape[],
960
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
961
+ tally?: PassTally,
962
+ stopAtMiss = true,
963
+ partition = true,
964
+ ): { width: number; height: number; rects: Rect[] } | null {
965
+ const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss, partition });
966
+ if (tally !== undefined) for (const key of Object.keys(tally) as Array<keyof PassTally>) tally[key] += search.tally[key];
967
+ return search.page;
968
+ }
969
+
970
+ /** What one `free` page search did: the page it chose, and what each width cost. */
971
+ export interface FreePageSearch {
972
+ page: { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null;
973
+ /** Candidate widths, from the widest cell to `maxEdge`. */
974
+ widths: number;
975
+ /** Widths whose placement ran to the end. */
976
+ completed: number;
977
+ /** Widths whose placement stopped once its lowest cell made the page larger than the best so far. */
978
+ abandoned: number;
979
+ /** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
980
+ skipped: number;
981
+ /** What the passes placed, by kind (`PassTally`). */
982
+ tally: PassTally;
983
+ }
984
+
985
+ /**
986
+ * How `freePageSearch` runs its passes — for the selftest's plants only
987
+ * (`PK94`, `PK95`, `PK106`). Each turns one saving off: `stopAtMiss: false`
988
+ * runs every footprint pass to its end and `edgeIndex: false` splits and
989
+ * prunes the whole free list by scanning all of it (issue #1102's two);
990
+ * `partition: false` splits every band over the whole list but keeps the edge
991
+ * index (issue #1165's). The page is the same either way; only what it costs
992
+ * differs, and the plants hold that.
993
+ */
994
+ export interface FreePageSearchOptions {
995
+ stopAtMiss?: boolean;
996
+ /** `false` splits the whole free list and scans all of it for a piece's containers (`splitFree`), as the prune did before issue #1102. */
997
+ edgeIndex?: boolean;
998
+ /** `false` splits every band over the whole free list (`splitFreeByCell`), as before issue #1165 — the plant of `PK106`. */
999
+ partition?: boolean;
1000
+ /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`) — `tools/pack_anchor.ts`'s candidate rules; default `box`. */
1001
+ anchors?: FootprintAnchors;
1002
+ }
1003
+
1004
+ /**
1005
+ * `smallestFreePageFor`'s search with its bookkeeping — exported so the
1006
+ * selftest can compare its page against an unbounded every-width reference and
1007
+ * see that the bounds did prune (`PK75`). The page is the whole of what the
1008
+ * packer uses; the counts are for the control. `shapes`, one per cell, is
1009
+ * `shape: 'polygon'`'s placement (`placePass`); absent, the rectangle pass.
1010
+ */
1011
+ export function freePageSearch(
1012
+ cells: Array<{ w: number; h: number }>,
1013
+ maxEdge: number,
1014
+ shapes?: readonly CellShape[],
1015
+ options: FreePageSearchOptions = {},
1016
+ ): FreePageSearch {
1017
+ const wholeOrNothing = options.stopAtMiss ?? true;
1018
+ let widest = 0;
1019
+ let tallest = 0;
1020
+ let cellArea = 0;
1021
+ for (const cell of cells) {
1022
+ widest = Math.max(widest, cell.w);
1023
+ tallest = Math.max(tallest, cell.h);
1024
+ cellArea += cell.w * cell.h;
1025
+ }
1026
+ // 🔒 Under `polygon` cells may overlap, so their area is no bound on the page;
1027
+ // what is, is the texels they OWN, which are disjoint (issue #1099). Bounding
1028
+ // by the cells' area skipped every width a polygon spill page needed, and a
1029
+ // spilled page whose cells overlapped was refused as fitting no page at all.
1030
+ if (shapes !== undefined) {
1031
+ cellArea = 0;
1032
+ for (const shape of shapes) cellArea += shape.owned;
1033
+ }
1034
+ const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: emptyTally() };
1035
+ let best: { width: number; height: number; rects: Rect[] } | null = null;
1036
+ for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
1037
+ out.widths++;
1038
+ const bestArea = best === null ? Infinity : best.width * best.height;
1039
+ if (cellArea > width * maxEdge || width * tallest > bestArea) {
1040
+ out.skipped++;
1041
+ continue;
1042
+ }
1043
+ const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
1044
+ const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors, partition: options.partition ?? true });
1045
+ let height = 0;
1046
+ for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
1047
+ if (height > maxBottom) {
1048
+ out.abandoned++;
1049
+ continue;
1050
+ }
1051
+ out.completed++;
1052
+ if (attempt.some((r) => r === null)) continue;
1053
+ const rects = attempt as Rect[];
1054
+ if (best !== null) {
1055
+ const area = width * height;
1056
+ if (area > bestArea) continue;
1057
+ if (area === bestArea) {
1058
+ const square = Math.abs(width - height);
1059
+ const bestSquare = Math.abs(best.width - best.height);
1060
+ if (square > bestSquare) continue;
1061
+ if (square === bestSquare && width >= best.width) continue;
1062
+ }
1063
+ }
1064
+ best = { width, height, rects };
1065
+ }
1066
+ out.page = best;
1067
+ return out;
1068
+ }
1069
+
1070
+ /**
1071
+ * One `free` candidate, unbounded: the cells placed at `width` on a page
1072
+ * `maxEdge` tall, and the height that placement needs — or `null` when they do
1073
+ * not all fit. It is the pass `freePageSearch` runs at each width with no
1074
+ * bound, exported so the selftest can rebuild a reference search from the same
1075
+ * placement (`PK74`'s 32-px grid, `PK75`'s every width, `PK76`'s `pot` search).
1076
+ */
1077
+ export function placeAtWidth(
1078
+ cells: Array<{ w: number; h: number }>,
1079
+ width: number,
1080
+ height: number,
1081
+ ): { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null {
1082
+ const attempt = packOnePage(cells, width, height);
1083
+ if (attempt.some((r) => r === null)) return null;
1084
+ const rects = attempt as Rect[];
1085
+ let bottom = 0;
1086
+ for (const r of rects) bottom = Math.max(bottom, r.y + r.h);
1087
+ return { width, height: bottom, rects };
1088
+ }
1089
+
1090
+ /**
1091
+ * MaxRects with Best Short Side Fit, no rotation.
1092
+ *
1093
+ * The free list starts as the whole page and every placement splits every free
1094
+ * rectangle it overlaps into up to four new ones, after which rectangles wholly
1095
+ * contained in another are pruned. It is the standard formulation (Jylänki 2010)
1096
+ * and it is here rather than in a dependency because rigc has one dependency and
1097
+ * a bin packer is 90 lines.
1098
+ *
1099
+ * 🔒 **Determinism.** BSSF's score can tie, and a tie broken by "whichever came
1100
+ * first in the free list" makes the output depend on the order splits happened
1101
+ * to be pushed. So the tie-break is spelled out and total — smallest long-side
1102
+ * leftover, then topmost, then leftmost — and the caller sorts its input before
1103
+ * calling. Same inputs, byte-identical page.
1104
+ *
1105
+ * `maxBottom` is the `free` search's abandon bound (see `freePageSearch`): once a
1106
+ * placed cell's bottom edge passes it the pass stops, and the cells not placed
1107
+ * are `null`. The default, `Infinity`, never stops a pass — which is every `pot`
1108
+ * call, so a `pot` page is placed exactly as it was before the bound existed.
1109
+ */
1110
+ function packOnePage(
1111
+ cells: Array<{ w: number; h: number }>,
1112
+ pageW: number,
1113
+ pageH: number,
1114
+ maxBottom = Infinity,
1115
+ ): Array<Rect | null> {
1116
+ const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1117
+ const placed: Array<Rect | null> = [];
1118
+
1119
+ for (const cell of cells) {
1120
+ let best: Rect | null = null;
1121
+ let bestShort = Infinity;
1122
+ let bestLong = Infinity;
1123
+ for (const fr of free) {
1124
+ if (fr.w < cell.w || fr.h < cell.h) continue;
1125
+ const leftoverW = fr.w - cell.w;
1126
+ const leftoverH = fr.h - cell.h;
1127
+ const short = Math.min(leftoverW, leftoverH);
1128
+ const long = Math.max(leftoverW, leftoverH);
1129
+ if (best !== null) {
1130
+ if (short > bestShort) continue;
1131
+ if (short === bestShort) {
1132
+ if (long > bestLong) continue;
1133
+ if (long === bestLong) {
1134
+ if (fr.y > best.y) continue;
1135
+ if (fr.y === best.y && fr.x >= best.x) continue;
1136
+ }
1137
+ }
1138
+ }
1139
+ best = fr;
1140
+ bestShort = short;
1141
+ bestLong = long;
1142
+ }
1143
+ if (best === null) {
1144
+ placed.push(null);
1145
+ continue;
1146
+ }
1147
+ const put: Rect = { x: best.x, y: best.y, w: cell.w, h: cell.h };
1148
+ placed.push(put);
1149
+ if (put.y + put.h > maxBottom) {
1150
+ while (placed.length < cells.length) placed.push(null);
1151
+ return placed;
1152
+ }
1153
+
1154
+ // Split every free rectangle the placement overlaps, then prune.
1155
+ const next: Rect[] = [];
1156
+ for (const fr of free) {
1157
+ const overlaps = put.x < fr.x + fr.w && put.x + put.w > fr.x && put.y < fr.y + fr.h && put.y + put.h > fr.y;
1158
+ if (!overlaps) {
1159
+ next.push(fr);
1160
+ continue;
1161
+ }
1162
+ if (put.x > fr.x) next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
1163
+ if (put.x + put.w < fr.x + fr.w) {
1164
+ next.push({ x: put.x + put.w, y: fr.y, w: fr.x + fr.w - (put.x + put.w), h: fr.h });
1165
+ }
1166
+ if (put.y > fr.y) next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
1167
+ if (put.y + put.h < fr.y + fr.h) {
1168
+ next.push({ x: fr.x, y: put.y + put.h, w: fr.w, h: fr.y + fr.h - (put.y + put.h) });
1169
+ }
1170
+ }
1171
+ const contains = (a: Rect, b: Rect): boolean =>
1172
+ b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
1173
+ free.length = 0;
1174
+ for (let i = 0; i < next.length; i++) {
1175
+ if (next[i].w <= 0 || next[i].h <= 0) continue;
1176
+ let contained = false;
1177
+ for (let j = 0; j < next.length && !contained; j++) {
1178
+ if (i === j || next[j].w <= 0 || next[j].h <= 0) continue;
1179
+ // On a mutual containment (two identical rectangles) the later index
1180
+ // loses, so exactly one survives and it is always the same one.
1181
+ if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) contained = true;
1182
+ }
1183
+ if (!contained) free.push(next[i]);
1184
+ }
1185
+ }
1186
+ return placed;
1187
+ }
1188
+
1189
+ /**
1190
+ * One placement pass: the rectangle pass (`packOnePage`) when `shapes` is
1191
+ * absent, which is every `shape: 'rect'` call and so every pack made before the
1192
+ * option existed, and the footprint pass (`packOnePageByFootprint`) when it is
1193
+ * given.
1194
+ */
1195
+ function placePass(
1196
+ cells: Array<{ w: number; h: number }>,
1197
+ shapes: readonly CellShape[] | undefined,
1198
+ pageW: number,
1199
+ pageH: number,
1200
+ maxBottom = Infinity,
1201
+ pass: PassOptions = {},
1202
+ ): Array<Rect | null> {
1203
+ const wholeOrNothing = pass.wholeOrNothing ?? false;
1204
+ const tally = pass.tally;
1205
+ if (shapes === undefined || shapes.every((s) => s.whole)) {
1206
+ const byRect = packOnePage(cells, pageW, pageH, maxBottom);
1207
+ if (tally !== undefined) tally.rectPlaced += placedCount(byRect);
1208
+ return byRect;
1209
+ }
1210
+ // ⭐ `wholeOrNothing` is the page searches' question (issue #1102): they keep
1211
+ // a pass only when it placed every cell, so a pass that has missed one is
1212
+ // already discarded, whatever it does next. Two savings follow, and neither
1213
+ // can change a page the search keeps:
1214
+ //
1215
+ // * the rectangle pass is not run when the cells' own area exceeds the
1216
+ // page — disjoint rectangles cannot all fit, so it could not place every
1217
+ // cell, and the pass it would have lost or won against is discarded either
1218
+ // way (a footprint pass that placed every cell beats a rectangle pass that
1219
+ // did not, on the first key);
1220
+ // * the footprint pass stops at its first miss (`packOnePageByFootprint`'s
1221
+ // `stopAtMiss`), returning the cells after it as not placed.
1222
+ //
1223
+ // What is returned can then differ from the full answer only in which of two
1224
+ // failed passes it is, or in how many cells a failed pass placed — and the
1225
+ // searches read neither. The spill's assignment pass, which keeps the cells
1226
+ // a pass did place, never asks this (`packAtlas`).
1227
+ //
1228
+ // Measured on a replica of a production rig (31 meshes, a two-page `free`
1229
+ // spill at 2048, padding 2), where the pack took 260–304 s against `rect`'s
1230
+ // 0.14–0.24 s: the single-page search ran a full footprint pass at every one of its
1231
+ // 854 widths (54 % of the time), because nothing fits and so no best page
1232
+ // ever bounds a width, and the first page's own search ran one at every width
1233
+ // it did not abandon (46 %). The largest cell's owned box does not start at
1234
+ // its cell's corner, so on an empty page that cell is every footprint pass's
1235
+ // first miss — all 1,709 of them — and every one of those passes was thrown
1236
+ // away (issue #1104 measures the anchor that causes it). With the early stop the pack takes 0.15–0.7 s by the load, the
1237
+ // same pages.
1238
+ const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
1239
+ const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
1240
+ if (tally !== undefined && byRect !== null) tally.rectPlaced += placedCount(byRect);
1241
+ // ⭐ `polygon` never costs a page anything `rect` would have saved. Both passes
1242
+ // are run on the same page and the better one is kept: more cells placed,
1243
+ // then the higher bottom edge, then — on a tie — the rectangle pass. Two
1244
+ // disjoint cells never share a protected texel, so the rectangle pass is
1245
+ // itself a legal footprint placement, and keeping it where it is better is
1246
+ // choosing between two legal answers, not giving the mode up. Measured
1247
+ // before this rule existed: the footprint pass alone wrote larger pages
1248
+ // than `rect` on two of the five gallery rigs that carry a mesh, because a
1249
+ // greedy pass that places one cell differently places every later one
1250
+ // differently too.
1251
+ const anchors = pass.anchors ?? DEFAULT_FOOTPRINT_ANCHORS;
1252
+ let byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, anchors === 'best-of' ? 'box' : anchors, pass.partition ?? true);
1253
+ if (anchors === 'best-of') {
1254
+ // Issue #1104's fourth candidate: both searches on the same page, the
1255
+ // better kept and the first anchor's on a tie — so a pass is never worse
1256
+ // than the first anchor's on that page.
1257
+ if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1258
+ const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell', pass.partition ?? true);
1259
+ const bottom = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1260
+ const placedBox = placedCount(byFootprint);
1261
+ const placedTwo = placedCount(byTwo);
1262
+ if (placedTwo > placedBox || (placedTwo === placedBox && bottom(byTwo) < bottom(byFootprint))) byFootprint = byTwo;
1263
+ if (tally !== undefined) tally.footprintPlaced += placedTwo;
1264
+ } else if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1265
+ if (byRect === null) return byFootprint;
1266
+ const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1267
+ const placedRect = placedCount(byRect);
1268
+ const placedFoot = placedCount(byFootprint);
1269
+ if (placedFoot !== placedRect) return placedFoot > placedRect ? byFootprint : byRect;
1270
+ return bottomOf(byFootprint) < bottomOf(byRect) ? byFootprint : byRect;
1271
+ }
1272
+
1273
+ /** How `placePass` runs — what the page searches ask of it (issue #1102). */
1274
+ interface PassOptions {
1275
+ /** Only a pass that places every cell is kept by the caller. See `placePass`. */
1276
+ wholeOrNothing?: boolean;
1277
+ /** Where to count what the passes placed. */
1278
+ tally?: PassTally;
1279
+ /** `splitFree`'s edge index (default on); off only for the selftest's plant. */
1280
+ edgeIndex?: boolean;
1281
+ /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
1282
+ anchors?: FootprintAnchors;
1283
+ /** `splitFreeByCell`'s partition (default on); off only for the selftest's plant. */
1284
+ partition?: boolean;
1285
+ }
1286
+
1287
+ /** How many cells a pass placed. */
1288
+ function placedCount(pass: ReadonlyArray<Rect | null>): number {
1289
+ let n = 0;
1290
+ for (const r of pass) if (r !== null) n++;
1291
+ return n;
1292
+ }
1293
+
1294
+ /** The cells' own area — what disjoint rectangles need of a page at least. */
1295
+ function cellAreaOf(cells: ReadonlyArray<{ w: number; h: number }>): number {
1296
+ let area = 0;
1297
+ for (const cell of cells) area += cell.w * cell.h;
1298
+ return area;
1299
+ }
1300
+
1301
+ /**
1302
+ * What a page search's passes placed, by kind — the operation count a search's
1303
+ * cost is held by (`PK93`), since a footprint placement splits the free list
1304
+ * once per band of what the cell owns and a rectangle placement once.
1305
+ */
1306
+ export interface PassTally {
1307
+ /** Cells placed by rectangle passes (`packOnePage`). */
1308
+ rectPlaced: number;
1309
+ /** Cells placed by footprint passes (`packOnePageByFootprint`). */
1310
+ footprintPlaced: number;
1311
+ /** Free-list splits the footprint passes made — one per band of every cell they placed (`splitFree`). */
1312
+ bandSplits: number;
1313
+ /** Containment tests the footprint passes' prune made (`splitFree`). */
1314
+ containmentTests: number;
1315
+ /**
1316
+ * Free-list entries the footprint passes' splits read: the list's length at
1317
+ * every band split, and once per placed cell the whole list the partition
1318
+ * reads (`splitFreeByCell`) — the work issue #1165 measured, held by `PK106`.
1319
+ */
1320
+ entriesRead: number;
1321
+ /**
1322
+ * Free rectangles that held a cell's owned box and were refused as its
1323
+ * first-anchor candidate only because the cell, anchored there, would leave
1324
+ * the page — at negative x or y, or past the right or bottom edge (issue
1325
+ * #1104, `tools/pack_anchor.ts`).
1326
+ */
1327
+ boxAnchorRefused: number;
1328
+ /** Cells a footprint pass missed although some free rectangle held their owned box. */
1329
+ missedByAnchor: number;
1330
+ /** Cells a footprint pass placed at the second anchor — the cell's own corner on the free rectangle's — under a candidate rule that has one (`FOOTPRINT_ANCHORS`). */
1331
+ secondAnchorPlaced: number;
1332
+ }
1333
+
1334
+ /** A tally with nothing counted. */
1335
+ export function emptyTally(): PassTally {
1336
+ return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, entriesRead: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
1337
+ }
1338
+
1339
+ // ---------------------------------------------------------------------------
1340
+ // footprints — `shape: 'polygon'` (issue #1099)
1341
+ // ---------------------------------------------------------------------------
1342
+
1343
+ /**
1344
+ * A cell as `shape: 'polygon'` places it: the region plus `padding` on every
1345
+ * side, and the cell texels the region OWNS — its protected set.
1346
+ *
1347
+ * ## The protected set, and why it is the footprint test
1348
+ *
1349
+ * A region attachment owns its whole cell, exactly the cell `shape: 'rect'`
1350
+ * keeps apart from every other: it draws its whole quad, and its gutter is what
1351
+ * makes its edge sample as the loose page's does (`extrudeCell`).
1352
+ *
1353
+ * A mesh region owns every cell texel `t` whose unit square, grown by `reach =
1354
+ * max(padding, 1)` texels on every side (a Chebyshev dilation), meets one of
1355
+ * the region's footprint polygons, closed — boundary contact counts. Then:
1356
+ *
1357
+ * * it holds every texel the mesh can sample. A bilinear tap at a point `q`
1358
+ * inside a triangle reads the four texels whose centres are within one
1359
+ * texel of `q`, and each of those squares lies within half a texel of `q`;
1360
+ * `reach` ≥ 1 covers that with room for the float32 page UVs the runtime
1361
+ * stores (`src/atlas.ts`'s header: worst 3.15e-5 texels);
1362
+ * * it keeps the padding the rectangle keeps. A rectangle's cell is the
1363
+ * rectangle grown by `padding`, and this is the hull grown by `padding` —
1364
+ * for a hull that covers its whole drawing, texel for texel the same cell,
1365
+ * which is why such a mesh is placed exactly as a rectangle is;
1366
+ * * it is clipped to the cell, so a region never claims a texel outside the
1367
+ * cell its own pixels are extruded into.
1368
+ *
1369
+ * ⇒ **Two cells may be placed with their rectangles overlapping exactly when
1370
+ * their protected sets share no texel.** For two rectangles that is today's
1371
+ * rule — two cells share no texel — so a pack with no mesh region is placed
1372
+ * by `rect`'s arithmetic, decision for decision. For any two footprints it
1373
+ * means their Chebyshev distance is at least `2 · padding`: if a point of one
1374
+ * were within `2 · padding` of a point of the other, the texel under their
1375
+ * midpoint would be in both protected sets.
1376
+ *
1377
+ * ## Exactness — where it rounds, and in which direction
1378
+ *
1379
+ * The polygons are doubles: a UV as `skeleton.json` states it, times the
1380
+ * region's integer size. The set is computed row by row — the band
1381
+ * `[row − reach, row + 1 + reach]` against each polygon: every edge clipped to
1382
+ * the band, and the polygon's even-odd spans on the band's two lines, which
1383
+ * together are the polygon's exact extent inside the band — and every
1384
+ * comparison that could decide "outside" is made `FOOTPRINT_SLACK` in the
1385
+ * polygon's favour. **The only rounding grows the set**, by at most that slack
1386
+ * (1e-6 texels, against a double's 1e-13 on these magnitudes), so it can make
1387
+ * two footprints keep further apart than they had to and never lets two
1388
+ * touch.
1389
+ */
1390
+ export interface CellShape {
1391
+ /** The cell: the region plus `padding` on every side. */
1392
+ width: number;
1393
+ height: number;
1394
+ /** 1 on every texel the region owns, row-major over the cell. */
1395
+ mask: Uint8Array;
1396
+ /** `mask` rounded out to bands of `FOOTPRINT_BAND_ROWS` rows, as disjoint rectangles in cell texels, top to bottom — what the free list is split by. */
1397
+ rects: Rect[];
1398
+ /** The bounding box of `mask`, in cell texels. */
1399
+ bbox: Rect;
1400
+ /** The mask is the whole cell: the region is placed exactly as `shape: 'rect'` places it. */
1401
+ whole: boolean;
1402
+ /** How many texels the mask owns — what the `free` search's area bound sums, since owned sets are disjoint and cells may not be. */
1403
+ owned: number;
1404
+ }
1405
+
1406
+ /** How far in a polygon's favour every footprint comparison is made. See `CellShape`, *Exactness*. */
1407
+ export const FOOTPRINT_SLACK = 1e-6;
1408
+
1409
+ /**
1410
+ * How many rows of a cell's owned set one free-list rectangle spans — the
1411
+ * resolution the footprint pass's free list keeps, not the resolution of the
1412
+ * footprint test (which is the exact set, texel by texel). Splitting the free
1413
+ * list by one rectangle per row of a curved hull made it thousands long: a
1414
+ * 60-part set of ellipse hulls ran over ten minutes on a `free` page. See
1415
+ * `packOnePageByFootprint` for what the bands cost.
1416
+ */
1417
+ export const FOOTPRINT_BAND_ROWS = 8;
1418
+
1419
+ /**
1420
+ * The closed x-extent of one polygon inside the band `y0 ≤ y ≤ y1`, as a list
1421
+ * of closed intervals whose union is exactly that extent: each edge clipped to
1422
+ * the band, and the even-odd spans on the band's two lines (a point inside the
1423
+ * polygon and the band either reaches a band line vertically without leaving
1424
+ * the polygon, or meets an edge inside the band on the way).
1425
+ */
1426
+ function bandExtent(poly: readonly number[], offset: number, y0: number, y1: number, out: Array<[number, number]>): void {
1427
+ const n = poly.length / 2;
1428
+ for (let i = 0; i < n; i++) {
1429
+ const ax = poly[2 * i] + offset;
1430
+ const ay = poly[2 * i + 1] + offset;
1431
+ const j = (i + 1) % n;
1432
+ const bx = poly[2 * j] + offset;
1433
+ const by = poly[2 * j + 1] + offset;
1434
+ if (Math.max(ay, by) < y0 || Math.min(ay, by) > y1) continue;
1435
+ if (ay === by) {
1436
+ out.push([Math.min(ax, bx), Math.max(ax, bx)]);
1437
+ continue;
1438
+ }
1439
+ let t0 = (y0 - ay) / (by - ay);
1440
+ let t1 = (y1 - ay) / (by - ay);
1441
+ if (t0 > t1) [t0, t1] = [t1, t0];
1442
+ t0 = Math.max(0, t0);
1443
+ t1 = Math.min(1, t1);
1444
+ if (t0 > t1) continue;
1445
+ const xa = ax + (bx - ax) * t0;
1446
+ const xb = ax + (bx - ax) * t1;
1447
+ out.push([Math.min(xa, xb), Math.max(xa, xb)]);
1448
+ }
1449
+ for (const y of [y0, y1]) {
1450
+ const crossings: number[] = [];
1451
+ for (let i = 0; i < n; i++) {
1452
+ const ay = poly[2 * i + 1] + offset;
1453
+ const j = (i + 1) % n;
1454
+ const by = poly[2 * j + 1] + offset;
1455
+ if ((ay <= y && y < by) || (by <= y && y < ay)) {
1456
+ const ax = poly[2 * i] + offset;
1457
+ const bx = poly[2 * j] + offset;
1458
+ crossings.push(ax + ((y - ay) * (bx - ax)) / (by - ay));
1459
+ }
1460
+ }
1461
+ crossings.sort((a, b) => a - b);
1462
+ for (let k = 0; k + 1 < crossings.length; k += 2) out.push([crossings[k], crossings[k + 1]]);
1463
+ }
1464
+ }
1465
+
1466
+ /**
1467
+ * A region's cell and protected set (`CellShape`) — the rectangle when
1468
+ * `footprint` is absent, the dilated footprint polygons when it is given.
1469
+ * Exported so the selftest can hold the pages to the sets the packer kept
1470
+ * apart (`PK79`) without restating the rule.
1471
+ */
1472
+ export function footprintCell(width: number, height: number, padding: number, footprint?: PackFootprint): CellShape {
1473
+ const cw = width + 2 * padding;
1474
+ const ch = height + 2 * padding;
1475
+ const whole = (): CellShape => {
1476
+ const cell = { x: 0, y: 0, w: cw, h: ch };
1477
+ return { width: cw, height: ch, mask: new Uint8Array(cw * ch).fill(1), rects: [cell], bbox: { ...cell }, whole: true, owned: cw * ch };
1478
+ };
1479
+ if (footprint === undefined) return whole();
1480
+ const mask = new Uint8Array(cw * ch);
1481
+ const reach = Math.max(padding, 1);
1482
+ const spans: Array<[number, number]> = [];
1483
+ for (let row = 0; row < ch; row++) {
1484
+ spans.length = 0;
1485
+ const y0 = row - reach - FOOTPRINT_SLACK;
1486
+ const y1 = row + 1 + reach + FOOTPRINT_SLACK;
1487
+ for (const poly of footprint.polygons) if (poly.length >= 6) bandExtent(poly, padding, y0, y1, spans);
1488
+ const at = row * cw;
1489
+ for (const [a, b] of spans) {
1490
+ const from = Math.max(0, Math.ceil(a - 1 - reach - FOOTPRINT_SLACK));
1491
+ const to = Math.min(cw - 1, Math.floor(b + reach + FOOTPRINT_SLACK));
1492
+ for (let x = from; x <= to; x++) mask[at + x] = 1;
1493
+ }
1494
+ }
1495
+ if (mask.every((v) => v === 1)) return whole();
1496
+ // The rectangles the free list is split by: the set rounded OUT to bands of
1497
+ // `FOOTPRINT_BAND_ROWS` rows — each band the columns any of its rows owns,
1498
+ // as runs, merged downwards while a run repeats exactly. Disjoint, a superset
1499
+ // of the set, and in a fixed order (top to bottom, then left to right), so
1500
+ // the free list is split the same way every time. Only the free list reads
1501
+ // them; what a cell owns, what is drawn and what the footprint test compares
1502
+ // is the exact set.
1503
+ const rects: Rect[] = [];
1504
+ let open = new Map<number, Rect>();
1505
+ const columns = new Uint8Array(cw);
1506
+ for (let band = 0; band < ch; band += FOOTPRINT_BAND_ROWS) {
1507
+ const rows = Math.min(FOOTPRINT_BAND_ROWS, ch - band);
1508
+ columns.fill(0);
1509
+ for (let row = band; row < band + rows; row++) for (let x = 0; x < cw; x++) if (mask[row * cw + x] === 1) columns[x] = 1;
1510
+ const next = new Map<number, Rect>();
1511
+ let x = 0;
1512
+ while (x < cw) {
1513
+ if (columns[x] === 0) {
1514
+ x++;
1515
+ continue;
1516
+ }
1517
+ const start = x;
1518
+ while (x < cw && columns[x] === 1) x++;
1519
+ const key = start * (cw + 1) + x;
1520
+ const continued = open.get(key);
1521
+ if (continued !== undefined && continued.y + continued.h === band) {
1522
+ continued.h += rows;
1523
+ next.set(key, continued);
1524
+ } else {
1525
+ const rect = { x: start, y: band, w: x - start, h: rows };
1526
+ rects.push(rect);
1527
+ next.set(key, rect);
1528
+ }
1529
+ }
1530
+ open = next;
1531
+ }
1532
+ // The bounding box is the exact set's: a candidate is placed so that box
1533
+ // lies in a free rectangle, which no other cell's bands reach.
1534
+ let minX = cw;
1535
+ let minY = ch;
1536
+ let maxX = 0;
1537
+ let maxY = 0;
1538
+ for (let y = 0; y < ch; y++) {
1539
+ for (let x = 0; x < cw; x++) {
1540
+ if (mask[y * cw + x] === 0) continue;
1541
+ minX = Math.min(minX, x);
1542
+ minY = Math.min(minY, y);
1543
+ maxX = Math.max(maxX, x + 1);
1544
+ maxY = Math.max(maxY, y + 1);
1545
+ }
1546
+ }
1547
+ // A footprint that owns nothing (no polygon of three vertices) still owns a
1548
+ // texel: a cell that claimed no texel could be placed on top of another and
1549
+ // the packer would have nothing to keep apart. Its top-left texel is the
1550
+ // smallest claim, and it is the cell's own.
1551
+ if (rects.length === 0) {
1552
+ mask[0] = 1;
1553
+ rects.push({ x: 0, y: 0, w: 1, h: 1 });
1554
+ minX = 0;
1555
+ minY = 0;
1556
+ maxX = 1;
1557
+ maxY = 1;
1558
+ }
1559
+ let count = 0;
1560
+ for (const v of mask) count += v;
1561
+ return { width: cw, height: ch, mask, rects, bbox: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, whole: false, owned: count };
1562
+ }
1563
+
1564
+ /** Whether two placed cells' protected sets share a texel. */
1565
+ function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: number, by: number): boolean {
1566
+ const x0 = Math.max(ax, bx);
1567
+ const y0 = Math.max(ay, by);
1568
+ const x1 = Math.min(ax + a.width, bx + b.width);
1569
+ const y1 = Math.min(ay + a.height, by + b.height);
1570
+ for (let y = y0; y < y1; y++) {
1571
+ const rowA = (y - ay) * a.width - ax;
1572
+ const rowB = (y - by) * b.width - bx;
1573
+ for (let x = x0; x < x1; x++) if (a.mask[rowA + x] === 1 && b.mask[rowB + x] === 1) return true;
1574
+ }
1575
+ return false;
1576
+ }
1577
+
1578
+ /**
1579
+ * MaxRects' split of every free rectangle `put` overlaps, then its prune —
1580
+ * `packOnePage`'s, over the first `length` entries of `free`, written back in
1581
+ * place; returns the new length. Four savings, none of which changes a
1582
+ * rectangle or the list's order:
1583
+ *
1584
+ * * a free rectangle the placement did not touch is never tested for
1585
+ * containment. Before the split no free rectangle lay inside another (the
1586
+ * prune's own postcondition), and every new piece lies inside the
1587
+ * rectangle it was cut from, so an untouched rectangle inside a new piece
1588
+ * would have lain inside that one — the test `packOnePage` makes for it
1589
+ * always answers "not contained";
1590
+ * * ⭐ (issue #1099, measured on production rigs) a free rectangle narrower
1591
+ * than `minW` or shorter than `minH` is dropped. The caller passes the
1592
+ * least bounding box of every cell the pass places, so such a rectangle can
1593
+ * never be a candidate, and neither can any piece later cut from it (a
1594
+ * piece lies inside the rectangle it was cut from); and a rectangle a
1595
+ * dropped one contains is itself too small. So every rectangle that could
1596
+ * ever be a candidate is kept, in the order the full prune keeps it. What
1597
+ * it removes is the staircase of slivers an irregular hull's bands cut
1598
+ * along its edge: on a production-shaped set (30 regions, 200 to 900 px,
1599
+ * hulls near their rectangles, a `free` page about 2023x2046) the pack took
1600
+ * 18.5 s with the slivers kept and spent 95 % of it in this prune. A piece
1601
+ * that would be dropped is not made at all (issue #1165): it takes part in
1602
+ * no test and is not written back, so it changes no other entry's index
1603
+ * order either;
1604
+ * * the edge index (issue #1102), below;
1605
+ * * (issue #1165) the rebuilt list, each entry's side and the edge lists are
1606
+ * kept between calls (`splitNext` and its neighbours) and the list is
1607
+ * written back over itself by index, never emptied and refilled: on the
1608
+ * production rigs the emptying and the pushes that refilled it were most
1609
+ * of this function's time, and none of it was arithmetic.
1610
+ */
1611
+ function splitFree(free: Rect[], length: number, put: Rect, minW: number, minH: number, edgeIndex: boolean, tally?: PassTally): number {
1612
+ const px0 = put.x;
1613
+ const py0 = put.y;
1614
+ const px1 = put.x + put.w;
1615
+ const py1 = put.y + put.h;
1616
+ // A touched entry yields at most four pieces.
1617
+ if (splitSide.length < length * 4) {
1618
+ const capacity = Math.max(64, length * 8);
1619
+ splitSide = new Int8Array(capacity);
1620
+ splitLeft = new Int32Array(capacity);
1621
+ splitRight = new Int32Array(capacity);
1622
+ splitAbove = new Int32Array(capacity);
1623
+ splitBelow = new Int32Array(capacity);
1624
+ }
1625
+ const next = splitNext;
1626
+ const side = splitSide;
1627
+ let n = 0;
1628
+ for (let k = 0; k < length; k++) {
1629
+ const fr = free[k];
1630
+ const fx1 = fr.x + fr.w;
1631
+ const fy1 = fr.y + fr.h;
1632
+ if (!(px0 < fx1 && px1 > fr.x && py0 < fy1 && py1 > fr.y)) {
1633
+ next[n] = fr;
1634
+ side[n++] = UNTOUCHED;
1635
+ continue;
1636
+ }
1637
+ if (px0 > fr.x && px0 - fr.x >= minW && fr.h >= minH) {
1638
+ next[n] = { x: fr.x, y: fr.y, w: px0 - fr.x, h: fr.h };
1639
+ side[n++] = LEFT_OF_PUT;
1640
+ }
1641
+ if (px1 < fx1 && fx1 - px1 >= minW && fr.h >= minH) {
1642
+ next[n] = { x: px1, y: fr.y, w: fx1 - px1, h: fr.h };
1643
+ side[n++] = RIGHT_OF_PUT;
1644
+ }
1645
+ if (py0 > fr.y && fr.w >= minW && py0 - fr.y >= minH) {
1646
+ next[n] = { x: fr.x, y: fr.y, w: fr.w, h: py0 - fr.y };
1647
+ side[n++] = ABOVE_PUT;
1648
+ }
1649
+ if (py1 < fy1 && fr.w >= minW && fy1 - py1 >= minH) {
1650
+ next[n] = { x: fr.x, y: py1, w: fr.w, h: fy1 - py1 };
1651
+ side[n++] = BELOW_PUT;
1652
+ }
1653
+ }
1654
+ // ⭐ The edge index (issue #1102): a piece's containers are looked for only
1655
+ // among the entries that share the edge it was cut along. A piece cut from
1656
+ // the left of `put` ends at `put.x` and keeps its parent's rows, which meet
1657
+ // `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
1658
+ // those rows from the piece's left edge to at least `put.x`; if it reached
1659
+ // past `put.x` it would overlap `put` — and no entry of `next` does (an
1660
+ // untouched rectangle by definition, a piece by construction). So every
1661
+ // container of a left piece ENDS at `put.x`; likewise a right piece's starts
1662
+ // at `put.x + put.w`, an upper piece's ends at `put.y` and a lower piece's
1663
+ // starts at `put.y + put.h`. The test is the full prune's test, asked of the
1664
+ // only entries that can answer it yes, so `contained` is the same boolean and
1665
+ // the list the same list in the same order. Measured where the footprint
1666
+ // pass does work (`fixtures/polypack_shapes.ts`, seed 1105, the searches a
1667
+ // `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
1668
+ // 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
1669
+ // 11,928,721,590 → 537,215,014 and 101 s → 14.5 s. `edgeIndex: false` is
1670
+ // that scan — every usable entry in `left` — kept so the selftest can hold
1671
+ // that the index finds what the scan finds (`PK94`) and for nothing else.
1672
+ const left = splitLeft;
1673
+ const right = splitRight;
1674
+ const above = splitAbove;
1675
+ const below = splitBelow;
1676
+ let nLeft = 0;
1677
+ let nRight = 0;
1678
+ let nAbove = 0;
1679
+ let nBelow = 0;
1680
+ for (let j = 0; j < n; j++) {
1681
+ const r = next[j];
1682
+ if (r.w < minW || r.h < minH) continue;
1683
+ if (!edgeIndex) {
1684
+ left[nLeft++] = j;
1685
+ continue;
1686
+ }
1687
+ if (r.x + r.w === px0) left[nLeft++] = j;
1688
+ if (r.x === px1) right[nRight++] = j;
1689
+ if (r.y + r.h === py0) above[nAbove++] = j;
1690
+ if (r.y === py1) below[nBelow++] = j;
1691
+ }
1692
+ let w = 0;
1693
+ let tests = 0;
1694
+ for (let i = 0; i < n; i++) {
1695
+ const piece = next[i];
1696
+ if (piece.w < minW || piece.h < minH) continue;
1697
+ const cut = side[i];
1698
+ if (cut === UNTOUCHED) {
1699
+ free[w++] = piece;
1700
+ continue;
1701
+ }
1702
+ const list = !edgeIndex || cut === LEFT_OF_PUT ? left : cut === RIGHT_OF_PUT ? right : cut === ABOVE_PUT ? above : below;
1703
+ const count = !edgeIndex || cut === LEFT_OF_PUT ? nLeft : cut === RIGHT_OF_PUT ? nRight : cut === ABOVE_PUT ? nAbove : nBelow;
1704
+ let contained = false;
1705
+ for (let t = 0; t < count; t++) {
1706
+ const j = list[t];
1707
+ if (i === j) continue;
1708
+ tests++;
1709
+ const other = next[j];
1710
+ // On a mutual containment (two identical rectangles) the later index
1711
+ // loses, so exactly one survives and it is always the same one.
1712
+ if (rectContains(other, piece) && (j < i || !rectContains(piece, other))) {
1713
+ contained = true;
1714
+ break;
1715
+ }
1716
+ }
1717
+ if (!contained) free[w++] = piece;
1718
+ }
1719
+ if (tally !== undefined) {
1720
+ tally.bandSplits++;
1721
+ tally.containmentTests += tests;
1722
+ tally.entriesRead += length;
1723
+ }
1724
+ return w;
1725
+ }
1726
+
1727
+ /** Whether `a` contains `b`. */
1728
+ function rectContains(a: Rect, b: Rect): boolean {
1729
+ return b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
1730
+ }
1731
+
1732
+ /**
1733
+ * `splitFree`'s working storage, kept between calls so a split allocates
1734
+ * nothing but the pieces it cuts (issue #1165). `splitFree` is never
1735
+ * re-entered and reads no entry past the `n` it wrote this call, so what an
1736
+ * earlier call left behind is never read.
1737
+ */
1738
+ const splitNext: Rect[] = [];
1739
+ let splitSide = new Int8Array(0);
1740
+ let splitLeft = new Int32Array(0);
1741
+ let splitRight = new Int32Array(0);
1742
+ let splitAbove = new Int32Array(0);
1743
+ let splitBelow = new Int32Array(0);
1744
+ /** `splitFreeByCell`'s near list, kept between calls for the same reason. */
1745
+ const splitNear: Rect[] = [];
1746
+
1747
+ /**
1748
+ * Every band of one placed cell split out of the first `length` entries of
1749
+ * `free` (`splitFree`, once per band, top to bottom); returns the new length.
1750
+ *
1751
+ * ⭐ **The list is split near the cell only** (issue #1165). Before a cell's
1752
+ * bands are split, the list is parted once into the entries that touch the
1753
+ * bands' bounding box — overlapping it or sharing an edge with it — and the
1754
+ * rest; every band is then split over the near part alone, and the far part is
1755
+ * written back first, the near part after it. Nothing the far part holds could
1756
+ * have been read:
1757
+ *
1758
+ * * a far entry does not overlap any band (every band lies inside the box),
1759
+ * so every split leaves it untouched — kept as it is, never tested;
1760
+ * * a far entry contains no piece any band cuts. A piece cut from the left of
1761
+ * a band ends at the band's left edge `x` and keeps a row `y` of the band
1762
+ * (its parent overlapped the band), so a container of it holds the texel
1763
+ * `(x − 1, y)` — inside the box or against its left edge, which an entry
1764
+ * that neither overlaps the box nor shares an edge with it cannot hold.
1765
+ * Likewise for the other three sides. And every piece is cut from a near
1766
+ * entry, so it is in the near part for the next band.
1767
+ *
1768
+ * So every split makes the decisions over the near part that it makes over the
1769
+ * whole list, and the far part comes through every split unchanged. ⚠️ What
1770
+ * changes is the ORDER of the list: the far entries move ahead of the near ones.
1771
+ * That decides nothing, because nothing that reads the list reads its order:
1772
+ *
1773
+ * * a split keeps every untouched entry, and keeps a piece unless another
1774
+ * usable entry contains it, the later index losing only between two equal
1775
+ * rectangles — and which of two equal rectangles survives is invisible,
1776
+ * they are the same rectangle — so the SET of rectangles after a split is
1777
+ * a function of the set before it;
1778
+ * * a candidate is chosen by a total order on its score, its free
1779
+ * rectangle's corner and its anchor (`packOnePageByFootprint`); two free
1780
+ * rectangles that tie on all of those put the cell at the same place.
1781
+ *
1782
+ * So the placements are the ones the whole-list split makes, decision for
1783
+ * decision. What it saves: on three production rigs the near part is 10 to 22 %
1784
+ * of the list (about 90 entries of 940 on the slowest), and each of a cell's
1785
+ * 50 or so bands read the whole list. `partition: false` splits every band over
1786
+ * the whole list in order, as before — kept so the selftest can plant the
1787
+ * work back (`PK106`) and hold that the pages do not move (`PK94`, through
1788
+ * `edgeIndex: false`, which splits the whole list too).
1789
+ */
1790
+ function splitFreeByCell(
1791
+ free: Rect[],
1792
+ length: number,
1793
+ cellX: number,
1794
+ cellY: number,
1795
+ rects: readonly Rect[],
1796
+ minW: number,
1797
+ minH: number,
1798
+ edgeIndex: boolean,
1799
+ partition: boolean,
1800
+ tally?: PassTally,
1801
+ ): number {
1802
+ if (!edgeIndex || !partition) {
1803
+ let n = length;
1804
+ for (const r of rects) n = splitFree(free, n, { x: cellX + r.x, y: cellY + r.y, w: r.w, h: r.h }, minW, minH, edgeIndex, tally);
1805
+ return n;
1806
+ }
1807
+ let x0 = Infinity;
1808
+ let y0 = Infinity;
1809
+ let x1 = -Infinity;
1810
+ let y1 = -Infinity;
1811
+ for (const r of rects) {
1812
+ x0 = Math.min(x0, cellX + r.x);
1813
+ y0 = Math.min(y0, cellY + r.y);
1814
+ x1 = Math.max(x1, cellX + r.x + r.w);
1815
+ y1 = Math.max(y1, cellY + r.y + r.h);
1816
+ }
1817
+ const near = splitNear;
1818
+ let nNear = 0;
1819
+ let nFar = 0;
1820
+ for (let k = 0; k < length; k++) {
1821
+ const fr = free[k];
1822
+ if (fr.x <= x1 && fr.x + fr.w >= x0 && fr.y <= y1 && fr.y + fr.h >= y0) near[nNear++] = fr;
1823
+ else free[nFar++] = fr;
1824
+ }
1825
+ if (tally !== undefined) tally.entriesRead += length;
1826
+ for (const r of rects) nNear = splitFree(near, nNear, { x: cellX + r.x, y: cellY + r.y, w: r.w, h: r.h }, minW, minH, true, tally);
1827
+ for (let k = 0; k < nNear; k++) free[nFar + k] = near[k];
1828
+ return nFar + nNear;
1829
+ }
1830
+
1831
+ /** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
1832
+ const LEFT_OF_PUT = 0;
1833
+ const RIGHT_OF_PUT = 1;
1834
+ const ABOVE_PUT = 2;
1835
+ const BELOW_PUT = 3;
1836
+ const UNTOUCHED = -1;
1837
+
1838
+ /**
1839
+ * `packOnePage` with the free-space test replaced by the footprint test
1840
+ * (issue #1099) — the placement `shape: 'polygon'` makes.
1841
+ *
1842
+ * What changes is what the free list is the free space OF. `packOnePage`
1843
+ * splits it by each placed cell; this splits it by each placed cell's
1844
+ * protected set rounded out to bands of `FOOTPRINT_BAND_ROWS` rows
1845
+ * (`CellShape.rects`), so a free rectangle may lie inside an earlier cell
1846
+ * wherever that cell's region draws nothing. The bands only make the free list
1847
+ * coarser — a free rectangle is still clear of every owned texel — and they
1848
+ * are what keeps it short: per row, a 60-part set of ellipse hulls packed on a
1849
+ * `free` page in over ten minutes; in bands of 8 rows, in 14.9 s against the
1850
+ * rectangle pass's 0.6 s, the same page — and with `splitFree`'s sliver drop
1851
+ * beside them, in 5.8 s against 2.2 s (a loaded machine). Everything else is
1852
+ * MaxRects as `packOnePage` runs it:
1853
+ *
1854
+ * * a candidate is a free rectangle that holds the cell's protected set's
1855
+ * bounding box, anchored at its top-left corner — the cell placed so that
1856
+ * box's corner lands there, which may put the cell's own unclaimed
1857
+ * texels over a neighbour's — and whose cell stays on the page
1858
+ * (*Candidates*, below, for the rules measured beside it);
1859
+ * * the score is Best Short Side Fit over the part of the free rectangle the
1860
+ * box uses from its corner (the box itself, under the default anchor),
1861
+ * with the same total tie-break (smallest long-side leftover, then
1862
+ * topmost, then leftmost, then the first anchor);
1863
+ * * the free list is split and pruned by the same code (`splitFree`), once
1864
+ * per rectangle of the protected set.
1865
+ *
1866
+ * ⇒ For a cell whose set is the whole cell (`CellShape.whole`), the box IS the
1867
+ * cell, the anchor IS the free rectangle's corner and the split IS the cell's:
1868
+ * a pack with no mesh region makes `packOnePage`'s decisions one for one
1869
+ * (`PK82`).
1870
+ *
1871
+ * ## Candidates — the second anchor (issue #1104)
1872
+ *
1873
+ * The first anchor puts the owned box's corner on the free rectangle's corner,
1874
+ * the cell then reaching up and left of it by the box's offset. So a cell whose
1875
+ * box is inset by `(dx, dy)` can only go where a free rectangle starts at least
1876
+ * `dx` from the page's left and `dy` from its top — on an empty page, nowhere.
1877
+ * A large region whose mesh draws a small part of it is then every footprint
1878
+ * pass's first miss: on `fixtures/polypack_shapes.ts`'s seed 1107, and on the
1879
+ * production rig it stands for, every footprint pass lost to the rectangle pass
1880
+ * and the polygon pack was the `rect` pack to the texel.
1881
+ *
1882
+ * The second anchor puts the cell's own corner there instead, the box inset by
1883
+ * its offset, and is a candidate when the free rectangle holds the box where it
1884
+ * then lies. The two coincide when the box starts at the cell's corner, so it
1885
+ * is only tried for an inset box — never for a whole cell, which is why a pack
1886
+ * with no mesh region is unchanged by it. Both keep the box inside a free
1887
+ * rectangle and the cell on the page, so no texel test, gutter or atlas field
1888
+ * changes: `bounds` stays inside `size`, as the runtime's UVs assume. Which
1889
+ * free rectangles get it is the rule (`FOOTPRINT_ANCHORS`); which rule's pack
1890
+ * is written is `packAtlas`'s choice by Σ page area.
1891
+ *
1892
+ * 🔒 **The footprint test is then stated outright rather than trusted to the
1893
+ * bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
1894
+ * because no free rectangle overlaps one; every placement is nevertheless
1895
+ * checked texel for texel against every set already placed, and a meeting is
1896
+ * an internal error, not a placement.
1897
+ *
1898
+ * It terminates and is deterministic for `packOnePage`'s reasons: one pass
1899
+ * over the cells in the caller's order, each over a finite free list, every
1900
+ * choice made by a total order, and no input but the cells and the page.
1901
+ */
1902
+ function packOnePageByFootprint(
1903
+ shapes: readonly CellShape[],
1904
+ pageW: number,
1905
+ pageH: number,
1906
+ maxBottom = Infinity,
1907
+ stopAtMiss = false,
1908
+ edgeIndex = true,
1909
+ tally?: PassTally,
1910
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
1911
+ partition = true,
1912
+ ): Array<Rect | null> {
1913
+ // The free list is `free`'s first `freeLength` entries (`splitFree`).
1914
+ const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1915
+ let freeLength = 1;
1916
+ const placed: Array<Rect | null> = [];
1917
+ const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
1918
+ // The least box any cell of this pass needs — what a free rectangle must
1919
+ // hold to be a candidate for anything (`splitFree`, the second saving).
1920
+ let minW = Infinity;
1921
+ let minH = Infinity;
1922
+ for (const shape of shapes) {
1923
+ minW = Math.min(minW, shape.bbox.w);
1924
+ minH = Math.min(minH, shape.bbox.h);
1925
+ }
1926
+
1927
+ for (const shape of shapes) {
1928
+ const box = shape.bbox;
1929
+ let best: { x: number; y: number; frX: number; frY: number; anchor: number } | null = null;
1930
+ let bestShort = Infinity;
1931
+ let bestLong = Infinity;
1932
+ let held = false;
1933
+ // The second anchor differs from the first only when the box is inset.
1934
+ const anchorCount = anchors !== 'box' && (box.x !== 0 || box.y !== 0) ? 2 : 1;
1935
+ for (let f = 0; f < freeLength; f++) {
1936
+ const fr = free[f];
1937
+ if (fr.w < box.w || fr.h < box.h) continue;
1938
+ held = true;
1939
+ let firstOffPage = false;
1940
+ for (let anchor = 0; anchor < anchorCount; anchor++) {
1941
+ // Anchor 0: the owned box's corner on the free corner. Anchor 1: the
1942
+ // cell's own corner there, the box then inset by its own offset.
1943
+ const x = anchor === 0 ? fr.x - box.x : fr.x;
1944
+ const y = anchor === 0 ? fr.y - box.y : fr.y;
1945
+ const usedW = anchor === 0 ? box.w : box.x + box.w;
1946
+ const usedH = anchor === 0 ? box.h : box.y + box.h;
1947
+ if (usedW > fr.w || usedH > fr.h) continue;
1948
+ const offPage = x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH;
1949
+ if (anchor === 0) firstOffPage = offPage;
1950
+ // `box-else-cell`: the second anchor only where the first left the page.
1951
+ if (anchor === 1 && anchors === 'box-else-cell' && !firstOffPage) continue;
1952
+ if (offPage) {
1953
+ if (anchor === 0 && tally !== undefined) tally.boxAnchorRefused++;
1954
+ continue;
1955
+ }
1956
+ const leftoverW = fr.w - usedW;
1957
+ const leftoverH = fr.h - usedH;
1958
+ const short = Math.min(leftoverW, leftoverH);
1959
+ const long = Math.max(leftoverW, leftoverH);
1960
+ if (best !== null) {
1961
+ if (short > bestShort) continue;
1962
+ if (short === bestShort) {
1963
+ if (long > bestLong) continue;
1964
+ if (long === bestLong) {
1965
+ if (fr.y > best.frY) continue;
1966
+ if (fr.y === best.frY) {
1967
+ if (fr.x > best.frX) continue;
1968
+ if (fr.x === best.frX && anchor >= best.anchor) continue;
1969
+ }
1970
+ }
1971
+ }
1972
+ }
1973
+ best = { x, y, frX: fr.x, frY: fr.y, anchor };
1974
+ bestShort = short;
1975
+ bestLong = long;
1976
+ }
1977
+ }
1978
+ if (best === null) {
1979
+ if (held && tally !== undefined) tally.missedByAnchor++;
1980
+ placed.push(null);
1981
+ // A caller that keeps only a pass placing every cell has its answer
1982
+ // (`placePass`, `wholeOrNothing`): the rest are not placed.
1983
+ if (stopAtMiss) {
1984
+ while (placed.length < shapes.length) placed.push(null);
1985
+ return placed;
1986
+ }
1987
+ continue;
1988
+ }
1989
+ if (best.anchor === 1 && tally !== undefined) tally.secondAnchorPlaced++;
1990
+ const put: Rect = { x: best.x, y: best.y, w: shape.width, h: shape.height };
1991
+ for (const other of owned) {
1992
+ // Two cells that do not overlap cannot share a texel, and that answer
1993
+ // takes no texel to read; only an overlap is scanned, and only over the
1994
+ // overlap's own box (`shapesMeet`).
1995
+ const apart =
1996
+ put.x >= other.x + other.shape.width || other.x >= put.x + put.w || put.y >= other.y + other.shape.height || other.y >= put.y + put.h;
1997
+ if (apart) continue;
1998
+ if (shapesMeet(shape, put.x, put.y, other.shape, other.x, other.y)) {
1999
+ throw new CompileError(
2000
+ `internal: the footprint pass placed a ${shape.width}x${shape.height} cell at ${put.x},${put.y} over ` +
2001
+ `the footprint of the ${other.shape.width}x${other.shape.height} cell at ${other.x},${other.y}`,
2002
+ );
2003
+ }
2004
+ }
2005
+ placed.push(put);
2006
+ owned.push({ shape, x: put.x, y: put.y });
2007
+ if (put.y + put.h > maxBottom) {
2008
+ while (placed.length < shapes.length) placed.push(null);
2009
+ return placed;
2010
+ }
2011
+ freeLength = splitFreeByCell(free, freeLength, put.x, put.y, shape.rects, minW, minH, edgeIndex, partition, tally);
2012
+ }
2013
+ return placed;
2014
+ }
2015
+
2016
+ /**
2017
+ * One footprint pass on an empty `pageW x pageH` page, run to its end, with
2018
+ * what it counted — exported for `tools/pack_anchor.ts` (issue #1104).
2019
+ */
2020
+ export function footprintPass(
2021
+ shapes: readonly CellShape[],
2022
+ pageW: number,
2023
+ pageH: number,
2024
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
2025
+ ): { rects: Array<{ x: number; y: number; w: number; h: number } | null>; tally: PassTally } {
2026
+ const tally = emptyTally();
2027
+ const rects = packOnePageByFootprint(shapes, pageW, pageH, Infinity, false, true, tally, anchors);
2028
+ return { rects, tally };
2029
+ }
2030
+
2031
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
2032
+ const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
2033
+
2034
+ /**
2035
+ * What every packed region's attachments draw, read off the `skeleton.json` the
2036
+ * build emitted — the footprints `shape: 'polygon'` packs by (issue #1099).
2037
+ *
2038
+ * The emitted text and not the model, because the footprint has to be what the
2039
+ * runtime will sample: the mesh's UVs as the file writes them.
2040
+ *
2041
+ * * The region an attachment samples is its `path`, else its `name`, else its
2042
+ * key — the runtime's rule — and a `sequence` samples `that + (start +
2043
+ * frame)` zero-padded to `digits`, for every frame (`start` 1 and `digits` 0
2044
+ * when the file omits them, the parser's defaults).
2045
+ * * A **region** attachment's footprint is its rectangle (`null` here).
2046
+ * * A **mesh**'s is the polygon of its first `hull` vertices, closed from the
2047
+ * last back to the first, and each of its triangles — the hull is the outer
2048
+ * loop of the triangles' union, so for a well-formed mesh the triangles add
2049
+ * nothing, and for one whose triangles reach outside its loop they protect
2050
+ * what is drawn. In the region's own texels: `(u · width, v · height)`.
2051
+ * * A **linked mesh** draws its source's geometry over its own region: the
2052
+ * mesh keyed `source` (`parent` before 4.3) in the slot `slot` names (its
2053
+ * own by default) of the skin `skin` names (`default` by default).
2054
+ * * A region with **any** rectangle use is a rectangle, and so is every
2055
+ * region whose drawing cannot be read as a polygon here — a mesh whose UVs
2056
+ * leave 0..1 (it samples outside its own rectangle, which only the
2057
+ * rectangle keeps the same), a hull or triangle list that does not index its
2058
+ * vertices, a link whose source is not a mesh. Several meshes over one
2059
+ * region give it the union of their polygons.
2060
+ *
2061
+ * Returns, per region name, its footprint, or `null` for a rectangle; a region
2062
+ * no attachment names is absent, and the packer treats it as a rectangle.
2063
+ * Nothing read here can be invented: every fallback is the rectangle, which is
2064
+ * the footprint `shape: 'rect'` gives everything.
2065
+ */
2066
+ export function packFootprints(
2067
+ skeletonText: string,
2068
+ sizeOf: (region: string) => { width: number; height: number } | undefined,
2069
+ ): Map<string, PackFootprint | null> {
2070
+ const doc: unknown = JSON.parse(skeletonText);
2071
+ const skins = isObject(doc) && Array.isArray(doc.skins) ? doc.skins : [];
2072
+ const tableOf = (skin: string, slot: string): Record<string, unknown> | undefined => {
2073
+ for (const s of skins) {
2074
+ if (!isObject(s) || s.name !== skin || !isObject(s.attachments)) continue;
2075
+ const table = s.attachments[slot];
2076
+ return isObject(table) ? table : undefined;
2077
+ }
2078
+ return undefined;
2079
+ };
2080
+ /** A mesh's UV polygons — hull loop, then triangles — or null when they cannot be read as polygons over its own rectangle. */
2081
+ const uvPolygonsOf = (mesh: Record<string, unknown>): number[][] | null => {
2082
+ const { uvs, hull, triangles } = mesh;
2083
+ if (!isNumberList(uvs) || uvs.length % 2 !== 0 || typeof hull !== 'number' || !isNumberList(triangles)) return null;
2084
+ const vertices = uvs.length / 2;
2085
+ if (!Number.isInteger(hull) || hull < 3 || hull > vertices || triangles.length % 3 !== 0) return null;
2086
+ if (uvs.some((n) => n < 0 || n > 1)) return null;
2087
+ if (triangles.some((t) => !Number.isInteger(t) || t < 0 || t >= vertices)) return null;
2088
+ const out: number[][] = [uvs.slice(0, 2 * hull)];
2089
+ for (let t = 0; t < triangles.length; t += 3) {
2090
+ const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
2091
+ out.push([uvs[2 * a], uvs[2 * a + 1], uvs[2 * b], uvs[2 * b + 1], uvs[2 * c], uvs[2 * c + 1]]);
2092
+ }
2093
+ return out;
2094
+ };
2095
+ const uses = new Map<string, number[][][] | null>();
2096
+ const use = (region: string, polygons: number[][] | null): void => {
2097
+ const held = uses.get(region);
2098
+ if (held === null) return;
2099
+ if (polygons === null) uses.set(region, null);
2100
+ else if (held === undefined) uses.set(region, [polygons]);
2101
+ else held.push(polygons);
2102
+ };
2103
+ for (const s of skins) {
2104
+ if (!isObject(s) || !isObject(s.attachments)) continue;
2105
+ for (const [slot, table] of Object.entries(s.attachments)) {
2106
+ if (!isObject(table)) continue;
2107
+ for (const [key, att] of Object.entries(table)) {
2108
+ if (!isObject(att)) continue;
2109
+ const type = att.type ?? 'region';
2110
+ if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
2111
+ const base = typeof att.path === 'string' ? att.path : typeof att.name === 'string' ? att.name : key;
2112
+ const regions: string[] = [];
2113
+ const seq = att.sequence;
2114
+ if (isObject(seq) && typeof seq.count === 'number') {
2115
+ const start = typeof seq.start === 'number' ? seq.start : 1;
2116
+ const digits = typeof seq.digits === 'number' ? seq.digits : 0;
2117
+ for (let f = 0; f < seq.count; f++) regions.push(base + String(start + f).padStart(digits, '0'));
2118
+ } else regions.push(base);
2119
+ let polygons: number[][] | null = null;
2120
+ if (type === 'mesh') polygons = uvPolygonsOf(att);
2121
+ else if (type === 'linkedmesh') {
2122
+ const sourceKey = typeof att.source === 'string' ? att.source : typeof att.parent === 'string' ? att.parent : null;
2123
+ const source =
2124
+ sourceKey === null
2125
+ ? undefined
2126
+ : tableOf(typeof att.skin === 'string' ? att.skin : 'default', typeof att.slot === 'string' ? att.slot : slot)?.[sourceKey];
2127
+ polygons = isObject(source) && source.type === 'mesh' ? uvPolygonsOf(source) : null;
2128
+ }
2129
+ for (const region of regions) use(region, polygons);
2130
+ }
2131
+ }
2132
+ }
2133
+ const out = new Map<string, PackFootprint | null>();
2134
+ for (const [region, held] of uses) {
2135
+ const size = sizeOf(region);
2136
+ if (held === null || size === undefined) {
2137
+ out.set(region, null);
2138
+ continue;
2139
+ }
2140
+ const polygons = held.flat().map((poly) => poly.map((n, i) => n * (i % 2 === 0 ? size.width : size.height)));
2141
+ out.set(region, { polygons });
2142
+ }
2143
+ return out;
2144
+ }
2145
+
2146
+ /**
2147
+ * Copy the texels a region owns (`CellShape.mask`) onto a page, with the
2148
+ * values `extrudeCell` gives them — the second of `shape: 'polygon'`'s two
2149
+ * drawing passes (see `packAtlas`).
2150
+ */
2151
+ function extrudeOwned(page: Plate, source: Plate, cellX: number, cellY: number, padding: number, shape: CellShape): void {
2152
+ const w = source.width;
2153
+ const h = source.height;
2154
+ const dst = page.data;
2155
+ const src = source.data;
2156
+ for (let cy = 0; cy < shape.height; cy++) {
2157
+ const sy = Math.max(0, Math.min(h - 1, cy - padding));
2158
+ const srcRow = sy * w * 4;
2159
+ const dstRow = (cellY + cy) * page.width * 4;
2160
+ for (let cx = 0; cx < shape.width; cx++) {
2161
+ if (shape.mask[cy * shape.width + cx] === 0) continue;
2162
+ const sx = Math.max(0, Math.min(w - 1, cx - padding));
2163
+ const s = srcRow + sx * 4;
2164
+ const d = dstRow + (cellX + cx) * 4;
2165
+ dst[d] = src[s];
2166
+ dst[d + 1] = src[s + 1];
2167
+ dst[d + 2] = src[s + 2];
2168
+ dst[d + 3] = src[s + 3];
2169
+ }
2170
+ }
2171
+ }
2172
+
2173
+ /**
2174
+ * The packing order, and it is stated rather than inherited.
2175
+ *
2176
+ * Descending by long side then by area is what makes a shelf packer behave; the
2177
+ * name is the final tie-break so that two parts of identical size always pack in
2178
+ * the same order. Region names are unique within a compile (a region name IS the
2179
+ * PNG basename, and `addImage` refuses a duplicate), so this is a TOTAL order —
2180
+ * which is the property `sort` needs for its result not to depend on the order
2181
+ * it was handed. `Bun.Glob` and `readdirSync` are both unsorted; nothing here
2182
+ * relies on the caller having sorted anything.
2183
+ */
2184
+ function packOrder(a: PackInput, b: PackInput): number {
2185
+ const longA = Math.max(a.width, a.height);
2186
+ const longB = Math.max(b.width, b.height);
2187
+ if (longA !== longB) return longB - longA;
2188
+ const areaA = a.width * a.height;
2189
+ const areaB = b.width * b.height;
2190
+ if (areaA !== areaB) return areaB - areaA;
2191
+ return a.region < b.region ? -1 : a.region > b.region ? 1 : 0;
2192
+ }
2193
+
2194
+ /**
2195
+ * Copy one region's pixels onto a page, and fill its gutter by extending its
2196
+ * edges outwards.
2197
+ *
2198
+ * ⭐ **This is what makes the packed render agree with the unpacked one, and it is
2199
+ * not an optimisation.**
2200
+ * The rasteriser samples a page bilinearly and `bilinear` CLAMPS its taps to the
2201
+ * page's bounds ([`src/render.ts`](render.ts)) — so on an unpacked page, where
2202
+ * the region IS the page, a sample at the region's outer edge reads that edge
2203
+ * twice. Pack the same region into the middle of a bigger page and the clamp
2204
+ * stops happening: the second tap is now whatever is next door. Transparent
2205
+ * gutter is not a fix, it is a different wrong answer — the edge would fade.
2206
+ *
2207
+ * Extending the edge outwards reproduces the clamp exactly, because it makes the
2208
+ * neighbouring texel equal to the edge texel, which is what the clamp returned.
2209
+ * One pixel is all bilinear can reach; the gutter is `padding` pixels because a
2210
+ * consumer that mipmaps the page averages 2x2 blocks and 2 keeps that average
2211
+ * inside the region's own colours one level down. Hence `DEFAULT_PADDING = 2`:
2212
+ * 1 is the correctness floor, 2 is the floor plus one level of headroom, and 0
2213
+ * would put two unrelated drawings in adjacent texels.
2214
+ */
2215
+ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, padding: number): void {
2216
+ const w = source.width;
2217
+ const h = source.height;
2218
+ // Straight into the two buffers: a 1024x1024 page is a million pixels and
2219
+ // `Plate.get` allocates a tuple per read.
2220
+ const dst = page.data;
2221
+ const src = source.data;
2222
+ for (let cy = 0; cy < h + 2 * padding; cy++) {
2223
+ const sy = Math.max(0, Math.min(h - 1, cy - padding));
2224
+ const srcRow = sy * w * 4;
2225
+ const dstRow = (cellY + cy) * page.width * 4;
2226
+ for (let cx = 0; cx < w + 2 * padding; cx++) {
2227
+ const sx = Math.max(0, Math.min(w - 1, cx - padding));
2228
+ const s = srcRow + sx * 4;
2229
+ const d = dstRow + (cellX + cx) * 4;
2230
+ dst[d] = src[s];
2231
+ dst[d + 1] = src[s + 1];
2232
+ dst[d + 2] = src[s + 2];
2233
+ dst[d + 3] = src[s + 3];
2234
+ }
2235
+ }
2236
+ }
2237
+
2238
+ /** Where every part lands and the size each page is written at — one candidate's whole pack, before anything is drawn. */
2239
+ interface PackLayout {
2240
+ /** page index -> the placements on it, in packing order. */
2241
+ perPage: Placement[][];
2242
+ /** page index -> the size that page is written at. */
2243
+ pageSizes: Array<{ width: number; height: number }>;
2244
+ placements: Placement[];
2245
+ }
2246
+
2247
+ /**
2248
+ * One whole pack's layout under one placement rule: the single-page search,
2249
+ * or — when the set does not fit one page — the spill, each page then shrunk
2250
+ * to the smallest that holds it. `shapes` absent is the rectangle pass
2251
+ * throughout (`rect`, and the first of `polygon`'s candidates); given, the
2252
+ * footprint pass under `anchors`.
2253
+ */
2254
+ function layoutPack(
2255
+ sorted: readonly PackInput[],
2256
+ cells: Array<{ w: number; h: number }>,
2257
+ shapes: readonly CellShape[] | undefined,
2258
+ maxEdge: number,
2259
+ pageEdges: PageEdges,
2260
+ padding: number,
2261
+ anchors: FootprintAnchors,
2262
+ tally?: PassTally,
2263
+ stopAtMiss = true,
2264
+ partition = true,
2265
+ ): PackLayout {
2266
+ const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss, partition);
2267
+
2268
+ /** page index -> the placements on it, in packing order. */
2269
+ const perPage: Placement[][] = [];
2270
+ /** page index -> the size that page is written at. */
2271
+ const pageSizes: Array<{ width: number; height: number }> = [];
2272
+ const placements: Placement[] = [];
2273
+ if (single !== null) {
2274
+ perPage.push([]);
2275
+ pageSizes.push({ width: single.width, height: single.height });
2276
+ single.rects.forEach((rect, i) => {
2277
+ const place: Placement = {
2278
+ region: sorted[i].region,
2279
+ page: 0,
2280
+ x: rect.x + padding,
2281
+ y: rect.y + padding,
2282
+ width: sorted[i].width,
2283
+ height: sorted[i].height,
2284
+ };
2285
+ perPage[0].push(place);
2286
+ placements.push(place);
2287
+ });
2288
+ } else {
2289
+ // Spill. Which parts share a page is decided at the MAXIMUM size — that is
2290
+ // what makes the boundary deterministic and independent of the shrink below
2291
+ // — and parts are taken in packing order, whatever will not fit the current
2292
+ // page opening the next one.
2293
+ let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
2294
+ const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
2295
+ shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
2296
+ while (remaining.length > 0) {
2297
+ const pageIndex = perPage.length;
2298
+ const attempt = placePass(
2299
+ remaining.map((r) => r.cell),
2300
+ shapesOf(remaining),
2301
+ maxEdge,
2302
+ maxEdge,
2303
+ Infinity,
2304
+ { anchors, tally, partition },
2305
+ );
2306
+ const onPage: typeof remaining = [];
2307
+ const leftOver: typeof remaining = [];
2308
+ attempt.forEach((rect, i) => {
2309
+ if (rect === null) leftOver.push(remaining[i]);
2310
+ else onPage.push(remaining[i]);
2311
+ });
2312
+ if (onPage.length === 0) {
2313
+ // Unreachable: every cell was proven to fit an empty page above. Kept as
2314
+ // a named stop rather than an infinite loop if that ever stops holding.
2315
+ throw new CompileError(
2316
+ `packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
2317
+ );
2318
+ }
2319
+ // ⭐ Then the page is written at the smallest power-of-two pair that holds
2320
+ // the cells assigned to it, not at the maximum (issue #266, follow-up 3).
2321
+ // A spill used to write `pageSize x pageSize` for every page, so a set that
2322
+ // overflowed by one small part paid for a second full page of transparency
2323
+ // — 4 MiB of decoded RAM at the 2048 default for a part that might be
2324
+ // 64x64. Re-packing through the same search the single-page case uses is
2325
+ // what keeps "the page written is the smallest that holds what is on it"
2326
+ // one rule; a page whose own cells need the maximum simply gets it back.
2327
+ const shrunk = smallestPageFor(
2328
+ onPage.map((r) => r.cell),
2329
+ maxEdge,
2330
+ pageEdges,
2331
+ shapesOf(onPage),
2332
+ anchors,
2333
+ tally,
2334
+ stopAtMiss,
2335
+ partition,
2336
+ );
2337
+ if (shrunk === null) {
2338
+ // Unreachable for the same reason as the stall above: these cells were
2339
+ // just placed on a maxEdge page.
2340
+ throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
2341
+ }
2342
+ const onThisPage: Placement[] = [];
2343
+ shrunk.rects.forEach((rect, i) => {
2344
+ const place: Placement = {
2345
+ region: onPage[i].input.region,
2346
+ page: pageIndex,
2347
+ x: rect.x + padding,
2348
+ y: rect.y + padding,
2349
+ width: onPage[i].input.width,
2350
+ height: onPage[i].input.height,
2351
+ };
2352
+ onThisPage.push(place);
2353
+ placements.push(place);
2354
+ });
2355
+ perPage.push(onThisPage);
2356
+ pageSizes.push({ width: shrunk.width, height: shrunk.height });
2357
+ remaining = leftOver;
2358
+ }
2359
+ }
2360
+
2361
+ return { perPage, pageSizes, placements };
2362
+ }
2363
+
2364
+ /**
2365
+ * Pack these parts onto shared pages.
2366
+ *
2367
+ * ## Page size
2368
+ *
2369
+ * `pageSize` is a MAXIMUM, not the size written. Both page edges are powers of
2370
+ * two and the pack is tried at every power-of-two pair up to that maximum, in
2371
+ * order of increasing area, so a four-part rig gets a 128x64 page rather than a
2372
+ * megabyte of transparency. Powers of two are not decoration: `region.x /
2373
+ * page.width` is the sampling coordinate every texel is read through, and a
2374
+ * power-of-two denominator makes that division exact in binary floating point —
2375
+ * which is what keeps a packed region's samples on the same grid as the unpacked
2376
+ * page's. With a non-power-of-two page the region origin itself would round, and
2377
+ * the one-bit residual described in this file's header would be two roundings
2378
+ * deep instead of one.
2379
+ *
2380
+ * Only when the whole set will not fit one page at the maximum does it spill.
2381
+ * **Which parts share a page is then decided at the maximum size** — that is what
2382
+ * makes the boundary deterministic — **and each page is written at the smallest
2383
+ * power-of-two pair that holds the cells assigned to it** (issue #266). So the
2384
+ * rule is the same one either way: the page written is the smallest that holds
2385
+ * what is on it. A spill used to write `pageSize x pageSize` for every page,
2386
+ * which charged a set that overflowed by one small part a second full page of
2387
+ * transparency — 4 MiB of decoded RAM at the 2048 default. A single part whose
2388
+ * cell is bigger than the maximum is refused by name — silently splitting one
2389
+ * drawing across two pages is not a thing the format can express.
2390
+ *
2391
+ * ## `pageEdges: 'free'` — a page sized to the parts rather than to a power of two (issue #860)
2392
+ *
2393
+ * Opt-in, and the default stays `pot`: every runtime accepts a power-of-two page
2394
+ * and the editor's own packer writes one by default — 9 of the 10 atlases in the
2395
+ * fetched `examples/` corpus are power-of-two on both edges. Under `free` the
2396
+ * search is `smallestFreePageFor`: every width from the widest cell up (issue
2397
+ * #872; a 32-px grid until then), the height the placement needs, least area
2398
+ * first, then squarer, then narrower, with two exact bounds that keep it cheap. Everything
2399
+ * else is shared — the MaxRects pass, the packing order, the spill rule (which
2400
+ * parts share a page is still decided at `maxEdge x maxEdge`) and `--page-size`
2401
+ * as the ceiling on both edges, floored to a power of two exactly as under `pot`.
2402
+ *
2403
+ * What it buys and what it costs, observed on two 20- and 22-part painting rigs:
2404
+ * the page goes from 1024x2048 to 967x1338 (2,097,152 to 1,293,846 texels,
2405
+ * -38.3 %) and from 512x2048 to 479x1166 (1,048,576 to 558,514, -46.7 %). Under
2406
+ * the 32-px grid #860 shipped with they were 1888x697 (-37.3 %) and 480x1166
2407
+ * (-46.6 %). The cost is the exactness the paragraph above describes:
2408
+ * `x / pageWidth` is no longer exact, so a REGION attachment's sampling
2409
+ * coordinate joins a mesh's at `PK05`'s bound of one least significant bit
2410
+ * against the loose build. It does not go past it, and no region's bytes change
2411
+ * (`PK02`'s lift-back holds under both).
2412
+ *
2413
+ * ## `shape: 'polygon'` — rectangles that overlap where nothing is drawn (issue #1099)
2414
+ *
2415
+ * Opt-in, and the default stays `rect`, which is this function exactly as it
2416
+ * was before the option: under `rect` no footprint is computed and every pass
2417
+ * is `packOnePage`. Under `polygon` every input carries the footprint its
2418
+ * attachments draw (`packFootprints`: a mesh's emitted hull, a region
2419
+ * attachment's rectangle), and:
2420
+ *
2421
+ * * **what a cell owns** is `footprintCell`'s set — the footprint grown by the
2422
+ * padding, clipped to the cell; two cells may overlap when they own no texel
2423
+ * in common, which between two rectangles is `rect`'s rule and between any
2424
+ * two footprints keeps them `2 · padding` apart (`CellShape`);
2425
+ * * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
2426
+ * split by what each cell owns, run beside the rectangle pass on every page
2427
+ * tried and kept only when it is better (`placePass`);
2428
+ * * **the pack written** is the least Σ page area — over every page, a spill's
2429
+ * included — of three whole packs: `rect`'s, the footprint pack under the
2430
+ * owned box's anchor, and under `box-else-cell` (issue #1104), the earlier
2431
+ * on a tie. So a `polygon` pack is never larger in page area than `rect`'s
2432
+ * on any set, by construction, and a set with no mesh footprint is `rect`'s
2433
+ * pack to the byte (`PK82`). The per-page rule alone never promised that
2434
+ * across a spill: seed 1105 under `pot` packed 8,388,608 texels against
2435
+ * `rect`'s 6,291,456 before it (`PK96`). It costs the three packs' searches
2436
+ * — no search is shared, since a free search's bounds depend on the best
2437
+ * page its own passes found;
2438
+ * * **the pixels** are drawn in two passes: every cell whole in packing order,
2439
+ * as under `rect`, then every region's owned texels again with its own
2440
+ * values. The owned sets are disjoint, so the second pass's order decides
2441
+ * nothing and every texel a region can sample is its own (`PK79`); a texel
2442
+ * nobody owns carries the last cell drawn over it, which nothing samples.
2443
+ *
2444
+ * What it costs is measured, not conceded: a region that moves samples through
2445
+ * a different `x / pageWidth`, so on the two gallery rigs whose pack moved,
2446
+ * 207 and 36 channel samples differ from the `rect` render, every one by 1 —
2447
+ * the bit two `rect` packs of the same rig differ by when one is `pot` and one
2448
+ * `free` (155 and 44). `--page-edges`, the width search and the spill rule are
2449
+ * unchanged.
2450
+ *
2451
+ * ⚠️ `pot` is not the smaller answer's poor relation, and its search was never
2452
+ * "double until it fits": it tries every power-of-two pair in order of area.
2453
+ * On both rigs above the cells' own area (1,206,755 and 540,793 texels at
2454
+ * padding 2) already exceeds the next power-of-two page down, so no `pot`
2455
+ * packer could do better; the only lever left was the power of two itself.
2456
+ */
2457
+ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResult {
2458
+ const pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
2459
+ const padding = opts.padding ?? DEFAULT_PADDING;
2460
+ const stem = opts.pageStem ?? 'skeleton';
2461
+ const pageEdges = opts.pageEdges ?? DEFAULT_PAGE_EDGES;
2462
+ if (!PAGE_EDGES.includes(pageEdges)) {
2463
+ throw new CompileError(`--page-edges ${JSON.stringify(String(opts.pageEdges))}; known values: ${PAGE_EDGES.join(', ')}`);
2464
+ }
2465
+ const shape = opts.shape ?? DEFAULT_PACK_SHAPE;
2466
+ if (!PACK_SHAPES.includes(shape)) {
2467
+ throw new CompileError(`--pack-shape ${JSON.stringify(String(opts.shape))}; known values: ${PACK_SHAPES.join(', ')}`);
2468
+ }
2469
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
2470
+ throw new CompileError(`--page-size must be a positive integer, got ${String(opts.pageSize)}`);
2471
+ }
2472
+ if (!Number.isInteger(padding) || padding < 0) {
2473
+ throw new CompileError(`--padding must be a non-negative integer, got ${String(opts.padding)}`);
2474
+ }
2475
+ if (inputs.length === 0) throw new CompileError('nothing to pack: this compile emitted no images');
2476
+
2477
+ const maxEdge = floorPowerOfTwo(pageSize);
2478
+
2479
+ const sorted = inputs.slice().sort(packOrder);
2480
+ const cells = sorted.map((input) => ({ w: input.width + 2 * padding, h: input.height + 2 * padding }));
2481
+
2482
+ for (let i = 0; i < sorted.length; i++) {
2483
+ if (cells[i].w <= maxEdge && cells[i].h <= maxEdge) continue;
2484
+ throw new CompileError(
2485
+ `"${sorted[i].region}" is ${sorted[i].width}x${sorted[i].height} and with --padding ${padding} needs a ` +
2486
+ `${cells[i].w}x${cells[i].h} cell, which does not fit a ${maxEdge}x${maxEdge} page ` +
2487
+ `(${sorted[i].absPath}). Raise --page-size, lower --padding, or leave this build unpacked — a packer ` +
2488
+ 'cannot split one drawing across two pages.',
2489
+ );
2490
+ }
2491
+
2492
+ // `polygon` only: every cell's protected set. Under `rect` there are none and
2493
+ // every pass below is the rectangle pass, the code it was before #1099.
2494
+ const shapes =
2495
+ shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
2496
+
2497
+ // ⭐ `polygon` is a choice between whole packs (issue #1104), made on the
2498
+ // one figure the mode exists to lower — the sum of the pages' areas — and
2499
+ // made over every page, a spill's included. The candidates, in order: the
2500
+ // rectangle pack (`rect`'s own, to the byte); the footprint pack with the
2501
+ // owned box's anchor (`box`); and the footprint pack that may also put a
2502
+ // cell's own corner where the first anchor would leave the page
2503
+ // (`box-else-cell`). The least Σ area wins, the earlier candidate on a tie.
2504
+ // So a `polygon` pack is never larger than `rect`'s, nor than the one-anchor
2505
+ // pack, on any set — by construction, across a spill as well as on one page,
2506
+ // which no single greedy rule gives: one more candidate position placed
2507
+ // PK78's gem differently and every tile after it (one page 7.2 % larger),
2508
+ // and the first page of a spill choosing differently left seed 1105 under
2509
+ // `pot` on two 2048x2048 pages against `rect`'s 2048x2048 + 1024x2048.
2510
+ // `footprintAnchors` names one rule alone instead (the instrument, the plant).
2511
+ const candidates: Array<{ name: PackCandidate; layout: PackLayout }> = [];
2512
+ if (shapes === undefined) candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2513
+ else if (opts.footprintAnchors !== undefined) candidates.push({ name: opts.footprintAnchors, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, opts.footprintAnchors, opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2514
+ else {
2515
+ candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2516
+ for (const rule of POLYGON_CANDIDATE_RULES) candidates.push({ name: rule, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, rule, opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2517
+ }
2518
+ const areaOf = (layout: PackLayout): number => layout.pageSizes.reduce((n, p) => n + p.width * p.height, 0);
2519
+ let chosen = candidates[0];
2520
+ for (const c of candidates) if (areaOf(c.layout) < areaOf(chosen.layout)) chosen = c;
2521
+ const { perPage, pageSizes, placements } = chosen.layout;
2522
+
2523
+ // Draw. Reading each source once, in packing order, keeps the decode count at
2524
+ // one per part whatever the page layout turned out to be.
2525
+ const byRegion = new Map(sorted.map((input) => [input.region, input]));
2526
+ const shapeByRegion = new Map<string, CellShape>();
2527
+ if (shapes !== undefined) sorted.forEach((input, i) => shapeByRegion.set(input.region, shapes[i]));
2528
+ const pages: PackedPage[] = [];
2529
+ const emitPages: EmitPage[] = [];
2530
+ perPage.forEach((onPage, index) => {
2531
+ const { width: pageW, height: pageH } = pageSizes[index];
2532
+ const plate = new Plate(pageW, pageH);
2533
+ let covered = 0;
2534
+ /** `polygon` only: each region's source and protected set, for the second pass. */
2535
+ const owners: Array<{ source: Plate; place: Placement; shape: CellShape }> = [];
2536
+ for (const place of onPage) {
2537
+ const input = byRegion.get(place.region)!;
2538
+ const source = readPlate(input.absPath);
2539
+ if (source.width !== input.width || source.height !== input.height) {
2540
+ // The size in the atlas came from the PNG's IHDR (`readPngInfo`); this is
2541
+ // the decoded image. They disagreeing means the file changed between the
2542
+ // two reads, or one of the two readers is wrong about it.
2543
+ throw new CompileError(
2544
+ `"${place.region}" measured ${input.width}x${input.height} from its PNG header and decodes to ` +
2545
+ `${source.width}x${source.height} (${input.absPath})`,
2546
+ );
2547
+ }
2548
+ extrudeCell(plate, source, place.x - padding, place.y - padding, padding);
2549
+ covered += place.width * place.height;
2550
+ const owned = shapeByRegion.get(place.region);
2551
+ if (owned !== undefined) owners.push({ source, place, shape: owned });
2552
+ }
2553
+ // `polygon`'s second pass. The first wrote every cell whole, in packing
2554
+ // order, so where two cells overlap the later one's texels lie on top; this
2555
+ // one writes each region's protected set again, with its own values. The
2556
+ // sets are pairwise disjoint (the footprint test), so the order of this pass
2557
+ // decides nothing, and every texel a region owns ends as its own: a texel
2558
+ // under some region's footprint carries that region's value, and a texel
2559
+ // under none carries the last cell drawn over it.
2560
+ // Every region, a whole cell's included: a rectangle's texels are what a
2561
+ // later mesh's cell is most likely to have been drawn over. A page whose
2562
+ // every cell is whole had no overlap to undo and is left as the first pass
2563
+ // drew it, which is `rect`'s page.
2564
+ if (owners.some((o) => !o.shape.whole)) {
2565
+ for (const { source, place, shape: owned } of owners) extrudeOwned(plate, source, place.x - padding, place.y - padding, padding, owned);
2566
+ }
2567
+ const name = index === 0 ? `${stem}.png` : `${stem}${index + 1}.png`;
2568
+ pages.push({ name, width: pageW, height: pageH, plate, occupancy: covered / (pageW * pageH) });
2569
+ emitPages.push({
2570
+ name,
2571
+ width: pageW,
2572
+ height: pageH,
2573
+ // Within a page, regions are listed by name. Placement order is equally
2574
+ // deterministic; a name order makes two packs of the same set diffable
2575
+ // even when a part changed size and moved.
2576
+ regions: onPage
2577
+ .slice()
2578
+ .sort((a, b) => (a.region < b.region ? -1 : a.region > b.region ? 1 : 0))
2579
+ .map((place) => ({
2580
+ name: place.region,
2581
+ x: place.x,
2582
+ y: place.y,
2583
+ width: place.width,
2584
+ height: place.height,
2585
+ // No trim: `offsets` states the drawing's full size and a zero inset,
2586
+ // which is what makes a region's own width/height the attachment's.
2587
+ offsetX: 0,
2588
+ offsetY: 0,
2589
+ originalWidth: place.width,
2590
+ originalHeight: place.height,
2591
+ })),
2592
+ });
2593
+ });
2594
+
2595
+ return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape, candidate: chosen.name };
2596
+ }
2597
+
2598
+ // ---------------------------------------------------------------------------
2599
+ // reading one region back out of a page
2600
+ // ---------------------------------------------------------------------------
2601
+
2602
+ /**
2603
+ * One region's drawing, lifted off its page.
2604
+ *
2605
+ * Two callers, and they are the reason this is a function rather than two loops:
2606
+ * the compiler needs a part's own pixel grid when a generator measures the art
2607
+ * (a contour mesh traces its alpha), and the selftest needs it to assert that a
2608
+ * packed region is a LOSSLESS copy of the loose PNG it came from. One extractor
2609
+ * means the proof and the use cannot drift.
2610
+ *
2611
+ * The result is `originalWidth x originalHeight` — the untrimmed drawing — with
2612
+ * the kept rectangle placed at its trim offset and the rest left transparent.
2613
+ * `offsetY` is measured from the drawing's BOTTOM (the format's convention) and
2614
+ * a plate's rows run downwards, so the kept rectangle's top row is
2615
+ * `originalHeight - offsetY - height`.
2616
+ *
2617
+ * ## A rotated region is MEASURED, not guessed at (issue #570)
2618
+ *
2619
+ * This refused a rotated region until 2026-09-17, on the argument that the
2620
+ * runtime holds "three opinions" about the mapping. Measurement refutes the
2621
+ * argument: the three are not three readings of one mapping, they are one
2622
+ * mapping and two places that do not implement it.
2623
+ *
2624
+ * * `MeshAttachment.computeUVs` (spine-core 4.3.13,
2625
+ * `dist/attachments/MeshAttachment.js:126-162`) is the one routine that
2626
+ * states where a region's texels are for **all four** `degrees`, and it is
2627
+ * the routine `substituteTexture` in [`src/render.ts`](render.ts) already
2628
+ * goes through. The loop below inverts what it samples: `PKR02` asks it,
2629
+ * for every texel of every region of the corpus atlases, which page texel
2630
+ * the runtime samples and compares the lift — 132 regions, 2,848,402
2631
+ * texels, 0 apart, three of them turned (90 twice, 270 once) and each of
2632
+ * those lifting differently with the turn ignored; `PK29` lifts the
2633
+ * packer's fixture at each of 0, 90, 180 and 270 back to its PNG byte for
2634
+ * byte;
2635
+ * * `TextureAtlas`'s `u2`/`v2` (`dist/TextureAtlas.js:164-171`) transpose the
2636
+ * rectangle at 90 and not at 270, so at 270 they describe a rectangle the
2637
+ * page does not have — but `MeshAttachment.computeUVs` never reads them for
2638
+ * an atlas region, and neither does this;
2639
+ * * `RegionAttachment.computeUVs` (`dist/attachments/RegionAttachment.js:156-167`)
2640
+ * assigns the turned corner order at 90 and at nothing else, which is a
2641
+ * region-attachment rendering defect (issue #199) and not a statement about
2642
+ * where the drawing sits.
2643
+ *
2644
+ * On texel centres — at 90 and 270 measured against the runtime's sampling
2645
+ * (`PKR02`), at 180 against the packer fixture's turn (`PK29`) — the mapping
2646
+ * puts kept-rectangle pixel `(x, y)` — `x` from the drawing's left, `y` down from `top` — at page
2647
+ * pixel `(X + x, Y + y)` unturned, `(X + y, Y + width - 1 - x)` at 90,
2648
+ * `(X + width - 1 - x, Y + height - 1 - y)` at 180 and
2649
+ * `(X + height - 1 - y, Y + x)` at 270, writing `X`/`Y` for the region's own
2650
+ * `x`/`y`; the packed footprint is `height x width` for the two quarter turns
2651
+ * and `width x height` for the other two. `repackRotatedTrimmed` in
2652
+ * `selftest.ts` derived the same 270 mapping for issue #199's fixture, and had
2653
+ * been shipping it green, while this comment claimed the mapping was unknowable.
2654
+ *
2655
+ * ⚠️ Any other `degrees` takes the unturned branch, because that is what the
2656
+ * runtime does with it: `regionFields.rotate` (`dist/TextureAtlas.js:87-93`)
2657
+ * `parseInt`s the value without checking it, and `computeUVs` falls to
2658
+ * `default:` for everything that is not 90, 180 or 270. Reading such a region
2659
+ * unturned is not a guess, it is agreement with the thing that will draw it.
2660
+ *
2661
+ * rigc's own packer still never rotates (`PACK_NO_ROTATE`), so only a foreign
2662
+ * atlas reaches any branch but the first.
2663
+ */
2664
+ export function extractRegion(page: Plate, region: AtlasRegion): Plate {
2665
+ const out = new Plate(region.originalWidth, region.originalHeight);
2666
+ const top = region.originalHeight - region.offsetY - region.height;
2667
+ const { degrees } = region;
2668
+ for (let y = 0; y < region.height; y++) {
2669
+ for (let x = 0; x < region.width; x++) {
2670
+ const px =
2671
+ degrees === 90
2672
+ ? region.x + y
2673
+ : degrees === 180
2674
+ ? region.x + region.width - 1 - x
2675
+ : degrees === 270
2676
+ ? region.x + region.height - 1 - y
2677
+ : region.x + x;
2678
+ const py =
2679
+ degrees === 90
2680
+ ? region.y + region.width - 1 - x
2681
+ : degrees === 180
2682
+ ? region.y + region.height - 1 - y
2683
+ : degrees === 270
2684
+ ? region.y + x
2685
+ : region.y + y;
2686
+ out.set(region.offsetX + x, top + y, page.get(px, py));
2687
+ }
2688
+ }
2689
+ return out;
2690
+ }
2691
+
2692
+ // ---------------------------------------------------------------------------
2693
+ // a page against the file it names
2694
+ // ---------------------------------------------------------------------------
2695
+
2696
+ /**
2697
+ * The one page rectangle every region on a page shares: the size the atlas
2698
+ * declares for it against the size of the file it names.
2699
+ *
2700
+ * ⭐ **What a size that disagrees with the file is and is not**, measured rather
2701
+ * than assumed (issue #715). `TextureAtlas` computes every region's UVs as a
2702
+ * fraction of the DECLARED size — `region.u = region.x / page.width`, spine-core
2703
+ * 4.3.13 `dist/TextureAtlas.js:162-171` — `MeshAttachment.computeUVs` takes its
2704
+ * `textureWidth` from the same field (`dist/attachments/MeshAttachment.js:125`),
2705
+ * `RegionAttachment.computeUVs` reads nothing but `u/v/u2/v2`
2706
+ * (`dist/attachments/RegionAttachment.js:152-167`), and `TextureAtlasPage.setTexture`
2707
+ * never writes `width`/`height`. So **nothing in the region mapping reads the
2708
+ * texture's own size**, and a page whose PNG is the declared page RESCALED is
2709
+ * addressed at the same fraction of the picture whatever size the file is:
2710
+ * measured on a coordinate-ramp page where every texel names its own position,
2711
+ * rigc's own rasteriser drew 116,480 pixels in both and 0 in exactly one at a
2712
+ * uniform 0.5.
2713
+ *
2714
+ * ⇒ That is why this is a clause about **texel** readers rather than about
2715
+ * drawing, and why it is nevertheless not renderer policy. Three readers address
2716
+ * the page at the coordinates the atlas states, and two of them are rigc's own:
2717
+ * `A19`'s alpha scan in [`src/validate.ts`](validate.ts), the region lift
2718
+ * `partPlate` traces a mesh generator over in [`src/compile.ts`](compile.ts), and `spine-html`'s region tier, which
2719
+ * cuts each part with `drawImage(image, x, y, w, h, …)` and says in its own
2720
+ * comment that it tests against the image rather than the `size:` line. Measured
2721
+ * on the same page: the lift returned 768 of 768 texels from somewhere else.
2722
+ *
2723
+ * 🔑 And the format already states coarser texels honestly — `scale:`, which
2724
+ * rigc reads (`AtlasPage.scale`) and `--atlas-in` divides by. The same art
2725
+ * declared that way builds green and renders identically, so this refusal names
2726
+ * a repair the format provides rather than one rigc invented.
2727
+ */
2728
+ export interface PageGridReading {
2729
+ /** file width / declared width, and the same for height. Both 1 when they agree. */
2730
+ readonly x: number;
2731
+ readonly y: number;
2732
+ /** One ratio for both axes — the only relation a `scale:` line can state. */
2733
+ readonly uniform: boolean;
2734
+ }
2735
+
2736
+ /** `null` when the page declares no positive size for the ratios to divide by. */
2737
+ export function pageGridReading(
2738
+ declared: { width: number; height: number },
2739
+ file: { width: number; height: number },
2740
+ ): PageGridReading | null {
2741
+ if (declared.width <= 0 || declared.height <= 0) return null;
2742
+ return {
2743
+ x: file.width / declared.width,
2744
+ y: file.height / declared.height,
2745
+ // Cross-multiplied rather than compared as two divisions: the question is
2746
+ // whether one rational number describes both axes, and two floats that
2747
+ // round to the same digits are not that.
2748
+ uniform: file.width * declared.height === file.height * declared.width,
2749
+ };
2750
+ }
2751
+
2752
+ /** A ratio, printed the one way every message here prints one. */
2753
+ function gridRatio(n: number): string {
2754
+ return n.toFixed(4);
2755
+ }
2756
+
2757
+ /**
2758
+ * The first number on this page that a `scale:` re-declaration could not carry,
2759
+ * or `null` when every one of them lands on a whole texel of the file.
2760
+ *
2761
+ * Every number in a region block is in the page's own texel units — `bounds` and
2762
+ * `offsets` alike — so re-declaring the page at the file's size means scaling all
2763
+ * eight by the same ratio, and a region that then lands between texels is one no
2764
+ * reader could cut out. Stated as integer arithmetic (`n * file % declared`)
2765
+ * rather than as a float test, so the answer does not depend on how the ratio
2766
+ * rounded. ⚠️ One ratio for both axes, so this is the UNIFORM case's question
2767
+ * and is only ever asked there — a page whose two axes differ has no `scale:`
2768
+ * line to be re-declared with at all.
2769
+ */
2770
+ function firstNumberOffTheCoarseGrid(
2771
+ regions: ReadonlyArray<{
2772
+ name: string;
2773
+ x: number;
2774
+ y: number;
2775
+ width: number;
2776
+ height: number;
2777
+ offsetX: number;
2778
+ offsetY: number;
2779
+ originalWidth: number;
2780
+ originalHeight: number;
2781
+ }>,
2782
+ declared: number,
2783
+ file: number,
2784
+ ): string | null {
2785
+ for (const region of regions) {
2786
+ const numbers: Array<[string, number]> = [
2787
+ ['bounds x', region.x],
2788
+ ['bounds y', region.y],
2789
+ ['bounds width', region.width],
2790
+ ['bounds height', region.height],
2791
+ ['offsets offsetX', region.offsetX],
2792
+ ['offsets offsetY', region.offsetY],
2793
+ ['offsets originalWidth', region.originalWidth],
2794
+ ['offsets originalHeight', region.originalHeight],
2795
+ ];
2796
+ for (const [field, value] of numbers) {
2797
+ if ((value * file) % declared === 0) continue;
2798
+ return (
2799
+ `region ${JSON.stringify(region.name.trim())}'s \`${field}\` of ${value} becomes ` +
2800
+ `${((value * file) / declared).toFixed(4)} on the file's own grid, which is not a whole texel`
2801
+ );
2802
+ }
2803
+ }
2804
+ return null;
2805
+ }
2806
+
2807
+ /** What every size-mismatch sentence says before it says what to do about it. */
2808
+ const PAGE_GRID_PREAMBLE =
2809
+ 'A runtime does not read the file\'s own size anywhere in the region mapping — `TextureAtlas` computes every ' +
2810
+ "region's UVs as a fraction of the DECLARED size (`region.u = region.x / page.width`, spine-core " +
2811
+ '`dist/TextureAtlas.js:162-171`) — so a page whose PNG is the declared page RESCALED draws the same picture at ' +
2812
+ "the file's resolution, and one whose PNG is anything else draws whatever sits at those fractions. What a " +
2813
+ 'declared size that disagrees with the file breaks is every reader that addresses the page in TEXELS: this ' +
2814
+ "validator's own `A19` alpha scan, the region lift rigc's mesh generators trace, and a canvas renderer that " +
2815
+ 'cuts each part out of the page by source rectangle.';
2816
+
2817
+ /**
2818
+ * What a page that is not its declared size is, stated as the page, both sizes
2819
+ * and the two ratios — the clause every message about it opens with.
2820
+ *
2821
+ * `A06`'s refusal starts with it, and so does every line in which a reader that
2822
+ * addresses the page in texels declines to (issue #750): `explain` and `build`
2823
+ * print it where they withhold a mesh's fit, and the contour generator's refusal
2824
+ * carries the whole of `pageGridSentence` below. One derivation, so a page is
2825
+ * never described two ways by the two halves of one run.
2826
+ */
2827
+ export function pageGridSaid(
2828
+ page: { name: string; width: number; height: number },
2829
+ file: { width: number; height: number },
2830
+ ): string {
2831
+ const said = `page "${page.name}" declares ${page.width}x${page.height} and its PNG is ${file.width}x${file.height}`;
2832
+ const grid = pageGridReading(page, file);
2833
+ return grid === null
2834
+ ? said
2835
+ : `${said} — ${gridRatio(grid.x)} of the declared width and ${gridRatio(grid.y)} of the declared height`;
2836
+ }
2837
+
2838
+ /**
2839
+ * `A06`'s whole sentence for a page whose file is not its declared size, or
2840
+ * `null` when the two agree: the page and its ratios (`pageGridSaid`), why a
2841
+ * runtime still draws it and which readers it breaks, and the repair the format
2842
+ * offers — or why it offers none.
2843
+ *
2844
+ * It lives here rather than in [`src/validate.ts`](validate.ts) because the
2845
+ * compiler states it too, and `src/compile.ts` must not link the runtime that
2846
+ * file links. `regions` is the page's own regions; only the uniform case reads
2847
+ * them, to find a number a `scale:` re-declaration could not carry.
2848
+ */
2849
+ export function pageGridSentence(
2850
+ page: { name: string; width: number; height: number },
2851
+ file: { width: number; height: number },
2852
+ regions: Parameters<typeof firstNumberOffTheCoarseGrid>[0],
2853
+ ): string | null {
2854
+ if (page.width === file.width && page.height === file.height) return null;
2855
+ const said = pageGridSaid(page, file);
2856
+ const grid = pageGridReading(page, file);
2857
+ if (grid === null) return `${said}. ${PAGE_GRID_PREAMBLE}`;
2858
+ const off = grid.uniform ? firstNumberOffTheCoarseGrid(regions, page.width, file.width) : null;
2859
+ const repair = !grid.uniform
2860
+ ? 'The `scale:` header states one ratio for both axes, so a page whose axes differ cannot be ' +
2861
+ `declared honestly at all: re-export the page at ${page.width}x${page.height}, or repack it.`
2862
+ : off !== null
2863
+ ? 'The format states coarser texels with the `scale:` header, but this page cannot be re-declared ' +
2864
+ `that way: ${off}. Re-export the page at ${page.width}x${page.height}, or repack it.`
2865
+ : 'The format states coarser texels with the `scale:` header and rigc builds that: declare ' +
2866
+ `\`size: ${file.width}, ${file.height}\` with \`scale: ${gridRatio(grid.x)}\` and multiply every ` +
2867
+ `\`bounds\`/\`offsets\` on this page by ${gridRatio(grid.x)}, and every part keeps the size it ` +
2868
+ 'has now.';
2869
+ return `${said}. ${PAGE_GRID_PREAMBLE} ${repair}`;
2870
+ }