spine-rigc 0.2.1 → 0.4.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/README.md +258 -9
- package/cli.ts +57 -6
- package/docs/AUTHORING.md +1006 -50
- package/docs/SPEC_COVERAGE.md +21 -14
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1555 -98
- package/src/compile.ts +325 -6
- package/src/framing.ts +280 -0
- package/src/ladder.ts +1 -1
- package/src/render.ts +43 -2
- package/src/rig.ts +169 -6
- package/src/slots.ts +102 -4
- package/src/timelines.ts +9 -5
- package/src/types.ts +80 -1
- package/src/validate.ts +192 -2
package/src/slots.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The cheap matcher labels the reference frame's connected components and asks
|
|
7
7
|
* which one each of the candidate's slots landed on. It is right whenever the
|
|
8
|
-
* parts of a shot are separate blobs, and it has
|
|
9
|
-
*
|
|
8
|
+
* parts of a shot are separate blobs, and it has three failure modes that honest
|
|
9
|
+
* ladder runs hit head-on (issues #34 and #37) — all three the same mistake, which
|
|
10
|
+
* is treating a blob as a part:
|
|
10
11
|
*
|
|
11
12
|
* - **Parts that touch label as one component.** Rung 4 is a disc with five chain
|
|
12
13
|
* links hanging off it; they touch in every frame of every animation, so every
|
|
@@ -15,6 +16,13 @@
|
|
|
15
16
|
* 4 px ball, on the frames where it rests against the course and has no component
|
|
16
17
|
* of its own, matched the floating girder 47 px away — and the summary line
|
|
17
18
|
* reported **48.3 px of drift for a 4 px ball**, unflagged.
|
|
19
|
+
* - **A blob one part dominates passes for that part.** Rung 2's reference merges
|
|
20
|
+
* the course, the water, the panel and both rings into one component in which the
|
|
21
|
+
* course is 81 % of the ink, so it is 1.24x the course's own and no wider than its
|
|
22
|
+
* box — both merge tests see nothing, and the run reported *"course drift
|
|
23
|
+
* 11.2 px"*, the distance to a five-part centroid (issue #37). `occupantsOf`
|
|
24
|
+
* answers that one with the label map: anything else the candidate drew inside the
|
|
25
|
+
* blob makes the blob's centroid nobody's position.
|
|
18
26
|
*
|
|
19
27
|
* So: components first, and when a component cannot be attributed to one slot, the
|
|
20
28
|
* slot's own rendered quad is **template-matched** against the reference in a
|
|
@@ -93,6 +101,21 @@ export interface Component {
|
|
|
93
101
|
maxY: number;
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The reference frame's components, and which one each pixel belongs to.
|
|
106
|
+
*
|
|
107
|
+
* The label map is what makes "is anything ELSE inside this blob?" a measurement
|
|
108
|
+
* rather than a guess from bounding boxes — see `occupantsOf`. It is indexed
|
|
109
|
+
* row-major on the frame's own grid, and `-1` is background or a component too
|
|
110
|
+
* small to be a part.
|
|
111
|
+
*/
|
|
112
|
+
export interface ComponentField {
|
|
113
|
+
components: Component[];
|
|
114
|
+
labels: Int32Array;
|
|
115
|
+
width: number;
|
|
116
|
+
height: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
/**
|
|
97
120
|
* Connected components of "not the background colour", 8-connected.
|
|
98
121
|
*
|
|
@@ -101,6 +124,11 @@ export interface Component {
|
|
|
101
124
|
* twenty and every match is ambiguous for a reason that is about the labeller.
|
|
102
125
|
*/
|
|
103
126
|
export function componentsOf(plate: Plate, background: RGBA): Component[] {
|
|
127
|
+
return componentField(plate, background).components;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The same labelling, with the map kept — see `ComponentField`. */
|
|
131
|
+
export function componentField(plate: Plate, background: RGBA): ComponentField {
|
|
104
132
|
const { width, height } = plate;
|
|
105
133
|
const label = new Int32Array(width * height).fill(-1);
|
|
106
134
|
const out: Component[] = [];
|
|
@@ -145,7 +173,54 @@ export function componentsOf(plate: Plate, background: RGBA): Component[] {
|
|
|
145
173
|
out.push({ pixels, cx: sx / pixels, cy: sy / pixels, minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
|
|
146
174
|
}
|
|
147
175
|
}
|
|
148
|
-
|
|
176
|
+
// Crumbs out, biggest first — and the label map carried through the reorder, so
|
|
177
|
+
// a label is always an index into the array the caller is handed. Renumbering
|
|
178
|
+
// rather than sorting the map is what keeps the two from drifting apart.
|
|
179
|
+
const keep = out.map((c, id) => ({ c, id })).filter(({ c }) => c.pixels >= MIN_COMPONENT_PIXELS);
|
|
180
|
+
keep.sort((a, b) => b.c.pixels - a.c.pixels);
|
|
181
|
+
const renumbered = new Int32Array(out.length).fill(-1);
|
|
182
|
+
keep.forEach(({ id }, index) => {
|
|
183
|
+
renumbered[id] = index;
|
|
184
|
+
});
|
|
185
|
+
for (let at = 0; at < label.length; at++) label[at] = label[at] < 0 ? -1 : renumbered[label[at]];
|
|
186
|
+
return { components: keep.map(({ c }) => c), labels: label, width, height };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Which drawn slots' ink sits inside each component, by centroid.
|
|
191
|
+
*
|
|
192
|
+
* ## Why this exists: a blob one part dominates is still a blob
|
|
193
|
+
*
|
|
194
|
+
* The size and bounding-box tests below catch a merge when the merged neighbour is
|
|
195
|
+
* a material fraction of the blob. They cannot catch the case issue #37 filed:
|
|
196
|
+
* rung 2's reference merges the course, the water, the panel and both rings into
|
|
197
|
+
* one component, and the **course is 81 % of it**, so the blob is only 1.24x the
|
|
198
|
+
* course's own ink and barely wider than the course's own box. It passed every
|
|
199
|
+
* test, and the summary line reported *"course drift 11.2 px"* — the distance from
|
|
200
|
+
* the course's centroid to the centroid of a blob holding four other parts, which
|
|
201
|
+
* is not a measurement of the course at all.
|
|
202
|
+
*
|
|
203
|
+
* One label lookup per drawn slot answers it exactly: if anything else the
|
|
204
|
+
* candidate drew lands on this component's own pixels, the component is more than
|
|
205
|
+
* one part and its centroid is nobody's position. That is the same judgement the
|
|
206
|
+
* two-claimants rule below already makes; what was missing is that a slot which
|
|
207
|
+
* never got as far as *claiming* the blob — because it was refused for being 13x
|
|
208
|
+
* too small, or diverted to the template matcher — still proves the blob is shared.
|
|
209
|
+
*/
|
|
210
|
+
function occupantsOf(field: ComponentField, footprints: Map<string, Footprint>): Map<number, string[]> {
|
|
211
|
+
const out = new Map<number, string[]>();
|
|
212
|
+
for (const [slot, foot] of footprints) {
|
|
213
|
+
if (foot.pixels === 0) continue;
|
|
214
|
+
const x = Math.floor(foot.cx);
|
|
215
|
+
const y = Math.floor(foot.cy);
|
|
216
|
+
if (x < 0 || y < 0 || x >= field.width || y >= field.height) continue;
|
|
217
|
+
const label = field.labels[y * field.width + x];
|
|
218
|
+
if (label < 0) continue;
|
|
219
|
+
const seen = out.get(label) ?? [];
|
|
220
|
+
seen.push(slot);
|
|
221
|
+
out.set(label, seen);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
149
224
|
}
|
|
150
225
|
|
|
151
226
|
/** How the drift beside a slot was arrived at. `none` means it could not be. */
|
|
@@ -213,11 +288,16 @@ interface Pending {
|
|
|
213
288
|
*/
|
|
214
289
|
export function matchSlots(
|
|
215
290
|
footprints: Map<string, Footprint>,
|
|
216
|
-
|
|
291
|
+
field: ComponentField,
|
|
217
292
|
source: SlotSource | null,
|
|
218
293
|
): { tracks: SlotTrack[]; matchedComponents: number } {
|
|
294
|
+
const components = field.components;
|
|
219
295
|
const pending: Pending[] = [];
|
|
220
296
|
const takenBy = new Map<Component, string[]>();
|
|
297
|
+
const occupants = occupantsOf(field, footprints);
|
|
298
|
+
/** Which component each one is, so an occupancy list can be looked up by it. */
|
|
299
|
+
const idOf = new Map<Component, number>();
|
|
300
|
+
components.forEach((component, id) => idOf.set(component, id));
|
|
221
301
|
|
|
222
302
|
for (const [slot, foot] of [...footprints].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
223
303
|
const track = blankTrack(slot);
|
|
@@ -320,6 +400,24 @@ export function matchSlots(
|
|
|
320
400
|
}
|
|
321
401
|
}
|
|
322
402
|
|
|
403
|
+
// ...and a component ONE slot claimed while other ink of the candidate's sits
|
|
404
|
+
// inside it is the same blob by the other route — the one that reported a
|
|
405
|
+
// dominant part's distance to a five-part blob as that part's drift (#37). The
|
|
406
|
+
// claim goes to the template matcher for the same reason: a centroid shared by
|
|
407
|
+
// several parts is not this part's position.
|
|
408
|
+
for (const entry of pending) {
|
|
409
|
+
if (entry.claimed === null || entry.track.ambiguity !== null) continue;
|
|
410
|
+
const id = idOf.get(entry.claimed);
|
|
411
|
+
if (id === undefined) continue;
|
|
412
|
+
const others = (occupants.get(id) ?? []).filter((slot) => slot !== entry.track.slot);
|
|
413
|
+
if (others.length === 0) continue;
|
|
414
|
+
entry.track.ambiguity =
|
|
415
|
+
`this slot's reference component also holds ${others.map((s) => JSON.stringify(s)).join(', ')} — ` +
|
|
416
|
+
`${entry.claimed.pixels} px of blob against this slot's own ${Math.round(entry.foot.pixels)} px, so its ` +
|
|
417
|
+
"centroid is the merged shape's and not this part's";
|
|
418
|
+
clearMatch(entry.track);
|
|
419
|
+
}
|
|
420
|
+
|
|
323
421
|
// The fallback: anything the components could not attribute, correlated against
|
|
324
422
|
// its own rendered pixels.
|
|
325
423
|
if (source) {
|
package/src/timelines.ts
CHANGED
|
@@ -87,11 +87,15 @@ const EVENT_CHANNELS: Record<string, number | null> = { events: null };
|
|
|
87
87
|
* How far past an animation's declared `duration` a key time may land: one step
|
|
88
88
|
* of the grid every key time is rounded onto.
|
|
89
89
|
*
|
|
90
|
-
* The compiler
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
90
|
+
* The compiler quantises key times onto that grid with `keyTime`, which rounds
|
|
91
|
+
* DOWN (issue #99), so 1e-6 s is the finest distinction a key can make and a
|
|
92
|
+
* *correct* key now misses its target only on the early side: a key the author put
|
|
93
|
+
* exactly ON a duration of 68/12 s emits as 5.666666 rather than past it. What
|
|
94
|
+
* still needs the tolerance is the other side of the same rule — `validate` re-runs
|
|
95
|
+
* it on an emitted file read back through a **Float32Array**, whose steps are
|
|
96
|
+
* coarser than this one and round both ways, and on artifacts no rigc compile ever
|
|
97
|
+
* touched. Anything a whole step past a declared duration was authored there, not
|
|
98
|
+
* rounded onto it.
|
|
95
99
|
*
|
|
96
100
|
* ⚠️ `FRAME` (1/60 s) is the wrong tolerance for this, which is why the constant
|
|
97
101
|
* is separate rather than reused: 1/60 s answers "is the DECLARED DURATION
|
package/src/types.ts
CHANGED
|
@@ -303,6 +303,33 @@ export interface MotionDrawOrderKey {
|
|
|
303
303
|
offsets?: MotionDrawOrderOffset[];
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* One firing of a declared event, at one time.
|
|
308
|
+
*
|
|
309
|
+
* ⚠️ Like `drawOrder` and unlike a `track`, this timeline names **no target**:
|
|
310
|
+
* 4.3 writes it as `animations.<a>.events` beside `bones` and `slots`
|
|
311
|
+
* (SPEC_COVERAGE part 1-8), and there is one per animation. The `name` picks
|
|
312
|
+
* an entry out of the rig spec's `events` table; the optional payload fields
|
|
313
|
+
* override that entry's defaults for this firing only.
|
|
314
|
+
*
|
|
315
|
+
* A key with no `int`/`float`/`string` inherits the event's setup payload
|
|
316
|
+
* (`:1250-1252`) — which is what the editor writes, and why `{ "t": 0.5,
|
|
317
|
+
* "name": "footstep" }` is the common shape.
|
|
318
|
+
*/
|
|
319
|
+
export interface MotionEventKey {
|
|
320
|
+
/** Time in seconds. */
|
|
321
|
+
t: number;
|
|
322
|
+
/** An event the rig spec declares. A miss throws in the parser; rigc refuses it. */
|
|
323
|
+
name: string;
|
|
324
|
+
/** Payload overrides for this firing. Omit to inherit the event's defaults. */
|
|
325
|
+
int?: number;
|
|
326
|
+
float?: number;
|
|
327
|
+
string?: string;
|
|
328
|
+
/** Read only when the declared event carries an `audio` path — see `RigEvent`. */
|
|
329
|
+
volume?: number;
|
|
330
|
+
balance?: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
306
333
|
export interface MotionAnimation {
|
|
307
334
|
/** Declared, then verified against the compiled result (rule 4). */
|
|
308
335
|
duration: number;
|
|
@@ -320,6 +347,11 @@ export interface MotionAnimation {
|
|
|
320
347
|
* time. First needed at ladder rung 5.
|
|
321
348
|
*/
|
|
322
349
|
drawOrder?: MotionDrawOrderKey[];
|
|
350
|
+
/**
|
|
351
|
+
* The event timeline. One per animation, names no target, and for the same
|
|
352
|
+
* reason `drawOrder` is not a `track`. First needed at the spineboy rung.
|
|
353
|
+
*/
|
|
354
|
+
events?: MotionEventKey[];
|
|
323
355
|
}
|
|
324
356
|
|
|
325
357
|
/**
|
|
@@ -442,7 +474,37 @@ export interface SpineMeshAttachment {
|
|
|
442
474
|
color?: string;
|
|
443
475
|
}
|
|
444
476
|
|
|
445
|
-
|
|
477
|
+
/**
|
|
478
|
+
* The two vertex-only attachments: a polygon and nothing else.
|
|
479
|
+
*
|
|
480
|
+
* `vertexCount` is not optional the way a mesh's is absent-by-design: the parser
|
|
481
|
+
* reads `map.vertexCount << 1`, so an omission is `0` and `readVertices` decodes
|
|
482
|
+
* the coordinate array as a weight run and stores nothing.
|
|
483
|
+
*/
|
|
484
|
+
export interface SpineBoundingBoxAttachment {
|
|
485
|
+
type: 'boundingbox';
|
|
486
|
+
vertexCount: number;
|
|
487
|
+
/** Unweighted x/y pairs, or the weighted run — same encoding as a mesh's. */
|
|
488
|
+
vertices: number[];
|
|
489
|
+
color?: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export interface SpineClippingAttachment {
|
|
493
|
+
type: 'clipping';
|
|
494
|
+
/** The last slot the clip applies to. Absent = to the bottom of the order. */
|
|
495
|
+
end?: string;
|
|
496
|
+
convex?: boolean;
|
|
497
|
+
inverse?: boolean;
|
|
498
|
+
vertexCount: number;
|
|
499
|
+
vertices: number[];
|
|
500
|
+
color?: string;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export type SpineAttachment =
|
|
504
|
+
| SpineRegionAttachment
|
|
505
|
+
| SpineMeshAttachment
|
|
506
|
+
| SpineBoundingBoxAttachment
|
|
507
|
+
| SpineClippingAttachment;
|
|
446
508
|
|
|
447
509
|
export type SpineTimelineKey = Record<string, unknown>;
|
|
448
510
|
|
|
@@ -469,6 +531,11 @@ export interface SpineSkeletonJson {
|
|
|
469
531
|
slots: SpineSlot[];
|
|
470
532
|
constraints?: SpineConstraint[];
|
|
471
533
|
skins: Array<{ name: string; attachments: Record<string, Record<string, SpineAttachment>> }>;
|
|
534
|
+
/**
|
|
535
|
+
* Event definitions, keyed by name (`SkeletonJson.ts:451-464`). An object, not
|
|
536
|
+
* an array — the one top-level collection in the format that is.
|
|
537
|
+
*/
|
|
538
|
+
events?: Record<string, SpineEvent>;
|
|
472
539
|
animations: Record<
|
|
473
540
|
string,
|
|
474
541
|
{
|
|
@@ -477,10 +544,22 @@ export interface SpineSkeletonJson {
|
|
|
477
544
|
physics?: Record<string, Record<string, SpineTimelineKey[]>>;
|
|
478
545
|
/** Whole-animation timeline: no target name, one array per animation. */
|
|
479
546
|
drawOrder?: SpineTimelineKey[];
|
|
547
|
+
/** The other whole-animation timeline; same shape, same reason. */
|
|
548
|
+
events?: SpineTimelineKey[];
|
|
480
549
|
}
|
|
481
550
|
>;
|
|
482
551
|
}
|
|
483
552
|
|
|
553
|
+
/** One entry of the emitted `events` map: the payload a firing inherits. */
|
|
554
|
+
export interface SpineEvent {
|
|
555
|
+
int?: number;
|
|
556
|
+
float?: number;
|
|
557
|
+
string?: string;
|
|
558
|
+
audio?: string;
|
|
559
|
+
volume?: number;
|
|
560
|
+
balance?: number;
|
|
561
|
+
}
|
|
562
|
+
|
|
484
563
|
// ---------------------------------------------------------------------------
|
|
485
564
|
// Compiler result
|
|
486
565
|
// ---------------------------------------------------------------------------
|
package/src/validate.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
AnimationState,
|
|
20
20
|
AnimationStateData,
|
|
21
21
|
AtlasAttachmentLoader,
|
|
22
|
+
BoundingBoxAttachment,
|
|
22
23
|
ClippingAttachment,
|
|
23
24
|
MeshAttachment,
|
|
24
25
|
Physics,
|
|
@@ -42,8 +43,9 @@ export interface Failure {
|
|
|
42
43
|
*
|
|
43
44
|
* ⭐ The distinction this draws is the difference between "wrong" and "not how we
|
|
44
45
|
* do it here", and conflating the two is how a validator stops being usable on
|
|
45
|
-
* anybody else's data.
|
|
46
|
-
* (`spine-html`)
|
|
46
|
+
* anybody else's data. Fourteen of the 34 assertions are policy — seven for one
|
|
47
|
+
* renderer (`spine-html`) and one project's canvas budget, seven for rigc's own
|
|
48
|
+
* formations — and every one of them fires
|
|
47
49
|
* on real, correct, editor-produced Spine data — the official example projects
|
|
48
50
|
* carry clipping attachments, unweighted meshes, 116-triangle meshes and packed
|
|
49
51
|
* atlases, all of which are perfectly valid and none of which spine-html likes.
|
|
@@ -113,6 +115,8 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
|
113
115
|
A29_STROKE_WITHIN_CONTACT_DEPTH: 'archetype',
|
|
114
116
|
A30_STROKE_WITHIN_CAP_CONTAINMENT: 'archetype',
|
|
115
117
|
A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
|
|
118
|
+
A32_EVENT_KEYS_RESOLVE: 'validity',
|
|
119
|
+
A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
|
|
116
120
|
};
|
|
117
121
|
|
|
118
122
|
export interface ValidateInput {
|
|
@@ -370,6 +374,81 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
370
374
|
if (!sawATimeline) return skip('A31_DRAW_ORDER_OFFSETS_RESOLVE', 'no animation carries a drawOrder timeline');
|
|
371
375
|
});
|
|
372
376
|
|
|
377
|
+
// --- A32: every event key fires a declared event, in order ----------------
|
|
378
|
+
//
|
|
379
|
+
// The event timeline's three failure modes, and only the first is loud:
|
|
380
|
+
//
|
|
381
|
+
// 1. **An undeclared name.** `findEvent` returns null and `readAnimation`
|
|
382
|
+
// throws `Event not found` (SkeletonJson.ts:1244). A00 would catch it, but
|
|
383
|
+
// as a parser message about a name with no context; this one says which
|
|
384
|
+
// animation, which key, and what the skeleton does declare.
|
|
385
|
+
// 2. **Times out of order.** `readAnimation` writes frame `i` from key `i` in
|
|
386
|
+
// ARRAY order and never sorts, so a decreasing time builds an
|
|
387
|
+
// `EventTimeline` whose frames run backwards. It loads clean, and the
|
|
388
|
+
// firings behind the fold simply never come out. Equal times are fine —
|
|
389
|
+
// two events on one frame is ordinary — so this is non-decreasing.
|
|
390
|
+
// 3. **`volume`/`balance` on a silent event.** `:1254-1257` reads them only
|
|
391
|
+
// inside `if (event.data.audioPath)`, so on an event with no `audio` they
|
|
392
|
+
// are two numbers in the file that no runtime will ever read.
|
|
393
|
+
//
|
|
394
|
+
// It runs on the raw JSON rather than on the loaded data because the loaded
|
|
395
|
+
// `Event` no longer remembers which fields the file wrote: an override that was
|
|
396
|
+
// dropped and an override that matched the default are the same object.
|
|
397
|
+
check('A32_EVENT_KEYS_RESOLVE', () => {
|
|
398
|
+
if (!raw) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
399
|
+
if (!isObj(raw.animations)) return skip('A32_EVENT_KEYS_RESOLVE', 'the skeleton declares no animations');
|
|
400
|
+
const declared = isObj(raw.events) ? (raw.events as Json) : {};
|
|
401
|
+
const known = Object.keys(declared);
|
|
402
|
+
let sawATimeline = false;
|
|
403
|
+
for (const [animName, anim] of Object.entries(raw.animations as Json)) {
|
|
404
|
+
if (!isObj(anim) || !Array.isArray(anim.events)) continue;
|
|
405
|
+
sawATimeline = true;
|
|
406
|
+
let previous = -Infinity;
|
|
407
|
+
(anim.events as unknown[]).forEach((key, k) => {
|
|
408
|
+
const at = `animation "${animName}" event key ${k}`;
|
|
409
|
+
if (!isObj(key) || typeof key.name !== 'string') {
|
|
410
|
+
fail('A32_EVENT_KEYS_RESOLVE', `${at}: an event key needs a string "name"`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const definition = declared[key.name];
|
|
414
|
+
if (definition === undefined) {
|
|
415
|
+
fail(
|
|
416
|
+
'A32_EVENT_KEYS_RESOLVE',
|
|
417
|
+
`${at}: fires "${key.name}", which the skeleton's events block does not declare` +
|
|
418
|
+
(known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
|
|
419
|
+
);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// `time` defaults to 0 when absent (`:1247`), which is what the editor
|
|
423
|
+
// writes for a firing on frame 0.
|
|
424
|
+
const time = key.time === undefined ? 0 : key.time;
|
|
425
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) {
|
|
426
|
+
fail('A32_EVENT_KEYS_RESOLVE', `${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (time < previous) {
|
|
430
|
+
fail(
|
|
431
|
+
'A32_EVENT_KEYS_RESOLVE',
|
|
432
|
+
`${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
|
|
433
|
+
'array order and never sorts them, so the earlier firing is unreachable',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
previous = Math.max(previous, time);
|
|
437
|
+
const hasAudio = isObj(definition) && typeof definition.audio === 'string';
|
|
438
|
+
for (const field of ['volume', 'balance'] as const) {
|
|
439
|
+
if (key[field] !== undefined && !hasAudio) {
|
|
440
|
+
fail(
|
|
441
|
+
'A32_EVENT_KEYS_RESOLVE',
|
|
442
|
+
`${at}: "${key.name}" sets ${field}, but the event declares no audio path — the parser reads ` +
|
|
443
|
+
`${field} only for an event that has one, so it is dropped in silence`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (!sawATimeline) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
|
|
450
|
+
});
|
|
451
|
+
|
|
373
452
|
// --- A: the round trip ----------------------------------------------------
|
|
374
453
|
// The two loaded objects come back OUT of the assertion rather than being
|
|
375
454
|
// assigned into it. Everything below reads them, and a `let` written inside a
|
|
@@ -563,6 +642,117 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
563
642
|
}
|
|
564
643
|
});
|
|
565
644
|
|
|
645
|
+
// --- A33: bounding boxes and clipping polygons hold a real polygon -------
|
|
646
|
+
//
|
|
647
|
+
// These two types are the same shape — a polygon and nothing else — and they
|
|
648
|
+
// fail the same three ways, all three silent:
|
|
649
|
+
//
|
|
650
|
+
// 1. **A missing or wrong `vertexCount`.** The parser reads
|
|
651
|
+
// `map.vertexCount << 1` and hands it to `readVertices` as the length to
|
|
652
|
+
// expect (`:552`, `:632`). `undefined << 1` is 0, so an omission makes
|
|
653
|
+
// the coordinate array read as a WEIGHTED run: it decodes numbers as
|
|
654
|
+
// bone counts and weights, and the attachment ends up with no vertices
|
|
655
|
+
// at all. Nothing throws, and neither type draws a pixel, so nothing
|
|
656
|
+
// downstream notices either.
|
|
657
|
+
// 2. **A weighted run that does not decode to that many vertices.** Same
|
|
658
|
+
// trap as a mesh's (A04), minus the uvs that would have caught it.
|
|
659
|
+
// 3. **A clipping `end` naming a slot that is not there.**
|
|
660
|
+
// `skeletonData.findSlot` returns null on a miss and `:626-627` assigns
|
|
661
|
+
// the null, so the clip does not end where it was told to — it runs to
|
|
662
|
+
// the bottom of the draw order and takes every slot below it with it.
|
|
663
|
+
// Checked on the raw JSON, because a null `endSlot` and an `end` that
|
|
664
|
+
// was never written are the same loaded object.
|
|
665
|
+
check('A33_VERTEX_ATTACHMENT_GEOMETRY', () => {
|
|
666
|
+
const polygons: Array<{ what: string; att: BoundingBoxAttachment | ClippingAttachment }> = [];
|
|
667
|
+
for (const skin of data.skins) {
|
|
668
|
+
for (const entry of skin.getAttachments()) {
|
|
669
|
+
const att = entry.attachment;
|
|
670
|
+
if (att instanceof BoundingBoxAttachment) polygons.push({ what: `bounding box "${att.name}"`, att });
|
|
671
|
+
else if (att instanceof ClippingAttachment) polygons.push({ what: `clipping attachment "${att.name}"`, att });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const slotNames = new Set(data.slots.map((s) => s.name));
|
|
675
|
+
let endsChecked = 0;
|
|
676
|
+
if (raw && Array.isArray(raw.skins)) {
|
|
677
|
+
for (const skin of raw.skins as unknown[]) {
|
|
678
|
+
if (!isObj(skin) || !isObj(skin.attachments)) continue;
|
|
679
|
+
for (const [slotName, perSlot] of Object.entries(skin.attachments as Json)) {
|
|
680
|
+
if (!isObj(perSlot)) continue;
|
|
681
|
+
for (const [placeholder, att] of Object.entries(perSlot)) {
|
|
682
|
+
if (!isObj(att) || att.type !== 'clipping' || att.end === undefined) continue;
|
|
683
|
+
endsChecked++;
|
|
684
|
+
if (typeof att.end !== 'string' || !slotNames.has(att.end)) {
|
|
685
|
+
fail(
|
|
686
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
687
|
+
`clipping attachment "${placeholder}" on slot "${slotName}" ends at ${JSON.stringify(att.end)}, ` +
|
|
688
|
+
'which is not a slot of this skeleton — the clip would run to the bottom of the draw order',
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (polygons.length === 0 && endsChecked === 0) {
|
|
696
|
+
return skip('A33_VERTEX_ATTACHMENT_GEOMETRY', 'the skeleton carries no bounding box and no clipping attachment');
|
|
697
|
+
}
|
|
698
|
+
for (const { what, att } of polygons) {
|
|
699
|
+
const length = att.worldVerticesLength;
|
|
700
|
+
if (!Number.isInteger(length) || length < 6 || length % 2 !== 0) {
|
|
701
|
+
fail(
|
|
702
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
703
|
+
`${what} loaded worldVerticesLength ${length}; a polygon is an even count of at least 6 (3 vertices). ` +
|
|
704
|
+
'A missing "vertexCount" reads as 0 and takes the polygon with it',
|
|
705
|
+
);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const vertexCount = length / 2;
|
|
709
|
+
if (!att.bones) {
|
|
710
|
+
if (att.vertices.length !== length) {
|
|
711
|
+
fail(
|
|
712
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
713
|
+
`${what} declares ${vertexCount} vertices but holds ${att.vertices.length} unweighted numbers ` +
|
|
714
|
+
`(expected ${length}); the parser reads that mismatch as a weighted run`,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
// Weighted: `bones` is boneCount, (index × boneCount), repeated, and
|
|
720
|
+
// `vertices` holds x, y, weight per binding.
|
|
721
|
+
let decoded = 0;
|
|
722
|
+
let bindings = 0;
|
|
723
|
+
let ok = true;
|
|
724
|
+
for (let i = 0; i < att.bones.length; decoded++) {
|
|
725
|
+
const count = att.bones[i++];
|
|
726
|
+
if (!Number.isInteger(count) || count < 1 || i + count > att.bones.length) {
|
|
727
|
+
fail('A33_VERTEX_ATTACHMENT_GEOMETRY', `${what} vertex ${decoded} claims ${count} bone(s); the run is malformed`);
|
|
728
|
+
ok = false;
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
for (let k = 0; k < count; k++, i++) {
|
|
732
|
+
const index = att.bones[i];
|
|
733
|
+
if (index < 0 || index >= data.bones.length) {
|
|
734
|
+
fail('A33_VERTEX_ATTACHMENT_GEOMETRY', `${what} vertex ${decoded} references bone index ${index}`);
|
|
735
|
+
ok = false;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
bindings += count;
|
|
739
|
+
}
|
|
740
|
+
if (!ok) continue;
|
|
741
|
+
if (decoded !== vertexCount) {
|
|
742
|
+
fail(
|
|
743
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
744
|
+
`${what} declares ${vertexCount} vertices and its weighted run decodes to ${decoded}`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
if (att.vertices.length !== bindings * 3) {
|
|
748
|
+
fail(
|
|
749
|
+
'A33_VERTEX_ATTACHMENT_GEOMETRY',
|
|
750
|
+
`${what} has ${bindings} binding(s) and ${att.vertices.length} weight numbers (expected ${bindings * 3})`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
566
756
|
// --- A11 / A13 / A14: renderer + canvas budgets ----
|
|
567
757
|
check('A11_NO_CLIPPING_ATTACHMENTS', () => {
|
|
568
758
|
if (clippingCount > 0) {
|