@flighthq/skeleton2d-formats 0.3.0-next.1021.f5f1ee0

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,313 @@
1
+ import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
2
+ import type { ImportDiagnostic, MeshAttachment2D, RegionAttachment2D } from '@flighthq/types/contract';
3
+ import { MeshAttachment2DKind, RegionAttachment2DKind, TransformMode2D } from '@flighthq/types/contract';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { parseDragonBonesSkeleton } from './dragonBonesParse';
7
+
8
+ // Hand-authored minimal DragonBones JSON (per the real-asset rule: committed fixtures are hand-written,
9
+ // never transcribed from a rig). Bones are listed CHILD-FIRST to exercise the topological sort.
10
+ const DB_TWO_BONES = JSON.stringify({
11
+ frameRate: 24,
12
+ name: 'demo',
13
+ version: '5.5',
14
+ armature: [
15
+ {
16
+ name: 'armatureA',
17
+ bone: [
18
+ { name: 'arm', parent: 'root', length: 50, transform: { x: 10, y: 20, skX: 45, skY: 30, scX: 2, scY: 3 } },
19
+ { name: 'root' },
20
+ ],
21
+ },
22
+ ],
23
+ });
24
+
25
+ describe('parseDragonBonesSkeleton', () => {
26
+ it('parses the first armature, topologically sorts bones, and maps the skX/skY transform', () => {
27
+ const result = parseDragonBonesSkeleton(DB_TWO_BONES);
28
+ expect(result).not.toBeNull();
29
+ const bones = result!.skeleton.bones;
30
+ expect(bones.length).toBe(2);
31
+ // Topological sort puts the parent (listed second) before its child.
32
+ expect(bones[0].name).toBe('root');
33
+ expect(bones[0].parentIndex).toBe(-1);
34
+ const arm = bones[1];
35
+ expect(arm.name).toBe('arm');
36
+ expect(arm.parentIndex).toBe(0); // 'root' resolved to index 0
37
+ expect(arm.length).toBe(50);
38
+ expect(arm.x).toBe(10);
39
+ expect(arm.y).toBe(20);
40
+ // rotation = skY = 30, shearX = 0, shearY = skX − skY = 15; scale from scX/scY.
41
+ expect(arm.rotation).toBe(30);
42
+ expect(arm.shearX).toBe(0);
43
+ expect(arm.shearY).toBe(15);
44
+ expect(arm.scaleX).toBe(2);
45
+ expect(arm.scaleY).toBe(3);
46
+ expect(result!.animations).toEqual([]);
47
+ });
48
+
49
+ it('maps the newer rotate/skew transform form identically to skX/skY', () => {
50
+ const doc = { armature: [{ bone: [{ name: 'b', transform: { rotate: 30, skew: 15 } }] }] };
51
+ const bone = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0];
52
+ expect(bone.rotation).toBe(30);
53
+ expect(bone.shearY).toBe(15);
54
+ });
55
+
56
+ it('applies DragonBones defaults for an omitted transform', () => {
57
+ const doc = { armature: [{ bone: [{ name: 'b' }] }] };
58
+ const bone = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0];
59
+ expect(bone).toMatchObject({
60
+ x: 0,
61
+ y: 0,
62
+ rotation: 0,
63
+ shearX: 0,
64
+ shearY: 0,
65
+ scaleX: 1,
66
+ scaleY: 1,
67
+ length: 0,
68
+ parentIndex: -1,
69
+ transformMode: TransformMode2D.Normal,
70
+ });
71
+ });
72
+
73
+ it('maps the DragonBones inheritance booleans onto TransformMode2D', () => {
74
+ const doc = {
75
+ armature: [
76
+ {
77
+ bone: [
78
+ { name: 'normal' },
79
+ { name: 'onlyT', inheritRotation: false, inheritScale: false },
80
+ { name: 'noScale', inheritScale: false },
81
+ { name: 'noScaleRefl', inheritScale: false, inheritReflection: false },
82
+ { name: 'noRot', inheritRotation: false, inheritReflection: false },
83
+ ],
84
+ },
85
+ ],
86
+ };
87
+ const bones = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones;
88
+ const mode = (name: string) => bones.find((b) => b.name === name)!.transformMode;
89
+ expect(mode('normal')).toBe(TransformMode2D.Normal);
90
+ expect(mode('onlyT')).toBe(TransformMode2D.OnlyTranslation);
91
+ expect(mode('noScale')).toBe(TransformMode2D.NoScale);
92
+ expect(mode('noScaleRefl')).toBe(TransformMode2D.NoScaleOrReflection);
93
+ expect(mode('noRot')).toBe(TransformMode2D.NoRotationOrReflection);
94
+ });
95
+
96
+ it('Skip-crumbs an inheritance combo the five-value enum cannot express', () => {
97
+ // inheritRotation false + inheritReflection true (scale kept): "strip rotation, keep reflection" has no
98
+ // TransformMode2D — NoRotationOrReflection would also strip the reflection.
99
+ const doc = { armature: [{ bone: [{ name: 'b', inheritRotation: false }] }] };
100
+ const kinds = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).map(
101
+ (c) => c.kind,
102
+ );
103
+ expect(kinds).toContain('dragonbones.inherit-mode-unmapped');
104
+ });
105
+
106
+ it('parses slots with resolved bone index, image display, and ColorTransform tint', () => {
107
+ const doc = {
108
+ armature: [
109
+ {
110
+ bone: [{ name: 'root' }, { name: 'armBone', parent: 'root' }],
111
+ slot: [{ name: 'arm', parent: 'armBone', displayIndex: 0, color: { rM: 50, gM: 75, bM: 100, aM: 100 } }],
112
+ skin: [
113
+ {
114
+ name: 'default',
115
+ slot: [{ name: 'arm', display: [{ name: 'armImage', transform: { x: 1, y: 2, skX: 30, skY: 30 } }] }],
116
+ },
117
+ ],
118
+ },
119
+ ],
120
+ };
121
+ const slots = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots!;
122
+ expect(slots.length).toBe(1);
123
+ expect(slots[0].name).toBe('arm');
124
+ expect(slots[0].boneIndex).toBe(1); // 'armBone' resolved to its topo-sorted output index
125
+ // ColorTransform multiply 50/75/100/100 % → RR GG BB AA bytes 0x80 0xbf 0xff 0xff.
126
+ expect(slots[0].color).toBe(0x80bfffff);
127
+ const region = slots[0].attachment as RegionAttachment2D;
128
+ expect(region.kind).toBe(RegionAttachment2DKind);
129
+ expect(region).toMatchObject({ x: 1, y: 2, rotation: 30, scaleX: 1, scaleY: 1 });
130
+ });
131
+
132
+ it('parses an unweighted mesh display into a MeshAttachment2D (positions direct, skin null)', () => {
133
+ const doc = {
134
+ armature: [
135
+ {
136
+ bone: [{ name: 'root' }],
137
+ slot: [{ name: 's', parent: 'root' }],
138
+ skin: [
139
+ {
140
+ name: 'default',
141
+ slot: [
142
+ {
143
+ name: 's',
144
+ display: [
145
+ {
146
+ type: 'mesh',
147
+ name: 'm',
148
+ uvs: [0, 0, 1, 0, 1, 1],
149
+ triangles: [0, 1, 2],
150
+ vertices: [0, 0, 10, 0, 10, 10],
151
+ },
152
+ ],
153
+ },
154
+ ],
155
+ },
156
+ ],
157
+ },
158
+ ],
159
+ };
160
+ const mesh = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
161
+ expect(mesh.kind).toBe(MeshAttachment2DKind);
162
+ expect(mesh.skin).toBeNull();
163
+ expect(mesh.vertexCount).toBe(3);
164
+ expect(Array.from(mesh.vertices!)).toEqual([0, 0, 10, 0, 10, 10]);
165
+ expect(Array.from(mesh.triangles)).toEqual([0, 1, 2]);
166
+ });
167
+
168
+ // Wraps a single weighted mesh display in a full DragonBones doc. Bones are listed CHILD-FIRST so the
169
+ // topo-sort reorders (output [root=0, child=1]); the weights reference bones by armature FILE-ORDER index.
170
+ function weightedMeshDoc(mesh: Record<string, unknown>): string {
171
+ return JSON.stringify({
172
+ armature: [
173
+ {
174
+ bone: [{ name: 'child', parent: 'root' }, { name: 'root' }],
175
+ slot: [{ name: 's', parent: 'root' }],
176
+ skin: [{ name: 'default', slot: [{ name: 's', display: [{ type: 'mesh', name: 'm', ...mesh }] }] }],
177
+ },
178
+ ],
179
+ });
180
+ }
181
+
182
+ it('converts a WEIGHTED mesh to Skin2D offsets, remapping bone indices through the topo-sort (formula parity)', () => {
183
+ // slotPose identity; the used bone is armature raw index 0 ('child'), which the topo-sort places at
184
+ // OUTPUT index 1. Its bind matrix is translate(10,0), so the inverse maps vertex (12,5) → bind-local (2,5).
185
+ const mesh = parseDragonBonesSkeleton(
186
+ weightedMeshDoc({
187
+ uvs: [0, 0],
188
+ triangles: [],
189
+ vertices: [12, 5],
190
+ slotPose: [1, 0, 0, 1, 0, 0],
191
+ bonePose: [0, 1, 0, 0, 1, 10, 0], // rawBoneIndex 0, bind = translate(10,0)
192
+ weights: [1, 0, 1], // vertex 0: one influence, rawBoneIndex 0, weight 1
193
+ }),
194
+ )!.skeleton.slots![0].attachment as MeshAttachment2D;
195
+ expect(mesh.vertices).toBeNull();
196
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([1]);
197
+ // [outputBoneIndex, offsetX, offsetY, weight]: raw bone 0 ('child') → OUTPUT 1; offset (2,5).
198
+ expect(Array.from(mesh.skin!.influences)).toEqual([1, 2, 5, 1]);
199
+ });
200
+
201
+ it('applies the inverse bind ROTATION when converting a weighted mesh (formula parity)', () => {
202
+ // Bind matrix = a 90° rotation ([a,b,c,d]=[0,1,-1,0]); its inverse rotates the vertex (1,0) to (0,-1).
203
+ const mesh = parseDragonBonesSkeleton(
204
+ weightedMeshDoc({
205
+ uvs: [0, 0],
206
+ triangles: [],
207
+ vertices: [1, 0],
208
+ slotPose: [1, 0, 0, 1, 0, 0],
209
+ bonePose: [1, 0, 1, -1, 0, 0, 0], // rawBoneIndex 1 ('root' → output 0), bind = 90° rotation
210
+ weights: [1, 1, 1], // vertex 0: one influence, rawBoneIndex 1, weight 1
211
+ }),
212
+ )!.skeleton.slots![0].attachment as MeshAttachment2D;
213
+ const influences = Array.from(mesh.skin!.influences);
214
+ expect(influences[0]).toBe(0); // raw bone 1 ('root') → OUTPUT 0
215
+ expect(influences[1]).toBeCloseTo(0, 5); // offsetX
216
+ expect(influences[2]).toBeCloseTo(-1, 5); // offsetY
217
+ expect(influences[3]).toBe(1); // weight
218
+ });
219
+
220
+ it('recovers (Recover crumb) a weighted mesh whose stream is truncated, without reading past the end', () => {
221
+ const kinds = collectImportDiagnostics((sink) =>
222
+ parseDragonBonesSkeleton(
223
+ weightedMeshDoc({
224
+ uvs: [0, 0],
225
+ triangles: [],
226
+ vertices: [0, 0],
227
+ slotPose: [1, 0, 0, 1, 0, 0],
228
+ bonePose: [1, 1, 0, 0, 1, 0, 0],
229
+ weights: [2, 1, 1], // declares 2 influences but only supplies one before the stream ends
230
+ }),
231
+ sink,
232
+ ),
233
+ ).map((c) => c.kind);
234
+ expect(kinds).toContain('dragonbones.weighted-mesh-recovered');
235
+ });
236
+
237
+ it('holds an unmodeled display at its displayIndex slot (null, not dropped) so indices stay aligned', () => {
238
+ // A boundingBox display at index 0 must NOT drop, or the image at index 1 would shift to 0 and
239
+ // displayIndex 1 would then address the wrong display (read-integrity axis 12 on the display array).
240
+ const doc = {
241
+ armature: [
242
+ {
243
+ bone: [{ name: 'root' }],
244
+ slot: [{ name: 's', parent: 'root', displayIndex: 1 }],
245
+ skin: [
246
+ {
247
+ name: 'default',
248
+ slot: [
249
+ {
250
+ name: 's',
251
+ display: [
252
+ { type: 'boundingBox', name: 'bb' },
253
+ { name: 'img', transform: { x: 9 } },
254
+ ],
255
+ },
256
+ ],
257
+ },
258
+ ],
259
+ },
260
+ ],
261
+ };
262
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
263
+ parseDragonBonesSkeleton(JSON.stringify(doc), sink),
264
+ );
265
+ expect(crumbs.map((c) => c.kind)).toContain('dragonbones.boundingBox-display-unsupported');
266
+ // displayIndex 1 still resolves to the image, not shifted down by the dropped mesh.
267
+ const region = parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as RegionAttachment2D;
268
+ expect(region.kind).toBe(RegionAttachment2DKind);
269
+ expect(region.x).toBe(9);
270
+ });
271
+
272
+ it('Skip-crumbs additional armatures, alternate skins, and the unmodeled animation section', () => {
273
+ const doc = {
274
+ armature: [
275
+ {
276
+ bone: [{ name: 'root' }],
277
+ slot: [{ name: 's', parent: 'root' }],
278
+ skin: [
279
+ { name: 'default', slot: [] },
280
+ { name: 'costume2', slot: [] },
281
+ ],
282
+ animation: [{ name: 'idle', duration: 1 }],
283
+ },
284
+ { bone: [{ name: 'other' }] },
285
+ ],
286
+ };
287
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
288
+ parseDragonBonesSkeleton(JSON.stringify(doc), sink),
289
+ );
290
+ const kinds = crumbs.map((c) => c.kind);
291
+ expect(kinds).toContain('dragonbones.multi-armature-unsupported');
292
+ expect(kinds).toContain('dragonbones.alternate-skin-unsupported');
293
+ expect(kinds).toContain('dragonbones.animation-unsupported');
294
+ // Only the first armature is parsed.
295
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0].name).toBe('root');
296
+ });
297
+
298
+ it('emits a bone with an unresolved parent as a root and Skip-crumbs it', () => {
299
+ const doc = { armature: [{ bone: [{ name: 'orphan', parent: 'ghost' }] }] };
300
+ const kinds = collectImportDiagnostics((sink) => parseDragonBonesSkeleton(JSON.stringify(doc), sink)).map(
301
+ (c) => c.kind,
302
+ );
303
+ expect(kinds).toContain('dragonbones.unresolved-bone-parent');
304
+ expect(parseDragonBonesSkeleton(JSON.stringify(doc))!.skeleton.bones[0].parentIndex).toBe(-1);
305
+ });
306
+
307
+ it('returns null for malformed JSON and for a non-DragonBones document (no armature)', () => {
308
+ expect(parseDragonBonesSkeleton('{ not json')).toBeNull();
309
+ expect(parseDragonBonesSkeleton('42')).toBeNull();
310
+ expect(parseDragonBonesSkeleton(JSON.stringify({ bones: [] }))).toBeNull(); // a Spine-shaped doc
311
+ expect(parseDragonBonesSkeleton(JSON.stringify({ armature: [] }))).toBeNull(); // empty armature list
312
+ });
313
+ });
@@ -0,0 +1,50 @@
1
+ import type { ImportDiagnostic, Skeleton2DImport } from '@flighthq/types/contract';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { parseSkeleton2D, registerSkeleton2DFormat } from './skeletonDetect';
5
+
6
+ describe('parseSkeleton2D', () => {
7
+ it('auto-detects a Spine JSON document and parses it', () => {
8
+ const doc = JSON.stringify({ skeleton: { spine: '4.1' }, bones: [{ name: 'root' }] });
9
+ const result = parseSkeleton2D(doc);
10
+ expect(result).not.toBeNull();
11
+ expect(result!.skeleton.bones.length).toBe(1);
12
+ expect(result!.skeleton.bones[0].name).toBe('root');
13
+ });
14
+
15
+ it('threads the diagnostics sink through to the detected parser', () => {
16
+ const crumbs: ImportDiagnostic[] = [];
17
+ const doc = JSON.stringify({
18
+ bones: [{ name: 'root' }],
19
+ slots: [{ name: 's', bone: 'root', attachment: 'c' }],
20
+ skins: [{ name: 'default', attachments: { s: { c: { type: 'point' } } } }],
21
+ });
22
+ parseSkeleton2D(doc, crumbs);
23
+ expect(crumbs.map((c) => c.kind)).toContain('spine.point-attachment-unsupported');
24
+ });
25
+
26
+ it('auto-detects a DragonBones JSON document (armature container) and parses it', () => {
27
+ const doc = JSON.stringify({ version: '5.5', armature: [{ bone: [{ name: 'root' }] }] });
28
+ const result = parseSkeleton2D(doc);
29
+ expect(result).not.toBeNull();
30
+ expect(result!.skeleton.bones[0].name).toBe('root');
31
+ });
32
+
33
+ it('returns null when no registered format recognizes the text', () => {
34
+ expect(parseSkeleton2D('not a skeleton')).toBeNull();
35
+ expect(parseSkeleton2D('<xml/>')).toBeNull();
36
+ expect(parseSkeleton2D('{ "unrelated": true }')).toBeNull();
37
+ });
38
+ });
39
+
40
+ describe('registerSkeleton2DFormat', () => {
41
+ it('registers a custom (vendor-prefixed) format that parseSkeleton2D then dispatches to', () => {
42
+ const sentinel = { animations: [], skeleton: { bones: [], slots: null } } as unknown as Skeleton2DImport;
43
+ registerSkeleton2DFormat(
44
+ 'acme.Rig',
45
+ (text) => text.startsWith('ACME'),
46
+ () => sentinel,
47
+ );
48
+ expect(parseSkeleton2D('ACME rig v1')).toBe(sentinel);
49
+ });
50
+ });
@@ -0,0 +1,292 @@
1
+ import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
2
+ import { applyAnimationClipToSkeleton2D, cloneSkeleton2D } from '@flighthq/skeleton2d/contract';
3
+ import type { ImportDiagnostic, MeshAttachment2D, RegionAttachment2D } from '@flighthq/types/contract';
4
+ import { MeshAttachment2DKind, RegionAttachment2DKind, TransformMode2D } from '@flighthq/types/contract';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { parseSpineSkeleton } from './spineParse';
8
+
9
+ // Hand-authored minimal Spine skeleton JSON (per the real-asset rule: committed fixtures are hand-written,
10
+ // never transcribed from a licensed rig). Two bones: a root, and a child that sets every transform field.
11
+ const SPINE_TWO_BONES = JSON.stringify({
12
+ skeleton: { spine: '4.1', hash: 'x' },
13
+ bones: [
14
+ { name: 'root' },
15
+ {
16
+ name: 'arm',
17
+ parent: 'root',
18
+ length: 50,
19
+ x: 10,
20
+ y: 20,
21
+ rotation: 45,
22
+ scaleX: 2,
23
+ scaleY: 3,
24
+ shearX: 5,
25
+ shearY: 6,
26
+ transform: 'onlyTranslation',
27
+ },
28
+ ],
29
+ });
30
+
31
+ describe('parseSpineSkeleton', () => {
32
+ it('parses the bone hierarchy: names, parent resolution, TRS, and transform mode', () => {
33
+ const result = parseSpineSkeleton(SPINE_TWO_BONES);
34
+ expect(result).not.toBeNull();
35
+ const bones = result!.skeleton.bones;
36
+ expect(bones.length).toBe(2);
37
+ expect(bones[0].name).toBe('root');
38
+ expect(bones[0].parentIndex).toBe(-1);
39
+ expect(bones[0].transformMode).toBe(TransformMode2D.Normal);
40
+
41
+ const arm = bones[1];
42
+ expect(arm.name).toBe('arm');
43
+ expect(arm.parentIndex).toBe(0); // 'root' resolved to index 0
44
+ expect(arm.length).toBe(50);
45
+ expect(arm.x).toBe(10);
46
+ expect(arm.y).toBe(20);
47
+ expect(arm.rotation).toBe(45);
48
+ expect(arm.scaleX).toBe(2);
49
+ expect(arm.scaleY).toBe(3);
50
+ expect(arm.shearX).toBe(5);
51
+ expect(arm.shearY).toBe(6);
52
+ expect(arm.transformMode).toBe(TransformMode2D.OnlyTranslation);
53
+ });
54
+
55
+ it('applies Spine defaults for omitted bone fields', () => {
56
+ const bones = parseSpineSkeleton(JSON.stringify({ bones: [{ name: 'b' }] }))!.skeleton.bones;
57
+ expect(bones[0]).toMatchObject({
58
+ x: 0,
59
+ y: 0,
60
+ rotation: 0,
61
+ scaleX: 1,
62
+ scaleY: 1,
63
+ shearX: 0,
64
+ shearY: 0,
65
+ length: 0,
66
+ parentIndex: -1,
67
+ transformMode: TransformMode2D.Normal,
68
+ });
69
+ });
70
+
71
+ it('maps every Spine transform-mode string', () => {
72
+ const doc = {
73
+ bones: [
74
+ { name: 'a', transform: 'noRotationOrReflection' },
75
+ { name: 'b', transform: 'noScale' },
76
+ { name: 'c', transform: 'noScaleOrReflection' },
77
+ { name: 'd', transform: 'bogus' },
78
+ ],
79
+ };
80
+ const bones = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.bones;
81
+ expect(bones[0].transformMode).toBe(TransformMode2D.NoRotationOrReflection);
82
+ expect(bones[1].transformMode).toBe(TransformMode2D.NoScale);
83
+ expect(bones[2].transformMode).toBe(TransformMode2D.NoScaleOrReflection);
84
+ expect(bones[3].transformMode).toBe(TransformMode2D.Normal); // unknown → default
85
+ });
86
+
87
+ it('returns null for malformed JSON and for a non-object document', () => {
88
+ expect(parseSpineSkeleton('{ not json')).toBeNull();
89
+ expect(parseSpineSkeleton('42')).toBeNull();
90
+ expect(parseSpineSkeleton('null')).toBeNull();
91
+ });
92
+
93
+ it('best-efforts an empty skeleton when bones are missing', () => {
94
+ const result = parseSpineSkeleton(JSON.stringify({ skeleton: { spine: '4.1' } }));
95
+ expect(result).not.toBeNull();
96
+ expect(result!.skeleton.bones.length).toBe(0);
97
+ expect(result!.animations).toEqual([]);
98
+ });
99
+
100
+ it('parses slots with resolved bone index, draw-order, region attachment, and color', () => {
101
+ const doc = {
102
+ bones: [{ name: 'root' }, { name: 'armBone', parent: 'root' }],
103
+ slots: [{ name: 'arm', bone: 'armBone', attachment: 'armImage', color: '80c0ffff' }],
104
+ skins: [
105
+ {
106
+ name: 'default',
107
+ attachments: { arm: { armImage: { type: 'region', x: 1, y: 2, rotation: 30, width: 40, height: 20 } } },
108
+ },
109
+ ],
110
+ };
111
+ const slots = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots!;
112
+ expect(slots.length).toBe(1);
113
+ expect(slots[0].name).toBe('arm');
114
+ expect(slots[0].boneIndex).toBe(1); // 'armBone' resolved
115
+ expect(slots[0].color).toBe(0x80c0ffff);
116
+ const region = slots[0].attachment as RegionAttachment2D;
117
+ expect(region.kind).toBe(RegionAttachment2DKind);
118
+ expect(region).toMatchObject({ x: 1, y: 2, rotation: 30, width: 40, height: 20, scaleX: 1, scaleY: 1 });
119
+ });
120
+
121
+ it('parses an unweighted mesh attachment (positions local to the bone, skin null)', () => {
122
+ const doc = {
123
+ bones: [{ name: 'root' }],
124
+ slots: [{ name: 's', bone: 'root', attachment: 'm' }],
125
+ skins: [
126
+ {
127
+ name: 'default',
128
+ attachments: {
129
+ s: { m: { type: 'mesh', uvs: [0, 0, 1, 0, 1, 1], triangles: [0, 1, 2], vertices: [0, 0, 10, 0, 10, 10] } },
130
+ },
131
+ },
132
+ ],
133
+ };
134
+ const mesh = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
135
+ expect(mesh.kind).toBe(MeshAttachment2DKind);
136
+ expect(mesh.skin).toBeNull();
137
+ expect(mesh.vertexCount).toBe(3);
138
+ expect(Array.from(mesh.vertices!)).toEqual([0, 0, 10, 0, 10, 10]);
139
+ expect(Array.from(mesh.triangles)).toEqual([0, 1, 2]);
140
+ });
141
+
142
+ it('parses a weighted mesh attachment into a Skin2D influence stream', () => {
143
+ // 1 vertex, 2 influences: bone 0 offset (1,2) w0.25, bone 1 offset (3,4) w0.75. uvs give vertexCount=1.
144
+ const doc = {
145
+ bones: [{ name: 'a' }, { name: 'b' }],
146
+ slots: [{ name: 's', bone: 'a', attachment: 'm' }],
147
+ skins: [
148
+ {
149
+ name: 'default',
150
+ attachments: {
151
+ s: { m: { type: 'mesh', uvs: [0, 0], triangles: [], vertices: [2, 0, 1, 2, 0.25, 1, 3, 4, 0.75] } },
152
+ },
153
+ },
154
+ ],
155
+ };
156
+ const mesh = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
157
+ expect(mesh.vertices).toBeNull();
158
+ expect(mesh.skin).not.toBeNull();
159
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([2]);
160
+ expect(Array.from(mesh.skin!.influences)).toEqual([0, 1, 2, 0.25, 1, 3, 4, 0.75]);
161
+ });
162
+
163
+ it('recovers a malformed bone as an aligned placeholder so file-order indices stay valid (read-integrity axis 12)', () => {
164
+ // The bone array is positionally referenced by weighted-mesh influences, so a malformed entry must hold
165
+ // its slot rather than drop — else every later bone shifts and those indices point at the wrong bone.
166
+ const doc = { bones: [{ name: 'a' }, null, { name: 'c' }] };
167
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
168
+ parseSpineSkeleton(JSON.stringify(doc), sink),
169
+ );
170
+ const bones = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.bones;
171
+ expect(bones.length).toBe(3); // placeholder holds index 1
172
+ expect(bones[0].name).toBe('a');
173
+ expect(bones[1].name).toBeNull(); // inert placeholder
174
+ expect(bones[2].name).toBe('c'); // still at index 2 — NOT shifted down to 1
175
+ expect(crumbs.map((c) => c.kind)).toContain('spine.malformed-bone-recovered');
176
+ });
177
+
178
+ it('bounds a weighted-vertex stream against its actual length instead of reading past it (read-integrity axis 13)', () => {
179
+ // vertexCount = 1 (from uvs); the stream declares boneCount 5 but supplies only one (boneIndex,x,y,weight)
180
+ // quad. The declared count is clamped to what the stream actually holds — no undefined→NaN, no runaway loop.
181
+ const doc = {
182
+ bones: [{ name: 'a' }],
183
+ slots: [{ name: 's', bone: 'a', attachment: 'm' }],
184
+ skins: [
185
+ {
186
+ name: 'default',
187
+ attachments: { s: { m: { type: 'mesh', uvs: [0, 0], triangles: [], vertices: [5, 0, 1, 2, 0.25] } } },
188
+ },
189
+ ],
190
+ };
191
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
192
+ parseSpineSkeleton(JSON.stringify(doc), sink),
193
+ );
194
+ const mesh = parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment as MeshAttachment2D;
195
+ expect(Array.from(mesh.skin!.influenceCounts)).toEqual([1]); // clamped 5 → 1
196
+ expect(Array.from(mesh.skin!.influences)).toEqual([0, 1, 2, 0.25]); // exactly the one available quad
197
+ expect(crumbs.map((c) => c.kind)).toContain('spine.weighted-vertices-truncated');
198
+ });
199
+
200
+ it('Skip-crumbs an unmodeled attachment type and an alternate skin', () => {
201
+ const doc = {
202
+ bones: [{ name: 'root' }],
203
+ slots: [{ name: 's', bone: 'root', attachment: 'clip' }],
204
+ skins: [
205
+ { name: 'default', attachments: { s: { clip: { type: 'clipping', end: 's', vertexCount: 4, vertices: [] } } } },
206
+ { name: 'costume2', attachments: {} },
207
+ ],
208
+ };
209
+ const crumbs: ImportDiagnostic[] = collectImportDiagnostics((sink) =>
210
+ parseSpineSkeleton(JSON.stringify(doc), sink),
211
+ );
212
+ expect(crumbs.map((c) => c.kind)).toContain('spine.clipping-attachment-unsupported');
213
+ expect(crumbs.map((c) => c.kind)).toContain('spine.alternate-skin-unsupported');
214
+ // The clipping attachment was dropped (not shown on the slot).
215
+ expect(parseSpineSkeleton(JSON.stringify(doc))!.skeleton.slots![0].attachment).toBeNull();
216
+ });
217
+
218
+ it('builds a named animation clip of RELATIVE deltas that compose onto the setup pose', () => {
219
+ const doc = {
220
+ bones: [{ name: 'b', rotation: 10, x: 5, scaleX: 2 }],
221
+ animations: {
222
+ walk: {
223
+ bones: {
224
+ b: {
225
+ rotate: [
226
+ { time: 0, value: 0 },
227
+ { time: 1, value: 90 },
228
+ ],
229
+ translate: [
230
+ { time: 0, x: 0, y: 0 },
231
+ { time: 1, x: 20, y: 0 },
232
+ ],
233
+ scale: [
234
+ { time: 0, x: 1, y: 1 },
235
+ { time: 1, x: 3, y: 1 },
236
+ ],
237
+ },
238
+ },
239
+ },
240
+ },
241
+ };
242
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
243
+ expect(result.animations.length).toBe(1);
244
+ expect(result.animations[0].name).toBe('walk');
245
+ // Compose the clip onto a pose clone at t=1. The end result is identical to the old setup-baked
246
+ // encoding — rotation 10+90, x 5+20, scaleX 2*3 — confirming the relative-delta switch is numerically
247
+ // neutral on the original rig; the win is portability/blending, not different numbers.
248
+ const setup = result.skeleton;
249
+ const pose = cloneSkeleton2D(setup);
250
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
251
+ expect(pose.bones[0].rotation).toBeCloseTo(100, 5);
252
+ expect(pose.bones[0].x).toBeCloseTo(25, 5);
253
+ expect(pose.bones[0].scaleX).toBeCloseTo(6, 5); // multiplier: setup 2 × 3
254
+ expect(setup.bones[0].rotation).toBe(10); // the parsed setup pose is left intact
255
+ });
256
+
257
+ it('uses Step interpolation when every keyframe of a timeline is stepped', () => {
258
+ const doc = {
259
+ bones: [{ name: 'b' }],
260
+ animations: {
261
+ a: {
262
+ bones: {
263
+ b: {
264
+ rotate: [
265
+ { time: 0, value: 0, curve: 'stepped' },
266
+ { time: 1, value: 90, curve: 'stepped' },
267
+ ],
268
+ },
269
+ },
270
+ },
271
+ },
272
+ };
273
+ const result = parseSpineSkeleton(JSON.stringify(doc))!;
274
+ const pose = cloneSkeleton2D(result.skeleton);
275
+ applyAnimationClipToSkeleton2D(result.animations[0].clip, result.skeleton, pose, 0.9);
276
+ expect(pose.bones[0].rotation).toBeCloseTo(0, 5); // stepped holds the t=0 keyframe (delta 0) until t=1
277
+ });
278
+
279
+ it('Skip-crumbs constraint, event, and slot animation timelines', () => {
280
+ const doc = {
281
+ bones: [{ name: 'b' }],
282
+ animations: {
283
+ a: { bones: {}, ik: { c1: [] }, transform: { t1: [] }, events: [{ time: 0, name: 'e' }], slots: { s: {} } },
284
+ },
285
+ };
286
+ const kinds = collectImportDiagnostics((sink) => parseSpineSkeleton(JSON.stringify(doc), sink)).map((c) => c.kind);
287
+ expect(kinds).toContain('spine.ik-timeline-unsupported');
288
+ expect(kinds).toContain('spine.transform-timeline-unsupported');
289
+ expect(kinds).toContain('spine.event-timeline-unsupported');
290
+ expect(kinds).toContain('spine.slot-timeline-unsupported');
291
+ });
292
+ });