spine-rigc 0.6.0 → 0.7.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/types.ts CHANGED
@@ -330,6 +330,144 @@ export interface MotionEventKey {
330
330
  balance?: number;
331
331
  }
332
332
 
333
+ /**
334
+ * One key of an IK constraint's timeline (`animations.<a>.ik.<constraint>`).
335
+ *
336
+ * ⚠️ Every field is **optional and absolute**, and that pairing is the trap. The
337
+ * parser reads each one with its own default per key
338
+ * (`SkeletonJson.ts`: `mix` 1, `softness` 0, `bendPositive` true, `compress`
339
+ * false, `stretch` false) — so a key that omits `softness` does not hold the
340
+ * previous key's softness, it snaps to 0. rigc therefore refuses a track whose
341
+ * keys do not all name the SAME set of fields: state the value on every key, or
342
+ * on none of them.
343
+ *
344
+ * `mix` and `softness` are the timeline's two curve channels, in that order.
345
+ * The three booleans are stepped by nature — nothing interpolates them.
346
+ */
347
+ export interface MotionIkKey {
348
+ /** Time in seconds. */
349
+ t: number;
350
+ /** 0..1: how much of the constrained rotation is applied. Parser default 1. */
351
+ mix?: number;
352
+ /** Distance from full reach at which the bones stop straightening. Default 0. */
353
+ softness?: number;
354
+ /** Two-bone IK bend direction. Default true. */
355
+ bendPositive?: boolean;
356
+ /** One-bone IK: scale the bone down to reach a close target. Default false. */
357
+ compress?: boolean;
358
+ /** Scale the bone up to reach a far target. Default false. */
359
+ stretch?: boolean;
360
+ /** Named easing from `easings`, or "stepped". Absent = linear. */
361
+ ease?: string;
362
+ /** The raw form: 4 absolute (time, value) numbers per channel — 8 here. */
363
+ curve?: number[] | 'stepped';
364
+ }
365
+
366
+ /**
367
+ * One IK constraint keyed over time.
368
+ *
369
+ * 4.3 writes this as `animations.<a>.ik.<constraint>` — **one unnamed timeline
370
+ * per constraint**, so the constraint name is the only target there is and the
371
+ * group carries no timeline name at all.
372
+ */
373
+ export interface MotionIkTrack {
374
+ /** An `ik` constraint the rig spec declares. */
375
+ constraint: string;
376
+ keys: MotionIkKey[];
377
+ }
378
+
379
+ /**
380
+ * One key of a transform constraint's timeline
381
+ * (`animations.<a>.transform.<constraint>`).
382
+ *
383
+ * Six mixes, six curve channels, in the order written here — which is the order
384
+ * the parser reads them and therefore the order a curve array concatenates.
385
+ * The same absent-means-default rule as `MotionIkKey` applies, with one extra
386
+ * quirk: `mixY` defaults to **this key's own `mixX`**, not to 1.
387
+ */
388
+ export interface MotionTransformKey {
389
+ /** Time in seconds. */
390
+ t: number;
391
+ /** Parser default 1. */
392
+ mixRotate?: number;
393
+ /** Parser default 1. */
394
+ mixX?: number;
395
+ /** Parser default: the same key's `mixX`. */
396
+ mixY?: number;
397
+ /** Parser default 1. */
398
+ mixScaleX?: number;
399
+ /** Parser default 1. */
400
+ mixScaleY?: number;
401
+ /** Parser default 1. */
402
+ mixShearY?: number;
403
+ ease?: string;
404
+ /** 4 absolute (time, value) numbers per channel — 24 here. */
405
+ curve?: number[] | 'stepped';
406
+ }
407
+
408
+ /** One transform constraint keyed over time. Same shape rule as `MotionIkTrack`. */
409
+ export interface MotionTransformTrack {
410
+ /** A `transform` constraint the rig spec declares. */
411
+ constraint: string;
412
+ keys: MotionTransformKey[];
413
+ }
414
+
415
+ /**
416
+ * One key of a deform timeline
417
+ * (`animations.<a>.attachments.<skin>.<slot>.<attachment>.deform`).
418
+ *
419
+ * 🚨 This is the only key in the format whose meaning depends on the object it
420
+ * is attached to. The parser builds a zero-filled array as long as the
421
+ * attachment's own deform array, copies this key's `vertices` into it starting at
422
+ * `offset`, and leaves the rest alone — so a key is a **sparse edit of the setup
423
+ * geometry**, and both the length of that array and the meaning of an index into
424
+ * it come from the attachment (`SkeletonJson.ts`, the `deform` branch):
425
+ *
426
+ * - **unweighted** attachment — the array is one `x, y` pair per VERTEX, and
427
+ * the parser adds the setup position back on load. The numbers here are
428
+ * therefore offsets from setup, in the slot bone's space.
429
+ * - **weighted** attachment — the array is one `x, y` pair per BONE INFLUENCE
430
+ * (`vertices.length / 3 * 2`), each in the bind space of that influence's
431
+ * bone. A vertex with three bones on it occupies three pairs.
432
+ *
433
+ * `offset` is an index into that array and works for both. `fromVertex` is
434
+ * rigc's ergonomic form — a VERTEX index, which rigc translates — and it is
435
+ * accepted only where the translation is honest: always on an unweighted
436
+ * attachment, and on a weighted one only when every vertex the run covers has
437
+ * exactly one bone influence. Anything else is refused by name rather than
438
+ * emitted as a plausible-looking lie.
439
+ */
440
+ export interface MotionDeformKey {
441
+ /** Time in seconds. */
442
+ t: number;
443
+ /** Where the run starts in the attachment's own deform array. Default 0. */
444
+ offset?: number;
445
+ /** The same start, given as a vertex index. Never together with `offset`. */
446
+ fromVertex?: number;
447
+ /**
448
+ * The run: `x, y` offsets, two numbers per array slot. Absent or `null` is the
449
+ * parser's own encoding for "back to the setup pose" — the key with no edit.
450
+ */
451
+ vertices?: number[] | null;
452
+ ease?: string;
453
+ /**
454
+ * One channel, and it interpolates the deform FRACTION from 0 to 1 rather than
455
+ * any value in `vertices` (`readCurve(..., 0, 1, 1)`). So a raw curve is 4
456
+ * numbers whose value axis runs 0..1.
457
+ */
458
+ curve?: number[] | 'stepped';
459
+ }
460
+
461
+ /** One attachment's geometry keyed over time. */
462
+ export interface MotionDeformTrack {
463
+ /** The skin the attachment lives in. Absent = `"default"`. */
464
+ skin?: string;
465
+ slot: string;
466
+ /** The attachment's placeholder name inside that skin and slot. */
467
+ attachment: string;
468
+ keys: MotionDeformKey[];
469
+ }
470
+
333
471
  export interface MotionAnimation {
334
472
  /** Declared, then verified against the compiled result (rule 4). */
335
473
  duration: number;
@@ -337,6 +475,23 @@ export interface MotionAnimation {
337
475
  loop: boolean;
338
476
  note?: string;
339
477
  tracks: MotionTrack[];
478
+ /**
479
+ * IK constraint timelines, one entry per constraint.
480
+ *
481
+ * ⭐ Not a `track`, and the reason is the key rather than the target. A track's
482
+ * key is one `v` — an array, a name, or nothing — and these three families each
483
+ * carry a shape of their own: five named fields for IK, six for a transform
484
+ * constraint, and for a deform a sparse run whose meaning depends on the
485
+ * attachment. Folding them into `MotionKey` would make `v` mean four different
486
+ * things depending on `property`, and the type would stop documenting any of
487
+ * them. So they sit beside `tracks`, where 4.3 also writes them
488
+ * (`animations.<a>.ik`, `.transform`, `.attachments`).
489
+ */
490
+ ik?: MotionIkTrack[];
491
+ /** Transform constraint timelines, one entry per constraint. */
492
+ transform?: MotionTransformTrack[];
493
+ /** Deform timelines, one entry per skin/slot/attachment triple. */
494
+ deform?: MotionDeformTrack[];
340
495
  /**
341
496
  * The draw-order timeline. **One per animation, and it names no target** —
342
497
  * which is why it is not a `track`: 4.3 writes it as `animations.<a>.drawOrder`
@@ -541,7 +696,15 @@ export interface SpineSkeletonJson {
541
696
  {
542
697
  slots?: Record<string, Record<string, SpineTimelineKey[]>>;
543
698
  bones?: Record<string, Record<string, SpineTimelineKey[]>>;
699
+ /**
700
+ * `ik.<constraint> = keys[]` — the constraint IS the timeline, so there is
701
+ * no timeline name between the two. Same for `transform`.
702
+ */
703
+ ik?: Record<string, SpineTimelineKey[]>;
704
+ transform?: Record<string, SpineTimelineKey[]>;
544
705
  physics?: Record<string, Record<string, SpineTimelineKey[]>>;
706
+ /** `attachments.<skin>.<slot>.<attachment>.<timeline> = keys[]` — four deep. */
707
+ attachments?: Record<string, Record<string, Record<string, Record<string, SpineTimelineKey[]>>>>;
545
708
  /** Whole-animation timeline: no target name, one array per animation. */
546
709
  drawOrder?: SpineTimelineKey[];
547
710
  /** The other whole-animation timeline; same shape, same reason. */
package/src/validate.ts CHANGED
@@ -43,7 +43,7 @@ export interface Failure {
43
43
  *
44
44
  * ⭐ The distinction this draws is the difference between "wrong" and "not how we
45
45
  * do it here", and conflating the two is how a validator stops being usable on
46
- * anybody else's data. Fourteen of the 34 assertions are policy — seven for one
46
+ * anybody else's data. Fourteen of the 36 assertions are policy — seven for one
47
47
  * renderer (`spine-html`) and one project's canvas budget, seven for rigc's own
48
48
  * formations — and every one of them fires
49
49
  * on real, correct, editor-produced Spine data — the official example projects
@@ -133,6 +133,8 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
133
133
  A31_DRAW_ORDER_OFFSETS_RESOLVE: 'validity',
134
134
  A32_EVENT_KEYS_RESOLVE: 'validity',
135
135
  A33_VERTEX_ATTACHMENT_GEOMETRY: 'validity',
136
+ A34_CONSTRAINT_TIMELINE_TARGETS: 'validity',
137
+ A35_DEFORM_KEYS_FIT_THE_ATTACHMENT: 'validity',
136
138
  };
137
139
 
138
140
  export interface ValidateInput {
@@ -228,6 +230,38 @@ function isObj(v: unknown): v is Json {
228
230
  return typeof v === 'object' && v !== null && !Array.isArray(v);
229
231
  }
230
232
 
233
+ /**
234
+ * How long the array a deform key edits is, read off one raw attachment — or
235
+ * `null` when the file does not say.
236
+ *
237
+ * The rule is `readVertices`' own: the attachment's `vertices` is coordinates
238
+ * when its length equals `worldVerticesLength`, and a weight run otherwise. A
239
+ * deform array is therefore one `x, y` pair per **vertex** in the first case and
240
+ * one per **bone influence** (`vertices.length / 3`) in the second — the same
241
+ * count with two different meanings, which is exactly why this measures rather
242
+ * than assumes.
243
+ *
244
+ * `null` for the two shapes that cannot be measured from this object alone: a
245
+ * type with no vertices at all (nothing to deform, and the parser throws on it),
246
+ * and a `linkedmesh`, whose geometry belongs to another attachment.
247
+ */
248
+ function deformArrayLength(att: Json): number | null {
249
+ const type = typeof att.type === 'string' ? att.type : 'region';
250
+ let worldVerticesLength: number;
251
+ if (type === 'mesh') {
252
+ if (!Array.isArray(att.uvs)) return null;
253
+ worldVerticesLength = att.uvs.length;
254
+ } else if (type === 'boundingbox' || type === 'clipping' || type === 'path') {
255
+ if (typeof att.vertexCount !== 'number') return null;
256
+ worldVerticesLength = att.vertexCount * 2;
257
+ } else {
258
+ return null;
259
+ }
260
+ if (!Array.isArray(att.vertices)) return null;
261
+ const vertices = att.vertices as unknown[];
262
+ return vertices.length === worldVerticesLength ? worldVerticesLength : (vertices.length / 3) * 2;
263
+ }
264
+
231
265
  export function validate(input: ValidateInput): ValidateReport {
232
266
  const failures: Failure[] = [];
233
267
  const passed: string[] = [];
@@ -468,6 +502,200 @@ export function validate(input: ValidateInput): ValidateReport {
468
502
  if (!sawATimeline) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
469
503
  });
470
504
 
505
+ // --- A34: ik / transform timelines aim at a constraint of that type -------
506
+ //
507
+ // These two groups are one unnamed timeline per constraint
508
+ // (`animations.<a>.ik.<name>`), and the parser resolves the name AND the type:
509
+ // `findConstraint(name, IkConstraintData)` returns null for a transform
510
+ // constraint that happens to share the name, and `readAnimation` then throws
511
+ // `IK Constraint not found`. That one is loud — A00 reports it, as a parser
512
+ // message about a name with no context — so this assertion exists for the
513
+ // second failure, which is silent:
514
+ //
515
+ // **An empty key array.** `let keyMap = constraintMap[0]; if (!keyMap)
516
+ // continue;` — the group is read, the timeline is skipped, and nothing is
517
+ // said. `"ik": { "leg-ik": [] }` is a timeline that does not exist, written
518
+ // by a generator that thought it wrote one.
519
+ //
520
+ // Reporting both from here also means a candidate with a misspelled constraint
521
+ // gets told which constraints it does have, rather than being handed the
522
+ // loader's own sentence.
523
+ check('A34_CONSTRAINT_TIMELINE_TARGETS', () => {
524
+ if (!raw) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton JSON did not parse (A00 owns that failure)');
525
+ if (!isObj(raw.animations)) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton declares no animations');
526
+ const typeOf = new Map<string, string>();
527
+ for (const entry of Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []) {
528
+ if (isObj(entry) && typeof entry.name === 'string') typeOf.set(entry.name, String(entry.type));
529
+ }
530
+ let sawATimeline = false;
531
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
532
+ if (!isObj(anim)) continue;
533
+ for (const group of ['ik', 'transform'] as const) {
534
+ if (!isObj(anim[group])) continue;
535
+ for (const [name, keys] of Object.entries(anim[group] as Json)) {
536
+ sawATimeline = true;
537
+ const at = `animation "${animName}" ${group} timeline "${name}"`;
538
+ const declared = typeOf.get(name);
539
+ if (declared === undefined) {
540
+ const known = [...typeOf.entries()].filter(([, t]) => t === group).map(([n]) => n);
541
+ fail(
542
+ 'A34_CONSTRAINT_TIMELINE_TARGETS',
543
+ `${at}: the skeleton's constraints array has no "${name}"` +
544
+ (known.length ? ` (${group} constraints: ${known.join(', ')})` : `, and no ${group} constraint at all`),
545
+ );
546
+ continue;
547
+ }
548
+ if (declared !== group) {
549
+ fail(
550
+ 'A34_CONSTRAINT_TIMELINE_TARGETS',
551
+ `${at}: "${name}" is declared as a "${declared}" constraint, so the ${group} lookup misses it and the loader throws`,
552
+ );
553
+ continue;
554
+ }
555
+ if (!Array.isArray(keys) || keys.length === 0) {
556
+ fail(
557
+ 'A34_CONSTRAINT_TIMELINE_TARGETS',
558
+ `${at}: the key array is ${Array.isArray(keys) ? 'empty' : JSON.stringify(keys)}; the parser reads ` +
559
+ 'key 0, finds nothing and skips the whole timeline without a word',
560
+ );
561
+ }
562
+ }
563
+ }
564
+ }
565
+ if (!sawATimeline) {
566
+ return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'no animation carries an ik or transform constraint timeline');
567
+ }
568
+ });
569
+
570
+ // --- A35: a deform key's run lands inside the attachment it edits ---------
571
+ //
572
+ // 🚨 The nastiest silent failure in the animation half of this format. A deform
573
+ // key is a sparse edit of a vertex array whose length comes from the attachment,
574
+ // and the parser applies it with
575
+ //
576
+ // Utils.arrayCopy(verticesValue, 0, deform, start, verticesValue.length)
577
+ //
578
+ // into a `Float32Array`. Writing past the end of a typed array in JavaScript is
579
+ // a **no-op** — no throw, no warning, no NaN — so a run one pair too long, or a
580
+ // run aimed at an attachment with fewer vertices than the author thought, loses
581
+ // its tail and deforms the rest correctly. The result looks nearly right, which
582
+ // is worse than looking wrong.
583
+ //
584
+ // Two more shapes, both of them equally quiet:
585
+ //
586
+ // * an **odd `offset`** puts every x of the run on a y and every y on the
587
+ // next x — the array is pairs, and nothing in the format says so;
588
+ // * a **non-finite value** in `vertices` reaches `computeWorldVertices` and
589
+ // turns the vertex into NaN, which A10 would only catch if the deformed
590
+ // slot's BONE went non-finite, and it does not.
591
+ //
592
+ // The length depends on the encoding and the two are the same split
593
+ // `readVertices` makes: unweighted is one pair per vertex, weighted is one pair
594
+ // per bone influence (`vertices.length / 3 * 2`). Deriving it here rather than
595
+ // assuming either is the whole point — assuming would produce a confident,
596
+ // wrong bound on half the meshes in the world.
597
+ check('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', () => {
598
+ if (!raw) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton JSON did not parse (A00 owns that failure)');
599
+ if (!isObj(raw.animations)) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'the skeleton declares no animations');
600
+ /** skin name -> slot -> attachment, straight off the raw JSON. */
601
+ const skins = new Map<string, Json>();
602
+ for (const skin of Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
603
+ if (isObj(skin) && typeof skin.name === 'string' && isObj(skin.attachments)) {
604
+ skins.set(skin.name, skin.attachments as Json);
605
+ }
606
+ }
607
+ let sawATimeline = false;
608
+ let measured = 0;
609
+ for (const [animName, anim] of Object.entries(raw.animations as Json)) {
610
+ if (!isObj(anim) || !isObj(anim.attachments)) continue;
611
+ for (const [skinName, slotMap] of Object.entries(anim.attachments as Json)) {
612
+ if (!isObj(slotMap)) continue;
613
+ for (const [slotName, attMap] of Object.entries(slotMap)) {
614
+ if (!isObj(attMap)) continue;
615
+ for (const [attName, timelines] of Object.entries(attMap)) {
616
+ if (!isObj(timelines) || !Array.isArray(timelines.deform)) continue;
617
+ sawATimeline = true;
618
+ const at = `animation "${animName}" deform ${skinName}/${slotName}/${attName}`;
619
+ const attachment = isObj(skins.get(skinName)?.[slotName])
620
+ ? ((skins.get(skinName)![slotName] as Json)[attName] as unknown)
621
+ : undefined;
622
+ if (!isObj(attachment)) {
623
+ fail(
624
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
625
+ `${at}: skin "${skinName}" has no attachment "${attName}" on slot "${slotName}"; the parser throws ` +
626
+ '`Timeline attachment not found`',
627
+ );
628
+ continue;
629
+ }
630
+ const length = deformArrayLength(attachment);
631
+ if (length === null) {
632
+ // A linked mesh takes its geometry from another attachment, so the
633
+ // raw file cannot state this one's length. Saying nothing beats
634
+ // inventing a bound: an assertion with a default is how "nothing to
635
+ // measure" becomes a measurement of the wrong thing.
636
+ continue;
637
+ }
638
+ measured++;
639
+ const keys = timelines.deform as unknown[];
640
+ if (keys.length === 0) {
641
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at}: the key array is empty; the parser skips the timeline in silence`);
642
+ continue;
643
+ }
644
+ keys.forEach((key, k) => {
645
+ if (!isObj(key)) {
646
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: not an object`);
647
+ return;
648
+ }
649
+ const vertices = key.vertices;
650
+ if (vertices === undefined || vertices === null) return; // "back to setup" — nothing to fit
651
+ if (!Array.isArray(vertices)) {
652
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: vertices is ${JSON.stringify(vertices)}, not an array`);
653
+ return;
654
+ }
655
+ const offset = key.offset === undefined ? 0 : key.offset;
656
+ if (typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0) {
657
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: offset is ${JSON.stringify(key.offset)}`);
658
+ return;
659
+ }
660
+ if (offset % 2 !== 0) {
661
+ fail(
662
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
663
+ `${at} key ${k}: offset ${offset} is odd, so the run's x values land on y slots and back again`,
664
+ );
665
+ }
666
+ if (vertices.length % 2 !== 0) {
667
+ fail(
668
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
669
+ `${at} key ${k}: the run holds ${vertices.length} numbers and the deform array is x, y pairs`,
670
+ );
671
+ }
672
+ if (offset + vertices.length > length) {
673
+ fail(
674
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
675
+ `${at} key ${k}: the run covers ${offset}..${offset + vertices.length} of a ${length}-long deform ` +
676
+ 'array; everything past the end is copied into a Float32Array and dropped without a word',
677
+ );
678
+ }
679
+ for (const n of vertices as unknown[]) {
680
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
681
+ fail('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', `${at} key ${k}: the run holds a non-finite value ${JSON.stringify(n)}`);
682
+ break;
683
+ }
684
+ }
685
+ });
686
+ }
687
+ }
688
+ }
689
+ }
690
+ if (!sawATimeline) return skip('A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', 'no animation carries a deform timeline');
691
+ if (measured === 0) {
692
+ return skip(
693
+ 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT',
694
+ 'every deform timeline here keys an attachment whose vertex count the raw file does not state (a linked mesh)',
695
+ );
696
+ }
697
+ });
698
+
471
699
  // --- A: the round trip ----------------------------------------------------
472
700
  // The two loaded objects come back OUT of the assertion rather than being
473
701
  // assigned into it. Everything below reads them, and a `let` written inside a