spine-rigc 2.1.0 → 2.1.2

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.
package/README.md CHANGED
@@ -698,7 +698,8 @@ what it found — the executable, the bundle, the `CFBundleName` that bundle
698
698
  declares, or the banner the binary prints about itself — rather than starting it
699
699
  and failing downstream. Both refusals point at `--exported <file>`, which
700
700
  measures an export the editor already made and is the half of this tool that
701
- needs no editor at all.
701
+ needs no editor at all. A build whose `skeleton.json` is missing, is not JSON, or
702
+ holds JSON that is not one object is refused by name before the editor starts.
702
703
 
703
704
  ⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
704
705
  editor, so no automated check runs it.
package/docs/AUTHORING.md CHANGED
@@ -145,6 +145,10 @@ thing: with `@esotericsoftware/spine-core` installed beside the package, `rigc`
145
145
  `bun cli.ts` and runs every command; without it, `rigc` runs `cli_core.ts` — the
146
146
  entry that links none of spine-core, `bun cli_core.ts` in a clone — which refuses by
147
147
  name the commands that need the runtime (its `--help` lists the ones it runs).
148
+ `validate` is one of them, and pointed at a rigc build — a directory with
149
+ `skeleton.model.json` beside the pair — its refusal says that build already ran the
150
+ gate before it wrote, and which rules that gate is on this entry, before the install
151
+ route; on a Spine export it says what it always said (`RC36`).
148
152
  `rigc --version` names the entry that ran.
149
153
 
150
154
  That entry's `build` takes the same flags and writes the same files, byte for byte:
@@ -517,17 +521,39 @@ what changes is that two rectangles may overlap where neither footprint is.
517
521
  touch.
518
522
  - **The placement** is the same MaxRects search, ordering and tie-breaks, with
519
523
  the free list split by what each region owns instead of by its cell, and a
520
- candidate is the free rectangle that holds what the cell owns. Every pass is
521
- also run as the `rect` pass on the same page and the better kept — more parts
522
- placed, then the higher bottom edge, then `rect` on a tie — so a `polygon`
523
- page is never larger than the `rect` page. `--page-edges` and the spill rule
524
- are unchanged, except that the `free` search's area bound sums the texels
525
- the cells own rather than the cells, since overlapping cells can cover more
526
- than the page (a spilled `polygon` page was refused as fitting no page until
527
- it did — `PK86`). What it costs in time is measured on a set shaped like a
528
- production rig (30 regions of 200 to 900 px, hulls near their rectangles,
529
- one `free` page about 2023x2046): 1.1–1.4 s against `rect`'s 0.2 s for the
530
- pack alone, the same page.
524
+ candidate is a free rectangle that holds the box of what the cell owns. Every
525
+ pass is also run as the `rect` pass on the same page and the better kept —
526
+ more parts placed, then the higher bottom edge, then `rect` on a tie.
527
+ `--page-edges` and the spill rule are unchanged, except that the `free`
528
+ search's area bound sums the texels the cells own rather than the cells,
529
+ since overlapping cells can cover more than the page (a spilled `polygon`
530
+ page was refused as fitting no page until it did — `PK86`).
531
+ - **Where a cell may go in a free rectangle** (issue #1104). A candidate puts
532
+ the box of what the cell owns at the free rectangle's top-left corner, the
533
+ cell reaching up and left of it — so a cell whose drawn part is inset from
534
+ its corner (a large region whose mesh draws a small part of it) could never
535
+ go on an empty page, and every pass lost to the `rect` pass. A second
536
+ candidate puts the cell's own corner there instead, wherever the first would
537
+ leave the page. Both keep the cell on the page; the atlas format does not
538
+ change.
539
+ - **The pack written is the least total page area of three whole packs** —
540
+ the `rect` pack, the footprint pack with the first anchor alone, and with
541
+ both — summed over every page, a spill's included, the earlier on a tie. So a
542
+ `polygon` pack is never larger in page area than the `rect` pack on any set,
543
+ by construction (before #1104 a spill could make it so: a generated set under
544
+ `pot` wrote 8,388,608 texels against `rect`'s 6,291,456). No single placement
545
+ rule gives that: the second anchor alone packs one test set 7.2 % larger than
546
+ the first. On a production rig's region set (31 meshes, a two-page `free`
547
+ spill under `rect`) it takes 4,367,445 texels to 2,585,264 (−40.8 %); on a
548
+ generated set of that shape (`fixtures/polypack_shapes.ts`, seed 1107)
549
+ 4,433,292 over two pages to 2,037,204 on one; under `pot` both go to one
550
+ 2048x2048 page. **What it costs**, for the pack alone (`--page-edges free`,
551
+ padding 2, page size 2048): the three packs' searches, none shared — on seed
552
+ 1107 1.09 s against `rect`'s 0.06 s, on seed 1105 1.29 s against 0.07 s,
553
+ under `pot` 0.17–0.21 s; counted, 13.6 times `rect`'s free-list splits on seed
554
+ 1107, which `PK93` holds within 25 times. The cost is the placing the
555
+ second anchor makes possible: before #1102 the same search threw its passes
556
+ away and took 260–304 s on the production set.
531
557
  - **The pixels.** Every cell is drawn whole in packing order, then every
532
558
  region's owned texels are drawn again with its own values, so every texel a
533
559
  region can sample is its own (`PK79`). A texel nobody owns carries the last
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/atlas.ts CHANGED
@@ -713,6 +713,44 @@ export const PACK_SHAPES = ['rect', 'polygon'] as const;
713
713
  export type PackShape = (typeof PACK_SHAPES)[number];
714
714
  export const DEFAULT_PACK_SHAPE: PackShape = 'rect';
715
715
 
716
+ /**
717
+ * Where a `polygon` candidate may put a cell against the free rectangle it is
718
+ * placed in (issue #1104) — `packOnePageByFootprint`, *Candidates*.
719
+ *
720
+ * `box` puts the cell's owned box on the free rectangle's corner — the only
721
+ * rule before #1104. The other three may also put the cell's own corner there
722
+ * (`packOnePageByFootprint`, *Candidates*):
723
+ *
724
+ * * `box-or-cell` — on every free rectangle;
725
+ * * `box-else-cell` — only on a free rectangle where the owned box's anchor
726
+ * would put the cell off the page;
727
+ * * `best-of` — the footprint pass run with `box` and with `box-or-cell` on
728
+ * the same page and the better kept (more cells, then the higher bottom
729
+ * edge, then `box`), as `placePass` keeps the better of `rect` and a
730
+ * footprint pass.
731
+ *
732
+ * No single rule is never-larger than `box` — `box-or-cell` packs `PK78`'s
733
+ * wedge set 7.2 % larger — so `polygon` does not pick one: it packs the whole
734
+ * set as `rect`, under `box` and under `box-else-cell` and keeps the least Σ
735
+ * page area (`POLYGON_CANDIDATE_RULES`, `packAtlas`). `box-else-cell` is the
736
+ * third candidate on measurement: over 96 packs (seeds 1100–1124, the tree's
737
+ * 21 sets, the wedge and spill sets, `pot` and `free`) the choice with it sums
738
+ * to 212,607,001 texels, with `best-of` 214,407,755 and with `box-or-cell` the
739
+ * same. `best-of` is never worse than `box` on one page, but against
740
+ * `box-else-cell` it gives the smaller pack on 12 of the 27 sets where the two
741
+ * differ and the larger on 15 — neither dominates, and the sum decides.
742
+ *
743
+ * The CLI never sets one rule; `PackOptions.footprintAnchors` is the instrument's and the plant's.
744
+ */
745
+ export const FOOTPRINT_ANCHORS = ['box', 'box-or-cell', 'box-else-cell', 'best-of'] as const;
746
+ export type FootprintAnchors = (typeof FOOTPRINT_ANCHORS)[number];
747
+ /** The rule a footprint search uses when it is given none — `freePageSearch`'s and `footprintPass`'s callers outside `packAtlas`. */
748
+ export const DEFAULT_FOOTPRINT_ANCHORS: FootprintAnchors = 'box';
749
+ /** The footprint rules `polygon` packs under besides `rect`, in the order a tie goes to (`packAtlas`). */
750
+ export const POLYGON_CANDIDATE_RULES: readonly FootprintAnchors[] = ['box', 'box-else-cell'];
751
+ /** Which whole pack a `polygon` pack kept: `rect`'s, or one footprint rule's. */
752
+ export type PackCandidate = 'rect' | FootprintAnchors;
753
+
716
754
  /**
717
755
  * The part of a region its attachments draw, in the region's own texels — x
718
756
  * right and y down from the drawing's top-left corner, so `(u · width, v ·
@@ -750,6 +788,21 @@ export interface PackOptions {
750
788
  pageEdges?: PageEdges;
751
789
  /** What two rectangles may share (default `rect`). See `PACK_SHAPES`. */
752
790
  shape?: PackShape;
791
+ /**
792
+ * One footprint rule alone instead of `polygon`'s choice between whole packs
793
+ * (`FOOTPRINT_ANCHORS`) — for `tools/pack_anchor.ts` and the selftest's plant
794
+ * (`PK97`); the CLI never sets it. Absent, `polygon` keeps the least Σ page
795
+ * area of `rect` and `POLYGON_CANDIDATE_RULES`.
796
+ */
797
+ footprintAnchors?: FootprintAnchors;
798
+ /** Where to count what every search the pack runs placed (`PassTally`) — the controls' and the instrument's cost reading. */
799
+ tally?: PassTally;
800
+ /**
801
+ * `false` runs every footprint pass of every search to its end — issue
802
+ * #1102's early stop off, for the selftest's plant (`PK95`) only. The pages are
803
+ * the same either way (`PK94`); only the cost differs.
804
+ */
805
+ stopAtMiss?: boolean;
753
806
  }
