spine-rigc 2.1.0 → 2.1.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/docs/AUTHORING.md CHANGED
@@ -524,10 +524,19 @@ what changes is that two rectangles may overlap where neither footprint is.
524
524
  are unchanged, except that the `free` search's area bound sums the texels
525
525
  the cells own rather than the cells, since overlapping cells can cover more
526
526
  than the page (a spilled `polygon` page was refused as fitting no page until
527
- it did — `PK86`). What it costs in time is measured on a set shaped like a
528
- production rig (30 regions of 200 to 900 px, hulls near their rectangles,
529
- one `free` page about 2023x2046): 1.1–1.4 s against `rect`'s 0.2 s for the
530
- pack alone, the same page.
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.
531
540
  - **The pixels.** Every cell is drawn whole in packing order, then every
532
541
  region's owned texels are drawn again with its own values, so every texel a
533
542
  region can sample is its own (`PK79`). A texel nobody owns carries the last
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.1.0",
3
+ "version": "2.1.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": {
package/src/atlas.ts CHANGED
@@ -834,7 +834,7 @@ function smallestPageFor(
834
834
  for (const w of edges) for (const h of edges) candidates.push({ w, h });
835
835
  candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
836
836
  for (const candidate of candidates) {
837
- const attempt = placePass(cells, shapes, candidate.w, candidate.h);
837
+ const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: true });
838
838
  if (attempt.some((r) => r === null)) continue;
839
839
  return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
840
840
  }
@@ -901,6 +901,21 @@ export interface FreePageSearch {
901
901
  abandoned: number;
902
902
  /** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
903
903
  skipped: number;
904
+ /** What the passes placed, by kind (`PassTally`). */
905
+ tally: PassTally;
906
+ }
907
+
908
+ /**
909
+ * How `freePageSearch` runs its passes — for the selftest's plants only
910
+ * (`PK94`, `PK95`). Each turns one of issue #1102's savings off: `stopAtMiss:
911
+ * false` runs every footprint pass to its end, `edgeIndex: false` prunes the
912
+ * free list by scanning all of it. The page is the same either way; only what
913
+ * it costs differs, and the plants hold that.
914
+ */
915
+ export interface FreePageSearchOptions {
916
+ stopAtMiss?: boolean;
917
+ /** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
918
+ edgeIndex?: boolean;
904
919
  }
905
920
 
906
921
  /**
@@ -910,7 +925,13 @@ export interface FreePageSearch {
910
925
  * packer uses; the counts are for the control. `shapes`, one per cell, is
911
926
  * `shape: 'polygon'`'s placement (`placePass`); absent, the rectangle pass.
912
927
  */
913
- export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number, shapes?: readonly CellShape[]): FreePageSearch {
928
+ export function freePageSearch(
929
+ cells: Array<{ w: number; h: number }>,
930
+ maxEdge: number,
931
+ shapes?: readonly CellShape[],
932
+ options: FreePageSearchOptions = {},
933
+ ): FreePageSearch {
934
+ const wholeOrNothing = options.stopAtMiss ?? true;
914
935
  let widest = 0;
915
936
  let tallest = 0;
916
937
  let cellArea = 0;
@@ -927,7 +948,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
927
948
  cellArea = 0;
928
949
  for (const shape of shapes) cellArea += shape.owned;
929
950
  }
930
- const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
951
+ const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0 } };
931
952
  let best: { width: number; height: number; rects: Rect[] } | null = null;
932
953
  for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
933
954
  out.widths++;
@@ -937,7 +958,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
937
958
  continue;
938
959
  }
939
960
  const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
940
- const attempt = placePass(cells, shapes, width, maxEdge, maxBottom);
961
+ const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true });
941
962
  let height = 0;
942
963
  for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
