spine-rigc 2.11.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/atlas.ts CHANGED
@@ -249,8 +249,13 @@ export function pageFootprint(region: { width: number; height: number; degrees:
249
249
  * field". Four values maximum, because that is where the runtime stops (`if (i
250
250
  * === 4) return 4`) and a fifth would silently mean something here that it does
251
251
  * not mean there.
252
+ *
253
+ * Exported for `src/repack.ts` (issue #1169), which reads the keys a page and a
254
+ * region state — the ones this parser drops (`filter`, `format`, `repeat`,
255
+ * `split`, `pad`) included — with this function rather than a second spelling
256
+ * of the format, and holds what it walked to `parseAtlasText`'s regions.
252
257
  */
253
- function readEntry(line: string | null): { key: string; values: string[] } | null {
258
+ export function readEntry(line: string | null): { key: string; values: string[] } | null {
254
259
  if (line === null) return null;
255
260
  const trimmed = line.trim();
256
261
  if (trimmed.length === 0) return null;
@@ -803,6 +808,13 @@ export interface PackOptions {
803
808
  * the same either way (`PK94`); only the cost differs.
804
809
  */
805
810
  stopAtMiss?: boolean;
811
+ /**
812
+ * `false` splits every band of every cell a footprint pass places over the
813
+ * whole free list — issue #1165's partition off (`splitFreeByCell`), for the
814
+ * selftest's plant (`PK106`) only. The pages are the same either way; only
815
+ * the cost differs.
816
+ */
817
+ partition?: boolean;
806
818
  }
807
819
 
808
820
  /** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
@@ -884,15 +896,16 @@ function smallestPageFor(
884
896
  anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
885
897
  tally?: PassTally,
886
898
  stopAtMiss = true,
899
+ partition = true,
887
900
  ): { width: number; height: number; rects: Rect[] } | null {
888
- if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss);
901
+ if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss, partition);
889
902
  const edges: number[] = [];
890
903
  for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
891
904
  const candidates: Array<{ w: number; h: number }> = [];
892
905
  for (const w of edges) for (const h of edges) candidates.push({ w, h });
893
906
  candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
894
907
  for (const candidate of candidates) {
895
- const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally });
908
+ const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally, partition });
896
909
  if (attempt.some((r) => r === null)) continue;
897
910
  return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
898
911
  }
@@ -947,8 +960,9 @@ function smallestFreePageFor(
947
960
  anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
948
961
  tally?: PassTally,
949
962
  stopAtMiss = true,
963
+ partition = true,
950
964
  ): { width: number; height: number; rects: Rect[] } | null {
951
- const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss });
965
+ const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss, partition });
952
966
  if (tally !== undefined) for (const key of Object.keys(tally) as Array<keyof PassTally>) tally[key] += search.tally[key];
953
967
  return search.page;
954
968
  }
@@ -970,15 +984,19 @@ export interface FreePageSearch {
970
984
 
971
985
  /**
972
986
  * 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.
987
+ * (`PK94`, `PK95`, `PK106`). Each turns one saving off: `stopAtMiss: false`
988
+ * runs every footprint pass to its end and `edgeIndex: false` splits and
989
+ * prunes the whole free list by scanning all of it (issue #1102's two);
990
+ * `partition: false` splits every band over the whole list but keeps the edge
991
+ * index (issue #1165's). The page is the same either way; only what it costs
992
+ * differs, and the plants hold that.
977
993
  */
978
994
  export interface FreePageSearchOptions {
979
995
  stopAtMiss?: boolean;
980
- /** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
996
+ /** `false` splits the whole free list and scans all of it for a piece's containers (`splitFree`), as the prune did before issue #1102. */
981
997
  edgeIndex?: boolean;
998
+ /** `false` splits every band over the whole free list (`splitFreeByCell`), as before issue #1165 — the plant of `PK106`. */
999
+ partition?: boolean;
982
1000
  /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`) — `tools/pack_anchor.ts`'s candidate rules; default `box`. */
983
1001
  anchors?: FootprintAnchors;
984
1002
  }
@@ -1023,7 +1041,7 @@ export function freePageSearch(
1023
1041
  continue;
1024
1042
  }
1025
1043
  const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
1026
- const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors });
1044
+ const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors, partition: options.partition ?? true });
1027
1045
  let height = 0;
