spine-rigc 2.1.1 → 2.1.3

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,10 @@ 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,
703
+ and so is a build with no atlas or an atlas that is a directory, or an
704
+ `--exported` file that is not one JSON object.
702
705
 
703
706
  ⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
704
707
  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,26 +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**, measured for the pack alone
528
- (`--page-edges free`, padding 2, page size 2048, both shapes in one
529
- process, a shared machine, so read the ratios): on a production rig's region
530
- set (31 meshes, a few of them large, a two-page spill, the `rect` pages to
531
- the texel) 0.15–0.18 s against `rect`'s 0.06 s — it was 260–304 s before
532
- issue #1102, because a page search ran a full footprint pass at every width
533
- and threw each one away; on a generated set of that shape
534
- (`fixtures/polypack_shapes.ts`, seed 1107) 0.17 s against 0.06 s, from
535
- 4.5 s. Where the footprint pass does move a page the cost is real work and
536
- stays: on a generated set it packs smaller (seed 1105, 4,882,012 →
537
- 4,467,942 texels over two pages) 0.96–0.99 s against 0.07–0.10 s, about ten
538
- to fourteen times `rect`, from 127 s. `PK93` holds the first generated set
539
- as a count of free-list splits, within ten times `rect`'s.
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.
540
557
  - **The pixels.** Every cell is drawn whole in packing order, then every
541
558
  region's owned texels are drawn again with its own values, so every texel a
542
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.1",
3
+ "version": "2.1.3",
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, Infinity, { wholeOrNothing: true });
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. */
@@ -916,6 +979,8 @@ export interface FreePageSearchOptions {
916
979
  stopAtMiss?: boolean;
917
980
  /** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
918
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;
919
984
  }
920
985
 
921
986
  /**
@@ -948,7 +1013,7 @@ export function freePageSearch(
948
1013
  cellArea = 0;
949
1014
  for (const shape of shapes) cellArea += shape.owned;
950
1015
  }
951
- const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0 } };
1016
+ const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: emptyTally() };
952
1017
  let best: { width: number; height: number; rects: Rect[] } | null = null;
953
1018
  for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
954
1019
  out.widths++;
@@ -958,7 +1023,7 @@ export function freePageSearch(
958
1023
  continue;
959
1024
  }
960
1025
  const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
961
- const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true });
1026
+ const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors });
962
1027
  let height = 0;
963
1028
  for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
964
1029
  if (height > maxBottom) {
@@ -1150,7 +1215,7 @@ function placePass(
1150
1215
  // it did not abandon (46 %). The largest cell's owned box does not start at
1151
1216
  // its cell's corner, so on an empty page that cell is every footprint pass's
1152
1217
  // first miss — all 1,709 of them — and every one of those passes was thrown
1153
- // away. With the early stop the pack takes 0.15–0.7 s by the load, the
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
1154
1219
  // same pages.
1155
1220
  const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
1156
1221
  const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
@@ -1165,8 +1230,20 @@ function placePass(
1165
1230
  // than `rect` on two of the five gallery rigs that carry a mesh, because a
1166
1231
  // greedy pass that places one cell differently places every later one
1167
1232
  // differently too.
1168
- const byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally);
1169
- if (tally !== undefined) tally.footprintPlaced += placedCount(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);
1170
1247
  if (byRect === null) return byFootprint;
1171
1248
  const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1172
1249
  const placedRect = placedCount(byRect);
@@ -1183,6 +1260,8 @@ interface PassOptions {
1183
1260
  tally?: PassTally;
1184
1261
  /** `splitFree`'s edge index (default on); off only for the selftest's plant. */
1185
1262
  edgeIndex?: boolean;
1263
+ /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
1264
+ anchors?: FootprintAnchors;
1186
1265
  }
1187
1266
 
1188
1267
  /** How many cells a pass placed. */
@@ -1213,6 +1292,22 @@ export interface PassTally {
1213
1292
  bandSplits: number;
1214
1293
  /** Containment tests the footprint passes' prune made (`splitFree`). */
1215
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 };
1216
1311
  }