754
807
 
755
808
  /** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
@@ -782,6 +835,8 @@ export interface PackResult {
782
835
  padding: number;
783
836
  /** The shape the pack was made under — what the `pack:` line's last field states. */
784
837
  shape: PackShape;
838
+ /** The whole pack that was kept: `rect` under `shape: 'rect'`, and under `polygon` the candidate of least Σ page area (`packAtlas`). */
839
+ candidate: PackCandidate;
785
840
  }
786
841
 
787
842
  /** A free rectangle in the MaxRects free list. */
@@ -826,15 +881,18 @@ function smallestPageFor(
826
881
  maxEdge: number,
827
882
  pageEdges: PageEdges,
828
883
  shapes?: readonly CellShape[],
884
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
885
+ tally?: PassTally,
886
+ stopAtMiss = true,
829
887
  ): { width: number; height: number; rects: Rect[] } | null {
830
- if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes);
888
+ if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss);
831
889
  const edges: number[] = [];
832
890
  for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
833
891
  const candidates: Array<{ w: number; h: number }> = [];
834
892
  for (const w of edges) for (const h of edges) candidates.push({ w, h });
835
893
  candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
836
894
  for (const candidate of candidates) {
837
- const attempt = placePass(cells, shapes, candidate.w, candidate.h);
895
+ const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally });
838
896
  if (attempt.some((r) => r === null)) continue;
839
897
  return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
840
898
  }
@@ -886,8 +944,13 @@ function smallestFreePageFor(
886
944
  cells: Array<{ w: number; h: number }>,
887
945
  maxEdge: number,
888
946
  shapes?: readonly CellShape[],
947
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
948
+ tally?: PassTally,
949
+ stopAtMiss = true,
889
950
  ): { width: number; height: number; rects: Rect[] } | null {
890
- return freePageSearch(cells, maxEdge, shapes).page;
951
+ const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss });
952
+ if (tally !== undefined) for (const key of Object.keys(tally) as Array<keyof PassTally>) tally[key] += search.tally[key];
953
+ return search.page;
891
954
  }
892
955
 
893
956
  /** What one `free` page search did: the page it chose, and what each width cost. */
@@ -901,6 +964,23 @@ export interface FreePageSearch {
901
964
  abandoned: number;
902
965
  /** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
903
966
  skipped: number;
967
+ /** What the passes placed, by kind (`PassTally`). */
968
+ tally: PassTally;
969
+ }
970
+
971
+ /**
972
+ * How `freePageSearch` runs its passes — for the selftest's plants only
973
+ * (`PK94`, `PK95`). Each turns one of issue #1102's savings off: `stopAtMiss:
974
+ * false` runs every footprint pass to its end, `edgeIndex: false` prunes the
975
+ * free list by scanning all of it. The page is the same either way; only what
976
+ * it costs differs, and the plants hold that.
977
+ */
978
+ export interface FreePageSearchOptions {
979
+ stopAtMiss?: boolean;
980
+ /** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
981
+ edgeIndex?: boolean;
982
+ /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`) — `tools/pack_anchor.ts`'s candidate rules; default `box`. */
983
+ anchors?: FootprintAnchors;
904
984
  }
905
985
 
906
986
  /**
@@ -910,7 +990,13 @@ export interface FreePageSearch {
910
990
  * packer uses; the counts are for the control. `shapes`, one per cell, is
911
991
  * `shape: 'polygon'`'s placement (`placePass`); absent, the rectangle pass.
912
992
  */
913
- export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number, shapes?: readonly CellShape[]): FreePageSearch {
993
+ export function freePageSearch(
994
+ cells: Array<{ w: number; h: number }>,
995
+ maxEdge: number,
996
+ shapes?: readonly CellShape[],
997
+ options: FreePageSearchOptions = {},
998
+ ): FreePageSearch {
999
+ const wholeOrNothing = options.stopAtMiss ?? true;
914
1000
  let widest = 0;
915
1001
  let tallest = 0;
916
1002
  let cellArea = 0;
@@ -927,7 +1013,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
927
1013
  cellArea = 0;
928
1014
  for (const shape of shapes) cellArea += shape.owned;
929
1015
  }
930
- const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
1016
+ const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: emptyTally() };
931
1017
  let best: { width: number; height: number; rects: Rect[] } | null = null;
932
1018
  for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
933
1019
  out.widths++;
@@ -937,7 +1023,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
937
1023
  continue;
938
1024
  }
939
1025
  const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
940
- const attempt = placePass(cells, shapes, width, maxEdge, maxBottom);
1026
+ const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors });
941
1027
  let height = 0;
942
1028
  for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
