spine-rigc 2.10.1 → 2.11.1

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
@@ -554,7 +554,7 @@ commands take it and what its default is.
554
554
  | Command | Does |
555
555
  | --- | --- |
556
556
  | `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained, with `skeleton.images` pointing at it so the editor's import finds the parts |
557
- | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size`, `--padding`, `--page-edges` and `--pack-shape` tune it — `--pack-shape polygon` lets a neighbour sit inside a mesh's rectangle where its hull is not |
557
+ | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size`, `--padding`, `--page-edges` and `--pack-shape` tune it — `--pack-shape polygon` lets a neighbour sit inside a mesh's rectangle where its hull is not, and with `--page-edges free` costs 12–56 times `rect`'s pack (3–11 s against 0.2–0.3 s on 90–144 parts over two to four pages; past 5 s from about 60 parts, a second or so below 45 on one page — AUTHORING §0.1) |
558
558
  | `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
559
559
  | `validate <dir>` | re-gates artifacts already on disk |
560
560
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
package/docs/AUTHORING.md CHANGED
@@ -554,6 +554,20 @@ what changes is that two rectangles may overlap where neither footprint is.
554
554
  1107, which `PK93` holds within 25 times. The cost is the placing the
555
555
  second anchor makes possible: before #1102 the same search threw its passes
556
556
  away and took 260–304 s on the production set.