1217
1312
 
1218
1313
  // ---------------------------------------------------------------------------
@@ -1589,9 +1684,12 @@ const UNTOUCHED = -1;
1589
1684
  * * a candidate is a free rectangle that holds the cell's protected set's
1590
1685
  * bounding box, anchored at its top-left corner — the cell placed so that
1591
1686
  * box's corner lands there, which may put the cell's own unclaimed
1592
- * texels over a neighbour's — and whose cell stays on the page;
1593
- * * the score is Best Short Side Fit over that box, with the same total
1594
- * 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);
1595
1693
  * * the free list is split and pruned by the same code (`splitFree`), once
1596
1694
  * per rectangle of the protected set.
1597
1695
  *
@@ -1600,6 +1698,27 @@ const UNTOUCHED = -1;
1600
1698
  * a pack with no mesh region makes `packOnePage`'s decisions one for one
1601
1699
  * (`PK82`).
1602
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
+ *
1603
1722
  * 🔒 **The footprint test is then stated outright rather than trusted to the
1604
1723
  * bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
1605
1724
  * because no free rectangle overlaps one; every placement is nevertheless
@@ -1618,6 +1737,7 @@ function packOnePageByFootprint(
1618
1737
  stopAtMiss = false,
1619
1738
  edgeIndex = true,
1620
1739
  tally?: PassTally,
1740
+ anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
1621
1741
  ): Array<Rect | null> {
1622
1742
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1623
1743
  const placed: Array<Rect | null> = [];
@@ -1633,33 +1753,56 @@ function packOnePageByFootprint(
1633
1753
 
1634
1754
  for (const shape of shapes) {
1635
1755
  const box = shape.bbox;
1636
- let best: Rect | null = null;
1756
+ let best: { x: number; y: number; frX: number; frY: number; anchor: number } | null = null;
1637
1757
  let bestShort = Infinity;
1638
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;
1639
1762
  for (const fr of free) {
1640
1763
  if (fr.w < box.w || fr.h < box.h) continue;
1641
- const x = fr.x - box.x;
1642
- const y = fr.y - box.y;
1643
- if (x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH) continue;
1644
- const leftoverW = fr.w - box.w;
1645
- const leftoverH = fr.h - box.h;
1646
- const short = Math.min(leftoverW, leftoverH);
1647
- const long = Math.max(leftoverW, leftoverH);
1648
- if (best !== null) {
1649
- if (short > bestShort) continue;
1650
- if (short === bestShort) {
1651
- if (long > bestLong) continue;
1652
- if (long === bestLong) {
1653
- if (fr.y > best.y) continue;
1654
- 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
+ }
1655
1797
  }
1656
1798
  }
1799
+ best = { x, y, frX: fr.x, frY: fr.y, anchor };
1800
+ bestShort = short;
1801
+ bestLong = long;
1657
1802
  }
1658
- best = fr;
1659
- bestShort = short;
1660
- bestLong = long;
1661
1803
  }
1662
1804
  if (best === null) {
1805
+ if (held && tally !== undefined) tally.missedByAnchor++;
1663
1806
  placed.push(null);
1664
1807
  // A caller that keeps only a pass placing every cell has its answer
1665
1808
  // (`placePass`, `wholeOrNothing`): the rest are not placed.
@@ -1669,7 +1812,8 @@ function packOnePageByFootprint(
1669
1812
  }
1670
1813
  continue;
1671
1814
  }
1672
- 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 };
1673
1817
  for (const other of owned) {
1674
1818
  // Two cells that do not overlap cannot share a texel, and that answer
1675
1819
  // takes no texel to read; only an overlap is scanned, and only over the
@@ -1695,6 +1839,21 @@ function packOnePageByFootprint(
1695
1839
  return placed;
1696
1840
  }
1697
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
+
1698
1857
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
1699
1858
  const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
1700
1859
 
@@ -1902,6 +2061,130 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
1902
2061
  }
1903
2062
  }