943
964
  if (height > maxBottom) {
@@ -1094,9 +1115,46 @@ function placePass(
1094
1115
  pageW: number,
1095
1116
  pageH: number,
1096
1117
  maxBottom = Infinity,
1118
+ pass: PassOptions = {},
1097
1119
  ): Array<Rect | null> {
1098
- const byRect = packOnePage(cells, pageW, pageH, maxBottom);
1099
- if (shapes === undefined || shapes.every((s) => s.whole)) return byRect;
1120
+ const wholeOrNothing = pass.wholeOrNothing ?? false;
1121
+ const tally = pass.tally;
1122
+ if (shapes === undefined || shapes.every((s) => s.whole)) {
1123
+ const byRect = packOnePage(cells, pageW, pageH, maxBottom);
1124
+ if (tally !== undefined) tally.rectPlaced += placedCount(byRect);
1125
+ return byRect;
1126
+ }
1127
+ // ⭐ `wholeOrNothing` is the page searches' question (issue #1102): they keep
1128
+ // a pass only when it placed every cell, so a pass that has missed one is
1129
+ // already discarded, whatever it does next. Two savings follow, and neither
1130
+ // can change a page the search keeps:
1131
+ //
1132
+ // * the rectangle pass is not run when the cells' own area exceeds the
1133
+ // page — disjoint rectangles cannot all fit, so it could not place every
1134
+ // cell, and the pass it would have lost or won against is discarded either
1135
+ // way (a footprint pass that placed every cell beats a rectangle pass that
1136
+ // did not, on the first key);
1137
+ // * the footprint pass stops at its first miss (`packOnePageByFootprint`'s
1138
+ // `stopAtMiss`), returning the cells after it as not placed.
1139
+ //
1140
+ // What is returned can then differ from the full answer only in which of two
1141
+ // failed passes it is, or in how many cells a failed pass placed — and the
1142
+ // searches read neither. The spill's assignment pass, which keeps the cells
1143
+ // a pass did place, never asks this (`packAtlas`).
1144
+ //
1145
+ // Measured on a replica of a production rig (31 meshes, a two-page `free`
1146
+ // spill at 2048, padding 2), where the pack took 260–304 s against `rect`'s
1147
+ // 0.14–0.24 s: the single-page search ran a full footprint pass at every one of its
1148
+ // 854 widths (54 % of the time), because nothing fits and so no best page
1149
+ // ever bounds a width, and the first page's own search ran one at every width
1150
+ // it did not abandon (46 %). The largest cell's owned box does not start at
1151
+ // its cell's corner, so on an empty page that cell is every footprint pass's
1152
+ // 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
1154
+ // same pages.
1155
+ const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
1156
+ const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
1157
+ if (tally !== undefined && byRect !== null) tally.rectPlaced += placedCount(byRect);
1100
1158
  // ⭐ `polygon` never costs a page anything `rect` would have saved. Both passes
1101
1159
  // are run on the same page and the better one is kept: more cells placed,
1102
1160
  // then the higher bottom edge, then — on a tie — the rectangle pass. Two
@@ -1107,15 +1165,56 @@ function placePass(
1107
1165
  // than `rect` on two of the five gallery rigs that carry a mesh, because a
1108
1166
  // greedy pass that places one cell differently places every later one
1109
1167
  // differently too.
1110
- const byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom);
1111
- const placedOf = (pass: Array<Rect | null>): number => pass.reduce((n, r) => (r === null ? n : n + 1), 0);
1112
- const bottomOf = (pass: Array<Rect | null>): number => pass.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1113
- const placedRect = placedOf(byRect);
1114
- const placedFoot = placedOf(byFootprint);
1168
+ const byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally);
1169
+ if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
1170
+ if (byRect === null) return byFootprint;
1171
+ const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
1172
+ const placedRect = placedCount(byRect);
1173
+ const placedFoot = placedCount(byFootprint);
1115
1174
  if (placedFoot !== placedRect) return placedFoot > placedRect ? byFootprint : byRect;
1116
1175
  return bottomOf(byFootprint) < bottomOf(byRect) ? byFootprint : byRect;
1117
1176
  }
1118
1177
 
1178
+ /** How `placePass` runs — what the page searches ask of it (issue #1102). */
1179
+ interface PassOptions {
1180
+ /** Only a pass that places every cell is kept by the caller. See `placePass`. */
1181
+ wholeOrNothing?: boolean;
1182
+ /** Where to count what the passes placed. */
1183
+ tally?: PassTally;
1184
+ /** `splitFree`'s edge index (default on); off only for the selftest's plant. */
1185
+ edgeIndex?: boolean;
1186
+ }
1187
+
1188
+ /** How many cells a pass placed. */
1189
+ function placedCount(pass: ReadonlyArray<Rect | null>): number {
1190
+ let n = 0;
1191
+ for (const r of pass) if (r !== null) n++;
1192
+ return n;
1193
+ }
1194
+
1195
+ /** The cells' own area — what disjoint rectangles need of a page at least. */
1196
+ function cellAreaOf(cells: ReadonlyArray<{ w: number; h: number }>): number {
1197
+ let area = 0;
1198
+ for (const cell of cells) area += cell.w * cell.h;
1199
+ return area;
1200
+ }
1201
+
1202
+ /**
1203
+ * What a page search's passes placed, by kind — the operation count a search's
1204
+ * cost is held by (`PK93`), since a footprint placement splits the free list
1205
+ * once per band of what the cell owns and a rectangle placement once.
1206
+ */
1207
+ export interface PassTally {
1208
+ /** Cells placed by rectangle passes (`packOnePage`). */
1209
+ rectPlaced: number;
1210
+ /** Cells placed by footprint passes (`packOnePageByFootprint`). */
1211
+ footprintPlaced: number;
1212
+ /** Free-list splits the footprint passes made — one per band of every cell they placed (`splitFree`). */
1213
+ bandSplits: number;
1214
+ /** Containment tests the footprint passes' prune made (`splitFree`). */
1215
+ containmentTests: number;
1216
+ }
1217
+
1119
1218
  // ---------------------------------------------------------------------------
