@flighthq/skeleton2d-formats 0.3.0-edge.1458.0c01b63

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.
@@ -0,0 +1,714 @@
1
+ import { createAnimationChannel, createAnimationClip, createAnimationTrack } from '@flighthq/animation/contract';
2
+ import { easeCubicBezier } from '@flighthq/easing/contract';
3
+ import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
4
+ import {
5
+ applyAnimationClipToSkeleton2D,
6
+ cloneSkeleton2D,
7
+ getSkeleton2DSkin,
8
+ setSkeleton2DSkin,
9
+ } from '@flighthq/skeleton2d/contract';
10
+ import type { ImportDiagnostic, MeshAttachment2D, RegionAttachment2D } from '@flighthq/types/contract';
11
+ import {
12
+ AnimationInterpolationLinear,
13
+ MeshAttachment2DKind,
14
+ RegionAttachment2DKind,
15
+ TransformMode2D,
16
+ } from '@flighthq/types/contract';
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import { parseSpineSkeleton } from './spineParse';
20
+
21
+ // Hand-authored minimal Spine skeleton JSON (per the real-asset rule: committed fixtures are hand-written,
22
+ // never transcribed from a licensed rig). Two bones: a root, and a child that sets every transform field.
23
+ const SPINE_TWO_BONES = JSON.stringify({
24
+ skeleton: { spine: '4.1', hash: 'x' },
25
+ bones: [
26
+ { name: 'root' },
27
+ {
28
+ name: 'arm',
29
+ parent: 'root',
30
+ length: 50,
31
+ x: 10,
32
+ y: 20,
33
+ rotation: 45,
34
+ scaleX: 2,
35
+ scaleY: 3,
36
+ shearX: 5,
37
+ shearY: 6,
38
+ transform: 'onlyTranslation',
39
+ },
40
+ ],
41
+ });
42
+
43
+ describe('parseSpineSkeleton', () => {
44
+ it('parses the bone hierarchy: names, parent resolution, TRS, and transform mode', () => {
45
+ const result = parseSpineSkeleton(SPINE_TWO_BONES);
46
+ expect(result).not.toBeNull();
47
+ const bones = result!.skeleton.bones;
48
+ expect(bones.length).toBe(2);
49
+ expect(bones[0].name).toBe('root');
50
+ expect(bones[0].parentIndex).toBe(-1);
51
+ expect(bones[0].transformMode).toBe(TransformMode2D.Normal);
52
+
53
+ const arm = bones[1];
54
+ expect(arm.name).toBe('arm');
55
+ expect(arm.parentIndex).toBe(0); // 'root' resolved to index 0
56
+ expect(arm.length).toBe(50);
57
+ expect(arm.x).toBe(10);
58
+ expect(arm.y).toBe(20);
59
+ expect(arm.rotation).toBe(45);
60
+ expect(arm.scaleX).toBe(2);
61
+ expect(arm.scaleY).toBe(3);
62
+ expect(arm.shearX).toBe(5);
63
+ expect(arm.shearY).toBe(6);
64
+ expect(arm.transformMode).toBe(TransformMode2D.OnlyTranslation);
65
+ });
66
+
67
+ it('applies Spine defaults for omitted bone fields', () => {
68
+ const bones = parseSpineSkeleton(JSON.stringify({ bones: [{ name: 'b' }] }))!.skeleton.bones;
69
+ expect(bones[0]).toMatchObject({
70
+ x: 0,
71
+ y: 0,
72
+ rotation: 0,
73
+ scaleX: 1,
74
+ scaleY: 1,
75
+ shearX: 0,
76
+ shearY: 0,
77
+ length: 0,
78
+ parentIndex: -1,
79
+ transformMode: TransformMode2D.Normal,
80
+ });
81
+ });
82
+
83
+ it('maps every Spine transform-mode string', () => {
84
+ const doc = {
85
+ bones: [
86
+ { name: 'a', transform: 'noRotationOrReflection' },
87
+ { name: 'b', transform: 'noScale' },
88
+ { name: 'c', transform: 'noScaleOrReflection' },
89
+ { name: 'd', transform: 'bogus' },
90
+ ],
91
+ };
92
+ const bones = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.bones;
93
+ expect(bones[0].transformMode).toBe(TransformMode2D.NoRotationOrReflection);
94
+ expect(bones[1].transformMode).toBe(TransformMode2D.NoScale);
95
+ expect(bones[2].transformMode).toBe(TransformMode2D.NoScaleOrReflection);
96
+ expect(bones[3].transformMode).toBe(TransformMode2D.Normal); // unknown → default
97
+ });
98
+
99
+ it('returns null for malformed JSON and for a non-object document', () => {
100
+ expect(parseSpineSkeleton('{ not json')).toBeNull();
101
+ expect(parseSpineSkeleton('42')).toBeNull();
102
+ expect(parseSpineSkeleton('null')).toBeNull();
103
+ });
104
+
105
+ it('best-efforts an empty skeleton when bones are missing', () => {
106
+ const result = parseSpineSkeleton(JSON.stringify({ skeleton: { spine: '4.1' } }));
107
+ expect(result).not.toBeNull();
108
+ expect(result!.skeleton.bones.length).toBe(0);
109
+ expect(result!.animations).toEqual([]);
110
+ });
111
+
112
+ it('parses slots with resolved bone index, draw-order, region attachment, and color', () => {
113
+ const doc = {
114
+ bones: [{ name: 'root' }, { name: 'armBone', parent: 'root' }],
115
+ slots: [{ name: 'arm', bone: 'armBone', attachment: 'armImage', color: '80c0ffff' }],
116
+ skins: [
117
+ {
118
+ name: 'default',
119
+ attachments: { arm: { armImage: { type: 'region', x: 1, y: 2, rotation: 30, width: 40, height: 20 } } },
120
+ },
121
+ ],
122
+ };
123
+ const slots = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots!;
124
+ expect(slots.length).toBe(1);
125
+ expect(slots[0].name).toBe('arm');
126
+ expect(slots[0].boneIndex).toBe(1); // 'armBone' resolved
127
+ expect(slots[0].color).toBe(0x80c0ffff);
128
+ const region = slots[0].attachment as RegionAttachment2D;
129
+ expect(region.kind).toBe(RegionAttachment2DKind);
130
+ expect(region).toMatchObject({ x: 1, y: 2, rotation: 30, width: 40, height: 20, scaleX: 1, scaleY: 1 });
131
+ });
132
+
133
+ it('parses an unweighted mesh attachment (positions local to the bone, skin null)', () => {
134
+ const doc = {
135
+ bones: [{ name: 'root' }],
136
+ slots: [{ name: 's', bone: 'root', attachment: 'm' }],
137
+ skins: [
138
+ {
139
+ name: 'default',
140
+ attachments: {
141
+ s: { m: { type: 'mesh', uvs: [0, 0, 1, 0, 1, 1], triangles: [0, 1, 2], vertices: [0, 0, 10, 0, 10, 10] } },
142
+ },
143
+ },
144
+ ],
145
+ };
146
+ const mesh = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
147
+ expect(mesh.kind).toBe(MeshAttachment2DKind);
148
+ expect(mesh.skin).toBeNull();
149
+ expect(mesh.vertexCount).toBe(3);
150
+ expect(Array.from(mesh.vertices!)).toEqual([0, 0, 10, 0, 10, 10]);
151
+ expect(Array.from(mesh.triangles)).toEqual([0, 1, 2]);
152
+ });
153
+
154
+ it('parses a weighted mesh attachment into a Skin2D influence stream', () => {
155
+ // 1 vertex, 2 influences: bone 0 offset (1,2) w0.25, bone 1 offset (3,4) w0.75. uvs give vertexCount=1.
156
+ const doc = {
157
+ bones: [{ name: 'a' }, { name: 'b' }],
158
+ slots: [{ name: 's', bone: 'a', attachment: 'm' }],
159
+ skins: [
160
+ {
161
+ name: 'default',
162
+ attachments: {
163
+ s: { m: { type: 'mesh', uvs: [0, 0], triangles: [], vertices: [2, 0, 1, 2, 0.25, 1, 3, 4, 0.75] } },
164
+ },
165
+ },
166
+ ],
167
+ };
168
+ const mesh = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
169
+ expect(mesh.vertices).toBeNull();
170
+ expect(mesh.skin).not.toBeNull();
171
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([2]);
172
+ expect(Array.from(mesh.skin!.influences)).toEqual([0, 1, 2, 0.25, 1, 3, 4, 0.75]);
173
+ });
174
+
175
+ it('recovers a malformed bone as an aligned placeholder so file-order indices stay valid (read-integrity axis 12)', () => {
176
+ // The bone array is positionally referenced by weighted-mesh influences, so a malformed entry must hold
177
+ // its slot rather than drop — else every later bone shifts and those indices point at the wrong bone.
178
+ const doc = { bones: [{ name: 'a' }, null, { name: 'c' }] };
179
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
180
+ parseSpineSkeleton(JSON.stringify(doc), sink),
181
+ );
182
+ const bones = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.bones;
183
+ expect(bones.length).toBe(3); // placeholder holds index 1
184
+ expect(bones[0].name).toBe('a');
185
+ expect(bones[1].name).toBeNull(); // inert placeholder
186
+ expect(bones[2].name).toBe('c'); // still at index 2 — NOT shifted down to 1
187
+ expect(crumbs.map((c) => c.kind)).toContain('spine.malformed-bone-recovered');
188
+ });
189
+
190
+ it('bounds a weighted-vertex stream against its actual length instead of reading past it (read-integrity axis 13)', () => {
191
+ // vertexCount = 1 (from uvs); the stream declares boneCount 5 but supplies only one (boneIndex,x,y,weight)
192
+ // quad. The declared count is clamped to what the stream actually holds — no undefined→NaN, no runaway loop.
193
+ const doc = {
194
+ bones: [{ name: 'a' }],
195
+ slots: [{ name: 's', bone: 'a', attachment: 'm' }],
196
+ skins: [
197
+ {
198
+ name: 'default',
199
+ attachments: { s: { m: { type: 'mesh', uvs: [0, 0], triangles: [], vertices: [5, 0, 1, 2, 0.25] } } },
200
+ },
201
+ ],
202
+ };
203
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
204
+ parseSpineSkeleton(JSON.stringify(doc), sink),
205
+ );
206
+ const mesh = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
207
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([1]); // clamped 5 → 1
208
+ expect(Array.from(mesh.skin!.influences)).toEqual([0, 1, 2, 0.25]); // exactly the one available quad
209
+ expect(crumbs.map((c) => c.kind)).toContain('spine.weighted-vertices-truncated');
210
+ });
211
+
212
+ it('Skip-crumbs an unmodeled attachment type, but now PARSES the alternate skin', () => {
213
+ const doc = {
214
+ bones: [{ name: 'root' }],
215
+ slots: [{ name: 'clip', bone: 'root', attachment: 'mask' }],
216
+ skins: [
217
+ { name: 'default', attachments: { clip: { mask: { type: 'clipping', end: 'clip' } } } },
218
+ { name: 'costume2', attachments: {} },
219
+ ],
220
+ };
221
+ const crumbs = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink));
222
+ expect(crumbs.map((c) => c.kind)).toContain('spine.clipping-attachment-unsupported');
223
+ // The alternate skin is a first-class wardrobe entry now, not a Skip crumb.
224
+ expect(crumbs.map((c) => c.kind)).not.toContain('spine.alternate-skin-unsupported');
225
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
226
+ expect(result.skeleton.skins!.map((s) => s.name)).toEqual(['default', 'costume2']);
227
+ // The clipping attachment is still dropped (not shown on the slot).
228
+ expect(result.skeleton.slots![0].attachment).toBeNull();
229
+ });
230
+
231
+ it('parses every named skin into the wardrobe, resolving slot names to indices', () => {
232
+ const doc = {
233
+ bones: [{ name: 'root' }],
234
+ slots: [
235
+ { name: 'head', bone: 'root', attachment: 'face' },
236
+ { name: 'hand', bone: 'root' },
237
+ ],
238
+ skins: [
239
+ { name: 'default', attachments: { head: { face: { width: 10, height: 10 } } } },
240
+ { name: 'goblin', attachments: { head: { face: { width: 20, height: 20 } }, hand: { axe: { width: 5 } } } },
241
+ ],
242
+ };
243
+ const skeleton = parseSpineSkeleton(JSON.stringify(doc))!.skeleton;
244
+ expect(skeleton.skins!.length).toBe(2);
245
+ const goblin = getSkeleton2DSkin(skeleton, 'goblin')!;
246
+ expect(goblin.attachments.map((a) => [a.slotIndex, a.name])).toEqual([
247
+ [0, 'face'],
248
+ [1, 'axe'],
249
+ ]);
250
+ // The setup pose comes from the DEFAULT skin, keyed by the slot's own attachment name.
251
+ expect((skeleton.slots![0].attachment as RegionAttachment2D).width).toBe(10);
252
+ expect(skeleton.slots![1].attachment).toBeNull();
253
+ });
254
+
255
+ it('wears an alternate skin over the setup pose through setSkeleton2DSkin', () => {
256
+ const doc = {
257
+ bones: [{ name: 'root' }],
258
+ slots: [
259
+ { name: 'head', bone: 'root', attachment: 'face' },
260
+ { name: 'body', bone: 'root', attachment: 'torso' },
261
+ ],
262
+ skins: [
263
+ { name: 'default', attachments: { head: { face: { width: 10 } }, body: { torso: { width: 99 } } } },
264
+ { name: 'goblin', attachments: { head: { face: { width: 20 } } } },
265
+ ],
266
+ };
267
+ const skeleton = parseSpineSkeleton(JSON.stringify(doc))!.skeleton;
268
+ setSkeleton2DSkin(skeleton, getSkeleton2DSkin(skeleton, 'goblin')!);
269
+ expect((skeleton.slots![0].attachment as RegionAttachment2D).width).toBe(20); // overridden
270
+ expect((skeleton.slots![1].attachment as RegionAttachment2D).width).toBe(99); // shared art survives
271
+ });
272
+
273
+ it('drops a skin entry naming a slot the skeleton does not have', () => {
274
+ const doc = {
275
+ bones: [{ name: 'root' }],
276
+ slots: [{ name: 'head', bone: 'root' }],
277
+ skins: [{ name: 'default', attachments: { ghost: { thing: { width: 1 } } } }],
278
+ };
279
+ const skeleton = parseSpineSkeleton(JSON.stringify(doc))!.skeleton;
280
+ expect(skeleton.skins![0].attachments).toEqual([]);
281
+ });
282
+
283
+ it('accepts the older object-form skins map', () => {
284
+ const doc = {
285
+ bones: [{ name: 'root' }],
286
+ slots: [{ name: 'head', bone: 'root', attachment: 'face' }],
287
+ skins: { default: { head: { face: { width: 7 } } }, alt: { head: { face: { width: 8 } } } },
288
+ };
289
+ const skeleton = parseSpineSkeleton(JSON.stringify(doc))!.skeleton;
290
+ expect(skeleton.skins!.map((s) => s.name).sort()).toEqual(['alt', 'default']);
291
+ expect((skeleton.slots![0].attachment as RegionAttachment2D).width).toBe(7);
292
+ });
293
+
294
+ it('builds a named animation clip of RELATIVE deltas that compose onto the setup pose', () => {
295
+ const doc = {
296
+ bones: [{ name: 'b', rotation: 10, x: 5, scaleX: 2 }],
297
+ animations: {
298
+ walk: {
299
+ bones: {
300
+ b: {
301
+ rotate: [
302
+ { time: 0, value: 0 },
303
+ { time: 1, value: 90 },
304
+ ],
305
+ translate: [
306
+ { time: 0, x: 0, y: 0 },
307
+ { time: 1, x: 20, y: 0 },
308
+ ],
309
+ scale: [
310
+ { time: 0, x: 1, y: 1 },
311
+ { time: 1, x: 3, y: 1 },
312
+ ],
313
+ },
314
+ },
315
+ },
316
+ },
317
+ };
318
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
319
+ expect(result.animations.length).toBe(1);
320
+ expect(result.animations[0].name).toBe('walk');
321
+ // Compose the clip onto a pose clone at t=1. The end result is identical to the old setup-baked
322
+ // encoding — rotation 10+90, x 5+20, scaleX 2*3 — confirming the relative-delta switch is numerically
323
+ // neutral on the original rig; the win is portability/blending, not different numbers.
324
+ const setup = result.skeleton;
325
+ const pose = cloneSkeleton2D(setup);
326
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
327
+ expect(pose.bones[0].rotation).toBeCloseTo(100, 5);
328
+ expect(pose.bones[0].x).toBeCloseTo(25, 5);
329
+ expect(pose.bones[0].scaleX).toBeCloseTo(6, 5); // multiplier: setup 2 × 3
330
+ expect(setup.bones[0].rotation).toBe(10); // the parsed setup pose is left intact
331
+ });
332
+
333
+ it('uses Step interpolation when every keyframe of a timeline is stepped', () => {
334
+ const doc = {
335
+ bones: [{ name: 'b' }],
336
+ animations: {
337
+ a: {
338
+ bones: {
339
+ b: {
340
+ rotate: [
341
+ { time: 0, value: 0, curve: 'stepped' },
342
+ { time: 1, value: 90, curve: 'stepped' },
343
+ ],
344
+ },
345
+ },
346
+ },
347
+ },
348
+ };
349
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
350
+ const pose = cloneSkeleton2D(result.skeleton);
351
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.9);
352
+ expect(pose.bones[0].rotation).toBeCloseTo(0, 5); // stepped holds the t=0 keyframe (delta 0) until t=1
353
+ });
354
+
355
+ it('HONORS a bezier curve keyframe, rebasing its absolute control points onto the segment', () => {
356
+ // Spine writes control points in ABSOLUTE time/value units, so the CSS ease-in curve (0.42, 0, 1, 1)
357
+ // over a 0..1s / 0..90deg segment is written as [0.42, 0, 1, 90]. A linear read would give 45 at the
358
+ // midpoint; the curve must bend it well below that.
359
+ const doc = curveDoc([
360
+ { time: 0, value: 0, curve: [0.42, 0, 1, 90] },
361
+ { time: 1, value: 90 },
362
+ ]);
363
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
364
+ const pose = cloneSkeleton2D(result.skeleton);
365
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
366
+ expect(pose.bones[0].rotation).toBeCloseTo(90 * easeCubicBezier(0.42, 0, 1, 1)(0.5), 4);
367
+ expect(pose.bones[0].rotation).toBeLessThan(40); // materially different from the linear 45
368
+ // Endpoints stay exact whatever the curve does between them.
369
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 1);
370
+ expect(pose.bones[0].rotation).toBeCloseTo(90, 5);
371
+ });
372
+
373
+ it('does NOT report divergence when components share a curve SHAPE across different value ranges', () => {
374
+ // x spans 0..10 and y spans 0..20, so the same shape is written with different raw `cy` numbers.
375
+ // Comparing raw numbers would cry divergence on nearly every translate timeline; comparing the
376
+ // NORMALIZED points is what makes this correct.
377
+ const doc = curveDoc(
378
+ [
379
+ { time: 0, x: 0, y: 0, curve: [0.42, 0, 1, 10, 0.42, 0, 1, 20] },
380
+ { time: 1, x: 10, y: 20 },
381
+ ],
382
+ 'translate',
383
+ );
384
+ const kinds = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).map((c) => c.kind);
385
+ expect(kinds).not.toContain('spine.per-component-curve-easing-unsupported');
386
+ });
387
+
388
+ it('Skip-crumbs a genuinely divergent per-component curve, and the DOMINANT component wins', () => {
389
+ const doc = curveDoc(
390
+ [
391
+ { time: 0, x: 0, y: 0, curve: [0.42, 0, 1, 10, 0.1, 0, 0.9, 20] },
392
+ { time: 1, x: 10, y: 20 },
393
+ ],
394
+ 'translate',
395
+ );
396
+ const crumbs = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).filter(
397
+ (c) => c.kind === 'spine.per-component-curve-easing-unsupported',
398
+ );
399
+ expect(crumbs.length).toBe(1);
400
+ expect(crumbs[0].detail).toMatchObject({ segments: 1 });
401
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
402
+ const pose = cloneSkeleton2D(result.skeleton);
403
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
404
+ // y moves 0..20 and x only 0..10, so Y's curve supplies the easing for both components.
405
+ const alpha = easeCubicBezier(0.1, 0, 0.9, 1)(0.5);
406
+ expect(pose.bones[0].x).toBeCloseTo(10 * alpha, 4);
407
+ expect(pose.bones[0].y).toBeCloseTo(20 * alpha, 4);
408
+ });
409
+
410
+ it('ignores a NEAR-CONSTANT component when choosing the curve, however tiny its motion', () => {
411
+ // The rebase divides by a component's value change, so a barely-moving component is a near-zero
412
+ // denominator: its control points normalize to wild values and the resulting curve is not the authored
413
+ // shape at all. Here x moves 0.004 while y moves a full 20 — y must win, and the tiny x curve (whose
414
+ // control points would rebase far outside the unit square) must not be allowed to supply the easing.
415
+ const doc = curveDoc(
416
+ [
417
+ { time: 0, x: 0.847, y: 0, curve: [0.174, 0.85, 0.184, 0.84, 0.174, 0, 0.184, 15.8] },
418
+ { time: 1, x: 0.843, y: 20 },
419
+ ],
420
+ 'translate',
421
+ );
422
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
423
+ const pose = cloneSkeleton2D(result.skeleton);
424
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
425
+ const alpha = easeCubicBezier(0.174, 0, 0.184, 0.79)(0.5);
426
+ expect(pose.bones[0].y).toBeCloseTo(20 * alpha, 3);
427
+ // Sanity: the eased value stays inside the segment. Riding x's curve produced values outside it.
428
+ expect(pose.bones[0].y).toBeGreaterThanOrEqual(0);
429
+ expect(pose.bones[0].y).toBeLessThanOrEqual(20);
430
+ });
431
+
432
+ it('skips a CONSTANT component when choosing the winning curve', () => {
433
+ // x never moves, so its curve carries no shape and rebasing it would divide by zero. y must win.
434
+ const doc = curveDoc(
435
+ [
436
+ { time: 0, x: 0, y: 0, curve: [0, 0, 1, 0, 0.42, 0, 1, 20] },
437
+ { time: 1, x: 0, y: 20 },
438
+ ],
439
+ 'translate',
440
+ );
441
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
442
+ const pose = cloneSkeleton2D(result.skeleton);
443
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
444
+ expect(pose.bones[0].y).toBeCloseTo(20 * easeCubicBezier(0.42, 0, 1, 1)(0.5), 4);
445
+ const kinds = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).map((c) => c.kind);
446
+ expect(kinds).not.toContain('spine.per-component-curve-easing-unsupported');
447
+ });
448
+
449
+ it('leaves an uncurved timeline with no segment easings at all', () => {
450
+ const doc = curveDoc([
451
+ { time: 0, value: 0 },
452
+ { time: 1, value: 90 },
453
+ ]);
454
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
455
+ expect(result.animations[0].clip.channels[0].track.segmentEasings).toBeNull();
456
+ const pose = cloneSkeleton2D(result.skeleton);
457
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
458
+ expect(pose.bones[0].rotation).toBeCloseTo(45, 5); // plain linear
459
+ });
460
+
461
+ it('CLAMPS a control point that overshoots its segment in time, and records the loss', () => {
462
+ // cx1 = -0.5 sits before the segment starts, which Spine allows but a CSS-style bezier cannot invert.
463
+ const doc = curveDoc([
464
+ { time: 0, value: 0, curve: [-0.5, 0, 1, 90] },
465
+ { time: 1, value: 90 },
466
+ ]);
467
+ const crumbs = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).filter(
468
+ (c) => c.kind === 'spine.curve-time-overshoot-clamped',
469
+ );
470
+ expect(crumbs.length).toBe(1);
471
+ expect(crumbs[0].detail).toMatchObject({ segments: 1 });
472
+ // It still produces a usable, finite easing rather than NaN.
473
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
474
+ const pose = cloneSkeleton2D(result.skeleton);
475
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
476
+ expect(Number.isFinite(pose.bones[0].rotation)).toBe(true);
477
+ expect(pose.bones[0].rotation).toBeGreaterThan(0);
478
+ });
479
+
480
+ it('leaves a y overshoot UNCLAMPED, since anticipation is legitimate', () => {
481
+ // cy1 below the start value is an anticipation curve — the pose dips below 0 before rising.
482
+ const doc = curveDoc([
483
+ { time: 0, value: 0, curve: [0.25, -45, 0.75, 90] },
484
+ { time: 1, value: 90 },
485
+ ]);
486
+ const kinds = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).map((c) => c.kind);
487
+ expect(kinds).not.toContain('spine.curve-time-overshoot-clamped');
488
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
489
+ const pose = cloneSkeleton2D(result.skeleton);
490
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.15);
491
+ expect(pose.bones[0].rotation).toBeLessThan(0);
492
+ });
493
+
494
+ it('builds an ABSOLUTE slot colour channel that the binder writes rather than composes', () => {
495
+ const doc = {
496
+ bones: [{ name: 'b' }],
497
+ slots: [{ name: 's', bone: 'b', color: '112233ff' }],
498
+ animations: {
499
+ a: {
500
+ slots: {
501
+ s: {
502
+ rgba: [
503
+ { time: 0, color: 'ff000080' },
504
+ { time: 1, color: '0000ffff' },
505
+ ],
506
+ },
507
+ },
508
+ },
509
+ },
510
+ };
511
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
512
+ const setup = result.skeleton;
513
+ const pose = cloneSkeleton2D(setup);
514
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 0);
515
+ expect(pose.slots![0].color).toBe(0xff000080);
516
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
517
+ expect(pose.slots![0].color).toBe(0x0000ffff);
518
+ // The setup colour is never blended in — a slot colour is authored absolutely.
519
+ expect(setup.slots![0].color).toBe(0x112233ff);
520
+ });
521
+
522
+ it('interpolates a slot colour across the segment', () => {
523
+ const doc = {
524
+ bones: [{ name: 'b' }],
525
+ slots: [{ name: 's', bone: 'b' }],
526
+ animations: {
527
+ a: {
528
+ slots: {
529
+ s: {
530
+ rgba: [
531
+ { time: 0, color: '00000000' },
532
+ { time: 1, color: 'ffffffff' },
533
+ ],
534
+ },
535
+ },
536
+ },
537
+ },
538
+ };
539
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
540
+ const pose = cloneSkeleton2D(result.skeleton);
541
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.5);
542
+ expect(pose.slots![0].color).toBe(0x80808080);
543
+ });
544
+
545
+ it('Skip-crumbs the slot timeline kinds Slot2D cannot represent', () => {
546
+ const doc = {
547
+ bones: [{ name: 'b' }],
548
+ slots: [{ name: 's', bone: 'b' }],
549
+ animations: { a: { slots: { s: { rgb: [], alpha: [], attachment: [], rgba2: [] } } } },
550
+ };
551
+ const kinds = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).map((c) => c.kind);
552
+ expect(kinds).toContain('spine.slot-rgb-timeline-unsupported');
553
+ expect(kinds).toContain('spine.slot-alpha-timeline-unsupported');
554
+ expect(kinds).toContain('spine.slot-rgba2-timeline-unsupported');
555
+ });
556
+
557
+ it('builds a STEP attachment-swap channel of indices into a per-channel table', () => {
558
+ const doc = {
559
+ bones: [{ name: 'b' }],
560
+ slots: [{ name: 's', bone: 'b', attachment: 'one' }],
561
+ skins: [{ name: 'default', attachments: { s: { one: { width: 1 }, two: { width: 2 } } } }],
562
+ animations: {
563
+ a: {
564
+ slots: {
565
+ s: {
566
+ attachment: [{ time: 0, name: 'one' }, { time: 1, name: 'two' }, { time: 2 }],
567
+ },
568
+ },
569
+ },
570
+ },
571
+ };
572
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
573
+ const pose = cloneSkeleton2D(result.skeleton);
574
+ const clip = result.animations[0].clip;
575
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 0);
576
+ expect((pose.slots![0].attachment as RegionAttachment2D).width).toBe(1);
577
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 1);
578
+ expect((pose.slots![0].attachment as RegionAttachment2D).width).toBe(2);
579
+ // A nameless keyframe is Spine's "hide this slot".
580
+ applyAnimationClipToSkeleton2D(clip, result.skeleton, pose, 2);
581
+ expect(pose.slots![0].attachment).toBeNull();
582
+ });
583
+
584
+ it('HOLDS each attachment until the next keyframe rather than interpolating', () => {
585
+ const doc = {
586
+ bones: [{ name: 'b' }],
587
+ slots: [{ name: 's', bone: 'b' }],
588
+ skins: [{ name: 'default', attachments: { s: { one: { width: 1 }, two: { width: 2 } } } }],
589
+ animations: {
590
+ a: {
591
+ slots: {
592
+ s: {
593
+ attachment: [
594
+ { time: 0, name: 'one' },
595
+ { time: 1, name: 'two' },
596
+ ],
597
+ },
598
+ },
599
+ },
600
+ },
601
+ };
602
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
603
+ const pose = cloneSkeleton2D(result.skeleton);
604
+ // Mid-segment must still show the FIRST attachment — there is no halfway art.
605
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.99);
606
+ expect((pose.slots![0].attachment as RegionAttachment2D).width).toBe(1);
607
+ });
608
+
609
+ it('FORCES step semantics even if the track claims Linear', () => {
610
+ // The binder must not trust the track here: interpolating between table INDICES would resolve to art
611
+ // that no keyframe ever named. Rebuild the channel as Linear and confirm it still steps.
612
+ const doc = {
613
+ bones: [{ name: 'b' }],
614
+ slots: [{ name: 's', bone: 'b' }],
615
+ skins: [{ name: 'default', attachments: { s: { one: { width: 1 }, two: { width: 2 } } } }],
616
+ animations: {
617
+ a: {
618
+ slots: {
619
+ s: {
620
+ attachment: [
621
+ { time: 0, name: 'one' },
622
+ { time: 1, name: 'two' },
623
+ ],
624
+ },
625
+ },
626
+ },
627
+ },
628
+ };
629
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
630
+ const channel = result.animations[0].clip.channels[0];
631
+ const linear = createAnimationChannel(
632
+ createAnimationTrack({
633
+ components: 1,
634
+ interpolation: AnimationInterpolationLinear,
635
+ times: channel.track.times,
636
+ values: channel.track.values,
637
+ }),
638
+ channel.targetRef,
639
+ );
640
+ const pose = cloneSkeleton2D(result.skeleton);
641
+ applyAnimationClipToSkeleton2D(createAnimationClip([linear]), result.skeleton, pose, 0.5);
642
+ expect((pose.slots![0].attachment as RegionAttachment2D).width).toBe(1); // not a blended index
643
+ });
644
+
645
+ it('DEDUPLICATES the attachment table when a swap cycles back', () => {
646
+ const doc = {
647
+ bones: [{ name: 'b' }],
648
+ slots: [{ name: 's', bone: 'b' }],
649
+ skins: [{ name: 'default', attachments: { s: { one: { width: 1 }, two: { width: 2 } } } }],
650
+ animations: {
651
+ a: {
652
+ slots: {
653
+ s: {
654
+ attachment: [
655
+ { time: 0, name: 'one' },
656
+ { time: 1, name: 'two' },
657
+ { time: 2, name: 'one' },
658
+ ],
659
+ },
660
+ },
661
+ },
662
+ },
663
+ };
664
+ const target = parseSpineSkeleton(JSON.stringify(doc))!.animations[0].clip.channels[0].targetRef as {
665
+ attachments: unknown[];
666
+ };
667
+ expect(target.attachments.length).toBe(2); // 'one' is stored once, not twice
668
+ });
669
+
670
+ it('keeps timing when a keyframe names art the setup skin does not supply', () => {
671
+ // Dropping the keyframe would shift every later swap earlier; it becomes a hide instead.
672
+ const doc = {
673
+ bones: [{ name: 'b' }],
674
+ slots: [{ name: 's', bone: 'b' }],
675
+ skins: [{ name: 'default', attachments: { s: { one: { width: 1 } } } }],
676
+ animations: {
677
+ a: {
678
+ slots: {
679
+ s: {
680
+ attachment: [
681
+ { time: 0, name: 'ghost' },
682
+ { time: 1, name: 'one' },
683
+ ],
684
+ },
685
+ },
686
+ },
687
+ },
688
+ };
689
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
690
+ const pose = cloneSkeleton2D(result.skeleton);
691
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0);
692
+ expect(pose.slots![0].attachment).toBeNull();
693
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 1);
694
+ expect((pose.slots![0].attachment as RegionAttachment2D).width).toBe(1);
695
+ });
696
+
697
+ it('Skip-crumbs constraint, event, and slot animation timelines', () => {
698
+ const doc = {
699
+ bones: [{ name: 'b' }],
700
+ animations: {
701
+ a: { bones: {}, ik: { c1: [] }, transform: { t1: [] }, events: [{ time: 0, name: 'e' }], slots: { s: {} } },
702
+ },
703
+ };
704
+ const kinds = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).map((c) => c.kind);
705
+ expect(kinds).toContain('spine.ik-timeline-unsupported');
706
+ expect(kinds).toContain('spine.transform-timeline-unsupported');
707
+ expect(kinds).toContain('spine.event-timeline-unsupported');
708
+ });
709
+ });
710
+
711
+ // A one-bone document whose single animation carries `keys` as bone `b`'s timeline of the given kind.
712
+ function curveDoc(keys: readonly Record<string, unknown>[], kind = 'rotate'): Record<string, unknown> {
713
+ return { bones: [{ name: 'b' }], animations: { a: { bones: { b: { [kind]: keys } } } } };
714
+ }