spine-rigc 0.5.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/compile.ts CHANGED
@@ -21,6 +21,7 @@ import { basename, dirname, relative, resolve } from 'node:path';
21
21
  import { existsSync, readFileSync } from 'node:fs';
22
22
  import { readPngInfo } from './png.ts';
23
23
  import { CompileError, NotImplementedError } from './errors.ts';
24
+ import { parseJsonWithPosition } from './json-position.ts';
24
25
  import {
25
26
  parseRigSpec,
26
27
  type RigAttachment,
@@ -51,10 +52,13 @@ import type {
51
52
  EasingHandles,
52
53
  FaceManifest,
53
54
  FaceManifestPart,
55
+ MotionDeformTrack,
54
56
  MotionDrawOrderKey,
55
57
  MotionEventKey,
58
+ MotionIkTrack,
56
59
  MotionSpec,
57
60
  MotionTrack,
61
+ MotionTransformTrack,
58
62
  RigInfo,
59
63
  SpineAttachment,
60
64
  SpineBone,
@@ -205,6 +209,78 @@ const PHYSICS_TRACKS: Record<string, { fields: string[]; identity: number[] }> =
205
209
  reset: { fields: [], identity: [] },
206
210
  };
207
211
 
212
+ /**
213
+ * One numeric channel of a constraint timeline: the JSON field, the value the
214
+ * parser uses when a key omits it, and — for `mixY` alone — the field it takes
215
+ * that default FROM.
216
+ *
217
+ * Channel order is load-bearing twice over. `readCurve` indexes a curve array by
218
+ * channel (`curve[value << 2]`), so the order here is the order four-number
219
+ * groups concatenate in; and the parser reads the fields in this order, so
220
+ * emitting them in it keeps the file readable against an editor export.
221
+ */
222
+ interface ConstraintChannel {
223
+ field: string;
224
+ dflt: number;
225
+ /** `mixY` defaults to the SAME key's `mixX`, not to 1 (`:988`). */
226
+ inheritsFrom?: string;
227
+ }
228
+
229
+ /** A stepped-by-nature boolean on a constraint key. Never a curve channel. */
230
+ interface ConstraintFlag {
231
+ field: string;
232
+ dflt: boolean;
233
+ }
234
+
235
+ interface ConstraintTimelineShape {
236
+ channels: ConstraintChannel[];
237
+ flags: ConstraintFlag[];
238
+ /** Per-field bounds, where the runtime documents one. */
239
+ range: Record<string, [number, number]>;
240
+ }
241
+
242
+ /**
243
+ * The two constraint groups that are ONE unnamed timeline per constraint
244
+ * (`animations.<a>.ik.<name>`, `animations.<a>.transform.<name>`).
245
+ *
246
+ * ⚠️ Every field is optional in the file and every one has a per-key default, so
247
+ * omitting a field on one key of a track does not carry the previous key's value
248
+ * forward — it snaps to the default. `compileConstraintTrack` refuses a track
249
+ * whose keys disagree about which fields they name, because that is the shape
250
+ * that loads clean and plays something nobody wrote.
251
+ *
252
+ * The bounds: `IkConstraintPose.mix` is documented as a percentage 0-1 and
253
+ * `softness` as a distance, while every transform mix is documented **unbounded**
254
+ * — so only the IK pair carries a range, and refusing a transform mix above 1
255
+ * would refuse correct data (an over-mix is a real editor idiom).
256
+ */
257
+ const CONSTRAINT_TIMELINES: Record<'ik' | 'transform', ConstraintTimelineShape> = {
258
+ ik: {
259
+ channels: [
260
+ { field: 'mix', dflt: 1 },
261
+ { field: 'softness', dflt: 0 },
262
+ ],
263
+ flags: [
264
+ { field: 'bendPositive', dflt: true },
265
+ { field: 'compress', dflt: false },
266
+ { field: 'stretch', dflt: false },
267
+ ],
268
+ range: { mix: [0, 1], softness: [0, Infinity] },
269
+ },
270
+ transform: {
271
+ channels: [
272
+ { field: 'mixRotate', dflt: 1 },
273
+ { field: 'mixX', dflt: 1 },
274
+ { field: 'mixY', dflt: 1, inheritsFrom: 'mixX' },
275
+ { field: 'mixScaleX', dflt: 1 },
276
+ { field: 'mixScaleY', dflt: 1 },
277
+ { field: 'mixShearY', dflt: 1 },
278
+ ],
279
+ flags: [],
280
+ range: {},
281
+ },
282
+ };
283
+
208
284
  /** Physics constraint fields and their parser defaults (SkeletonJson.js:295-319). */
209
285
  const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
210
286
  const PHYSICS_PARAMS: Array<[string, number]> = [
@@ -255,8 +331,17 @@ export function bezierForChannel(
255
331
  // ---------------------------------------------------------------------------
256
332
 
257
333
  function readJson<T>(path: string): T {
334
+ let text: string;
258
335
  try {
259
- return JSON.parse(readFileSync(path, 'utf8')) as T;
336
+ text = readFileSync(path, 'utf8');
337
+ } catch (err) {
338
+ throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
339
+ }
340
+ try {
341
+ // A parse failure gets a line/column appended to the runtime's own
342
+ // message — see `parseJsonWithPosition` for why `JSON.parse` alone
343
+ // cannot say where.
344
+ return parseJsonWithPosition(text) as T;
260
345
  } catch (err) {
261
346
  throw new CompileError(`cannot read ${path}: ${(err as Error).message}`);
262
347
  }
@@ -319,6 +404,36 @@ export interface CompileOptions {
319
404
  imagesDir?: string;
320
405
  }
321
406
 
407
+ /**
408
+ * One part = one page. No packer, so no PMA trap, no rotation, no strip
409
+ * offsets. Region covers the page exactly => u2=v2=1.
410
+ *
411
+ * Two text-shape traps are load-bearing here:
412
+ * * a region name is the RAW line, not a trimmed one -> no indentation;
413
+ * * a blank line closes the page block -> none between header and regions.
414
+ *
415
+ * Exported (rather than inlined into `compile`) so `--copy-images` can call it a
416
+ * second time with `page` rewritten to the copies' filenames, after the copy
417
+ * itself has happened — see [`src/emit.ts`](emit.ts). Everything else about an
418
+ * image (`region`, `width`, `height`) is unchanged by that rewrite; only where the
419
+ * bytes live moved.
420
+ */
421
+ export function buildAtlasText(images: CompiledImage[]): string {
422
+ const atlasLines: string[] = [];
423
+ images.forEach((img, i) => {
424
+ if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
425
+ atlasLines.push(img.page);
426
+ atlasLines.push(`size: ${img.width}, ${img.height}`);
427
+ atlasLines.push('filter: Linear, Linear');
428
+ atlasLines.push('pma: false');
429
+ atlasLines.push(img.region);
430
+ atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
431
+ atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
432
+ atlasLines.push('rotate: 0');
433
+ });
434
+ return `${atlasLines.join('\n')}\n`;
435
+ }
436
+
322
437
  export function compile(opts: CompileOptions): CompileResult {
323
438
  const rigPath = resolve(opts.rigPath);
324
439
  const motionPath = resolve(opts.motionPath);
@@ -330,17 +445,38 @@ export function compile(opts: CompileOptions): CompileResult {
330
445
  const motion = readJson<MotionSpec>(motionPath);
331
446
  const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
332
447
 
333
- if (motion.spec !== 'rigc-motion/1') {
334
- throw new CompileError(`unknown motion spec version: ${String(motion.spec)}`);
335
- }
336
- // The motion spec was authored against one formation. Pairing it with another
337
- // rig would aim its keys at bones whose names happen to match and whose meaning
338
- // does not — the class of wrongness that loads, plays and lies.
339
- if (motion.archetype !== rig.name) {
340
- throw new CompileError(
341
- `motion spec names archetype "${motion.archetype}" but the rig spec at ${rigPath} is called "${rig.name}"`,
342
- );
343
- }
448
+ // The rig spec names its own path in every message `parseRigSpec` throws (its
449
+ // `where` argument, above). The motion spec gets no such treatment below —
450
+ // every "animation … bone … property" refusal is built from data alone — so a
451
+ // reader with two input files and one error has no way to tell which one is at
452
+ // fault. This wraps the motion-only regions (the version/archetype check right
453
+ // below, the physics table and the animation loop further down) and prefixes
454
+ // the motion path onto any `CompileError` that escapes them, unless the
455
+ // message already names it.
456
+ const withMotionSource = <T>(fn: () => T): T => {
457
+ try {
458
+ return fn();
459
+ } catch (err) {
460
+ if (err instanceof CompileError && !err.message.includes(motionPath)) {
461
+ throw new CompileError(`${motionPath}: ${err.message}`);
462
+ }
463
+ throw err;
464
+ }
465
+ };
466
+
467
+ withMotionSource(() => {
468
+ if (motion.spec !== 'rigc-motion/1') {
469
+ throw new CompileError(`unknown motion spec version: ${String(motion.spec)}`);
470
+ }
471
+ // The motion spec was authored against one formation. Pairing it with another
472
+ // rig would aim its keys at bones whose names happen to match and whose meaning
473
+ // does not — the class of wrongness that loads, plays and lies.
474
+ if (motion.archetype !== rig.name) {
475
+ throw new CompileError(
476
+ `motion spec names archetype "${motion.archetype}" but the rig spec at ${rigPath} is called "${rig.name}"`,
477
+ );
478
+ }
479
+ });
344
480
 
345
481
  // The stage. The rig may state it outright (a foreign skeleton has no crop);
346
482
  // otherwise the manifest's crop is it. With neither there is nothing to
@@ -546,25 +682,7 @@ export function compile(opts: CompileOptions): CompileResult {
546
682
  }
547
683
 
548
684
  // -- 2. atlas --------------------------------------------------------------
549
- // One part = one page. No packer, so no PMA trap, no rotation, no strip
550
- // offsets. Region covers the page exactly => u2=v2=1.
551
- //
552
- // Two text-shape traps are load-bearing here:
553
- // * a region name is the RAW line, not a trimmed one -> no indentation;
554
- // * a blank line closes the page block -> none between header and regions.
555
- const atlasLines: string[] = [];
556
- images.forEach((img, i) => {
557
- if (i > 0) atlasLines.push(''); // exactly one blank line BETWEEN pages
558
- atlasLines.push(img.page);
559
- atlasLines.push(`size: ${img.width}, ${img.height}`);
560
- atlasLines.push('filter: Linear, Linear');
561
- atlasLines.push('pma: false');
562
- atlasLines.push(img.region);
563
- atlasLines.push(`bounds: 0, 0, ${img.width}, ${img.height}`);
564
- atlasLines.push(`offsets: 0, 0, ${img.width}, ${img.height}`);
565
- atlasLines.push('rotate: 0');
566
- });
567
- const atlasText = `${atlasLines.join('\n')}\n`;
685
+ const atlasText = buildAtlasText(images);
568
686
 
569
687
  // -- 3. bones --------------------------------------------------------------
570
688
  //
@@ -724,132 +842,248 @@ export function compile(opts: CompileOptions): CompileResult {
724
842
  const constraints: SpineConstraint[] = [];
725
843
  const physicsReport: CompileResult['physics'] = [];
726
844
  const constraintNames = new Set<string>();
845
+ // `ik` and `transform` timelines resolve their target by name AND by type —
846
+ // `findConstraint(name, IkConstraintData)` returns null for a transform
847
+ // constraint of the same name and the parser then throws. Keeping the type
848
+ // beside the name is what lets the refusal say which of the two it is.
849
+ const constraintTypes = new Map<string, string>();
727
850
  for (const spec of rig.constraints ?? []) {
728
851
  constraints.push(buildRigConstraint(spec as RigConstraintInput, boneNames));
729
852
  constraintNames.add(spec.name);
853
+ constraintTypes.set(spec.name, spec.type);
730
854
  }
731
- for (const [name, spec] of Object.entries(motion.physics ?? {})) {
732
- if (constraintNames.has(name)) {
733
- throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
734
- }
735
- constraintNames.add(name);
736
- if (!boneNames.has(spec.bone)) {
737
- throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
738
- }
739
- const entry: SpineConstraint = {
740
- name,
741
- type: 'physics',
742
- bone: spec.bone,
743
- };
744
- const components: string[] = [];
745
- for (const comp of PHYSICS_COMPONENTS) {
746
- const v = spec[comp];
747
- if (v === undefined || v === 0) continue;
748
- entry[comp] = r6(v);
749
- components.push(comp);
750
- }
751
- if (!components.length) {
752
- // The parser is happy with this and the constraint does nothing at all.
753
- // A23 catches it too; refusing here means it never reaches the gate.
754
- throw new CompileError(
755
- `physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
756
- );
855
+ withMotionSource(() => {
856
+ for (const [name, spec] of Object.entries(motion.physics ?? {})) {
857
+ if (constraintNames.has(name)) {
858
+ throw new CompileError(`constraint "${name}" is declared in both the rig spec and the motion spec's physics table`);
859
+ }
860
+ constraintNames.add(name);
861
+ constraintTypes.set(name, 'physics');
862
+ if (!boneNames.has(spec.bone)) {
863
+ throw new CompileError(`physics constraint "${name}" targets unknown bone "${spec.bone}"`);
864
+ }
865
+ const entry: SpineConstraint = {
866
+ name,
867
+ type: 'physics',
868
+ bone: spec.bone,
869
+ };
870
+ const components: string[] = [];
871
+ for (const comp of PHYSICS_COMPONENTS) {
872
+ const v = spec[comp];
873
+ if (v === undefined || v === 0) continue;
874
+ entry[comp] = r6(v);
875
+ components.push(comp);
876
+ }
877
+ if (!components.length) {
878
+ // The parser is happy with this and the constraint does nothing at all.
879
+ // A23 catches it too; refusing here means it never reaches the gate.
880
+ throw new CompileError(
881
+ `physics constraint "${name}" drives no component — set at least one of ${PHYSICS_COMPONENTS.join('/')}`,
882
+ );
883
+ }
884
+ for (const [param, dflt] of PHYSICS_PARAMS) {
885
+ const v = spec[param as keyof typeof spec] as number | undefined;
886
+ if (v === undefined || v === dflt) continue;
887
+ entry[param] = r6(v);
888
+ }
889
+ constraints.push(entry);
890
+ physicsReport.push({
891
+ name,
892
+ bone: spec.bone,
893
+ components,
894
+ mix: spec.mix ?? 1,
895
+ drivesMesh: meshBones.has(spec.bone),
896
+ });
757
897
  }
758
- for (const [param, dflt] of PHYSICS_PARAMS) {
759
- const v = spec[param as keyof typeof spec] as number | undefined;
760
- if (v === undefined || v === dflt) continue;
761
- entry[param] = r6(v);
762
- }
763
- constraints.push(entry);
764
- physicsReport.push({
765
- name,
766
- bone: spec.bone,
767
- components,
768
- mix: spec.mix ?? 1,
769
- drivesMesh: meshBones.has(spec.bone),
770
- });
771
- }
898
+ });
772
899
 
773
900
  // -- 5. animations ---------------------------------------------------------
774
901
  const animations: SpineSkeletonJson['animations'] = {};
775
902
  const declaredDurations: Record<string, number> = {};
776
903
  const slotNames = new Set(slots.map((s) => s.name));
777
904
 
778
- for (const [animName, anim] of Object.entries(motion.animations)) {
779
- declaredDurations[animName] = anim.duration;
780
- const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
781
- const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
782
- const physicsTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
783
- const claimed = new Set<string>();
784
- let compiledDuration = 0;
785
-
786
- for (const track of anim.tracks) {
787
- const isPhysicsTrack = track.property in PHYSICS_TRACKS;
788
- const isBoneTrack = !isPhysicsTrack && track.property in BONE_TRACKS;
789
- const targets = resolveTargets(track, motion, animName);
790
- targets.forEach((target, index) => {
791
- if (isPhysicsTrack) {
792
- if (!constraintNames.has(target)) {
793
- throw new CompileError(`animation "${animName}" keys unknown physics constraint "${target}"`);
905
+ withMotionSource(() => {
906
+ for (const [animName, anim] of Object.entries(motion.animations)) {
907
+ declaredDurations[animName] = anim.duration;
908
+ const slotTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
909
+ const boneTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
910
+ const physicsTimelines: Record<string, Record<string, SpineTimelineKey[]>> = {};
911
+ const claimed = new Set<string>();
912
+ let compiledDuration = 0;
913
+
914
+ for (const track of anim.tracks) {
915
+ const isPhysicsTrack = track.property in PHYSICS_TRACKS;
916
+ const isBoneTrack = !isPhysicsTrack && track.property in BONE_TRACKS;
917
+ const targets = resolveTargets(track, motion, animName);
918
+ targets.forEach((target, index) => {
919
+ if (isPhysicsTrack) {
920
+ if (!constraintNames.has(target)) {
921
+ throw new CompileError(`animation "${animName}" keys unknown physics constraint "${target}"`);
922
+ }
923
+ } else if (isBoneTrack) {
924
+ if (!boneNames.has(target)) {
925
+ throw new CompileError(`animation "${animName}" keys unknown bone "${target}"`);
926
+ }
927
+ } else if (!slotNames.has(target)) {
928
+ throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
929
+ }
930
+ const claim = `${target}.${track.property}`;
931
+ if (claimed.has(claim)) {
932
+ throw new CompileError(
933
+ `animation "${animName}" has two tracks on ${claim}; merge them into one track`,
934
+ );
935
+ }
936
+ claimed.add(claim);
937
+
938
+ const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
939
+ const keys = isPhysicsTrack
940
+ ? compileValueTrack(track, motion, animName, anim.duration, target, shift, PHYSICS_TRACKS, 'physics constraint')
941
+ : isBoneTrack
942
+ ? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
943
+ : compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
944
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
945
+ if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
946
+ else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
947
+ else (slotTimelines[target] ??= {})[track.property] = keys;
948
+ });
949
+ }
950
+
951
+ // -- constraint timelines: one unnamed timeline per constraint ---------
952
+ //
953
+ // `ik` and `transform` sit beside `tracks` rather than in it because their
954
+ // keys carry named fields instead of one `v` — see `MotionAnimation.ik`.
955
+ // The target is resolved by name AND by type: `findConstraint(name,
956
+ // IkConstraintData)` misses a transform constraint of the same name and the
957
+ // parser throws in the consumer's process, so the mismatch is named here.
958
+ const constraintTimelines: Record<'ik' | 'transform', Record<string, SpineTimelineKey[]>> = {
959
+ ik: {},
960
+ transform: {},
961
+ };
962
+ for (const group of ['ik', 'transform'] as const) {
963
+ const tracks: Array<MotionIkTrack | MotionTransformTrack> = anim[group] ?? [];
964
+ if (!Array.isArray(tracks)) {
965
+ throw new CompileError(`animation "${animName}": "${group}" must be an array of { constraint, keys } entries`);
966
+ }
967
+ for (const track of tracks) {
968
+ const name = track.constraint;
969
+ if (typeof name !== 'string' || name.length === 0) {
970
+ throw new CompileError(`animation "${animName}": a ${group} timeline needs a "constraint" name`);
794
971
  }
795
- } else if (isBoneTrack) {
796
- if (!boneNames.has(target)) {
797
- throw new CompileError(`animation "${animName}" keys unknown bone "${target}"`);
972
+ const type = constraintTypes.get(name);
973
+ if (type === undefined) {
974
+ const known = [...constraintTypes.entries()].filter(([, t]) => t === group).map(([n]) => n);
975
+ throw new CompileError(
976
+ `animation "${animName}" keys unknown ${group} constraint "${name}"; ` +
977
+ (known.length
978
+ ? `the rig declares ${group} constraint(s): ${known.join(', ')}`
979
+ : `the rig declares no ${group} constraint at all`),
980
+ );
798
981
  }
799
- } else if (!slotNames.has(target)) {
800
- throw new CompileError(`animation "${animName}" targets unknown slot "${target}"`);
982
+ if (type !== group) {
983
+ throw new CompileError(
984
+ `animation "${animName}" keys "${name}" as ${group === 'ik' ? 'an' : 'a'} ${group} constraint, but the rig declares it as a ` +
985
+ `"${type}" constraint — the parser looks a timeline's target up by name AND type, misses, and throws`,
986
+ );
987
+ }
988
+ if (constraintTimelines[group][name]) {
989
+ throw new CompileError(
990
+ `animation "${animName}" has two ${group} timelines on constraint "${name}"; ` +
991
+ 'the group holds one timeline per constraint, so merge them into one',
992
+ );
993
+ }
994
+ const keys = compileConstraintTrack(group, track, motion, animName, anim.duration);
995
+ for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
996
+ constraintTimelines[group][name] = keys;
801
997
  }
802
- const claim = `${target}.${track.property}`;
803
- if (claimed.has(claim)) {
998
+ }
999
+
1000
+ // -- deform timelines: keyed on a skin/slot/attachment triple ----------
1001
+ // Four deep, because the format is: skin -> slot -> attachment -> timeline
1002
+ // name -> keys. `deform` is one of two timeline names an attachment can
1003
+ // carry (the other is `sequence`), which is why the level exists at all.
1004
+ const deformTimelines: Record<string, Record<string, Record<string, Record<string, SpineTimelineKey[]>>>> = {};
1005
+ const deformTracks: MotionDeformTrack[] = anim.deform ?? [];
1006
+ if (!Array.isArray(deformTracks)) {
1007
+ throw new CompileError(
1008
+ `animation "${animName}": "deform" must be an array of { slot, attachment, keys } entries`,
1009
+ );
1010
+ }
1011
+ for (const track of deformTracks) {
1012
+ const skinName = track.skin ?? 'default';
1013
+ const at = `animation "${animName}" deform ${skinName}/${String(track.slot)}/${String(track.attachment)}`;
1014
+ const table = skinTables.get(skinName);
1015
+ if (!table) {
804
1016
  throw new CompileError(
805
- `animation "${animName}" has two tracks on ${claim}; merge them into one track`,
1017
+ `${at}: this rig emits no skin called "${skinName}" (it emits: ${[...skinTables.keys()].join(', ')})`,
806
1018
  );
807
1019
  }
808
- claimed.add(claim);
809
-
810
- const shift = (track.lag ?? 0) + (track.stagger ?? 0) * index;
811
- const keys = isPhysicsTrack
812
- ? compileValueTrack(track, motion, animName, anim.duration, target, shift, PHYSICS_TRACKS, 'physics constraint')
813
- : isBoneTrack
814
- ? compileValueTrack(track, motion, animName, anim.duration, target, shift, BONE_TRACKS, 'bone')
815
- : compileTrack(track, motion, animName, anim.duration, target, shift, tableFor('default'));
1020
+ const perSlot = table[track.slot];
1021
+ if (!perSlot) {
1022
+ throw new CompileError(
1023
+ `${at}: skin "${skinName}" gives slot "${String(track.slot)}" no attachments` +
1024
+ (slotNames.has(track.slot) ? '' : ', and this rig does not declare that slot at all'),
1025
+ );
1026
+ }
1027
+ const attachment = perSlot[track.attachment];
1028
+ if (!attachment) {
1029
+ throw new CompileError(
1030
+ `${at}: slot "${track.slot}" in skin "${skinName}" has no attachment "${String(track.attachment)}" ` +
1031
+ `(it has: ${Object.keys(perSlot).join(', ')})`,
1032
+ );
1033
+ }
1034
+ if (deformTimelines[skinName]?.[track.slot]?.[track.attachment]) {
1035
+ throw new CompileError(`${at}: two deform timelines on one attachment; merge them into one`);
1036
+ }
1037
+ const keys = compileDeformTrack(
1038
+ track,
1039
+ motion,
1040
+ animName,
1041
+ anim.duration,
1042
+ deformGeometryOf(attachment, at),
1043
+ );
816
1044
  for (const key of keys) compiledDuration = Math.max(compiledDuration, key.time as number);
817
- if (isPhysicsTrack) (physicsTimelines[target] ??= {})[track.property] = keys;
818
- else if (isBoneTrack) (boneTimelines[target] ??= {})[track.property] = keys;
819
- else (slotTimelines[target] ??= {})[track.property] = keys;
820
- });
821
- }
1045
+ ((deformTimelines[skinName] ??= {})[track.slot] ??= {})[track.attachment] = { deform: keys };
1046
+ }
822
1047
 
823
- const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
824
- if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
825
-
826
- const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
827
- // An event timeline counts towards the animation's length the same as any
828
- // other: `readAnimation` takes the duration from the longest timeline it
829
- // built, and `EventTimeline.getDuration()` is its last frame like the rest.
830
- if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
831
-
832
- // Rule 4: the declared duration is verified, because skeleton JSON does not
833
- // carry one — the loader takes the max key time.
834
- //
835
- // This arm is about the DECLARED DURATION being wrong, so it compares one
836
- // number per animation and tolerates a frame of it. The other arm —
837
- // `checkKeyTime`, above, per key — is about a key landing past the end, and
838
- // a frame is 16,667 times too coarse to see one. Both are needed: this one
839
- // catches an animation that stops a second early, and only that one catches
840
- // a key on a track whose neighbour already sits on the declared duration.
841
- if (Math.abs(compiledDuration - anim.duration) > FRAME) {
842
- throw new CompileError(
843
- `animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
844
- );
1048
+ const drawOrder = anim.drawOrder ? compileDrawOrder(anim.drawOrder, animName, anim.duration, slots) : null;
1049
+ if (drawOrder) for (const key of drawOrder) compiledDuration = Math.max(compiledDuration, key.time as number);
1050
+
1051
+ const eventKeys = anim.events ? compileEvents(anim.events, animName, anim.duration, rig.events ?? {}) : null;
1052
+ // An event timeline counts towards the animation's length the same as any
1053
+ // other: `readAnimation` takes the duration from the longest timeline it
1054
+ // built, and `EventTimeline.getDuration()` is its last frame like the rest.
1055
+ if (eventKeys) for (const key of eventKeys) compiledDuration = Math.max(compiledDuration, key.time as number);
1056
+
1057
+ // Rule 4: the declared duration is verified, because skeleton JSON does not
1058
+ // carry one — the loader takes the max key time.
1059
+ //
1060
+ // This arm is about the DECLARED DURATION being wrong, so it compares one
1061
+ // number per animation and tolerates a frame of it. The other arm —
1062
+ // `checkKeyTime`, above, per key — is about a key landing past the end, and
1063
+ // a frame is 16,667 times too coarse to see one. Both are needed: this one
1064
+ // catches an animation that stops a second early, and only that one catches
1065
+ // a key on a track whose neighbour already sits on the declared duration.
1066
+ if (Math.abs(compiledDuration - anim.duration) > FRAME) {
1067
+ throw new CompileError(
1068
+ `animation "${animName}" declares duration ${anim.duration}s but its last key is at ${compiledDuration}s`,
1069
+ );
1070
+ }
1071
+ // Group order is `readAnimation`'s own reading order, so an emitted file
1072
+ // diffs cleanly against an editor export. Each line is conditional, which
1073
+ // is what keeps a spec that uses none of the new groups byte-identical.
1074
+ animations[animName] = {};
1075
+ if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
1076
+ if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
1077
+ if (Object.keys(constraintTimelines.ik).length) animations[animName].ik = constraintTimelines.ik;
1078
+ if (Object.keys(constraintTimelines.transform).length) {
1079
+ animations[animName].transform = constraintTimelines.transform;
1080
+ }
1081
+ if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
1082
+ if (Object.keys(deformTimelines).length) animations[animName].attachments = deformTimelines;
1083
+ if (drawOrder) animations[animName].drawOrder = drawOrder;
1084
+ if (eventKeys) animations[animName].events = eventKeys;
845
1085
  }
846
- animations[animName] = {};
847
- if (Object.keys(slotTimelines).length) animations[animName].slots = slotTimelines;
848
- if (Object.keys(boneTimelines).length) animations[animName].bones = boneTimelines;
849
- if (Object.keys(physicsTimelines).length) animations[animName].physics = physicsTimelines;
850
- if (drawOrder) animations[animName].drawOrder = drawOrder;
851
- if (eventKeys) animations[animName].events = eventKeys;
852
- }
1086
+ });
853
1087
 
854
1088
  // -- 6. assemble -----------------------------------------------------------
855
1089
  const header: SpineSkeletonJson['skeleton'] = {
@@ -2063,6 +2297,431 @@ function compileEvents(
2063
2297
  return out;
2064
2298
  }
2065
2299
 
2300
+ /**
2301
+ * An IK or transform constraint keyed over time — `animations.<a>.<group>.<name>`.
2302
+ *
2303
+ * One function for both because the two differ only in their field table: the
2304
+ * group is one unnamed timeline per constraint, every field is optional with a
2305
+ * per-key default, and the curve concatenates four numbers per channel in field
2306
+ * order. `CONSTRAINT_TIMELINES` holds what differs.
2307
+ *
2308
+ * 🚨 The refusal that is not obvious is the **uniform field set**. In this format
2309
+ * a key does not inherit anything from the key before it: `getValue(keyMap,
2310
+ * "softness", 0)` is read fresh per key, so a track written as
2311
+ *
2312
+ * ```
2313
+ * { "t": 0, "mix": 1, "softness": 20 }, { "t": 1, "mix": 0 }
2314
+ * ```
2315
+ *
2316
+ * does not hold softness at 20 and fade the mix out — it snaps softness to 0 at
2317
+ * t=1 and interpolates from 20 down to 0 on the way, which is a thing the author
2318
+ * did not write and cannot see. It loads, it plays, and it is wrong. So every key
2319
+ * of a track has to name the same fields; stating the default explicitly is the
2320
+ * way to opt in.
2321
+ *
2322
+ * ⚠️ The values a curve is built between are the **effective** ones — the
2323
+ * author's number where there is one, the parser's default where there is not.
2324
+ * That is reading the format, not inventing a value: it is exactly what the
2325
+ * runtime will interpolate, and a bezier built against anything else would
2326
+ * describe a curve the player does not play.
2327
+ */
2328
+ function compileConstraintTrack(
2329
+ group: 'ik' | 'transform',
2330
+ track: MotionIkTrack | MotionTransformTrack,
2331
+ motion: MotionSpec,
2332
+ animName: string,
2333
+ duration: number,
2334
+ ): SpineTimelineKey[] {
2335
+ const shape = CONSTRAINT_TIMELINES[group];
2336
+ const article = group === 'ik' ? 'an' : 'a';
2337
+ const where = `animation "${animName}" ${group} constraint "${track.constraint}"`;
2338
+ const keys = track.keys;
2339
+ if (!Array.isArray(keys) || keys.length === 0) throw new CompileError(`${where}: no keys`);
2340
+
2341
+ const read = (key: MotionIkTrack['keys'][number] | MotionTransformTrack['keys'][number], field: string): unknown =>
2342
+ (key as unknown as Record<string, unknown>)[field];
2343
+ const named = (key: MotionIkTrack['keys'][number] | MotionTransformTrack['keys'][number]): string[] =>
2344
+ [...shape.channels, ...shape.flags].map((c) => c.field).filter((field) => read(key, field) !== undefined);
2345
+
2346
+ // The uniform-field-set rule, checked against key 0 so the message can name the
2347
+ // key that differs rather than "some key".
2348
+ const first = named(keys[0]);
2349
+ const firstSet = new Set(first);
2350
+ keys.forEach((key, i) => {
2351
+ if (i === 0) return;
2352
+ const here = named(key);
2353
+ for (const field of here) {
2354
+ if (firstSet.has(field)) continue;
2355
+ throw new CompileError(
2356
+ `${where}: key ${i} (t=${key.t}) names "${field}" and key 0 does not. Every key of ${article} ${group} timeline ` +
2357
+ 'is read with its own default, so a field stated on some keys and not others snaps to the default on ' +
2358
+ 'the rest — state it on every key or on none.',
2359
+ );
2360
+ }
2361
+ for (const field of first) {
2362
+ if (here.includes(field)) continue;
2363
+ throw new CompileError(
2364
+ `${where}: key 0 names "${field}" and key ${i} (t=${key.t}) does not. Every key of ${article} ${group} timeline ` +
2365
+ `is read with its own default, so "${field}" would snap to ` +
2366
+ `${JSON.stringify(defaultOf(shape, field))} at t=${key.t} — state it on every key or on none.`,
2367
+ );
2368
+ }
2369
+ });
2370
+
2371
+ /** The value the runtime will see for `field` on this key: authored, or default. */
2372
+ const effective = (key: MotionIkTrack['keys'][number] | MotionTransformTrack['keys'][number], channel: ConstraintChannel): number => {
2373
+ const v = read(key, channel.field);
2374
+ if (v !== undefined) return v as number;
2375
+ if (channel.inheritsFrom === undefined) return channel.dflt;
2376
+ const inherited = read(key, channel.inheritsFrom);
2377
+ return inherited === undefined ? channel.dflt : (inherited as number);
2378
+ };
2379
+
2380
+ const out: SpineTimelineKey[] = [];
2381
+ for (let i = 0; i < keys.length; i++) {
2382
+ const key = keys[i];
2383
+ const next = keys[i + 1];
2384
+ if (!Number.isFinite(key.t)) throw new CompileError(`${where}: key ${i} has a non-finite time ${String(key.t)}`);
2385
+ const time = keyTime(key.t);
2386
+ if (i > 0 && time <= (out[i - 1].time as number)) {
2387
+ throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
2388
+ }
2389
+ checkKeyTime(where, time, key.t, duration);
2390
+
2391
+ const entry: SpineTimelineKey = { time };
2392
+ for (const channel of shape.channels) {
2393
+ const v = read(key, channel.field);
2394
+ if (v === undefined) continue;
2395
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
2396
+ throw new CompileError(`${where} (t=${key.t}): ${channel.field} is ${JSON.stringify(v)}, not a finite number`);
2397
+ }
2398
+ const bounds = shape.range[channel.field];
2399
+ if (bounds && (v < bounds[0] || v > bounds[1])) {
2400
+ throw new CompileError(
2401
+ `${where} (t=${key.t}): ${channel.field} is ${v}, outside ${bounds[0]}..${
2402
+ bounds[1] === Infinity ? '∞' : bounds[1]
2403
+ } — the runtime documents it as ${channel.field === 'mix' ? 'a percentage 0-1' : 'a distance'}`,
2404
+ );
2405
+ }
2406
+ entry[channel.field] = r6(v);
2407
+ }
2408
+ for (const flag of shape.flags) {
2409
+ const v = read(key, flag.field);
2410
+ if (v === undefined) continue;
2411
+ if (typeof v !== 'boolean') {
2412
+ throw new CompileError(`${where} (t=${key.t}): ${flag.field} is ${JSON.stringify(v)}, not true or false`);
2413
+ }
2414
+ entry[flag.field] = v;
2415
+ }
2416
+
2417
+ if (key.ease !== undefined && key.curve !== undefined) {
2418
+ throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
2419
+ }
2420
+ if (key.curve !== undefined) {
2421
+ if (!next) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
2422
+ entry.curve = rawCurve(key.curve, shape.channels.length, where, String(key.t));
2423
+ } else if (key.ease !== undefined && next) {
2424
+ if (key.ease === 'stepped') {
2425
+ entry.curve = 'stepped';
2426
+ } else {
2427
+ const handles = motion.easings?.[key.ease];
2428
+ if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
2429
+ const t2 = keyTime(next.t);
2430
+ const curve: number[] = [];
2431
+ for (const channel of shape.channels) {
2432
+ curve.push(...bezierForChannel(handles, time, t2, effective(key, channel), effective(next, channel)));
2433
+ }
2434
+ entry.curve = curve;
2435
+ }
2436
+ } else if (key.ease !== undefined && !next) {
2437
+ throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);
2438
+ }
2439
+ out.push(entry);
2440
+ }
2441
+ return out;
2442
+ }
2443
+
2444
+ /** The parser default for one field of a constraint timeline, for a message. */
2445
+ function defaultOf(shape: ConstraintTimelineShape, field: string): number | boolean {
2446
+ const channel = shape.channels.find((c) => c.field === field);
2447
+ if (channel) return channel.dflt;
2448
+ return shape.flags.find((f) => f.field === field)?.dflt ?? 0;
2449
+ }
2450
+
2451
+ /**
2452
+ * What a deform key is editing: the array the parser builds for one attachment.
2453
+ *
2454
+ * The two encodings are the reason this is derived rather than assumed, and they
2455
+ * are the same split `readVertices` makes when it decides whether a `vertices`
2456
+ * array is coordinates or a weight run:
2457
+ *
2458
+ * unweighted — `deformLength = vertices.length`, one `x, y` pair per vertex;
2459
+ * weighted — `deformLength = vertices.length / 3 * 2`, one pair per bone
2460
+ * INFLUENCE, because the loaded `vertices` is `x, y, weight` per
2461
+ * influence.
2462
+ *
2463
+ * Same shape, two meanings, and picking the wrong one writes a run that silently
2464
+ * lands on the wrong vertices.
2465
+ */
2466
+ interface DeformGeometry {
2467
+ weighted: boolean;
2468
+ /** How long the array the key edits is. */
2469
+ deformLength: number;
2470
+ vertexCount: number;
2471
+ /** Bone influences per vertex, in vertex order. Null on an unweighted attachment. */
2472
+ boneCounts: number[] | null;
2473
+ }
2474
+
2475
+ /**
2476
+ * Measure one emitted attachment's deform array.
2477
+ *
2478
+ * A region attachment is refused rather than measured: it has no `vertices` at
2479
+ * all, so `attachment.vertices.length` throws inside the parser — one of the very
2480
+ * few places this format fails loudly, and it fails in the consumer's process.
2481
+ */
2482
+ function deformGeometryOf(att: SpineAttachment, where: string): DeformGeometry {
2483
+ const type = (att as { type?: string }).type ?? 'region';
2484
+ let worldVerticesLength: number;
2485
+ if (type === 'mesh') {
2486
+ worldVerticesLength = (att as SpineMeshAttachment).uvs.length;
2487
+ } else if (type === 'boundingbox' || type === 'clipping') {
2488
+ worldVerticesLength = (att as SpineBoundingBoxAttachment).vertexCount * 2;
2489
+ } else {
2490
+ throw new CompileError(
2491
+ `${where}: a deform timeline keys the vertices of an attachment, and this one is a "${type}" — ` +
2492
+ 'it has no vertex array to deform. Deformable types: mesh, boundingbox, clipping.',
2493
+ );
2494
+ }
2495
+ const vertices = (att as SpineMeshAttachment).vertices ?? [];
2496
+ const weighted = vertices.length !== worldVerticesLength;
2497
+ if (!weighted) {
2498
+ return { weighted, deformLength: worldVerticesLength, vertexCount: worldVerticesLength / 2, boneCounts: null };
2499
+ }
2500
+ // Walk the weight run for the per-vertex influence counts. The run's own shape
2501
+ // is already assured by the attachment builders and by A33/A04; this only
2502
+ // counts, and a malformed run stops rather than producing a plausible number.
2503
+ const boneCounts: number[] = [];
2504
+ for (let i = 0; i < vertices.length; ) {
2505
+ const n = vertices[i++];
2506
+ if (!Number.isInteger(n) || n < 1) {
2507
+ throw new CompileError(`${where}: the attachment's weighted vertex run has a bone count of ${String(n)} at index ${i - 1}`);
2508
+ }
2509
+ i += n * 4;
2510
+ if (i > vertices.length) {
2511
+ throw new CompileError(`${where}: the attachment's weighted vertex run is truncated at vertex ${boneCounts.length}`);
2512
+ }
2513
+ boneCounts.push(n);
2514
+ }
2515
+ const influences = vertices.length / 3;
2516
+ return { weighted, deformLength: influences * 2, vertexCount: boneCounts.length, boneCounts };
2517
+ }
2518
+
2519
+ /**
2520
+ * One attachment's geometry keyed over time —
2521
+ * `animations.<a>.attachments.<skin>.<slot>.<attachment>.deform`.
2522
+ *
2523
+ * ⭐ Every refusal below is a silent failure of the parser's own deform branch,
2524
+ * and the first is the one that matters most:
2525
+ *
2526
+ * 1. **A run that does not fit.** The parser copies with
2527
+ * `Utils.arrayCopy(vertices, 0, deform, start, vertices.length)` into a
2528
+ * `Float32Array` sized from the attachment. Writing past the end of a typed
2529
+ * array is a **no-op in JavaScript** — no throw, no warning — so a run one
2530
+ * pair too long, or aimed at the wrong attachment, loses its tail and
2531
+ * deforms part of the mesh correctly. That is the worst possible failure
2532
+ * shape: it looks almost right.
2533
+ * 2. **An odd `offset`, or an odd run length.** The array is `x, y` pairs; an
2534
+ * odd index puts every x of the run on a y and vice versa. It loads.
2535
+ * 3. **`fromVertex` where a vertex is not one pair.** See below.
2536
+ * 4. **A key that carries both a run and no room for one**, or a non-finite
2537
+ * offset — a NaN in the deform array propagates into world vertices.
2538
+ *
2539
+ * `fromVertex` is rigc's own field and the reason it exists is issue #89's
2540
+ * observation: a deform key is the only key in the format whose meaning depends
2541
+ * on the attachment it is attached to, and an author reasons in vertices while
2542
+ * the array is indexed in influences. On an **unweighted** attachment the two
2543
+ * coincide, so the translation is exact. On a **weighted** one it is exact only
2544
+ * where each vertex the run covers has exactly ONE bone on it; with two bones a
2545
+ * vertex occupies two pairs and "move vertex 3 by (dx, dy)" is not a statement
2546
+ * the array can hold — the world offset would be
2547
+ * `Σ weightᵦ · Mᵦ · (dx, dy)`, which equals `(dx, dy)` only if every influencing
2548
+ * bone happens to share one world matrix. So that case is refused by name and
2549
+ * `offset` stays available for an author who really is writing bind-space
2550
+ * offsets per influence.
2551
+ */
2552
+ function compileDeformTrack(
2553
+ track: MotionDeformTrack,
2554
+ motion: MotionSpec,
2555
+ animName: string,
2556
+ duration: number,
2557
+ geometry: DeformGeometry,
2558
+ ): SpineTimelineKey[] {
2559
+ const skin = track.skin ?? 'default';
2560
+ const where = `animation "${animName}" deform ${skin}/${track.slot}/${track.attachment}`;
2561
+ const keys = track.keys;
2562
+ if (!Array.isArray(keys) || keys.length === 0) throw new CompileError(`${where}: no keys`);
2563
+
2564
+ const out: SpineTimelineKey[] = [];
2565
+ for (let i = 0; i < keys.length; i++) {
2566
+ const key = keys[i];
2567
+ if (!Number.isFinite(key.t)) throw new CompileError(`${where}: key ${i} has a non-finite time ${String(key.t)}`);
2568
+ const time = keyTime(key.t);
2569
+ if (i > 0 && time <= (out[i - 1].time as number)) {
2570
+ throw new CompileError(`${where}: key times must strictly increase (at t=${key.t})`);
2571
+ }
2572
+ checkKeyTime(where, time, key.t, duration);
2573
+ if (key.offset !== undefined && key.fromVertex !== undefined) {
2574
+ throw new CompileError(
2575
+ `${where} (t=${key.t}): a key gives its start as "offset" (an index into the deform array) or as ` +
2576
+ '"fromVertex" (a vertex index rigc translates), never both',
2577
+ );
2578
+ }
2579
+ const run = key.vertices ?? null;
2580
+ if (run === null) {
2581
+ // The parser's own encoding for "no edit": with no `vertices` the deform is
2582
+ // the setup pose. `offset`/`fromVertex` would be pointing into nothing, and
2583
+ // two spellings of one key must not emit two different files.
2584
+ if (key.offset !== undefined || key.fromVertex !== undefined) {
2585
+ throw new CompileError(
2586
+ `${where} (t=${key.t}): the key has no "vertices", which is the format's way of saying "back to the ` +
2587
+ 'setup pose" — so there is nothing for a start index to point at. Drop the offset, or give it a run.',
2588
+ );
2589
+ }
2590
+ out.push(deformKeyCurve({ time }, key, keys, i, motion, where));
2591
+ continue;
2592
+ }
2593
+ if (!Array.isArray(run) || run.length === 0) {
2594
+ throw new CompileError(
2595
+ `${where} (t=${key.t}): "vertices" is ${JSON.stringify(key.vertices)}; give an array of x, y offsets, ` +
2596
+ 'or omit it entirely for "back to the setup pose"',
2597
+ );
2598
+ }
2599
+ if (run.length % 2 !== 0) {
2600
+ throw new CompileError(
2601
+ `${where} (t=${key.t}): "vertices" holds ${run.length} numbers; the deform array is x, y PAIRS, so a run has an even length`,
2602
+ );
2603
+ }
2604
+ for (const n of run) {
2605
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
2606
+ throw new CompileError(`${where} (t=${key.t}): "vertices" holds a non-finite value ${JSON.stringify(n)}`);
2607
+ }
2608
+ }
2609
+ const start = deformStart(key, run.length, geometry, where);
2610
+ if (start + run.length > geometry.deformLength) {
2611
+ throw new CompileError(
2612
+ `${where} (t=${key.t}): the run starts at deform index ${start} and is ${run.length} long, which ends at ` +
2613
+ `${start + run.length}; this attachment's deform array is ${geometry.deformLength} long ` +
2614
+ `(${geometry.weighted ? `${geometry.deformLength / 2} bone influences` : `${geometry.vertexCount} vertices`}). ` +
2615
+ 'The parser copies into a Float32Array, so everything past the end is dropped without a word.',
2616
+ );
2617
+ }
2618
+ const entry: SpineTimelineKey = { time };
2619
+ // `offset` defaults to 0 in the parser and the editor omits it there, so an
2620
+ // authored 0, an authored `fromVertex: 0` and an absent start all emit the
2621
+ // same bytes — one meaning, one file.
2622
+ if (start !== 0) entry.offset = start;
2623
+ entry.vertices = run.map(r6);
2624
+ out.push(deformKeyCurve(entry, key, keys, i, motion, where));
2625
+ }
2626
+ return out;
2627
+ }
2628
+
2629
+ /**
2630
+ * Where in the deform array this key's run begins.
2631
+ *
2632
+ * `offset` is that index outright. `fromVertex` is a vertex index, and turning
2633
+ * one into the other is exact only where a vertex occupies exactly one pair —
2634
+ * which is every vertex of an unweighted attachment and only the single-bone
2635
+ * vertices of a weighted one.
2636
+ */
2637
+ function deformStart(
2638
+ key: MotionDeformTrack['keys'][number],
2639
+ runLength: number,
2640
+ geometry: DeformGeometry,
2641
+ where: string,
2642
+ ): number {
2643
+ if (key.offset !== undefined) {
2644
+ if (!Number.isInteger(key.offset) || key.offset < 0) {
2645
+ throw new CompileError(
2646
+ `${where} (t=${key.t}): offset is ${JSON.stringify(key.offset)}; it is an index into the deform array, so a whole number ≥ 0`,
2647
+ );
2648
+ }
2649
+ if (key.offset % 2 !== 0) {
2650
+ throw new CompileError(
2651
+ `${where} (t=${key.t}): offset ${key.offset} is odd. The deform array is x, y pairs, so an odd start puts ` +
2652
+ "every x of this run on a y — it loads, and the mesh tears. Use an even index, or say which vertex you meant with \"fromVertex\".",
2653
+ );
2654
+ }
2655
+ return key.offset;
2656
+ }
2657
+ if (key.fromVertex === undefined) return 0;
2658
+ const from = key.fromVertex;
2659
+ if (!Number.isInteger(from) || from < 0) {
2660
+ throw new CompileError(`${where} (t=${key.t}): fromVertex is ${JSON.stringify(from)}; it is a vertex index, so a whole number ≥ 0`);
2661
+ }
2662
+ const covered = runLength / 2;
2663
+ if (from + covered > geometry.vertexCount) {
2664
+ throw new CompileError(
2665
+ `${where} (t=${key.t}): fromVertex ${from} plus ${covered} vertex offset(s) runs to vertex ${from + covered}, ` +
2666
+ `and the attachment has ${geometry.vertexCount}`,
2667
+ );
2668
+ }
2669
+ if (!geometry.weighted) return from * 2;
2670
+ const counts = geometry.boneCounts!;
2671
+ for (let v = from; v < from + covered; v++) {
2672
+ if (counts[v] === 1) continue;
2673
+ throw new CompileError(
2674
+ `${where} (t=${key.t}): "fromVertex" counts VERTICES, and this attachment is weighted — its deform array ` +
2675
+ `holds one x, y pair per bone INFLUENCE, and vertex ${v} has ${counts[v]} of them. One offset per vertex ` +
2676
+ 'is not a thing that array can hold: the world offset of a multi-bone vertex is the weighted sum of a ' +
2677
+ 'per-bone offset in each bone\'s own bind space, so rigc will not guess one for you. Either key the ' +
2678
+ 'control bone instead, or write the bind-space pairs yourself and start the run with "offset" ' +
2679
+ `(vertex ${from} starts at deform index ${2 * counts.slice(0, from).reduce((a, b) => a + b, 0)}).`,
2680
+ );
2681
+ }
2682
+ let start = 0;
2683
+ for (let v = 0; v < from; v++) start += counts[v];
2684
+ return start * 2;
2685
+ }
2686
+
2687
+ /**
2688
+ * A deform key's curve.
2689
+ *
2690
+ * One channel, and it is not any value in `vertices`: `readCurve(curve, timeline,
2691
+ * bezier, frame, 0, time, time2, 0, 1, 1)` builds the cubic between **0 and 1**,
2692
+ * the fraction of the way from this key's geometry to the next one's. So a named
2693
+ * easing here is the same shape it would be anywhere, applied to the blend rather
2694
+ * than to a coordinate, and a raw curve is four numbers whose value axis is 0..1.
2695
+ */
2696
+ function deformKeyCurve(
2697
+ entry: SpineTimelineKey,
2698
+ key: MotionDeformTrack['keys'][number],
2699
+ keys: MotionDeformTrack['keys'],
2700
+ index: number,
2701
+ motion: MotionSpec,
2702
+ where: string,
2703
+ ): SpineTimelineKey {
2704
+ const hasNext = index + 1 < keys.length;
2705
+ if (key.ease !== undefined && key.curve !== undefined) {
2706
+ throw new CompileError(`${where}: a key carries both a named easing and a raw curve; pick one`);
2707
+ }
2708
+ if (key.curve !== undefined) {
2709
+ if (!hasNext) throw new CompileError(`${where}: last key carries a curve but has nothing to ease to`);
2710
+ entry.curve = rawCurve(key.curve, 1, where, String(key.t));
2711
+ return entry;
2712
+ }
2713
+ if (key.ease === undefined) return entry;
2714
+ if (!hasNext) throw new CompileError(`${where}: last key carries an easing but has nothing to ease to`);
2715
+ if (key.ease === 'stepped') {
2716
+ entry.curve = 'stepped';
2717
+ return entry;
2718
+ }
2719
+ const handles = motion.easings?.[key.ease];
2720
+ if (!handles) throw new CompileError(`${where}: unknown easing "${key.ease}"`);
2721
+ entry.curve = bezierForChannel(handles, entry.time as number, keyTime(keys[index + 1].t), 0, 1);
2722
+ return entry;
2723
+ }
2724
+
2066
2725
  function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string): string[] {
2067
2726
  const named = [track.slot, track.group, track.bone, track.physics].filter((v) => v !== undefined);
2068
2727
  if (named.length > 1) {