557
+ - **Where the cost starts** (issue #1165). Under `free` the page search runs
558
+ a footprint pass at every width below the page it settles on and throws each
559
+ away when a part misses, so the cost follows how full a page is more than how
560
+ many parts there are; `build --pack` packs twice (the second pack is
561
+ `A18`'s). On three production rigs of 90–144 parts over two to four pages the
562
+ free polygon pack alone takes 3.4–10.6 s against `rect`'s 0.17–0.33 s (12 to
563
+ 56 times; 10.7–41.8 s before #1165's free-list partition, the same pages),
564
+ and 0.4–0.6 s under `--page-edges pot`. On a generated set fitted to those
565
+ rigs (three seeds a size, 20 to 150 parts) it is past twice `rect` at every
566
+ size, 20 parts on one page included — `rect` there takes under 0.2 s and the
567
+ polygon pack 0.5–1 s — and its median passes 5 s at 60 parts (45 before),
568
+ worst 14.4 s at 75 parts over two pages. So below about 45 parts on one page
569
+ `polygon` with `free` costs a build a second or a few; past that, pick
570
+ `--page-edges pot` or `--pack-shape rect` where build time matters.
557
571
  - **The pixels.** Every cell is drawn whole in packing order, then every
558
572
  region's owned texels are drawn again with its own values, so every texel a
559
573
  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.10.1",
3
+ "version": "2.11.1",
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": {
@@ -10,6 +10,9 @@
10
10
  "./plate": "./tools/plate.ts",
11
11
  "./font5x7": "./tools/font5x7.ts",
12
12
  "./transform": "./src/transform.ts",
13
+ "./render": "./src/render.ts",
14
+ "./png": "./src/png.ts",
15
+ "./compile": "./src/compile.ts",
13
16
  "./cli": "./cli.ts",
14
17
  "./package.json": "./package.json",
15
18
  "./*.ts": "./*.ts",
package/src/atlas.ts CHANGED
@@ -803,6 +803,13 @@ export interface PackOptions {
803
803
  * the same either way (`PK94`); only the cost differs.
804
804
  */
805
805
  stopAtMiss?: boolean;
806
+ /**
807
+ * `false` splits every band of every cell a footprint pass places over the
808
+ * whole free list — issue #1165's partition off (`splitFreeByCell`), for the
809
+ * selftest's plant (`PK106`) only. The pages are the same either way; only
810
+ * the cost differs.
811
+ */
812
+ partition?: boolean;
806
813
  }
807
814
 
808
815
  /** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
@@ -884,15 +891,16 @@ function smallestPageFor(
884
891
  anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
885
892
  tally?: PassTally,
886
893
  stopAtMiss = true,
894
+ partition = true,
887
895
  ): { width: number; height: number; rects: Rect[] } | null {
888
- if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss);
896
+ if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss, partition);
889
897
  const edges: number[] = [];
890
898
  for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
891
899
  const candidates: Array<{ w: number; h: number }> = [];
892
900
  for (const w of edges) for (const h of edges) candidates.push({ w, h });
893
901
  candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
894
902
  for (const candidate of candidates) {
895
- const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally });
903
+ const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally, partition });
896
904
  if (attempt.some((r) => r === null)) continue;
897
905
  return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
898
906
  }
@@ -947,8 +955,9 @@ function smallestFreePageFor(
947
955
  anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
948
956
  tally?: PassTally,
949
957
  stopAtMiss = true,
958
+ partition = true,
950
959
  ): { width: number; height: number; rects: Rect[] } | null {
951
- const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss });
960
+ const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss, partition });
952
961
  if (tally !== undefined) for (const key of Object.keys(tally) as Array<keyof PassTally>) tally[key] += search.tally[key];
953
962
  return search.page;
954
963
  }
@@ -970,15 +979,19 @@ export interface FreePageSearch {
970
979
 
971
980
  /**
972
981
  * 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.
982
+ * (`PK94`, `PK95`, `PK106`). Each turns one saving off: `stopAtMiss: false`
983
+ * runs every footprint pass to its end and `edgeIndex: false` splits and
984
+ * prunes the whole free list by scanning all of it (issue #1102's two);
985
+ * `partition: false` splits every band over the whole list but keeps the edge
986
+ * index (issue #1165's). The page is the same either way; only what it costs
987
+ * differs, and the plants hold that.
977
988
  */
978
989
  export interface FreePageSearchOptions {
979
990
  stopAtMiss?: boolean;
980
- /** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
991
+ /** `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
992
  edgeIndex?: boolean;
993
+ /** `false` splits every band over the whole free list (`splitFreeByCell`), as before issue #1165 — the plant of `PK106`. */
994
+ partition?: boolean;
982
995
  /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`) — `tools/pack_anchor.ts`'s candidate rules; default `box`. */
983
996
  anchors?: FootprintAnchors;
984
997
  }
@@ -1023,7 +1036,7 @@ export function freePageSearch(
1023
1036
  continue;
1024
1037
  }
1025
1038
  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 });
1039
+ const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors, partition: options.partition ?? true });
1027
1040
  let height = 0;
1028
1041
  for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
1029
1042
  if (height > maxBottom) {
@@ -1231,13 +1244,13 @@ function placePass(
1231
1244
  // greedy pass that places one cell differently places every later one
1232
1245
  // differently too.
1233
1246
  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);
1247
+ let byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, anchors === 'best-of' ? 'box' : anchors, pass.partition ?? true);
1235
1248
  if (anchors === 'best-of') {
1236
1249
  // Issue #1104's fourth candidate: both searches on the same page, the
1237
1250
  // better kept and the first anchor's on a tie — so a pass is never worse
1238
1251
  // than the first anchor's on that page.
1239
1252
  if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1240
- const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell');
1253
+ const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell', pass.partition ?? true);
1241
1254
  const bottom = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1242
1255
  const placedBox = placedCount(byFootprint);
1243
1256
  const placedTwo = placedCount(byTwo);
@@ -1262,6 +1275,8 @@ interface PassOptions {
1262
1275
  edgeIndex?: boolean;
1263
1276
  /** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
1264
1277
  anchors?: FootprintAnchors;
1278
+ /** `splitFreeByCell`'s partition (default on); off only for the selftest's plant. */
1279
+ partition?: boolean;
1265
1280
  }
1266
1281
 
1267
1282
  /** How many cells a pass placed. */
@@ -1292,6 +1307,12 @@ export interface PassTally {
1292
1307
  bandSplits: number;
1293
1308
  /** Containment tests the footprint passes' prune made (`splitFree`). */
1294
1309
  containmentTests: number;
1310
+ /**
1311
+ * Free-list entries the footprint passes' splits read: the list's length at
1312
+ * every band split, and once per placed cell the whole list the partition
1313
+ * reads (`splitFreeByCell`) — the work issue #1165 measured, held by `PK106`.
1314
+ */
1315
+ entriesRead: number;
1295
1316
  /**
1296
1317
  * Free rectangles that held a cell's owned box and were refused as its
1297
1318
  * first-anchor candidate only because the cell, anchored there, would leave
@@ -1307,7 +1328,7 @@ export interface PassTally {
1307
1328
 
1308
1329
  /** A tally with nothing counted. */
1309
1330
  export function emptyTally(): PassTally {
1310
- return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
1331
+ return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, entriesRead: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
1311
1332
  }
1312
1333
 
1313
1334
  // ---------------------------------------------------------------------------
@@ -1551,57 +1572,81 @@ function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: numb
1551
1572
 
1552
1573
  /**
1553
1574
  * 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.
1575
+ * `packOnePage`'s, over the first `length` entries of `free`, written back in
1576
+ * place; returns the new length. Four savings, none of which changes a
1577
+ * rectangle or the list's order:
1578
+ *
1579
+ * * a free rectangle the placement did not touch is never tested for
1580
+ * containment. Before the split no free rectangle lay inside another (the
1581
+ * prune's own postcondition), and every new piece lies inside the
1582
+ * rectangle it was cut from, so an untouched rectangle inside a new piece
1583
+ * would have lain inside that one — the test `packOnePage` makes for it
1584
+ * always answers "not contained";
1585
+ * * ⭐ (issue #1099, measured on production rigs) a free rectangle narrower
1586
+ * than `minW` or shorter than `minH` is dropped. The caller passes the
1587
+ * least bounding box of every cell the pass places, so such a rectangle can
1588
+ * never be a candidate, and neither can any piece later cut from it (a
1589
+ * piece lies inside the rectangle it was cut from); and a rectangle a
1590
+ * dropped one contains is itself too small. So every rectangle that could
1591
+ * ever be a candidate is kept, in the order the full prune keeps it. What
1592
+ * it removes is the staircase of slivers an irregular hull's bands cut
1593
+ * along its edge: on a production-shaped set (30 regions, 200 to 900 px,
1594
+ * hulls near their rectangles, a `free` page about 2023x2046) the pack took
1595
+ * 18.5 s with the slivers kept and spent 95 % of it in this prune. A piece
1596
+ * that would be dropped is not made at all (issue #1165): it takes part in
1597
+ * no test and is not written back, so it changes no other entry's index
1598
+ * order either;
1599
+ * * the edge index (issue #1102), below;
1600
+ * * (issue #1165) the rebuilt list, each entry's side and the edge lists are
1601
+ * kept between calls (`splitNext` and its neighbours) and the list is
1602
+ * written back over itself by index, never emptied and refilled: on the
1603
+ * production rigs the emptying and the pushes that refilled it were most
1604
+ * of this function's time, and none of it was arithmetic.
1573
1605
  */
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);
1606
+ function splitFree(free: Rect[], length: number, put: Rect, minW: number, minH: number, edgeIndex: boolean, tally?: PassTally): number {
1607
+ const px0 = put.x;
1608
+ const py0 = put.y;
1609
+ const px1 = put.x + put.w;
1610
+ const py1 = put.y + put.h;
1611
+ // A touched entry yields at most four pieces.
1612
+ if (splitSide.length < length * 4) {
1613
+ const capacity = Math.max(64, length * 8);
1614
+ splitSide = new Int8Array(capacity);
1615
+ splitLeft = new Int32Array(capacity);
1616
+ splitRight = new Int32Array(capacity);
1617
+ splitAbove = new Int32Array(capacity);
1618
+ splitBelow = new Int32Array(capacity);
1619
+ }
1620
+ const next = splitNext;
1621
+ const side = splitSide;
1622
+ let n = 0;
1623
+ for (let k = 0; k < length; k++) {
1624
+ const fr = free[k];
1625
+ const fx1 = fr.x + fr.w;
1626
+ const fy1 = fr.y + fr.h;
1627
+ if (!(px0 < fx1 && px1 > fr.x && py0 < fy1 && py1 > fr.y)) {
1628
+ next[n] = fr;
1629
+ side[n++] = UNTOUCHED;
1583
1630
  continue;
1584
1631
  }
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);
1632
+ if (px0 > fr.x && px0 - fr.x >= minW && fr.h >= minH) {
1633
+ next[n] = { x: fr.x, y: fr.y, w: px0 - fr.x, h: fr.h };
1634
+ side[n++] = LEFT_OF_PUT;
1588
1635
  }
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);
1636
+ if (px1 < fx1 && fx1 - px1 >= minW && fr.h >= minH) {
1637
+ next[n] = { x: px1, y: fr.y, w: fx1 - px1, h: fr.h };
1638
+ side[n++] = RIGHT_OF_PUT;
1592
1639
  }
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);
1640
+ if (py0 > fr.y && fr.w >= minW && py0 - fr.y >= minH) {
1641
+ next[n] = { x: fr.x, y: fr.y, w: fr.w, h: py0 - fr.y };
1642
+ side[n++] = ABOVE_PUT;
1596
1643
  }
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);
1644
+ if (py1 < fy1 && fr.w >= minW && fy1 - py1 >= minH) {
1645
+ next[n] = { x: fr.x, y: py1, w: fr.w, h: fy1 - py1 };
1646
+ side[n++] = BELOW_PUT;
1600
1647
  }
1601
1648
  }
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
1649
+ // ⭐ The edge index (issue #1102): a piece's containers are looked for only
1605
1650
  // among the entries that share the edge it was cut along. A piece cut from
1606
1651
  // the left of `put` ends at `put.x` and keeps its parent's rows, which meet
1607
1652
  // `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
@@ -1617,45 +1662,165 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1, edgeIndex = true
1617
1662
  // `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
1618
1663
  // 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
1619
1664
  // 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++) {
1665
+ // that scan — every usable entry in `left` — kept so the selftest can hold
1666
+ // that the index finds what the scan finds (`PK94`) and for nothing else.
1667
+ const left = splitLeft;
1668
+ const right = splitRight;
1669
+ const above = splitAbove;
1670
+ const below = splitBelow;
1671
+ let nLeft = 0;
1672
+ let nRight = 0;
1673
+ let nAbove = 0;
1674
+ let nBelow = 0;
1675
+ for (let j = 0; j < n; j++) {
1625
1676
  const r = next[j];
1626
- if (!usable(r)) continue;
1677
+ if (r.w < minW || r.h < minH) continue;
1627
1678
  if (!edgeIndex) {
1628
- all.push(j);
1679
+ left[nLeft++] = j;
1629
1680
  continue;
1630
1681
  }
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);
1682
+ if (r.x + r.w === px0) left[nLeft++] = j;
1683
+ if (r.x === px1) right[nRight++] = j;
1684
+ if (r.y + r.h === py0) above[nAbove++] = j;
1685
+ if (r.y === py1) below[nBelow++] = j;
1635
1686
  }
1636
- free.length = 0;
1687
+ let w = 0;
1637
1688
  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]);
