spine-rigc 1.6.2 → 1.8.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 +166 -60
- package/docs/AUTHORING.md +25 -16
- package/package.json +1 -1
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/skin_entries.ts +35 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/kinds.ts +102 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/index.ts +130 -0
- package/src/assertions/model/parse.ts +136 -0
- package/src/assertions/model/skin_entries.ts +58 -0
- package/src/assertions/model/slot_colour.ts +80 -0
- package/src/assertions/reasons.ts +127 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +26 -16
- package/src/check.ts +70 -47
- package/src/compile.ts +118 -93
- package/src/core/constraints_path.ts +60 -38
- package/src/core/constraints_physics.ts +22 -4
- package/src/core/index.ts +102 -15
- package/src/core/uvs.ts +25 -5
- package/src/core/world.ts +80 -24
- package/src/deformmeasure.ts +383 -157
- package/src/deformreport.ts +3 -0
- package/src/deformstructure.ts +335 -0
- package/src/emit.ts +17 -6
- package/src/ingest.ts +18 -13
- package/src/keyorder.ts +2 -2
- package/src/model.ts +114 -13
- package/src/render.ts +520 -59
- package/src/render_core.ts +178 -21
- package/src/transform.ts +113 -128
- package/src/types.ts +6 -5
- package/src/validate.ts +200 -490
package/src/validate.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
// rectangle on a shared page.
|
|
66
66
|
import { readPlate } from '../tools/plate.ts';
|
|
67
67
|
import { pageFootprint, pageGridSentence } from './atlas.ts';
|
|
68
|
+
import { frameRegionName } from './core/uvs.ts';
|
|
68
69
|
import {
|
|
69
70
|
BEZIER_POINTS,
|
|
70
71
|
curveStorage,
|
|
@@ -99,11 +100,34 @@ import {
|
|
|
99
100
|
walkTimelines,
|
|
100
101
|
} from './timelines.ts';
|
|
101
102
|
import type { RigInfo } from './types.ts';
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
103
|
+
import { ASSERTION_KIND, kindRunsUnder, type AssertionProfile } from './assertions/kinds.ts';
|
|
104
|
+
import { verdictHarness, type Failure } from './assertions/harness.ts';
|
|
105
|
+
import { atStoredKey, isObj, type Json } from './assertions/values.ts';
|
|
106
|
+
import type { SkinEntryFacts } from './assertions/facts/skin_entries.ts';
|
|
107
|
+
import type { AtlasPageFacts } from './assertions/facts/atlas_pages.ts';
|
|
108
|
+
import type { SlotColourFacts, SlotTimelines } from './assertions/facts/slot_colour.ts';
|
|
109
|
+
import { a03RegionWidthHeightFinite } from './assertions/bodies/a03.ts';
|
|
110
|
+
import { a11NoClippingAttachments } from './assertions/bodies/a11.ts';
|
|
111
|
+
import { a17AtlasPageFilesExist } from './assertions/bodies/a17.ts';
|
|
112
|
+
import { a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten } from './assertions/bodies/a45.ts';
|
|
113
|
+
import {
|
|
114
|
+
SKIP_NO_ANIMATION,
|
|
115
|
+
SKIP_NO_ATLAS,
|
|
116
|
+
SKIP_NO_ATLAS_PAGE,
|
|
117
|
+
SKIP_NO_ATLAS_REGION,
|
|
118
|
+
SKIP_NO_ATTACHMENT_REGION_JOIN,
|
|
119
|
+
SKIP_NO_DECLARED_DURATION,
|
|
120
|
+
SKIP_NO_LINKED_MESH,
|
|
121
|
+
SKIP_NO_MESH_ATTACHMENT,
|
|
122
|
+
SKIP_NO_PHYSICS_CONSTRAINT,
|
|
123
|
+
SKIP_NO_POSE,
|
|
124
|
+
SKIP_NO_SEQUENCE,
|
|
125
|
+
SKIP_NO_SKELETON,
|
|
126
|
+
SKIP_NO_TIMELINE,
|
|
127
|
+
SKIP_NO_TWO_COLOR_TINT,
|
|
128
|
+
} from './assertions/reasons.ts';
|
|
129
|
+
|
|
130
|
+
export type { Failure } from './assertions/harness.ts';
|
|
107
131
|
|
|
108
132
|
/**
|
|
109
133
|
* Which body of rules to hold the artifact to.
|
|
@@ -129,7 +153,7 @@ export interface Failure {
|
|
|
129
153
|
* no longer honestly serve both — so the choice is made at every call site now,
|
|
130
154
|
* by the caller who knows which question they are asking.
|
|
131
155
|
*/
|
|
132
|
-
export type ValidateProfile =
|
|
156
|
+
export type ValidateProfile = AssertionProfile;
|
|
133
157
|
|
|
134
158
|
export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-html'];
|
|
135
159
|
|
|
@@ -146,84 +170,8 @@ export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-ht
|
|
|
146
170
|
*/
|
|
147
171
|
export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
|
|
148
172
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* `check()` throws on a name that has none — a new assertion must state its kind
|
|
152
|
-
* rather than defaulting into one, because the default would decide, silently,
|
|
153
|
-
* whether it runs on foreign data.
|
|
154
|
-
*
|
|
155
|
-
* validity — the file is wrong for any consumer. Runs under every profile.
|
|
156
|
-
* renderer — valid Spine that this project's renderer or frame budget refuses.
|
|
157
|
-
* archetype — a structural rule about rigc's own formations, meaningless to a
|
|
158
|
-
* skeleton rigc did not compile.
|
|
159
|
-
*
|
|
160
|
-
* Two assertions are MIXED and are marked `validity` here because their
|
|
161
|
-
* validity half must never stop running; their policy clauses are gated inside
|
|
162
|
-
* the assertion body against `profile`, and each such clause says so where it
|
|
163
|
-
* lives. They are A06 (size-vs-PNG and every region's rectangle inside the page
|
|
164
|
-
* it names are validity; pma / rotation / two regions over the same texels are
|
|
165
|
-
* policy — the rectangle moved across that line in issue #694, and the reason
|
|
166
|
-
* is stated where it now sits) and A20 (weight coherence is validity; requiring
|
|
167
|
-
* a mesh to be weighted at all is policy).
|
|
168
|
-
*
|
|
169
|
-
* A08 was the third until issue #574 retired its policy clause. It required a
|
|
170
|
-
* skin entry's placeholder to be spelled like the region it resolves to, under
|
|
171
|
-
* a renderer that resolves art by `path` and has never read a placeholder — so
|
|
172
|
-
* restating it as the renderer's own join made it a tautology over the validity
|
|
173
|
-
* half. The argument, with the renderer lines it is measured against, sits above
|
|
174
|
-
* `check('A08_…')`.
|
|
175
|
-
*/
|
|
176
|
-
const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
177
|
-
A00_ROUNDTRIP_PARSE: 'validity',
|
|
178
|
-
A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS: 'validity',
|
|
179
|
-
A02_NO_BONE_TRANSFORM_KEY: 'validity',
|
|
180
|
-
A03_REGION_WIDTH_HEIGHT_FINITE: 'validity',
|
|
181
|
-
A04_MESH_TRIANGLES_AND_ENCODING: 'validity',
|
|
182
|
-
A05_CURVE_ARRAY_LENGTH: 'validity',
|
|
183
|
-
A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
|
|
184
|
-
A07_ATLAS_TEXT_SHAPE: 'validity',
|
|
185
|
-
A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
|
|
186
|
-
A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
|
|
187
|
-
A10_NO_NAN_AFTER_STEPPING: 'validity',
|
|
188
|
-
A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
|
|
189
|
-
A12_NO_DARK_COLOR: 'renderer',
|
|
190
|
-
A13_MESH_BUDGET: 'renderer',
|
|
191
|
-
A14_NO_FULL_FRAME_MESH: 'renderer',
|
|
192
|
-
A15_IDLE_NO_MESH_BONE_KEYS: 'renderer',
|
|
193
|
-
A16_SKELETON_VERSION_4_3: 'validity',
|
|
194
|
-
A17_ATLAS_PAGE_FILES_EXIST: 'validity',
|
|
195
|
-
A18_DETERMINISTIC_EMIT: 'validity',
|
|
196
|
-
A19_OVERLAY_PNGS_HAVE_ALPHA: 'renderer',
|
|
197
|
-
A20_MESH_WEIGHTS_COHERENT: 'validity', // mixed — see above
|
|
198
|
-
A21_MESH_RIM_PINNED: 'archetype',
|
|
199
|
-
A22_MESH_UVS_IN_UNIT_RANGE: 'validity',
|
|
200
|
-
A23_PHYSICS_CONSTRAINT_EFFECTIVE: 'validity',
|
|
201
|
-
A24_AXIS_SPACE_STROKE: 'archetype',
|
|
202
|
-
A25_DETACHED_BONE_PARENTAGE: 'archetype',
|
|
203
|
-
A26_SLOT_DRAW_ORDER: 'archetype',
|
|
204
|
-
A27_REGION_NAME_MATCHES_PAGE_FILENAME: 'renderer',
|
|
205
|
-
A28_RIBBON_ROWS_SHARE_WEIGHTS: 'archetype',
|
|
206
|
-
A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
|
|
207
|
-
A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
|
|
208
|
-
A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
|
|
209
|
-
A32_EVENT_KEYS_RESOLVE: 'validity',
|
|
210
|
-
A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
|
|
211
|
-
A34_CONSTRAINT_TIMELINE_TARGETS: 'validity',
|
|
212
|
-
A35_DEFORM_KEYS_FIT_THE_ATTACHMENT: 'validity',
|
|
213
|
-
A36_PATH_CONSTRAINT_EFFECTIVE: 'validity',
|
|
214
|
-
A37_SLIDER_CONSTRAINT_EFFECTIVE: 'validity',
|
|
215
|
-
A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED: 'validity',
|
|
216
|
-
A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
|
|
217
|
-
A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
|
|
218
|
-
A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
|
|
219
|
-
A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
|
|
220
|
-
A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
|
|
221
|
-
A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
|
|
222
|
-
A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
|
|
223
|
-
A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
|
|
224
|
-
A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
|
|
225
|
-
A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT: 'validity',
|
|
226
|
-
};
|
|
173
|
+
// What kind of rule each assertion is — `./assertions/kinds.ts` since issue
|
|
174
|
+
// #1025, where the model side's harness reads the same table.
|
|
227
175
|
|
|
228
176
|
/**
|
|
229
177
|
* Every assertion this validator knows, in registry order.
|
|
@@ -241,112 +189,27 @@ export function assertionCountForProfile(profile: ValidateProfile): number {
|
|
|
241
189
|
return ASSERTION_NAMES.filter((name) => ASSERTION_KIND[name] === 'validity').length;
|
|
242
190
|
}
|
|
243
191
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
/*
|
|
266
|
-
* **An empty subject list: SKIP or PASS, and which one is derived (issue #580).**
|
|
267
|
-
*
|
|
268
|
-
* `check()` records a pass when a body runs to its end without a `fail()` or a
|
|
269
|
-
* `skip()`, so a body whose main construct is a loop over a list that is EMPTY
|
|
270
|
-
* passes having measured nothing — the vacuous green one ring out from the bare
|
|
271
|
-
* `return` guards issue #568 converted. Two different things can be true of such
|
|
272
|
-
* a loop, and the criterion decides between them by reading the rule's own name
|
|
273
|
-
* and its `fail()` sentences rather than by preference:
|
|
274
|
-
*
|
|
275
|
-
* * A name of the form ⟨subject⟩_⟨property⟩ — `REGION_WIDTH_HEIGHT_FINITE`,
|
|
276
|
-
* `MESH_TRIANGLES_AND_ENCODING`, `PHYSICS_CONSTRAINT_EFFECTIVE`,
|
|
277
|
-
* `ATLAS_PAGE_SIZE_MATCHES_PNG` — quantifies over the subject it names, and
|
|
278
|
-
* its `fail()` names a member of that subject, the value found and the value
|
|
279
|
-
* required. With no member, nothing was measured: it **SKIPs**, and the
|
|
280
|
-
* reason names the subject that was absent.
|
|
281
|
-
* * A name of the form NO_⟨construct⟩ — `NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS`,
|
|
282
|
-
* `NO_BONE_TRANSFORM_KEY`, `NO_CLIPPING_ATTACHMENTS`, `NO_DARK_COLOR`,
|
|
283
|
-
* `NO_FULL_FRAME_MESH` — quantifies over occurrences of something a correct
|
|
284
|
-
* artifact has NONE of, and its `fail()` reports that the construct is
|
|
285
|
-
* present rather than measuring a value on it. Zero occurrences IS the
|
|
286
|
-
* measurement, so it **PASSes**: the loop is a search of the artifact, and
|
|
287
|
-
* the artifact is what it measured.
|
|
288
|
-
* * A name carrying both halves takes each at its word.
|
|
289
|
-
* `A15_IDLE_NO_MESH_BONE_KEYS` is the pattern and already reads this way: no
|
|
290
|
-
* `idle` animation, or an `idle` with no bone timeline, is the named subject
|
|
291
|
-
* absent and SKIPs; no mesh-driving bone among the bones `idle` does key is
|
|
292
|
-
* the construct absent and passes. `A10_NO_NAN_AFTER_STEPPING` was the same
|
|
293
|
-
* shape until issue #902, and is the next bullet's now: #882 gave it a
|
|
294
|
-
* setup-pose clause whose subject is the bones, beside the stepping clause
|
|
295
|
-
* whose subject is the animations.
|
|
296
|
-
* * An assertion with more than one clause SKIPs only when EVERY clause had
|
|
297
|
-
* nothing to measure, which is the shape `A09`, `A33` and `A38` already
|
|
298
|
-
* carry (`polygons.length === 0 && endsChecked === 0`), and `A10` since #902.
|
|
299
|
-
* `A13_MESH_BUDGET` is why the clause is stated: a declared slot budget is
|
|
300
|
-
* measured against a count of mesh slots, and zero is a count, so that half
|
|
301
|
-
* passes on a rig with no mesh — while a rig that declares only a TRIANGLE
|
|
302
|
-
* budget has nothing left to measure and skips.
|
|
303
|
-
*
|
|
304
|
-
* ⚠️ What the criterion is not allowed to become is a table of assertions with
|
|
305
|
-
* their verdicts written beside them. Every row above is decided by reading the
|
|
306
|
-
* name and the sentences the assertion already prints, so a rule added tomorrow
|
|
307
|
-
* is decided by the same reading and nobody re-opens this question.
|
|
308
|
-
*/
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* What a SKIP says when the artifact carries no member of the subject a rule
|
|
312
|
-
* quantifies over (issue #580), by subject.
|
|
313
|
-
*
|
|
314
|
-
* One constant per SUBJECT rather than one per assertion, for the reason the two
|
|
315
|
-
* above are two: what the rule was denied is the whole content of its SKIP, and
|
|
316
|
-
* three rules denied the same thing should say so in the same words. Exported so
|
|
317
|
-
* a control compares against them rather than quoting them.
|
|
318
|
-
*/
|
|
319
|
-
export const SKIP_NO_REGION_ATTACHMENT = 'the skeleton carries no region attachment';
|
|
320
|
-
export const SKIP_NO_MESH_ATTACHMENT = 'the skeleton carries no mesh attachment';
|
|
321
|
-
export const SKIP_NO_ANIMATION = 'the skeleton carries no animation';
|
|
322
|
-
/**
|
|
323
|
-
* A10's (issue #902): its setup clause reads the bones and its stepping clause
|
|
324
|
-
* the animations, so it skips only when the skeleton carries neither — and the
|
|
325
|
-
* sentence is `SKIP_NO_ANIMATION`'s with the second subject added, so the two
|
|
326
|
-
* cannot drift into different words for the same absence.
|
|
327
|
-
*/
|
|
328
|
-
export const SKIP_NO_POSE = `${SKIP_NO_ANIMATION} and no bone, so there is no setup pose to read and nothing to step`;
|
|
329
|
-
export const SKIP_NO_TIMELINE = 'no animation here carries a timeline';
|
|
330
|
-
export const SKIP_NO_PHYSICS_CONSTRAINT = 'the skeleton declares no physics constraint';
|
|
331
|
-
export const SKIP_NO_ATLAS_PAGE = 'the atlas declares no page';
|
|
332
|
-
export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
|
|
333
|
-
export const SKIP_NO_ATTACHMENT_REGION_JOIN =
|
|
334
|
-
'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
|
|
335
|
-
export const SKIP_NO_TWO_COLOR_TINT =
|
|
336
|
-
'no slot declares a "dark" colour and no animation keys an "rgba2" or "rgb2" timeline, so there is no two-colour tint to read back';
|
|
337
|
-
export const SKIP_NO_SEPARABLE_COLOR =
|
|
338
|
-
'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
|
|
339
|
-
export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
|
|
340
|
-
export const SKIP_NO_SEQUENCE =
|
|
341
|
-
'no attachment carries a "sequence" block and no animation keys a "sequence" timeline, so there is no numbered series to read back';
|
|
342
|
-
/**
|
|
343
|
-
* A09's, which predates this list and joins it rather than being rewritten: it
|
|
344
|
-
* is the same fact about the same subject, and a control that compares against
|
|
345
|
-
* eight constants and quotes the ninth is a control with a hand-kept exception
|
|
346
|
-
* in it.
|
|
347
|
-
*/
|
|
348
|
-
export const SKIP_NO_DECLARED_DURATION =
|
|
349
|
-
'the motion spec declares no animations and the skeleton has none — a static rig has no duration to compare';
|
|
192
|
+
// What a SKIP says, by what the assertion was denied — `./assertions/reasons.ts`
|
|
193
|
+
// since issue #1025, so the assertion bodies that moved out of this file print
|
|
194
|
+
// the same sentences on both sides; every one is re-exported from here.
|
|
195
|
+
export {
|
|
196
|
+
SKIP_NO_ANIMATION,
|
|
197
|
+
SKIP_NO_ATLAS,
|
|
198
|
+
SKIP_NO_ATLAS_PAGE,
|
|
199
|
+
SKIP_NO_ATLAS_REGION,
|
|
200
|
+
SKIP_NO_ATTACHMENT_REGION_JOIN,
|
|
201
|
+
SKIP_NO_DECLARED_DURATION,
|
|
202
|
+
SKIP_NO_LINKED_MESH,
|
|
203
|
+
SKIP_NO_MESH_ATTACHMENT,
|
|
204
|
+
SKIP_NO_PHYSICS_CONSTRAINT,
|
|
205
|
+
SKIP_NO_POSE,
|
|
206
|
+
SKIP_NO_REGION_ATTACHMENT,
|
|
207
|
+
SKIP_NO_SEPARABLE_COLOR,
|
|
208
|
+
SKIP_NO_SEQUENCE,
|
|
209
|
+
SKIP_NO_SKELETON,
|
|
210
|
+
SKIP_NO_TIMELINE,
|
|
211
|
+
SKIP_NO_TWO_COLOR_TINT,
|
|
212
|
+
} from './assertions/reasons.ts';
|
|
350
213
|
|
|
351
214
|
export interface ValidateInput {
|
|
352
215
|
skeletonText: string;
|
|
@@ -613,6 +476,9 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
|
|
|
613
476
|
return arm === undefined ? physicsOutsideSays(rule, poseValue) : physicsBasisSays(arm);
|
|
614
477
|
}
|
|
615
478
|
|
|
479
|
+
/** `physicsTimelineNames`' table, once it has been built. */
|
|
480
|
+
let physicsTimelineNamesTable: Record<number, string> | null = null;
|
|
481
|
+
|
|
616
482
|
/**
|
|
617
483
|
* The skeleton-JSON name of each physics timeline, keyed by the runtime's own
|
|
618
484
|
* `Property` id.
|
|
@@ -624,16 +490,25 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
|
|
|
624
490
|
* on the right are the ones `SkeletonJson`'s physics branch reads
|
|
625
491
|
* (`SkeletonJson.js:1063-1094`), which is also what a motion spec's `property`
|
|
626
492
|
* says.
|
|
493
|
+
*
|
|
494
|
+
* ⚠️ Built on first use, not while the module loads (issue #1014): the
|
|
495
|
+
* computed keys read the runtime's `Property` enum, and seven reads of it at
|
|
496
|
+
* load time were the only spine-core access a module made before a command
|
|
497
|
+
* ran — so `--help`, `render` and `check` on a rigc build all touched the
|
|
498
|
+
* runtime for a table only the gate's physics rules consult.
|
|
627
499
|
*/
|
|
628
|
-
export
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
500
|
+
export function physicsTimelineNames(): Record<number, string> {
|
|
501
|
+
physicsTimelineNamesTable ??= {
|
|
502
|
+
[Property.physicsConstraintInertia]: 'inertia',
|
|
503
|
+
[Property.physicsConstraintStrength]: 'strength',
|
|
504
|
+
[Property.physicsConstraintDamping]: 'damping',
|
|
505
|
+
[Property.physicsConstraintMass]: 'mass',
|
|
506
|
+
[Property.physicsConstraintWind]: 'wind',
|
|
507
|
+
[Property.physicsConstraintGravity]: 'gravity',
|
|
508
|
+
[Property.physicsConstraintMix]: 'mix',
|
|
509
|
+
};
|
|
510
|
+
return physicsTimelineNamesTable;
|
|
511
|
+
}
|
|
637
512
|
|
|
638
513
|
/**
|
|
639
514
|
* Which physics constraints a physics timeline that names NO constraint writes
|
|
@@ -698,55 +573,30 @@ export function unnamedPhysicsTimeline(name: string): Timeline | null {
|
|
|
698
573
|
*/
|
|
699
574
|
const SPINE_4_3: SpineGeneration = '4.3';
|
|
700
575
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
function isObj(v: unknown): v is Json {
|
|
704
|
-
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* The time to pose a key at so that the runtime is AT it: the later of the
|
|
709
|
-
* file's number and that number as spine-core stores it (issue #771).
|
|
710
|
-
*
|
|
711
|
-
* 🚨 Every timeline keeps its key times in a `Float32Array`
|
|
712
|
-
* (`Utils.newFloatArray`), and a key time the float cannot hold exactly is
|
|
713
|
-
* stored at the nearest one — for `0.2`, `0.20000000298…`, which is LATER than
|
|
714
|
-
* the double `0.2`. Stepped to the file's own number, a timeline is then just
|
|
715
|
-
* BEFORE its key: before a first key it writes the setup value (`time <
|
|
716
|
-
* frames[0]`), and past a stepped key it still holds the one before
|
|
717
|
-
* (`frames[i] > time`). That is a pose one float step from the key, not the
|
|
718
|
-
* key's — measured on the selftest's own `ingest_probe`, whose `alpha` key at
|
|
719
|
-
* 0.2 posed the setup 1.0 and was refused as `the key states value 0.4`.
|
|
720
|
-
*
|
|
721
|
-
* ⚠️ The later of the two rather than `Math.fround` alone: where the float
|
|
722
|
-
* rounds DOWN, the file's number is already past the stored key, and a runtime
|
|
723
|
-
* built without typed arrays stores the double itself — in both, the file's
|
|
724
|
-
* number is the one at or after the key. What a key time rounds to is the
|
|
725
|
-
* runtime's storage and not the file's statement, so a rule judging what a KEY
|
|
726
|
-
* states poses at the key; the rounding itself is nothing an author can repair.
|
|
727
|
-
*/
|
|
728
|
-
function atStoredKey(time: number): number {
|
|
729
|
-
return Math.max(time, Math.fround(time));
|
|
730
|
-
}
|
|
576
|
+
// `Json`, `isObj` and `atStoredKey` are `./assertions/values.ts`'s since issue
|
|
577
|
+
// #1025: the bodies that moved out of this file read values the same way.
|
|
731
578
|
|
|
732
579
|
/**
|
|
733
580
|
* The atlas region names one raw skin entry will make the loader look up — or
|
|
734
581
|
* `null` when the file states a sequence this walk cannot predict.
|
|
735
582
|
*
|
|
736
|
-
* ⚠️
|
|
583
|
+
* ⚠️ Measured against the loader rather than reasoned out, because a join that
|
|
737
584
|
* merely looks right is the thing A08 exists to refuse. `readSequence`
|
|
738
585
|
* (`dist/SkeletonJson.js:641-649`) turns an absent or null `sequence` into
|
|
739
586
|
* `new Sequence(1, false)` — one lookup, at the bare path — and a present one
|
|
740
587
|
* into `new Sequence(count ?? 0, true)`, so a sequence map with no `count`
|
|
741
|
-
* looks up nothing at all.
|
|
742
|
-
*
|
|
588
|
+
* looks up nothing at all. Each frame's name is the core's `frameRegionName`
|
|
589
|
+
* (`src/core/uvs.ts`): `start + i`, left-padded with zeros to `digits`, after
|
|
590
|
+
* the path (issue #1015). `PS127` records what the loader asks for and compares,
|
|
591
|
+
* and the core suite holds the frame names to `Sequence.getPath` over 7,680
|
|
592
|
+
* cases.
|
|
743
593
|
*
|
|
744
594
|
* Nothing in `examples/` carries a `sequence` (measured: 0 occurrences across
|
|
745
595
|
* all twelve editor exports), so without this the assertion would have read a
|
|
746
596
|
* sequence's base path as a region name and refused correct foreign data —
|
|
747
597
|
* `A21_MESH_RIM_PINNED`'s old `|| 'ring'` default, one file over.
|
|
748
598
|
*/
|
|
749
|
-
function attachmentRegionLookups(sequence: unknown, path: string): string[] | null {
|
|
599
|
+
export function attachmentRegionLookups(sequence: unknown, path: string): string[] | null {
|
|
750
600
|
if (sequence === undefined || sequence === null) return [path];
|
|
751
601
|
if (!isObj(sequence)) return null;
|
|
752
602
|
const whole = (value: unknown, fallback: number): number | null => {
|
|
@@ -757,11 +607,9 @@ function attachmentRegionLookups(sequence: unknown, path: string): string[] | nu
|
|
|
757
607
|
const start = whole(sequence.start, 1);
|
|
758
608
|
const digits = whole(sequence.digits, 0);
|
|
759
609
|
if (count === null || start === null || digits === null || count < 0) return null;
|
|
610
|
+
const series = { count, start, digits, setup: 0 };
|
|
760
611
|
const lookups: string[] = [];
|
|
761
|
-
for (let i = 0; i < count; i++)
|
|
762
|
-
const frame = String(start + i);
|
|
763
|
-
lookups.push(`${path}${'0'.repeat(Math.max(0, digits - frame.length))}${frame}`);
|
|
764
|
-
}
|
|
612
|
+
for (let i = 0; i < count; i++) lookups.push(frameRegionName(path, series, i));
|
|
765
613
|
return lookups;
|
|
766
614
|
}
|
|
767
615
|
|
|
@@ -870,11 +718,11 @@ interface RawLinkedMesh {
|
|
|
870
718
|
* `"<skin>\0<slot>\0<placeholder>" -> source` for every linked mesh the raw
|
|
871
719
|
* skeleton declares, with what the file says about each one (issues #691, #710).
|
|
872
720
|
*
|
|
873
|
-
* 🔑 The
|
|
874
|
-
* `type: "linkedmesh"` share one branch and a truthy `source` is
|
|
875
|
-
* between them (`SkeletonJson.js:568-569`, `:582`). An empty
|
|
876
|
-
* there, so it is not a link here either — that map is read
|
|
877
|
-
* which is exactly what the runtime does with it.
|
|
721
|
+
* 🔑 The format decides it, and not by `type`: `type: "mesh"` and
|
|
722
|
+
* `type: "linkedmesh"` share one branch of the reader and a truthy `source` is
|
|
723
|
+
* what decides between them (`SkeletonJson.js:568-569`, `:582`). An empty
|
|
724
|
+
* `source` is falsy there, so it is not a link here either — that map is read
|
|
725
|
+
* as an ordinary mesh, which is exactly what the runtime does with it.
|
|
878
726
|
*/
|
|
879
727
|
function rawLinkedMeshes(raw: unknown): Map<string, RawLinkedMesh> {
|
|
880
728
|
const links = new Map<string, RawLinkedMesh>();
|
|
@@ -1142,51 +990,92 @@ function firstDifferingLine(first: string, second: string): string {
|
|
|
1142
990
|
return `${i + 1}: ${cut(a[i])} first, ${cut(b[i])} second`;
|
|
1143
991
|
}
|
|
1144
992
|
|
|
993
|
+
/**
|
|
994
|
+
* The runtime's supply of `SkinEntryFacts` (issue #1025): every skin of the
|
|
995
|
+
* loaded skeleton in the loaded order, and each skin's entries as
|
|
996
|
+
* `getAttachments()` lists them — the walk this file's prelude makes for its
|
|
997
|
+
* own lists, made once more here so the facts the moved bodies read are a
|
|
998
|
+
* value the selftest can compare with the model side's, order included.
|
|
999
|
+
*/
|
|
1000
|
+
export function spineSkinEntries(data: ReturnType<SkeletonJson['readSkeletonData']>): SkinEntryFacts {
|
|
1001
|
+
const regionAttachments: RegionAttachment[] = [];
|
|
1002
|
+
let clippingCount = 0;
|
|
1003
|
+
for (const skin of data.skins) {
|
|
1004
|
+
for (const entry of skin.getAttachments()) {
|
|
1005
|
+
if (entry.attachment instanceof RegionAttachment) regionAttachments.push(entry.attachment);
|
|
1006
|
+
else if (entry.attachment instanceof ClippingAttachment) clippingCount++;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return { regionAttachments, clippingCount };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The facts the moved bodies read, as `validate()` supplies them from a pair
|
|
1014
|
+
* spine-core loads — or `null` when the load throws, which is A00's failure
|
|
1015
|
+
* and `validate()`'s to name. For the selftest and `tools/verdict_gate.ts`,
|
|
1016
|
+
* which compare these with the model side's fact by fact, where a verdict
|
|
1017
|
+
* line would hide a difference (an order nothing failed on, a name no line
|
|
1018
|
+
* printed); `validate()` itself builds them from its own round trip.
|
|
1019
|
+
*/
|
|
1020
|
+
export function runtimeFacts(skeletonText: string, atlasText: string): { skinEntries: SkinEntryFacts; atlasPages: AtlasPageFacts; slotColour: SlotColourFacts } | null {
|
|
1021
|
+
let atlas: TextureAtlas;
|
|
1022
|
+
let data: ReturnType<SkeletonJson['readSkeletonData']>;
|
|
1023
|
+
try {
|
|
1024
|
+
atlas = new TextureAtlas(atlasText);
|
|
1025
|
+
data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(JSON.parse(skeletonText));
|
|
1026
|
+
} catch {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
return { skinEntries: spineSkinEntries(data), atlasPages: { atlas }, slotColour: spineSlotColourFacts(JSON.parse(skeletonText) as Json, data) };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* The runtime's supply of A45's facts (issue #1025): the slot timelines read
|
|
1034
|
+
* off the skeleton JSON in the file's order — every animation the file keys,
|
|
1035
|
+
* every slot an animation keys, skipping what is not an object, as A45's walk
|
|
1036
|
+
* always read them — whether the loaded skeleton holds an animation, and a
|
|
1037
|
+
* slot's colour posed on a fresh, non-looping track by spine-core: setup pose,
|
|
1038
|
+
* `update(0)`, `updateWorldTransform(Physics.reset)`, then the track stepped to
|
|
1039
|
+
* the time and applied. The model side's supply is
|
|
1040
|
+
* `./assertions/model/slot_colour.ts`; the selftest holds the two to the same
|
|
1041
|
+
* lines.
|
|
1042
|
+
*/
|
|
1043
|
+
function spineSlotColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SlotColourFacts {
|
|
1044
|
+
const slotTimelines: SlotTimelines[] = [];
|
|
1045
|
+
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
1046
|
+
for (const [animation, anim] of Object.entries(rawAnimations)) {
|
|
1047
|
+
if (!isObj(anim) || !isObj(anim.slots)) continue;
|
|
1048
|
+
for (const [slot, timelines] of Object.entries(anim.slots)) {
|
|
1049
|
+
if (!isObj(timelines)) continue;
|
|
1050
|
+
slotTimelines.push({ animation, slot, timelines });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
slotTimelines,
|
|
1055
|
+
hasAnimation: (name) => Boolean(data.findAnimation(name)),
|
|
1056
|
+
posedSlot: (animName, slotName, time) => {
|
|
1057
|
+
const skeleton = new Skeleton(data);
|
|
1058
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
1059
|
+
state.setAnimation(0, animName, false);
|
|
1060
|
+
skeleton.setupPose();
|
|
1061
|
+
skeleton.update(0);
|
|
1062
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
1063
|
+
state.update(time);
|
|
1064
|
+
state.apply(skeleton);
|
|
1065
|
+
return skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
1066
|
+
},
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1145
1070
|
export function validate(input: ValidateInput): ValidateReport {
|
|
1146
|
-
const failures: Failure[] = [];
|
|
1147
|
-
const passed: string[] = [];
|
|
1148
|
-
const skipped: ValidateReport['skipped'] = [];
|
|
1149
|
-
const profileSkipped: ValidateReport['profileSkipped'] = [];
|
|
1150
|
-
const stats: Record<string, number | string> = {};
|
|
1151
1071
|
const profile = input.profile;
|
|
1152
1072
|
/** True when this profile's rulebook includes the policy layer. */
|
|
1153
1073
|
const policy = profile === 'spine-html';
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
* Run one assertion; record it as passed only if it neither failed nor skipped,
|
|
1160
|
-
* and do not run it at all when the profile does not carry that kind of rule.
|
|
1161
|
-
*
|
|
1162
|
-
* The unknown-assertion throw is deliberate: an assertion with no entry in
|
|
1163
|
-
* ASSERTION_KIND would otherwise pick a profile by accident, and picking wrong
|
|
1164
|
-
* means either a rule that never runs or a rule that fires on everybody's data.
|
|
1165
|
-
*
|
|
1166
|
-
* The body's return value is handed back — `undefined` when the assertion did
|
|
1167
|
-
* not run or threw. Only A00 uses it, and it uses it so that the loaded atlas
|
|
1168
|
-
* and skeleton can be `const`: assigning them from inside this callback leaves
|
|
1169
|
-
* the type checker unable to see that they were ever set, and every later read
|
|
1170
|
-
* of `atlas.pages` or `data.bones` becomes a property of `never`.
|
|
1171
|
-
*/
|
|
1172
|
-
const check = <T>(assertion: string, body: () => T): T | undefined => {
|
|
1173
|
-
const kind = ASSERTION_KIND[assertion];
|
|
1174
|
-
if (!kind) throw new Error(`validate: assertion "${assertion}" has no ASSERTION_KIND entry`);
|
|
1175
|
-
if (kind !== 'validity' && !policy) {
|
|
1176
|
-
profileSkipped.push({ assertion, kind });
|
|
1177
|
-
return undefined;
|
|
1178
|
-
}
|
|
1179
|
-
const before = failures.length;
|
|
1180
|
-
const skippedBefore = skipped.length;
|
|
1181
|
-
let result: T | undefined;
|
|
1182
|
-
try {
|
|
1183
|
-
result = body();
|
|
1184
|
-
} catch (err) {
|
|
1185
|
-
fail(assertion, `threw: ${(err as Error).message}`);
|
|
1186
|
-
}
|
|
1187
|
-
if (failures.length === before && skipped.length === skippedBefore) passed.push(assertion);
|
|
1188
|
-
return result;
|
|
1189
|
-
};
|
|
1074
|
+
// The harness — `check`, `fail`, `skip` and the lists they fill — is
|
|
1075
|
+
// `./assertions/harness.ts`'s since issue #1025, and the rule is the one it
|
|
1076
|
+
// always was: the model side runs the bodies that moved there in the same
|
|
1077
|
+
// harness, so a verdict cannot be decided two ways.
|
|
1078
|
+
const { failures, passed, skipped, profileSkipped, stats, fail, skip, check, verdicts } = verdictHarness(profile, ASSERTION_KIND, 'validate');
|
|
1190
1079
|
|
|
1191
1080
|
// -------------------------------------------------------------------------
|
|
1192
1081
|
// Raw text / raw JSON assertions (they must run even if the parser is happy)
|
|
@@ -2016,7 +1905,6 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2016
1905
|
|
|
2017
1906
|
const regionAttachments: RegionAttachment[] = [];
|
|
2018
1907
|
const meshAttachments: MeshAttachment[] = [];
|
|
2019
|
-
let clippingCount = 0;
|
|
2020
1908
|
const meshSlots = new Set<number>();
|
|
2021
1909
|
/**
|
|
2022
1910
|
* The loaded mesh of every attachment the FILE spells as a link, to what the
|
|
@@ -2063,26 +1951,18 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2063
1951
|
linkedMeshes.set(att, link);
|
|
2064
1952
|
loadedLinks.set(join, att);
|
|
2065
1953
|
}
|
|
2066
|
-
}
|
|
1954
|
+
}
|
|
2067
1955
|
}
|
|
2068
1956
|
}
|
|
2069
1957
|
stats.regionAttachments = regionAttachments.length;
|
|
2070
1958
|
stats.meshAttachments = meshAttachments.length;
|
|
1959
|
+
// What the bodies that moved to `./assertions/bodies/` read of the skins —
|
|
1960
|
+
// the runtime's supply of `SkinEntryFacts`, in the loaded skins' own order
|
|
1961
|
+
// (issue #1025).
|
|
1962
|
+
const skinEntries = spineSkinEntries(data);
|
|
2071
1963
|
|
|
2072
1964
|
// --- A03: every region has finite width/height (case 6c) ---------------
|
|
2073
|
-
check('A03_REGION_WIDTH_HEIGHT_FINITE', () =>
|
|
2074
|
-
// ⟨subject⟩_⟨property⟩: with no region there is no size to find non-finite,
|
|
2075
|
-
// and a loop over nothing used to report that as held (#580).
|
|
2076
|
-
if (regionAttachments.length === 0) return skip('A03_REGION_WIDTH_HEIGHT_FINITE', SKIP_NO_REGION_ATTACHMENT);
|
|
2077
|
-
for (const att of regionAttachments) {
|
|
2078
|
-
if (!Number.isFinite(att.width) || !Number.isFinite(att.height)) {
|
|
2079
|
-
fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" loaded w=${att.width} h=${att.height}`);
|
|
2080
|
-
}
|
|
2081
|
-
if (att.width <= 0 || att.height <= 0) {
|
|
2082
|
-
fail('A03_REGION_WIDTH_HEIGHT_FINITE', `region "${att.name}" has a non-positive size`);
|
|
2083
|
-
}
|
|
2084
|
-
}
|
|
2085
|
-
});
|
|
1965
|
+
check('A03_REGION_WIDTH_HEIGHT_FINITE', () => a03RegionWidthHeightFinite(verdicts, skinEntries));
|
|
2086
1966
|
|
|
2087
1967
|
// --- A04: mesh triangles + encoding coherence (case 6f) ----------------
|
|
2088
1968
|
check('A04_MESH_TRIANGLES_AND_ENCODING', () => {
|
|
@@ -2277,11 +2157,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2277
2157
|
});
|
|
2278
2158
|
|
|
2279
2159
|
// --- A11 / A13 / A14: renderer + canvas budgets ----
|
|
2280
|
-
check('A11_NO_CLIPPING_ATTACHMENTS', () =>
|
|
2281
|
-
if (clippingCount > 0) {
|
|
2282
|
-
fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
|
|
2283
|
-
}
|
|
2284
|
-
});
|
|
2160
|
+
check('A11_NO_CLIPPING_ATTACHMENTS', () => a11NoClippingAttachments(verdicts, skinEntries));
|
|
2285
2161
|
// 📐 The two numbers come from the rig spec's `invariants`, never from here.
|
|
2286
2162
|
// A mesh budget is one consumer's frame time written down — the editor's own
|
|
2287
2163
|
// example projects ship meshes many times denser and they are valid — so a
|
|
@@ -3305,7 +3181,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3305
3181
|
const reached = keyedLive(
|
|
3306
3182
|
(timeline): timeline is PhysicsConstraintTimeline =>
|
|
3307
3183
|
timeline instanceof PhysicsConstraintTimeline &&
|
|
3308
|
-
|
|
3184
|
+
physicsTimelineNames()[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
|
|
3309
3185
|
(timeline, value) => {
|
|
3310
3186
|
timeline.set(raised, value);
|
|
3311
3187
|
return rule.poseOk(raised[rule.field]);
|
|
@@ -3388,7 +3264,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3388
3264
|
// `ConstraintTimeline1` encodes its propertyId as `<Property>|<index>`,
|
|
3389
3265
|
// so the name comes from the runtime's own enum rather than from a
|
|
3390
3266
|
// table of strings this file would have to keep in step.
|
|
3391
|
-
const name =
|
|
3267
|
+
const name = physicsTimelineNames()[Number(timeline.getPropertyIds()[0].split('|')[0])];
|
|
3392
3268
|
if (name === undefined) continue;
|
|
3393
3269
|
const rule = physicsRuleFor(name);
|
|
3394
3270
|
// -1 is the global form: the timeline drives every physics constraint
|
|
@@ -4782,174 +4658,10 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4782
4658
|
|
|
4783
4659
|
// --- A45: the separable colour timelines own their channels ------------
|
|
4784
4660
|
//
|
|
4785
|
-
//
|
|
4786
|
-
//
|
|
4787
|
-
//
|
|
4788
|
-
|
|
4789
|
-
// applied to A43 itself, against a clause on A10). And the SKIP is the
|
|
4790
|
-
// sharper half: a rig with a `dark` and no separable timeline has A43's
|
|
4791
|
-
// subject present, so a clause there could only PASS on it, which is a pass
|
|
4792
|
-
// for an absent subject.
|
|
4793
|
-
//
|
|
4794
|
-
// 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
|
|
4795
|
-
// clauses are about the file against the runtime:
|
|
4796
|
-
//
|
|
4797
|
-
// 1. **One channel, one timeline.** Each colour timeline poses its
|
|
4798
|
-
// channels at EVERY time — before its first key it writes the setup
|
|
4799
|
-
// value — so when two of one slot share a channel, the one applied
|
|
4800
|
-
// later (the one the file states later) overwrites the other
|
|
4801
|
-
// everywhere and the first one's keys on it are read by nothing
|
|
4802
|
-
// (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
|
|
4803
|
-
// separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
|
|
4804
|
-
// loads without a word.
|
|
4805
|
-
// 2. **Posed as written.** Stepped to each key's own time, the channels
|
|
4806
|
-
// the timeline writes are the key's: a colour that is not six hex
|
|
4807
|
-
// digits loads as NaN, and a key whose time another key repeats is
|
|
4808
|
-
// read by nothing, and both parse in silence.
|
|
4809
|
-
//
|
|
4810
|
-
// ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
|
|
4811
|
-
// setup alpha is NOT this rule's to see, and nothing that reads only the
|
|
4812
|
-
// file can see it: the result is a correct `rgba`, which is also what an
|
|
4813
|
-
// author keying the light colour and holding its alpha would write. What
|
|
4814
|
-
// differs is what happens UNDER another track that moves the alpha, which
|
|
4815
|
-
// is the consumer's composing rather than the object (CLAUDE.md). That
|
|
4816
|
-
// file SKIPs here — it keys no `rgb` or `alpha` — and says so.
|
|
4817
|
-
//
|
|
4818
|
-
// ⚠️ The required values are parsed HERE, as A43's are, and the channel
|
|
4819
|
-
// table is `timelines.ts`'s rather than the loaded timelines' property ids:
|
|
4820
|
-
// a check that asked the parser what a timeline writes would agree with it
|
|
4821
|
-
// whatever it did. A selftest control holds that table to the runtime's ids.
|
|
4822
|
-
check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => {
|
|
4823
|
-
const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
|
|
4824
|
-
/** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
|
|
4825
|
-
const readRgbHex = (hex: unknown): [number, number, number] | null => {
|
|
4826
|
-
if (typeof hex !== 'string') return null;
|
|
4827
|
-
const body = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
4828
|
-
if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
|
|
4829
|
-
return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
|
|
4830
|
-
};
|
|
4831
|
-
/** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
|
|
4832
|
-
const HEX_STEP = 1 / 510;
|
|
4833
|
-
const FLOAT_STEP = 1e-6;
|
|
4834
|
-
const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
|
|
4835
|
-
const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
|
|
4836
|
-
|
|
4837
|
-
// -- the subjects, read off the FILE ---------------------------------
|
|
4838
|
-
const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
|
|
4839
|
-
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
4840
|
-
for (const [animName, anim] of Object.entries(rawAnimations)) {
|
|
4841
|
-
if (!isObj(anim) || !isObj(anim.slots)) continue;
|
|
4842
|
-
for (const [slotName, timelines] of Object.entries(anim.slots)) {
|
|
4843
|
-
if (!isObj(timelines)) continue;
|
|
4844
|
-
if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
|
|
4845
|
-
subjects.push({ anim: animName, slot: slotName, timelines });
|
|
4846
|
-
}
|
|
4847
|
-
}
|
|
4848
|
-
}
|
|
4849
|
-
if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
|
|
4850
|
-
stats.separableColorTimelines = subjects.reduce(
|
|
4851
|
-
(n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
|
|
4852
|
-
0,
|
|
4853
|
-
);
|
|
4854
|
-
|
|
4855
|
-
for (const { anim: animName, slot: slotName, timelines } of subjects) {
|
|
4856
|
-
const at = `animation "${animName}" slot "${slotName}"`;
|
|
4857
|
-
// In FILE order, which is the order `readAnimation` pushes them and so
|
|
4858
|
-
// the order they apply in.
|
|
4859
|
-
const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
|
|
4860
|
-
|
|
4861
|
-
// -- clause 1: one channel, one timeline ---------------------------
|
|
4862
|
-
let shared = false;
|
|
4863
|
-
for (const channel of ['rgb', 'alpha', 'dark'] as const) {
|
|
4864
|
-
const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
|
|
4865
|
-
if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
|
|
4866
|
-
shared = true;
|
|
4867
|
-
const last = writers[writers.length - 1];
|
|
4868
|
-
fail(
|
|
4869
|
-
NAME,
|
|
4870
|
-
`${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
|
|
4871
|
-
`— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
|
|
4872
|
-
`file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
|
|
4873
|
-
'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
|
|
4874
|
-
);
|
|
4875
|
-
}
|
|
4876
|
-
if (shared) continue;
|
|
4877
|
-
|
|
4878
|
-
// -- clause 2: posed as written ------------------------------------
|
|
4879
|
-
//
|
|
4880
|
-
// ⚠️ Two things this does NOT do, both measured rather than skipped:
|
|
4881
|
-
// it does not read the loaded timeline's CLASS back, and it does not
|
|
4882
|
-
// hold a channel the timeline leaves alone to the setup pose. Against
|
|
4883
|
-
// the linked parser neither can fail — every `rgb` / `alpha` array that
|
|
4884
|
-
// loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
|
|
4885
|
-
// three other outcomes (an empty array, a slot the skeleton lacks, a
|
|
4886
|
-
// name outside the switch) throw at `A00` — so either would be a clause
|
|
4887
|
-
// nobody can see fire. The selftest reads both off spine-core directly
|
|
4888
|
-
// (`S82`–`S84`), where they are measurements of the runtime rather than
|
|
4889
|
-
// checks on a file.
|
|
4890
|
-
if (!data.findAnimation(animName)) {
|
|
4891
|
-
fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
|
|
4892
|
-
continue;
|
|
4893
|
-
}
|
|
4894
|
-
for (const name of ['rgb', 'alpha'] as const) {
|
|
4895
|
-
const keys = timelines[name];
|
|
4896
|
-
if (!Array.isArray(keys)) continue;
|
|
4897
|
-
const where = `${at} ${name}`;
|
|
4898
|
-
for (const rawKey of keys) {
|
|
4899
|
-
if (!isObj(rawKey)) continue;
|
|
4900
|
-
const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
|
|
4901
|
-
const skeleton = new Skeleton(data);
|
|
4902
|
-
const state = new AnimationState(new AnimationStateData(data));
|
|
4903
|
-
state.setAnimation(0, animName, false);
|
|
4904
|
-
skeleton.setupPose();
|
|
4905
|
-
skeleton.update(0);
|
|
4906
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
4907
|
-
// At the key as the runtime stores it, not one float step before
|
|
4908
|
-
// it — see `atStoredKey` (issue #771).
|
|
4909
|
-
state.update(atStoredKey(time));
|
|
4910
|
-
state.apply(skeleton);
|
|
4911
|
-
const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
4912
|
-
if (!posed) {
|
|
4913
|
-
fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
|
|
4914
|
-
continue;
|
|
4915
|
-
}
|
|
4916
|
-
const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
|
|
4917
|
-
if (name === 'rgb') {
|
|
4918
|
-
const stated = readRgbHex(rawKey.color);
|
|
4919
|
-
if (stated === null) {
|
|
4920
|
-
fail(
|
|
4921
|
-
NAME,
|
|
4922
|
-
`${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
|
|
4923
|
-
`digits — and the runtime poses (${show(light.slice(0, 3))})`,
|
|
4924
|
-
);
|
|
4925
|
-
} else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
|
|
4926
|
-
fail(
|
|
4927
|
-
NAME,
|
|
4928
|
-
`${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
|
|
4929
|
-
`${JSON.stringify(rawKey.color)} = (${show(stated)})`,
|
|
4930
|
-
);
|
|
4931
|
-
}
|
|
4932
|
-
} else {
|
|
4933
|
-
// `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
|
|
4934
|
-
// omits it there, so absence is read the parser's way rather than
|
|
4935
|
-
// as a malformed key.
|
|
4936
|
-
const stated = rawKey.value === undefined ? 0 : rawKey.value;
|
|
4937
|
-
if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
|
|
4938
|
-
fail(
|
|
4939
|
-
NAME,
|
|
4940
|
-
`${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
|
|
4941
|
-
(typeof stated !== 'number'
|
|
4942
|
-
? ' — an alpha key\'s value is a number'
|
|
4943
|
-
: stated < 0 || stated > 1
|
|
4944
|
-
? ' — the runtime clamps a posed alpha to 0..1'
|
|
4945
|
-
: ''),
|
|
4946
|
-
);
|
|
4947
|
-
}
|
|
4948
|
-
}
|
|
4949
|
-
}
|
|
4950
|
-
}
|
|
4951
|
-
}
|
|
4952
|
-
});
|
|
4661
|
+
// The body, and the argument for the rule, are `./assertions/bodies/a45.ts`
|
|
4662
|
+
// since issue #1025: the same body runs over the model document. What it
|
|
4663
|
+
// reads off spine-core's loaded skeleton is `spineSlotColourFacts`.
|
|
4664
|
+
check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten(verdicts, spineSlotColourFacts(raw, data)));
|
|
4953
4665
|
|
|
4954
4666
|
// --- A44: a linked mesh states no geometry of its own ------------------
|
|
4955
4667
|
//
|
|
@@ -5029,9 +4741,9 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
5029
4741
|
// reads each of those off the FILE and names the value. The second POSES
|
|
5030
4742
|
// every key at sampled times — mid-frame, so a float32 key time cannot land
|
|
5031
4743
|
// a sample on a frame boundary — and compares the region the slot shows
|
|
5032
|
-
// against the one the file's own statement gives: the frame
|
|
5033
|
-
// `
|
|
5034
|
-
// `
|
|
4744
|
+
// against the one the file's own statement gives: the frame the key's mode,
|
|
4745
|
+
// index and delay give (`frameOf` below), and the frame names
|
|
4746
|
+
// `attachmentRegionLookups` derives (the core's `frameRegionName`). Neither is
|
|
5035
4747
|
// read off the loaded timeline, so the check is not the runtime agreeing
|
|
5036
4748
|
// with itself.
|
|
5037
4749
|
//
|
|
@@ -5099,7 +4811,15 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
5099
4811
|
}
|
|
5100
4812
|
}
|
|
5101
4813
|
|
|
5102
|
-
/**
|
|
4814
|
+
/**
|
|
4815
|
+
* The frame the file's statement gives for one key at one elapsed time —
|
|
4816
|
+
* A46's prediction, not a call into the runtime. What holds it is the
|
|
4817
|
+
* comparison it feeds: every sample sets it against the frame the posed
|
|
4818
|
+
* slot shows, so a reading that disagreed with the runtime would fail A46
|
|
4819
|
+
* on a correct file. A46 passing with samples posed on the selftest's
|
|
4820
|
+
* series probes is that measurement for the modes they key; `M73` is its
|
|
4821
|
+
* red half.
|
|
4822
|
+
*/
|
|
5103
4823
|
const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
|
|
5104
4824
|
if (mode === 'hold') return index;
|
|
5105
4825
|
let i = index + Math.trunc(elapsed / delay + 0.00001);
|
|
@@ -5295,17 +5015,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
5295
5015
|
// green build had ever contained one; making that state legal is exactly what
|
|
5296
5016
|
// #608 does, so the same state has to stop printing four passes about nothing.
|
|
5297
5017
|
// `SKIP_NO_ATLAS_PAGE` is one string for all four because it is one condition.
|
|
5298
|
-
check('A17_ATLAS_PAGE_FILES_EXIST', () => {
|
|
5299
|
-
if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
|
|
5300
|
-
// The other absence, one ring in (#580): the atlas LOADED and declares no
|
|
5301
|
-
// page, so there is no file to find missing. `!atlas` and "zero pages" print
|
|
5302
|
-
// different reasons because they are different facts about the artifact.
|
|
5303
|
-
if (atlas.pages.length === 0) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS_PAGE);
|
|
5304
|
-
for (const page of atlas.pages) {
|
|
5305
|
-
const abs = resolve(input.atlasDir, page.name);
|
|
5306
|
-
if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
|
|
5307
|
-
}
|
|
5308
|
-
});
|
|
5018
|
+
check('A17_ATLAS_PAGE_FILES_EXIST', () => a17AtlasPageFilesExist(verdicts, { atlas }, input));
|
|
5309
5019
|
check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => {
|
|
5310
5020
|
if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
|
|
5311
5021
|
if (atlas.pages.length === 0) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS_PAGE);
|
|
@@ -5436,7 +5146,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
5436
5146
|
// is about rigc's own packer never turning a region, which is a statement
|
|
5437
5147
|
// about what rigc WRITES. It stopped being a statement about what rigc can
|
|
5438
5148
|
// read in issue #570 — `extractRegion` now lifts a rotated region back off
|
|
5439
|
-
// its page
|
|
5149
|
+
// its page, measured against `MeshAttachment.computeUVs` (`PKR02`) — and the two
|
|
5440
5150
|
// must not be re-merged: an artifact under the renderer's own rulebook that
|
|
5441
5151
|
// rigc did not pack is still a foreign artifact, whatever rigc can measure.
|
|
5442
5152
|
const regionsPerPage = new Map<string, TextureAtlasRegion[]>();
|
|
@@ -6214,7 +5924,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
6214
5924
|
for (const name of ASSERTION_NAMES) {
|
|
6215
5925
|
if (reported.has(name)) continue;
|
|
6216
5926
|
const kind = ASSERTION_KIND[name];
|
|
6217
|
-
if (kind !== 'validity' && !
|
|
5927
|
+
if (kind !== 'validity' && !kindRunsUnder(kind, profile)) profileSkipped.push({ assertion: name, kind });
|
|
6218
5928
|
else if (!roundTrip) skip(name, SKIP_NO_SKELETON);
|
|
6219
5929
|
else {
|
|
6220
5930
|
fail(
|