1028
1046
  for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
1029
1047
  if (height > maxBottom) {
@@ -1231,13 +1249,13 @@ function placePass(
1231
1249
  // greedy pass that places one cell differently places every later one
1232
1250
  // differently too.
1233
1251
  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);
1252
+ let byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, anchors === 'best-of' ? 'box' : anchors, pass.partition ?? true);
1235
1253
  if (anchors === 'best-of') {
1236
1254
  // Issue #1104's fourth candidate: both searches on the same page, the
1237
1255
  // better kept and the first anchor's on a tie — so a pass is never worse
1238
1256
  // than the first anchor's on that page.
1239
1257
  if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1240
- const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell');
1258
+ const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell', pass.partition ?? true);
1241
1259
  const bottom = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1242
1260
  const placedBox = placedCount(byFootprint);
1243
1261
  const placedTwo = placedCount(byTwo);
@@ -1262,6 +1280,8 @@ interface PassOptions {
1262
1280
  edgeIndex?: boolean;
1263
1281
  /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
1264
1282
  anchors?: FootprintAnchors;
1283
+ /** `splitFreeByCell`'s partition (default on); off only for the selftest's plant. */
1284
+ partition?: boolean;
1265
1285
  }
1266
1286
 
1267
1287
  /** How many cells a pass placed. */
@@ -1292,6 +1312,12 @@ export interface PassTally {
1292
1312
  bandSplits: number;
1293
1313
  /** Containment tests the footprint passes' prune made (`splitFree`). */
1294
1314
  containmentTests: number;
1315
+ /**
1316
+ * Free-list entries the footprint passes' splits read: the list's length at
1317
+ * every band split, and once per placed cell the whole list the partition
1318
+ * reads (`splitFreeByCell`) — the work issue #1165 measured, held by `PK106`.
1319
+ */
1320
+ entriesRead: number;
1295
1321
  /**
1296
1322
  * Free rectangles that held a cell's owned box and were refused as its
1297
1323
  * first-anchor candidate only because the cell, anchored there, would leave
@@ -1307,7 +1333,7 @@ export interface PassTally {
1307
1333
 
1308
1334
  /** A tally with nothing counted. */
1309
1335
  export function emptyTally(): PassTally {
1310
- return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
1336
+ return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, entriesRead: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
1311
1337
  }
1312
1338
 
1313
1339
  // ---------------------------------------------------------------------------
@@ -1551,57 +1577,81 @@ function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: numb
1551
1577
 
1552
1578
  /**
1553
1579
  * MaxRects' split of every free rectangle `put` overlaps, then its prune —
1554
- * `packOnePage`'s, with one saving that changes no rectangle and no order: a
1555
- * free rectangle `put` did not touch is never tested for containment. Before
1556
- * the split no free rectangle lay inside another (the prune's own
1557
- * postcondition), and every new piece lies inside the rectangle it was cut
1558
- * from, so an untouched rectangle inside a new piece would have lain inside
1559
- * that one — so the test `packOnePage` makes for it always answers "not
1560
- * contained", and skipping it keeps the list exactly as that prune leaves it.
1561
- *
1562
- * ⭐ And a second (issue #1099, measured on production rigs): a free rectangle
1563
- * narrower than `minW` or shorter than `minH` is dropped. The caller passes the
1564
- * least bounding box of every cell the pass places, so such a rectangle can
1565
- * never be a candidate, and neither can any piece later cut from it (a piece
1566
- * lies inside the rectangle it was cut from); and a rectangle a dropped one
1567
- * contains is itself too small. So every rectangle that could ever be a
1568
- * candidate is kept, in the order the full prune keeps it, and the decisions
1569
- * are the same. What it removes is the staircase of slivers an irregular hull's
1570
- * bands cut along its edge: on a production-shaped set (30 regions, 200 to 900
1571
- * px, hulls near their rectangles, a `free` page about 2023x2046) the pack
1572
- * took 18.5 s with the slivers kept and spent 95 % of it in this prune.
1580
+ * `packOnePage`'s, over the first `length` entries of `free`, written back in
1581
+ * place; returns the new length. Four savings, none of which changes a
1582
+ * rectangle or the list's order:
1583
+ *
1584
+ * * a free rectangle the placement did not touch is never tested for
1585
+ * containment. Before the split no free rectangle lay inside another (the
1586
+ * prune's own postcondition), and every new piece lies inside the
1587
+ * rectangle it was cut from, so an untouched rectangle inside a new piece
1588
+ * would have lain inside that one — the test `packOnePage` makes for it
1589
+ * always answers "not contained";
1590
+ * * ⭐ (issue #1099, measured on production rigs) a free rectangle narrower
1591
+ * than `minW` or shorter than `minH` is dropped. The caller passes the
1592
+ * least bounding box of every cell the pass places, so such a rectangle can
1593
+ * never be a candidate, and neither can any piece later cut from it (a
1594
+ * piece lies inside the rectangle it was cut from); and a rectangle a
1595
+ * dropped one contains is itself too small. So every rectangle that could
1596
+ * ever be a candidate is kept, in the order the full prune keeps it. What
1597
+ * it removes is the staircase of slivers an irregular hull's bands cut
1598
+ * along its edge: on a production-shaped set (30 regions, 200 to 900 px,
1599
+ * hulls near their rectangles, a `free` page about 2023x2046) the pack took
1600
+ * 18.5 s with the slivers kept and spent 95 % of it in this prune. A piece
1601
+ * that would be dropped is not made at all (issue #1165): it takes part in
1602
+ * no test and is not written back, so it changes no other entry's index
1603
+ * order either;
1604
+ * * the edge index (issue #1102), below;
1605
+ * * (issue #1165) the rebuilt list, each entry's side and the edge lists are
1606
+ * kept between calls (`splitNext` and its neighbours) and the list is
1607
+ * written back over itself by index, never emptied and refilled: on the
1608
+ * production rigs the emptying and the pushes that refilled it were most
1609
+ * of this function's time, and none of it was arithmetic.
1573
1610
  */
1574
- function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1, edgeIndex = true, tally?: PassTally): void {
1575
- const next: Rect[] = [];
1576
- /** Per entry of `next`: `UNTOUCHED`, or which side of `put` the piece was cut from. */
1577
- const side: number[] = [];
1578
- for (const fr of free) {
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;
1580
- if (!overlaps) {
1581
- next.push(fr);
1582
- side.push(UNTOUCHED);
1611
+ function splitFree(free: Rect[], length: number, put: Rect, minW: number, minH: number, edgeIndex: boolean, tally?: PassTally): number {
1612
+ const px0 = put.x;
1613
+ const py0 = put.y;
1614
+ const px1 = put.x + put.w;
1615
+ const py1 = put.y + put.h;
1616
+ // A touched entry yields at most four pieces.
1617
+ if (splitSide.length < length * 4) {
1618
+ const capacity = Math.max(64, length * 8);
1619
+ splitSide = new Int8Array(capacity);
1620
+ splitLeft = new Int32Array(capacity);
1621
+ splitRight = new Int32Array(capacity);
1622
+ splitAbove = new Int32Array(capacity);
1623
+ splitBelow = new Int32Array(capacity);
1624
+ }
1625
+ const next = splitNext;
1626
+ const side = splitSide;
1627
+ let n = 0;
1628
+ for (let k = 0; k < length; k++) {
1629
+ const fr = free[k];
1630
+ const fx1 = fr.x + fr.w;
1631
+ const fy1 = fr.y + fr.h;
1632
+ if (!(px0 < fx1 && px1 > fr.x && py0 < fy1 && py1 > fr.y)) {
1633
+ next[n] = fr;
1634
+ side[n++] = UNTOUCHED;
1583
1635
  continue;
1584
1636
  }
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);
1637
+ if (px0 > fr.x && px0 - fr.x >= minW && fr.h >= minH) {
1638
+ next[n] = { x: fr.x, y: fr.y, w: px0 - fr.x, h: fr.h };
1639
+ side[n++] = LEFT_OF_PUT;
1588
1640
  }
1589
- if (put.x + put.w < fr.x + fr.w) {
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);
1641
+ if (px1 < fx1 && fx1 - px1 >= minW && fr.h >= minH) {
1642
+ next[n] = { x: px1, y: fr.y, w: fx1 - px1, h: fr.h };
1643
+ side[n++] = RIGHT_OF_PUT;
1592
1644
  }
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);
1645
+ if (py0 > fr.y && fr.w >= minW && py0 - fr.y >= minH) {
1646
+ next[n] = { x: fr.x, y: fr.y, w: fr.w, h: py0 - fr.y };
1647
+ side[n++] = ABOVE_PUT;
1596
1648
  }
1597
- if (put.y + put.h < fr.y + fr.h) {
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);
1649
+ if (py1 < fy1 && fr.w >= minW && fy1 - py1 >= minH) {
1650
+ next[n] = { x: fr.x, y: py1, w: fr.w, h: fy1 - py1 };
1651
+ side[n++] = BELOW_PUT;
1600
1652
  }
1601
1653
  }
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;
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
1654
+ // ⭐ The edge index (issue #1102): a piece's containers are looked for only
1605
1655
  // among the entries that share the edge it was cut along. A piece cut from
1606
1656
  // the left of `put` ends at `put.x` and keeps its parent's rows, which meet
1607
1657
  // `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
@@ -1617,45 +1667,165 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1, edgeIndex = true
1617
1667
  // `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
1618
1668
  // 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
1619
1669
  // 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++) {
1670
+ // that scan — every usable entry in `left` — kept so the selftest can hold
1671
+ // that the index finds what the scan finds (`PK94`) and for nothing else.
1672
+ const left = splitLeft;
1673
+ const right = splitRight;
1674
+ const above = splitAbove;
1675
+ const below = splitBelow;
1676
+ let nLeft = 0;
1677
+ let nRight = 0;
1678
+ let nAbove = 0;
1679
+ let nBelow = 0;
1680
+ for (let j = 0; j < n; j++) {
1625
1681
  const r = next[j];
1626
- if (!usable(r)) continue;
1682
+ if (r.w < minW || r.h < minH) continue;
1627
1683
  if (!edgeIndex) {
1628
- all.push(j);
1684
+ left[nLeft++] = j;
1629
1685
  continue;
1630
1686
  }
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);
1687
+ if (r.x + r.w === px0) left[nLeft++] = j;
1688
+ if (r.x === px1) right[nRight++] = j;
1689
+ if (r.y + r.h === py0) above[nAbove++] = j;
1690
+ if (r.y === py1) below[nBelow++] = j;
1635
1691
  }
1636
- free.length = 0;
1692
+ let w = 0;
1637
1693
  let tests = 0;
1638
- for (let i = 0; i < next.length; i++) {
1639
- if (!usable(next[i])) continue;
1640
- if (side[i] === UNTOUCHED) {
1641
- free.push(next[i]);
1694
+ for (let i = 0; i < n; i++) {
1695
+ const piece = next[i];
1696
+ if (piece.w < minW || piece.h < minH) continue;
1697
+ const cut = side[i];
1698
+ if (cut === UNTOUCHED) {
1699
+ free[w++] = piece;
1642
1700
  continue;
1643
1701
  }
1702
+ const list = !edgeIndex || cut === LEFT_OF_PUT ? left : cut === RIGHT_OF_PUT ? right : cut === ABOVE_PUT ? above : below;
1703
+ const count = !edgeIndex || cut === LEFT_OF_PUT ? nLeft : cut === RIGHT_OF_PUT ? nRight : cut === ABOVE_PUT ? nAbove : nBelow;
1644
1704
  let contained = false;
1645
- for (const j of edgeIndex ? scan[side[i]] : all) {
1705
+ for (let t = 0; t < count; t++) {
1706
+ const j = list[t];
1646
1707
  if (i === j) continue;
1647
1708
  tests++;
1648
- if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) {
1709
+ const other = next[j];
1710
+ // On a mutual containment (two identical rectangles) the later index
1711
+ // loses, so exactly one survives and it is always the same one.
1712
+ if (rectContains(other, piece) && (j < i || !rectContains(piece, other))) {
1649
1713
  contained = true;
1650
1714
  break;
1651
1715
  }
1652
1716
  }
1653
- if (!contained) free.push(next[i]);
1717
+ if (!contained) free[w++] = piece;
1654
1718
  }
1655
1719
  if (tally !== undefined) {
1656
1720
  tally.bandSplits++;
1657
1721
  tally.containmentTests += tests;
1722
+ tally.entriesRead += length;
1723
+ }
1724
+ return w;
1725
+ }
1726
+
1727
+ /** Whether `a` contains `b`. */
1728
+ function rectContains(a: Rect, b: Rect): boolean {
1729
+ return b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
1730
+ }
1731
+
1732
+ /**
1733
+ * `splitFree`'s working storage, kept between calls so a split allocates
1734
+ * nothing but the pieces it cuts (issue #1165). `splitFree` is never
1735
+ * re-entered and reads no entry past the `n` it wrote this call, so what an
1736
+ * earlier call left behind is never read.
1737
+ */
1738
+ const splitNext: Rect[] = [];
1739
+ let splitSide = new Int8Array(0);
1740
+ let splitLeft = new Int32Array(0);
1741
+ let splitRight = new Int32Array(0);
1742
+ let splitAbove = new Int32Array(0);
1743
+ let splitBelow = new Int32Array(0);
1744
+ /** `splitFreeByCell`'s near list, kept between calls for the same reason. */
1745
+ const splitNear: Rect[] = [];
1746
+
1747
+ /**
1748
+ * Every band of one placed cell split out of the first `length` entries of
1749
+ * `free` (`splitFree`, once per band, top to bottom); returns the new length.
1750
+ *
1751
+ * ⭐ **The list is split near the cell only** (issue #1165). Before a cell's
1752
+ * bands are split, the list is parted once into the entries that touch the
1753
+ * bands' bounding box — overlapping it or sharing an edge with it — and the
1754
+ * rest; every band is then split over the near part alone, and the far part is
1755
+ * written back first, the near part after it. Nothing the far part holds could
1756
+ * have been read:
1757
+ *
1758
+ * * a far entry does not overlap any band (every band lies inside the box),
1759
+ * so every split leaves it untouched — kept as it is, never tested;
1760
+ * * a far entry contains no piece any band cuts. A piece cut from the left of
1761
+ * a band ends at the band's left edge `x` and keeps a row `y` of the band
1762
+ * (its parent overlapped the band), so a container of it holds the texel
1763
+ * `(x − 1, y)` — inside the box or against its left edge, which an entry
1764
+ * that neither overlaps the box nor shares an edge with it cannot hold.
1765
+ * Likewise for the other three sides. And every piece is cut from a near
1766
+ * entry, so it is in the near part for the next band.
1767
+ *
1768
+ * So every split makes the decisions over the near part that it makes over the
1769
+ * whole list, and the far part comes through every split unchanged. ⚠️ What
1770
+ * changes is the ORDER of the list: the far entries move ahead of the near ones.
1771
+ * That decides nothing, because nothing that reads the list reads its order:
1772
+ *
1773
+ * * a split keeps every untouched entry, and keeps a piece unless another
1774
+ * usable entry contains it, the later index losing only between two equal
1775
+ * rectangles — and which of two equal rectangles survives is invisible,
1776
+ * they are the same rectangle — so the SET of rectangles after a split is
1777
+ * a function of the set before it;
1778
+ * * a candidate is chosen by a total order on its score, its free
1779
+ * rectangle's corner and its anchor (`packOnePageByFootprint`); two free
1780
+ * rectangles that tie on all of those put the cell at the same place.
1781
+ *
1782
+ * So the placements are the ones the whole-list split makes, decision for
1783
+ * decision. What it saves: on three production rigs the near part is 10 to 22 %
1784
+ * of the list (about 90 entries of 940 on the slowest), and each of a cell's
1785
+ * 50 or so bands read the whole list. `partition: false` splits every band over
1786
+ * the whole list in order, as before — kept so the selftest can plant the
1787
+ * work back (`PK106`) and hold that the pages do not move (`PK94`, through
1788
+ * `edgeIndex: false`, which splits the whole list too).
1789
+ */
1790
+ function splitFreeByCell(
1791
+ free: Rect[],
1792
+ length: number,
1793
+ cellX: number,
1794
+ cellY: number,
1795
+ rects: readonly Rect[],
1796
+ minW: number,
1797
+ minH: number,
1798
+ edgeIndex: boolean,
1799
+ partition: boolean,
1800
+ tally?: PassTally,
1801
+ ): number {
1802
+ if (!edgeIndex || !partition) {
1803
+ let n = length;
1804
+ for (const r of rects) n = splitFree(free, n, { x: cellX + r.x, y: cellY + r.y, w: r.w, h: r.h }, minW, minH, edgeIndex, tally);
1805
+ return n;
1806
+ }
1807
+ let x0 = Infinity;
1808
+ let y0 = Infinity;
1809
+ let x1 = -Infinity;
1810
+ let y1 = -Infinity;
1811
+ for (const r of rects) {
1812
+ x0 = Math.min(x0, cellX + r.x);
1813
+ y0 = Math.min(y0, cellY + r.y);
1814
+ x1 = Math.max(x1, cellX + r.x + r.w);
1815
+ y1 = Math.max(y1, cellY + r.y + r.h);
1658
1816
  }
1817
+ const near = splitNear;
1818
+ let nNear = 0;
1819
+ let nFar = 0;
1820
+ for (let k = 0; k < length; k++) {
1821
+ const fr = free[k];
1822
+ if (fr.x <= x1 && fr.x + fr.w >= x0 && fr.y <= y1 && fr.y + fr.h >= y0) near[nNear++] = fr;
1823
+ else free[nFar++] = fr;
1824
+ }
1825
+ if (tally !== undefined) tally.entriesRead += length;
1826
+ for (const r of rects) nNear = splitFree(near, nNear, { x: cellX + r.x, y: cellY + r.y, w: r.w, h: r.h }, minW, minH, true, tally);
1827
+ for (let k = 0; k < nNear; k++) free[nFar + k] = near[k];
1828
+ return nFar + nNear;
1659
1829
  }
1660
1830
 
1661
1831
  /** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
@@ -1738,8 +1908,11 @@ function packOnePageByFootprint(
1738
1908
  edgeIndex = true,
1739
1909
  tally?: PassTally,
1740
1910
  anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
1911
+ partition = true,
1741
1912
  ): Array<Rect | null> {
1913
+ // The free list is `free`'s first `freeLength` entries (`splitFree`).
1742
1914
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1915
+ let freeLength = 1;
1743
1916
  const placed: Array<Rect | null> = [];
1744
1917
  const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
1745
1918
  // The least box any cell of this pass needs — what a free rectangle must
@@ -1759,7 +1932,8 @@ function packOnePageByFootprint(
1759
1932
  let held = false;
1760
1933
  // The second anchor differs from the first only when the box is inset.
1761
1934
  const anchorCount = anchors !== 'box' && (box.x !== 0 || box.y !== 0) ? 2 : 1;
1762
- for (const fr of free) {
1935
+ for (let f = 0; f < freeLength; f++) {
1936
+ const fr = free[f];
1763
1937
  if (fr.w < box.w || fr.h < box.h) continue;
1764
1938
  held = true;
1765
1939
  let firstOffPage = false;
@@ -1834,7 +2008,7 @@ function packOnePageByFootprint(
1834
2008
  while (placed.length < shapes.length) placed.push(null);
1835
2009
  return placed;
1836
2010
  }
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);
2011
+ freeLength = splitFreeByCell(free, freeLength, put.x, put.y, shape.rects, minW, minH, edgeIndex, partition, tally);
1838
2012
  }
1839
2013
  return placed;
1840
2014
  }
@@ -2087,8 +2261,9 @@ function layoutPack(
2087
2261
  anchors: FootprintAnchors,
2088
2262
  tally?: PassTally,
2089
2263
  stopAtMiss = true,
2264
+ partition = true,
2090
2265
  ): PackLayout {
2091
- const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss);
2266
+ const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss, partition);
2092
2267
 
2093
2268
  /** page index -> the placements on it, in packing order. */
2094
2269
  const perPage: Placement[][] = [];
@@ -2126,7 +2301,7 @@ function layoutPack(
2126
2301
  maxEdge,
2127
2302
  maxEdge,
2128
2303
  Infinity,
2129
- { anchors, tally },
2304
+ { anchors, tally, partition },
2130
2305
  );
2131
2306
  const onPage: typeof remaining = [];
2132
2307
  const leftOver: typeof remaining = [];
@@ -2157,6 +2332,7 @@ function layoutPack(
2157
2332
  anchors,
2158
2333
  tally,
2159
2334
  stopAtMiss,
2335
+ partition,
2160
2336
  );
2161
2337
  if (shrunk === null) {
2162
2338
  // Unreachable for the same reason as the stall above: these cells were
@@ -2333,11 +2509,11 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
2333
2509
  // `pot` on two 2048x2048 pages against `rect`'s 2048x2048 + 1024x2048.
2334
2510
  // `footprintAnchors` names one rule alone instead (the instrument, the plant).
2335
2511
  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) });
2512
+ if (shapes === undefined) candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2513
+ else if (opts.footprintAnchors !== undefined) candidates.push({ name: opts.footprintAnchors, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, opts.footprintAnchors, opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2338
2514
  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) });
2515
+ candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2516
+ for (const rule of POLYGON_CANDIDATE_RULES) candidates.push({ name: rule, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, rule, opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2341
2517
  }
2342
2518
  const areaOf = (layout: PackLayout): number => layout.pageSizes.reduce((n, p) => n + p.width * p.height, 0);
2343
2519
  let chosen = candidates[0];
@@ -34,6 +34,7 @@ import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
34
34
  import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
35
35
  import { type CompileResult } from '../types.ts';
36
36
  import { attachmentRegionJoins } from '../region_joins.ts';
37
+ import { cmdRepack } from './repack.ts';
37
38
  import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
38
39
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
39
40
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
@@ -1299,6 +1300,7 @@ export function cmdIngest(flags: Record<string, string>, positional: string[]):
1299
1300
  if (documentStage !== undefined) {
1300
1301
  console.log(` .. stage ${documentStage === null ? 'none declared' : `${documentStage.width} x ${documentStage.height}`} — read from the ${MODEL_DOCUMENT_FILE} beside it (its spine.sha256 is this skeleton's); the header's box is the setup-pose bounding box`);
1301
1302
  }
1303
+ if (flags['stage-box'] !== undefined) console.log(` .. stage read from the bounding box in slot "${flags['stage-box']}" (--stage-box); the header's box is the setup-pose bounding box`);
1302
1304
  let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
1303
1305
  try {
1304
1306
  result = ingest(readJsonFile(skeletonPath), {
@@ -1307,6 +1309,7 @@ export function cmdIngest(flags: Record<string, string>, positional: string[]):
1307
1309
  art,
1308
1310
  images: specImages,
1309
1311
  stage,
1312
+ ...(flags['stage-box'] === undefined ? {} : { stageBox: flags['stage-box'] }),
1310
1313
  source: basename(skeletonPath),
1311
1314
  version: readVersion(),
1312
1315
  });
@@ -1552,8 +1555,11 @@ export function cmdSkills(flags: Record<string, string>, positional: string[]):
1552
1555
  * the pages where the compile measured them — the files the round trip's own
1553
1556
  * A17 reads on `cli.ts` — and A18 compares the documents as written, the
1554
1557
  * copies' names in them. Under `--pack` the two are one text.
1558
+ *
1559
+ * Exported for the selftest's `RPK` plants (issue #1169), which run `repack`
1560
+ * through this entry's dispatch with a hook between the lift and the build.
1555
1561
  */
1556
- const MODEL_AND_TEXT_GATE: BuildGate = {
1562
+ export const MODEL_AND_TEXT_GATE: BuildGate = {
1557
1563
  heading: (profile) =>
1558
1564
  ` .. validate (the model side over the document + the round trip's rules restated over the emitted text, profile ${profile}; this entry links no spine-core, so the round trip does not run)`,
1559
1565
  run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
@@ -1592,10 +1598,13 @@ const MODEL_AND_TEXT_GATE: BuildGate = {
1592
1598
  * The bodies the second entry runs under a name whose full-entry body is the
1593
1599
  * runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
1594
1600
  * `cli_core.ts` alone, beside `CORE_COMMAND_RUNS`. `build` is `runBuild` with
1595
- * this entry's gate: it writes what `cli.ts build` writes.
1601
+ * this entry's gate: it writes what `cli.ts build` writes. `repack` (issue
1602
+ * #1169) is `./repack.ts`'s body with the same gate, for the same reason: it
1603
+ * is `build --pack` run over a build's own lifted regions.
1596
1604
  */
1597
1605
  export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
1598
1606
  build: ({ flags }) => runBuild(flags, MODEL_AND_TEXT_GATE),
1607
+ repack: (args) => cmdRepack(args, MODEL_AND_TEXT_GATE),
1599
1608
  };
1600
1609
 
1601
1610
  /**