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.
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * A06, the body (issue #1025, step 4c of #380): every page the atlas names is
3
3
  * the size its PNG is, every region lies inside its page, and — under
4
- * `spine-html` — no page claims premultiplied alpha, no two regions on one page
5
- * overlap and no region is turned.
4
+ * `spine-html` — no page claims premultiplied alpha and no region is turned.
5
+ * The clause that refused two regions on one page overlapping is
6
+ * `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` since issue #1099 (`./a49.ts`).
6
7
  *
7
8
  * Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
8
9
  * regions are a fact (`../facts/atlas_regions.ts`), the directory is the
@@ -13,7 +14,7 @@
13
14
  import { existsSync, readFileSync } from 'node:fs';
14
15
  import { resolve } from 'node:path';
15
16
  import type { Verdicts } from '../harness.ts';
16
- import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
17
+ import type { AtlasRegionFacts } from '../facts/atlas_regions.ts';
17
18
  import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
18
19
  import { pageFootprint, pageGridSentence } from '../../atlas.ts';
19
20
  import { readPngHeader } from '../../png.ts';
@@ -202,30 +203,27 @@ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { a
202
203
  }
203
204
  }
204
205
  if (!policy) return unreadSizes();
205
- // ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2). This
206
- // clause used to be the first alternative alone — every region's UVs
207
- // `(0,0)-(1,1)` — which is rigc's *unpacked* convention and exactly what
208
- // `build --pack` stops being true, so `--pack --profile spine-html` had to be
209
- // a named CLI refusal. A pack is not a defect, and refusing it under this
210
- // profile meant the one shape that exercises the renderer's shared-page
211
- // sampling could never be gated by the renderer's own rulebook.
212
- //
213
- // What the first alternative bought was the attachment -> region -> file
214
- // chain being checkable exactly, and `A27` already owns that half and already
215
- // stands down on a multi-region page. What is left to check on a *tiling*
216
- // page is what makes shared-page sampling well defined at all: no two regions
217
- // on one page overlapping. A foreign pack can fail that while loading clean,
218
- // and two overlapping rectangles put one drawing inside another's.
206
+ // ⭐ **One part per page OR a tiling page** (issue #266, follow-up 2), and
207
+ // since issue #1099 the tiling half is not this assertion's. It held that no
208
+ // two regions on one page overlap — the check that makes shared-page
209
+ // sampling well defined — and it was stated over rectangles, which is the
210
+ // right statement for a region attachment and the wrong one for a mesh: a
211
+ // mesh draws its hull, and `--pack-shape polygon` puts a neighbour inside a
212
+ // mesh's rectangle wherever the hull is not. The clause is now
213
+ // `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` ([`./a49.ts`](a49.ts)), which
214
+ // refuses a pair only where the rectangles overlap AND what the two regions
215
+ // draw does — so every pair this clause refused where neither region is a
216
+ // readable mesh hull (an alias, a sequence frame, a rotated region, two
217
+ // rectangles planted over the same texels) is refused there, by the same two
218
+ // rectangles, under the same profile.
219
219
  //
220
220
  // ⚠️ Its sibling — every region inside the page it names — moved above and
221
221
  // out of this profile in issue #694, and the two are not symmetrical. That
222
- // one is broken for every consumer; this one is a statement about what a
222
+ // one is broken for every consumer; the overlap is a statement about what a
223
223
  // pack MEANS, and the corpus is the evidence rather than the taste:
224
224
  // measured over the ten atlases in `examples/`, 0 of 132 regions are off
225
225
  // their page and 49 pairs on four of those pages overlap, editor-exported
226
- // and correct. A rule that called those files broken would be one
227
- // consumer's convention refusing everybody else's data, which is the thing
228
- // the profile split exists to prevent.
226
+ // and correct. That is why A49 is a renderer rule.
229
227
  //
230
228
  // ⚠️ Rotation stays refused either way, and that is not the same clause: it
231
229
  // is about rigc's own packer never turning a region, which is a statement