1689
+ for (let i = 0; i < n; i++) {
1690
+ const piece = next[i];
1691
+ if (piece.w < minW || piece.h < minH) continue;
1692
+ const cut = side[i];
1693
+ if (cut === UNTOUCHED) {
1694
+ free[w++] = piece;
1642
1695
  continue;
1643
1696
  }
1697
+ const list = !edgeIndex || cut === LEFT_OF_PUT ? left : cut === RIGHT_OF_PUT ? right : cut === ABOVE_PUT ? above : below;
1698
+ const count = !edgeIndex || cut === LEFT_OF_PUT ? nLeft : cut === RIGHT_OF_PUT ? nRight : cut === ABOVE_PUT ? nAbove : nBelow;
1644
1699
  let contained = false;
1645
- for (const j of edgeIndex ? scan[side[i]] : all) {
1700
+ for (let t = 0; t < count; t++) {
1701
+ const j = list[t];
1646
1702
  if (i === j) continue;
1647
1703
  tests++;
1648
- if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) {
1704
+ const other = next[j];
1705
+ // On a mutual containment (two identical rectangles) the later index
1706
+ // loses, so exactly one survives and it is always the same one.
1707
+ if (rectContains(other, piece) && (j < i || !rectContains(piece, other))) {
1649
1708
  contained = true;
1650
1709
  break;
1651
1710
  }
1652
1711
  }
1653
- if (!contained) free.push(next[i]);
1712
+ if (!contained) free[w++] = piece;
1654
1713
  }
1655
1714
  if (tally !== undefined) {
1656
1715
  tally.bandSplits++;
1657
1716
  tally.containmentTests += tests;
1717
+ tally.entriesRead += length;
1658
1718
  }
1719
+ return w;
1720
+ }
1721
+
1722
+ /** Whether `a` contains `b`. */
1723
+ function rectContains(a: Rect, b: Rect): boolean {
1724
+ 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;
1725
+ }
1726
+
1727
+ /**
1728
+ * `splitFree`'s working storage, kept between calls so a split allocates
1729
+ * nothing but the pieces it cuts (issue #1165). `splitFree` is never
1730
+ * re-entered and reads no entry past the `n` it wrote this call, so what an
1731
+ * earlier call left behind is never read.
1732
+ */
1733
+ const splitNext: Rect[] = [];
1734
+ let splitSide = new Int8Array(0);
1735
+ let splitLeft = new Int32Array(0);
1736
+ let splitRight = new Int32Array(0);
1737
+ let splitAbove = new Int32Array(0);
1738
+ let splitBelow = new Int32Array(0);
1739
+ /** `splitFreeByCell`'s near list, kept between calls for the same reason. */
1740
+ const splitNear: Rect[] = [];
1741
+
1742
+ /**
1743
+ * Every band of one placed cell split out of the first `length` entries of
1744
+ * `free` (`splitFree`, once per band, top to bottom); returns the new length.
1745
+ *
1746
+ * ⭐ **The list is split near the cell only** (issue #1165). Before a cell's
1747
+ * bands are split, the list is parted once into the entries that touch the
1748
+ * bands' bounding box — overlapping it or sharing an edge with it — and the
1749
+ * rest; every band is then split over the near part alone, and the far part is
1750
+ * written back first, the near part after it. Nothing the far part holds could
1751
+ * have been read:
1752
+ *
1753
+ * * a far entry does not overlap any band (every band lies inside the box),
1754
+ * so every split leaves it untouched — kept as it is, never tested;
1755
+ * * a far entry contains no piece any band cuts. A piece cut from the left of
1756
+ * a band ends at the band's left edge `x` and keeps a row `y` of the band
1757
+ * (its parent overlapped the band), so a container of it holds the texel
1758
+ * `(x − 1, y)` — inside the box or against its left edge, which an entry
1759
+ * that neither overlaps the box nor shares an edge with it cannot hold.
1760
+ * Likewise for the other three sides. And every piece is cut from a near
1761
+ * entry, so it is in the near part for the next band.
1762
+ *
1763
+ * So every split makes the decisions over the near part that it makes over the
1764
+ * whole list, and the far part comes through every split unchanged. ⚠️ What
1765
+ * changes is the ORDER of the list: the far entries move ahead of the near ones.
1766
+ * That decides nothing, because nothing that reads the list reads its order:
1767
+ *
1768
+ * * a split keeps every untouched entry, and keeps a piece unless another
1769
+ * usable entry contains it, the later index losing only between two equal
1770
+ * rectangles — and which of two equal rectangles survives is invisible,
1771
+ * they are the same rectangle — so the SET of rectangles after a split is
1772
+ * a function of the set before it;
1773
+ * * a candidate is chosen by a total order on its score, its free
1774
+ * rectangle's corner and its anchor (`packOnePageByFootprint`); two free
1775
+ * rectangles that tie on all of those put the cell at the same place.
1776
+ *
1777
+ * So the placements are the ones the whole-list split makes, decision for
1778
+ * decision. What it saves: on three production rigs the near part is 10 to 22 %
1779
+ * of the list (about 90 entries of 940 on the slowest), and each of a cell's
1780
+ * 50 or so bands read the whole list. `partition: false` splits every band over
1781
+ * the whole list in order, as before — kept so the selftest can plant the
1782
+ * work back (`PK106`) and hold that the pages do not move (`PK94`, through
1783
+ * `edgeIndex: false`, which splits the whole list too).
1784
+ */
1785
+ function splitFreeByCell(
1786
+ free: Rect[],
1787
+ length: number,
1788
+ cellX: number,
1789
+ cellY: number,
1790
+ rects: readonly Rect[],
1791
+ minW: number,
1792
+ minH: number,
1793
+ edgeIndex: boolean,
1794
+ partition: boolean,
1795
+ tally?: PassTally,
1796
+ ): number {
1797
+ if (!edgeIndex || !partition) {
1798
+ let n = length;
1799
+ 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);
1800
+ return n;
1801
+ }
1802
+ let x0 = Infinity;
1803
+ let y0 = Infinity;
1804
+ let x1 = -Infinity;
1805
+ let y1 = -Infinity;
1806
+ for (const r of rects) {
1807
+ x0 = Math.min(x0, cellX + r.x);
1808
+ y0 = Math.min(y0, cellY + r.y);
1809
+ x1 = Math.max(x1, cellX + r.x + r.w);
1810
+ y1 = Math.max(y1, cellY + r.y + r.h);
1811
+ }
1812
+ const near = splitNear;
1813
+ let nNear = 0;
1814
+ let nFar = 0;
1815
+ for (let k = 0; k < length; k++) {
1816
+ const fr = free[k];
1817
+ if (fr.x <= x1 && fr.x + fr.w >= x0 && fr.y <= y1 && fr.y + fr.h >= y0) near[nNear++] = fr;
1818
+ else free[nFar++] = fr;
1819
+ }
1820
+ if (tally !== undefined) tally.entriesRead += length;
1821
+ 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);
1822
+ for (let k = 0; k < nNear; k++) free[nFar + k] = near[k];
1823
+ return nFar + nNear;
1659
1824
  }
