spine-rigc 2.1.0 → 2.1.2
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 +2 -1
- package/docs/AUTHORING.md +37 -11
- package/package.json +1 -1
- package/src/atlas.ts +535 -138
- package/src/cli/shared.ts +56 -4
- package/tools/editor_roundtrip.ts +113 -1
package/README.md
CHANGED
|
@@ -698,7 +698,8 @@ what it found — the executable, the bundle, the `CFBundleName` that bundle
|
|
|
698
698
|
declares, or the banner the binary prints about itself — rather than starting it
|
|
699
699
|
and failing downstream. Both refusals point at `--exported <file>`, which
|
|
700
700
|
measures an export the editor already made and is the half of this tool that
|
|
701
|
-
needs no editor at all.
|
|
701
|
+
needs no editor at all. A build whose `skeleton.json` is missing, is not JSON, or
|
|
702
|
+
holds JSON that is not one object is refused by name before the editor starts.
|
|
702
703
|
|
|
703
704
|
⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
|
|
704
705
|
editor, so no automated check runs it.
|
package/docs/AUTHORING.md
CHANGED
|
@@ -145,6 +145,10 @@ thing: with `@esotericsoftware/spine-core` installed beside the package, `rigc`
|
|
|
145
145
|
`bun cli.ts` and runs every command; without it, `rigc` runs `cli_core.ts` — the
|
|
146
146
|
entry that links none of spine-core, `bun cli_core.ts` in a clone — which refuses by
|
|
147
147
|
name the commands that need the runtime (its `--help` lists the ones it runs).
|
|
148
|
+
`validate` is one of them, and pointed at a rigc build — a directory with
|
|
149
|
+
`skeleton.model.json` beside the pair — its refusal says that build already ran the
|
|
150
|
+
gate before it wrote, and which rules that gate is on this entry, before the install
|
|
151
|
+
route; on a Spine export it says what it always said (`RC36`).
|
|
148
152
|
`rigc --version` names the entry that ran.
|
|
149
153
|
|
|
150
154
|
That entry's `build` takes the same flags and writes the same files, byte for byte:
|
|
@@ -517,17 +521,39 @@ what changes is that two rectangles may overlap where neither footprint is.
|
|
|
517
521
|
touch.
|
|
518
522
|
- **The placement** is the same MaxRects search, ordering and tie-breaks, with
|
|
519
523
|
the free list split by what each region owns instead of by its cell, and a
|
|
520
|
-
candidate is
|
|
521
|
-
also run as the `rect` pass on the same page and the better kept —
|
|
522
|
-
placed, then the higher bottom edge, then `rect` on a tie
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
524
|
+
candidate is a free rectangle that holds the box of what the cell owns. Every
|
|
525
|
+
pass is also run as the `rect` pass on the same page and the better kept —
|
|
526
|
+
more parts placed, then the higher bottom edge, then `rect` on a tie.
|
|
527
|
+
`--page-edges` and the spill rule are unchanged, except that the `free`
|
|
528
|
+
search's area bound sums the texels the cells own rather than the cells,
|
|
529
|
+
since overlapping cells can cover more than the page (a spilled `polygon`
|
|
530
|
+
page was refused as fitting no page until it did — `PK86`).
|
|
531
|
+
- **Where a cell may go in a free rectangle** (issue #1104). A candidate puts
|
|
532
|
+
the box of what the cell owns at the free rectangle's top-left corner, the
|
|
533
|
+
cell reaching up and left of it — so a cell whose drawn part is inset from
|
|
534
|
+
its corner (a large region whose mesh draws a small part of it) could never
|
|
535
|
+
go on an empty page, and every pass lost to the `rect` pass. A second
|
|
536
|
+
candidate puts the cell's own corner there instead, wherever the first would
|
|
537
|
+
leave the page. Both keep the cell on the page; the atlas format does not
|
|
538
|
+
change.
|
|
539
|
+
- **The pack written is the least total page area of three whole packs** —
|
|
540
|
+
the `rect` pack, the footprint pack with the first anchor alone, and with
|
|
541
|
+
both — summed over every page, a spill's included, the earlier on a tie. So a
|
|
542
|
+
`polygon` pack is never larger in page area than the `rect` pack on any set,
|
|
543
|
+
by construction (before #1104 a spill could make it so: a generated set under
|
|
544
|
+
`pot` wrote 8,388,608 texels against `rect`'s 6,291,456). No single placement
|
|
545
|
+
rule gives that: the second anchor alone packs one test set 7.2 % larger than
|
|
546
|
+
the first. On a production rig's region set (31 meshes, a two-page `free`
|
|
547
|
+
spill under `rect`) it takes 4,367,445 texels to 2,585,264 (−40.8 %); on a
|
|
548
|
+
generated set of that shape (`fixtures/polypack_shapes.ts`, seed 1107)
|
|
549
|
+
4,433,292 over two pages to 2,037,204 on one; under `pot` both go to one
|
|
550
|
+
2048x2048 page. **What it costs**, for the pack alone (`--page-edges free`,
|
|
551
|
+
padding 2, page size 2048): the three packs' searches, none shared — on seed
|
|
552
|
+
1107 1.09 s against `rect`'s 0.06 s, on seed 1105 1.29 s against 0.07 s,
|
|
553
|
+
under `pot` 0.17–0.21 s; counted, 13.6 times `rect`'s free-list splits on seed
|
|
554
|
+
1107, which `PK93` holds within 25 times. The cost is the placing the
|
|
555
|
+
second anchor makes possible: before #1102 the same search threw its passes
|
|
556
|
+
away and took 260–304 s on the production set.
|
|
531
557
|
- **The pixels.** Every cell is drawn whole in packing order, then every
|
|
532
558
|
region's owned texels are drawn again with its own values, so every texel a
|
|
533
559
|
region can sample is its own (`PK79`). A texel nobody owns carries the last
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/atlas.ts
CHANGED
|
@@ -713,6 +713,44 @@ export const PACK_SHAPES = ['rect', 'polygon'] as const;
|
|
|
713
713
|
export type PackShape = (typeof PACK_SHAPES)[number];
|
|
714
714
|
export const DEFAULT_PACK_SHAPE: PackShape = 'rect';
|
|
715
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Where a `polygon` candidate may put a cell against the free rectangle it is
|
|
718
|
+
* placed in (issue #1104) — `packOnePageByFootprint`, *Candidates*.
|
|
719
|
+
*
|
|
720
|
+
* `box` puts the cell's owned box on the free rectangle's corner — the only
|
|
721
|
+
* rule before #1104. The other three may also put the cell's own corner there
|
|
722
|
+
* (`packOnePageByFootprint`, *Candidates*):
|
|
723
|
+
*
|
|
724
|
+
* * `box-or-cell` — on every free rectangle;
|
|
725
|
+
* * `box-else-cell` — only on a free rectangle where the owned box's anchor
|
|
726
|
+
* would put the cell off the page;
|
|
727
|
+
* * `best-of` — the footprint pass run with `box` and with `box-or-cell` on
|
|
728
|
+
* the same page and the better kept (more cells, then the higher bottom
|
|
729
|
+
* edge, then `box`), as `placePass` keeps the better of `rect` and a
|
|
730
|
+
* footprint pass.
|
|
731
|
+
*
|
|
732
|
+
* No single rule is never-larger than `box` — `box-or-cell` packs `PK78`'s
|
|
733
|
+
* wedge set 7.2 % larger — so `polygon` does not pick one: it packs the whole
|
|
734
|
+
* set as `rect`, under `box` and under `box-else-cell` and keeps the least Σ
|
|
735
|
+
* page area (`POLYGON_CANDIDATE_RULES`, `packAtlas`). `box-else-cell` is the
|
|
736
|
+
* third candidate on measurement: over 96 packs (seeds 1100–1124, the tree's
|
|
737
|
+
* 21 sets, the wedge and spill sets, `pot` and `free`) the choice with it sums
|
|
738
|
+
* to 212,607,001 texels, with `best-of` 214,407,755 and with `box-or-cell` the
|
|
739
|
+
* same. `best-of` is never worse than `box` on one page, but against
|
|
740
|
+
* `box-else-cell` it gives the smaller pack on 12 of the 27 sets where the two
|
|
741
|
+
* differ and the larger on 15 — neither dominates, and the sum decides.
|
|
742
|
+
*
|
|
743
|
+
* The CLI never sets one rule; `PackOptions.footprintAnchors` is the instrument's and the plant's.
|
|
744
|
+
*/
|
|
745
|
+
export const FOOTPRINT_ANCHORS = ['box', 'box-or-cell', 'box-else-cell', 'best-of'] as const;
|
|
746
|
+
export type FootprintAnchors = (typeof FOOTPRINT_ANCHORS)[number];
|
|
747
|
+
/** The rule a footprint search uses when it is given none — `freePageSearch`'s and `footprintPass`'s callers outside `packAtlas`. */
|
|
748
|
+
export const DEFAULT_FOOTPRINT_ANCHORS: FootprintAnchors = 'box';
|
|
749
|
+
/** The footprint rules `polygon` packs under besides `rect`, in the order a tie goes to (`packAtlas`). */
|
|
750
|
+
export const POLYGON_CANDIDATE_RULES: readonly FootprintAnchors[] = ['box', 'box-else-cell'];
|
|
751
|
+
/** Which whole pack a `polygon` pack kept: `rect`'s, or one footprint rule's. */
|
|
752
|
+
export type PackCandidate = 'rect' | FootprintAnchors;
|
|
753
|
+
|
|
716
754
|
/**
|
|
717
755
|
* The part of a region its attachments draw, in the region's own texels — x
|
|
718
756
|
* right and y down from the drawing's top-left corner, so `(u · width, v ·
|
|
@@ -750,6 +788,21 @@ export interface PackOptions {
|
|
|
750
788
|
pageEdges?: PageEdges;
|
|
751
789
|
/** What two rectangles may share (default `rect`). See `PACK_SHAPES`. */
|
|
752
790
|
shape?: PackShape;
|
|
791
|
+
/**
|
|
792
|
+
* One footprint rule alone instead of `polygon`'s choice between whole packs
|
|
793
|
+
* (`FOOTPRINT_ANCHORS`) — for `tools/pack_anchor.ts` and the selftest's plant
|
|
794
|
+
* (`PK97`); the CLI never sets it. Absent, `polygon` keeps the least Σ page
|
|
795
|
+
* area of `rect` and `POLYGON_CANDIDATE_RULES`.
|
|
796
|
+
*/
|
|
797
|
+
footprintAnchors?: FootprintAnchors;
|
|
798
|
+
/** Where to count what every search the pack runs placed (`PassTally`) — the controls' and the instrument's cost reading. */
|
|
799
|
+
tally?: PassTally;
|
|
800
|
+
/**
|
|
801
|
+
* `false` runs every footprint pass of every search to its end — issue
|
|
802
|
+
* #1102's early stop off, for the selftest's plant (`PK95`) only. The pages are
|
|
803
|
+
* the same either way (`PK94`); only the cost differs.
|
|
804
|
+
*/
|
|
805
|
+
stopAtMiss?: boolean;
|
|
753
806
|
}
|
|
754
807
|
|
|
755
808
|
/** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
|
|
@@ -782,6 +835,8 @@ export interface PackResult {
|
|
|
782
835
|
padding: number;
|
|
783
836
|
/** The shape the pack was made under — what the `pack:` line's last field states. */
|
|
784
837
|
shape: PackShape;
|
|
838
|
+
/** The whole pack that was kept: `rect` under `shape: 'rect'`, and under `polygon` the candidate of least Σ page area (`packAtlas`). */
|
|
839
|
+
candidate: PackCandidate;
|
|
785
840
|
}
|
|
786
841
|
|
|
787
842
|
/** A free rectangle in the MaxRects free list. */
|
|
@@ -826,15 +881,18 @@ function smallestPageFor(
|
|
|
826
881
|
maxEdge: number,
|
|
827
882
|
pageEdges: PageEdges,
|
|
828
883
|
shapes?: readonly CellShape[],
|
|
884
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
885
|
+
tally?: PassTally,
|
|
886
|
+
stopAtMiss = true,
|
|
829
887
|
): { width: number; height: number; rects: Rect[] } | null {
|
|
830
|
-
if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes);
|
|
888
|
+
if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes, anchors, tally, stopAtMiss);
|
|
831
889
|
const edges: number[] = [];
|
|
832
890
|
for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
|
|
833
891
|
const candidates: Array<{ w: number; h: number }> = [];
|
|
834
892
|
for (const w of edges) for (const h of edges) candidates.push({ w, h });
|
|
835
893
|
candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
|
|
836
894
|
for (const candidate of candidates) {
|
|
837
|
-
const attempt = placePass(cells, shapes, candidate.w, candidate.h);
|
|
895
|
+
const attempt = placePass(cells, shapes, candidate.w, candidate.h, Infinity, { wholeOrNothing: stopAtMiss, anchors, tally });
|
|
838
896
|
if (attempt.some((r) => r === null)) continue;
|
|
839
897
|
return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
|
|
840
898
|
}
|
|
@@ -886,8 +944,13 @@ function smallestFreePageFor(
|
|
|
886
944
|
cells: Array<{ w: number; h: number }>,
|
|
887
945
|
maxEdge: number,
|
|
888
946
|
shapes?: readonly CellShape[],
|
|
947
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
948
|
+
tally?: PassTally,
|
|
949
|
+
stopAtMiss = true,
|
|
889
950
|
): { width: number; height: number; rects: Rect[] } | null {
|
|
890
|
-
|
|
951
|
+
const search = freePageSearch(cells, maxEdge, shapes, { anchors, stopAtMiss });
|
|
952
|
+
if (tally !== undefined) for (const key of Object.keys(tally) as Array<keyof PassTally>) tally[key] += search.tally[key];
|
|
953
|
+
return search.page;
|
|
891
954
|
}
|
|
892
955
|
|
|
893
956
|
/** What one `free` page search did: the page it chose, and what each width cost. */
|
|
@@ -901,6 +964,23 @@ export interface FreePageSearch {
|
|
|
901
964
|
abandoned: number;
|
|
902
965
|
/** Widths never placed: too narrow for the cells' area, or wide enough that the tallest cell alone loses. */
|
|
903
966
|
skipped: number;
|
|
967
|
+
/** What the passes placed, by kind (`PassTally`). */
|
|
968
|
+
tally: PassTally;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* 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.
|
|
977
|
+
*/
|
|
978
|
+
export interface FreePageSearchOptions {
|
|
979
|
+
stopAtMiss?: boolean;
|
|
980
|
+
/** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
|
|
981
|
+
edgeIndex?: boolean;
|
|
982
|
+
/** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`) — `tools/pack_anchor.ts`'s candidate rules; default `box`. */
|
|
983
|
+
anchors?: FootprintAnchors;
|
|
904
984
|
}
|
|
905
985
|
|
|
906
986
|
/**
|
|
@@ -910,7 +990,13 @@ export interface FreePageSearch {
|
|
|
910
990
|
* packer uses; the counts are for the control. `shapes`, one per cell, is
|
|
911
991
|
* `shape: 'polygon'`'s placement (`placePass`); absent, the rectangle pass.
|
|
912
992
|
*/
|
|
913
|
-
export function freePageSearch(
|
|
993
|
+
export function freePageSearch(
|
|
994
|
+
cells: Array<{ w: number; h: number }>,
|
|
995
|
+
maxEdge: number,
|
|
996
|
+
shapes?: readonly CellShape[],
|
|
997
|
+
options: FreePageSearchOptions = {},
|
|
998
|
+
): FreePageSearch {
|
|
999
|
+
const wholeOrNothing = options.stopAtMiss ?? true;
|
|
914
1000
|
let widest = 0;
|
|
915
1001
|
let tallest = 0;
|
|
916
1002
|
let cellArea = 0;
|
|
@@ -927,7 +1013,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
|
|
|
927
1013
|
cellArea = 0;
|
|
928
1014
|
for (const shape of shapes) cellArea += shape.owned;
|
|
929
1015
|
}
|
|
930
|
-
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
|
|
1016
|
+
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: emptyTally() };
|
|
931
1017
|
let best: { width: number; height: number; rects: Rect[] } | null = null;
|
|
932
1018
|
for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
|
|
933
1019
|
out.widths++;
|
|
@@ -937,7 +1023,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
|
|
|
937
1023
|
continue;
|
|
938
1024
|
}
|
|
939
1025
|
const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
|
|
940
|
-
const attempt = placePass(cells, shapes, width, maxEdge, maxBottom);
|
|
1026
|
+
const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors });
|
|
941
1027
|
let height = 0;
|
|
942
1028
|
for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
|
|
943
1029
|
if (height > maxBottom) {
|
|
@@ -1094,9 +1180,46 @@ function placePass(
|
|
|
1094
1180
|
pageW: number,
|
|
1095
1181
|
pageH: number,
|
|
1096
1182
|
maxBottom = Infinity,
|
|
1183
|
+
pass: PassOptions = {},
|
|
1097
1184
|
): Array<Rect | null> {
|
|
1098
|
-
const
|
|
1099
|
-
|
|
1185
|
+
const wholeOrNothing = pass.wholeOrNothing ?? false;
|
|
1186
|
+
const tally = pass.tally;
|
|
1187
|
+
if (shapes === undefined || shapes.every((s) => s.whole)) {
|
|
1188
|
+
const byRect = packOnePage(cells, pageW, pageH, maxBottom);
|
|
1189
|
+
if (tally !== undefined) tally.rectPlaced += placedCount(byRect);
|
|
1190
|
+
return byRect;
|
|
1191
|
+
}
|
|
1192
|
+
// ⭐ `wholeOrNothing` is the page searches' question (issue #1102): they keep
|
|
1193
|
+
// a pass only when it placed every cell, so a pass that has missed one is
|
|
1194
|
+
// already discarded, whatever it does next. Two savings follow, and neither
|
|
1195
|
+
// can change a page the search keeps:
|
|
1196
|
+
//
|
|
1197
|
+
// * the rectangle pass is not run when the cells' own area exceeds the
|
|
1198
|
+
// page — disjoint rectangles cannot all fit, so it could not place every
|
|
1199
|
+
// cell, and the pass it would have lost or won against is discarded either
|
|
1200
|
+
// way (a footprint pass that placed every cell beats a rectangle pass that
|
|
1201
|
+
// did not, on the first key);
|
|
1202
|
+
// * the footprint pass stops at its first miss (`packOnePageByFootprint`'s
|
|
1203
|
+
// `stopAtMiss`), returning the cells after it as not placed.
|
|
1204
|
+
//
|
|
1205
|
+
// What is returned can then differ from the full answer only in which of two
|
|
1206
|
+
// failed passes it is, or in how many cells a failed pass placed — and the
|
|
1207
|
+
// searches read neither. The spill's assignment pass, which keeps the cells
|
|
1208
|
+
// a pass did place, never asks this (`packAtlas`).
|
|
1209
|
+
//
|
|
1210
|
+
// Measured on a replica of a production rig (31 meshes, a two-page `free`
|
|
1211
|
+
// spill at 2048, padding 2), where the pack took 260–304 s against `rect`'s
|
|
1212
|
+
// 0.14–0.24 s: the single-page search ran a full footprint pass at every one of its
|
|
1213
|
+
// 854 widths (54 % of the time), because nothing fits and so no best page
|
|
1214
|
+
// ever bounds a width, and the first page's own search ran one at every width
|
|
1215
|
+
// it did not abandon (46 %). The largest cell's owned box does not start at
|
|
1216
|
+
// its cell's corner, so on an empty page that cell is every footprint pass's
|
|
1217
|
+
// first miss — all 1,709 of them — and every one of those passes was thrown
|
|
1218
|
+
// away (issue #1104 measures the anchor that causes it). With the early stop the pack takes 0.15–0.7 s by the load, the
|
|
1219
|
+
// same pages.
|
|
1220
|
+
const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
|
|
1221
|
+
const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
|
|
1222
|
+
if (tally !== undefined && byRect !== null) tally.rectPlaced += placedCount(byRect);
|
|
1100
1223
|
// ⭐ `polygon` never costs a page anything `rect` would have saved. Both passes
|
|
1101
1224
|
// are run on the same page and the better one is kept: more cells placed,
|
|
1102
1225
|
// then the higher bottom edge, then — on a tie — the rectangle pass. Two
|
|
@@ -1107,15 +1230,86 @@ function placePass(
|
|
|
1107
1230
|
// than `rect` on two of the five gallery rigs that carry a mesh, because a
|
|
1108
1231
|
// greedy pass that places one cell differently places every later one
|
|
1109
1232
|
// differently too.
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1233
|
+
const anchors = pass.anchors ?? DEFAULT_FOOTPRINT_ANCHORS;
|
|
1234
|
+
let byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, anchors === 'best-of' ? 'box' : anchors);
|
|
1235
|
+
if (anchors === 'best-of') {
|
|
1236
|
+
// Issue #1104's fourth candidate: both searches on the same page, the
|
|
1237
|
+
// better kept and the first anchor's on a tie — so a pass is never worse
|
|
1238
|
+
// than the first anchor's on that page.
|
|
1239
|
+
if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
|
|
1240
|
+
const byTwo = packOnePageByFootprint(shapes, pageW, pageH, maxBottom, wholeOrNothing, pass.edgeIndex ?? true, tally, 'box-or-cell');
|
|
1241
|
+
const bottom = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
|
|
1242
|
+
const placedBox = placedCount(byFootprint);
|
|
1243
|
+
const placedTwo = placedCount(byTwo);
|
|
1244
|
+
if (placedTwo > placedBox || (placedTwo === placedBox && bottom(byTwo) < bottom(byFootprint))) byFootprint = byTwo;
|
|
1245
|
+
if (tally !== undefined) tally.footprintPlaced += placedTwo;
|
|
1246
|
+
} else if (tally !== undefined) tally.footprintPlaced += placedCount(byFootprint);
|
|
1247
|
+
if (byRect === null) return byFootprint;
|
|
1248
|
+
const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
|
|
1249
|
+
const placedRect = placedCount(byRect);
|
|
1250
|
+
const placedFoot = placedCount(byFootprint);
|
|
1115
1251
|
if (placedFoot !== placedRect) return placedFoot > placedRect ? byFootprint : byRect;
|
|
1116
1252
|
return bottomOf(byFootprint) < bottomOf(byRect) ? byFootprint : byRect;
|
|
1117
1253
|
}
|
|
1118
1254
|
|
|
1255
|
+
/** How `placePass` runs — what the page searches ask of it (issue #1102). */
|
|
1256
|
+
interface PassOptions {
|
|
1257
|
+
/** Only a pass that places every cell is kept by the caller. See `placePass`. */
|
|
1258
|
+
wholeOrNothing?: boolean;
|
|
1259
|
+
/** Where to count what the passes placed. */
|
|
1260
|
+
tally?: PassTally;
|
|
1261
|
+
/** `splitFree`'s edge index (default on); off only for the selftest's plant. */
|
|
1262
|
+
edgeIndex?: boolean;
|
|
1263
|
+
/** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
|
|
1264
|
+
anchors?: FootprintAnchors;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** How many cells a pass placed. */
|
|
1268
|
+
function placedCount(pass: ReadonlyArray<Rect | null>): number {
|
|
1269
|
+
let n = 0;
|
|
1270
|
+
for (const r of pass) if (r !== null) n++;
|
|
1271
|
+
return n;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/** The cells' own area — what disjoint rectangles need of a page at least. */
|
|
1275
|
+
function cellAreaOf(cells: ReadonlyArray<{ w: number; h: number }>): number {
|
|
1276
|
+
let area = 0;
|
|
1277
|
+
for (const cell of cells) area += cell.w * cell.h;
|
|
1278
|
+
return area;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* What a page search's passes placed, by kind — the operation count a search's
|
|
1283
|
+
* cost is held by (`PK93`), since a footprint placement splits the free list
|
|
1284
|
+
* once per band of what the cell owns and a rectangle placement once.
|
|
1285
|
+
*/
|
|
1286
|
+
export interface PassTally {
|
|
1287
|
+
/** Cells placed by rectangle passes (`packOnePage`). */
|
|
1288
|
+
rectPlaced: number;
|
|
1289
|
+
/** Cells placed by footprint passes (`packOnePageByFootprint`). */
|
|
1290
|
+
footprintPlaced: number;
|
|
1291
|
+
/** Free-list splits the footprint passes made — one per band of every cell they placed (`splitFree`). */
|
|
1292
|
+
bandSplits: number;
|
|
1293
|
+
/** Containment tests the footprint passes' prune made (`splitFree`). */
|
|
1294
|
+
containmentTests: number;
|
|
1295
|
+
/**
|
|
1296
|
+
* Free rectangles that held a cell's owned box and were refused as its
|
|
1297
|
+
* first-anchor candidate only because the cell, anchored there, would leave
|
|
1298
|
+
* the page — at negative x or y, or past the right or bottom edge (issue
|
|
1299
|
+
* #1104, `tools/pack_anchor.ts`).
|
|
1300
|
+
*/
|
|
1301
|
+
boxAnchorRefused: number;
|
|
1302
|
+
/** Cells a footprint pass missed although some free rectangle held their owned box. */
|
|
1303
|
+
missedByAnchor: number;
|
|
1304
|
+
/** Cells a footprint pass placed at the second anchor — the cell's own corner on the free rectangle's — under a candidate rule that has one (`FOOTPRINT_ANCHORS`). */
|
|
1305
|
+
secondAnchorPlaced: number;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/** A tally with nothing counted. */
|
|
1309
|
+
export function emptyTally(): PassTally {
|
|
1310
|
+
return { rectPlaced: 0, footprintPlaced: 0, bandSplits: 0, containmentTests: 0, boxAnchorRefused: 0, missedByAnchor: 0, secondAnchorPlaced: 0 };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1119
1313
|
// ---------------------------------------------------------------------------
|
|
1120
1314
|
// footprints — `shape: 'polygon'` (issue #1099)
|
|
1121
1315
|
// ---------------------------------------------------------------------------
|
|
@@ -1377,44 +1571,100 @@ function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: numb
|
|
|
1377
1571
|
* px, hulls near their rectangles, a `free` page about 2023x2046) the pack
|
|
1378
1572
|
* took 18.5 s with the slivers kept and spent 95 % of it in this prune.
|
|
1379
1573
|
*/
|
|
1380
|
-
function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
|
|
1574
|
+
function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1, edgeIndex = true, tally?: PassTally): void {
|
|
1381
1575
|
const next: Rect[] = [];
|
|
1382
|
-
|
|
1576
|
+
/** Per entry of `next`: `UNTOUCHED`, or which side of `put` the piece was cut from. */
|
|
1577
|
+
const side: number[] = [];
|
|
1383
1578
|
for (const fr of free) {
|
|
1384
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;
|
|
1385
1580
|
if (!overlaps) {
|
|
1386
1581
|
next.push(fr);
|
|
1387
|
-
|
|
1582
|
+
side.push(UNTOUCHED);
|
|
1388
1583
|
continue;
|
|
1389
1584
|
}
|
|
1390
|
-
if (put.x > fr.x)
|
|
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);
|
|
1588
|
+
}
|
|
1391
1589
|
if (put.x + put.w < fr.x + fr.w) {
|
|
1392
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);
|
|
1592
|
+
}
|
|
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);
|
|
1393
1596
|
}
|
|
1394
|
-
if (put.y > fr.y) next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
|
|
1395
1597
|
if (put.y + put.h < fr.y + fr.h) {
|
|
1396
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);
|
|
1397
1600
|
}
|
|
1398
|
-
while (untouched.length < next.length) untouched.push(false);
|
|
1399
1601
|
}
|
|
1400
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;
|
|
1401
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
|
|
1605
|
+
// among the entries that share the edge it was cut along. A piece cut from
|
|
1606
|
+
// the left of `put` ends at `put.x` and keeps its parent's rows, which meet
|
|
1607
|
+
// `put`'s rows (the parent overlapped `put`). A rectangle containing it covers
|
|
1608
|
+
// those rows from the piece's left edge to at least `put.x`; if it reached
|
|
1609
|
+
// past `put.x` it would overlap `put` — and no entry of `next` does (an
|
|
1610
|
+
// untouched rectangle by definition, a piece by construction). So every
|
|
1611
|
+
// container of a left piece ENDS at `put.x`; likewise a right piece's starts
|
|
1612
|
+
// at `put.x + put.w`, an upper piece's ends at `put.y` and a lower piece's
|
|
1613
|
+
// starts at `put.y + put.h`. The test is the full prune's test, asked of the
|
|
1614
|
+
// only entries that can answer it yes, so `contained` is the same boolean and
|
|
1615
|
+
// the list the same list in the same order. Measured where the footprint
|
|
1616
|
+
// pass does work (`fixtures/polypack_shapes.ts`, seed 1105, the searches a
|
|
1617
|
+
// `polygon` pack of it runs): 415,916,651 containment tests by the full scan,
|
|
1618
|
+
// 15,407,179 by the index, 3.2 s → 2.3 s; with the early stop off as well,
|
|
1619
|
+
// 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++) {
|
|
1625
|
+
const r = next[j];
|
|
1626
|
+
if (!usable(r)) continue;
|
|
1627
|
+
if (!edgeIndex) {
|
|
1628
|
+
all.push(j);
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
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);
|
|
1635
|
+
}
|
|
1402
1636
|
free.length = 0;
|
|
1637
|
+
let tests = 0;
|
|
1403
1638
|
for (let i = 0; i < next.length; i++) {
|
|
1404
1639
|
if (!usable(next[i])) continue;
|
|
1405
|
-
if (
|
|
1640
|
+
if (side[i] === UNTOUCHED) {
|
|
1406
1641
|
free.push(next[i]);
|
|
1407
1642
|
continue;
|
|
1408
1643
|
}
|
|
1409
1644
|
let contained = false;
|
|
1410
|
-
for (
|
|
1411
|
-
if (i === j
|
|
1412
|
-
|
|
1645
|
+
for (const j of edgeIndex ? scan[side[i]] : all) {
|
|
1646
|
+
if (i === j) continue;
|
|
1647
|
+
tests++;
|
|
1648
|
+
if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) {
|
|
1649
|
+
contained = true;
|
|
1650
|
+
break;
|
|
1651
|
+
}
|
|
1413
1652
|
}
|
|
1414
1653
|
if (!contained) free.push(next[i]);
|
|
1415
1654
|
}
|
|
1655
|
+
if (tally !== undefined) {
|
|
1656
|
+
tally.bandSplits++;
|
|
1657
|
+
tally.containmentTests += tests;
|
|
1658
|
+
}
|
|
1416
1659
|
}
|
|
1417
1660
|
|
|
1661
|
+
/** Which side of the placed rectangle a free-list piece was cut from (`splitFree`) — an index into its edge lists. */
|
|
1662
|
+
const LEFT_OF_PUT = 0;
|
|
1663
|
+
const RIGHT_OF_PUT = 1;
|
|
1664
|
+
const ABOVE_PUT = 2;
|
|
1665
|
+
const BELOW_PUT = 3;
|
|
1666
|
+
const UNTOUCHED = -1;
|
|
1667
|
+
|
|
1418
1668
|
/**
|
|
1419
1669
|
* `packOnePage` with the free-space test replaced by the footprint test
|
|
1420
1670
|
* (issue #1099) — the placement `shape: 'polygon'` makes.
|
|
@@ -1434,9 +1684,12 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
|
|
|
1434
1684
|
* * a candidate is a free rectangle that holds the cell's protected set's
|
|
1435
1685
|
* bounding box, anchored at its top-left corner — the cell placed so that
|
|
1436
1686
|
* box's corner lands there, which may put the cell's own unclaimed
|
|
1437
|
-
* texels over a neighbour's — and whose cell stays on the page
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1687
|
+
* texels over a neighbour's — and whose cell stays on the page
|
|
1688
|
+
* (*Candidates*, below, for the rules measured beside it);
|
|
1689
|
+
* * the score is Best Short Side Fit over the part of the free rectangle the
|
|
1690
|
+
* box uses from its corner (the box itself, under the default anchor),
|
|
1691
|
+
* with the same total tie-break (smallest long-side leftover, then
|
|
1692
|
+
* topmost, then leftmost, then the first anchor);
|
|
1440
1693
|
* * the free list is split and pruned by the same code (`splitFree`), once
|
|
1441
1694
|
* per rectangle of the protected set.
|
|
1442
1695
|
*
|
|
@@ -1445,6 +1698,27 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
|
|
|
1445
1698
|
* a pack with no mesh region makes `packOnePage`'s decisions one for one
|
|
1446
1699
|
* (`PK82`).
|
|
1447
1700
|
*
|
|
1701
|
+
* ## Candidates — the second anchor (issue #1104)
|
|
1702
|
+
*
|
|
1703
|
+
* The first anchor puts the owned box's corner on the free rectangle's corner,
|
|
1704
|
+
* the cell then reaching up and left of it by the box's offset. So a cell whose
|
|
1705
|
+
* box is inset by `(dx, dy)` can only go where a free rectangle starts at least
|
|
1706
|
+
* `dx` from the page's left and `dy` from its top — on an empty page, nowhere.
|
|
1707
|
+
* A large region whose mesh draws a small part of it is then every footprint
|
|
1708
|
+
* pass's first miss: on `fixtures/polypack_shapes.ts`'s seed 1107, and on the
|
|
1709
|
+
* production rig it stands for, every footprint pass lost to the rectangle pass
|
|
1710
|
+
* and the polygon pack was the `rect` pack to the texel.
|
|
1711
|
+
*
|
|
1712
|
+
* The second anchor puts the cell's own corner there instead, the box inset by
|
|
1713
|
+
* its offset, and is a candidate when the free rectangle holds the box where it
|
|
1714
|
+
* then lies. The two coincide when the box starts at the cell's corner, so it
|
|
1715
|
+
* is only tried for an inset box — never for a whole cell, which is why a pack
|
|
1716
|
+
* with no mesh region is unchanged by it. Both keep the box inside a free
|
|
1717
|
+
* rectangle and the cell on the page, so no texel test, gutter or atlas field
|
|
1718
|
+
* changes: `bounds` stays inside `size`, as the runtime's UVs assume. Which
|
|
1719
|
+
* free rectangles get it is the rule (`FOOTPRINT_ANCHORS`); which rule's pack
|
|
1720
|
+
* is written is `packAtlas`'s choice by Σ page area.
|
|
1721
|
+
*
|
|
1448
1722
|
* 🔒 **The footprint test is then stated outright rather than trusted to the
|
|
1449
1723
|
* bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
|
|
1450
1724
|
* because no free rectangle overlaps one; every placement is nevertheless
|
|
@@ -1455,7 +1729,16 @@ function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
|
|
|
1455
1729
|
* over the cells in the caller's order, each over a finite free list, every
|
|
1456
1730
|
* choice made by a total order, and no input but the cells and the page.
|
|
1457
1731
|
*/
|
|
1458
|
-
function packOnePageByFootprint(
|
|
1732
|
+
function packOnePageByFootprint(
|
|
1733
|
+
shapes: readonly CellShape[],
|
|
1734
|
+
pageW: number,
|
|
1735
|
+
pageH: number,
|
|
1736
|
+
maxBottom = Infinity,
|
|
1737
|
+
stopAtMiss = false,
|
|
1738
|
+
edgeIndex = true,
|
|
1739
|
+
tally?: PassTally,
|
|
1740
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
1741
|
+
): Array<Rect | null> {
|
|
1459
1742
|
const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
|
|
1460
1743
|
const placed: Array<Rect | null> = [];
|
|
1461
1744
|
const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
|
|
@@ -1470,37 +1753,67 @@ function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pag
|
|
|
1470
1753
|
|
|
1471
1754
|
for (const shape of shapes) {
|
|
1472
1755
|
const box = shape.bbox;
|
|
1473
|
-
let best:
|
|
1756
|
+
let best: { x: number; y: number; frX: number; frY: number; anchor: number } | null = null;
|
|
1474
1757
|
let bestShort = Infinity;
|
|
1475
1758
|
let bestLong = Infinity;
|
|
1759
|
+
let held = false;
|
|
1760
|
+
// The second anchor differs from the first only when the box is inset.
|
|
1761
|
+
const anchorCount = anchors !== 'box' && (box.x !== 0 || box.y !== 0) ? 2 : 1;
|
|
1476
1762
|
for (const fr of free) {
|
|
1477
1763
|
if (fr.w < box.w || fr.h < box.h) continue;
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
if (
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1764
|
+
held = true;
|
|
1765
|
+
let firstOffPage = false;
|
|
1766
|
+
for (let anchor = 0; anchor < anchorCount; anchor++) {
|
|
1767
|
+
// Anchor 0: the owned box's corner on the free corner. Anchor 1: the
|
|
1768
|
+
// cell's own corner there, the box then inset by its own offset.
|
|
1769
|
+
const x = anchor === 0 ? fr.x - box.x : fr.x;
|
|
1770
|
+
const y = anchor === 0 ? fr.y - box.y : fr.y;
|
|
1771
|
+
const usedW = anchor === 0 ? box.w : box.x + box.w;
|
|
1772
|
+
const usedH = anchor === 0 ? box.h : box.y + box.h;
|
|
1773
|
+
if (usedW > fr.w || usedH > fr.h) continue;
|
|
1774
|
+
const offPage = x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH;
|
|
1775
|
+
if (anchor === 0) firstOffPage = offPage;
|
|
1776
|
+
// `box-else-cell`: the second anchor only where the first left the page.
|
|
1777
|
+
if (anchor === 1 && anchors === 'box-else-cell' && !firstOffPage) continue;
|
|
1778
|
+
if (offPage) {
|
|
1779
|
+
if (anchor === 0 && tally !== undefined) tally.boxAnchorRefused++;
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
const leftoverW = fr.w - usedW;
|
|
1783
|
+
const leftoverH = fr.h - usedH;
|
|
1784
|
+
const short = Math.min(leftoverW, leftoverH);
|
|
1785
|
+
const long = Math.max(leftoverW, leftoverH);
|
|
1786
|
+
if (best !== null) {
|
|
1787
|
+
if (short > bestShort) continue;
|
|
1788
|
+
if (short === bestShort) {
|
|
1789
|
+
if (long > bestLong) continue;
|
|
1790
|
+
if (long === bestLong) {
|
|
1791
|
+
if (fr.y > best.frY) continue;
|
|
1792
|
+
if (fr.y === best.frY) {
|
|
1793
|
+
if (fr.x > best.frX) continue;
|
|
1794
|
+
if (fr.x === best.frX && anchor >= best.anchor) continue;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1492
1797
|
}
|
|
1493
1798
|
}
|
|
1799
|
+
best = { x, y, frX: fr.x, frY: fr.y, anchor };
|
|
1800
|
+
bestShort = short;
|
|
1801
|
+
bestLong = long;
|
|
1494
1802
|
}
|
|
1495
|
-
best = fr;
|
|
1496
|
-
bestShort = short;
|
|
1497
|
-
bestLong = long;
|
|
1498
1803
|
}
|
|
1499
1804
|
if (best === null) {
|
|
1805
|
+
if (held && tally !== undefined) tally.missedByAnchor++;
|
|
1500
1806
|
placed.push(null);
|
|
1807
|
+
// A caller that keeps only a pass placing every cell has its answer
|
|
1808
|
+
// (`placePass`, `wholeOrNothing`): the rest are not placed.
|
|
1809
|
+
if (stopAtMiss) {
|
|
1810
|
+
while (placed.length < shapes.length) placed.push(null);
|
|
1811
|
+
return placed;
|
|
1812
|
+
}
|
|
1501
1813
|
continue;
|
|
1502
1814
|
}
|
|
1503
|
-
|
|
1815
|
+
if (best.anchor === 1 && tally !== undefined) tally.secondAnchorPlaced++;
|
|
1816
|
+
const put: Rect = { x: best.x, y: best.y, w: shape.width, h: shape.height };
|
|
1504
1817
|
for (const other of owned) {
|
|
1505
1818
|
// Two cells that do not overlap cannot share a texel, and that answer
|
|
1506
1819
|
// takes no texel to read; only an overlap is scanned, and only over the
|
|
@@ -1521,11 +1834,26 @@ function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pag
|
|
|
1521
1834
|
while (placed.length < shapes.length) placed.push(null);
|
|
1522
1835
|
return placed;
|
|
1523
1836
|
}
|
|
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);
|
|
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);
|
|
1525
1838
|
}
|
|
1526
1839
|
return placed;
|
|
1527
1840
|
}
|
|
1528
1841
|
|
|
1842
|
+
/**
|
|
1843
|
+
* One footprint pass on an empty `pageW x pageH` page, run to its end, with
|
|
1844
|
+
* what it counted — exported for `tools/pack_anchor.ts` (issue #1104).
|
|
1845
|
+
*/
|
|
1846
|
+
export function footprintPass(
|
|
1847
|
+
shapes: readonly CellShape[],
|
|
1848
|
+
pageW: number,
|
|
1849
|
+
pageH: number,
|
|
1850
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
1851
|
+
): { rects: Array<{ x: number; y: number; w: number; h: number } | null>; tally: PassTally } {
|
|
1852
|
+
const tally = emptyTally();
|
|
1853
|
+
const rects = packOnePageByFootprint(shapes, pageW, pageH, Infinity, false, true, tally, anchors);
|
|
1854
|
+
return { rects, tally };
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1529
1857
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
1530
1858
|
const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
1531
1859
|
|
|
@@ -1733,6 +2061,130 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
1733
2061
|
}
|
|
1734
2062
|
}
|
|
1735
2063
|
|
|
2064
|
+
/** Where every part lands and the size each page is written at — one candidate's whole pack, before anything is drawn. */
|
|
2065
|
+
interface PackLayout {
|
|
2066
|
+
/** page index -> the placements on it, in packing order. */
|
|
2067
|
+
perPage: Placement[][];
|
|
2068
|
+
/** page index -> the size that page is written at. */
|
|
2069
|
+
pageSizes: Array<{ width: number; height: number }>;
|
|
2070
|
+
placements: Placement[];
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* One whole pack's layout under one placement rule: the single-page search,
|
|
2075
|
+
* or — when the set does not fit one page — the spill, each page then shrunk
|
|
2076
|
+
* to the smallest that holds it. `shapes` absent is the rectangle pass
|
|
2077
|
+
* throughout (`rect`, and the first of `polygon`'s candidates); given, the
|
|
2078
|
+
* footprint pass under `anchors`.
|
|
2079
|
+
*/
|
|
2080
|
+
function layoutPack(
|
|
2081
|
+
sorted: readonly PackInput[],
|
|
2082
|
+
cells: Array<{ w: number; h: number }>,
|
|
2083
|
+
shapes: readonly CellShape[] | undefined,
|
|
2084
|
+
maxEdge: number,
|
|
2085
|
+
pageEdges: PageEdges,
|
|
2086
|
+
padding: number,
|
|
2087
|
+
anchors: FootprintAnchors,
|
|
2088
|
+
tally?: PassTally,
|
|
2089
|
+
stopAtMiss = true,
|
|
2090
|
+
): PackLayout {
|
|
2091
|
+
const single = smallestPageFor(cells, maxEdge, pageEdges, shapes, anchors, tally, stopAtMiss);
|
|
2092
|
+
|
|
2093
|
+
/** page index -> the placements on it, in packing order. */
|
|
2094
|
+
const perPage: Placement[][] = [];
|
|
2095
|
+
/** page index -> the size that page is written at. */
|
|
2096
|
+
const pageSizes: Array<{ width: number; height: number }> = [];
|
|
2097
|
+
const placements: Placement[] = [];
|
|
2098
|
+
if (single !== null) {
|
|
2099
|
+
perPage.push([]);
|
|
2100
|
+
pageSizes.push({ width: single.width, height: single.height });
|
|
2101
|
+
single.rects.forEach((rect, i) => {
|
|
2102
|
+
const place: Placement = {
|
|
2103
|
+
region: sorted[i].region,
|
|
2104
|
+
page: 0,
|
|
2105
|
+
x: rect.x + padding,
|
|
2106
|
+
y: rect.y + padding,
|
|
2107
|
+
width: sorted[i].width,
|
|
2108
|
+
height: sorted[i].height,
|
|
2109
|
+
};
|
|
2110
|
+
perPage[0].push(place);
|
|
2111
|
+
placements.push(place);
|
|
2112
|
+
});
|
|
2113
|
+
} else {
|
|
2114
|
+
// Spill. Which parts share a page is decided at the MAXIMUM size — that is
|
|
2115
|
+
// what makes the boundary deterministic and independent of the shrink below
|
|
2116
|
+
// — and parts are taken in packing order, whatever will not fit the current
|
|
2117
|
+
// page opening the next one.
|
|
2118
|
+
let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
|
|
2119
|
+
const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
|
|
2120
|
+
shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
|
|
2121
|
+
while (remaining.length > 0) {
|
|
2122
|
+
const pageIndex = perPage.length;
|
|
2123
|
+
const attempt = placePass(
|
|
2124
|
+
remaining.map((r) => r.cell),
|
|
2125
|
+
shapesOf(remaining),
|
|
2126
|
+
maxEdge,
|
|
2127
|
+
maxEdge,
|
|
2128
|
+
Infinity,
|
|
2129
|
+
{ anchors, tally },
|
|
2130
|
+
);
|
|
2131
|
+
const onPage: typeof remaining = [];
|
|
2132
|
+
const leftOver: typeof remaining = [];
|
|
2133
|
+
attempt.forEach((rect, i) => {
|
|
2134
|
+
if (rect === null) leftOver.push(remaining[i]);
|
|
2135
|
+
else onPage.push(remaining[i]);
|
|
2136
|
+
});
|
|
2137
|
+
if (onPage.length === 0) {
|
|
2138
|
+
// Unreachable: every cell was proven to fit an empty page above. Kept as
|
|
2139
|
+
// a named stop rather than an infinite loop if that ever stops holding.
|
|
2140
|
+
throw new CompileError(
|
|
2141
|
+
`packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
// ⭐ Then the page is written at the smallest power-of-two pair that holds
|
|
2145
|
+
// the cells assigned to it, not at the maximum (issue #266, follow-up 3).
|
|
2146
|
+
// A spill used to write `pageSize x pageSize` for every page, so a set that
|
|
2147
|
+
// overflowed by one small part paid for a second full page of transparency
|
|
2148
|
+
// — 4 MiB of decoded RAM at the 2048 default for a part that might be
|
|
2149
|
+
// 64x64. Re-packing through the same search the single-page case uses is
|
|
2150
|
+
// what keeps "the page written is the smallest that holds what is on it"
|
|
2151
|
+
// one rule; a page whose own cells need the maximum simply gets it back.
|
|
2152
|
+
const shrunk = smallestPageFor(
|
|
2153
|
+
onPage.map((r) => r.cell),
|
|
2154
|
+
maxEdge,
|
|
2155
|
+
pageEdges,
|
|
2156
|
+
shapesOf(onPage),
|
|
2157
|
+
anchors,
|
|
2158
|
+
tally,
|
|
2159
|
+
stopAtMiss,
|
|
2160
|
+
);
|
|
2161
|
+
if (shrunk === null) {
|
|
2162
|
+
// Unreachable for the same reason as the stall above: these cells were
|
|
2163
|
+
// just placed on a maxEdge page.
|
|
2164
|
+
throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
|
|
2165
|
+
}
|
|
2166
|
+
const onThisPage: Placement[] = [];
|
|
2167
|
+
shrunk.rects.forEach((rect, i) => {
|
|
2168
|
+
const place: Placement = {
|
|
2169
|
+
region: onPage[i].input.region,
|
|
2170
|
+
page: pageIndex,
|
|
2171
|
+
x: rect.x + padding,
|
|
2172
|
+
y: rect.y + padding,
|
|
2173
|
+
width: onPage[i].input.width,
|
|
2174
|
+
height: onPage[i].input.height,
|
|
2175
|
+
};
|
|
2176
|
+
onThisPage.push(place);
|
|
2177
|
+
placements.push(place);
|
|
2178
|
+
});
|
|
2179
|
+
perPage.push(onThisPage);
|
|
2180
|
+
pageSizes.push({ width: shrunk.width, height: shrunk.height });
|
|
2181
|
+
remaining = leftOver;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
return { perPage, pageSizes, placements };
|
|
2186
|
+
}
|
|
2187
|
+
|
|
1736
2188
|
/**
|
|
1737
2189
|
* Pack these parts onto shared pages.
|
|
1738
2190
|
*
|
|
@@ -1796,9 +2248,17 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
1796
2248
|
* two footprints keeps them `2 · padding` apart (`CellShape`);
|
|
1797
2249
|
* * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
|
|
1798
2250
|
* split by what each cell owns, run beside the rectangle pass on every page
|
|
1799
|
-
* tried and kept only when it is better (`placePass`)
|
|
1800
|
-
*
|
|
1801
|
-
*
|
|
2251
|
+
* tried and kept only when it is better (`placePass`);
|
|
2252
|
+
* * **the pack written** is the least Σ page area — over every page, a spill's
|
|
2253
|
+
* included — of three whole packs: `rect`'s, the footprint pack under the
|
|
2254
|
+
* owned box's anchor, and under `box-else-cell` (issue #1104), the earlier
|
|
2255
|
+
* on a tie. So a `polygon` pack is never larger in page area than `rect`'s
|
|
2256
|
+
* on any set, by construction, and a set with no mesh footprint is `rect`'s
|
|
2257
|
+
* pack to the byte (`PK82`). The per-page rule alone never promised that
|
|
2258
|
+
* across a spill: seed 1105 under `pot` packed 8,388,608 texels against
|
|
2259
|
+
* `rect`'s 6,291,456 before it (`PK96`). It costs the three packs' searches
|
|
2260
|
+
* — no search is shared, since a free search's bounds depend on the best
|
|
2261
|
+
* page its own passes found;
|
|
1802
2262
|
* * **the pixels** are drawn in two passes: every cell whole in packing order,
|
|
1803
2263
|
* as under `rect`, then every region's owned texels again with its own
|
|
1804
2264
|
* values. The owned sets are disjoint, so the second pass's order decides
|
|
@@ -1858,94 +2318,31 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1858
2318
|
const shapes =
|
|
1859
2319
|
shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
|
|
1860
2320
|
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
placements.push(place);
|
|
1882
|
-
});
|
|
1883
|
-
} else {
|
|
1884
|
-
// Spill. Which parts share a page is decided at the MAXIMUM size — that is
|
|
1885
|
-
// what makes the boundary deterministic and independent of the shrink below
|
|
1886
|
-
// — and parts are taken in packing order, whatever will not fit the current
|
|
1887
|
-
// page opening the next one.
|
|
1888
|
-
let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
|
|
1889
|
-
const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
|
|
1890
|
-
shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
|
|
1891
|
-
while (remaining.length > 0) {
|
|
1892
|
-
const pageIndex = perPage.length;
|
|
1893
|
-
const attempt = placePass(
|
|
1894
|
-
remaining.map((r) => r.cell),
|
|
1895
|
-
shapesOf(remaining),
|
|
1896
|
-
maxEdge,
|
|
1897
|
-
maxEdge,
|
|
1898
|
-
);
|
|
1899
|
-
const onPage: typeof remaining = [];
|
|
1900
|
-
const leftOver: typeof remaining = [];
|
|
1901
|
-
attempt.forEach((rect, i) => {
|
|
1902
|
-
if (rect === null) leftOver.push(remaining[i]);
|
|
1903
|
-
else onPage.push(remaining[i]);
|
|
1904
|
-
});
|
|
1905
|
-
if (onPage.length === 0) {
|
|
1906
|
-
// Unreachable: every cell was proven to fit an empty page above. Kept as
|
|
1907
|
-
// a named stop rather than an infinite loop if that ever stops holding.
|
|
1908
|
-
throw new CompileError(
|
|
1909
|
-
`packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
|
|
1910
|
-
);
|
|
1911
|
-
}
|
|
1912
|
-
// ⭐ Then the page is written at the smallest power-of-two pair that holds
|
|
1913
|
-
// the cells assigned to it, not at the maximum (issue #266, follow-up 3).
|
|
1914
|
-
// A spill used to write `pageSize x pageSize` for every page, so a set that
|
|
1915
|
-
// overflowed by one small part paid for a second full page of transparency
|
|
1916
|
-
// — 4 MiB of decoded RAM at the 2048 default for a part that might be
|
|
1917
|
-
// 64x64. Re-packing through the same search the single-page case uses is
|
|
1918
|
-
// what keeps "the page written is the smallest that holds what is on it"
|
|
1919
|
-
// one rule; a page whose own cells need the maximum simply gets it back.
|
|
1920
|
-
const shrunk = smallestPageFor(
|
|
1921
|
-
onPage.map((r) => r.cell),
|
|
1922
|
-
maxEdge,
|
|
1923
|
-
pageEdges,
|
|
1924
|
-
shapesOf(onPage),
|
|
1925
|
-
);
|
|
1926
|
-
if (shrunk === null) {
|
|
1927
|
-
// Unreachable for the same reason as the stall above: these cells were
|
|
1928
|
-
// just placed on a maxEdge page.
|
|
1929
|
-
throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
|
|
1930
|
-
}
|
|
1931
|
-
const onThisPage: Placement[] = [];
|
|
1932
|
-
shrunk.rects.forEach((rect, i) => {
|
|
1933
|
-
const place: Placement = {
|
|
1934
|
-
region: onPage[i].input.region,
|
|
1935
|
-
page: pageIndex,
|
|
1936
|
-
x: rect.x + padding,
|
|
1937
|
-
y: rect.y + padding,
|
|
1938
|
-
width: onPage[i].input.width,
|
|
1939
|
-
height: onPage[i].input.height,
|
|
1940
|
-
};
|
|
1941
|
-
onThisPage.push(place);
|
|
1942
|
-
placements.push(place);
|
|
1943
|
-
});
|
|
1944
|
-
perPage.push(onThisPage);
|
|
1945
|
-
pageSizes.push({ width: shrunk.width, height: shrunk.height });
|
|
1946
|
-
remaining = leftOver;
|
|
1947
|
-
}
|
|
2321
|
+
// ⭐ `polygon` is a choice between whole packs (issue #1104), made on the
|
|
2322
|
+
// one figure the mode exists to lower — the sum of the pages' areas — and
|
|
2323
|
+
// made over every page, a spill's included. The candidates, in order: the
|
|
2324
|
+
// rectangle pack (`rect`'s own, to the byte); the footprint pack with the
|
|
2325
|
+
// owned box's anchor (`box`); and the footprint pack that may also put a
|
|
2326
|
+
// cell's own corner where the first anchor would leave the page
|
|
2327
|
+
// (`box-else-cell`). The least Σ area wins, the earlier candidate on a tie.
|
|
2328
|
+
// So a `polygon` pack is never larger than `rect`'s, nor than the one-anchor
|
|
2329
|
+
// pack, on any set — by construction, across a spill as well as on one page,
|
|
2330
|
+
// which no single greedy rule gives: one more candidate position placed
|
|
2331
|
+
// PK78's gem differently and every tile after it (one page 7.2 % larger),
|
|
2332
|
+
// and the first page of a spill choosing differently left seed 1105 under
|
|
2333
|
+
// `pot` on two 2048x2048 pages against `rect`'s 2048x2048 + 1024x2048.
|
|
2334
|
+
// `footprintAnchors` names one rule alone instead (the instrument, the plant).
|
|
2335
|
+
const candidates: Array<{ name: PackCandidate; layout: PackLayout }> = [];
|
|
2336
|
+
if (shapes === undefined) candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true) });
|
|
2337
|
+
else if (opts.footprintAnchors !== undefined) candidates.push({ name: opts.footprintAnchors, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, opts.footprintAnchors, opts.tally, opts.stopAtMiss ?? true) });
|
|
2338
|
+
else {
|
|
2339
|
+
candidates.push({ name: 'rect', layout: layoutPack(sorted, cells, undefined, maxEdge, pageEdges, padding, 'box', opts.tally, opts.stopAtMiss ?? true) });
|
|
2340
|
+
for (const rule of POLYGON_CANDIDATE_RULES) candidates.push({ name: rule, layout: layoutPack(sorted, cells, shapes, maxEdge, pageEdges, padding, rule, opts.tally, opts.stopAtMiss ?? true) });
|
|
1948
2341
|
}
|
|
2342
|
+
const areaOf = (layout: PackLayout): number => layout.pageSizes.reduce((n, p) => n + p.width * p.height, 0);
|
|
2343
|
+
let chosen = candidates[0];
|
|
2344
|
+
for (const c of candidates) if (areaOf(c.layout) < areaOf(chosen.layout)) chosen = c;
|
|
2345
|
+
const { perPage, pageSizes, placements } = chosen.layout;
|
|
1949
2346
|
|
|
1950
2347
|
// Draw. Reading each source once, in packing order, keeps the decode count at
|
|
1951
2348
|
// one per part whatever the page layout turned out to be.
|
|
@@ -2019,7 +2416,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
2019
2416
|
});
|
|
2020
2417
|
});
|
|
2021
2418
|
|
|
2022
|
-
return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape };
|
|
2419
|
+
return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape, candidate: chosen.name };
|
|
2023
2420
|
}
|
|
2024
2421
|
|
|
2025
2422
|
// ---------------------------------------------------------------------------
|
package/src/cli/shared.ts
CHANGED
|
@@ -1225,8 +1225,13 @@ interface CommandDoc {
|
|
|
1225
1225
|
* claim anybody keeps by hand: `RC26` in `selftest.ts` derives it from the
|
|
1226
1226
|
* import graph of the module whose bodies register the command, and a
|
|
1227
1227
|
* mark that disagrees with the graph is red by name.
|
|
1228
|
+
*
|
|
1229
|
+
* `rerunsTheGate` marks a command whose body re-runs the gate `build` ran
|
|
1230
|
+
* before it wrote (`validate`): refused by the entry that links none of the
|
|
1231
|
+
* runtime and pointed at a rigc build, its refusal says that gate already
|
|
1232
|
+
* ran (`gatedOnWrite`, issue #1097).
|
|
1228
1233
|
*/
|
|
1229
|
-
runtime: false | { for: string; core?: CoreBody };
|
|
1234
|
+
runtime: false | { for: string; core?: CoreBody; rerunsTheGate?: true };
|
|
1230
1235
|
/**
|
|
1231
1236
|
* Whether the command exists for the Spine format — reads, writes, compares
|
|
1232
1237
|
* or embeds Spine skeleton data as the whole of what it does with it — so
|
|
@@ -1311,7 +1316,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1311
1316
|
},
|
|
1312
1317
|
{
|
|
1313
1318
|
name: 'validate',
|
|
1314
|
-
runtime: { for: 'the gate it re-runs is the round trip through it' },
|
|
1319
|
+
runtime: { for: 'the gate it re-runs is the round trip through it', rerunsTheGate: true },
|
|
1315
1320
|
spineFormat: true,
|
|
1316
1321
|
usage: [
|
|
1317
1322
|
'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
|
|
@@ -2279,6 +2284,51 @@ export function entryCommands(linksRuntime: boolean): CommandDoc[] {
|
|
|
2279
2284
|
});
|
|
2280
2285
|
}
|
|
2281
2286
|
|
|
2287
|
+
/**
|
|
2288
|
+
* What the refusal of a command that re-runs the gate says when its target is
|
|
2289
|
+
* a rigc build (issue #1097): that build already ran the gate, before it wrote
|
|
2290
|
+
* anything. A consumer met the refusal and read it as a gate the install could
|
|
2291
|
+
* not run, because the refusal said only what `validate` needs (#1095).
|
|
2292
|
+
*
|
|
2293
|
+
* ⚠️ Every clause is a fact about what the directory carries, never a reading
|
|
2294
|
+
* of the document: `skeleton.model.json` beside the pair is what makes it a
|
|
2295
|
+
* rigc build (`resolveBuild`'s `modelPath`), and emit-only-after-green is
|
|
2296
|
+
* what makes a rigc build gated on write. It does not say which entry wrote
|
|
2297
|
+
* it, which spec version the document is, or that the skeleton is still the
|
|
2298
|
+
* one the gate passed — nothing here reads the document, so those are said as
|
|
2299
|
+
* where to look (`spine.sha256`), not as findings.
|
|
2300
|
+
*/
|
|
2301
|
+
export function gatedOnWriteSentence(skeletonPath: string): string {
|
|
2302
|
+
return (
|
|
2303
|
+
`The target is a rigc build — ${skeletonPath} has ${MODEL_DOCUMENT_FILE} beside it — and a rigc build writes nothing until its gate is green: ` +
|
|
2304
|
+
`that gate ran when the pair was written, and the document is the record of what it passed, its spine.sha256 naming the skeleton bytes the gate read ` +
|
|
2305
|
+
"— on this entry the gate is the model side's rules over the document and the round trip's own restated over the emitted text, " +
|
|
2306
|
+
'with A00_ROUNDTRIP_PARSE alone reported as a SKIP.'
|
|
2307
|
+
);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* `gatedOnWriteSentence` for a refused command marked `rerunsTheGate`, or
|
|
2312
|
+
* `null` — the refusal then says what it always said. The target is
|
|
2313
|
+
* `cmdValidate`'s: the positional (`.` without one) through `resolveBuild`;
|
|
2314
|
+
* a `--cut` or `--rig` run derives its directory from a spec, which the
|
|
2315
|
+
* refusal does not compile, so it keeps the sentence it had. Arguments
|
|
2316
|
+
* `parseArgs` or `resolveBuild` refuses are the command's to refuse, not the
|
|
2317
|
+
* runtime refusal's, and leave it as it was.
|
|
2318
|
+
*/
|
|
2319
|
+
function gatedOnWrite(doc: CommandDoc, args: readonly string[]): string | null {
|
|
2320
|
+
if (doc.runtime === false || doc.runtime.rerunsTheGate !== true) return null;
|
|
2321
|
+
try {
|
|
2322
|
+
const { flags, positional } = parseArgs([...args], REPEATABLE_FLAGS[doc.name]);
|
|
2323
|
+
if (flags.cut !== undefined || flags.rig !== undefined) return null;
|
|
2324
|
+
const build = resolveBuild(positional[0] ?? '.', flags.atlas);
|
|
2325
|
+
return build.modelPath === null ? null : gatedOnWriteSentence(build.skeletonPath);
|
|
2326
|
+
} catch (err) {
|
|
2327
|
+
if (err instanceof UsageError) return null;
|
|
2328
|
+
throw err;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2282
2332
|
/**
|
|
2283
2333
|
* A command `COMMANDS` documents and this entry does not run, because its body
|
|
2284
2334
|
* reaches spine-core and the entry links none of it — refused naming what the
|
|
@@ -2286,10 +2336,12 @@ export function entryCommands(linksRuntime: boolean): CommandDoc[] {
|
|
|
2286
2336
|
* A `SpineRuntimeError`, so it is printed and exits as the export an entry
|
|
2287
2337
|
* cannot pose is.
|
|
2288
2338
|
*/
|
|
2289
|
-
function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[]): SpineRuntimeError {
|
|
2339
|
+
function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[], args: readonly string[]): SpineRuntimeError {
|
|
2290
2340
|
const needs = doc.runtime === false ? 'nothing' : doc.runtime.for;
|
|
2341
|
+
const gated = gatedOnWrite(doc, args);
|
|
2291
2342
|
return new SpineRuntimeError(
|
|
2292
2343
|
`\`${doc.name}\` runs through spine-core (${needs}), and the runtime could not be used: ${SPINE_SIDE_ABSENT}. ` +
|
|
2344
|
+
(gated === null ? '' : `${gated} `) +
|
|
2293
2345
|
`The commands this entry runs are ${runs.join(', ')}; the entry that links spine-core runs \`${doc.name}\` — ` +
|
|
2294
2346
|
`installed, \`rigc ${doc.name}\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts ${doc.name}\``,
|
|
2295
2347
|
);
|
|
@@ -2325,7 +2377,7 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
|
|
|
2325
2377
|
}
|
|
2326
2378
|
if (!known.includes(command)) {
|
|
2327
2379
|
const elsewhere = COMMANDS.find((doc) => doc.name === command);
|
|
2328
|
-
if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known);
|
|
2380
|
+
if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known, rest);
|
|
2329
2381
|
throw new UsageError(`unknown command: ${command}`);
|
|
2330
2382
|
}
|
|
2331
2383
|
|
|
@@ -936,6 +936,103 @@ export function shapeDiff(before: Shape, after: Shape): string[] {
|
|
|
936
936
|
return rows;
|
|
937
937
|
}
|
|
938
938
|
|
|
939
|
+
/** What a JSON value is, in the words a refusal names it by: `null`, `an array`, `a number`, … */
|
|
940
|
+
function jsonKind(value: unknown): string {
|
|
941
|
+
if (value === null) return 'null';
|
|
942
|
+
if (Array.isArray(value)) return 'an array';
|
|
943
|
+
return typeof value === 'object' ? 'an object' : `a ${typeof value}`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** True for the one JSON kind a skeleton file and each of its records is: a plain object. */
|
|
947
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
948
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The build's `skeleton.json`, read and parsed once, or the sentence the tool
|
|
953
|
+
* refuses it with (issue #1090).
|
|
954
|
+
*
|
|
955
|
+
* 🚨 The `skeleton.images` probe in `main` used to be the first thing that read
|
|
956
|
+
* this file, and it read it with a bare `JSON.parse`. A file that was not JSON,
|
|
957
|
+
* or was JSON holding `null`, therefore left the tool as an uncaught
|
|
958
|
+
* `SyntaxError` / `TypeError` and its stack — before the editor, so with nothing
|
|
959
|
+
* of the editor's to quote, and in a shape no other refusal in this file has.
|
|
960
|
+
* And a file holding `[]` or `7` did not stop at all: the probe found no
|
|
961
|
+
* `skeleton.images` on it and the run went on to hand the editor a file that is
|
|
962
|
+
* not a skeleton, minutes of import for a refusal this could state for free.
|
|
963
|
+
*
|
|
964
|
+
* ⭐ So this is the one reader of the build's skeleton text before step 1, and
|
|
965
|
+
* it refuses by the file's path and what it found there: a directory, the
|
|
966
|
+
* parser's own message, or the JSON kind that is not an object. What it hands
|
|
967
|
+
* back is the value the probe reads, so a readable build is parsed exactly as
|
|
968
|
+
* it was and every line after this prints as it did.
|
|
969
|
+
*/
|
|
970
|
+
function readBuildSkeleton(source: string): { skeleton: Record<string, unknown> } | { refusal: string } {
|
|
971
|
+
const required =
|
|
972
|
+
'The round trip imports that file into the editor, so it has to be one skeleton JSON object — `skeleton`, ' +
|
|
973
|
+
'`bones`, `slots`, … — which is what `rigc build` writes there.';
|
|
974
|
+
let text: string;
|
|
975
|
+
try {
|
|
976
|
+
text = readFileSync(source, 'utf8');
|
|
977
|
+
} catch (err) {
|
|
978
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
979
|
+
if (code === 'EISDIR') return { refusal: `the build's skeleton.json at ${source} is a directory, not a file. ${required}` };
|
|
980
|
+
return { refusal: `the build's skeleton.json at ${source} could not be read (${code ?? String(err)}). ${required}` };
|
|
981
|
+
}
|
|
982
|
+
if (text.trim() === '') {
|
|
983
|
+
return { refusal: `the build's skeleton.json at ${source} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
|
|
984
|
+
}
|
|
985
|
+
let parsed: unknown;
|
|
986
|
+
try {
|
|
987
|
+
parsed = JSON.parse(text);
|
|
988
|
+
} catch (err) {
|
|
989
|
+
const said = err instanceof Error ? err.message : String(err);
|
|
990
|
+
return { refusal: `the build's skeleton.json at ${source} is not JSON — the parser stopped with "${said}". ${required}` };
|
|
991
|
+
}
|
|
992
|
+
if (!isRecord(parsed)) {
|
|
993
|
+
return { refusal: `the build's skeleton.json at ${source} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
|
|
994
|
+
}
|
|
995
|
+
return { skeleton: parsed };
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The field of the build's skeleton the images probe would have thrown on, as a
|
|
1000
|
+
* sentence naming it and what it holds — or null when every field the probe
|
|
1001
|
+
* dereferences is the kind it reads (issue #1090).
|
|
1002
|
+
*
|
|
1003
|
+
* ⚠️ Only those fields, and only on the path that reads them: the walk below
|
|
1004
|
+
* runs when `skeleton.images` is declared, and a field this does not read is the
|
|
1005
|
+
* gate's to judge after the import, not a precondition of the editor's. Asking
|
|
1006
|
+
* more here would be a second validator in front of the first.
|
|
1007
|
+
*/
|
|
1008
|
+
function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
|
|
1009
|
+
const head = skeleton.skeleton;
|
|
1010
|
+
if (!isRecord(head)) return null;
|
|
1011
|
+
const images = head.images;
|
|
1012
|
+
if (images === undefined) return null;
|
|
1013
|
+
if (typeof images !== 'string') return `\`skeleton.images\` holds ${jsonKind(images)}, not a path string`;
|
|
1014
|
+
if (images === '') return null;
|
|
1015
|
+
const skins = skeleton.skins;
|
|
1016
|
+
if (skins === undefined) return null;
|
|
1017
|
+
if (!Array.isArray(skins)) return `\`skins\` holds ${jsonKind(skins)}, not an array`;
|
|
1018
|
+
for (let i = 0; i < skins.length; i++) {
|
|
1019
|
+
const skin: unknown = skins[i];
|
|
1020
|
+
if (!isRecord(skin)) return `\`skins[${i}]\` holds ${jsonKind(skin)}, not an object`;
|
|
1021
|
+
const attachments = skin.attachments;
|
|
1022
|
+
if (attachments === undefined) continue;
|
|
1023
|
+
if (!isRecord(attachments)) return `\`skins[${i}].attachments\` holds ${jsonKind(attachments)}, not an object`;
|
|
1024
|
+
for (const [slot, entries] of Object.entries(attachments)) {
|
|
1025
|
+
if (!isRecord(entries)) return `\`skins[${i}].attachments.${slot}\` holds ${jsonKind(entries)}, not an object`;
|
|
1026
|
+
for (const [placeholder, att] of Object.entries(entries)) {
|
|
1027
|
+
if (!isRecord(att)) {
|
|
1028
|
+
return `\`skins[${i}].attachments.${slot}.${placeholder}\` holds ${jsonKind(att)}, not an object`;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
939
1036
|
function main(): void {
|
|
940
1037
|
const opts = parseArgs(process.argv.slice(2));
|
|
941
1038
|
const rigc = rigcCommand();
|
|
@@ -975,6 +1072,19 @@ function main(): void {
|
|
|
975
1072
|
};
|
|
976
1073
|
|
|
977
1074
|
if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
|
|
1075
|
+
// 🔒 Read and parsed HERE, once, before anything else reads it (issue #1090):
|
|
1076
|
+
// a build whose skeleton is not one JSON object is refused by name rather than
|
|
1077
|
+
// thrown as a stack by the probe below, or handed to the editor.
|
|
1078
|
+
const read = readBuildSkeleton(source);
|
|
1079
|
+
if ('refusal' in read) return fail(read.refusal);
|
|
1080
|
+
const probeFault = imagesProbeFault(read.skeleton);
|
|
1081
|
+
if (probeFault !== null) {
|
|
1082
|
+
fail(
|
|
1083
|
+
`the build's skeleton.json at ${source} is an object, but ${probeFault} — one of the fields read before ` +
|
|
1084
|
+
"step 1 to check that every image the editor's import will look for is there. A skeleton `rigc build` " +
|
|
1085
|
+
'wrote never carries that; rebuild the directory with `rigc build … --copy-images`.',
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
978
1088
|
|
|
979
1089
|
// 🚨 The art the EDITOR will look for, checked before the editor is started —
|
|
980
1090
|
// issue #562. The editor's JSON import reads `skeleton.images` and finds each
|
|
@@ -1010,7 +1120,9 @@ function main(): void {
|
|
|
1010
1120
|
skeleton?: { images?: string };
|
|
1011
1121
|
skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; name?: string; path?: string }>> }>;
|
|
1012
1122
|
}
|
|
1013
|
-
|
|
1123
|
+
// The value `readBuildSkeleton` parsed, and the fields this walk reads are
|
|
1124
|
+
// the ones `imagesProbeFault` has already held to their kinds.
|
|
1125
|
+
const probe = read.skeleton as ImagesProbe;
|
|
1014
1126
|
const declared = probe.skeleton?.images;
|
|
1015
1127
|
if (declared !== undefined && declared !== '') {
|
|
1016
1128
|
const imagesDir = resolve(opts.build, declared);
|