943
1029
  if (height > maxBottom) {
@@ -1094,9 +1180,46 @@ function placePass(
1094
1180
  pageW: number,
1095
1181
  pageH: number,
1096
1182
  maxBottom = Infinity,
1183
+ pass: PassOptions = {},
1097
1184
  ): Array<Rect | null> {
1098
- const byRect = packOnePage(cells, pageW, pageH, maxBottom);
1099
- if (shapes === undefined || shapes.every((s) => s.whole)) return byRect;
1185
+ const wholeOrNothing = pass.wholeOrNothing ?? false;
1186
+ const tally = pass.tally;
1187
+ if (shapes === undefined || shapes.every((s) => s.whole)) {
1188
+ const byRect = packOnePage(cells, pageW, pageH, maxBottom);
1189
+ if (tally !== undefined) tally.rectPlaced += placedCount(byRect);
1190
+ return byRect;
1191
+ }
1192
+ // ⭐ `wholeOrNothing` is the page searches' question (issue #1102): they keep
1193
+ // a pass only when it placed every cell, so a pass that has missed one is
1194
+ // already discarded, whatever it does next. Two savings follow, and neither
1195
+ // can change a page the search keeps:
1196
+ //
1197
+ // * the rectangle pass is not run when the cells' own area exceeds the
1198
+ // page — disjoint rectangles cannot all fit, so it could not place every
1199
+ // cell, and the pass it would have lost or won against is discarded either
1200
+ // way (a footprint pass that placed every cell beats a rectangle pass that
1201
+ // did not, on the first key);
1202
+ // * the footprint pass stops at its first miss (`packOnePageByFootprint`'s
1203
+ // `stopAtMiss`), returning the cells after it as not placed.
1204
+ //
1205
+ // What is returned can then differ from the full answer only in which of two
1206
+ // failed passes it is, or in how many cells a failed pass placed — and the
1207
+ // searches read neither. The spill's assignment pass, which keeps the cells
1208
+ // a pass did place, never asks this (`packAtlas`).
1209
+ //
1210
+ // Measured on a replica of a production rig (31 meshes, a two-page `free`
1211
+ // spill at 2048, padding 2), where the pack took 260–304 s against `rect`'s
1212
+ // 0.14–0.24 s: the single-page search ran a full footprint pass at every one of its
1213
+ // 854 widths (54 % of the time), because nothing fits and so no best page
1214
+ // ever bounds a width, and the first page's own search ran one at every width
1215
+ // it did not abandon (46 %). The largest cell's owned box does not start at
1216
+ // its cell's corner, so on an empty page that cell is every footprint pass's
1217
+ // first miss — all 1,709 of them — and every one of those passes was thrown
1218
+ // 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
1219
+ // same pages.
1220
+ const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
1221
+ const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
1222
+ if (tally !== undefined && byRect !== null) tally.rectPlaced += placedCount(byRect);
1100
1223
  // ⭐ `polygon` never costs a page anything `rect` would have saved. Both passes
1101
1224
  // are run on the same page and the better one is kept: more cells placed,
1102
1225
  // then the higher bottom edge, then — on a tie — the rectangle pass. Two
@@ -1107,15 +1230,86 @@ function placePass(
1107
1230
  // than `rect` on two of the five gallery rigs that carry a mesh, because a
1108
1231
  // greedy pass that places one cell differently places every later one
1109
1232
  // differently too.
1110
- const byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom);
1111
- const placedOf = (pass: Array<Rect | null>): number => pass.reduce((n, r) => (r === null ? n : n + 1), 0);
1112
- const bottomOf = (pass: Array<Rect | null>): number => pass.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1113
- const placedRect = placedOf(byRect);
1114
- const placedFoot = placedOf(byFootprint);
1233
+ const anchors = pass.anchors ?? DEFAULT_FOOTPRINT_ANCHORS;
1234
+ let byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, anchors === 'best-of' ? 'box' : anchors);
1235
+ if (anchors === 'best-of') {
1236
+ // Issue #1104's fourth candidate: both searches on the same page, the
1237
+ // better kept and the first anchor's on a tie — so a pass is never worse
1238
+ // than the first anchor's on that page.
1239
+ if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1240
+ const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell');
1241
+ const bottom = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1242
+ const placedBox = placedCount(byFootprint);
1243
+ const placedTwo = placedCount(byTwo);
1244
+ if (placedTwo > placedBox || (placedTwo === placedBox && bottom(byTwo) < bottom(byFootprint))) byFootprint = byTwo;
1245
+ if (tally !== undefined) tally.footprintPlaced += placedTwo;
1246
+ } else if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1247
+ if (byRect === null) return byFootprint;
1248
+ const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1249
+ const placedRect = placedCount(byRect);
1250
+ const placedFoot = placedCount(byFootprint);
1115
1251
  if (placedFoot !== placedRect) return placedFoot > placedRect ? byFootprint : byRect;
1116
1252
  return bottomOf(byFootprint) < bottomOf(byRect) ? byFootprint : byRect;
1117
1253
  }
1118
1254
 
1255
+ /** How `placePass` runs — what the page searches ask of it (issue #1102). */
1256
+ interface PassOptions {
1257
+ /** Only a pass that places every cell is kept by the caller. See `placePass`. */
1258
+ wholeOrNothing?: boolean;
1259
+ /** Where to count what the passes placed. */
1260
+ tally?: PassTally;
1261
+ /** `splitFree`'s edge index (default on); off only for the selftest's plant. */
1262
+ edgeIndex?: boolean;
1263
+ /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
1264
+ anchors?: FootprintAnchors;
1265
+ }
1266
+
1267
+ /** How many cells a pass placed. */
1268
+ function placedCount(pass: ReadonlyArray<Rect | null>): number {
1269
+ let n = 0;
1270
+ for (const r of pass) if (r !== null) n++;
1271
+ return n;
1272
+ }
1273
+
1274
+ /** The cells' own area — what disjoint rectangles need of a page at least. */
1275
+ function cellAreaOf(cells: ReadonlyArray<{ w: number; h: number }>): number {
1276
+ let area = 0;
1277
+ for (const cell of cells) area += cell.w * cell.h;
1278
+ return area;
1279
+ }
1280
+
1281
+ /**
1282
+ * What a page search's passes placed, by kind — the operation count a search's
1283
+ * cost is held by (`PK93`), since a footprint placement splits the free list
1284
+ * once per band of what the cell owns and a rectangle placement once.
1285
+ */
1286
+ export interface PassTally {
1287
+ /** Cells placed by rectangle passes (`packOnePage`). */
1288
+ rectPlaced: number;
1289
+ /** Cells placed by footprint passes (`packOnePageByFootprint`). */
1290
+ footprintPlaced: number;
1291
+ /** Free-list splits the footprint passes made — one per band of every cell they placed (`splitFree`). */
1292
+ bandSplits: number;
1293
+ /** Containment tests the footprint passes' prune made (`splitFree`). */
1294
+ containmentTests: number;
1295
+ /**
1296
+ * Free rectangles that held a cell's owned box and were refused as its
1297
+ * first-anchor candidate only because the cell, anchored there, would leave
1298
+ * the page — at negative x or y, or past the right or bottom edge (issue
1299
+ * #1104, `tools/pack_anchor.ts`).
1300
+ */
1301
+ boxAnchorRefused: number;
1302
+ /** Cells a footprint pass missed although some free rectangle held their owned box. */
1303
+ missedByAnchor: number;
1304
+ /** 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`). */
1305
+ secondAnchorPlaced: number;
1306
+ }
1307
+
1308
+ /** A tally with nothing counted. */
1309
+ export function emptyTally(): PassTally {
1310
+ return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
1311
+ }
1312
+
1119
1313
  // ---------------------------------------------------------------------------
1120
1314
  // footprints — `shape: 'polygon'` (issue #1099)
1121
1315
  // ---------------------------------------------------------------------------
@@ -1377,44 +1571,100 @@ function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: numb
1377
1571
  * px, hulls near their rectangles, a `free` page about 2023x2046) the pack
1378
1572
  * took 18.5 s with the slivers kept and spent 95 % of it in this prune.
1379
1573
  */