1660
1825
 
1661
1826
  /** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
@@ -1738,8 +1903,11 @@ function packOnePageByFootprint(
1738
1903
  edgeIndex = true,
1739
1904
  tally?: PassTally,
1740
1905
  anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
1906
+ partition = true,
1741
1907
  ): Array<Rect | null> {
1908
+ // The free list is `free`'s first `freeLength` entries (`splitFree`).
1742
1909
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1910
+ let freeLength = 1;
1743
1911
  const placed: Array<Rect | null> = [];
1744
1912
  const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
1745
1913
  // The least box any cell of this pass needs — what a free rectangle must
@@ -1759,7 +1927,8 @@ function packOnePageByFootprint(
1759
1927
  let held = false;
1760
1928
  // The second anchor differs from the first only when the box is inset.
1761
1929
  const anchorCount = anchors !== 'box' && (box.x !== 0 || box.y !== 0) ? 2 : 1;
1762
- for (const fr of free) {
1930
+ for (let f = 0; f < freeLength; f++) {
1931
+ const fr = free[f];
1763
1932
  if (fr.w < box.w || fr.h < box.h) continue;
1764
1933
  held = true;
1765
1934
  let firstOffPage = false;
@@ -1834,7 +2003,7 @@ function packOnePageByFootprint(
1834
2003
  while (placed.length < shapes.length) placed.push(null);
1835
2004
  return placed;
1836
2005
  }
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);
2006
+ freeLength = splitFreeByCell(free, freeLength, put.x, put.y, shape.rects, minW, minH, edgeIndex, partition, tally);
1838
2007
  }
1839
2008
  return placed;
1840
2009
  }
@@ -2087,8 +2256,9 @@ function layoutPack(
2087
2256
  anchors: FootprintAnchors,
2088
2257
  tally?: PassTally,
2089
2258
  stopAtMiss = true,
2259
+ partition = true,
2090
2260
  ): PackLayout {
2091
- const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss);
2261
+ const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss, partition);
2092
2262
 
2093
2263
  /** page index -> the placements on it, in packing order. */
