spine-rigc 2.1.1 → 2.1.3
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 +4 -1
- package/docs/AUTHORING.md +37 -20
- package/package.json +1 -1
- package/src/atlas.ts +349 -121
- package/src/cli/shared.ts +56 -4
- package/tools/editor_roundtrip.ts +232 -32
package/README.md
CHANGED
|
@@ -698,7 +698,10 @@ 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,
|
|
703
|
+
and so is a build with no atlas or an atlas that is a directory, or an
|
|
704
|
+
`--exported` file that is not one JSON object.
|
|
702
705
|
|
|
703
706
|
⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
|
|
704
707
|
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,26 +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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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.
|
|
540
557
|
- **The pixels.** Every cell is drawn whole in packing order, then every
|
|
541
558
|
region's owned texels are drawn again with its own values, so every texel a
|
|
542
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.3",
|
|
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, Infinity, { wholeOrNothing:
|
|
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. */
|
|
@@ -916,6 +979,8 @@ export interface FreePageSearchOptions {
|
|
|
916
979
|
stopAtMiss?: boolean;
|
|
917
980
|
/** `false` scans the whole free list for a piece's containers (`splitFree`), as the prune did before issue #1102. */
|
|
918
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;
|
|
919
984
|
}
|
|
920
985
|
|
|
921
986
|
/**
|
|
@@ -948,7 +1013,7 @@ export function freePageSearch(
|
|
|
948
1013
|
cellArea = 0;
|
|
949
1014
|
for (const shape of shapes) cellArea += shape.owned;
|
|
950
1015
|
}
|
|
951
|
-
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally:
|
|
1016
|
+
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0, tally: emptyTally() };
|
|
952
1017
|
let best: { width: number; height: number; rects: Rect[] } | null = null;
|
|
953
1018
|
for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
|
|
954
1019
|
out.widths++;
|
|
@@ -958,7 +1023,7 @@ export function freePageSearch(
|
|
|
958
1023
|
continue;
|
|
959
1024
|
}
|
|
960
1025
|
const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
|
|
961
|
-
const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true });
|
|
1026
|
+
const attempt = placePass(cells, shapes, width, maxEdge, maxBottom, { wholeOrNothing, tally: out.tally, edgeIndex: options.edgeIndex ?? true, anchors: options.anchors });
|
|
962
1027
|
let height = 0;
|
|
963
1028
|
for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
|
|
964
1029
|
if (height > maxBottom) {
|
|
@@ -1150,7 +1215,7 @@ function placePass(
|
|
|
1150
1215
|
// it did not abandon (46 %). The largest cell's owned box does not start at
|
|
1151
1216
|
// its cell's corner, so on an empty page that cell is every footprint pass's
|
|
1152
1217
|
// 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
|
|
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
|
|
1154
1219
|
// same pages.
|
|
1155
1220
|
const rectCanFit = !wholeOrNothing || cellAreaOf(cells) <= pageW * pageH;
|
|
1156
1221
|
const byRect = rectCanFit ? packOnePage(cells, pageW, pageH, maxBottom) : null;
|
|
@@ -1165,8 +1230,20 @@ function placePass(
|
|
|
1165
1230
|
// than `rect` on two of the five gallery rigs that carry a mesh, because a
|
|
1166
1231
|
// greedy pass that places one cell differently places every later one
|
|
1167
1232
|
// differently too.
|
|
1168
|
-
const
|
|
1169
|
-
|
|
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);
|
|
1170
1247
|
if (byRect === null) return byFootprint;
|
|
1171
1248
|
const bottomOf = (placed: Array<Rect | null>): number => placed.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
|
|
1172
1249
|
const placedRect = placedCount(byRect);
|
|
@@ -1183,6 +1260,8 @@ interface PassOptions {
|
|
|
1183
1260
|
tally?: PassTally;
|
|
1184
1261
|
/** `splitFree`'s edge index (default on); off only for the selftest's plant. */
|
|
1185
1262
|
edgeIndex?: boolean;
|
|
1263
|
+
/** Where a candidate may anchor a cell (`FOOTPRINT_ANCHORS`). */
|
|
1264
|
+
anchors?: FootprintAnchors;
|
|
1186
1265
|
}
|
|
1187
1266
|
|
|
1188
1267
|
/** How many cells a pass placed. */
|
|
@@ -1213,6 +1292,22 @@ export interface PassTally {
|
|
|
1213
1292
|
bandSplits: number;
|
|
1214
1293
|
/** Containment tests the footprint passes' prune made (`splitFree`). */
|
|
1215
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 };
|
|
1216
1311
|
}
|
|
1217
1312
|
|
|
1218
1313
|
// ---------------------------------------------------------------------------
|
|
@@ -1589,9 +1684,12 @@ const UNTOUCHED = -1;
|
|
|
1589
1684
|
* * a candidate is a free rectangle that holds the cell's protected set's
|
|
1590
1685
|
* bounding box, anchored at its top-left corner — the cell placed so that
|
|
1591
1686
|
* box's corner lands there, which may put the cell's own unclaimed
|
|
1592
|
-
* texels over a neighbour's — and whose cell stays on the page
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
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);
|
|
1595
1693
|
* * the free list is split and pruned by the same code (`splitFree`), once
|
|
1596
1694
|
* per rectangle of the protected set.
|
|
1597
1695
|
*
|
|
@@ -1600,6 +1698,27 @@ const UNTOUCHED = -1;
|
|
|
1600
1698
|
* a pack with no mesh region makes `packOnePage`'s decisions one for one
|
|
1601
1699
|
* (`PK82`).
|
|
1602
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
|
+
*
|
|
1603
1722
|
* 🔒 **The footprint test is then stated outright rather than trusted to the
|
|
1604
1723
|
* bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
|
|
1605
1724
|
* because no free rectangle overlaps one; every placement is nevertheless
|
|
@@ -1618,6 +1737,7 @@ function packOnePageByFootprint(
|
|
|
1618
1737
|
stopAtMiss = false,
|
|
1619
1738
|
edgeIndex = true,
|
|
1620
1739
|
tally?: PassTally,
|
|
1740
|
+
anchors: FootprintAnchors = DEFAULT_FOOTPRINT_ANCHORS,
|
|
1621
1741
|
): Array<Rect | null> {
|
|
1622
1742
|
const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
|
|
1623
1743
|
const placed: Array<Rect | null> = [];
|
|
@@ -1633,33 +1753,56 @@ function packOnePageByFootprint(
|
|
|
1633
1753
|
|
|
1634
1754
|
for (const shape of shapes) {
|
|
1635
1755
|
const box = shape.bbox;
|
|
1636
|
-
let best:
|
|
1756
|
+
let best: { x: number; y: number; frX: number; frY: number; anchor: number } | null = null;
|
|
1637
1757
|
let bestShort = Infinity;
|
|
1638
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;
|
|
1639
1762
|
for (const fr of free) {
|
|
1640
1763
|
if (fr.w < box.w || fr.h < box.h) continue;
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
if (
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
+
}
|
|
1655
1797
|
}
|
|
1656
1798
|
}
|
|
1799
|
+
best = { x, y, frX: fr.x, frY: fr.y, anchor };
|
|
1800
|
+
bestShort = short;
|
|
1801
|
+
bestLong = long;
|
|
1657
1802
|
}
|
|
1658
|
-
best = fr;
|
|
1659
|
-
bestShort = short;
|
|
1660
|
-
bestLong = long;
|
|
1661
1803
|
}
|
|
1662
1804
|
if (best === null) {
|
|
1805
|
+
if (held && tally !== undefined) tally.missedByAnchor++;
|
|
1663
1806
|
placed.push(null);
|
|
1664
1807
|
// A caller that keeps only a pass placing every cell has its answer
|
|
1665
1808
|
// (`placePass`, `wholeOrNothing`): the rest are not placed.
|
|
@@ -1669,7 +1812,8 @@ function packOnePageByFootprint(
|
|
|
1669
1812
|
}
|
|
1670
1813
|
continue;
|
|
1671
1814
|
}
|
|
1672
|
-
|
|
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 };
|
|
1673
1817
|
for (const other of owned) {
|
|
1674
1818
|
// Two cells that do not overlap cannot share a texel, and that answer
|
|
1675
1819
|
// takes no texel to read; only an overlap is scanned, and only over the
|
|
@@ -1695,6 +1839,21 @@ function packOnePageByFootprint(
|
|
|
1695
1839
|
return placed;
|
|
1696
1840
|
}
|
|
1697
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
|
+
|
|
1698
1857
|
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
1699
1858
|
const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
1700
1859
|
|
|
@@ -1902,6 +2061,130 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
1902
2061
|
}
|
|
1903
2062
|
}
|
|
1904
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
|
+
|
|
1905
2188
|
/**
|
|
1906
2189
|
* Pack these parts onto shared pages.
|
|
1907
2190
|
*
|
|
@@ -1965,9 +2248,17 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
1965
2248
|
* two footprints keeps them `2 · padding` apart (`CellShape`);
|
|
1966
2249
|
* * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
|
|
1967
2250
|
* split by what each cell owns, run beside the rectangle pass on every page
|
|
1968
|
-
* tried and kept only when it is better (`placePass`)
|
|
1969
|
-
*
|
|
1970
|
-
*
|
|
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;
|
|
1971
2262
|
* * **the pixels** are drawn in two passes: every cell whole in packing order,
|
|
1972
2263
|
* as under `rect`, then every region's owned texels again with its own
|
|
1973
2264
|
* values. The owned sets are disjoint, so the second pass's order decides
|
|
@@ -2027,94 +2318,31 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
2027
2318
|
const shapes =
|
|
2028
2319
|
shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
|
|
2029
2320
|
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
placements.push(place);
|
|
2051
|
-
});
|
|
2052
|
-
} else {
|
|
2053
|
-
// Spill. Which parts share a page is decided at the MAXIMUM size — that is
|
|
2054
|
-
// what makes the boundary deterministic and independent of the shrink below
|
|
2055
|
-
// — and parts are taken in packing order, whatever will not fit the current
|
|
2056
|
-
// page opening the next one.
|
|
2057
|
-
let remaining = sorted.map((input, i) => ({ input, cell: cells[i], shape: shapes?.[i] }));
|
|
2058
|
-
const shapesOf = (list: typeof remaining): CellShape[] | undefined =>
|
|
2059
|
-
shapes === undefined ? undefined : list.map((r) => r.shape ?? footprintCell(r.input.width, r.input.height, padding));
|
|
2060
|
-
while (remaining.length > 0) {
|
|
2061
|
-
const pageIndex = perPage.length;
|
|
2062
|
-
const attempt = placePass(
|
|
2063
|
-
remaining.map((r) => r.cell),
|
|
2064
|
-
shapesOf(remaining),
|
|
2065
|
-
maxEdge,
|
|
2066
|
-
maxEdge,
|
|
2067
|
-
);
|
|
2068
|
-
const onPage: typeof remaining = [];
|
|
2069
|
-
const leftOver: typeof remaining = [];
|
|
2070
|
-
attempt.forEach((rect, i) => {
|
|
2071
|
-
if (rect === null) leftOver.push(remaining[i]);
|
|
2072
|
-
else onPage.push(remaining[i]);
|
|
2073
|
-
});
|
|
2074
|
-
if (onPage.length === 0) {
|
|
2075
|
-
// Unreachable: every cell was proven to fit an empty page above. Kept as
|
|
2076
|
-
// a named stop rather than an infinite loop if that ever stops holding.
|
|
2077
|
-
throw new CompileError(
|
|
2078
|
-
`packing stalled with ${remaining.length} region(s) left and an empty ${maxEdge}x${maxEdge} page`,
|
|
2079
|
-
);
|
|
2080
|
-
}
|
|
2081
|
-
// ⭐ Then the page is written at the smallest power-of-two pair that holds
|
|
2082
|
-
// the cells assigned to it, not at the maximum (issue #266, follow-up 3).
|
|
2083
|
-
// A spill used to write `pageSize x pageSize` for every page, so a set that
|
|
2084
|
-
// overflowed by one small part paid for a second full page of transparency
|
|
2085
|
-
// — 4 MiB of decoded RAM at the 2048 default for a part that might be
|
|
2086
|
-
// 64x64. Re-packing through the same search the single-page case uses is
|
|
2087
|
-
// what keeps "the page written is the smallest that holds what is on it"
|
|
2088
|
-
// one rule; a page whose own cells need the maximum simply gets it back.
|
|
2089
|
-
const shrunk = smallestPageFor(
|
|
2090
|
-
onPage.map((r) => r.cell),
|
|
2091
|
-
maxEdge,
|
|
2092
|
-
pageEdges,
|
|
2093
|
-
shapesOf(onPage),
|
|
2094
|
-
);
|
|
2095
|
-
if (shrunk === null) {
|
|
2096
|
-
// Unreachable for the same reason as the stall above: these cells were
|
|
2097
|
-
// just placed on a maxEdge page.
|
|
2098
|
-
throw new CompileError(`page ${pageIndex + 1} of the spill holds ${onPage.length} region(s) that no page fits`);
|
|
2099
|
-
}
|
|
2100
|
-
const onThisPage: Placement[] = [];
|
|
2101
|
-
shrunk.rects.forEach((rect, i) => {
|
|
2102
|
-
const place: Placement = {
|
|
2103
|
-
region: onPage[i].input.region,
|
|
2104
|
-
page: pageIndex,
|
|
2105
|
-
x: rect.x + padding,
|
|
2106
|
-
y: rect.y + padding,
|
|
2107
|
-
width: onPage[i].input.width,
|
|
2108
|
-
height: onPage[i].input.height,
|
|
2109
|
-
};
|
|
2110
|
-
onThisPage.push(place);
|
|
2111
|
-
placements.push(place);
|
|
2112
|
-
});
|
|
2113
|
-
perPage.push(onThisPage);
|
|
2114
|
-
pageSizes.push({ width: shrunk.width, height: shrunk.height });
|
|
2115
|
-
remaining = leftOver;
|
|
2116
|
-
}
|
|
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) });
|
|
2117
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;
|
|
2118
2346
|
|
|
2119
2347
|
// Draw. Reading each source once, in packing order, keeps the decode count at
|
|
2120
2348
|
// one per part whatever the page layout turned out to be.
|
|
@@ -2188,7 +2416,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
2188
2416
|
});
|
|
2189
2417
|
});
|
|
2190
2418
|
|
|
2191
|
-
return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape };
|
|
2419
|
+
return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape, candidate: chosen.name };
|
|
2192
2420
|
}
|
|
2193
2421
|
|
|
2194
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,167 @@ 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
|
+
return readSkeletonObject(
|
|
972
|
+
source,
|
|
973
|
+
"the build's skeleton.json",
|
|
974
|
+
'The round trip imports that file into the editor, so it has to be one skeleton JSON object — `skeleton`, ' +
|
|
975
|
+
'`bones`, `slots`, … — which is what `rigc build` writes there.',
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* One skeleton file read and parsed, with its bytes — or the sentence the tool
|
|
981
|
+
* refuses it with, naming `what` it is, its path, what was found there and the
|
|
982
|
+
* `required` sentence. The one body behind `readBuildSkeleton` and the
|
|
983
|
+
* `--exported` read (issue #1107), so the two files are refused in one shape.
|
|
984
|
+
*
|
|
985
|
+
* The bytes are handed back because the `--exported` file is written into the
|
|
986
|
+
* candidate from them: the file step 3 measures is then the file this parsed,
|
|
987
|
+
* byte for byte, rather than whatever is at the path by the time step 3 runs.
|
|
988
|
+
*/
|
|
989
|
+
function readSkeletonObject(
|
|
990
|
+
path: string,
|
|
991
|
+
what: string,
|
|
992
|
+
required: string,
|
|
993
|
+
): { skeleton: Record<string, unknown>; bytes: Buffer } | { refusal: string } {
|
|
994
|
+
let bytes: Buffer;
|
|
995
|
+
try {
|
|
996
|
+
bytes = readFileSync(path);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
999
|
+
if (code === 'EISDIR') return { refusal: `${what} at ${path} is a directory, not a file. ${required}` };
|
|
1000
|
+
return { refusal: `${what} at ${path} could not be read (${code ?? String(err)}). ${required}` };
|
|
1001
|
+
}
|
|
1002
|
+
const text = bytes.toString('utf8');
|
|
1003
|
+
if (text.trim() === '') {
|
|
1004
|
+
return { refusal: `${what} at ${path} is empty (${text.length} byte(s), no JSON value in them). ${required}` };
|
|
1005
|
+
}
|
|
1006
|
+
let parsed: unknown;
|
|
1007
|
+
try {
|
|
1008
|
+
parsed = JSON.parse(text);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const said = err instanceof Error ? err.message : String(err);
|
|
1011
|
+
return { refusal: `${what} at ${path} is not JSON — the parser stopped with "${said}". ${required}` };
|
|
1012
|
+
}
|
|
1013
|
+
if (!isRecord(parsed)) {
|
|
1014
|
+
return { refusal: `${what} at ${path} is JSON, but it holds ${jsonKind(parsed)} rather than an object. ${required}` };
|
|
1015
|
+
}
|
|
1016
|
+
return { skeleton: parsed, bytes };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* The build's atlas, read once before step 1, or the sentence the tool refuses
|
|
1021
|
+
* it with (issue #1107): its name and the page names it lists.
|
|
1022
|
+
*
|
|
1023
|
+
* 🚨 This read used to sit at step 3, after the editor had imported and
|
|
1024
|
+
* exported. So a build with no atlas was refused by name only after a licensed
|
|
1025
|
+
* editor had spent an import and an export on it, and a directory named
|
|
1026
|
+
* `*.atlas` beside the build threw `EISDIR` there as a stack. Neither needs the
|
|
1027
|
+
* editor to answer: the atlas is rigc's input to step 3, not the editor's.
|
|
1028
|
+
*
|
|
1029
|
+
* ⭐ Every `*.atlas` entry is read, not only the one whose pages are listed,
|
|
1030
|
+
* because step 3 copies every one of them beside the export, and a directory
|
|
1031
|
+
* among them throws at that copy. The first entry is the atlas, as it always was.
|
|
1032
|
+
*
|
|
1033
|
+
* ⚠️ "Read" is all this can mean. The tool's own reader of an atlas is the line
|
|
1034
|
+
* filter below, which accepts any text, so there is no malformed atlas for it to
|
|
1035
|
+
* refuse; what the atlas SAYS is the gate's to judge at step 3 (`A08`, `A17`).
|
|
1036
|
+
*/
|
|
1037
|
+
function readBuildAtlas(build: string): { pageNames: string[] } | { refusal: string } {
|
|
1038
|
+
const atlases = readdirSync(build).filter((f) => f.endsWith('.atlas'));
|
|
1039
|
+
if (atlases.length === 0) return { refusal: `no .atlas in the build directory ${build}` };
|
|
1040
|
+
const required =
|
|
1041
|
+
'Step 3 copies the build\'s atlas and pages beside the export so the export can be loaded and measured, so ' +
|
|
1042
|
+
'every `.atlas` there has to be a file — the one `rigc build` writes.';
|
|
1043
|
+
let first: string | null = null;
|
|
1044
|
+
for (const name of atlases) {
|
|
1045
|
+
const path = join(build, name);
|
|
1046
|
+
try {
|
|
1047
|
+
const text = readFileSync(path, 'utf8');
|
|
1048
|
+
if (first === null) first = text;
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
1051
|
+
if (code === 'EISDIR') return { refusal: `the build's atlas at ${path} is a directory, not a file. ${required}` };
|
|
1052
|
+
return { refusal: `the build's atlas at ${path} could not be read (${code ?? String(err)}). ${required}` };
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const pageNames = (first ?? '')
|
|
1056
|
+
.split('\n')
|
|
1057
|
+
.filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
|
|
1058
|
+
.map((line) => line.trim());
|
|
1059
|
+
return { pageNames };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* The field of the build's skeleton the images probe would have thrown on, as a
|
|
1064
|
+
* sentence naming it and what it holds — or null when every field the probe
|
|
1065
|
+
* dereferences is the kind it reads (issue #1090).
|
|
1066
|
+
*
|
|
1067
|
+
* ⚠️ Only those fields, and only on the path that reads them: the walk below
|
|
1068
|
+
* runs when `skeleton.images` is declared, and a field this does not read is the
|
|
1069
|
+
* gate's to judge after the import, not a precondition of the editor's. Asking
|
|
1070
|
+
* more here would be a second validator in front of the first.
|
|
1071
|
+
*/
|
|
1072
|
+
function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
|
|
1073
|
+
const head = skeleton.skeleton;
|
|
1074
|
+
if (!isRecord(head)) return null;
|
|
1075
|
+
const images = head.images;
|
|
1076
|
+
if (images === undefined) return null;
|
|
1077
|
+
if (typeof images !== 'string') return `\`skeleton.images\` holds ${jsonKind(images)}, not a path string`;
|
|
1078
|
+
if (images === '') return null;
|
|
1079
|
+
const skins = skeleton.skins;
|
|
1080
|
+
if (skins === undefined) return null;
|
|
1081
|
+
if (!Array.isArray(skins)) return `\`skins\` holds ${jsonKind(skins)}, not an array`;
|
|
1082
|
+
for (let i = 0; i < skins.length; i++) {
|
|
1083
|
+
const skin: unknown = skins[i];
|
|
1084
|
+
if (!isRecord(skin)) return `\`skins[${i}]\` holds ${jsonKind(skin)}, not an object`;
|
|
1085
|
+
const attachments = skin.attachments;
|
|
1086
|
+
if (attachments === undefined) continue;
|
|
1087
|
+
if (!isRecord(attachments)) return `\`skins[${i}].attachments\` holds ${jsonKind(attachments)}, not an object`;
|
|
1088
|
+
for (const [slot, entries] of Object.entries(attachments)) {
|
|
1089
|
+
if (!isRecord(entries)) return `\`skins[${i}].attachments.${slot}\` holds ${jsonKind(entries)}, not an object`;
|
|
1090
|
+
for (const [placeholder, att] of Object.entries(entries)) {
|
|
1091
|
+
if (!isRecord(att)) {
|
|
1092
|
+
return `\`skins[${i}].attachments.${slot}.${placeholder}\` holds ${jsonKind(att)}, not an object`;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
939
1100
|
function main(): void {
|
|
940
1101
|
const opts = parseArgs(process.argv.slice(2));
|
|
941
1102
|
const rigc = rigcCommand();
|
|
@@ -975,6 +1136,19 @@ function main(): void {
|
|
|
975
1136
|
};
|
|
976
1137
|
|
|
977
1138
|
if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
|
|
1139
|
+
// 🔒 Read and parsed HERE, once, before anything else reads it (issue #1090):
|
|
1140
|
+
// a build whose skeleton is not one JSON object is refused by name rather than
|
|
1141
|
+
// thrown as a stack by the probe below, or handed to the editor.
|
|
1142
|
+
const read = readBuildSkeleton(source);
|
|
1143
|
+
if ('refusal' in read) return fail(read.refusal);
|
|
1144
|
+
const probeFault = imagesProbeFault(read.skeleton);
|
|
1145
|
+
if (probeFault !== null) {
|
|
1146
|
+
fail(
|
|
1147
|
+
`the build's skeleton.json at ${source} is an object, but ${probeFault} — one of the fields read before ` +
|
|
1148
|
+
"step 1 to check that every image the editor's import will look for is there. A skeleton `rigc build` " +
|
|
1149
|
+
'wrote never carries that; rebuild the directory with `rigc build … --copy-images`.',
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
978
1152
|
|
|
979
1153
|
// 🚨 The art the EDITOR will look for, checked before the editor is started —
|
|
980
1154
|
// issue #562. The editor's JSON import reads `skeleton.images` and finds each
|
|
@@ -1001,16 +1175,22 @@ function main(): void {
|
|
|
1001
1175
|
// makes — and `--pack --copy-images` is refused by `cli.ts`, correctly,
|
|
1002
1176
|
// because a packed atlas does not reference loose parts.
|
|
1003
1177
|
//
|
|
1004
|
-
// ⛔ It is checked HERE, before step 1,
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1178
|
+
// ⛔ It is checked HERE, before step 1, because it matters to the editor's
|
|
1179
|
+
// import in step 1. The atlas and an `--exported` file are read before step 1
|
|
1180
|
+
// as well, just below, although nothing before step 3 uses them (issue #1107):
|
|
1181
|
+
// they are rigc's inputs and not the editor's, so reading them first costs the
|
|
1182
|
+
// editor nothing and refuses before a licensed editor is started on a trip
|
|
1183
|
+
// that cannot finish — the reason `readBuildSkeleton` reads the build's
|
|
1184
|
+
// skeleton first (#1090). So the order is: every input rigc reads is read
|
|
1185
|
+
// before the editor starts, and only what the editor writes is read after it.
|
|
1008
1186
|
{
|
|
1009
1187
|
interface ImagesProbe {
|
|
1010
1188
|
skeleton?: { images?: string };
|
|
1011
1189
|
skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; name?: string; path?: string }>> }>;
|
|
1012
1190
|
}
|
|
1013
|
-
|
|
1191
|
+
// The value `readBuildSkeleton` parsed, and the fields this walk reads are
|
|
1192
|
+
// the ones `imagesProbeFault` has already held to their kinds.
|
|
1193
|
+
const probe = read.skeleton as ImagesProbe;
|
|
1014
1194
|
const declared = probe.skeleton?.images;
|
|
1015
1195
|
if (declared !== undefined && declared !== '') {
|
|
1016
1196
|
const imagesDir = resolve(opts.build, declared);
|
|
@@ -1055,6 +1235,48 @@ function main(): void {
|
|
|
1055
1235
|
}
|
|
1056
1236
|
}
|
|
1057
1237
|
|
|
1238
|
+
// The export is a skeleton only; it needs the build's atlas and pages beside
|
|
1239
|
+
// it to be a candidate anything can load — so the atlas is step 3's, and it is
|
|
1240
|
+
// read here, before step 1, for the reason the sentence above gives.
|
|
1241
|
+
//
|
|
1242
|
+
// 🚨 Which is why the build has to be SELF-CONTAINED, and this refuses when it
|
|
1243
|
+
// is not. An ordinary build's atlas names its pages by a relative path back to
|
|
1244
|
+
// the art directory; copy that atlas to a directory at another depth and every
|
|
1245
|
+
// page name resolves to nothing. Found by running this tool — it reported four
|
|
1246
|
+
// `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
|
|
1247
|
+
// the editor's, which is the worst kind of red: a real assertion, correctly
|
|
1248
|
+
// fired, pointing at the wrong culprit.
|
|
1249
|
+
const atlas = readBuildAtlas(opts.build);
|
|
1250
|
+
if ('refusal' in atlas) return fail(atlas.refusal);
|
|
1251
|
+
const wandering = atlas.pageNames.filter((n) => n.includes('/'));
|
|
1252
|
+
if (wandering.length > 0) {
|
|
1253
|
+
fail(
|
|
1254
|
+
`the build's atlas names its pages by path, not by filename — the first is "${wandering[0]}". ` +
|
|
1255
|
+
'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
|
|
1256
|
+
`${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
|
|
1257
|
+
'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// 🔒 The `--exported` file, read and parsed here for the same reason (issue
|
|
1262
|
+
// #1107): one skeleton JSON object, in `readBuildSkeleton`'s shape, or refused
|
|
1263
|
+
// by name. It used to be read first by step 4's `skinsDeclaredBy`, bare, so a
|
|
1264
|
+
// file that was not JSON was gated and diffed by steps 3–4 and then left the
|
|
1265
|
+
// tool as a `SyntaxError` stack. Step 3's candidate is written from these
|
|
1266
|
+
// bytes, so what is measured is what was parsed.
|
|
1267
|
+
let exportedBytes: Buffer | null = null;
|
|
1268
|
+
if (opts.exported !== null) {
|
|
1269
|
+
if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
|
|
1270
|
+
const got = readSkeletonObject(
|
|
1271
|
+
opts.exported,
|
|
1272
|
+
'the --exported file',
|
|
1273
|
+
'Steps 3-6 measure that file as the editor\'s JSON export of this build, so it has to be one skeleton JSON ' +
|
|
1274
|
+
'object — `skeleton`, `bones`, `slots`, … — which is what the editor\'s `-e json` export writes.',
|
|
1275
|
+
);
|
|
1276
|
+
if ('refusal' in got) return fail(got.refusal);
|
|
1277
|
+
exportedBytes = got.bytes;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1058
1280
|
rmSync(opts.out, { recursive: true, force: true });
|
|
1059
1281
|
mkdirSync(join(opts.out, 'export'), { recursive: true });
|
|
1060
1282
|
mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
|
|
@@ -1065,7 +1287,6 @@ function main(): void {
|
|
|
1065
1287
|
|
|
1066
1288
|
let exportedJson: string;
|
|
1067
1289
|
if (opts.exported !== null) {
|
|
1068
|
-
if (!existsSync(opts.exported)) fail(`no such export: ${opts.exported}`);
|
|
1069
1290
|
exportedJson = opts.exported;
|
|
1070
1291
|
emit(` editor NOT RUN — measuring an export the editor already made: ${opts.exported}`);
|
|
1071
1292
|
emit('');
|
|
@@ -1134,33 +1355,12 @@ function main(): void {
|
|
|
1134
1355
|
exportedJson = join(opts.out, 'export', written[0]);
|
|
1135
1356
|
}
|
|
1136
1357
|
|
|
1137
|
-
// The
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
// 🚨 Which is why the build has to be SELF-CONTAINED, and this refuses when it
|
|
1141
|
-
// is not. An ordinary build's atlas names its pages by a relative path back to
|
|
1142
|
-
// the art directory; copy that atlas to a directory at another depth and every
|
|
1143
|
-
// page name resolves to nothing. Found by running this tool — it reported four
|
|
1144
|
-
// `A17_ATLAS_PAGE_FILES_EXIST` failures that were the harness's fault and not
|
|
1145
|
-
// the editor's, which is the worst kind of red: a real assertion, correctly
|
|
1146
|
-
// fired, pointing at the wrong culprit.
|
|
1358
|
+
// The candidate: the export beside the build's atlas and pages, which were
|
|
1359
|
+
// read before step 1 (see `readBuildAtlas`). An `--exported` file is written
|
|
1360
|
+
// from the bytes parsed there; the editor's own export is copied from disk.
|
|
1147
1361
|
const cand = join(opts.out, 'export-cand');
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
const pageNames = readFileSync(join(opts.build, atlasName!), 'utf8')
|
|
1151
|
-
.split('\n')
|
|
1152
|
-
.filter((line) => /\.(png|jpg|jpeg)\s*$/i.test(line.trim()) && !line.startsWith(' ') && !line.startsWith('\t'))
|
|
1153
|
-
.map((line) => line.trim());
|
|
1154
|
-
const wandering = pageNames.filter((n) => n.includes('/'));
|
|
1155
|
-
if (wandering.length > 0) {
|
|
1156
|
-
fail(
|
|
1157
|
-
`the build's atlas names its pages by path, not by filename — the first is "${wandering[0]}". ` +
|
|
1158
|
-
'The round trip copies the atlas beside the export, at a different depth, so every one of those ' +
|
|
1159
|
-
`${wandering.length} page name(s) would resolve to nothing and A17 would blame the editor for it. ` +
|
|
1160
|
-
'Rebuild with `--copy-images`, which puts the pages beside the skeleton and names them plainly.',
|
|
1161
|
-
);
|
|
1162
|
-
}
|
|
1163
|
-
copyFileSync(exportedJson, join(cand, 'skeleton.json'));
|
|
1362
|
+
if (exportedBytes !== null) writeFileSync(join(cand, 'skeleton.json'), exportedBytes);
|
|
1363
|
+
else copyFileSync(exportedJson, join(cand, 'skeleton.json'));
|
|
1164
1364
|
for (const f of readdirSync(opts.build)) {
|
|
1165
1365
|
if (f.endsWith('.atlas') || f.endsWith('.png')) copyFileSync(join(opts.build, f), join(cand, f));
|
|
1166
1366
|
}
|