1380
- function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
1574
+ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1, edgeIndex = true, tally?: PassTally): void {
1381
1575
  const next: Rect[] = [];
1382
- const untouched: boolean[] = [];
1576
+ /** Per entry of `next`: `UNTOUCHED`, or which side of `put` the piece was cut from. */
1577
+ const side: number[] = [];
1383
1578
  for (const fr of free) {
1384
1579
  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;
1385
1580
  if (!overlaps) {
1386
1581
  next.push(fr);
1387
- untouched.push(true);
1582
+ side.push(UNTOUCHED);
1388
1583
  continue;
1389
1584
  }
1390
- if (put.x > fr.x) next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
1585
+ if (put.x > fr.x) {
1586
+ next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
1587
+ side.push(LEFT_OF_PUT);
1588
+ }
1391
1589
  if (put.x + put.w < fr.x + fr.w) {
1392
1590
  next.push({ x: put.x + put.w, y: fr.y, w: fr.x + fr.w - (put.x + put.w), h: fr.h });
1591
+ side.push(RIGHT_OF_PUT);
1592
+ }
1593
+ if (put.y > fr.y) {
1594
+ next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
1595
+ side.push(ABOVE_PUT);
1393
1596
  }
1394
- if (put.y > fr.y) next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
1395
1597
  if (put.y + put.h < fr.y + fr.h) {
1396
1598
  next.push({ x: fr.x, y: put.y + put.h, w: fr.w, h: fr.y + fr.h - (put.y + put.h) });
1599
+ side.push(BELOW_PUT);
1397
1600
  }
1398
- while (untouched.length < next.length) untouched.push(false);
1399
1601
  }
1400
1602
  const contains = (a: Rect, b: Rect): boolean => b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
1401
1603
  const usable = (r: Rect): boolean => r.w >= minW && r.h >= minH;
1604
+ // ⭐ The third saving (issue #1102): a piece's containers are looked for only
1605
+ // among the entries that share the edge it was cut along. A piece cut from
1606
+ // the left of `put` ends at `put.x` and keeps its parent's rows, which meet
1607
+ // `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
1608
+ // those rows from the piece's left edge to at least `put.x`; if it reached
1609
+ // past `put.x` it would overlap `put` — and no entry of `next` does (an
1610
+ // untouched rectangle by definition, a piece by construction). So every
1611
+ // container of a left piece ENDS at `put.x`; likewise a right piece's starts
1612
+ // at `put.x + put.w`, an upper piece's ends at `put.y` and a lower piece's
1613
+ // starts at `put.y + put.h`. The test is the full prune's test, asked of the
1614
+ // only entries that can answer it yes, so `contained` is the same boolean and
1615
+ // the list the same list in the same order. Measured where the footprint
1616
+ // pass does work (`fixtures/polypack_shapes.ts`, seed 1105, the searches a
1617
+ // `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
1618
+ // 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
1619
+ // 11,928,721,590 → 537,215,014 and 101 s → 14.5 s. `edgeIndex: false` is
1620
+ // that scan, kept so the selftest can hold that the index finds what the
1621
+ // scan finds (`PK94`) and for nothing else.
1622
+ const scan: number[][] = [[], [], [], []];
1623
+ const all: number[] = [];
1624
+ for (let j = 0; j < next.length; j++) {
1625
+ const r = next[j];
1626
+ if (!usable(r)) continue;
1627
+ if (!edgeIndex) {
1628
+ all.push(j);
1629
+ continue;
1630
+ }
1631
+ if (r.x + r.w === put.x) scan[LEFT_OF_PUT].push(j);
1632
+ if (r.x === put.x + put.w) scan[RIGHT_OF_PUT].push(j);
1633
+ if (r.y + r.h === put.y) scan[ABOVE_PUT].push(j);
1634
+ if (r.y === put.y + put.h) scan[BELOW_PUT].push(j);
1635
+ }
1402
1636
  free.length = 0;
1637
+ let tests = 0;
1403
1638
  for (let i = 0; i < next.length; i++) {
1404
1639
  if (!usable(next[i])) continue;
1405
- if (untouched[i]) {
1640
+ if (side[i] === UNTOUCHED) {
1406
1641
  free.push(next[i]);
1407
1642
  continue;
1408
1643
  }
1409
1644
  let contained = false;
1410
- for (let j = 0; j < next.length && !contained; j++) {
1411
- if (i === j || !usable(next[j])) continue;
1412
- if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) contained = true;
1645
+ for (const j of edgeIndex ? scan[side[i]] : all) {
1646
+ if (i === j) continue;
1647
+ tests++;
1648
+ if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) {
1649
+ contained = true;
1650
+ break;
1651
+ }
1413
1652
  }
1414
1653
  if (!contained) free.push(next[i]);
1415
1654
  }
1655
+ if (tally !== undefined) {
1656
+ tally.bandSplits++;
1657
+ tally.containmentTests += tests;
1658
+ }
1416
1659
  }
1417
1660
 
1661
+ /** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
1662
+ const LEFT_OF_PUT = 0;
1663
+ const RIGHT_OF_PUT = 1;
1664
+ const ABOVE_PUT = 2;
1665
+ const BELOW_PUT = 3;
1666
+ const UNTOUCHED = -1;
1667
+
1418
1668
  /**
1419
1669
  * `packOnePage` with the free-space test replaced by the footprint test
1420
1670
  * (issue #1099) — the placement `shape: 'polygon'` makes.
@@ -1434,9 +1684,12 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
1434
1684
  * * a candidate is a free rectangle that holds the cell's protected set's
1435
1685
  * bounding box, anchored at its top-left corner — the cell placed so that
1436
1686
  * box's corner lands there, which may put the cell's own unclaimed
1437
- * texels over a neighbour's — and whose cell stays on the page;
1438
- * * the score is Best Short Side Fit over that box, with the same total
1439
- * tie-break (smallest long-side leftover, then topmost, then leftmost);
1687
+ * texels over a neighbour's — and whose cell stays on the page
1688
+ * (*Candidates*, below, for the rules measured beside it);
1689
+ * * the score is Best Short Side Fit over the part of the free rectangle the
1690
+ * box uses from its corner (the box itself, under the default anchor),
1691
+ * with the same total tie-break (smallest long-side leftover, then
1692
+ * topmost, then leftmost, then the first anchor);
1440
1693
  * * the free list is split and pruned by the same code (`splitFree`), once
1441
1694
  * per rectangle of the protected set.
1442
1695
  *
@@ -1445,6 +1698,27 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
1445
1698
  * a pack with no mesh region makes `packOnePage`'s decisions one for one
1446
1699
  * (`PK82`).
1447
1700
  *
1701
+ * ## Candidates — the second anchor (issue #1104)
1702
+ *
1703
+ * The first anchor puts the owned box's corner on the free rectangle's corner,
1704
+ * the cell then reaching up and left of it by the box's offset. So a cell whose
1705
+ * box is inset by `(dx, dy)` can only go where a free rectangle starts at least
1706
+ * `dx` from the page's left and `dy` from its top — on an empty page, nowhere.
1707
+ * A large region whose mesh draws a small part of it is then every footprint
1708
+ * pass's first miss: on `fixtures/polypack_shapes.ts`'s seed 1107, and on the
1709
+ * production rig it stands for, every footprint pass lost to the rectangle pass
1710
+ * and the polygon pack was the `rect` pack to the texel.
1711
+ *
1712
+ * The second anchor puts the cell's own corner there instead, the box inset by
1713
+ * its offset, and is a candidate when the free rectangle holds the box where it
1714
+ * then lies. The two coincide when the box starts at the cell's corner, so it
1715
+ * is only tried for an inset box — never for a whole cell, which is why a pack
1716
+ * with no mesh region is unchanged by it. Both keep the box inside a free
1717
+ * rectangle and the cell on the page, so no texel test, gutter or atlas field
1718
+ * changes: `bounds` stays inside `size`, as the runtime's UVs assume. Which
1719
+ * free rectangles get it is the rule (`FOOTPRINT_ANCHORS`); which rule's pack
1720
+ * is written is `packAtlas`'s choice by Σ page area.
1721
+ *
1448
1722
  * 🔒 **The footprint test is then stated outright rather than trusted to the
1449
1723
  * bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
1450
1724
  * because no free rectangle overlaps one; every placement is nevertheless
@@ -1455,7 +1729,16 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
1455
1729
  * over the cells in the caller's order, each over a finite free list, every
1456
1730
  * choice made by a total order, and no input but the cells and the page.
1457
1731
  */
