spine-rigc 2.0.2 → 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/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 = packOnePage(cells, candidate.w, candidate.h);
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 = packOnePage(cells, width, maxEdge, maxBottom);
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
- const single = smallestPageFor(cells, maxEdge, pageEdges);
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 = packOnePage(
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: Array<{ input: PackInput; cell: { w: number; h: number } }> = [];
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
  // ---------------------------------------------------------------------------