@@ -234,38 +232,6 @@ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { a
234
232
  // its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
235
233
  // must not be re-merged: an artifact under the renderer's own rulebook that
236
234
  // rigc did not pack is still a foreign artifact, whatever rigc can measure.
237
- const regionsPerPage = new Map<string, AtlasRegionEntry[]>();
238
- for (const region of atlas.regions) {
239
- const on = regionsPerPage.get(region.page.name);
240
- if (on) on.push(region);
241
- else regionsPerPage.set(region.page.name, [region]);
242
- }
243
- for (const [pageName, on] of regionsPerPage) {
244
- // The `onePartPerPage` guard that stood here went with the clause it was
245
- // written for: with only the pair check left, a page carrying one region
246
- // has no pair and the loop below does nothing on it anyway. Nothing reads
247
- // `u`/`v`/`u2`/`v2` in this assertion any more, which is the point of
248
- // #579 kept rather than restated.
249
- const rects = on.map((region) => {
250
- const foot = pageFootprint(region);
251
- return { name: region.name, x: region.x, y: region.y, width: foot.width, height: foot.height };
252
- });
253
- for (let i = 0; i < rects.length; i++) {
254
- for (let j = i + 1; j < rects.length; j++) {
255
- const a = rects[i];
256
- const b = rects[j];
257
- if (a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height) {
258
- fail(
259
- 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
260
- `regions "${a.name}" (${a.x},${a.y} ${a.width}x${a.height}) and "${b.name}" (${b.x},${b.y} ` +
261
- `${b.width}x${b.height}) overlap on page "${pageName}"; a page is one part covering it exactly or ` +
262
- 'a tiling of regions that do not, and two rectangles over the same texels put one drawing inside ' +
263
- "the other's",
264
- );
265
- }
266
- }
267
- }
268
- }
269
235
  for (const region of atlas.regions) {
270
236
  if (region.degrees !== 0) {
271
237
  fail(
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A49, the body (issue #1099): no two regions on one page draw over each
3
+ * other — refused only where their rectangles overlap **and** what they draw
4
+ * overlaps, so a mesh's region may sit over a neighbour's rectangle wherever
5
+ * its hull is not. The footprint, and why the test is exactly A06's old tiling
6
+ * clause for two rectangles, is `../footprints.ts`.
7
+ *
8
+ * ⭐ **The clause it replaces.** A06 held, under `spine-html`, that no two
9
+ * region rectangles on one page overlap. A rectangle's footprint is the
10
+ * rectangle, so on every pair A06 refused where neither region is a readable
11
+ * mesh hull — every editor alias, every sequence frame, every rotated region,
12
+ * every plant of two rectangles over the same texels — this refuses the same
13
+ * pair, printing the same two rectangles first. What it accepts that A06 did
14
+ * not is a pair whose rectangles overlap where a hull leaves the rectangle
15
+ * transparent: what `--pack-shape polygon` writes.
16
+ *
17
+ * 🔸 **Nothing about the padding.** A06 never held a gap between two
18
+ * rectangles, and the padding a pack was made with is on no file the gate
19
+ * reads — not the atlas, not the model document; only the `pack:` line on
20
+ * stdout states it. The `2 · padding` the packer keeps between footprints is
21
+ * the packer's promise, held by `PK81`.
22
+ *
23
+ * 📐 **Renderer kind, the profile the clause ran under.** Two regions over the
24
+ * same texels is what an editor's packer writes for an alias or a sequence:
25
+ * measured over the ten atlases in `examples/`, 49 pairs on four pages, and a
26
+ * correct editor export. A rule that refused them under `spine` would be one
27
+ * consumer's convention refusing everybody else's data.
28
+ *
29
+ * The order of the lines is the atlas's: pages as they come, and within a page
30
+ * each pair in region order — the order A06's clause printed them in.
31
+ */
32
+ import type { Verdicts } from '../harness.ts';
33
+ import type { AtlasRegionFacts } from '../facts/atlas_regions.ts';
34
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
35
+ import type { RegionJoinFacts } from '../facts/region_joins.ts';
36
+ import { footprintOverlap, rectanglesOverlap, regionFootprints, type RegionFootprint } from '../footprints.ts';
37
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE, SKIP_NO_ATLAS_REGION, SKIP_NO_ATLAS_REGION_PAIR } from '../reasons.ts';
38
+
39
+ const NAME = 'A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP';
40
+
41
+ /** A number for the sentence: to four places, trailing zeros dropped — `347`, `81.5`, `1916.5512`. */
42
+ const texels = (n: number): string => n.toFixed(4).replace(/\.?0+$/, '');
43
+
44
+ const rectSaid = (f: RegionFootprint): string => `"${f.region.name}" (${f.rect.x},${f.rect.y} ${f.rect.width}x${f.rect.height})`;
45
+
46
+ export function a49PackedFootprintsDoNotOverlap({ fail, skip }: Verdicts, facts: AtlasRegionFacts, joins: RegionJoinFacts, meshes: MeshFacts | null): void {
47
+ const { atlas } = facts;
48
+ if (!atlas) return skip(NAME, SKIP_NO_ATLAS);
49
+ if (atlas.pages.length === 0) return skip(NAME, SKIP_NO_ATLAS_PAGE);
50
+ if (atlas.regions.length === 0) return skip(NAME, SKIP_NO_ATLAS_REGION);
51
+ const feet = regionFootprints(facts, joins, meshes);
52
+ const perPage = new Map<string, RegionFootprint[]>();
53
+ for (const foot of feet) {
54
+ const on = perPage.get(foot.region.page.name);
55
+ if (on) on.push(foot);
56
+ else perPage.set(foot.region.page.name, [foot]);
57
+ }
58
+ if ([...perPage.values()].every((on) => on.length < 2)) return skip(NAME, SKIP_NO_ATLAS_REGION_PAIR);
59
+ for (const [pageName, on] of perPage) {
60
+ for (let i = 0; i < on.length; i++) {
61
+ for (let j = i + 1; j < on.length; j++) {
62
+ const a = on[i];
63
+ const b = on[j];
64
+ if (!rectanglesOverlap(a, b)) continue;
65
+ const shared = footprintOverlap(a, b);
66
+ if (shared === null) continue;
67
+ const where =
68
+ a.whole && b.whole
69
+ ? `the two rectangles share ${texels(shared.box.x0)},${texels(shared.box.y0)} ${texels(shared.box.x1 - shared.box.x0)}x${texels(shared.box.y1 - shared.box.y0)}`
70
+ : `what they draw overlaps by ${texels(shared.area)} texel(s) of area inside ${texels(shared.box.x0)},${texels(shared.box.y0)} ` +
71
+ `${texels(shared.box.x1 - shared.box.x0)}x${texels(shared.box.y1 - shared.box.y0)}`;
72
+ fail(
73
+ NAME,
74
+ `regions ${rectSaid(a)} and ${rectSaid(b)} overlap on page "${pageName}", and so do their footprints — ` +
75
+ `"${a.region.name}" draws ${a.drawn}, "${b.region.name}" draws ${b.drawn}; ${where}. One drawing samples ` +
76
+ "texels that are the other's: move one region clear of the other's footprint, or pack the page again",
77
+ );
78
+ }
79
+ }
80
+ }
81
+ }
@@ -3,7 +3,7 @@
3
3
  * step 4e of #380) — the half of the gate the entry that links none of
4
4
  * spine-core runs beside the model side (`../model/index.ts`).
5
5
  *
6
- * ⭐ **Every one of the 49 is accounted for, by name.** The model side runs
6
+ * ⭐ **Every one of the 50 is accounted for, by name.** The model side runs
7
7
  * the moved assertions (`MOVED_ASSERTIONS`) over the document. What is left —
8
8
  * the complement, read off `ASSERTION_KIND` rather than listed — is the
9
9
  * round trip's own: A00, A01, A02, A05, A07, A16, A18, A31, A35 at the time of
@@ -0,0 +1,367 @@
1
+ /**
2
+ * What each region of an atlas draws, in page texels, and whether two of those
3
+ * drawings overlap — the reading `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` makes
4
+ * (issue #1099, the gate half of `--pack-shape polygon`).
5
+ *
6
+ * ## The footprint
7
+ *
8
+ * A region's footprint is the part of its page some attachment samples:
9
+ *
10
+ * * a region a **mesh** names (its `path`, else its name, and every frame of
11
+ * its `sequence` — the lookups `../region_lookups.ts` makes, the loader's
12
+ * own) draws the mesh's hull loop — its first `hullLength / 2` UVs, closed
13
+ * last to first — and each of its triangles; a **linked mesh** draws its
14
+ * source's, which is what `MeshEntry.regionUVs` and `triangles` already
15
+ * hold for one. A UV `(u, v)` lands on the page where
16
+ * `MeshAttachment.computeUVs` puts it, times the page size: the default
17
+ * case of `computeUvs` in `src/core/uvs.ts`, which the core suite holds to
18
+ * the runtime — `x = region.x − offsetX + u · originalWidth`,
19
+ * `y = region.y − (originalHeight − offsetY − height) + v · originalHeight`;
20
+ * * a region a **region attachment** names draws its whole rectangle, and so
21
+ * does a region **nothing** names (a frame no attachment reaches, the second
22
+ * of two regions with one name — the runtime's lookup returns the first);
23
+ * * a region is its rectangle, too, wherever the mesh cannot be read as a
24
+ * polygon over it: a mesh whose UVs leave 0..1, a hull or triangle list
25
+ * that does not index its vertices, a region turned by `rotate:` (the hull
26
+ * is not turned here; A06 refuses rotation under the profile this runs
27
+ * in), and every region at all when a skin entry's `sequence` is one the
28
+ * lookup walk will not guess at — then which regions a mesh reaches is not
29
+ * known, and the rectangle is the reading that assumes nothing.
30
+ *
31
+ * Those fallbacks are the packer's (`packFootprints` in `src/atlas.ts`), and
32
+ * every one of them is the rectangle — the footprint A06's tiling clause gave
33
+ * every region before this reading existed.
34
+ *
35
+ * ## The overlap, and why it is exactly A06's statement for rectangles
36
+ *
37
+ * Two regions on one page are refused when their rectangles' interiors overlap
38
+ * **and** their footprints' interiors overlap. Touching is not overlapping, as
39
+ * A06 never refused two rectangles that share an edge. A hull lies inside its
40
+ * rectangle wherever it is read as a hull (UVs in 0..1, offsets 0, which is
41
+ * every region rigc packs), so a footprint overlap implies a rectangle overlap;
42
+ * the rectangle test runs first anyway, so that "nothing A06 accepted is
43
+ * refused" is a property of this code rather than of the art.
44
+ *
45
+ * Each footprint is cut into convex pieces — a rectangle is one, a triangle is
46
+ * one, a hull loop is ear-clipped into triangles — and two footprints overlap
47
+ * when some piece of one and some piece of the other do. Two convex pieces are
48
+ * apart when an edge normal of either separates their projections (the
49
+ * separating-axis theorem, complete for two convex polygons in the plane).
50
+ * Every such comparison is made `FOOTPRINT_SLACK` texels in favour of
51
+ * "apart", the packer's own constant: the coordinates are doubles (a UV as the
52
+ * file writes it times an integer size), so a pair that merely touches may
53
+ * compute as overlapping by a few ulps, and a touch is what A06 accepts.
54
+ * Integer rectangles are exact either way.
55
+ *
56
+ * Links nothing from the runtime.
57
+ */
58
+ import { FOOTPRINT_SLACK, pageFootprint } from '../atlas.ts';
59
+ import type { AtlasRegionEntry, AtlasRegionFacts } from './facts/atlas_regions.ts';
60
+ import type { MeshEntry, MeshFacts } from './facts/mesh_attachments.ts';
61
+ import type { RegionJoinFacts } from './facts/region_joins.ts';
62
+
63
+ /** A point in page texels, x right and y down from the page's top-left. */
64
+ export type PagePoint = readonly [number, number];
65
+
66
+ /** A region's rectangle on its page, as `pageFootprint` derives it (a turned region's is transposed). */
67
+ export interface PageRect {
68
+ readonly x: number;
69
+ readonly y: number;
70
+ readonly width: number;
71
+ readonly height: number;
72
+ }
73
+
74
+ /** What one region draws. */
75
+ export interface RegionFootprint {
76
+ readonly region: AtlasRegionEntry;
77
+ readonly rect: PageRect;
78
+ /** `true` when the footprint is the whole rectangle. */
79
+ readonly whole: boolean;
80
+ /** What draws it, as the failure sentence states it. */
81
+ readonly drawn: string;
82
+ /** Its convex pieces, each a loop of page points; one piece, the rectangle, when `whole`. */
83
+ readonly pieces: ReadonlyArray<readonly PagePoint[]>;
84
+ }
85
+
86
+ /** The rectangle of a region on its page. */
87
+ export function pageRectOf(region: AtlasRegionEntry): PageRect {
88
+ const foot = pageFootprint(region);
89
+ return { x: region.x, y: region.y, width: foot.width, height: foot.height };
90
+ }
91
+
92
+ function rectPiece(r: PageRect): PagePoint[] {
93
+ return [
94
+ [r.x, r.y],
95
+ [r.x + r.width, r.y],
96
+ [r.x + r.width, r.y + r.height],
97
+ [r.x, r.y + r.height],
98
+ ];
99
+ }
100
+
101
+ const cross = (o: PagePoint, a: PagePoint, b: PagePoint): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
102
+
103
+ function signedArea(loop: readonly PagePoint[]): number {
104
+ let sum = 0;
105
+ for (let i = 0; i < loop.length; i++) {
106
+ const a = loop[i];
107
+ const b = loop[(i + 1) % loop.length];
108
+ sum += a[0] * b[1] - b[0] * a[1];
109
+ }
110
+ return sum / 2;
111
+ }
112
+
113
+ /**
114
+ * A closed loop cut into triangles by ear clipping. A loop with no area gives
115
+ * no piece (it draws nothing a neighbour could sit in). A vertex on a straight
116
+ * run, or the tip of a zero-area spike, is dropped, since it bounds no area.
117
+ * On a loop that is not simple the clipping still ends, and every triangle it
118
+ * gives has three of the loop's own vertices — so it never reaches outside the
119
+ * loop's convex hull, which is inside the region's rectangle.
120
+ */
121
+ export function earClip(loop: readonly PagePoint[]): PagePoint[][] {
122
+ const points: PagePoint[] = [];
123
+ for (const p of loop) {
124
+ const last = points[points.length - 1];
125
+ if (last === undefined || last[0] !== p[0] || last[1] !== p[1]) points.push(p);
126
+ }
127
+ while (points.length > 1 && points[0][0] === points[points.length - 1][0] && points[0][1] === points[points.length - 1][1]) points.pop();
128
+ if (points.length < 3) return [];
129
+ const area = signedArea(points);
130
+ if (area === 0) return [];
131
+ const ring = area > 0 ? points : points.slice().reverse();
132
+ const idx = ring.map((_, i) => i);
133
+ const out: PagePoint[][] = [];
134
+ let guard = 0;
135
+ while (idx.length > 3 && guard++ < 4 * ring.length * ring.length) {
136
+ let clipped = false;
137
+ for (let k = 0; k < idx.length; k++) {
138
+ const prev = ring[idx[(k + idx.length - 1) % idx.length]];
139
+ const cur = ring[idx[k]];
140
+ const next = ring[idx[(k + 1) % idx.length]];
141
+ const turn = cross(prev, cur, next);
142
+ if (turn === 0) {
143
+ idx.splice(k, 1);
144
+ clipped = true;
145
+ break;
146
+ }
147
+ if (turn < 0) continue;
148
+ let blocked = false;
149
+ for (const j of idx) {
150
+ const p = ring[j];
151
+ if (p === prev || p === cur || p === next) continue;
152
+ if (cross(prev, cur, p) >= 0 && cross(cur, next, p) >= 0 && cross(next, prev, p) >= 0) {
153
+ blocked = true;
154
+ break;
155
+ }
156
+ }
157
+ if (blocked) continue;
158
+ out.push([prev, cur, next]);
159
+ idx.splice(k, 1);
160
+ clipped = true;
161
+ break;
162
+ }
163
+ // No ear on a loop that is not simple: take the first convex corner anyway,
164
+ // which keeps every triangle on the loop's own vertices (header).
165
+ if (!clipped) {
166
+ const k = idx.findIndex((_, n) => cross(ring[idx[(n + idx.length - 1) % idx.length]], ring[idx[n]], ring[idx[(n + 1) % idx.length]]) > 0);
167
+ const at = k < 0 ? 0 : k;
168
+ out.push([ring[idx[(at + idx.length - 1) % idx.length]], ring[idx[at]], ring[idx[(at + 1) % idx.length]]]);
169
+ idx.splice(at, 1);
170
+ }
171
+ }
172
+ if (idx.length === 3) {
173
+ const tri: PagePoint[] = [ring[idx[0]], ring[idx[1]], ring[idx[2]]];
174
+ if (cross(tri[0], tri[1], tri[2]) !== 0) out.push(tri);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /** A mesh's UV polygons over its own drawing — the hull loop, then each triangle — or why they cannot be read as polygons over its rectangle. */
180
+ function meshPolygons(mesh: MeshEntry): { loops: number[][] } | { why: string } {
181
+ const uvs = mesh.regionUVs;
182
+ const vertices = uvs.length / 2;
183
+ const hull = mesh.hullLength / 2;
184
+ if (uvs.length % 2 !== 0 || !Number.isInteger(hull) || hull < 3 || hull > vertices) return { why: `mesh "${mesh.placeholder}" states a hull of ${hull} over ${vertices} vertex(es)` };
185
+ if (uvs.some((n) => !(n >= 0 && n <= 1))) return { why: `mesh "${mesh.placeholder}"'s UVs leave 0..1, so it samples outside its own rectangle` };
186
+ if (mesh.triangles.length % 3 !== 0 || mesh.triangles.some((t) => !Number.isInteger(t) || t < 0 || t >= vertices)) {
187
+ return { why: `mesh "${mesh.placeholder}"'s triangles do not index its ${vertices} vertex(es)` };
188
+ }
189
+ const loops: number[][] = [uvs.slice(0, 2 * hull)];
190
+ for (let t = 0; t < mesh.triangles.length; t += 3) {
191
+ const [a, b, c] = [mesh.triangles[t], mesh.triangles[t + 1], mesh.triangles[t + 2]];
192
+ loops.push([uvs[2 * a], uvs[2 * a + 1], uvs[2 * b], uvs[2 * b + 1], uvs[2 * c], uvs[2 * c + 1]]);
193
+ }
194
+ return { loops };
195
+ }
196
+
197
+ const keyOf = (skin: string, slot: string, placeholder: string): string => `${skin}\u0000${slot}\u0000${placeholder}`;
198
+
199
+ /**
200
+ * Every region's footprint, in the atlas's region order. `meshes` is `null`
201
+ * where the skeleton did not load (the round trip's own failure is A00's): then
202
+ * which region a mesh draws is not known, and every footprint is its rectangle.
203
+ */
204
+ export function regionFootprints({ atlas }: AtlasRegionFacts, { joins }: RegionJoinFacts, meshes: MeshFacts | null): RegionFootprint[] {
205
+ if (atlas === null) return [];
206
+ const rectOf = (region: AtlasRegionEntry, drawn: string): RegionFootprint => {
207
+ const rect = pageRectOf(region);
208
+ return { region, rect, whole: true, drawn, pieces: [rectPiece(rect)] };
209
+ };
210
+ if (meshes === null || joins === null) {
211
+ const why = meshes === null ? 'the skeleton did not load, so what draws it is not known' : 'the skeleton did not parse, so what draws it is not known';
212
+ return atlas.regions.map((region) => rectOf(region, `its whole rectangle (${why})`));
213
+ }
214
+ const unread = joins.find((join) => join.lookups === null);
215
+ if (unread !== undefined) {
216
+ const why = `skin "${unread.skin}" slot "${unread.slot}" entry "${unread.placeholder}" states a sequence whose frames cannot be read, so which regions a mesh reaches is not known`;
217
+ return atlas.regions.map((region) => rectOf(region, `its whole rectangle (${why})`));
218
+ }
219
+ const meshAt = new Map<string, MeshEntry>();
220
+ for (const mesh of meshes.meshes) {
221
+ const key = keyOf(mesh.skin, mesh.slot, mesh.placeholder);
222
+ if (!meshAt.has(key)) meshAt.set(key, mesh);
223
+ }
224
+ /** Per region drawn: the meshes over it, or the reason it is a rectangle. */
225
+ const uses = new Map<AtlasRegionEntry, { meshes: MeshEntry[]; rect: string | null }>();
226
+ for (const join of joins) {
227
+ const mesh = meshAt.get(keyOf(join.skin, join.slot, join.placeholder));
228
+ for (const name of join.lookups ?? []) {
229
+ const region = atlas.findRegion(name);
230
+ if (region === null) continue; // A08 names a lookup that finds no region
231
+ const use = uses.get(region) ?? { meshes: [], rect: null };
232
+ uses.set(region, use);
233
+ if (mesh === undefined) use.rect ??= `its whole rectangle, which region attachment "${join.placeholder}" (skin "${join.skin}", slot "${join.slot}") draws`;
234
+ else use.meshes.push(mesh);
235
+ }
236
+ }
237
+ return atlas.regions.map((region) => {
238
+ const use = uses.get(region);
239
+ if (use === undefined) return rectOf(region, 'its whole rectangle, which no attachment draws');
240
+ if (use.rect !== null) return rectOf(region, use.rect);
241
+ if (region.degrees !== 0) return rectOf(region, `its whole rectangle (it is turned ${region.degrees} degrees, and a hull is not turned here)`);
242
+ const pieces: PagePoint[][] = [];
243
+ const left = region.x - region.offsetX;
244
+ const top = region.y - (region.originalHeight - region.offsetY - region.height);
245
+ for (const mesh of use.meshes) {
246
+ const read = meshPolygons(mesh);
247
+ if ('why' in read) return rectOf(region, `its whole rectangle (${read.why})`);
248
+ read.loops.forEach((loop, i) => {
249
+ const points: PagePoint[] = [];
250
+ for (let k = 0; k + 1 < loop.length; k += 2) points.push([left + loop[k] * region.originalWidth, top + loop[k + 1] * region.originalHeight]);
251
+ if (i === 0) pieces.push(...earClip(points));
252
+ else if (cross(points[0], points[1], points[2]) !== 0) pieces.push(points);
253
+ });
254
+ }
255
+ const named = use.meshes.map((m) => `"${m.placeholder}" (skin "${m.skin}", slot "${m.slot}")`);
256
+ const drawn = named.length === 1 ? `the hull of mesh ${named[0]}` : `the hulls of meshes ${named.join(', ')}`;
257
+ return { region, rect: pageRectOf(region), whole: false, drawn, pieces };
258
+ });
259
+ }
260
+
261
+ /** Whether two convex pieces' interiors overlap by more than `FOOTPRINT_SLACK` along every edge normal of either. */
262
+ export function piecesOverlap(a: readonly PagePoint[], b: readonly PagePoint[]): boolean {
263
+ for (const poly of [a, b]) {
264
+ for (let i = 0; i < poly.length; i++) {
265
+ const p = poly[i];
266
+ const q = poly[(i + 1) % poly.length];
267
+ const nx = q[1] - p[1];
268
+ const ny = p[0] - q[0];
269
+ const len = Math.hypot(nx, ny);
270
+ if (len === 0) continue;
271
+ let minA = Infinity;
272
+ let maxA = -Infinity;
273
+ let minB = Infinity;
274
+ let maxB = -Infinity;
275
+ for (const v of a) {
276
+ const d = (v[0] * nx + v[1] * ny) / len;
277
+ minA = Math.min(minA, d);
278
+ maxA = Math.max(maxA, d);
279
+ }
280
+ for (const v of b) {
281
+ const d = (v[0] * nx + v[1] * ny) / len;
282
+ minB = Math.min(minB, d);
283
+ maxB = Math.max(maxB, d);
284
+ }
285
+ if (maxA <= minB + FOOTPRINT_SLACK || maxB <= minA + FOOTPRINT_SLACK) return false;
286
+ }
287
+ }
288
+ return true;
289
+ }
290
+
291
+ /** The convex polygon two convex pieces share (Sutherland–Hodgman), for the sentence that names the overlap. */
292
+ function sharedPolygon(subject: readonly PagePoint[], clip: readonly PagePoint[]): PagePoint[] {
293
+ const sign = signedArea(clip) >= 0 ? 1 : -1;
294
+ let out: PagePoint[] = subject.slice();
295
+ for (let i = 0; i < clip.length && out.length > 0; i++) {
296
+ const a = clip[i];
297
+ const b = clip[(i + 1) % clip.length];
298
+ const input = out;
299
+ out = [];
300
+ const inside = (p: PagePoint): boolean => sign * cross(a, b, p) >= 0;
301
+ for (let k = 0; k < input.length; k++) {
302
+ const p = input[k];
303
+ const q = input[(k + 1) % input.length];
304
+ const pin = inside(p);
305
+ const qin = inside(q);
306
+ if (pin) out.push(p);
307
+ if (pin !== qin) {
308
+ const dp = cross(a, b, p);
309
+ const dq = cross(a, b, q);
310
+ const t = dp / (dp - dq);
311
+ out.push([p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]);
312
+ }
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+
318
+ /** What two footprints share: the largest area any two pieces share and the box around every shared part — `null` when they do not overlap. */
319
+ export interface FootprintOverlap {
320
+ readonly area: number;
321
+ readonly box: { readonly x0: number; readonly y0: number; readonly x1: number; readonly y1: number };
322
+ }
323
+
324
+ const overlaps = (a: PageRect, b: PageRect): boolean => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
325
+
326
+ function boxOf(piece: readonly PagePoint[]): { x0: number; y0: number; x1: number; y1: number } {
327
+ let x0 = Infinity;
328
+ let y0 = Infinity;
329
+ let x1 = -Infinity;
330
+ let y1 = -Infinity;
331
+ for (const [x, y] of piece) {
332
+ x0 = Math.min(x0, x);
333
+ y0 = Math.min(y0, y);
334
+ x1 = Math.max(x1, x);
335
+ y1 = Math.max(y1, y);
336
+ }
337
+ return { x0, y0, x1, y1 };
338
+ }
339
+
340
+ /** Whether the rectangles' interiors overlap — A06's own test, strict, so two rectangles that share an edge do not. */
341
+ export function rectanglesOverlap(a: RegionFootprint, b: RegionFootprint): boolean {
342
+ return overlaps(a.rect, b.rect);
343
+ }
344
+
345
+ /** What two footprints' interiors share, or `null` when they do not overlap. Asked only of a pair whose rectangles overlap. */
346
+ export function footprintOverlap(a: RegionFootprint, b: RegionFootprint): FootprintOverlap | null {
347
+ let area = 0;
348
+ let box: { x0: number; y0: number; x1: number; y1: number } | null = null;
349
+ const boxesA = a.pieces.map(boxOf);
350
+ const boxesB = b.pieces.map(boxOf);
351
+ for (let i = 0; i < a.pieces.length; i++) {
352
+ for (let j = 0; j < b.pieces.length; j++) {
353
+ const ba = boxesA[i];
354
+ const bb = boxesB[j];
355
+ if (ba.x1 <= bb.x0 || bb.x1 <= ba.x0 || ba.y1 <= bb.y0 || bb.y1 <= ba.y0) continue;
356
+ if (!piecesOverlap(a.pieces[i], b.pieces[j])) continue;
357
+ // The verdict is the separating-axis test's; the clipped polygon only
358
+ // measures it for the sentence, and where rounding leaves it degenerate
359
+ // the two pieces' boxes stand in for where they meet.
360
+ const shared = sharedPolygon(a.pieces[i], b.pieces[j]);
361
+ if (shared.length >= 3) area = Math.max(area, Math.abs(signedArea(shared)));
362
+ const sb = shared.length >= 3 ? boxOf(shared) : { x0: Math.max(ba.x0, bb.x0), y0: Math.max(ba.y0, bb.y0), x1: Math.min(ba.x1, bb.x1), y1: Math.min(ba.y1, bb.y1) };
363
+ box = box === null ? sb : { x0: Math.min(box.x0, sb.x0), y0: Math.min(box.y0, sb.y0), x1: Math.max(box.x1, sb.x1), y1: Math.max(box.y1, sb.y1) };
364
+ }
365
+ }
366
+ return box === null ? null : { area, box };
367
+ }
@@ -32,9 +32,11 @@ export type AssertionKind = 'validity' | 'renderer' | 'archetype';
32
32
  * validity half must never stop running; their policy clauses are gated inside
33
33
  * the assertion body against `profile`, and each such clause says so where it
34
34
  * lives. They are A06 (size-vs-PNG and every region's rectangle inside the page
35
- * it names are validity; pma / rotation / two regions over the same texels are
36
- * policy — the rectangle moved across that line in issue #694, and the reason
37
- * is stated where it now sits) and A20 (weight coherence is validity; requiring
35
+ * it names are validity; pma and rotation are policy — the rectangle moved
36
+ * across that line in issue #694, and the reason is stated where it now sits;
37
+ * the policy clause about two regions over the same texels left A06 for
38
+ * `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` in issue #1099, a renderer rule of
39
+ * its own) and A20 (weight coherence is validity; requiring
38
40
  * a mesh to be weighted at all is policy).
39
41
  *
40
42
  * A08 was the third until issue #574 retired its policy clause. It required a
@@ -94,6 +96,7 @@ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
94
96
  A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
95
97
  A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
96
98
  A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
99
+ A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP: 'renderer',
97
100
  };
98
101
 
99
102
  /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `spine-html` every kind. */
@@ -31,7 +31,7 @@
31
31
  * handed over — now either one given beside the document is refused by name.
32
32
  *
33
33
  * ⛔ **Not wired into any command.** `build` keeps the round trip and its
34
- * forty-nine lines; this entry is what the selftest and the instrument call
34
+ * fifty lines; this entry is what the selftest and the instrument call
35
35
  * until every moved assertion is on it (the card's design, point 7).
36
36
  *
37
37
  * Links nothing from the runtime, directly or through what it imports — held
@@ -62,6 +62,7 @@ import { a22MeshUvsInUnitRange } from '../bodies/a22.ts';
62
62
  import { a38SkinMembersAreSkinRequired } from '../bodies/a38.ts';
63
63
  import { a06AtlasPageSizeMatchesPng } from '../bodies/a06.ts';
64
64
  import { a19OverlayPngsHaveAlpha } from '../bodies/a19.ts';
65
+ import { a49PackedFootprintsDoNotOverlap } from '../bodies/a49.ts';
65
66
  import { a27RegionNameMatchesPageFilename } from '../bodies/a27.ts';
66
67
  import { modelAnimatedBones } from './animated_bones.ts';
67
68
  import { modelSkinMembers } from './skin_members.ts';
@@ -304,6 +305,7 @@ export const MOVED_ASSERTIONS: readonly MovedAssertion[] = [
304
305
  { code: 'A17_ATLAS_PAGE_FILES_EXIST', run: (v, read, input, supply) => a17AtlasPageFilesExist(v, supply.atlasPages(read), input), unread: SKIP_NO_ATLAS },
305
306
  { code: 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG', run: (v, read, input, supply) => a06AtlasPageSizeMatchesPng(v, supply.atlasRegions(read, input), input, input.profile === 'spine-html'), unread: SKIP_NO_ATLAS },
306
307
  { code: 'A19_OVERLAY_PNGS_HAVE_ALPHA', run: (v, read, input, supply) => a19OverlayPngsHaveAlpha(v, supply.atlasRegions(read, input), supply.stage(read, input), supply.skinEntries(read), { atlasDir: input.atlasDir, rig: supply.rigInfo(read, input) }), unread: SKIP_NO_ATLAS },
308
+ { code: 'A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP', run: (v, read, input, supply) => a49PackedFootprintsDoNotOverlap(v, supply.atlasRegions(read, input), supply.regionJoins(read), supply.meshes(read)), unread: SKIP_NO_ATLAS },
307
309
  { code: 'A24_AXIS_SPACE_STROKE', run: (v, read, input, supply) => a24AxisSpaceStroke(v, supply.boneTimelines(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
308
310
  { code: 'A25_DETACHED_BONE_PARENTAGE', run: (v, read, input, supply) => a25DetachedBoneParentage(v, supply.skeletonRoster(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
309
311
  { code: 'A26_SLOT_DRAW_ORDER', run: (v, read, input, supply) => a26SlotDrawOrder(v, supply.skeletonRoster(read), { rig: supply.rigInfo(read, input) }), unread: SKIP_NO_MODEL, beforeTheParse: true },
@@ -99,6 +99,8 @@ export const SKIP_NO_TIMELINE = 'no animation here carries a timeline';
99
99
  export const SKIP_NO_PHYSICS_CONSTRAINT = 'the skeleton declares no physics constraint';
100
100
  export const SKIP_NO_ATLAS_PAGE = 'the atlas declares no page';
101
101
  export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
102
+ /** A49's (issue #1099): the subject is a pair of regions on one page, and an atlas of one-region pages carries none. */
103
+ export const SKIP_NO_ATLAS_REGION_PAIR = 'no page of the atlas carries two regions, so there is no pair of footprints to hold apart';
102
104
  export const SKIP_NO_ATTACHMENT_REGION_JOIN =
103
105
  'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
104
106
  export const SKIP_NO_TWO_COLOR_TINT =