1458
- function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pageH: number, maxBottom = Infinity): Array<Rect | null> {
1732
+ function packOnePageByFootprint(
1733
+ shapes: readonly CellShape[],
1734
+ pageW: number,
1735
+ pageH: number,
1736
+ maxBottom = Infinity,
1737
+ stopAtMiss = false,
1738
+ edgeIndex = true,
1739
+ tally?: PassTally,
1740
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
1741
+ ): Array<Rect | null> {
1459
1742
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1460
1743
  const placed: Array<Rect | null> = [];
1461
1744
  const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
@@ -1470,37 +1753,67 @@ function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pag
1470
1753
 
1471
1754
  for (const shape of shapes) {
1472
1755
  const box = shape.bbox;
1473
- let best: Rect | null = null;
1756
+ let best: { x: number; y: number; frX: number; frY: number; anchor: number } | null = null;
1474
1757
  let bestShort = Infinity;
1475
1758
  let bestLong = Infinity;
1759
+ let held = false;
1760
+ // The second anchor differs from the first only when the box is inset.
1761
+ const anchorCount = anchors !== 'box' && (box.x !== 0 || box.y !== 0) ? 2 : 1;
1476
1762
  for (const fr of free) {
1477
1763
  if (fr.w < box.w || fr.h < box.h) continue;
1478
- const x = fr.x - box.x;
1479
- const y = fr.y - box.y;
1480
- if (x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH) continue;
1481
- const leftoverW = fr.w - box.w;
1482
- const leftoverH = fr.h - box.h;
1483
- const short = Math.min(leftoverW, leftoverH);
1484
- const long = Math.max(leftoverW, leftoverH);
1485
- if (best !== null) {
1486
- if (short > bestShort) continue;
1487
- if (short === bestShort) {
1488
- if (long > bestLong) continue;
1489
- if (long === bestLong) {
1490
- if (fr.y > best.y) continue;
1491
- if (fr.y === best.y && fr.x >= best.x) continue;
1764
+ held = true;
1765
+ let firstOffPage = false;
1766
+ for (let anchor = 0; anchor < anchorCount; anchor++) {
1767
+ // Anchor 0: the owned box's corner on the free corner. Anchor 1: the
1768
+ // cell's own corner there, the box then inset by its own offset.
1769
+ const x = anchor === 0 ? fr.x - box.x : fr.x;
1770
+ const y = anchor === 0 ? fr.y - box.y : fr.y;
1771
+ const usedW = anchor === 0 ? box.w : box.x + box.w;
1772
+ const usedH = anchor === 0 ? box.h : box.y + box.h;
1773
+ if (usedW > fr.w || usedH > fr.h) continue;
1774
+ const offPage = x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH;
1775
+ if (anchor === 0) firstOffPage = offPage;
1776
+ // `box-else-cell`: the second anchor only where the first left the page.
1777
+ if (anchor === 1 && anchors === 'box-else-cell' && !firstOffPage) continue;
1778
+ if (offPage) {
1779
+ if (anchor === 0 && tally !== undefined) tally.boxAnchorRefused++;
1780
+ continue;
1781
+ }
1782
+ const leftoverW = fr.w - usedW;
1783
+ const leftoverH = fr.h - usedH;
1784
+ const short = Math.min(leftoverW, leftoverH);
1785
+ const long = Math.max(leftoverW, leftoverH);
1786
+ if (best !== null) {
1787
+ if (short > bestShort) continue;
1788
+ if (short === bestShort) {
1789
+ if (long > bestLong) continue;
1790
+ if (long === bestLong) {
1791
+ if (fr.y > best.frY) continue;
1792
+ if (fr.y === best.frY) {
1793
+ if (fr.x > best.frX) continue;
1794
+ if (fr.x === best.frX && anchor >= best.anchor) continue;
1795
+ }
1796
+ }
1492
1797
  }
1493
1798
  }
1799
+ best = { x, y, frX: fr.x, frY: fr.y, anchor };
1800
+ bestShort = short;
1801
+ bestLong = long;
1494
1802
  }
1495
- best = fr;
1496
- bestShort = short;
1497
- bestLong = long;
1498
1803
  }
1499
1804
  if (best === null) {
1805
+ if (held && tally !== undefined) tally.missedByAnchor++;
1500
1806
  placed.push(null);
1807
+ // A caller that keeps only a pass placing every cell has its answer
1808
+ // (`placePass`, `wholeOrNothing`): the rest are not placed.
1809
+ if (stopAtMiss) {
1810
+ while (placed.length < shapes.length) placed.push(null);
1811
+ return placed;
1812
+ }
1501
1813
  continue;
1502
1814
  }
1503
- const put: Rect = { x: best.x - box.x, y: best.y - box.y, w: shape.width, h: shape.height };
1815
+ if (best.anchor === 1 && tally !== undefined) tally.secondAnchorPlaced++;
1816
+ const put: Rect = { x: best.x, y: best.y, w: shape.width, h: shape.height };
1504
1817
  for (const other of owned) {
1505
1818
  // Two cells that do not overlap cannot share a texel, and that answer
1506
1819
  // takes no texel to read; only an overlap is scanned, and only over the
@@ -1521,11 +1834,26 @@ function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pag
1521
1834
  while (placed.length < shapes.length) placed.push(null);
1522
1835
  return placed;
1523
1836
  }
1524
- for (const r of shape.rects) splitFree(free, { x: put.x + r.x, y: put.y + r.y, w: r.w, h: r.h }, minW, minH);
1837
+ for (const r of shape.rects) splitFree(free, { x: put.x + r.x, y: put.y + r.y, w: r.w, h: r.h }, minW, minH, edgeIndex, tally);
1525
1838
  }
1526
1839
  return placed;
1527
1840
  }
1528
1841
 
1842
+ /**
1843
+ * One footprint pass on an empty `pageW x pageH` page, run to its end, with
1844
+ * what it counted — exported for `tools/pack_anchor.ts` (issue #1104).
1845
+ */
1846
+ export function footprintPass(
1847
+ shapes: readonly CellShape[],
1848
+ pageW: number,
1849
+ pageH: number,
1850
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
1851
+ ): { rects: Array<{ x: number; y: number; w: number; h: number } | null>; tally: PassTally } {
1852
+ const tally = emptyTally();
1853
+ const rects = packOnePageByFootprint(shapes, pageW, pageH, Infinity, false, true, tally, anchors);
1854
+ return { rects, tally };
1855
+ }
1856
+
1529
1857
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
1530
1858
  const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
1531
1859
 
@@ -1733,6 +2061,130 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
1733
2061
  }
1734
2062
  }
1735
2063
 
2064
+ /** Where every part lands and the size each page is written at — one candidate's whole pack, before anything is drawn. */
2065
+ interface PackLayout {
2066
+ /** page index -> the placements on it, in packing order. */
2067
+ perPage: Placement[][];
2068
+ /** page index -> the size that page is written at. */
2069
+ pageSizes: Array<{ width: number; height: number }>;
2070
+ placements: Placement[];
2071
+ }
2072
+
2073
+ /**
2074
+ * One whole pack's layout under one placement rule: the single-page search,
2075
+ * or — when the set does not fit one page — the spill, each page then shrunk
2076
+ * to the smallest that holds it. `shapes` absent is the rectangle pass
2077
+ * throughout (`rect`, and the first of `polygon`'s candidates); given, the
2078
+ * footprint pass under `anchors`.
2079
+ */
2080
+ function layoutPack(
2081
+ sorted: readonly PackInput[],
2082
+ cells: Array<{ w: number; h: number }>,
2083
+ shapes: readonly CellShape[] | undefined,
2084
+ maxEdge: number,
2085
+ pageEdges: PageEdges,
2086
+ padding: number,
2087
+ anchors: FootprintAnchors,
2088
+ tally?: PassTally,
2089
+ stopAtMiss = true,
2090
+ ): PackLayout {
2091
+ const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss);
2092
+
2093
+ /** page index -> the placements on it, in packing order. */
2094
+ const perPage: Placement[][] = [];
2095
+ /** page index -> the size that page is written at. */
2096
+ const pageSizes: Array<{ width: number; height: number }> = [];
2097
+ const placements: Placement[] = [];
2098
+ if (single !== null) {
2099
+ perPage.push([]);
2100
+ pageSizes.push({ width: single.width, height: single.height });
2101
+ single.rects.forEach((rect, i) => {
2102
+ const place: Placement = {
2103
+ region: sorted[i].region,
2104
+ page: 0,
2105
+ x: rect.x + padding,
2106
+ y: rect.y + padding,
2107
+ width: sorted[i].width,
2108
+ height: sorted[i].height,
2109
+ };
2110
+ perPage[0].push(place);
2111
+ placements.push(place);
2112
+ });
2113
+ } else {
2114
+ // Spill. Which parts share a page is decided at the MAXIMUM size — that is
2115
+ // what makes the boundary deterministic and independent of the shrink below
2116
+ // — and parts are taken in packing order, whatever will not fit the current
2117
+ // page opening the next one.
2118
+ let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
2119
+ const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
2120
+ shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
2121
+ while (remaining.length > 0) {
2122
+ const pageIndex = perPage.length;
2123
+ const attempt = placePass(
2124
+ remaining.map((r) => r.cell),
2125
+ shapesOf(remaining),
2126
+ maxEdge,
2127
+ maxEdge,
2128
+ Infinity,
2129
+ { anchors, tally },
2130
+ );
2131
+ const onPage: typeof remaining = [];
2132
+ const leftOver: typeof remaining = [];
2133
+ attempt.forEach((rect, i) => {
2134
+ if (rect === null) leftOver.push(remaining[i]);
2135
+ else onPage.push(remaining[i]);
2136
+ });
2137
+ if (onPage.length === 0) {
2138
+ // Unreachable: every cell was proven to fit an empty page above. Kept as
2139
+ // a named stop rather than an infinite loop if that ever stops holding.
2140
+ throw new CompileError(
2141
+ `packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
2142
+ );
2143
+ }
2144
+ // ⭐ Then the page is written at the smallest power-of-two pair that holds
2145
+ // the cells assigned to it, not at the maximum (issue #266, follow-up 3).
2146
+ // A spill used to write `pageSize x pageSize` for every page, so a set that
2147
+ // overflowed by one small part paid for a second full page of transparency
2148
+ // — 4 MiB of decoded RAM at the 2048 default for a part that might be
2149
+ // 64x64. Re-packing through the same search the single-page case uses is
2150
+ // what keeps "the page written is the smallest that holds what is on it"
2151
+ // one rule; a page whose own cells need the maximum simply gets it back.
2152
+ const shrunk = smallestPageFor(
2153
+ onPage.map((r) => r.cell),
2154
+ maxEdge,
2155
+ pageEdges,
2156
+ shapesOf(onPage),
2157
+ anchors,
2158
+ tally,
2159
+ stopAtMiss,
2160
+ );
2161
+ if (shrunk === null) {
2162
+ // Unreachable for the same reason as the stall above: these cells were
2163
+ // just placed on a maxEdge page.
2164
+ throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
2165
+ }
2166
+ const onThisPage: Placement[] = [];
2167
+ shrunk.rects.forEach((rect, i) => {
2168
+ const place: Placement = {
2169
+ region: onPage[i].input.region,
2170
+ page: pageIndex,
2171
+ x: rect.x + padding,
2172
+ y: rect.y + padding,
2173
+ width: onPage[i].input.width,
2174
+ height: onPage[i].input.height,
2175
+ };
2176
+ onThisPage.push(place);
2177
+ placements.push(place);
2178
+ });
2179
+ perPage.push(onThisPage);
2180
+ pageSizes.push({ width: shrunk.width, height: shrunk.height });
2181
+ remaining = leftOver;
2182
+ }
2183
+ }
2184
+
2185
+ return { perPage, pageSizes, placements };
2186
+ }
2187
+
1736
2188
  /**
1737
2189
  * Pack these parts onto shared pages.
1738
2190
  *
@@ -1796,9 +2248,17 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
1796
2248
  * two footprints keeps them `2 · padding` apart (`CellShape`);
1797
2249
  * * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
1798
2250
  * split by what each cell owns, run beside the rectangle pass on every page
1799
- * tried and kept only when it is better (`placePass`) — so a `polygon` page
1800
- * is never larger than the `rect` page, and a set with no mesh footprint is
1801
- * placed exactly as under `rect` (`PK82`);
2251
+ * tried and kept only when it is better (`placePass`);
2252
+ * * **the pack written** is the least Σ page area — over every page, a spill's
2253
+ * included — of three whole packs: `rect`'s, the footprint pack under the
2254
+ * owned box's anchor, and under `box-else-cell` (issue #1104), the earlier
2255
+ * on a tie. So a `polygon` pack is never larger in page area than `rect`'s
2256
+ * on any set, by construction, and a set with no mesh footprint is `rect`'s
2257
+ * pack to the byte (`PK82`). The per-page rule alone never promised that
2258
+ * across a spill: seed 1105 under `pot` packed 8,388,608 texels against
2259
+ * `rect`'s 6,291,456 before it (`PK96`). It costs the three packs' searches
2260
+ * — no search is shared, since a free search's bounds depend on the best
2261
+ * page its own passes found;
1802
2262
  * * **the pixels** are drawn in two passes: every cell whole in packing order,
1803
2263
  * as under `rect`, then every region's owned texels again with its own
1804
2264
  * values. The owned sets are disjoint, so the second pass's order decides
@@ -1858,94 +2318,31 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
1858
2318
  const shapes =
1859
2319
  shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
1860
2320
 
1861
- const single = smallestPageFor(cells, maxEdge, pageEdges, shapes);
1862
-
1863
- /** page index -> the placements on it, in packing order. */
1864
- const perPage: Placement[][] = [];
1865
- /** page index -> the size that page is written at. */
1866
- const pageSizes: Array<{ width: number; height: number }> = [];
1867
- const placements: Placement[] = [];
1868
- if (single !== null) {
1869
- perPage.push([]);
1870
- pageSizes.push({ width: single.width, height: single.height });
1871
- single.rects.forEach((rect, i) => {
1872
- const place: Placement = {
1873
- region: sorted[i].region,
1874
- page: 0,
1875
- x: rect.x + padding,
1876
- y: rect.y + padding,
1877
- width: sorted[i].width,
1878
- height: sorted[i].height,
1879
- };
1880
- perPage[0].push(place);
1881
- placements.push(place);
1882
- });
1883
- } else {
1884
- // Spill. Which parts share a page is decided at the MAXIMUM size — that is
1885
- // what makes the boundary deterministic and independent of the shrink below
1886
- // — and parts are taken in packing order, whatever will not fit the current
1887
- // page opening the next one.
1888
- let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
1889
- const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
1890
- shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
1891
- while (remaining.length > 0) {
1892
- const pageIndex = perPage.length;
1893
- const attempt = placePass(
1894
- remaining.map((r) => r.cell),
1895
- shapesOf(remaining),
1896
- maxEdge,
1897
- maxEdge,
1898
- );
1899
- const onPage: typeof remaining = [];
1900
- const leftOver: typeof remaining = [];
1901
- attempt.forEach((rect, i) => {
1902
- if (rect === null) leftOver.push(remaining[i]);
1903
- else onPage.push(remaining[i]);
1904
- });
1905
- if (onPage.length === 0) {
1906
- // Unreachable: every cell was proven to fit an empty page above. Kept as
1907
- // a named stop rather than an infinite loop if that ever stops holding.
1908
- throw new CompileError(
1909
- `packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
1910
- );
1911
- }
1912
- // ⭐ Then the page is written at the smallest power-of-two pair that holds
1913
- // the cells assigned to it, not at the maximum (issue #266, follow-up 3).
1914
- // A spill used to write `pageSize x pageSize` for every page, so a set that
1915
- // overflowed by one small part paid for a second full page of transparency
1916
- // — 4 MiB of decoded RAM at the 2048 default for a part that might be
1917
- // 64x64. Re-packing through the same search the single-page case uses is
1918
- // what keeps "the page written is the smallest that holds what is on it"
1919
- // one rule; a page whose own cells need the maximum simply gets it back.
1920
- const shrunk = smallestPageFor(
1921
- onPage.map((r) => r.cell),
1922
- maxEdge,
1923
- pageEdges,
1924
- shapesOf(onPage),
1925
- );
1926
- if (shrunk === null) {
1927
- // Unreachable for the same reason as the stall above: these cells were
1928
- // just placed on a maxEdge page.
1929
- throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
1930
- }
1931
- const onThisPage: Placement[] = [];
1932
- shrunk.rects.forEach((rect, i) => {
1933
- const place: Placement = {
1934
- region: onPage[i].input.region,
1935
- page: pageIndex,
1936
- x: rect.x + padding,
1937
- y: rect.y + padding,
1938
- width: onPage[i].input.width,
1939
- height: onPage[i].input.height,
1940
- };
1941
- onThisPage.push(place);
1942
- placements.push(place);
1943
- });
1944
- perPage.push(onThisPage);
1945
- pageSizes.push({ width: shrunk.width, height: shrunk.height });
1946
- remaining = leftOver;
1947
- }
2321
+ // ⭐ `polygon` is a choice between whole packs (issue #1104), made on the
2322
+ // one figure the mode exists to lower — the sum of the pages' areas — and
2323
+ // made over every page, a spill's included. The candidates, in order: the
2324
+ // rectangle pack (`rect`'s own, to the byte); the footprint pack with the
2325
+ // owned box's anchor (`box`); and the footprint pack that may also put a
2326
+ // cell's own corner where the first anchor would leave the page
2327
+ // (`box-else-cell`). The least Σ area wins, the earlier candidate on a tie.
2328
+ // So a `polygon` pack is never larger than `rect`'s, nor than the one-anchor
2329
+ // pack, on any set — by construction, across a spill as well as on one page,
2330
+ // which no single greedy rule gives: one more candidate position placed
2331
+ // PK78's gem differently and every tile after it (one page 7.2 % larger),
2332
+ // and the first page of a spill choosing differently left seed 1105 under
2333
+ // `pot` on two 2048x2048 pages against `rect`'s 2048x2048 + 1024x2048.
2334
+ // `footprintAnchors` names one rule alone instead (the instrument, the plant).
2335
+ const candidates: Array<{ name: PackCandidate; layout: PackLayout }> = [];
2336
+ if (shapes === undefined) candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true) });
2337
+ 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) });
2338
+ else {
2339
+ candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true) });
2340
+ 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) });
1948
2341
  }