1120
1219
  // footprints — `shape: 'polygon'` (issue #1099)
1121
1220
  // ---------------------------------------------------------------------------
@@ -1377,44 +1476,100 @@ function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: numb
1377
1476
  * px, hulls near their rectangles, a `free` page about 2023x2046) the pack
1378
1477
  * took 18.5 s with the slivers kept and spent 95 % of it in this prune.
1379
1478
  */
1380
- function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
1479
+ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1, edgeIndex = true, tally?: PassTally): void {
1381
1480
  const next: Rect[] = [];
1382
- const untouched: boolean[] = [];
1481
+ /** Per entry of `next`: `UNTOUCHED`, or which side of `put` the piece was cut from. */
1482
+ const side: number[] = [];
1383
1483
  for (const fr of free) {
1384
1484
  const overlaps = put.x < fr.x + fr.w && put.x + put.w > fr.x && put.y < fr.y + fr.h && put.y + put.h > fr.y;
1385
1485
  if (!overlaps) {
1386
1486
  next.push(fr);
1387
- untouched.push(true);
1487
+ side.push(UNTOUCHED);
1388
1488
  continue;
1389
1489
  }
1390
- if (put.x > fr.x) next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
1490
+ if (put.x > fr.x) {
1491
+ next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
1492
+ side.push(LEFT_OF_PUT);
1493
+ }
1391
1494
  if (put.x + put.w < fr.x + fr.w) {
1392
1495
  next.push({ x: put.x + put.w, y: fr.y, w: fr.x + fr.w - (put.x + put.w), h: fr.h });
1496
+ side.push(RIGHT_OF_PUT);
1497
+ }
1498
+ if (put.y > fr.y) {
1499
+ next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
1500
+ side.push(ABOVE_PUT);
1393
1501
  }
1394
- if (put.y > fr.y) next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
1395
1502
  if (put.y + put.h < fr.y + fr.h) {
1396
1503
  next.push({ x: fr.x, y: put.y + put.h, w: fr.w, h: fr.y + fr.h - (put.y + put.h) });
1504
+ side.push(BELOW_PUT);
1397
1505
  }
1398
- while (untouched.length < next.length) untouched.push(false);
1399
1506
  }
1400
1507
  const contains = (a: Rect, b: Rect): boolean => b.x >= a.x && b.y >= a.y && b.x + b.w <= a.x + a.w && b.y + b.h <= a.y + a.h;
1401
1508
  const usable = (r: Rect): boolean => r.w >= minW && r.h >= minH;
1509
+ // ⭐ The third saving (issue #1102): a piece's containers are looked for only
1510
+ // among the entries that share the edge it was cut along. A piece cut from
1511
+ // the left of `put` ends at `put.x` and keeps its parent's rows, which meet
1512
+ // `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
1513
+ // those rows from the piece's left edge to at least `put.x`; if it reached
1514
+ // past `put.x` it would overlap `put` — and no entry of `next` does (an
1515
+ // untouched rectangle by definition, a piece by construction). So every
1516
+ // container of a left piece ENDS at `put.x`; likewise a right piece's starts
1517
+ // at `put.x + put.w`, an upper piece's ends at `put.y` and a lower piece's
1518
+ // starts at `put.y + put.h`. The test is the full prune's test, asked of the
1519
+ // only entries that can answer it yes, so `contained` is the same boolean and
1520
+ // the list the same list in the same order. Measured where the footprint
1521
+ // pass does work (`fixtures/polypack_shapes.ts`, seed 1105, the searches a
1522
+ // `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
1523
+ // 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
1524
+ // 11,928,721,590 → 537,215,014 and 101 s → 14.5 s. `edgeIndex: false` is
1525
+ // that scan, kept so the selftest can hold that the index finds what the
1526
+ // scan finds (`PK94`) and for nothing else.
1527
+ const scan: number[][] = [[], [], [], []];
1528
+ const all: number[] = [];
1529
+ for (let j = 0; j < next.length; j++) {
1530
+ const r = next[j];
1531
+ if (!usable(r)) continue;
1532
+ if (!edgeIndex) {
1533
+ all.push(j);
1534
+ continue;
1535
+ }
1536
+ if (r.x + r.w === put.x) scan[LEFT_OF_PUT].push(j);
1537
+ if (r.x === put.x + put.w) scan[RIGHT_OF_PUT].push(j);
1538
+ if (r.y + r.h === put.y) scan[ABOVE_PUT].push(j);
1539
+ if (r.y === put.y + put.h) scan[BELOW_PUT].push(j);
1540
+ }
1402
1541
  free.length = 0;
1542
+ let tests = 0;
1403
1543
  for (let i = 0; i < next.length; i++) {
1404
1544
  if (!usable(next[i])) continue;
1405
- if (untouched[i]) {
1545
+ if (side[i] === UNTOUCHED) {
1406
1546
  free.push(next[i]);
1407
1547
  continue;
1408
1548
  }
1409
1549
  let contained = false;
1410
- for (let j = 0; j < next.length && !contained; j++) {
1411
- if (i === j || !usable(next[j])) continue;
1412
- if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) contained = true;
1550
+ for (const j of edgeIndex ? scan[side[i]] : all) {
1551
+ if (i === j) continue;
1552
+ tests++;
1553
+ if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) {
1554
+ contained = true;
1555
+ break;
1556
+ }
1413
1557
  }
1414
1558
  if (!contained) free.push(next[i]);
1415
1559
  }
1560
+ if (tally !== undefined) {
1561
+ tally.bandSplits++;
1562
+ tally.containmentTests += tests;
1563
+ }
1416
1564
  }
