spine-rigc 2.0.3 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +2 -2
- package/README.md +6 -6
- package/docs/AUTHORING.md +86 -14
- package/docs/FACE.md +1 -1
- package/docs/INGEST.md +6 -6
- package/package.json +1 -1
- package/src/assertions/bodies/a06.ts +19 -53
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/emitted/index.ts +1 -1
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/kinds.ts +6 -3
- package/src/assertions/model/index.ts +3 -1
- package/src/assertions/reasons.ts +2 -0
- package/src/atlas.ts +712 -11
- package/src/cli/shared.ts +55 -22
- package/src/validate.ts +8 -0
package/src/atlas.ts
CHANGED
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
* be ADDED to the page for the render to agree as well, and `PACK_NO_ROTATE` for
|
|
32
32
|
* the field this deliberately does not use.
|
|
33
33
|
*
|
|
34
|
+
* Under `shape: 'polygon'` (issue #1099) the copy is lossless where a region
|
|
35
|
+
* can be SAMPLED: two rectangles may overlap where neither region draws, so a
|
|
36
|
+
* mesh region's rectangle outside its hull may carry a neighbour's texels, and
|
|
37
|
+
* the claim the selftest holds there is that every texel within a tap of what
|
|
38
|
+
* a region draws is its own (`PK79`). See `packAtlas`, *`shape: 'polygon'`*.
|
|
39
|
+
*
|
|
34
40
|
* ⚠️ **The rendered pictures are equal to within one least significant bit, not
|
|
35
41
|
* bit-for-bit, and the difference is arithmetic rather than texels.** Measured: 0
|
|
36
42
|
* to 480 channel samples of 7 to 21 million on the three public fixtures, worst
|
|
@@ -693,6 +699,32 @@ export const DEFAULT_PAGE_EDGES: PageEdges = 'pot';
|
|
|
693
699
|
*/
|
|
694
700
|
export const FREE_EDGE_STEP = 1;
|
|
695
701
|
|
|
702
|
+
/**
|
|
703
|
+
* What a packed region's rectangle may share with its neighbours' — `--pack-shape`
|
|
704
|
+
* (issue #1099, stage 1 of #1093). See `packAtlas`, *`shape: 'polygon'`*.
|
|
705
|
+
*
|
|
706
|
+
* `rect` is the default and is the packer exactly as it was before the option
|
|
707
|
+
* existed: no two cells overlap. `polygon` packs every region whose every
|
|
708
|
+
* attachment is a mesh by that mesh's emitted hull, so two rectangles may
|
|
709
|
+
* overlap where neither region's footprint is; a region attachment's footprint
|
|
710
|
+
* stays its rectangle, because it draws its whole quad.
|
|
711
|
+
*/
|
|
712
|
+
export const PACK_SHAPES = ['rect', 'polygon'] as const;
|
|
713
|
+
export type PackShape = (typeof PACK_SHAPES)[number];
|
|
714
|
+
export const DEFAULT_PACK_SHAPE: PackShape = 'rect';
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The part of a region its attachments draw, in the region's own texels — x
|
|
718
|
+
* right and y down from the drawing's top-left corner, so `(u · width, v ·
|
|
719
|
+
* height)` for a mesh UV `(u, v)`. Each polygon is a flat `[x0, y0, x1, y1, …]`
|
|
720
|
+
* loop, closed from its last vertex back to its first: a mesh's hull loop, and
|
|
721
|
+
* each of its triangles. Absent on a `PackInput`, the footprint is the whole
|
|
722
|
+
* rectangle, which is what every region attachment's is.
|
|
723
|
+
*/
|
|
724
|
+
export interface PackFootprint {
|
|
725
|
+
polygons: ReadonlyArray<readonly number[]>;
|
|
726
|
+
}
|
|
727
|
+
|
|
696
728
|
/** The part a page is packed from: its region name and its pixels. */
|
|
697
729
|
export interface PackInput {
|
|
698
730
|
region: string;
|
|
@@ -700,6 +732,11 @@ export interface PackInput {
|
|
|
700
732
|
absPath: string;
|
|
701
733
|
width: number;
|
|
702
734
|
height: number;
|
|
735
|
+
/**
|
|
736
|
+
* What the region's attachments draw (`packFootprints`), read only under
|
|
737
|
+
* `shape: 'polygon'`. Absent means the rectangle.
|
|
738
|
+
*/
|
|
739
|
+
footprint?: PackFootprint;
|
|
703
740
|
}
|
|
704
741
|
|
|
705
742
|
export interface PackOptions {
|
|
@@ -711,6 +748,8 @@ export interface PackOptions {
|
|
|
711
748
|
pageStem?: string;
|
|
712
749
|
/** What the page's edges may be (default `pot`). See `PAGE_EDGES`. */
|
|
713
750
|
pageEdges?: PageEdges;
|
|
751
|
+
/** What two rectangles may share (default `rect`). See `PACK_SHAPES`. */
|
|
752
|
+
shape?: PackShape;
|
|
714
753
|
}
|
|
715
754
|
|
|
716
755
|
/** Where one region landed. `x`/`y` are the REGION's own corner, not its cell's. */
|
|
@@ -741,6 +780,8 @@ export interface PackResult {
|
|
|
741
780
|
/** The atlas text for the packed pages. */
|
|
742
781
|
atlasText: string;
|
|
743
782
|
padding: number;
|
|
783
|
+
/** The shape the pack was made under — what the `pack:` line's last field states. */
|
|
784
|
+
shape: PackShape;
|
|
744
785
|
}
|
|
745
786
|
|
|
746
787
|
/** A free rectangle in the MaxRects free list. */
|
|
@@ -784,15 +825,16 @@ function smallestPageFor(
|
|
|
784
825
|
cells: Array<{ w: number; h: number }>,
|
|
785
826
|
maxEdge: number,
|
|
786
827
|
pageEdges: PageEdges,
|
|
828
|
+
shapes?: readonly CellShape[],
|
|
787
829
|
): { width: number; height: number; rects: Rect[] } | null {
|
|
788
|
-
if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge);
|
|
830
|
+
if (pageEdges === 'free') return smallestFreePageFor(cells, maxEdge, shapes);
|
|
789
831
|
const edges: number[] = [];
|
|
790
832
|
for (let e = 1; e <= maxEdge; e *= 2) edges.push(e);
|
|
791
833
|
const candidates: Array<{ w: number; h: number }> = [];
|
|
792
834
|
for (const w of edges) for (const h of edges) candidates.push({ w, h });
|
|
793
835
|
candidates.sort((a, b) => a.w * a.h - b.w * b.h || a.w - b.w);
|
|
794
836
|
for (const candidate of candidates) {
|
|
795
|
-
const attempt =
|
|
837
|
+
const attempt = placePass(cells, shapes, candidate.w, candidate.h);
|
|
796
838
|
if (attempt.some((r) => r === null)) continue;
|
|
797
839
|
return { width: candidate.w, height: candidate.h, rects: attempt as Rect[] };
|
|
798
840
|
}
|
|
@@ -843,8 +885,9 @@ function smallestPageFor(
|
|
|
843
885
|
function smallestFreePageFor(
|
|
844
886
|
cells: Array<{ w: number; h: number }>,
|
|
845
887
|
maxEdge: number,
|
|
888
|
+
shapes?: readonly CellShape[],
|
|
846
889
|
): { width: number; height: number; rects: Rect[] } | null {
|
|
847
|
-
return freePageSearch(cells, maxEdge).page;
|
|
890
|
+
return freePageSearch(cells, maxEdge, shapes).page;
|
|
848
891
|
}
|
|
849
892
|
|
|
850
893
|
/** What one `free` page search did: the page it chose, and what each width cost. */
|
|
@@ -864,9 +907,10 @@ export interface FreePageSearch {
|
|
|
864
907
|
* `smallestFreePageFor`'s search with its bookkeeping — exported so the
|
|
865
908
|
* selftest can compare its page against an unbounded every-width reference and
|
|
866
909
|
* see that the bounds did prune (`PK75`). The page is the whole of what the
|
|
867
|
-
* packer uses; the counts are for the control.
|
|
910
|
+
* packer uses; the counts are for the control. `shapes`, one per cell, is
|
|
911
|
+
* `shape: 'polygon'`'s placement (`placePass`); absent, the rectangle pass.
|
|
868
912
|
*/
|
|
869
|
-
export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number): FreePageSearch {
|
|
913
|
+
export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge: number, shapes?: readonly CellShape[]): FreePageSearch {
|
|
870
914
|
let widest = 0;
|
|
871
915
|
let tallest = 0;
|
|
872
916
|
let cellArea = 0;
|
|
@@ -875,6 +919,14 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
|
|
|
875
919
|
tallest = Math.max(tallest, cell.h);
|
|
876
920
|
cellArea += cell.w * cell.h;
|
|
877
921
|
}
|
|
922
|
+
// 🔒 Under `polygon` cells may overlap, so their area is no bound on the page;
|
|
923
|
+
// what is, is the texels they OWN, which are disjoint (issue #1099). Bounding
|
|
924
|
+
// by the cells' area skipped every width a polygon spill page needed, and a
|
|
925
|
+
// spilled page whose cells overlapped was refused as fitting no page at all.
|
|
926
|
+
if (shapes !== undefined) {
|
|
927
|
+
cellArea = 0;
|
|
928
|
+
for (const shape of shapes) cellArea += shape.owned;
|
|
929
|
+
}
|
|
878
930
|
const out: FreePageSearch = { page: null, widths: 0, completed: 0, abandoned: 0, skipped: 0 };
|
|
879
931
|
let best: { width: number; height: number; rects: Rect[] } | null = null;
|
|
880
932
|
for (let width = Math.ceil(widest / FREE_EDGE_STEP) * FREE_EDGE_STEP; width <= maxEdge; width += FREE_EDGE_STEP) {
|
|
@@ -885,7 +937,7 @@ export function freePageSearch(cells: Array<{ w: number; h: number }>, maxEdge:
|
|
|
885
937
|
continue;
|
|
886
938
|
}
|
|
887
939
|
const maxBottom = best === null ? Infinity : Math.floor(bestArea / width);
|
|
888
|
-
const attempt =
|
|
940
|
+
const attempt = placePass(cells, shapes, width, maxEdge, maxBottom);
|
|
889
941
|
let height = 0;
|
|
890
942
|
for (const r of attempt) if (r !== null) height = Math.max(height, r.y + r.h);
|
|
891
943
|
if (height > maxBottom) {
|
|
@@ -1030,6 +1082,592 @@ function packOnePage(
|
|
|
1030
1082
|
return placed;
|
|
1031
1083
|
}
|
|
1032
1084
|
|
|
1085
|
+
/**
|
|
1086
|
+
* One placement pass: the rectangle pass (`packOnePage`) when `shapes` is
|
|
1087
|
+
* absent, which is every `shape: 'rect'` call and so every pack made before the
|
|
1088
|
+
* option existed, and the footprint pass (`packOnePageByFootprint`) when it is
|
|
1089
|
+
* given.
|
|
1090
|
+
*/
|
|
1091
|
+
function placePass(
|
|
1092
|
+
cells: Array<{ w: number; h: number }>,
|
|
1093
|
+
shapes: readonly CellShape[] | undefined,
|
|
1094
|
+
pageW: number,
|
|
1095
|
+
pageH: number,
|
|
1096
|
+
maxBottom = Infinity,
|
|
1097
|
+
): Array<Rect | null> {
|
|
1098
|
+
const byRect = packOnePage(cells, pageW, pageH, maxBottom);
|
|
1099
|
+
if (shapes === undefined || shapes.every((s) => s.whole)) return byRect;
|
|
1100
|
+
// ⭐ `polygon` never costs a page anything `rect` would have saved. Both passes
|
|
1101
|
+
// are run on the same page and the better one is kept: more cells placed,
|
|
1102
|
+
// then the higher bottom edge, then — on a tie — the rectangle pass. Two
|
|
1103
|
+
// disjoint cells never share a protected texel, so the rectangle pass is
|
|
1104
|
+
// itself a legal footprint placement, and keeping it where it is better is
|
|
1105
|
+
// choosing between two legal answers, not giving the mode up. Measured
|
|
1106
|
+
// before this rule existed: the footprint pass alone wrote larger pages
|
|
1107
|
+
// than `rect` on two of the five gallery rigs that carry a mesh, because a
|
|
1108
|
+
// greedy pass that places one cell differently places every later one
|
|
1109
|
+
// differently too.
|
|
1110
|
+
const byFootprint = packOnePageByFootprint(shapes, pageW, pageH, maxBottom);
|
|
1111
|
+
const placedOf = (pass: Array<Rect | null>): number => pass.reduce((n, r) => (r === null ? n : n + 1), 0);
|
|
1112
|
+
const bottomOf = (pass: Array<Rect | null>): number => pass.reduce((n, r) => (r === null ? n : Math.max(n, r.y + r.h)), 0);
|
|
1113
|
+
const placedRect = placedOf(byRect);
|
|
1114
|
+
const placedFoot = placedOf(byFootprint);
|
|
1115
|
+
if (placedFoot !== placedRect) return placedFoot > placedRect ? byFootprint : byRect;
|
|
1116
|
+
return bottomOf(byFootprint) < bottomOf(byRect) ? byFootprint : byRect;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ---------------------------------------------------------------------------
|
|
1120
|
+
// footprints — `shape: 'polygon'` (issue #1099)
|
|
1121
|
+
// ---------------------------------------------------------------------------
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* A cell as `shape: 'polygon'` places it: the region plus `padding` on every
|
|
1125
|
+
* side, and the cell texels the region OWNS — its protected set.
|
|
1126
|
+
*
|
|
1127
|
+
* ## The protected set, and why it is the footprint test
|
|
1128
|
+
*
|
|
1129
|
+
* A region attachment owns its whole cell, exactly the cell `shape: 'rect'`
|
|
1130
|
+
* keeps apart from every other: it draws its whole quad, and its gutter is what
|
|
1131
|
+
* makes its edge sample as the loose page's does (`extrudeCell`).
|
|
1132
|
+
*
|
|
1133
|
+
* A mesh region owns every cell texel `t` whose unit square, grown by `reach =
|
|
1134
|
+
* max(padding, 1)` texels on every side (a Chebyshev dilation), meets one of
|
|
1135
|
+
* the region's footprint polygons, closed — boundary contact counts. Then:
|
|
1136
|
+
*
|
|
1137
|
+
* * it holds every texel the mesh can sample. A bilinear tap at a point `q`
|
|
1138
|
+
* inside a triangle reads the four texels whose centres are within one
|
|
1139
|
+
* texel of `q`, and each of those squares lies within half a texel of `q`;
|
|
1140
|
+
* `reach` ≥ 1 covers that with room for the float32 page UVs the runtime
|
|
1141
|
+
* stores (`src/atlas.ts`'s header: worst 3.15e-5 texels);
|
|
1142
|
+
* * it keeps the padding the rectangle keeps. A rectangle's cell is the
|
|
1143
|
+
* rectangle grown by `padding`, and this is the hull grown by `padding` —
|
|
1144
|
+
* for a hull that covers its whole drawing, texel for texel the same cell,
|
|
1145
|
+
* which is why such a mesh is placed exactly as a rectangle is;
|
|
1146
|
+
* * it is clipped to the cell, so a region never claims a texel outside the
|
|
1147
|
+
* cell its own pixels are extruded into.
|
|
1148
|
+
*
|
|
1149
|
+
* ⇒ **Two cells may be placed with their rectangles overlapping exactly when
|
|
1150
|
+
* their protected sets share no texel.** For two rectangles that is today's
|
|
1151
|
+
* rule — two cells share no texel — so a pack with no mesh region is placed
|
|
1152
|
+
* by `rect`'s arithmetic, decision for decision. For any two footprints it
|
|
1153
|
+
* means their Chebyshev distance is at least `2 · padding`: if a point of one
|
|
1154
|
+
* were within `2 · padding` of a point of the other, the texel under their
|
|
1155
|
+
* midpoint would be in both protected sets.
|
|
1156
|
+
*
|
|
1157
|
+
* ## Exactness — where it rounds, and in which direction
|
|
1158
|
+
*
|
|
1159
|
+
* The polygons are doubles: a UV as `skeleton.json` states it, times the
|
|
1160
|
+
* region's integer size. The set is computed row by row — the band
|
|
1161
|
+
* `[row − reach, row + 1 + reach]` against each polygon: every edge clipped to
|
|
1162
|
+
* the band, and the polygon's even-odd spans on the band's two lines, which
|
|
1163
|
+
* together are the polygon's exact extent inside the band — and every
|
|
1164
|
+
* comparison that could decide "outside" is made `FOOTPRINT_SLACK` in the
|
|
1165
|
+
* polygon's favour. **The only rounding grows the set**, by at most that slack
|
|
1166
|
+
* (1e-6 texels, against a double's 1e-13 on these magnitudes), so it can make
|
|
1167
|
+
* two footprints keep further apart than they had to and never lets two
|
|
1168
|
+
* touch.
|
|
1169
|
+
*/
|
|
1170
|
+
export interface CellShape {
|
|
1171
|
+
/** The cell: the region plus `padding` on every side. */
|
|
1172
|
+
width: number;
|
|
1173
|
+
height: number;
|
|
1174
|
+
/** 1 on every texel the region owns, row-major over the cell. */
|
|
1175
|
+
mask: Uint8Array;
|
|
1176
|
+
/** `mask` rounded out to bands of `FOOTPRINT_BAND_ROWS` rows, as disjoint rectangles in cell texels, top to bottom — what the free list is split by. */
|
|
1177
|
+
rects: Rect[];
|
|
1178
|
+
/** The bounding box of `mask`, in cell texels. */
|
|
1179
|
+
bbox: Rect;
|
|
1180
|
+
/** The mask is the whole cell: the region is placed exactly as `shape: 'rect'` places it. */
|
|
1181
|
+
whole: boolean;
|
|
1182
|
+
/** How many texels the mask owns — what the `free` search's area bound sums, since owned sets are disjoint and cells may not be. */
|
|
1183
|
+
owned: number;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** How far in a polygon's favour every footprint comparison is made. See `CellShape`, *Exactness*. */
|
|
1187
|
+
export const FOOTPRINT_SLACK = 1e-6;
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* How many rows of a cell's owned set one free-list rectangle spans — the
|
|
1191
|
+
* resolution the footprint pass's free list keeps, not the resolution of the
|
|
1192
|
+
* footprint test (which is the exact set, texel by texel). Splitting the free
|
|
1193
|
+
* list by one rectangle per row of a curved hull made it thousands long: a
|
|
1194
|
+
* 60-part set of ellipse hulls ran over ten minutes on a `free` page. See
|
|
1195
|
+
* `packOnePageByFootprint` for what the bands cost.
|
|
1196
|
+
*/
|
|
1197
|
+
export const FOOTPRINT_BAND_ROWS = 8;
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* The closed x-extent of one polygon inside the band `y0 ≤ y ≤ y1`, as a list
|
|
1201
|
+
* of closed intervals whose union is exactly that extent: each edge clipped to
|
|
1202
|
+
* the band, and the even-odd spans on the band's two lines (a point inside the
|
|
1203
|
+
* polygon and the band either reaches a band line vertically without leaving
|
|
1204
|
+
* the polygon, or meets an edge inside the band on the way).
|
|
1205
|
+
*/
|
|
1206
|
+
function bandExtent(poly: readonly number[], offset: number, y0: number, y1: number, out: Array<[number, number]>): void {
|
|
1207
|
+
const n = poly.length / 2;
|
|
1208
|
+
for (let i = 0; i < n; i++) {
|
|
1209
|
+
const ax = poly[2 * i] + offset;
|
|
1210
|
+
const ay = poly[2 * i + 1] + offset;
|
|
1211
|
+
const j = (i + 1) % n;
|
|
1212
|
+
const bx = poly[2 * j] + offset;
|
|
1213
|
+
const by = poly[2 * j + 1] + offset;
|
|
1214
|
+
if (Math.max(ay, by) < y0 || Math.min(ay, by) > y1) continue;
|
|
1215
|
+
if (ay === by) {
|
|
1216
|
+
out.push([Math.min(ax, bx), Math.max(ax, bx)]);
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
let t0 = (y0 - ay) / (by - ay);
|
|
1220
|
+
let t1 = (y1 - ay) / (by - ay);
|
|
1221
|
+
if (t0 > t1) [t0, t1] = [t1, t0];
|
|
1222
|
+
t0 = Math.max(0, t0);
|
|
1223
|
+
t1 = Math.min(1, t1);
|
|
1224
|
+
if (t0 > t1) continue;
|
|
1225
|
+
const xa = ax + (bx - ax) * t0;
|
|
1226
|
+
const xb = ax + (bx - ax) * t1;
|
|
1227
|
+
out.push([Math.min(xa, xb), Math.max(xa, xb)]);
|
|
1228
|
+
}
|
|
1229
|
+
for (const y of [y0, y1]) {
|
|
1230
|
+
const crossings: number[] = [];
|
|
1231
|
+
for (let i = 0; i < n; i++) {
|
|
1232
|
+
const ay = poly[2 * i + 1] + offset;
|
|
1233
|
+
const j = (i + 1) % n;
|
|
1234
|
+
const by = poly[2 * j + 1] + offset;
|
|
1235
|
+
if ((ay <= y && y < by) || (by <= y && y < ay)) {
|
|
1236
|
+
const ax = poly[2 * i] + offset;
|
|
1237
|
+
const bx = poly[2 * j] + offset;
|
|
1238
|
+
crossings.push(ax + ((y - ay) * (bx - ax)) / (by - ay));
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
crossings.sort((a, b) => a - b);
|
|
1242
|
+
for (let k = 0; k + 1 < crossings.length; k += 2) out.push([crossings[k], crossings[k + 1]]);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* A region's cell and protected set (`CellShape`) — the rectangle when
|
|
1248
|
+
* `footprint` is absent, the dilated footprint polygons when it is given.
|
|
1249
|
+
* Exported so the selftest can hold the pages to the sets the packer kept
|
|
1250
|
+
* apart (`PK79`) without restating the rule.
|
|
1251
|
+
*/
|
|
1252
|
+
export function footprintCell(width: number, height: number, padding: number, footprint?: PackFootprint): CellShape {
|
|
1253
|
+
const cw = width + 2 * padding;
|
|
1254
|
+
const ch = height + 2 * padding;
|
|
1255
|
+
const whole = (): CellShape => {
|
|
1256
|
+
const cell = { x: 0, y: 0, w: cw, h: ch };
|
|
1257
|
+
return { width: cw, height: ch, mask: new Uint8Array(cw * ch).fill(1), rects: [cell], bbox: { ...cell }, whole: true, owned: cw * ch };
|
|
1258
|
+
};
|
|
1259
|
+
if (footprint === undefined) return whole();
|
|
1260
|
+
const mask = new Uint8Array(cw * ch);
|
|
1261
|
+
const reach = Math.max(padding, 1);
|
|
1262
|
+
const spans: Array<[number, number]> = [];
|
|
1263
|
+
for (let row = 0; row < ch; row++) {
|
|
1264
|
+
spans.length = 0;
|
|
1265
|
+
const y0 = row - reach - FOOTPRINT_SLACK;
|
|
1266
|
+
const y1 = row + 1 + reach + FOOTPRINT_SLACK;
|
|
1267
|
+
for (const poly of footprint.polygons) if (poly.length >= 6) bandExtent(poly, padding, y0, y1, spans);
|
|
1268
|
+
const at = row * cw;
|
|
1269
|
+
for (const [a, b] of spans) {
|
|
1270
|
+
const from = Math.max(0, Math.ceil(a - 1 - reach - FOOTPRINT_SLACK));
|
|
1271
|
+
const to = Math.min(cw - 1, Math.floor(b + reach + FOOTPRINT_SLACK));
|
|
1272
|
+
for (let x = from; x <= to; x++) mask[at + x] = 1;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (mask.every((v) => v === 1)) return whole();
|
|
1276
|
+
// The rectangles the free list is split by: the set rounded OUT to bands of
|
|
1277
|
+
// `FOOTPRINT_BAND_ROWS` rows — each band the columns any of its rows owns,
|
|
1278
|
+
// as runs, merged downwards while a run repeats exactly. Disjoint, a superset
|
|
1279
|
+
// of the set, and in a fixed order (top to bottom, then left to right), so
|
|
1280
|
+
// the free list is split the same way every time. Only the free list reads
|
|
1281
|
+
// them; what a cell owns, what is drawn and what the footprint test compares
|
|
1282
|
+
// is the exact set.
|
|
1283
|
+
const rects: Rect[] = [];
|
|
1284
|
+
let open = new Map<number, Rect>();
|
|
1285
|
+
const columns = new Uint8Array(cw);
|
|
1286
|
+
for (let band = 0; band < ch; band += FOOTPRINT_BAND_ROWS) {
|
|
1287
|
+
const rows = Math.min(FOOTPRINT_BAND_ROWS, ch - band);
|
|
1288
|
+
columns.fill(0);
|
|
1289
|
+
for (let row = band; row < band + rows; row++) for (let x = 0; x < cw; x++) if (mask[row * cw + x] === 1) columns[x] = 1;
|
|
1290
|
+
const next = new Map<number, Rect>();
|
|
1291
|
+
let x = 0;
|
|
1292
|
+
while (x < cw) {
|
|
1293
|
+
if (columns[x] === 0) {
|
|
1294
|
+
x++;
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
const start = x;
|
|
1298
|
+
while (x < cw && columns[x] === 1) x++;
|
|
1299
|
+
const key = start * (cw + 1) + x;
|
|
1300
|
+
const continued = open.get(key);
|
|
1301
|
+
if (continued !== undefined && continued.y + continued.h === band) {
|
|
1302
|
+
continued.h += rows;
|
|
1303
|
+
next.set(key, continued);
|
|
1304
|
+
} else {
|
|
1305
|
+
const rect = { x: start, y: band, w: x - start, h: rows };
|
|
1306
|
+
rects.push(rect);
|
|
1307
|
+
next.set(key, rect);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
open = next;
|
|
1311
|
+
}
|
|
1312
|
+
// The bounding box is the exact set's: a candidate is placed so that box
|
|
1313
|
+
// lies in a free rectangle, which no other cell's bands reach.
|
|
1314
|
+
let minX = cw;
|
|
1315
|
+
let minY = ch;
|
|
1316
|
+
let maxX = 0;
|
|
1317
|
+
let maxY = 0;
|
|
1318
|
+
for (let y = 0; y < ch; y++) {
|
|
1319
|
+
for (let x = 0; x < cw; x++) {
|
|
1320
|
+
if (mask[y * cw + x] === 0) continue;
|
|
1321
|
+
minX = Math.min(minX, x);
|
|
1322
|
+
minY = Math.min(minY, y);
|
|
1323
|
+
maxX = Math.max(maxX, x + 1);
|
|
1324
|
+
maxY = Math.max(maxY, y + 1);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
// A footprint that owns nothing (no polygon of three vertices) still owns a
|
|
1328
|
+
// texel: a cell that claimed no texel could be placed on top of another and
|
|
1329
|
+
// the packer would have nothing to keep apart. Its top-left texel is the
|
|
1330
|
+
// smallest claim, and it is the cell's own.
|
|
1331
|
+
if (rects.length === 0) {
|
|
1332
|
+
mask[0] = 1;
|
|
1333
|
+
rects.push({ x: 0, y: 0, w: 1, h: 1 });
|
|
1334
|
+
minX = 0;
|
|
1335
|
+
minY = 0;
|
|
1336
|
+
maxX = 1;
|
|
1337
|
+
maxY = 1;
|
|
1338
|
+
}
|
|
1339
|
+
let count = 0;
|
|
1340
|
+
for (const v of mask) count += v;
|
|
1341
|
+
return { width: cw, height: ch, mask, rects, bbox: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, whole: false, owned: count };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** Whether two placed cells' protected sets share a texel. */
|
|
1345
|
+
function shapesMeet(a: CellShape, ax: number, ay: number, b: CellShape, bx: number, by: number): boolean {
|
|
1346
|
+
const x0 = Math.max(ax, bx);
|
|
1347
|
+
const y0 = Math.max(ay, by);
|
|
1348
|
+
const x1 = Math.min(ax + a.width, bx + b.width);
|
|
1349
|
+
const y1 = Math.min(ay + a.height, by + b.height);
|
|
1350
|
+
for (let y = y0; y < y1; y++) {
|
|
1351
|
+
const rowA = (y - ay) * a.width - ax;
|
|
1352
|
+
const rowB = (y - by) * b.width - bx;
|
|
1353
|
+
for (let x = x0; x < x1; x++) if (a.mask[rowA + x] === 1 && b.mask[rowB + x] === 1) return true;
|
|
1354
|
+
}
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* MaxRects' split of every free rectangle `put` overlaps, then its prune —
|
|
1360
|
+
* `packOnePage`'s, with one saving that changes no rectangle and no order: a
|
|
1361
|
+
* free rectangle `put` did not touch is never tested for containment. Before
|
|
1362
|
+
* the split no free rectangle lay inside another (the prune's own
|
|
1363
|
+
* postcondition), and every new piece lies inside the rectangle it was cut
|
|
1364
|
+
* from, so an untouched rectangle inside a new piece would have lain inside
|
|
1365
|
+
* that one — so the test `packOnePage` makes for it always answers "not
|
|
1366
|
+
* contained", and skipping it keeps the list exactly as that prune leaves it.
|
|
1367
|
+
*
|
|
1368
|
+
* ⭐ And a second (issue #1099, measured on production rigs): a free rectangle
|
|
1369
|
+
* narrower than `minW` or shorter than `minH` is dropped. The caller passes the
|
|
1370
|
+
* least bounding box of every cell the pass places, so such a rectangle can
|
|
1371
|
+
* never be a candidate, and neither can any piece later cut from it (a piece
|
|
1372
|
+
* lies inside the rectangle it was cut from); and a rectangle a dropped one
|
|
1373
|
+
* contains is itself too small. So every rectangle that could ever be a
|
|
1374
|
+
* candidate is kept, in the order the full prune keeps it, and the decisions
|
|
1375
|
+
* are the same. What it removes is the staircase of slivers an irregular hull's
|
|
1376
|
+
* bands cut along its edge: on a production-shaped set (30 regions, 200 to 900
|
|
1377
|
+
* px, hulls near their rectangles, a `free` page about 2023x2046) the pack
|
|
1378
|
+
* took 18.5 s with the slivers kept and spent 95 % of it in this prune.
|
|
1379
|
+
*/
|
|
1380
|
+
function splitFree(free: Rect[], put: Rect, minW = 1, minH = 1): void {
|
|
1381
|
+
const next: Rect[] = [];
|
|
1382
|
+
const untouched: boolean[] = [];
|
|
1383
|
+
for (const fr of free) {
|
|
1384
|
+
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
|
+
if (!overlaps) {
|
|
1386
|
+
next.push(fr);
|
|
1387
|
+
untouched.push(true);
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (put.x > fr.x) next.push({ x: fr.x, y: fr.y, w: put.x - fr.x, h: fr.h });
|
|
1391
|
+
if (put.x + put.w < fr.x + fr.w) {
|
|
1392
|
+
next.push({ x: put.x + put.w, y: fr.y, w: fr.x + fr.w - (put.x + put.w), h: fr.h });
|
|
1393
|
+
}
|
|
1394
|
+
if (put.y > fr.y) next.push({ x: fr.x, y: fr.y, w: fr.w, h: put.y - fr.y });
|
|
1395
|
+
if (put.y + put.h < fr.y + fr.h) {
|
|
1396
|
+
next.push({ x: fr.x, y: put.y + put.h, w: fr.w, h: fr.y + fr.h - (put.y + put.h) });
|
|
1397
|
+
}
|
|
1398
|
+
while (untouched.length < next.length) untouched.push(false);
|
|
1399
|
+
}
|
|
1400
|
+
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
|
+
const usable = (r: Rect): boolean => r.w >= minW && r.h >= minH;
|
|
1402
|
+
free.length = 0;
|
|
1403
|
+
for (let i = 0; i < next.length; i++) {
|
|
1404
|
+
if (!usable(next[i])) continue;
|
|
1405
|
+
if (untouched[i]) {
|
|
1406
|
+
free.push(next[i]);
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
let contained = false;
|
|
1410
|
+
for (let j = 0; j < next.length && !contained; j++) {
|
|
1411
|
+
if (i === j || !usable(next[j])) continue;
|
|
1412
|
+
if (contains(next[j], next[i]) && (j < i || !contains(next[i], next[j]))) contained = true;
|
|
1413
|
+
}
|
|
1414
|
+
if (!contained) free.push(next[i]);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* `packOnePage` with the free-space test replaced by the footprint test
|
|
1420
|
+
* (issue #1099) — the placement `shape: 'polygon'` makes.
|
|
1421
|
+
*
|
|
1422
|
+
* What changes is what the free list is the free space OF. `packOnePage`
|
|
1423
|
+
* splits it by each placed cell; this splits it by each placed cell's
|
|
1424
|
+
* protected set rounded out to bands of `FOOTPRINT_BAND_ROWS` rows
|
|
1425
|
+
* (`CellShape.rects`), so a free rectangle may lie inside an earlier cell
|
|
1426
|
+
* wherever that cell's region draws nothing. The bands only make the free list
|
|
1427
|
+
* coarser — a free rectangle is still clear of every owned texel — and they
|
|
1428
|
+
* are what keeps it short: per row, a 60-part set of ellipse hulls packed on a
|
|
1429
|
+
* `free` page in over ten minutes; in bands of 8 rows, in 14.9 s against the
|
|
1430
|
+
* rectangle pass's 0.6 s, the same page — and with `splitFree`'s sliver drop
|
|
1431
|
+
* beside them, in 5.8 s against 2.2 s (a loaded machine). Everything else is
|
|
1432
|
+
* MaxRects as `packOnePage` runs it:
|
|
1433
|
+
*
|
|
1434
|
+
* * a candidate is a free rectangle that holds the cell's protected set's
|
|
1435
|
+
* bounding box, anchored at its top-left corner — the cell placed so that
|
|
1436
|
+
* 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
|
+
* * the score is Best Short Side Fit over that box, with the same total
|
|
1439
|
+
* tie-break (smallest long-side leftover, then topmost, then leftmost);
|
|
1440
|
+
* * the free list is split and pruned by the same code (`splitFree`), once
|
|
1441
|
+
* per rectangle of the protected set.
|
|
1442
|
+
*
|
|
1443
|
+
* ⇒ For a cell whose set is the whole cell (`CellShape.whole`), the box IS the
|
|
1444
|
+
* cell, the anchor IS the free rectangle's corner and the split IS the cell's:
|
|
1445
|
+
* a pack with no mesh region makes `packOnePage`'s decisions one for one
|
|
1446
|
+
* (`PK82`).
|
|
1447
|
+
*
|
|
1448
|
+
* 🔒 **The footprint test is then stated outright rather than trusted to the
|
|
1449
|
+
* bookkeeping.** A candidate inside a free rectangle cannot meet a placed set,
|
|
1450
|
+
* because no free rectangle overlaps one; every placement is nevertheless
|
|
1451
|
+
* checked texel for texel against every set already placed, and a meeting is
|
|
1452
|
+
* an internal error, not a placement.
|
|
1453
|
+
*
|
|
1454
|
+
* It terminates and is deterministic for `packOnePage`'s reasons: one pass
|
|
1455
|
+
* over the cells in the caller's order, each over a finite free list, every
|
|
1456
|
+
* choice made by a total order, and no input but the cells and the page.
|
|
1457
|
+
*/
|
|
1458
|
+
function packOnePageByFootprint(shapes: readonly CellShape[], pageW: number, pageH: number, maxBottom = Infinity): Array<Rect | null> {
|
|
1459
|
+
const free: Rect[] = [{ x: 0, y: 0, w: pageW, h: pageH }];
|
|
1460
|
+
const placed: Array<Rect | null> = [];
|
|
1461
|
+
const owned: Array<{ shape: CellShape; x: number; y: number }> = [];
|
|
1462
|
+
// The least box any cell of this pass needs — what a free rectangle must
|
|
1463
|
+
// hold to be a candidate for anything (`splitFree`, the second saving).
|
|
1464
|
+
let minW = Infinity;
|
|
1465
|
+
let minH = Infinity;
|
|
1466
|
+
for (const shape of shapes) {
|
|
1467
|
+
minW = Math.min(minW, shape.bbox.w);
|
|
1468
|
+
minH = Math.min(minH, shape.bbox.h);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
for (const shape of shapes) {
|
|
1472
|
+
const box = shape.bbox;
|
|
1473
|
+
let best: Rect | null = null;
|
|
1474
|
+
let bestShort = Infinity;
|
|
1475
|
+
let bestLong = Infinity;
|
|
1476
|
+
for (const fr of free) {
|
|
1477
|
+
if (fr.w < box.w || fr.h < box.h) continue;
|
|
1478
|
+
const x = fr.x - box.x;
|
|
1479
|
+
const y = fr.y - box.y;
|
|
1480
|
+
if (x < 0 || y < 0 || x + shape.width > pageW || y + shape.height > pageH) continue;
|
|
1481
|
+
const leftoverW = fr.w - box.w;
|
|
1482
|
+
const leftoverH = fr.h - box.h;
|
|
1483
|
+
const short = Math.min(leftoverW, leftoverH);
|
|
1484
|
+
const long = Math.max(leftoverW, leftoverH);
|
|
1485
|
+
if (best !== null) {
|
|
1486
|
+
if (short > bestShort) continue;
|
|
1487
|
+
if (short === bestShort) {
|
|
1488
|
+
if (long > bestLong) continue;
|
|
1489
|
+
if (long === bestLong) {
|
|
1490
|
+
if (fr.y > best.y) continue;
|
|
1491
|
+
if (fr.y === best.y && fr.x >= best.x) continue;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
best = fr;
|
|
1496
|
+
bestShort = short;
|
|
1497
|
+
bestLong = long;
|
|
1498
|
+
}
|
|
1499
|
+
if (best === null) {
|
|
1500
|
+
placed.push(null);
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
const put: Rect = { x: best.x - box.x, y: best.y - box.y, w: shape.width, h: shape.height };
|
|
1504
|
+
for (const other of owned) {
|
|
1505
|
+
// Two cells that do not overlap cannot share a texel, and that answer
|
|
1506
|
+
// takes no texel to read; only an overlap is scanned, and only over the
|
|
1507
|
+
// overlap's own box (`shapesMeet`).
|
|
1508
|
+
const apart =
|
|
1509
|
+
put.x >= other.x + other.shape.width || other.x >= put.x + put.w || put.y >= other.y + other.shape.height || other.y >= put.y + put.h;
|
|
1510
|
+
if (apart) continue;
|
|
1511
|
+
if (shapesMeet(shape, put.x, put.y, other.shape, other.x, other.y)) {
|
|
1512
|
+
throw new CompileError(
|
|
1513
|
+
`internal: the footprint pass placed a ${shape.width}x${shape.height} cell at ${put.x},${put.y} over ` +
|
|
1514
|
+
`the footprint of the ${other.shape.width}x${other.shape.height} cell at ${other.x},${other.y}`,
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
placed.push(put);
|
|
1519
|
+
owned.push({ shape, x: put.x, y: put.y });
|
|
1520
|
+
if (put.y + put.h > maxBottom) {
|
|
1521
|
+
while (placed.length < shapes.length) placed.push(null);
|
|
1522
|
+
return placed;
|
|
1523
|
+
}
|
|
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);
|
|
1525
|
+
}
|
|
1526
|
+
return placed;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
1530
|
+
const isNumberList = (v: unknown): v is number[] => Array.isArray(v) && v.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* What every packed region's attachments draw, read off the `skeleton.json` the
|
|
1534
|
+
* build emitted — the footprints `shape: 'polygon'` packs by (issue #1099).
|
|
1535
|
+
*
|
|
1536
|
+
* The emitted text and not the model, because the footprint has to be what the
|
|
1537
|
+
* runtime will sample: the mesh's UVs as the file writes them.
|
|
1538
|
+
*
|
|
1539
|
+
* * The region an attachment samples is its `path`, else its `name`, else its
|
|
1540
|
+
* key — the runtime's rule — and a `sequence` samples `that + (start +
|
|
1541
|
+
* frame)` zero-padded to `digits`, for every frame (`start` 1 and `digits` 0
|
|
1542
|
+
* when the file omits them, the parser's defaults).
|
|
1543
|
+
* * A **region** attachment's footprint is its rectangle (`null` here).
|
|
1544
|
+
* * A **mesh**'s is the polygon of its first `hull` vertices, closed from the
|
|
1545
|
+
* last back to the first, and each of its triangles — the hull is the outer
|
|
1546
|
+
* loop of the triangles' union, so for a well-formed mesh the triangles add
|
|
1547
|
+
* nothing, and for one whose triangles reach outside its loop they protect
|
|
1548
|
+
* what is drawn. In the region's own texels: `(u · width, v · height)`.
|
|
1549
|
+
* * A **linked mesh** draws its source's geometry over its own region: the
|
|
1550
|
+
* mesh keyed `source` (`parent` before 4.3) in the slot `slot` names (its
|
|
1551
|
+
* own by default) of the skin `skin` names (`default` by default).
|
|
1552
|
+
* * A region with **any** rectangle use is a rectangle, and so is every
|
|
1553
|
+
* region whose drawing cannot be read as a polygon here — a mesh whose UVs
|
|
1554
|
+
* leave 0..1 (it samples outside its own rectangle, which only the
|
|
1555
|
+
* rectangle keeps the same), a hull or triangle list that does not index its
|
|
1556
|
+
* vertices, a link whose source is not a mesh. Several meshes over one
|
|
1557
|
+
* region give it the union of their polygons.
|
|
1558
|
+
*
|
|
1559
|
+
* Returns, per region name, its footprint, or `null` for a rectangle; a region
|
|
1560
|
+
* no attachment names is absent, and the packer treats it as a rectangle.
|
|
1561
|
+
* Nothing read here can be invented: every fallback is the rectangle, which is
|
|
1562
|
+
* the footprint `shape: 'rect'` gives everything.
|
|
1563
|
+
*/
|
|
1564
|
+
export function packFootprints(
|
|
1565
|
+
skeletonText: string,
|
|
1566
|
+
sizeOf: (region: string) => { width: number; height: number } | undefined,
|
|
1567
|
+
): Map<string, PackFootprint | null> {
|
|
1568
|
+
const doc: unknown = JSON.parse(skeletonText);
|
|
1569
|
+
const skins = isObject(doc) && Array.isArray(doc.skins) ? doc.skins : [];
|
|
1570
|
+
const tableOf = (skin: string, slot: string): Record<string, unknown> | undefined => {
|
|
1571
|
+
for (const s of skins) {
|
|
1572
|
+
if (!isObject(s) || s.name !== skin || !isObject(s.attachments)) continue;
|
|
1573
|
+
const table = s.attachments[slot];
|
|
1574
|
+
return isObject(table) ? table : undefined;
|
|
1575
|
+
}
|
|
1576
|
+
return undefined;
|
|
1577
|
+
};
|
|
1578
|
+
/** A mesh's UV polygons — hull loop, then triangles — or null when they cannot be read as polygons over its own rectangle. */
|
|
1579
|
+
const uvPolygonsOf = (mesh: Record<string, unknown>): number[][] | null => {
|
|
1580
|
+
const { uvs, hull, triangles } = mesh;
|
|
1581
|
+
if (!isNumberList(uvs) || uvs.length % 2 !== 0 || typeof hull !== 'number' || !isNumberList(triangles)) return null;
|
|
1582
|
+
const vertices = uvs.length / 2;
|
|
1583
|
+
if (!Number.isInteger(hull) || hull < 3 || hull > vertices || triangles.length % 3 !== 0) return null;
|
|
1584
|
+
if (uvs.some((n) => n < 0 || n > 1)) return null;
|
|
1585
|
+
if (triangles.some((t) => !Number.isInteger(t) || t < 0 || t >= vertices)) return null;
|
|
1586
|
+
const out: number[][] = [uvs.slice(0, 2 * hull)];
|
|
1587
|
+
for (let t = 0; t < triangles.length; t += 3) {
|
|
1588
|
+
const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
|
|
1589
|
+
out.push([uvs[2 * a], uvs[2 * a + 1], uvs[2 * b], uvs[2 * b + 1], uvs[2 * c], uvs[2 * c + 1]]);
|
|
1590
|
+
}
|
|
1591
|
+
return out;
|
|
1592
|
+
};
|
|
1593
|
+
const uses = new Map<string, number[][][] | null>();
|
|
1594
|
+
const use = (region: string, polygons: number[][] | null): void => {
|
|
1595
|
+
const held = uses.get(region);
|
|
1596
|
+
if (held === null) return;
|
|
1597
|
+
if (polygons === null) uses.set(region, null);
|
|
1598
|
+
else if (held === undefined) uses.set(region, [polygons]);
|
|
1599
|
+
else held.push(polygons);
|
|
1600
|
+
};
|
|
1601
|
+
for (const s of skins) {
|
|
1602
|
+
if (!isObject(s) || !isObject(s.attachments)) continue;
|
|
1603
|
+
for (const [slot, table] of Object.entries(s.attachments)) {
|
|
1604
|
+
if (!isObject(table)) continue;
|
|
1605
|
+
for (const [key, att] of Object.entries(table)) {
|
|
1606
|
+
if (!isObject(att)) continue;
|
|
1607
|
+
const type = att.type ?? 'region';
|
|
1608
|
+
if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
|
|
1609
|
+
const base = typeof att.path === 'string' ? att.path : typeof att.name === 'string' ? att.name : key;
|
|
1610
|
+
const regions: string[] = [];
|
|
1611
|
+
const seq = att.sequence;
|
|
1612
|
+
if (isObject(seq) && typeof seq.count === 'number') {
|
|
1613
|
+
const start = typeof seq.start === 'number' ? seq.start : 1;
|
|
1614
|
+
const digits = typeof seq.digits === 'number' ? seq.digits : 0;
|
|
1615
|
+
for (let f = 0; f < seq.count; f++) regions.push(base + String(start + f).padStart(digits, '0'));
|
|
1616
|
+
} else regions.push(base);
|
|
1617
|
+
let polygons: number[][] | null = null;
|
|
1618
|
+
if (type === 'mesh') polygons = uvPolygonsOf(att);
|
|
1619
|
+
else if (type === 'linkedmesh') {
|
|
1620
|
+
const sourceKey = typeof att.source === 'string' ? att.source : typeof att.parent === 'string' ? att.parent : null;
|
|
1621
|
+
const source =
|
|
1622
|
+
sourceKey === null
|
|
1623
|
+
? undefined
|
|
1624
|
+
: tableOf(typeof att.skin === 'string' ? att.skin : 'default', typeof att.slot === 'string' ? att.slot : slot)?.[sourceKey];
|
|
1625
|
+
polygons = isObject(source) && source.type === 'mesh' ? uvPolygonsOf(source) : null;
|
|
1626
|
+
}
|
|
1627
|
+
for (const region of regions) use(region, polygons);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const out = new Map<string, PackFootprint | null>();
|
|
1632
|
+
for (const [region, held] of uses) {
|
|
1633
|
+
const size = sizeOf(region);
|
|
1634
|
+
if (held === null || size === undefined) {
|
|
1635
|
+
out.set(region, null);
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
const polygons = held.flat().map((poly) => poly.map((n, i) => n * (i % 2 === 0 ? size.width : size.height)));
|
|
1639
|
+
out.set(region, { polygons });
|
|
1640
|
+
}
|
|
1641
|
+
return out;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Copy the texels a region owns (`CellShape.mask`) onto a page, with the
|
|
1646
|
+
* values `extrudeCell` gives them — the second of `shape: 'polygon'`'s two
|
|
1647
|
+
* drawing passes (see `packAtlas`).
|
|
1648
|
+
*/
|
|
1649
|
+
function extrudeOwned(page: Plate, source: Plate, cellX: number, cellY: number, padding: number, shape: CellShape): void {
|
|
1650
|
+
const w = source.width;
|
|
1651
|
+
const h = source.height;
|
|
1652
|
+
const dst = page.data;
|
|
1653
|
+
const src = source.data;
|
|
1654
|
+
for (let cy = 0; cy < shape.height; cy++) {
|
|
1655
|
+
const sy = Math.max(0, Math.min(h - 1, cy - padding));
|
|
1656
|
+
const srcRow = sy * w * 4;
|
|
1657
|
+
const dstRow = (cellY + cy) * page.width * 4;
|
|
1658
|
+
for (let cx = 0; cx < shape.width; cx++) {
|
|
1659
|
+
if (shape.mask[cy * shape.width + cx] === 0) continue;
|
|
1660
|
+
const sx = Math.max(0, Math.min(w - 1, cx - padding));
|
|
1661
|
+
const s = srcRow + sx * 4;
|
|
1662
|
+
const d = dstRow + (cellX + cx) * 4;
|
|
1663
|
+
dst[d] = src[s];
|
|
1664
|
+
dst[d + 1] = src[s + 1];
|
|
1665
|
+
dst[d + 2] = src[s + 2];
|
|
1666
|
+
dst[d + 3] = src[s + 3];
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1033
1671
|
/**
|
|
1034
1672
|
* The packing order, and it is stated rather than inherited.
|
|
1035
1673
|
*
|
|
@@ -1144,6 +1782,36 @@ function extrudeCell(page: Plate, source: Plate, cellX: number, cellY: number, p
|
|
|
1144
1782
|
* against the loose build. It does not go past it, and no region's bytes change
|
|
1145
1783
|
* (`PK02`'s lift-back holds under both).
|
|
1146
1784
|
*
|
|
1785
|
+
* ## `shape: 'polygon'` — rectangles that overlap where nothing is drawn (issue #1099)
|
|
1786
|
+
*
|
|
1787
|
+
* Opt-in, and the default stays `rect`, which is this function exactly as it
|
|
1788
|
+
* was before the option: under `rect` no footprint is computed and every pass
|
|
1789
|
+
* is `packOnePage`. Under `polygon` every input carries the footprint its
|
|
1790
|
+
* attachments draw (`packFootprints`: a mesh's emitted hull, a region
|
|
1791
|
+
* attachment's rectangle), and:
|
|
1792
|
+
*
|
|
1793
|
+
* * **what a cell owns** is `footprintCell`'s set — the footprint grown by the
|
|
1794
|
+
* padding, clipped to the cell; two cells may overlap when they own no texel
|
|
1795
|
+
* in common, which between two rectangles is `rect`'s rule and between any
|
|
1796
|
+
* two footprints keeps them `2 · padding` apart (`CellShape`);
|
|
1797
|
+
* * **the placement** is `packOnePageByFootprint`, MaxRects with the free list
|
|
1798
|
+
* split by what each cell owns, run beside the rectangle pass on every page
|
|
1799
|
+
* tried and kept only when it is better (`placePass`) — so a `polygon` page
|
|
1800
|
+
* is never larger than the `rect` page, and a set with no mesh footprint is
|
|
1801
|
+
* placed exactly as under `rect` (`PK82`);
|
|
1802
|
+
* * **the pixels** are drawn in two passes: every cell whole in packing order,
|
|
1803
|
+
* as under `rect`, then every region's owned texels again with its own
|
|
1804
|
+
* values. The owned sets are disjoint, so the second pass's order decides
|
|
1805
|
+
* nothing and every texel a region can sample is its own (`PK79`); a texel
|
|
1806
|
+
* nobody owns carries the last cell drawn over it, which nothing samples.
|
|
1807
|
+
*
|
|
1808
|
+
* What it costs is measured, not conceded: a region that moves samples through
|
|
1809
|
+
* a different `x / pageWidth`, so on the two gallery rigs whose pack moved,
|
|
1810
|
+
* 207 and 36 channel samples differ from the `rect` render, every one by 1 —
|
|
1811
|
+
* the bit two `rect` packs of the same rig differ by when one is `pot` and one
|
|
1812
|
+
* `free` (155 and 44). `--page-edges`, the width search and the spill rule are
|
|
1813
|
+
* unchanged.
|
|
1814
|
+
*
|
|
1147
1815
|
* ⚠️ `pot` is not the smaller answer's poor relation, and its search was never
|
|
1148
1816
|
* "double until it fits": it tries every power-of-two pair in order of area.
|
|
1149
1817
|
* On both rigs above the cells' own area (1,206,755 and 540,793 texels at
|
|
@@ -1158,6 +1826,10 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1158
1826
|
if (!PAGE_EDGES.includes(pageEdges)) {
|
|
1159
1827
|
throw new CompileError(`--page-edges ${JSON.stringify(String(opts.pageEdges))}; known values: ${PAGE_EDGES.join(', ')}`);
|
|
1160
1828
|
}
|
|
1829
|
+
const shape = opts.shape ?? DEFAULT_PACK_SHAPE;
|
|
1830
|
+
if (!PACK_SHAPES.includes(shape)) {
|
|
1831
|
+
throw new CompileError(`--pack-shape ${JSON.stringify(String(opts.shape))}; known values: ${PACK_SHAPES.join(', ')}`);
|
|
1832
|
+
}
|
|
1161
1833
|
if (!Number.isInteger(pageSize) || pageSize < 1) {
|
|
1162
1834
|
throw new CompileError(`--page-size must be a positive integer, got ${String(opts.pageSize)}`);
|
|
1163
1835
|
}
|
|
@@ -1181,7 +1853,12 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1181
1853
|
);
|
|
1182
1854
|
}
|
|
1183
1855
|
|
|
1184
|
-
|
|
1856
|
+
// `polygon` only: every cell's protected set. Under `rect` there are none and
|
|
1857
|
+
// every pass below is the rectangle pass, the code it was before #1099.
|
|
1858
|
+
const shapes =
|
|
1859
|
+
shape === 'polygon' ? sorted.map((input) => footprintCell(input.width, input.height, padding, input.footprint)) : undefined;
|
|
1860
|
+
|
|
1861
|
+
const single = smallestPageFor(cells, maxEdge, pageEdges, shapes);
|
|
1185
1862
|
|
|
1186
1863
|
/** page index -> the placements on it, in packing order. */
|
|
1187
1864
|
const perPage: Placement[][] = [];
|
|
@@ -1208,15 +1885,18 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1208
1885
|
// what makes the boundary deterministic and independent of the shrink below
|
|
1209
1886
|
// — and parts are taken in packing order, whatever will not fit the current
|
|
1210
1887
|
// page opening the next one.
|
|
1211
|
-
let remaining = sorted.map((input, i) => ({ input, cell: cells[i] }));
|
|
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));
|
|
1212
1891
|
while (remaining.length > 0) {
|
|
1213
1892
|
const pageIndex = perPage.length;
|
|
1214
|
-
const attempt =
|
|
1893
|
+
const attempt = placePass(
|
|
1215
1894
|
remaining.map((r) => r.cell),
|
|
1895
|
+
shapesOf(remaining),
|
|
1216
1896
|
maxEdge,
|
|
1217
1897
|
maxEdge,
|
|
1218
1898
|
);
|
|
1219
|
-
const onPage:
|
|
1899
|
+
const onPage: typeof remaining = [];
|
|
1220
1900
|
const leftOver: typeof remaining = [];
|
|
1221
1901
|
attempt.forEach((rect, i) => {
|
|
1222
1902
|
if (rect === null) leftOver.push(remaining[i]);
|
|
@@ -1241,6 +1921,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1241
1921
|
onPage.map((r) => r.cell),
|
|
1242
1922
|
maxEdge,
|
|
1243
1923
|
pageEdges,
|
|
1924
|
+
shapesOf(onPage),
|
|
1244
1925
|
);
|
|
1245
1926
|
if (shrunk === null) {
|
|
1246
1927
|
// Unreachable for the same reason as the stall above: these cells were
|
|
@@ -1269,12 +1950,16 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1269
1950
|
// Draw. Reading each source once, in packing order, keeps the decode count at
|
|
1270
1951
|
// one per part whatever the page layout turned out to be.
|
|
1271
1952
|
const byRegion = new Map(sorted.map((input) => [input.region, input]));
|
|
1953
|
+
const shapeByRegion = new Map<string, CellShape>();
|
|
1954
|
+
if (shapes !== undefined) sorted.forEach((input, i) => shapeByRegion.set(input.region, shapes[i]));
|
|
1272
1955
|
const pages: PackedPage[] = [];
|
|
1273
1956
|
const emitPages: EmitPage[] = [];
|
|
1274
1957
|
perPage.forEach((onPage, index) => {
|
|
1275
1958
|
const { width: pageW, height: pageH } = pageSizes[index];
|
|
1276
1959
|
const plate = new Plate(pageW, pageH);
|
|
1277
1960
|
let covered = 0;
|
|
1961
|
+
/** `polygon` only: each region's source and protected set, for the second pass. */
|
|
1962
|
+
const owners: Array<{ source: Plate; place: Placement; shape: CellShape }> = [];
|
|
1278
1963
|
for (const place of onPage) {
|
|
1279
1964
|
const input = byRegion.get(place.region)!;
|
|
1280
1965
|
const source = readPlate(input.absPath);
|
|
@@ -1289,6 +1974,22 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1289
1974
|
}
|
|
1290
1975
|
extrudeCell(plate, source, place.x - padding, place.y - padding, padding);
|
|
1291
1976
|
covered += place.width * place.height;
|
|
1977
|
+
const owned = shapeByRegion.get(place.region);
|
|
1978
|
+
if (owned !== undefined) owners.push({ source, place, shape: owned });
|
|
1979
|
+
}
|
|
1980
|
+
// `polygon`'s second pass. The first wrote every cell whole, in packing
|
|
1981
|
+
// order, so where two cells overlap the later one's texels lie on top; this
|
|
1982
|
+
// one writes each region's protected set again, with its own values. The
|
|
1983
|
+
// sets are pairwise disjoint (the footprint test), so the order of this pass
|
|
1984
|
+
// decides nothing, and every texel a region owns ends as its own: a texel
|
|
1985
|
+
// under some region's footprint carries that region's value, and a texel
|
|
1986
|
+
// under none carries the last cell drawn over it.
|
|
1987
|
+
// Every region, a whole cell's included: a rectangle's texels are what a
|
|
1988
|
+
// later mesh's cell is most likely to have been drawn over. A page whose
|
|
1989
|
+
// every cell is whole had no overlap to undo and is left as the first pass
|
|
1990
|
+
// drew it, which is `rect`'s page.
|
|
1991
|
+
if (owners.some((o) => !o.shape.whole)) {
|
|
1992
|
+
for (const { source, place, shape: owned } of owners) extrudeOwned(plate, source, place.x - padding, place.y - padding, padding, owned);
|
|
1292
1993
|
}
|
|
1293
1994
|
const name = index === 0 ? `${stem}.png` : `${stem}${index + 1}.png`;
|
|
1294
1995
|
pages.push({ name, width: pageW, height: pageH, plate, occupancy: covered / (pageW * pageH) });
|
|
@@ -1318,7 +2019,7 @@ export function packAtlas(inputs: PackInput[], opts: PackOptions = {}): PackResu
|
|
|
1318
2019
|
});
|
|
1319
2020
|
});
|
|
1320
2021
|
|
|
1321
|
-
return { pages, placements, atlasText: writeAtlasText(emitPages), padding };
|
|
2022
|
+
return { pages, placements, atlasText: writeAtlasText(emitPages), padding, shape };
|
|
1322
2023
|
}
|
|
1323
2024
|
|
|
1324
2025
|
// ---------------------------------------------------------------------------
|