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/NOTICE.md +2 -2
- package/README.md +9 -7
- package/docs/AUTHORING.md +104 -14
- package/docs/FACE.md +1 -1
- package/docs/INGEST.md +35 -26
- 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/compile.ts +4 -1
- package/src/diff.ts +327 -5
- package/src/validate.ts +98 -7
- package/tools/editor_roundtrip.ts +75 -1
|
@@ -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
|
|
5
|
-
*
|
|
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 {
|
|
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)
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
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;
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/src/assertions/kinds.ts
CHANGED
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
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 =
|