2342
+ const areaOf = (layout: PackLayout): number => layout.pageSizes.reduce((n, p) => n + p.width * p.height, 0);
2343
+ let chosen = candidates[0];
2344
+ for (const c of candidates) if (areaOf(c.layout) < areaOf(chosen.layout)) chosen = c;
2345
+ const { perPage, pageSizes, placements } = chosen.layout;
1949
2346
 
1950
2347
  // Draw. Reading each source once, in packing order, keeps the decode count at
1951
2348
  // one per part whatever the page layout turned out to be.
@@ -2019,7 +2416,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
2019
2416
  });
2020
2417
  });
2021
2418
 
2022
- return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape };
2419
+ return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape, candidate: chosen.name };
2023
2420
  }
2024
2421
 
2025
2422
  // ---------------------------------------------------------------------------
package/src/cli/shared.ts CHANGED
@@ -1225,8 +1225,13 @@ interface CommandDoc {
1225
1225
  * claim anybody keeps by hand: `RC26` in `selftest.ts` derives it from the
1226
1226
  * import graph of the module whose bodies register the command, and a
1227
1227
  * mark that disagrees with the graph is red by name.
1228
+ *
1229
+ * `rerunsTheGate` marks a command whose body re-runs the gate `build` ran
1230
+ * before it wrote (`validate`): refused by the entry that links none of the
1231
+ * runtime and pointed at a rigc build, its refusal says that gate already
1232
+ * ran (`gatedOnWrite`, issue #1097).
1228
1233
  */
1229
- runtime: false | { for: string; core?: CoreBody };
1234
+ runtime: false | { for: string; core?: CoreBody; rerunsTheGate?: true };
1230
1235
  /**
1231
1236
  * Whether the command exists for the Spine format — reads, writes, compares
1232
1237
  * or embeds Spine skeleton data as the whole of what it does with it — so
@@ -1311,7 +1316,7 @@ export const COMMANDS: CommandDoc[] = [
1311
1316
  },
1312
1317
  {
1313
1318
  name: 'validate',
1314
- runtime: { for: 'the gate it re-runs is the round trip through it' },
1319
+ runtime: { for: 'the gate it re-runs is the round trip through it', rerunsTheGate: true },
1315
1320
  spineFormat: true,
1316
1321
  usage: [
1317
1322
  'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
@@ -2279,6 +2284,51 @@ export function entryCommands(linksRuntime: boolean): CommandDoc[] {
2279
2284
  });
2280
2285
  }
2281
2286
 
2287
+ /**
2288
+ * What the refusal of a command that re-runs the gate says when its target is
2289
+ * a rigc build (issue #1097): that build already ran the gate, before it wrote
2290
+ * anything. A consumer met the refusal and read it as a gate the install could
2291
+ * not run, because the refusal said only what `validate` needs (#1095).
2292
+ *
2293
+ * ⚠️ Every clause is a fact about what the directory carries, never a reading
2294
+ * of the document: `skeleton.model.json` beside the pair is what makes it a
2295
+ * rigc build (`resolveBuild`'s `modelPath`), and emit-only-after-green is
2296
+ * what makes a rigc build gated on write. It does not say which entry wrote
2297
+ * it, which spec version the document is, or that the skeleton is still the
2298
+ * one the gate passed — nothing here reads the document, so those are said as
2299
+ * where to look (`spine.sha256`), not as findings.
2300
+ */
2301
+ export function gatedOnWriteSentence(skeletonPath: string): string {
2302
+ return (
2303
+ `The target is a rigc build — ${skeletonPath} has ${MODEL_DOCUMENT_FILE} beside it — and a rigc build writes nothing until its gate is green: ` +
2304
+ `that gate ran when the pair was written, and the document is the record of what it passed, its spine.sha256 naming the skeleton bytes the gate read ` +
2305
+ "— on this entry the gate is the model side's rules over the document and the round trip's own restated over the emitted text, " +
2306
+ 'with A00_ROUNDTRIP_PARSE alone reported as a SKIP.'
2307
+ );
2308
+ }
2309
+
2310
+ /**
2311
+ * `gatedOnWriteSentence` for a refused command marked `rerunsTheGate`, or
2312
+ * `null` — the refusal then says what it always said. The target is
2313
+ * `cmdValidate`'s: the positional (`.` without one) through `resolveBuild`;
2314
+ * a `--cut` or `--rig` run derives its directory from a spec, which the
2315
+ * refusal does not compile, so it keeps the sentence it had. Arguments
2316
+ * `parseArgs` or `resolveBuild` refuses are the command's to refuse, not the
2317
+ * runtime refusal's, and leave it as it was.
2318
+ */
2319
+ function gatedOnWrite(doc: CommandDoc, args: readonly string[]): string | null {
2320
+ if (doc.runtime === false || doc.runtime.rerunsTheGate !== true) return null;
2321
+ try {
2322
+ const { flags, positional } = parseArgs([...args], REPEATABLE_FLAGS[doc.name]);
2323
+ if (flags.cut !== undefined || flags.rig !== undefined) return null;
2324
+ const build = resolveBuild(positional[0] ?? '.', flags.atlas);
2325
+ return build.modelPath === null ? null : gatedOnWriteSentence(build.skeletonPath);
2326
+ } catch (err) {
2327
+ if (err instanceof UsageError) return null;
2328
+ throw err;
2329
+ }
2330
+ }
2331
+
2282
2332
  /**
2283
2333
  * A command `COMMANDS` documents and this entry does not run, because its body
2284
2334
  * reaches spine-core and the entry links none of it — refused naming what the
@@ -2286,10 +2336,12 @@ export function entryCommands(linksRuntime: boolean): CommandDoc[] {
2286
2336
  * A `SpineRuntimeError`, so it is printed and exits as the export an entry
2287
2337
  * cannot pose is.
2288
2338
  */
2289
- function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[]): SpineRuntimeError {
2339
+ function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[], args: readonly string[]): SpineRuntimeError {
2290
2340
  const needs = doc.runtime === false ? 'nothing' : doc.runtime.for;
2341
+ const gated = gatedOnWrite(doc, args);
2291
2342
  return new SpineRuntimeError(
2292
2343
  `\`${doc.name}\` runs through spine-core (${needs}), and the runtime could not be used: ${SPINE_SIDE_ABSENT}. ` +
2344
+ (gated === null ? '' : `${gated} `) +
2293
2345
  `The commands this entry runs are ${runs.join(', ')}; the entry that links spine-core runs \`${doc.name}\` — ` +
2294
2346
  `installed, \`rigc ${doc.name}\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts ${doc.name}\``,
2295
2347
  );
@@ -2325,7 +2377,7 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
2325
2377
  }
2326
2378
  if (!known.includes(command)) {
2327
2379
  const elsewhere = COMMANDS.find((doc) => doc.name === command);
2328
- if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known);
2380
+ if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known, rest);
2329
2381
  throw new UsageError(`unknown command: ${command}`);
