spine-rigc 1.8.0 → 1.9.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/cli.ts +2 -2
- package/docs/AUTHORING.md +3 -3
- package/package.json +1 -1
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a06.ts +195 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a19.ts +295 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +255 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a41.ts +83 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a47.ts +59 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/constraint_words.ts +177 -0
- package/src/assertions/facts/animated_bones.ts +28 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/constraints.ts +131 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +71 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/skin_entries.ts +4 -2
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/skin_meshes.ts +49 -0
- package/src/assertions/facts/stage.ts +24 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +30 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/constraints.ts +207 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +130 -13
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +90 -0
- package/src/assertions/model/parse.ts +2 -0
- package/src/assertions/model/region_joins.ts +59 -0
- package/src/assertions/model/skin_entries.ts +2 -1
- package/src/assertions/model/skin_members.ts +62 -0
- package/src/assertions/model/skin_meshes.ts +90 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/vertex_polygons.ts +71 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/check.ts +8 -6
- package/src/compile.ts +145 -38
- package/src/core/index.ts +154 -24
- package/src/deformstructure.ts +16 -7
- package/src/emit_spine.ts +21 -41
- package/src/keyorder.ts +40 -0
- package/src/model.ts +183 -20
- package/src/render.ts +66 -25
- package/src/render_core.ts +41 -13
- package/src/validate.ts +601 -2328
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A19, the body (issue #1025, step 4c of #380): every overlay part can draw a
|
|
3
|
+
* transparent pixel — only the base plate may be opaque.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
|
|
6
|
+
* regions are a fact (`../facts/atlas_regions.ts`), the stage is a fact
|
|
7
|
+
* (`../facts/stage.ts`), the region attachments are the skins' fact
|
|
8
|
+
* (`../facts/skin_entries.ts`), and the directory and the rig info are the
|
|
9
|
+
* caller's. Why transparency is not the same thing as an alpha channel (#215)
|
|
10
|
+
* stays above the `check` call in `validate()`.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import type { Verdicts } from '../harness.ts';
|
|
15
|
+
import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
|
|
16
|
+
import type { SkinEntryFacts } from '../facts/skin_entries.ts';
|
|
17
|
+
import type { StageFacts } from '../facts/stage.ts';
|
|
18
|
+
import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
|
|
19
|
+
import { pageFootprint } from '../../atlas.ts';
|
|
20
|
+
import { colourTypeName, readPngHeader } from '../../png.ts';
|
|
21
|
+
import { readPlate } from '../../../tools/plate.ts';
|
|
22
|
+
import type { RigInfo } from '../../types.ts';
|
|
23
|
+
|
|
24
|
+
export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
|
|
25
|
+
// A SKIP for the same reason A06's is (#568). This one is invisible under
|
|
26
|
+
// `spine`, where the profile excludes the rule before its body runs — and
|
|
27
|
+
// that is exactly why it was worth finding: `--profile spine-html` reported
|
|
28
|
+
// it, and A27 below, green on an atlas nothing had read.
|
|
29
|
+
if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
|
|
30
|
+
if (atlas.pages.length === 0) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS_PAGE);
|
|
31
|
+
const stageW = stage.width ?? 0;
|
|
32
|
+
const stageH = stage.height ?? 0;
|
|
33
|
+
// 🔒 **Which image is the base plate is decided ONCE, and the rig's own
|
|
34
|
+
// statement decides it** (issue #770). A cut manifest names its base plate
|
|
35
|
+
// — the part whose window IS the crop — and that needs no stage box; the
|
|
36
|
+
// size of an attachment against the stage is read only when the build
|
|
37
|
+
// names none, because it is then the only statement there is (every build
|
|
38
|
+
// from a rig spec, and `validate <dir>`, which has no rig at all). Both
|
|
39
|
+
// routes below read the same two sets, so they cannot disagree about it.
|
|
40
|
+
//
|
|
41
|
+
// ⚠️ Before this, "at least the stage's size" was the only reading, and a
|
|
42
|
+
// stageless manifest rig came out with no base plate: the packed build was
|
|
43
|
+
// refused for its plate's opaque texels while the loose build of the same
|
|
44
|
+
// rig passed on the file's colour type. The order is a rule rather than a
|
|
45
|
+
// coincidence of the fixtures, where the two readings agree: a stage stated
|
|
46
|
+
// small enough for an overlay to "cover" is exactly where they part.
|
|
47
|
+
const named = input.rig?.basePlates ?? [];
|
|
48
|
+
const basePages = new Set<string>();
|
|
49
|
+
const baseRegions = new Set<string>();
|
|
50
|
+
const exempt = (name: string): void => {
|
|
51
|
+
const region = atlas.findRegion(name);
|
|
52
|
+
if (region) {
|
|
53
|
+
basePages.add(region.page.name);
|
|
54
|
+
baseRegions.add(region.name);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (named.length > 0) {
|
|
58
|
+
for (const name of named) exempt(name);
|
|
59
|
+
} else {
|
|
60
|
+
for (const att of regionAttachments) {
|
|
61
|
+
if (stageW && stageH && att.width >= stageW && att.height >= stageH) exempt(att.path || att.name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// The escape hatch is named the way it is reachable. With a base plate the
|
|
65
|
+
// rig names, that plate; with none but a stage, the size reading; and with
|
|
66
|
+
// neither, nothing here decides which image is the plate — so the sentence
|
|
67
|
+
// says what would, rather than pointing at a door that is not there.
|
|
68
|
+
const undecided =
|
|
69
|
+
'Only a base plate may be opaque, and nothing here decides which image that is: this skeleton declares no ' +
|
|
70
|
+
'stage size to measure one against, and ';
|
|
71
|
+
const exemption =
|
|
72
|
+
named.length > 0
|
|
73
|
+
? `Only the base plate the rig names (${named.map((name) => JSON.stringify(name)).join(', ')}, the part ` +
|
|
74
|
+
"whose window is the cut manifest's crop) may be opaque."
|
|
75
|
+
: stageW && stageH
|
|
76
|
+
? `Only the one image big enough to cover the whole stage (${stageW}x${stageH}) may be opaque.`
|
|
77
|
+
: input.rig
|
|
78
|
+
? `${undecided}the build names no base plate. Either would decide it: give the rig spec a "skeleton" ` +
|
|
79
|
+
'stage the plate covers, or build from a cut manifest, whose base plate is the part whose window is ' +
|
|
80
|
+
'the crop.'
|
|
81
|
+
: `${undecided}no rig was given to name one. Either would decide it: state a "skeleton" stage the plate ` +
|
|
82
|
+
'covers, or validate with the specs it was built from (--rig, --motion and the --manifest, whose base ' +
|
|
83
|
+
'plate is the part whose window is the crop).';
|
|
84
|
+
// 🚨 Counted per page for the unpacked convention and per REGION on a shared
|
|
85
|
+
// page, and the split is not a convenience (issue #266, follow-up 2). A
|
|
86
|
+
// packed page's own file all but always declares transparency — the gutter
|
|
87
|
+
// and whatever is left over of the page are transparent — so the file-level
|
|
88
|
+
// question is answered "yes" by the packing itself, whatever the parts on it
|
|
89
|
+
// look like. Asking it that way once packs became gateable under this profile
|
|
90
|
+
// would have turned this assertion into a pass that measures nothing, which
|
|
91
|
+
// is the failure mode this file exists to prevent. So a shared page is opened
|
|
92
|
+
// and each region's own rectangle is measured instead.
|
|
93
|
+
const sharedPages = new Map<string, AtlasRegionEntry[]>();
|
|
94
|
+
for (const region of atlas.regions) {
|
|
95
|
+
const on = sharedPages.get(region.page.name);
|
|
96
|
+
if (on) on.push(region);
|
|
97
|
+
else sharedPages.set(region.page.name, [region]);
|
|
98
|
+
}
|
|
99
|
+
for (const page of atlas.pages) {
|
|
100
|
+
const abs = resolve(input.atlasDir, page.name);
|
|
101
|
+
if (!existsSync(abs)) continue;
|
|
102
|
+
const on = sharedPages.get(page.name) ?? [];
|
|
103
|
+
// 🔒 **A page that is not a PNG is a non-measurement for every part on
|
|
104
|
+
// it** (issue #732), stated the way #705's and #715's are: a FAIL naming
|
|
105
|
+
// what was not read, because `skip()` is per assertion and would delete
|
|
106
|
+
// the verdicts on every other page of the same report. The file's
|
|
107
|
+
// identity is `A06`'s to judge, and this sentence points there. A page
|
|
108
|
+
// holding nothing but the full-stage base plate has no part to judge and
|
|
109
|
+
// says nothing, exactly as a readable one would.
|
|
110
|
+
const header = readPngHeader(abs);
|
|
111
|
+
if (header.problem !== null) {
|
|
112
|
+
const shared = on.length > 1;
|
|
113
|
+
const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
|
|
114
|
+
if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
|
|
115
|
+
const subject = shared
|
|
116
|
+
? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
|
|
117
|
+
: `part image "${page.name}" is`;
|
|
118
|
+
fail(
|
|
119
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
120
|
+
`${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
|
|
121
|
+
'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
|
|
122
|
+
"belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
|
|
123
|
+
'--profile spine-html: the default --profile spine does not run this check.',
|
|
124
|
+
);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (on.length > 1) {
|
|
128
|
+
// 🚨 A rotated region is refused by A06 under this profile, so this
|
|
129
|
+
// reading was assumed to be cosmetic — a rectangle printed beside a
|
|
130
|
+
// failure already standing. It is not: the loop below OPENS the
|
|
131
|
+
// rectangle and stops at the first transparent texel, so a rectangle
|
|
132
|
+
// wider than the drawing runs into the transparent gutter, finds its
|
|
133
|
+
// texel there and names nothing. With the transpose applied at 90 only,
|
|
134
|
+
// two fully opaque parts on one page were measured green at
|
|
135
|
+
// `rotate: 270` and red at 0, 90 and 180 — this assertion's own verdict,
|
|
136
|
+
// flipped by the rotation it does not judge (issue #579). The footprint
|
|
137
|
+
// is `pageFootprint`'s, which every other reader of it now calls.
|
|
138
|
+
// 🚨 **The scan counts what it READ, and zero texels read is not a
|
|
139
|
+
// verdict** (issue #705). The `continue` above walks past every
|
|
140
|
+
// coordinate that is not on the page, so a rectangle none of whose
|
|
141
|
+
// texels are on it came out of this loop with `transparent` still
|
|
142
|
+
// false — indistinguishable from a solid drawing — and the sentence
|
|
143
|
+
// below then stated opacity over texels nobody had opened. Measured on
|
|
144
|
+
// a pack shaped like #707's: `part "block" is opaque in every one of
|
|
145
|
+
// its 12x8 texels`, over **0 of 96**, on art carrying 36 clear texels
|
|
146
|
+
// where it was packed. That is the message-as-UI defect in one line —
|
|
147
|
+
// the reader is sent to re-export a part whose alpha was never the
|
|
148
|
+
// problem, and the rectangle that is the problem belongs to A06.
|
|
149
|
+
//
|
|
150
|
+
// ⚠️ It is a FAIL rather than a SKIP, and the report's own shape
|
|
151
|
+
// decides that rather than taste. `skip()` is per ASSERTION, so
|
|
152
|
+
// skipping here would delete the verdicts on every other part of the
|
|
153
|
+
// page — on that same pack the second part is genuinely opaque and is
|
|
154
|
+
// named — and adding a skip BESIDE those failures puts A19 in two of
|
|
155
|
+
// the four buckets `reportLines` adds up, which prints `45 assertions`
|
|
156
|
+
// where the registry holds 44. What is left is a failure that says
|
|
157
|
+
// what was not measured, which is also what "green means measured"
|
|
158
|
+
// requires: a part this rule could not read must not be certified by
|
|
159
|
+
// it.
|
|
160
|
+
const plate = readPlate(abs);
|
|
161
|
+
// 🚨 **The scan's coordinates are the atlas's, so a file that is not the
|
|
162
|
+
// declared grid is a non-measurement for every region on it** (issue
|
|
163
|
+
// #715), and #705's clause above does not cover it. That one fires when
|
|
164
|
+
// a rectangle has NO texel on the page; a page whose image is a rescale
|
|
165
|
+
// of the declared size leaves most rectangles partly on it, at
|
|
166
|
+
// coordinates that address a different part of the picture. Measured on
|
|
167
|
+
// a two-region pack at a uniform 0.5: the opaque part's failure
|
|
168
|
+
// DISAPPEARED — the scan found a transparent texel 32 texels away from
|
|
169
|
+
// it and returned — while the other printed #705's sentence over 0 of
|
|
170
|
+
// 256 texels. A verdict and a silence, both about texels nobody located.
|
|
171
|
+
//
|
|
172
|
+
// A FAIL for #705's reason, word for word: `skip()` is per assertion and
|
|
173
|
+
// would delete the verdicts on every other page in the same report.
|
|
174
|
+
if (plate.width !== page.width || plate.height !== page.height) {
|
|
175
|
+
for (const region of on) {
|
|
176
|
+
if (baseRegions.has(region.name)) continue;
|
|
177
|
+
const { width, height } = pageFootprint(region);
|
|
178
|
+
fail(
|
|
179
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
180
|
+
`part "${region.name}" is not measured: this rule opens the page at the coordinates the atlas ` +
|
|
181
|
+
`states, and page "${page.name}" declares ${page.width}x${page.height} over a ` +
|
|
182
|
+
`${plate.width}x${plate.height} image, so the ${width}x${height} rectangle at ${region.x},` +
|
|
183
|
+
`${region.y} is not where "${region.name}"'s texels are on this file and this rule states ` +
|
|
184
|
+
'nothing about whether it can draw a transparent pixel. The page grid is ' +
|
|
185
|
+
"A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and it names the ratio and how to declare the page " +
|
|
186
|
+
'honestly. This is renderer policy, and it belongs to --profile spine-html: the default ' +
|
|
187
|
+
'--profile spine does not run this check.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const region of on) {
|
|
193
|
+
if (baseRegions.has(region.name)) continue;
|
|
194
|
+
const { width, height } = pageFootprint(region);
|
|
195
|
+
const declared = width * height;
|
|
196
|
+
let read = 0;
|
|
197
|
+
let transparent = false;
|
|
198
|
+
for (let y = region.y; y < region.y + height && !transparent; y++) {
|
|
199
|
+
for (let x = region.x; x < region.x + width; x++) {
|
|
200
|
+
if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) continue;
|
|
201
|
+
read++;
|
|
202
|
+
if (plate.get(x, y)[3] < 255) {
|
|
203
|
+
transparent = true;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (transparent) continue;
|
|
209
|
+
// The page's size here is the DECODED image's and not the `size:`
|
|
210
|
+
// line's, because it is the bound this scan actually clipped
|
|
211
|
+
// against; where the two disagree A06 says so in its own sentence.
|
|
212
|
+
if (read === 0) {
|
|
213
|
+
fail(
|
|
214
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
215
|
+
`part "${region.name}" is not measured: this rule read 0 of the ${declared} texels of its ` +
|
|
216
|
+
`${width}x${height} rectangle at ${region.x},${region.y} on page "${page.name}", whose image is ` +
|
|
217
|
+
`${plate.width}x${plate.height}, so it states nothing about whether "${region.name}" can draw a ` +
|
|
218
|
+
"transparent pixel. A region's rectangle is A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and one " +
|
|
219
|
+
'that runs off its page is refused there by name. This is renderer policy, and it belongs to ' +
|
|
220
|
+
'--profile spine-html: the default --profile spine does not run this check.',
|
|
221
|
+
);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// A rectangle partly on the page states the verdict over the texels
|
|
225
|
+
// it read and says how many of the declared ones that was. A whole
|
|
226
|
+
// rectangle prints the sentence it has always printed, to the byte.
|
|
227
|
+
const over =
|
|
228
|
+
read === declared
|
|
229
|
+
? `every one of its ${width}x${height} texels on shared page "${page.name}"`
|
|
230
|
+
: `every one of the ${read} texels of its ${width}x${height} rectangle at ${region.x},${region.y} ` +
|
|
231
|
+
`that are on shared page "${page.name}", whose image is ${plate.width}x${plate.height} — the ` +
|
|
232
|
+
`other ${declared - read} of the ${declared} it declares are not on the page and are not ` +
|
|
233
|
+
'measured here';
|
|
234
|
+
fail(
|
|
235
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
236
|
+
`part "${region.name}" is opaque in ${over}, so it would paint a solid rectangle over whatever is ` +
|
|
237
|
+
`drawn behind it. Re-export the part with transparency and pack again. ${exemption} This is ` +
|
|
238
|
+
'renderer policy, and it belongs to --profile spine-html: the default --profile spine does not run ' +
|
|
239
|
+
'this check.',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const info = header.info;
|
|
245
|
+
if (basePages.has(page.name)) continue; // the base plate: opaque is correct
|
|
246
|
+
// The header is the FAST NEGATIVE and only that (#215's rule, unchanged):
|
|
247
|
+
// a colour type 0, 2 or 3 file with no tRNS chunk has nowhere to keep a
|
|
248
|
+
// transparent texel, so it is refused without opening it.
|
|
249
|
+
if (!info.hasTransparency) {
|
|
250
|
+
fail(
|
|
251
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
252
|
+
`part image "${page.name}" cannot be transparent anywhere: it is colour type ${info.colourType} ` +
|
|
253
|
+
`(${colourTypeName(info.colourType)}) with no tRNS chunk, so it would paint a solid rectangle over ` +
|
|
254
|
+
'whatever is drawn behind it. Re-export it with transparency — as RGBA, or as an indexed or greyscale ' +
|
|
255
|
+
`PNG that keeps its tRNS chunk. ${exemption} This is renderer policy, and it belongs to --profile ` +
|
|
256
|
+
'spine-html: the default --profile spine does not run this check.',
|
|
257
|
+
);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// 🚨 **A header that says "could be transparent" is not an answer, and
|
|
261
|
+
// the texels decide it exactly as they do on a shared page** (issue
|
|
262
|
+
// #777). Until this the loose route stopped here, so a part saved as
|
|
263
|
+
// RGBA passed whether or not any texel used the channel: on the
|
|
264
|
+
// articulated fixture, stageless, an overlay rewritten as colour type 6
|
|
265
|
+
// with 0 of its 16,000 texels below full alpha PASSED loose and was
|
|
266
|
+
// refused by `--pack` of the same rig, over the same texels. The runtime
|
|
267
|
+
// draws those texels, not the file's declaration, so the loose verdict
|
|
268
|
+
// was the false green. The rectangle is the whole decoded image — on a
|
|
269
|
+
// loose page the file IS the part, so no atlas coordinate is read and
|
|
270
|
+
// #705's and #715's non-measurements cannot arise here — and the scan
|
|
271
|
+
// stops at the first clear texel, as the shared page's does.
|
|
272
|
+
const plate = readPlate(abs);
|
|
273
|
+
let transparent = false;
|
|
274
|
+
for (let y = 0; y < plate.height && !transparent; y++) {
|
|
275
|
+
for (let x = 0; x < plate.width; x++) {
|
|
276
|
+
if (plate.get(x, y)[3] < 255) {
|
|
277
|
+
transparent = true;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (transparent) continue;
|
|
283
|
+
const holds = info.hasAlpha
|
|
284
|
+
? `colour type ${info.colourType} (${colourTypeName(info.colourType)}) carries an alpha channel`
|
|
285
|
+
: `colour type ${info.colourType} (${colourTypeName(info.colourType)}) carries a tRNS chunk`;
|
|
286
|
+
fail(
|
|
287
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
288
|
+
`part image "${page.name}" is opaque in every one of its ${plate.width}x${plate.height} texels, so it ` +
|
|
289
|
+
`would paint a solid rectangle over whatever is drawn behind it: its file can hold transparency — ${holds} ` +
|
|
290
|
+
'— and no texel uses it. Re-export the part with the transparency it is meant to have. ' +
|
|
291
|
+
`${exemption} This is renderer policy, and it belongs to --profile spine-html: the default --profile ` +
|
|
292
|
+
'spine does not run this check.',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A20, the body (issue #1025, step 4c of #380): a weighted mesh's weights
|
|
3
|
+
* cohere — every vertex bound, every weight finite and non-negative, every
|
|
4
|
+
* vertex's weights summing to 1 — and, under `spine-html`, a generated mesh is
|
|
5
|
+
* weighted, binds only bones that move it and binds every bone it declares.
|
|
6
|
+
*
|
|
7
|
+
* Moved out of `src/validate.ts` clause by clause, every clause about the
|
|
8
|
+
* weights themselves moving: a document's mesh names each vertex's bindings,
|
|
9
|
+
* so a vertex count that disagrees with the `uvs`, an empty binding list, a
|
|
10
|
+
* weight that is not finite, below 0 or 0 on a generated mesh, a sum off 1 and
|
|
11
|
+
* a declared bone no vertex binds each have a place to live there (the reader
|
|
12
|
+
* refuses the first three by the record's name). The one clause that does not
|
|
13
|
+
* — a binding's index past the bone array — is about the encoding: the
|
|
14
|
+
* document names a binding's bone, and the index is the emitter's to write.
|
|
15
|
+
* It stays with the round trip, which hands its finding in on the binding
|
|
16
|
+
* (`MeshBinding.encoding`), printed where the body always printed it.
|
|
17
|
+
*
|
|
18
|
+
* The weights are the ones the runtime holds — float32 — and a binding's bone
|
|
19
|
+
* its index in the roster, because the weight-0 clause prints it.
|
|
20
|
+
*/
|
|
21
|
+
import type { Verdicts } from '../harness.ts';
|
|
22
|
+
import type { MeshFacts } from '../facts/mesh_attachments.ts';
|
|
23
|
+
import { meshKindOf } from '../mesh_kinds.ts';
|
|
24
|
+
import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
|
|
25
|
+
import type { RigInfo } from '../../types.ts';
|
|
26
|
+
|
|
27
|
+
export function a20MeshWeightsCoherent({ fail, skip }: Verdicts, { meshes, bones }: MeshFacts, policy: boolean, rig: RigInfo | undefined): void {
|
|
28
|
+
if (meshes.length === 0) return skip('A20_MESH_WEIGHTS_COHERENT', SKIP_NO_MESH_ATTACHMENT);
|
|
29
|
+
for (const mesh of meshes) {
|
|
30
|
+
// 🚨 Authored geometry is not rigc's to have opinions about. The two
|
|
31
|
+
// policy branches in this assertion are both statements about what a
|
|
32
|
+
// rigc GENERATOR is supposed to produce — "a mesh here is weighted",
|
|
33
|
+
// "a generated mesh binds only bones that move it" — and neither is a
|
|
34
|
+
// fact about Spine or about somebody else's mesh. Applying them to
|
|
35
|
+
// authored geometry failed correct data (issue #44). The coherence
|
|
36
|
+
// rules below the branch are unconditional and still apply.
|
|
37
|
+
const generated = meshKindOf(mesh, rig) !== 'authored';
|
|
38
|
+
if (!mesh.weights) {
|
|
39
|
+
// 📐 PROFILE. An unweighted mesh is perfectly valid Spine — spineboy
|
|
40
|
+
// ships two — and the runtime poses it from the slot bone. What is
|
|
41
|
+
// NOT valid, in any profile, is a weighted mesh whose weights do not
|
|
42
|
+
// cohere, which is everything below this branch. So the requirement
|
|
43
|
+
// that a mesh be weighted at all is the policy half, and it is the
|
|
44
|
+
// only half gated here.
|
|
45
|
+
if (policy && generated) {
|
|
46
|
+
fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" is unweighted; the ring tier drives meshes by bones`);
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const perVertex = mesh.weights;
|
|
51
|
+
const expected = mesh.worldVerticesLength / 2;
|
|
52
|
+
if (perVertex.length !== expected) {
|
|
53
|
+
fail(
|
|
54
|
+
'A20_MESH_WEIGHTS_COHERENT',
|
|
55
|
+
`mesh "${mesh.name}" has weights for ${perVertex.length} vertices but ${expected} uv pairs`,
|
|
56
|
+
);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
perVertex.forEach((vertex, i) => {
|
|
60
|
+
if (!vertex.length) fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} has no bones`);
|
|
61
|
+
let sum = 0;
|
|
62
|
+
for (const { bone, weight, encoding } of vertex) {
|
|
63
|
+
if (!Number.isFinite(weight) || weight < 0) {
|
|
64
|
+
fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} has weight ${weight}`);
|
|
65
|
+
}
|
|
66
|
+
// 📐 PROFILE. A weight of exactly 0 is legal, harmless Spine: the
|
|
67
|
+
// runtime accumulates `(…) * weight` (Attachment.js:131), so the
|
|
68
|
+
// binding contributes nothing. The Spine editor writes them — the
|
|
69
|
+
// auto-weighted meshes in 6-arcs, 7-anticipation and 8-follow-through
|
|
70
|
+
// carry dozens, and their vertex weights still sum to 1. Treating one
|
|
71
|
+
// as corruption failed three rungs of the ladder on correct data.
|
|
72
|
+
// In a rigc-GENERATED ring or ribbon it is still a defect: the
|
|
73
|
+
// generator bound a bone that does nothing, which is a bug in the
|
|
74
|
+
// generator and dead work in the runtime's inner loop. So it stays a
|
|
75
|
+
// failure under spine-html and is not one under spine.
|
|
76
|
+
else if (policy && generated && weight === 0) {
|
|
77
|
+
fail(
|
|
78
|
+
'A20_MESH_WEIGHTS_COHERENT',
|
|
79
|
+
`mesh "${mesh.name}" vertex ${i} is bound to bone index ${bone} at weight 0; a generated mesh binds only bones that move it`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
// The index's range is the encoding's, kept with the round trip (the header).
|
|
83
|
+
if (encoding !== undefined) fail('A20_MESH_WEIGHTS_COHERENT', encoding);
|
|
84
|
+
sum += weight;
|
|
85
|
+
}
|
|
86
|
+
if (Math.abs(sum - 1) > 1e-3) {
|
|
87
|
+
fail('A20_MESH_WEIGHTS_COHERENT', `mesh "${mesh.name}" vertex ${i} weights sum to ${sum.toFixed(4)}`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// 📐 PROFILE, and the converse of the weight-0 branch above: that one
|
|
91
|
+
// says a generated mesh binds only bones that MOVE it, and this one says
|
|
92
|
+
// every bone it declares moves it. They are halves of one sentence — the
|
|
93
|
+
// bone set the mesh declares is the bone set its weights reference — so
|
|
94
|
+
// they are one assertion rather than two, and neither is a fact about
|
|
95
|
+
// Spine: a declared bone nothing binds loads and renders perfectly.
|
|
96
|
+
//
|
|
97
|
+
// 🚨 It is the one mesh question a vertex cannot answer, which is why
|
|
98
|
+
// every per-vertex rule above was green on the ring that raised it: a
|
|
99
|
+
// rig-spec ring naming two grips bound one of them, and the absent bone
|
|
100
|
+
// appears in no vertex, in no sum and in no index (issue #684). The
|
|
101
|
+
// declaration comes from the compiler's own record of what it bound,
|
|
102
|
+
// which is what the `MESH` report line prints.
|
|
103
|
+
if (policy && generated && rig) {
|
|
104
|
+
const declared = rig.meshDeclaredBones[mesh.slot] || [];
|
|
105
|
+
const bound = new Set<string>();
|
|
106
|
+
for (const vertex of perVertex) {
|
|
107
|
+
for (const { bone } of vertex) {
|
|
108
|
+
const named = bones[bone];
|
|
109
|
+
if (named !== undefined) bound.add(named);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const name of declared) {
|
|
113
|
+
if (bound.has(name)) continue;
|
|
114
|
+
fail(
|
|
115
|
+
'A20_MESH_WEIGHTS_COHERENT',
|
|
116
|
+
`mesh "${mesh.name}" declares bone "${name}" and none of its ${perVertex.length} vertices binds it; ` +
|
|
117
|
+
`the weights reference ${[...bound].map((n) => `"${n}"`).join(', ')}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A21, the body (issue #1025, step 4c of #380): a generated mesh's rim — or
|
|
3
|
+
* a ribbon's entry row — is pinned to the slot's own bone, and a soft region
|
|
4
|
+
* moves only the vertices it declares. An `archetype` rule.
|
|
5
|
+
*
|
|
6
|
+
* Moved out of `src/validate.ts` whole: every clause is about the rig — which
|
|
7
|
+
* vertex sits on the region border or the hull, which bone carries it and at
|
|
8
|
+
* what weight — and a document's mesh states each of those (its `uvs`, its
|
|
9
|
+
* `hull`, its bindings by bone). Nothing it reads is the encoding's. The
|
|
10
|
+
* weights are the runtime's float32, a binding's bone its index in the roster
|
|
11
|
+
* (named back through the roster where the sentence names it), and a link's
|
|
12
|
+
* geometry its source's.
|
|
13
|
+
*/
|
|
14
|
+
import type { Verdicts } from '../harness.ts';
|
|
15
|
+
import type { MeshFacts } from '../facts/mesh_attachments.ts';
|
|
16
|
+
import { authoredMeshNames, meshKindOf } from '../mesh_kinds.ts';
|
|
17
|
+
import type { RigInfo } from '../../types.ts';
|
|
18
|
+
|
|
19
|
+
export function a21MeshRimPinned({ fail, skip }: Verdicts, { meshes, bones }: MeshFacts, rig: RigInfo | undefined): void {
|
|
20
|
+
// Without the rig, ring and ribbon cannot be told apart — and the two kinds
|
|
21
|
+
// pin OPPOSITE edges, so guessing one would either check the wrong edge or
|
|
22
|
+
// check nothing while reporting a pass.
|
|
23
|
+
if (!rig) {
|
|
24
|
+
return skip('A21_MESH_RIM_PINNED', 'no rig info (validating a bare directory), so ring and ribbon cannot be told apart');
|
|
25
|
+
}
|
|
26
|
+
if (!meshes.some((m) => m.weights)) {
|
|
27
|
+
return skip('A21_MESH_RIM_PINNED', 'the skeleton has no weighted mesh attachment, so there is no rim to find unpinned');
|
|
28
|
+
}
|
|
29
|
+
// An authored mesh has no rim rigc drew and no entry row rigc placed, and
|
|
30
|
+
// a linked mesh has no rim of its OWN at all — the one it draws belongs to
|
|
31
|
+
// its source, and is measured there. Nothing to measure is a SKIP — never
|
|
32
|
+
// a pass, and never a failure on somebody else's correct geometry.
|
|
33
|
+
//
|
|
34
|
+
// 🔸 A `segments` mesh is rigc's own geometry and still has no rim to pin:
|
|
35
|
+
// its outline is traced off the art and weighted by distance like every
|
|
36
|
+
// other vertex, because a layer pulled by named bones is SUPPOSED to move
|
|
37
|
+
// at its edge. "Pinned to the slot bone" is not a claim that generator
|
|
38
|
+
// makes, so there is nothing here to hold it to — and `A20`'s coherence
|
|
39
|
+
// rules, which it does make, still apply to it in full.
|
|
40
|
+
const rimless = (m: (typeof meshes)[number]): boolean => meshKindOf(m, rig) === 'authored' || meshKindOf(m, rig) === 'segments';
|
|
41
|
+
const measurable = meshes.filter((m) => m.weights && !rimless(m));
|
|
42
|
+
if (measurable.length === 0) {
|
|
43
|
+
const authored = authoredMeshNames(meshes.filter((m) => m.weights), rig);
|
|
44
|
+
const segmented = meshes.filter((m) => m.weights && meshKindOf(m, rig) === 'segments').map((m) => `"${m.name}"`);
|
|
45
|
+
return skip(
|
|
46
|
+
'A21_MESH_RIM_PINNED',
|
|
47
|
+
segmented.length === 0
|
|
48
|
+
? `every weighted mesh here is authored or linked geometry (${authored.join(', ')}), not a rigc ring or ` +
|
|
49
|
+
'ribbon — rigc did not place its rim, so it has no rim of its own to find unpinned'
|
|
50
|
+
: `every weighted mesh here is ${authored.length ? `authored or linked geometry (${authored.join(', ')}) or ` : ''}` +
|
|
51
|
+
`a "segments" lattice (${segmented.join(', ')}), not a rigc ring or ribbon — a segments mesh weights its ` +
|
|
52
|
+
'outline by distance to the bones it names, so it has no rim pinned to the slot bone to find unpinned',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
for (const mesh of measurable) {
|
|
56
|
+
if (!mesh.weights) continue;
|
|
57
|
+
const perVertexAll = mesh.weights;
|
|
58
|
+
const slotBoneOf = mesh.slotBone;
|
|
59
|
+
// A ribbon's outer boundary is SUPPOSED to move — that is the whole point
|
|
60
|
+
// of a strip that changes length. So the rule splits by mesh kind rather
|
|
61
|
+
// than being relaxed: for a ribbon the invariant is that the ENTRY row
|
|
62
|
+
// cannot move, because that row is where the strip joins the part it
|
|
63
|
+
// comes out of. Both rules protect the same thing (the mesh's join to the
|
|
64
|
+
// plate underneath); they just live at different edges of the mesh.
|
|
65
|
+
//
|
|
66
|
+
// A `contour` takes the hull path below, and that is the check it wants
|
|
67
|
+
// rather than a third branch: a contour mesh's hull IS every vertex it
|
|
68
|
+
// has, and every one is pinned to the slot bone at weight 1, so "the rim
|
|
69
|
+
// is pinned" reads over the whole mesh. If a later contour tier ever
|
|
70
|
+
// moves interior vertices, this is the assertion that has to grow a
|
|
71
|
+
// branch — it will fail rather than pass quietly, which is the right way
|
|
72
|
+
// round.
|
|
73
|
+
if (meshKindOf(mesh, rig) === 'ribbon') {
|
|
74
|
+
const uvs = mesh.regionUVs ?? [];
|
|
75
|
+
let entryRow = 0;
|
|
76
|
+
for (let v = 0; v < perVertexAll.length; v++) {
|
|
77
|
+
if (Math.abs(uvs[v * 2 + 1]) > 1e-6) continue; // not on the entry edge
|
|
78
|
+
entryRow++;
|
|
79
|
+
const vertex = perVertexAll[v];
|
|
80
|
+
if (vertex.length !== 1 || Math.abs(vertex[0].weight - 1) > 1e-6) {
|
|
81
|
+
fail(
|
|
82
|
+
'A21_MESH_RIM_PINNED',
|
|
83
|
+
`ribbon "${mesh.name}" entry vertex ${v} is not pinned (${vertex.map((w) => w.weight.toFixed(3)).join('+')})`,
|
|
84
|
+
);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (slotBoneOf && bones[vertex[0].bone] !== slotBoneOf) {
|
|
88
|
+
fail(
|
|
89
|
+
'A21_MESH_RIM_PINNED',
|
|
90
|
+
`ribbon "${mesh.name}" entry vertex ${v} is pinned to "${bones[vertex[0].bone]}", not the anchor bone "${slotBoneOf}"`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (entryRow < 2) {
|
|
95
|
+
fail('A21_MESH_RIM_PINNED', `ribbon "${mesh.name}" has ${entryRow} vertices on its entry edge; a strip needs two`);
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// A rim a soft mask deliberately CARRIED (issue #382). The rule splits
|
|
100
|
+
// by declaration rather than being relaxed — the same move the ribbon
|
|
101
|
+
// branch above makes, and for the same reason: a wobbling silhouette is
|
|
102
|
+
// supposed to move, and the invariant is that nothing ELSE does. So on
|
|
103
|
+
// such a mesh a vertex is either pinned to the slot bone at 1 or shared
|
|
104
|
+
// between it and the ONE bone the rig declared, and a third bone, a
|
|
105
|
+
// wrong bone or a weight that does not close is still a failure.
|
|
106
|
+
const boundBone = rig.meshSoftBones[mesh.slot] ?? null;
|
|
107
|
+
if (boundBone !== null) {
|
|
108
|
+
const allowed = new Set([slotBoneOf, boundBone]);
|
|
109
|
+
let carried = 0;
|
|
110
|
+
for (let v = 0; v < perVertexAll.length; v++) {
|
|
111
|
+
const vertex = perVertexAll[v];
|
|
112
|
+
let sum = 0;
|
|
113
|
+
for (const { bone, weight } of vertex) {
|
|
114
|
+
const name = bones[bone];
|
|
115
|
+
if (!allowed.has(name)) {
|
|
116
|
+
fail(
|
|
117
|
+
'A21_MESH_RIM_PINNED',
|
|
118
|
+
`mesh "${mesh.name}" vertex ${v} is carried by "${name}", and this mesh declares only ` +
|
|
119
|
+
`"${slotBoneOf}" and the soft region's "${boundBone}"`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (name === boundBone && weight > 0) carried = carried + (weight >= 1 ? 1 : 0);
|
|
123
|
+
sum += weight;
|
|
124
|
+
}
|
|
125
|
+
if (Math.abs(sum - 1) > 1e-4) {
|
|
126
|
+
fail(
|
|
127
|
+
'A21_MESH_RIM_PINNED',
|
|
128
|
+
`mesh "${mesh.name}" vertex ${v} weights sum to ${sum.toFixed(4)}; a depth-bound mesh splits each ` +
|
|
129
|
+
'vertex between the slot bone and the bound bone, so it closes at 1',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// A mask that carried nothing reached here as a mesh pinned exactly
|
|
134
|
+
// as before, which is not the thing that was declared.
|
|
135
|
+
if (carried === 0) {
|
|
136
|
+
fail(
|
|
137
|
+
'A21_MESH_RIM_PINNED',
|
|
138
|
+
`mesh "${mesh.name}" declares a soft region on "${boundBone}" and no vertex is fully carried by it`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const hullVertices = mesh.hullLength / 2;
|
|
144
|
+
if (!Number.isInteger(hullVertices) || hullVertices < 3) {
|
|
145
|
+
fail('A21_MESH_RIM_PINNED', `mesh "${mesh.name}" declares hull ${mesh.hullLength / 2}; the rim must be a real ring`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const perVertex = mesh.weights;
|
|
149
|
+
if (hullVertices > perVertex.length) {
|
|
150
|
+
fail('A21_MESH_RIM_PINNED', `mesh "${mesh.name}" hull is ${hullVertices} of ${perVertex.length} vertices`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// The rim is the alpha contour where generated pixels meet untouched
|
|
154
|
+
// base. One bone at weight 1, and that bone must be the slot's own —
|
|
155
|
+
// anything else and the seam can move.
|
|
156
|
+
const slotBone = mesh.slotBone;
|
|
157
|
+
for (let i = 0; i < hullVertices; i++) {
|
|
158
|
+
const vertex = perVertex[i];
|
|
159
|
+
if (vertex.length !== 1 || Math.abs(vertex[0].weight - 1) > 1e-6) {
|
|
160
|
+
fail(
|
|
161
|
+
'A21_MESH_RIM_PINNED',
|
|
162
|
+
`mesh "${mesh.name}" rim vertex ${i} is not pinned (${vertex.map((v) => v.weight.toFixed(3)).join('+')})`,
|
|
163
|
+
);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (slotBone && bones[vertex[0].bone] !== slotBone) {
|
|
167
|
+
fail(
|
|
168
|
+
'A21_MESH_RIM_PINNED',
|
|
169
|
+
`mesh "${mesh.name}" rim vertex ${i} is pinned to "${bones[vertex[0].bone]}", not the slot bone "${slotBone}"`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Independent of the ring ORDER: whatever sits on the region border is
|
|
174
|
+
// the outline, and the outline moving means the part's own edge moving.
|
|
175
|
+
// Without this, reordering the rings would move the pinned prefix off
|
|
176
|
+
// the outline and A21 would still pass on the count alone.
|
|
177
|
+
const uvs = mesh.regionUVs ?? [];
|
|
178
|
+
for (let v = 0; v < perVertex.length; v++) {
|
|
179
|
+
const u = uvs[v * 2];
|
|
180
|
+
const t = uvs[v * 2 + 1];
|
|
181
|
+
const onBorder = [u, t].some((c) => Math.abs(c) < 1e-6 || Math.abs(c - 1) < 1e-6);
|
|
182
|
+
if (!onBorder) continue;
|
|
183
|
+
const vertex = perVertex[v];
|
|
184
|
+
if (vertex.length !== 1 || Math.abs(vertex[0].weight - 1) > 1e-6) {
|
|
185
|
+
fail('A21_MESH_RIM_PINNED', `mesh "${mesh.name}" vertex ${v} is on the region border but not pinned`);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A22, the body (issue #1025, step 4c of #380): every mesh's authored UVs lie
|
|
3
|
+
* in the unit square, one pair per vertex.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
|
|
6
|
+
* are a fact (`../facts/skin_meshes.ts`). The `check` call stays in
|
|
7
|
+
* `validate()`.
|
|
8
|
+
*/
|
|
9
|
+
import type { Verdicts } from '../harness.ts';
|
|
10
|
+
import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
11
|
+
import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
|
|
12
|
+
|
|
13
|
+
export function a22MeshUvsInUnitRange({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts): void {
|
|
14
|
+
const meshAttachments = meshes;
|
|
15
|
+
if (meshAttachments.length === 0) return skip('A22_MESH_UVS_IN_UNIT_RANGE', SKIP_NO_MESH_ATTACHMENT);
|
|
16
|
+
for (const mesh of meshAttachments) {
|
|
17
|
+
// `regionUVs` is what the JSON authored; `uvs` is the page-space result
|
|
18
|
+
// and stays EMPTY until a renderer calls computeUVs, so asserting on it
|
|
19
|
+
// here would be asserting on the wrong array (measured: length 0 after
|
|
20
|
+
// a clean load). With one part per page the two are equal anyway —
|
|
21
|
+
// computeUVs reduces to `u + regionUV * width` with u=0, width=1
|
|
22
|
+
// (MeshAttachment.js:173-174), which is the claim this assertion rests
|
|
23
|
+
// on and this is where it is checked.
|
|
24
|
+
const uvs = mesh.regionUVs;
|
|
25
|
+
if (!uvs || uvs.length !== mesh.worldVerticesLength) {
|
|
26
|
+
fail(
|
|
27
|
+
'A22_MESH_UVS_IN_UNIT_RANGE',
|
|
28
|
+
`mesh "${mesh.name}" has ${uvs?.length ?? 0} authored uv values for ${mesh.worldVerticesLength}`,
|
|
29
|
+
);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i < uvs.length; i++) {
|
|
33
|
+
if (!Number.isFinite(uvs[i]) || uvs[i] < -1e-6 || uvs[i] > 1 + 1e-6) {
|
|
34
|
+
fail('A22_MESH_UVS_IN_UNIT_RANGE', `mesh "${mesh.name}" uv[${i}] is ${uvs[i]}`);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|