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/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 two failure modes that two
9
- * honest ladder runs hit head-on (issue #34):
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
- return out.filter((c) => c.pixels >= MIN_COMPONENT_PIXELS).sort((a, b) => b.pixels - a.pixels);
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
- components: Component[],
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 emits key times through `r6`, so 1e-6 s is the finest distinction
91
- * a key can make and half a step is the most a *correct* key can miss its target
92
- * by. A key the author put exactly ON a duration of 68/12 s emits as 5.666667 —
93
- * 3.3e-7 s late, and legal. Anything a whole step further was authored past the
94
- * end, not rounded onto it.
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
- export type SpineAttachment = SpineRegionAttachment | SpineMeshAttachment;
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. Nine of the 31 assertions are policy for one renderer
46
- * (`spine-html`) or for one project's canvas budget, and every one of them fires
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) {