2330
2382
  }
2331
2383
 
@@ -936,6 +936,103 @@ export function shapeDiff(before: Shape, after: Shape): string[] {
936
936
  return rows;
937
937
  }
938
938
 
939
+ /** What a JSON value is, in the words a refusal names it by: `null`, `an array`, `a number`, … */
940
+ function jsonKind(value: unknown): string {
941
+ if (value === null) return 'null';
942
+ if (Array.isArray(value)) return 'an array';
943
+ return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
944
+ }
945
+
946
+ /** True for the one JSON kind a skeleton file and each of its records is: a plain object. */
947
+ function isRecord(value: unknown): value is Record<string, unknown> {
948
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
949
+ }
950
+
951
+ /**
952
+ * The build's `skeleton.json`, read and parsed once, or the sentence the tool
953
+ * refuses it with (issue #1090).
954
+ *
955
+ * 🚨 The `skeleton.images` probe in `main` used to be the first thing that read
956
+ * this file, and it read it with a bare `JSON.parse`. A file that was not JSON,
957
+ * or was JSON holding `null`, therefore left the tool as an uncaught
958
+ * `SyntaxError` / `TypeError` and its stack — before the editor, so with nothing
959
+ * of the editor's to quote, and in a shape no other refusal in this file has.
960
+ * And a file holding `[]` or `7` did not stop at all: the probe found no
961
+ * `skeleton.images` on it and the run went on to hand the editor a file that is
962
+ * not a skeleton, minutes of import for a refusal this could state for free.
963
+ *
964
+ * ⭐ So this is the one reader of the build's skeleton text before step 1, and
965
+ * it refuses by the file's path and what it found there: a directory, the
966
+ * parser's own message, or the JSON kind that is not an object. What it hands
967
+ * back is the value the probe reads, so a readable build is parsed exactly as
968
+ * it was and every line after this prints as it did.
969
+ */
970
+ function readBuildSkeleton(source: string): { skeleton: Record<string, unknown> } | { refusal: string } {
971
+ const required =
972
+ 'The round trip imports that file into the editor, so it has to be one skeleton JSON object — `skeleton`, ' +
973
+ '`bones`, `slots`, … — which is what `rigc build` writes there.';
974
+ let text: string;
975
+ try {
976
+ text = readFileSync(source, 'utf8');
977
+ } catch (err) {
978
+ const code = (err as NodeJS.ErrnoException).code;
979
+ if (code === 'EISDIR') return { refusal: `the build's skeleton.json at ${source} is a directory, not a file. ${required}` };
980
+ return { refusal: `the build's skeleton.json at ${source} could not be read (${code ?? String(err)}). ${required}` };
981
+ }
982
+ if (text.trim() === '') {
983
+ return { refusal: `the build's skeleton.json at ${source} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
984
+ }
985
+ let parsed: unknown;
986
+ try {
987
+ parsed = JSON.parse(text);
988
+ } catch (err) {
989
+ const said = err instanceof Error ? err.message : String(err);
990
+ return { refusal: `the build's skeleton.json at ${source} is not JSON — the parser stopped with "${said}". ${required}` };
991
+ }
992
+ if (!isRecord(parsed)) {
993
+ return { refusal: `the build's skeleton.json at ${source} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
994
+ }
995
+ return { skeleton: parsed };
996
+ }
997
+
998
+ /**
999
+ * The field of the build's skeleton the images probe would have thrown on, as a
1000
+ * sentence naming it and what it holds — or null when every field the probe
1001
+ * dereferences is the kind it reads (issue #1090).
1002
+ *
1003
+ * ⚠️ Only those fields, and only on the path that reads them: the walk below
1004
+ * runs when `skeleton.images` is declared, and a field this does not read is the
1005
+ * gate's to judge after the import, not a precondition of the editor's. Asking
1006
+ * more here would be a second validator in front of the first.
1007
+ */
1008
+ function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
1009
+ const head = skeleton.skeleton;
1010
+ if (!isRecord(head)) return null;
1011
+ const images = head.images;
1012
+ if (images === undefined) return null;
1013
+ if (typeof images !== 'string') return `\`skeleton.images\` holds ${jsonKind(images)}, not a path string`;
1014
+ if (images === '') return null;
1015
+ const skins = skeleton.skins;
1016
+ if (skins === undefined) return null;
1017
+ if (!Array.isArray(skins)) return `\`skins\` holds ${jsonKind(skins)}, not an array`;
1018
+ for (let i = 0; i < skins.length; i++) {
1019
+ const skin: unknown = skins[i];
1020
+ if (!isRecord(skin)) return `\`skins[${i}]\` holds ${jsonKind(skin)}, not an object`;
1021
+ const attachments = skin.attachments;
1022
+ if (attachments === undefined) continue;
1023
+ if (!isRecord(attachments)) return `\`skins[${i}].attachments\` holds ${jsonKind(attachments)}, not an object`;
1024
+ for (const [slot, entries] of Object.entries(attachments)) {
1025
+ if (!isRecord(entries)) return `\`skins[${i}].attachments.${slot}\` holds ${jsonKind(entries)}, not an object`;
1026
+ for (const [placeholder, att] of Object.entries(entries)) {
1027
+ if (!isRecord(att)) {
1028
+ return `\`skins[${i}].attachments.${slot}.${placeholder}\` holds ${jsonKind(att)}, not an object`;
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ return null;
1034
+ }
1035
+
939
1036
  function main(): void {
940
1037
  const opts = parseArgs(process.argv.slice(2));
941
1038
  const rigc = rigcCommand();
@@ -975,6 +1072,19 @@ function main(): void {
975
1072
  };
976
1073
 
977
1074
  if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
1075
+ // 🔒 Read and parsed HERE, once, before anything else reads it (issue #1090):
1076
+ // a build whose skeleton is not one JSON object is refused by name rather than
1077
+ // thrown as a stack by the probe below, or handed to the editor.
1078
+ const read = readBuildSkeleton(source);
1079
+ if ('refusal' in read) return fail(read.refusal);
1080
+ const probeFault = imagesProbeFault(read.skeleton);
1081
+ if (probeFault !== null) {
1082
+ fail(
1083
+ `the build's skeleton.json at ${source} is an object, but ${probeFault} — one of the fields read before ` +
1084
+ "step 1 to check that every image the editor's import will look for is there. A skeleton `rigc build` " +
1085
+ 'wrote never carries that; rebuild the directory with `rigc build … --copy-images`.',
1086
+ );
1087
+ }
978
1088
 
979
1089
  // 🚨 The art the EDITOR will look for, checked before the editor is started —
980
1090
  // issue #562. The editor's JSON import reads `skeleton.images` and finds each
@@ -1010,7 +1120,9 @@ function main(): void {
1010
1120
  skeleton?: { images?: string };
1011
1121
  skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; name?: string; path?: string }>> }>;
1012
1122
  }
1013
- const probe = JSON.parse(readFileSync(source, 'utf8')) as ImagesProbe;
1123
+ // The value `readBuildSkeleton` parsed, and the fields this walk reads are
1124
+ // the ones `imagesProbeFault` has already held to their kinds.
1125
+ const probe = read.skeleton as ImagesProbe;
1014
1126
  const declared = probe.skeleton?.images;
1015
1127
  if (declared !== undefined && declared !== '') {
1016
1128
  const imagesDir = resolve(opts.build, declared);