2094
2264
  const perPage: Placement[][] = [];
@@ -2126,7 +2296,7 @@ function layoutPack(
2126
2296
  maxEdge,
2127
2297
  maxEdge,
2128
2298
  Infinity,
2129
- { anchors, tally },
2299
+ { anchors, tally, partition },
2130
2300
  );
2131
2301
  const onPage: typeof remaining = [];
2132
2302
  const leftOver: typeof remaining = [];
@@ -2157,6 +2327,7 @@ function layoutPack(
2157
2327
  anchors,
2158
2328
  tally,
2159
2329
  stopAtMiss,
2330
+ partition,
2160
2331
  );
2161
2332
  if (shrunk === null) {
2162
2333
  // Unreachable for the same reason as the stall above: these cells were
@@ -2333,11 +2504,11 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
2333
2504
  // `pot` on two 2048x2048 pages against `rect`'s 2048x2048 + 1024x2048.
2334
2505
  // `footprintAnchors` names one rule alone instead (the instrument, the plant).
2335
2506
  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) });
2507
+ if (shapes === undefined) candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2508
+ 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
2509
  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) });
2510
+ candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true, opts.partition ?? true) });
2511
+ 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
2512
  }
2342
2513
  const areaOf = (layout: PackLayout): number => layout.pageSizes.reduce((n, p) => n + p.width * p.height, 0);
2343
2514
  let chosen = candidates[0];