1417
1565
 
1566
+ /** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
1567
+ const LEFT_OF_PUT = 0;
1568
+ const RIGHT_OF_PUT = 1;
1569
+ const ABOVE_PUT = 2;
1570
+ const BELOW_PUT = 3;
1571
+ const UNTOUCHED = -1;
1572
+
1418
1573
  /**
1419
1574
  * `packOnePage` with the free-space test replaced by the footprint test
1420
1575
  * (issue #1099) — the placement `shape: 'polygon'` makes.
@@ -1455,7 +1610,15 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
1455
1610
  * over the cells in the caller's order, each over a finite free list, every
1456
1611
  * choice made by a total order, and no input but the cells and the page.
1457
1612
  */
1458
- function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pageH: number, maxBottom = Infinity): Array<Rect | null> {
1613
+ function packOnePageByFootprint(
1614
+ shapes: readonly CellShape[],
1615
+ pageW: number,
1616
+ pageH: number,
1617
+ maxBottom = Infinity,
1618
+ stopAtMiss = false,
1619
+ edgeIndex = true,
1620
+ tally?: PassTally,
1621
+ ): Array<Rect | null> {
1459
1622
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
1460
1623
  const placed: Array<Rect | null> = [];
1461
1624
  const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
@@ -1498,6 +1661,12 @@ function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pag
1498
1661
  }
1499
1662
  if (best === null) {
1500
1663
  placed.push(null);
1664
+ // A caller that keeps only a pass placing every cell has its answer
1665
+ // (`placePass`, `wholeOrNothing`): the rest are not placed.
1666
+ if (stopAtMiss) {
1667
+ while (placed.length < shapes.length) placed.push(null);
1668
+ return placed;
1669
+ }
1501
1670
  continue;
1502
1671
  }
1503
1672
  const put: Rect = { x: best.x - box.x, y: best.y - box.y, w: shape.width, h: shape.height };
@@ -1521,7 +1690,7 @@ function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pag
1521
1690
  while (placed.length < shapes.length) placed.push(null);
1522
1691
  return placed;
1523
1692
  }
1524
- for (const r of shape.rects) splitFree(free, { x: put.x + r.x, y: put.y + r.y, w: r.w, h: r.h }, minW, minH);
1693
+ for (const r of shape.rects) splitFree(free, { x: put.x + r.x, y: put.y + r.y, w: r.w, h: r.h }, minW, minH, edgeIndex, tally);
1525
1694
  }
1526
1695
  return placed;
1527
1696
  }