1904
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
+
1905
2188
  /**
1906
2189
  * Pack these parts onto shared pages.
1907
2190
  *
@@ -1965,9 +2248,17 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
1965
2248
  * two footprints keeps them `2 · padding` apart (`CellShape`);
1966
2249
  * * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
1967
2250
  * split by what each cell owns, run beside the rectangle pass on every page
1968
- * tried and kept only when it is better (`placePass`) — so a `polygon` page
1969
- * is never larger than the `rect` page, and a set with no mesh footprint is
1970
- * 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;
1971
2262
  * * **the pixels** are drawn in two passes: every cell whole in packing order,
1972
2263
  * as under `rect`, then every region's owned texels again with its own
1973
2264
  * values. The owned sets are disjoint, so the second pass's order decides
@@ -2027,94 +2318,31 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
2027
2318
  const shapes =
2028
2319
  shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
2029
2320
 
2030
- const single = smallestPageFor(cells, maxEdge, pageEdges, shapes);
2031
-
2032
- /** page index -> the placements on it, in packing order. */
2033
- const perPage: Placement[][] = [];
2034
- /** page index -> the size that page is written at. */
2035
- const pageSizes: Array<{ width: number; height: number }> = [];
2036
- const placements: Placement[] = [];
2037
- if (single !== null) {
2038
- perPage.push([]);
2039
- pageSizes.push({ width: single.width, height: single.height });
2040
- single.rects.forEach((rect, i) => {
2041
- const place: Placement = {
2042
- region: sorted[i].region,
2043
- page: 0,
2044
- x: rect.x + padding,
2045
- y: rect.y + padding,
2046
- width: sorted[i].width,
2047
- height: sorted[i].height,
2048
- };
2049
- perPage[0].push(place);
2050
- placements.push(place);
2051
- });
2052
- } else {
2053
- // Spill. Which parts share a page is decided at the MAXIMUM size — that is
2054
- // what makes the boundary deterministic and independent of the shrink below
2055
- // — and parts are taken in packing order, whatever will not fit the current
2056
- // page opening the next one.
2057
- let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
2058
- const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
2059
- shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
2060
- while (remaining.length > 0) {
2061
- const pageIndex = perPage.length;
2062
- const attempt = placePass(
2063
- remaining.map((r) => r.cell),
2064
- shapesOf(remaining),
2065
- maxEdge,
2066
- maxEdge,
2067
- );
2068
- const onPage: typeof remaining = [];
2069
- const leftOver: typeof remaining = [];
2070
- attempt.forEach((rect, i) => {
2071
- if (rect === null) leftOver.push(remaining[i]);
2072
- else onPage.push(remaining[i]);
2073
- });
2074
- if (onPage.length === 0) {
2075
- // Unreachable: every cell was proven to fit an empty page above. Kept as
2076
- // a named stop rather than an infinite loop if that ever stops holding.
2077
- throw new CompileError(
2078
- `packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
2079
- );
2080
- }
2081
- // ⭐ Then the page is written at the smallest power-of-two pair that holds
2082
- // the cells assigned to it, not at the maximum (issue #266, follow-up 3).
2083
- // A spill used to write `pageSize x pageSize` for every page, so a set that
2084
- // overflowed by one small part paid for a second full page of transparency
2085
- // — 4 MiB of decoded RAM at the 2048 default for a part that might be
2086
- // 64x64. Re-packing through the same search the single-page case uses is
2087
- // what keeps "the page written is the smallest that holds what is on it"
2088
- // one rule; a page whose own cells need the maximum simply gets it back.
2089
- const shrunk = smallestPageFor(
2090
- onPage.map((r) => r.cell),
2091
- maxEdge,
2092
- pageEdges,
2093
- shapesOf(onPage),
2094
- );
2095
- if (shrunk === null) {
2096
- // Unreachable for the same reason as the stall above: these cells were
2097
- // just placed on a maxEdge page.
2098
- throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
2099
- }
2100
- const onThisPage: Placement[] = [];
2101
- shrunk.rects.forEach((rect, i) => {
2102
- const place: Placement = {
2103
- region: onPage[i].input.region,
2104
- page: pageIndex,
2105
- x: rect.x + padding,
2106
- y: rect.y + padding,
2107
- width: onPage[i].input.width,
2108
- height: onPage[i].input.height,
2109
- };
2110
- onThisPage.push(place);
2111
- placements.push(place);
2112
- });
2113
- perPage.push(onThisPage);
2114
- pageSizes.push({ width: shrunk.width, height: shrunk.height });
2115
- remaining = leftOver;
2116
- }
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) });
2117
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;
2118
2346
 
2119
2347
  // Draw. Reading each source once, in packing order, keeps the decode count at
2120
2348
  // one per part whatever the page layout turned out to be.
@@ -2188,7 +2416,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
2188
2416
  });
2189
2417
  });
2190
2418
 
2191
- return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape };
2419
+ return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape, candidate: chosen.name };
2192
2420
  }
2193
2421
 
2194
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,167 @@ 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
+ return readSkeletonObject(
972
+ source,
973
+ "the build's skeleton.json",
974
+ 'The round trip imports that file into the editor, so it has to be one skeleton JSON object — `skeleton`, ' +
975
+ '`bones`, `slots`, … — which is what `rigc build` writes there.',
976
+ );
977
+ }
978
+
979
+ /**
980
+ * One skeleton file read and parsed, with its bytes — or the sentence the tool
981
+ * refuses it with, naming `what` it is, its path, what was found there and the
982
+ * `required` sentence. The one body behind `readBuildSkeleton` and the
983
+ * `--exported` read (issue #1107), so the two files are refused in one shape.
984
+ *
985
+ * The bytes are handed back because the `--exported` file is written into the
986
+ * candidate from them: the file step 3 measures is then the file this parsed,
987
+ * byte for byte, rather than whatever is at the path by the time step 3 runs.
988
+ */
989
+ function readSkeletonObject(
990
+ path: string,
991
+ what: string,
992
+ required: string,
993
+ ): { skeleton: Record<string, unknown>; bytes: Buffer } | { refusal: string } {
994
+ let bytes: Buffer;
995
+ try {
996
+ bytes = readFileSync(path);
997
+ } catch (err) {
998
+ const code = (err as NodeJS.ErrnoException).code;
999
+ if (code === 'EISDIR') return { refusal: `${what} at ${path} is a directory, not a file. ${required}` };
1000
+ return { refusal: `${what} at ${path} could not be read (${code ?? String(err)}). ${required}` };
1001
+ }
1002
+ const text = bytes.toString('utf8');
1003
+ if (text.trim() === '') {
1004
+ return { refusal: `${what} at ${path} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
1005
+ }
1006
+ let parsed: unknown;
1007
+ try {
1008
+ parsed = JSON.parse(text);
1009
+ } catch (err) {
1010
+ const said = err instanceof Error ? err.message : String(err);
1011
+ return { refusal: `${what} at ${path} is not JSON — the parser stopped with "${said}". ${required}` };
1012
+ }
1013
+ if (!isRecord(parsed)) {
1014
+ return { refusal: `${what} at ${path} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
1015
+ }
1016
+ return { skeleton: parsed, bytes };
1017
+ }
1018
+
1019
+ /**
1020
+ * The build's atlas, read once before step 1, or the sentence the tool refuses
1021
+ * it with (issue #1107): its name and the page names it lists.
1022
+ *
1023
+ * 🚨 This read used to sit at step 3, after the editor had imported and
1024
+ * exported. So a build with no atlas was refused by name only after a licensed
1025
+ * editor had spent an import and an export on it, and a directory named
1026
+ * `*.atlas` beside the build threw `EISDIR` there as a stack. Neither needs the
1027
+ * editor to answer: the atlas is rigc's input to step 3, not the editor's.
1028
+ *
1029
+ * ⭐ Every `*.atlas` entry is read, not only the one whose pages are listed,
1030
+ * because step 3 copies every one of them beside the export, and a directory
1031
+ * among them throws at that copy. The first entry is the atlas, as it always was.
1032
+ *
1033
+ * ⚠️ "Read" is all this can mean. The tool's own reader of an atlas is the line
1034
+ * filter below, which accepts any text, so there is no malformed atlas for it to
1035
+ * refuse; what the atlas SAYS is the gate's to judge at step 3 (`A08`, `A17`).
1036
+ */
1037
+ function readBuildAtlas(build: string): { pageNames: string[] } | { refusal: string } {
1038
+ const atlases = readdirSync(build).filter((f) => f.endsWith('.atlas'));
1039
+ if (atlases.length === 0) return { refusal: `no .atlas in the build directory ${build}` };
1040
+ const required =
1041
+ 'Step 3 copies the build\'s atlas and pages beside the export so the export can be loaded and measured, so ' +
1042
+ 'every `.atlas` there has to be a file — the one `rigc build` writes.';
1043
+ let first: string | null = null;
1044
+ for (const name of atlases) {
1045
+ const path = join(build, name);
1046
+ try {
1047
+ const text = readFileSync(path, 'utf8');
1048
+ if (first === null) first = text;
1049
+ } catch (err) {
1050
+ const code = (err as NodeJS.ErrnoException).code;
1051
+ if (code === 'EISDIR') return { refusal: `the build's atlas at ${path} is a directory, not a file. ${required}` };
1052
+ return { refusal: `the build's atlas at ${path} could not be read (${code ?? String(err)}). ${required}` };
1053
+ }
1054
+ }
1055
+ const pageNames = (first ?? '')
1056
+ .split('\n')
1057
+ .filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
1058
+ .map((line) => line.trim());
1059
+ return { pageNames };
1060
+ }
1061
+
1062
+ /**
1063
+ * The field of the build's skeleton the images probe would have thrown on, as a
1064
+ * sentence naming it and what it holds — or null when every field the probe
1065
+ * dereferences is the kind it reads (issue #1090).
1066
+ *
1067
+ * ⚠️ Only those fields, and only on the path that reads them: the walk below
1068
+ * runs when `skeleton.images` is declared, and a field this does not read is the
1069
+ * gate's to judge after the import, not a precondition of the editor's. Asking
1070
+ * more here would be a second validator in front of the first.
1071
+ */
1072
+ function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
1073
+ const head = skeleton.skeleton;
1074
+ if (!isRecord(head)) return null;
1075
+ const images = head.images;
1076
+ if (images === undefined) return null;
1077
+ if (typeof images !== 'string') return `\`skeleton.images\` holds ${jsonKind(images)}, not a path string`;
1078
+ if (images === '') return null;
1079
+ const skins = skeleton.skins;
1080
+ if (skins === undefined) return null;
1081
+ if (!Array.isArray(skins)) return `\`skins\` holds ${jsonKind(skins)}, not an array`;
1082
+ for (let i = 0; i < skins.length; i++) {
1083
+ const skin: unknown = skins[i];
1084
+ if (!isRecord(skin)) return `\`skins[${i}]\` holds ${jsonKind(skin)}, not an object`;
1085
+ const attachments = skin.attachments;
1086
+ if (attachments === undefined) continue;
1087
+ if (!isRecord(attachments)) return `\`skins[${i}].attachments\` holds ${jsonKind(attachments)}, not an object`;
1088
+ for (const [slot, entries] of Object.entries(attachments)) {
1089
+ if (!isRecord(entries)) return `\`skins[${i}].attachments.${slot}\` holds ${jsonKind(entries)}, not an object`;
1090
+ for (const [placeholder, att] of Object.entries(entries)) {
1091
+ if (!isRecord(att)) {
1092
+ return `\`skins[${i}].attachments.${slot}.${placeholder}\` holds ${jsonKind(att)}, not an object`;
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+
939
1100
  function main(): void {
940
1101
  const opts = parseArgs(process.argv.slice(2));
941
1102
  const rigc = rigcCommand();
@@ -975,6 +1136,19 @@ function main(): void {
975
1136
  };
976
1137
 
977
1138
  if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
1139
+ // 🔒 Read and parsed HERE, once, before anything else reads it (issue #1090):
1140
+ // a build whose skeleton is not one JSON object is refused by name rather than
1141
+ // thrown as a stack by the probe below, or handed to the editor.
1142
+ const read = readBuildSkeleton(source);
1143
+ if ('refusal' in read) return fail(read.refusal);
1144
+ const probeFault = imagesProbeFault(read.skeleton);
1145
+ if (probeFault !== null) {
1146
+ fail(
1147
+ `the build's skeleton.json at ${source} is an object, but ${probeFault} — one of the fields read before ` +
1148
+ "step 1 to check that every image the editor's import will look for is there. A skeleton `rigc build` " +
1149
+ 'wrote never carries that; rebuild the directory with `rigc build … --copy-images`.',
1150
+ );
1151
+ }
978
1152
 
979
1153
  // 🚨 The art the EDITOR will look for, checked before the editor is started —
980
1154
  // issue #562. The editor's JSON import reads `skeleton.images` and finds each
@@ -1001,16 +1175,22 @@ function main(): void {
1001
1175
  // makes — and `--pack --copy-images` is refused by `cli.ts`, correctly,
1002
1176
  // because a packed atlas does not reference loose parts.
1003
1177
  //
1004
- // ⛔ It is checked HERE, before step 1, rather than beside the atlas check
1005
- // further down, because each precondition sits before the step it protects:
1006
- // the atlas's page names matter to the harness's own copy in step 3, and this
1007
- // matters to the editor's import in step 1.
1178
+ // ⛔ It is checked HERE, before step 1, because it matters to the editor's
1179
+ // import in step 1. The atlas and an `--exported` file are read before step 1
1180
+ // as well, just below, although nothing before step 3 uses them (issue #1107):
1181
+ // they are rigc's inputs and not the editor's, so reading them first costs the
1182
+ // editor nothing and refuses before a licensed editor is started on a trip
1183
+ // that cannot finish — the reason `readBuildSkeleton` reads the build's
1184
+ // skeleton first (#1090). So the order is: every input rigc reads is read
1185
+ // before the editor starts, and only what the editor writes is read after it.
1008
1186
  {
1009
1187
  interface ImagesProbe {
1010
1188
  skeleton?: { images?: string };
1011
1189
  skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; name?: string; path?: string }>> }>;
1012
1190
  }
1013
- const probe = JSON.parse(readFileSync(source, 'utf8')) as ImagesProbe;
1191
+ // The value `readBuildSkeleton` parsed, and the fields this walk reads are
1192
+ // the ones `imagesProbeFault` has already held to their kinds.
1193
+ const probe = read.skeleton as ImagesProbe;
1014
1194
  const declared = probe.skeleton?.images;
1015
1195
  if (declared !== undefined && declared !== '') {
1016
1196
  const imagesDir = resolve(opts.build, declared);
@@ -1055,6 +1235,48 @@ function main(): void {
1055
1235
  }
1056
1236
  }
1057
1237
 
1238
+ // The export is a skeleton only; it needs the build's atlas and pages beside
1239
+ // it to be a candidate anything can load — so the atlas is step 3's, and it is
1240
+ // read here, before step 1, for the reason the sentence above gives.
1241
+ //
1242
+ // 🚨 Which is why the build has to be SELF-CONTAINED, and this refuses when it
1243
+ // is not. An ordinary build's atlas names its pages by a relative path back to
1244
+ // the art directory; copy that atlas to a directory at another depth and every
1245
+ // page name resolves to nothing. Found by running this tool — it reported four
1246
+ // `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
1247
+ // the editor's, which is the worst kind of red: a real assertion, correctly
1248
+ // fired, pointing at the wrong culprit.
1249
+ const atlas = readBuildAtlas(opts.build);
1250
+ if ('refusal' in atlas) return fail(atlas.refusal);
1251
+ const wandering = atlas.pageNames.filter((n) => n.includes('/'));
1252
+ if (wandering.length > 0) {
1253
+ fail(
1254
+ `the build's atlas names its pages by path, not by filename — the first is "${wandering[0]}". ` +
1255
+ 'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
1256
+ `${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
1257
+ 'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
1258
+ );
1259
+ }
1260
+
1261
+ // 🔒 The `--exported` file, read and parsed here for the same reason (issue
1262
+ // #1107): one skeleton JSON object, in `readBuildSkeleton`'s shape, or refused
1263
+ // by name. It used to be read first by step 4's `skinsDeclaredBy`, bare, so a
1264
+ // file that was not JSON was gated and diffed by steps 3–4 and then left the
1265
+ // tool as a `SyntaxError` stack. Step 3's candidate is written from these
1266
+ // bytes, so what is measured is what was parsed.
1267
+ let exportedBytes: Buffer | null = null;
1268
+ if (opts.exported !== null) {
1269
+ if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
1270
+ const got = readSkeletonObject(
1271
+ opts.exported,
1272
+ 'the --exported file',
1273
+ 'Steps 3-6 measure that file as the editor\'s JSON export of this build, so it has to be one skeleton JSON ' +
1274
+ 'object — `skeleton`, `bones`, `slots`, … — which is what the editor\'s `-e json` export writes.',
1275
+ );
1276
+ if ('refusal' in got) return fail(got.refusal);
1277
+ exportedBytes = got.bytes;
1278
+ }
1279
+
1058
1280
  rmSync(opts.out, { recursive: true, force: true });
1059
1281
  mkdirSync(join(opts.out, 'export'), { recursive: true });
1060
1282
  mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
@@ -1065,7 +1287,6 @@ function main(): void {
1065
1287
 
1066
1288
  let exportedJson: string;
1067
1289
  if (opts.exported !== null) {
1068
- if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
1069
1290
  exportedJson = opts.exported;
1070
1291
  emit(` editor NOT RUN — measuring an export the editor already made: ${opts.exported}`);
1071
1292
  emit('');
@@ -1134,33 +1355,12 @@ function main(): void {
1134
1355
  exportedJson = join(opts.out, 'export', written[0]);
1135
1356
  }
1136
1357
 
1137
- // The export is a skeleton only; it needs the build's atlas and pages beside
1138
- // it to be a candidate anything can load.
1139
- //
1140
- // 🚨 Which is why the build has to be SELF-CONTAINED, and this refuses when it
1141
- // is not. An ordinary build's atlas names its pages by a relative path back to
1142
- // the art directory; copy that atlas to a directory at another depth and every
1143
- // page name resolves to nothing. Found by running this tool — it reported four
1144
- // `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
1145
- // the editor's, which is the worst kind of red: a real assertion, correctly
1146
- // fired, pointing at the wrong culprit.
1358
+ // The candidate: the export beside the build's atlas and pages, which were
1359
+ // read before step 1 (see `readBuildAtlas`). An `--exported` file is written
1360
+ // from the bytes parsed there; the editor's own export is copied from disk.
1147
1361
  const cand = join(opts.out, 'export-cand');
1148
- const atlasName = readdirSync(opts.build).find((f) => f.endsWith('.atlas'));
1149
- if (atlasName === undefined) fail(`no .atlas in the build directory ${opts.build}`);
1150
- const pageNames = readFileSync(join(opts.build, atlasName!), 'utf8')
1151
- .split('\n')
1152
- .filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
1153
- .map((line) => line.trim());
1154
- const wandering = pageNames.filter((n) => n.includes('/'));
1155
- if (wandering.length > 0) {
1156
- fail(
1157
- `the build's atlas names its pages by path, not by filename — the first is "${wandering[0]}". ` +
1158
- 'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
1159
- `${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
1160
- 'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
1161
- );
1162
- }
1163
- copyFileSync(exportedJson, join(cand, 'skeleton.json'));
1362
+ if (exportedBytes !== null) writeFileSync(join(cand, 'skeleton.json'), exportedBytes);
1363
+ else copyFileSync(exportedJson, join(cand, 'skeleton.json'));
1164
1364
  for (const f of readdirSync(opts.build)) {
1165
1365
  if (f.endsWith('.atlas') || f.endsWith('.png')) copyFileSync(join(opts.build, f), join(cand, f));
1166
1366
  }