spine-rigc 1.3.0 โ†’ 1.5.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
@@ -518,6 +518,43 @@ export interface EmitPage {
518
518
  * ([`src/validate.ts`](validate.ts)) and `artUvsOf`
519
519
  * ([`src/render.ts`](render.ts)), which returns no art UVs for a turned region
520
520
  * attachment, so `check`'s texture substitution would report it unmatched.
521
+ *
522
+ * ๐Ÿ“ **Measured again for issue #866, on twelve region sets, and held back again.**
523
+ * The sets are the two painting rigs above and ten production rigs of 20 to 22
524
+ * parts. Each was packed by this file's own search at padding 2 twice: as
525
+ * shipped, and with every cell also tried turned under the same BSSF score
526
+ * (ties go to the unturned cell). The shipped search reproduced the page every
527
+ * one of the twelve atlases was written at. Area change from turning:
528
+ *
529
+ * | set | `pot` | `pot` + turn | `free` | `free` + turn |
530
+ * | --- | --- | --- | --- | --- |
531
+ * | 22-part painting | 1024x2048 | 0.00 % | 1888x697 | โˆ’1.14 % |
532
+ * | 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
533
+ * | P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
534
+ * | P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
535
+ * | P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
536
+ * | P4 | 1024x2048 | 0.00 % | 864x1346 | โˆ’6.16 % |
537
+ * | P5 | 2048x2048 | 0.00 % | 1344x1768 | โˆ’13.37 % |
538
+ * | P6 | 1024x2048 | 0.00 % | 800x1934 | โˆ’1.62 % |
539
+ * | P7 | 512x2048 | 0.00 % | 1024x928 | โˆ’0.05 % |
540
+ * | P8 | 512x2048 | 0.00 % | 608x1250 | โˆ’4.96 % |
541
+ * | P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
542
+ * | P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
543
+ *
544
+ * Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
545
+ * it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
546
+ * noise: the nearest other width on the 32-px grid the search then used moves
547
+ * P5's unturned page by 18.13 %, and searching every width instead of every
548
+ * 32nd finds an unturned P5 page 10.22 % smaller with nothing turned. With every
549
+ * width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
550
+ * (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
551
+ * packer still never turns a region.
552
+ *
553
+ * โš ๏ธ The `free` column and the 18.13 % are measurements of the search as it was
554
+ * on that day, a 32-px width grid. Issue #872 replaced it with every width (see
555
+ * `FREE_EDGE_STEP`), so today's `free` pages for these sets are the every-width
556
+ * ones the paragraph above compares against, and the 5.05 % is the turn gain
557
+ * measured on the search that ships.
521
558
  */
522
559
  export const PACK_NO_ROTATE = 0;
523
560
 
@@ -597,7 +634,7 @@ export const DEFAULT_PADDING = 2;
597
634
  * What a packed page's edges may be โ€” `--page-edges`. See `packAtlas`, *Page size*.
598
635
  *
599
636
  * `pot` is the default and the only value until issue #860: both edges are powers
600
- * of two. `free` lets the width be any multiple of `FREE_EDGE_STEP` and the height
637
+ * of two. `free` lets the width be any whole number of pixels and the height
601
638
  * whatever the placement needs. What `free` costs is measured rather than
602
639
  * conceded: a region attachment's sampling coordinate stops being exact and moves
603
640
  * to `PK05`'s bound of one least significant bit, the bound a mesh already had.
@@ -605,8 +642,28 @@ export const DEFAULT_PADDING = 2;
605
642
  export const PAGE_EDGES = ['pot', 'free'] as const;
606
643
  export type PageEdges = (typeof PAGE_EDGES)[number];
607
644
  export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
608
- /** The step a `free` page's width is tried on. See `packAtlas`, *Page size*. */
609
- export const FREE_EDGE_STEP = 32;
645
+ /**
646
+ * The step a `free` page's width is tried on: **1, every width** (issue #872).
647
+ * See `smallestFreePageFor` for the search and `packAtlas`, *Page size*, for
648
+ * what it buys.
649
+ *
650
+ * It was 32 until #872, and the grid was the cost. On twelve region sets (two
651
+ * painting rigs and ten production rigs of 20 to 22 parts) the 32-px grid's page
652
+ * was larger than the every-width page on eleven, by up to 11.38 % (P5: 1344x1768
653
+ * against 1427x1495). Finer fixed steps and coarse-to-fine searches were measured
654
+ * against the every-width page too, and none of them is safe: the area as a
655
+ * function of the width has minima one pixel wide, so a step-4 grid refined
656
+ * over ยฑ3 px of its best four widths still misses one (the fetched
657
+ * `5-squash-and-stretch` atlas: 867x415 against 863x415), and the coarse-to-fine
658
+ * search that was exact on all twelve sets missed by 0.82 % on the first
659
+ * held-out atlas it met. Every width is exact by construction; what keeps it
660
+ * cheap is the two bounds `smallestFreePageFor` prunes with, and they are exact
661
+ * too.
662
+ *
663
+ * Kept as a named constant rather than deleted because it is importable, and a
664
+ * reader of the old value should find the new one rather than a missing name.
665
+ */
666
+ export const FREE_EDGE_STEP = 1;
610
667
 
611
668
  /** The part a page is packed from: its region name and its pixels. */
612
669
  export interface PackInput {
@@ -720,9 +777,8 @@ function smallestPageFor(
720
777
  *
721
778
  * The rule, which `docs/AUTHORING.md` ยง0.1 states in the same words:
722
779
  *
723
- * * the candidate WIDTHS are the multiples of `FREE_EDGE_STEP` from the widest
724
- * cell up to `maxEdge`, plus `maxEdge` itself (it is a power of two, so this
725
- * only adds anything when it is smaller than the step);
780
+ * * the candidate WIDTHS are every whole width from the widest cell up to
781
+ * `maxEdge` (`FREE_EDGE_STEP` is 1);
726
782
  * * at each width the cells are placed by the same MaxRects pass as a `pot`
727
783
  * page, on a page `maxEdge` tall, and the HEIGHT is the bottom edge of the
728
784
  * lowest cell โ€” what that placement needs, not rounded;
@@ -735,26 +791,84 @@ function smallestPageFor(
735
791
  * in the page height (a shorter page changes which free rectangle scores best),
736
792
  * so "the least height that fits" is not a quantity a bisection could find, and a
737
793
  * definition that could not be computed exactly would not be deterministic.
794
+ *
795
+ * โญ **Two bounds make every width cheap, and neither can change the answer**
796
+ * (issue #872). Both compare against the best page found so far, and both are
797
+ * strict, so a width that could still TIE the best โ€” and win on the squarer or
798
+ * narrower key โ€” is always placed to the end:
799
+ *
800
+ * * a width is not placed at all when the cells' own area exceeds
801
+ * `width x maxEdge` (it cannot fit), or when `width x` the tallest cell
802
+ * already exceeds the best area (no page at that width is shorter than its
803
+ * tallest cell);
804
+ * * a placement is abandoned the moment its lowest cell's bottom edge makes
805
+ * `width x bottom` exceed the best area. The bottom edge only grows as cells
806
+ * are added, so the page that placement would have finished is larger still.
807
+ *
808
+ * Neither changes a placement that runs to the end โ€” the pass is the `pot` pass,
809
+ * on the same page, and abandoning it only stops it early โ€” so the winner is the
810
+ * every-width winner, and `PK75` compares the two on every set it builds. What
811
+ * they save, measured on the twelve sets: 391,854 cell placements for an
812
+ * unbounded every-width search, 47,978 bounded, against 12,361 for the retired
813
+ * 32-px grid.
738
814
  */
739
815
  function smallestFreePageFor(
740
816
  cells: Array<{ w: number; h: number }>,
741
817
  maxEdge: number,
742
818
  ): { width: number; height: number; rects: Rect[] } | null {
819
+ return freePageSearch(cells, maxEdge).page;
820
+ }
821
+
822
+ /** What one `free` page search did: the page it chose, and what each width cost. */
823
+ export interface FreePageSearch {
824
+ page: { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null;
825
+ /** Candidate widths, from the widest cell to `maxEdge`. */
826
+ widths: number;
827
+ /** Widths whose placement ran to the end. */
828
+ completed: number;
829
+ /** Widths whose placement stopped once its lowest cell made the page larger than the best so far. */
830
+ abandoned: number;
831
+ /** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
832
+ skipped: number;
833
+ }
834
+
835
+ /**
836
+ * `smallestFreePageFor`'s search with its bookkeeping โ€” exported so the
837
+ * selftest can compare its page against an unbounded every-width reference and
838
+ * see that the bounds did prune (`PK75`). The page is the whole of what the
839
+ * packer uses; the counts are for the control.
840
+ */
841
+ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number): FreePageSearch {
743
842
  let widest = 0;
744
- for (const cell of cells) widest = Math.max(widest, cell.w);
745
- const widths: number[] = [];
746
- for (let w = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; w <= maxEdge; w += FREE_EDGE_STEP) widths.push(w);
747
- if (widths[widths.length - 1] !== maxEdge && widest <= maxEdge) widths.push(maxEdge);
843
+ let tallest = 0;
844
+ let cellArea = 0;
845
+ for (const cell of cells) {
846
+ widest = Math.max(widest, cell.w);
847
+ tallest = Math.max(tallest, cell.h);
848
+ cellArea += cell.w * cell.h;
849
+ }
850
+ const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
748
851
  let best: { width: number; height: number; rects: Rect[] } | null = null;
749
- for (const width of widths) {
750
- const attempt = packOnePage(cells, width, maxEdge);
852
+ for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
853
+ out.widths++;
854
+ const bestArea = best === null ? Infinity : best.width * best.height;
855
+ if (cellArea > width * maxEdge || width * tallest > bestArea) {
856
+ out.skipped++;
857
+ continue;
858
+ }
859
+ const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
860
+ const attempt = packOnePage(cells, width, maxEdge, maxBottom);
861
+ let height = 0;
862
+ for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
863
+ if (height > maxBottom) {
864
+ out.abandoned++;
865
+ continue;
866
+ }
867
+ out.completed++;
751
868
  if (attempt.some((r) => r === null)) continue;
752
869
  const rects = attempt as Rect[];
753
- let height = 0;
754
- for (const r of rects) height = Math.max(height, r.y + r.h);
755
870
  if (best !== null) {
756
871
  const area = width * height;
757
- const bestArea = best.width * best.height;
758
872
  if (area > bestArea) continue;
759
873
  if (area === bestArea) {
760
874
  const square = Math.abs(width - height);
@@ -765,7 +879,28 @@ function smallestFreePageFor(
765
879
  }
766
880
  best = { width, height, rects };
767
881
  }
768
- return best;
882
+ out.page = best;
883
+ return out;
884
+ }
885
+
886
+ /**
887
+ * One `free` candidate, unbounded: the cells placed at `width` on a page
888
+ * `maxEdge` tall, and the height that placement needs โ€” or `null` when they do
889
+ * not all fit. It is the pass `freePageSearch` runs at each width with no
890
+ * bound, exported so the selftest can rebuild a reference search from the same
891
+ * placement (`PK74`'s 32-px grid, `PK75`'s every width, `PK76`'s `pot` search).
892
+ */
893
+ export function placeAtWidth(
894
+ cells: Array<{ w: number; h: number }>,
895
+ width: number,
896
+ height: number,
897
+ ): { width: number; height: number; rects: Array<{ x: number; y: number; w: number; h: number }> } | null {
898
+ const attempt = packOnePage(cells, width, height);
899
+ if (attempt.some((r) => r === null)) return null;
900
+ const rects = attempt as Rect[];
901
+ let bottom = 0;
902
+ for (const r of rects) bottom = Math.max(bottom, r.y + r.h);
903
+ return { width, height: bottom, rects };
769
904
  }
770
905
 
771
906
  /**
@@ -782,8 +917,18 @@ function smallestFreePageFor(
782
917
  * to be pushed. So the tie-break is spelled out and total โ€” smallest long-side
783
918
  * leftover, then topmost, then leftmost โ€” and the caller sorts its input before
784
919
  * calling. Same inputs, byte-identical page.
920
+ *
921
+ * `maxBottom` is the `free` search's abandon bound (see `freePageSearch`): once a
922
+ * placed cell's bottom edge passes it the pass stops, and the cells not placed
923
+ * are `null`. The default, `Infinity`, never stops a pass โ€” which is every `pot`
924
+ * call, so a `pot` page is placed exactly as it was before the bound existed.
785
925
  */
786
- function packOnePage(cells: Array<{ w: number; h: number }>, pageW: number, pageH: number): Array<Rect | null> {
926
+ function packOnePage(
927
+ cells: Array<{ w: number; h: number }>,
928
+ pageW: number,
929
+ pageH: number,
930
+ maxBottom = Infinity,
931
+ ): Array<Rect | null> {
787
932
  const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
788
933
  const placed: Array<Rect | null> = [];
789
934
 
@@ -817,6 +962,10 @@ function packOnePage(cells: Array<{ w: number; h: number }>, pageW: number, page
817
962
  }
818
963
  const put: Rect = { x: best.x, y: best.y, w: cell.w, h: cell.h };
819
964
  placed.push(put);
965
+ if (put.y + put.h > maxBottom) {
966
+ while (placed.length < cells.length) placed.push(null);
967
+ return placed;
968
+ }
820
969
 
821
970
  // Split every free rectangle the placement overlaps, then prune.
822
971
  const next: Rect[] = [];
@@ -950,15 +1099,18 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
950
1099
  * Opt-in, and the default stays `pot`: every runtime accepts a power-of-two page
951
1100
  * and the editor's own packer writes one by default โ€” 9 of the 10 atlases in the
952
1101
  * fetched `examples/` corpus are power-of-two on both edges. Under `free` the
953
- * search is `smallestFreePageFor`: widths on a `FREE_EDGE_STEP` grid, the height
954
- * the placement needs, least area first, then squarer, then narrower. Everything
1102
+ * search is `smallestFreePageFor`: every width from the widest cell up (issue
1103
+ * #872; a 32-px grid until then), the height the placement needs, least area
1104
+ * first, then squarer, then narrower, with two exact bounds that keep it cheap. Everything
955
1105
  * else is shared โ€” the MaxRects pass, the packing order, the spill rule (which
956
1106
  * parts share a page is still decided at `maxEdge x maxEdge`) and `--page-size`
957
1107
  * as the ceiling on both edges, floored to a power of two exactly as under `pot`.
958
1108
  *
959
1109
  * What it buys and what it costs, observed on two 20- and 22-part painting rigs:
960
- * the page went from 1024x2048 to 1888x697 (2,097,152 to 1,315,936 texels,
961
- * -37.3 %) and from 512x2048 to 480x1166 (1,048,576 to 559,680, -46.6 %). The cost is the exactness the paragraph above describes:
1110
+ * the page goes from 1024x2048 to 967x1338 (2,097,152 to 1,293,846 texels,
1111
+ * -38.3 %) and from 512x2048 to 479x1166 (1,048,576 to 558,514, -46.7 %). Under
1112
+ * the 32-px grid #860 shipped with they were 1888x697 (-37.3 %) and 480x1166
1113
+ * (-46.6 %). The cost is the exactness the paragraph above describes:
962
1114
  * `x / pageWidth` is no longer exact, so a REGION attachment's sampling
963
1115
  * coordinate joins a mesh's at `PK05`'s bound of one least significant bit
964
1116
  * against the loose build. It does not go past it, and no region's bytes change
package/src/check.ts CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  PAD,
81
81
  FRAMES_SIDECAR,
82
82
  FRAMES_SPEC,
83
+ nonFinitePoseOf,
83
84
  SHEET_COLUMNS,
84
85
  SHEET_FILE,
85
86
  SHEET_GAP,
@@ -1225,6 +1226,27 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1225
1226
  // to compare it โ€” and posing twice is both slower and a chance for the framing
1226
1227
  // and the comparison to disagree about what they measured.
1227
1228
  const prepared = sets.map((set) => prepareSet(located.root, set, posable, options.as, poseOptions));
1229
+ // ๐Ÿ”’ A pose that is not finite is refused before anything measures it (issue
1230
+ // #873), in the words `render` and the geometry export use for it. Measured
1231
+ // on a planted bone at x=1e309 before this: an overflowing root read "posed no
1232
+ // drawable attachment", one at -1e309 "drew no pixel", and one at +1e309 or a
1233
+ // rotation at 1e309 exited 0 with MAE 1.00 โ€” the part missing from every
1234
+ // frame, and nothing said why.
1235
+ const posed = prepared.filter((p) => p.missing === null);
1236
+ const overflow = posed.some((p) =>
1237
+ p.frames.some((frame) => frame.pieces.some((piece) => piece.world.some((value) => !Number.isFinite(value)))),
1238
+ );
1239
+ if (overflow) {
1240
+ const found = nonFinitePoseOf(
1241
+ posable.data,
1242
+ options.skin,
1243
+ posed.map((p) => ({ animation: p.candidateAnimation, fps: p.set.fps })),
1244
+ );
1245
+ if (found === null) {
1246
+ throw new Error('a drawn piece of the candidate is not finite and no bone or vertex of its pose is โ€” the two were posed differently');
1247
+ }
1248
+ throw new CheckError(`the candidate is posed to a number that is not finite, so no frame of it can be compared: ${found}`);
1249
+ }
1228
1250
  const pairs = prepared.flatMap((p) => p.pairs);
1229
1251
  if (pairs.length === 0) {
1230
1252
  notes.push('no reference frame has a candidate frame at the same index โ€” nothing below was measured');