@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.
- package/dist/contract.d.ts +5 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +5 -0
- package/dist/contract.js.map +1 -0
- package/dist/dragonBonesParse.d.ts +3 -0
- package/dist/dragonBonesParse.d.ts.map +1 -0
- package/dist/dragonBonesParse.js +867 -0
- package/dist/dragonBonesParse.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/skeletonDetect.d.ts +4 -0
- package/dist/skeletonDetect.d.ts.map +1 -0
- package/dist/skeletonDetect.js +47 -0
- package/dist/skeletonDetect.js.map +1 -0
- package/dist/spineBinaryParse.d.ts +3 -0
- package/dist/spineBinaryParse.d.ts.map +1 -0
- package/dist/spineBinaryParse.js +874 -0
- package/dist/spineBinaryParse.js.map +1 -0
- package/dist/spineBinaryReader.d.ts +14 -0
- package/dist/spineBinaryReader.d.ts.map +1 -0
- package/dist/spineBinaryReader.js +137 -0
- package/dist/spineBinaryReader.js.map +1 -0
- package/dist/spineParse.d.ts +3 -0
- package/dist/spineParse.d.ts.map +1 -0
- package/dist/spineParse.js +619 -0
- package/dist/spineParse.js.map +1 -0
- package/package.json +50 -0
- package/src/dragonBonesParse.test.ts +1042 -0
- package/src/skeletonDetect.test.ts +50 -0
- package/src/spineBinaryParse.test.ts +418 -0
- package/src/spineBinaryReader.test.ts +214 -0
- package/src/spineParse.test.ts +714 -0
|
@@ -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,418 @@
|
|
|
1
|
+
import { easeCubicBezier } from '@flighthq/easing/contract';
|
|
2
|
+
import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
|
|
3
|
+
import { applyAnimationClipToSkeleton2D, cloneSkeleton2D } from '@flighthq/skeleton2d/contract';
|
|
4
|
+
import type { RegionAttachment2D } from '@flighthq/types/contract';
|
|
5
|
+
import { RegionAttachment2DKind, TransformMode2D } from '@flighthq/types/contract';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { parseSpineSkeletonBinary } from './spineBinaryParse';
|
|
9
|
+
|
|
10
|
+
describe('parseSpineSkeletonBinary', () => {
|
|
11
|
+
it('parses the header, bones, and slots of a 4.x file', () => {
|
|
12
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary())!;
|
|
13
|
+
expect(result).not.toBeNull();
|
|
14
|
+
const bones = result.skeleton.bones;
|
|
15
|
+
expect(bones.length).toBe(2);
|
|
16
|
+
expect(bones[0]).toMatchObject({ name: 'root', parentIndex: -1, rotation: 0, x: 0, y: 0 });
|
|
17
|
+
// Bone 0 writes NO parent index — the reader must not consume one for it, or every later field shifts.
|
|
18
|
+
expect(bones[1]).toMatchObject({
|
|
19
|
+
length: 26.25,
|
|
20
|
+
name: 'hip',
|
|
21
|
+
parentIndex: 0,
|
|
22
|
+
rotation: 19.5,
|
|
23
|
+
scaleX: 2,
|
|
24
|
+
scaleY: 0.5,
|
|
25
|
+
shearX: 1.5,
|
|
26
|
+
shearY: -1.5,
|
|
27
|
+
x: 1.25,
|
|
28
|
+
y: 247.5,
|
|
29
|
+
});
|
|
30
|
+
const slots = result.skeleton.slots!;
|
|
31
|
+
expect(slots.length).toBe(1);
|
|
32
|
+
expect(slots[0]).toMatchObject({ boneIndex: 1, color: 0x80c0ffff, name: 'body' });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('builds a named clip of RELATIVE bone deltas, like the .json parser', () => {
|
|
36
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary())!;
|
|
37
|
+
expect(result.animations.length).toBe(1);
|
|
38
|
+
expect(result.animations[0].name).toBe('walk');
|
|
39
|
+
// Setup rotation is 19.5 on the hip; the timeline's +90 delta composes onto it rather than replacing it.
|
|
40
|
+
const setup = result.skeleton;
|
|
41
|
+
const pose = cloneSkeleton2D(setup);
|
|
42
|
+
applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
|
|
43
|
+
expect(pose.bones[1].rotation).toBeCloseTo(109.5, 4);
|
|
44
|
+
expect(setup.bones[1].rotation).toBeCloseTo(19.5, 4); // setup left intact
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('widens a PER-AXIS timeline, leaving the untouched axis at its setup value', () => {
|
|
48
|
+
// Ordinal 2 is translateX: one value per keyframe, driving only x. y must receive the identity delta,
|
|
49
|
+
// not garbage — a two-component path cannot express "x only" any other way.
|
|
50
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary({ boneTimelineType: 2 }))!;
|
|
51
|
+
const setup = result.skeleton;
|
|
52
|
+
const pose = cloneSkeleton2D(setup);
|
|
53
|
+
applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
|
|
54
|
+
expect(pose.bones[1].x).toBeCloseTo(1.25 + 90, 4); // setup x + delta
|
|
55
|
+
expect(pose.bones[1].y).toBeCloseTo(247.5, 4); // setup y, untouched
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('drives BOTH components from a combined translate timeline', () => {
|
|
59
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary({ boneTimelineType: 1 }))!;
|
|
60
|
+
const setup = result.skeleton;
|
|
61
|
+
const pose = cloneSkeleton2D(setup);
|
|
62
|
+
applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 1);
|
|
63
|
+
expect(pose.bones[1].x).toBeCloseTo(1.25 + 90, 4);
|
|
64
|
+
expect(pose.bones[1].y).toBeCloseTo(247.5 + 90, 4);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rebases a BEZIER segment onto per-interval easing, in absolute time/value units', () => {
|
|
68
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary({ bezier: true }))!;
|
|
69
|
+
const track = result.animations[0].clip.channels[0].track;
|
|
70
|
+
expect(track.segmentEasings).not.toBeNull();
|
|
71
|
+
const setup = result.skeleton;
|
|
72
|
+
const pose = cloneSkeleton2D(setup);
|
|
73
|
+
applyAnimationClipToSkeleton2D(result.animations[0].clip, setup, pose, 0.5);
|
|
74
|
+
// curve [0.42, 0, 1, 90] over a 0..1s / 0..90deg segment is the CSS ease-in shape.
|
|
75
|
+
expect(pose.bones[1].rotation).toBeCloseTo(19.5 + 90 * easeCubicBezier(0.42, 0, 1, 1)(0.5), 3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves a LINEAR timeline with no segment easings', () => {
|
|
79
|
+
const track = parseSpineSkeletonBinary(buildSpineBinary())!.animations[0].clip.channels[0].track;
|
|
80
|
+
expect(track.segmentEasings).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves a slot to the setup attachment the SKIN defines, which is read after the slot', () => {
|
|
84
|
+
// The slot names its attachment before the skin exists in the stream, so this is the ordering the parser
|
|
85
|
+
// has to defer: name captured during slots, resolved once the skin is parsed.
|
|
86
|
+
const region = parseSpineSkeletonBinary(buildSpineBinary())!.skeleton.slots![0].attachment as RegionAttachment2D;
|
|
87
|
+
expect(region).not.toBeNull();
|
|
88
|
+
expect(region.kind).toBe(RegionAttachment2DKind);
|
|
89
|
+
expect(region).toMatchObject({ height: 32, name: 'body-attachment', rotation: 12.5, width: 64, x: 3.5, y: 4.5 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('parses a RIGID mesh attachment: uvs, triangles, and bone-local positions', () => {
|
|
93
|
+
const skin = parseSpineSkeletonBinary(buildSpineBinary())!;
|
|
94
|
+
// The mesh is the second attachment on the slot; it is not the setup one, so reach it via the region's
|
|
95
|
+
// sibling — the parse must still have consumed it correctly for the file to end cleanly.
|
|
96
|
+
const crumbs = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(buildSpineBinary(), sink));
|
|
97
|
+
expect(crumbs.find((c) => c.kind === 'spine.binary-tail-unparsed')!.detail).toMatchObject({ bytes: 0 });
|
|
98
|
+
expect(skin.skeleton.slots!.length).toBe(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('decodes a WEIGHTED mesh into Skin2D influences without re-packing', () => {
|
|
102
|
+
// Spine's binary influence stream is already [boneIndex, x, y, weight] per influence, which is exactly
|
|
103
|
+
// Skin2D's layout — so a wrong stride here would surface as a clean end-of-file failure.
|
|
104
|
+
const crumbs = collectImportDiagnostics((sink) =>
|
|
105
|
+
parseSpineSkeletonBinary(buildSpineBinary({ weightedMesh: true }), sink),
|
|
106
|
+
);
|
|
107
|
+
expect(crumbs.find((c) => c.kind === 'spine.binary-tail-unparsed')!.detail).toMatchObject({ bytes: 0 });
|
|
108
|
+
expect(crumbs.map((c) => c.kind)).not.toContain('spine.binary-truncated');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('CONSUMES IK constraint records it does not model, so the skin after them still parses', () => {
|
|
112
|
+
// Constraints cannot be skipped — they carry no length — so mis-walking one desynchronizes the skin.
|
|
113
|
+
// A resolved attachment on the far side is therefore the proof that the walk was byte-exact.
|
|
114
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary({ ikConstraints: 2 }))!;
|
|
115
|
+
expect(result.skeleton.slots![0].attachment).not.toBeNull();
|
|
116
|
+
const kinds = collectImportDiagnostics((sink) =>
|
|
117
|
+
parseSpineSkeletonBinary(buildSpineBinary({ ikConstraints: 2 }), sink),
|
|
118
|
+
).map((c) => c.kind);
|
|
119
|
+
expect(kinds).toContain('spine.ik-constraint-unsupported');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('REJECTS a version whose record layout this importer does not describe', () => {
|
|
123
|
+
const crumbs = collectImportDiagnostics((sink) =>
|
|
124
|
+
expect(parseSpineSkeletonBinary(buildSpineBinary({ version: '3.8.99' }), sink)).toBeNull(),
|
|
125
|
+
);
|
|
126
|
+
const crumb = crumbs.find((c) => c.kind === 'spine.binary-version-unsupported')!;
|
|
127
|
+
expect(crumb.detail).toMatchObject({ version: '3.8.99' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('REJECTS an unreadable header rather than decoding garbage', () => {
|
|
131
|
+
const kinds = collectImportDiagnostics((sink) =>
|
|
132
|
+
expect(parseSpineSkeletonBinary(Uint8Array.from([1, 2, 3]), sink)).toBeNull(),
|
|
133
|
+
).map((c) => c.kind);
|
|
134
|
+
expect(kinds).toContain('spine.binary-header-unreadable');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('reports how many bytes of the file it did not parse', () => {
|
|
138
|
+
const bytes = buildSpineBinary();
|
|
139
|
+
const crumbs = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(bytes, sink));
|
|
140
|
+
const tail = crumbs.find((c) => c.kind === 'spine.binary-tail-unparsed')!;
|
|
141
|
+
// The fixture ends exactly where this landing stops parsing, so nothing is left over — the crumb still
|
|
142
|
+
// fires to record that the importer STOPPED rather than finished, which is the honest signal while the
|
|
143
|
+
// event and animation sections are still unmodeled.
|
|
144
|
+
expect(tail.detail).toMatchObject({ bytes: 0 });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('RECOVERS from a truncated file, keeping whatever records were complete', () => {
|
|
148
|
+
const full = buildSpineBinary();
|
|
149
|
+
const truncated = full.subarray(0, full.byteLength - 6);
|
|
150
|
+
const crumbs = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(truncated, sink));
|
|
151
|
+
expect(crumbs.map((c) => c.kind)).toContain('spine.binary-truncated');
|
|
152
|
+
// It still returns a skeleton rather than null or a throw: the bones completed before the cut.
|
|
153
|
+
const result = parseSpineSkeletonBinary(truncated)!;
|
|
154
|
+
expect(result.skeleton.bones.length).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('maps the transform-mode ORDINAL positionally, falling back to Normal when out of range', () => {
|
|
158
|
+
const modes = [
|
|
159
|
+
TransformMode2D.Normal,
|
|
160
|
+
TransformMode2D.OnlyTranslation,
|
|
161
|
+
TransformMode2D.NoRotationOrReflection,
|
|
162
|
+
TransformMode2D.NoScale,
|
|
163
|
+
TransformMode2D.NoScaleOrReflection,
|
|
164
|
+
];
|
|
165
|
+
for (let ordinal = 0; ordinal < modes.length; ordinal++) {
|
|
166
|
+
const bones = parseSpineSkeletonBinary(buildSpineBinary({ transformMode: ordinal }))!.skeleton.bones;
|
|
167
|
+
expect(bones[1].transformMode).toEqual(modes[ordinal]);
|
|
168
|
+
}
|
|
169
|
+
// An ordinal from a future version with more modes must not yield an undefined inherit rule.
|
|
170
|
+
const beyond = parseSpineSkeletonBinary(buildSpineBinary({ transformMode: 99 }))!.skeleton.bones;
|
|
171
|
+
expect(beyond[1].transformMode).toEqual(TransformMode2D.Normal);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('honors the nonessential flag, which changes the bone record width', () => {
|
|
175
|
+
// With nonessential set, each bone carries a trailing editor color. Reading the flag but not the color
|
|
176
|
+
// would desynchronize every following record, so the slot below is the canary.
|
|
177
|
+
const result = parseSpineSkeletonBinary(buildSpineBinary({ nonessential: true }))!;
|
|
178
|
+
expect(result.skeleton.bones.length).toBe(2);
|
|
179
|
+
expect(result.skeleton.slots![0]).toMatchObject({ boneIndex: 1, name: 'body' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('Skip-crumbs a slot dark color, which Slot2D cannot represent', () => {
|
|
183
|
+
const kinds = collectImportDiagnostics((sink) =>
|
|
184
|
+
parseSpineSkeletonBinary(buildSpineBinary({ darkColor: 0x102030 }), sink),
|
|
185
|
+
).map((c) => c.kind);
|
|
186
|
+
expect(kinds).toContain('spine.slot-dark-color-unsupported');
|
|
187
|
+
const plain = collectImportDiagnostics((sink) => parseSpineSkeletonBinary(buildSpineBinary(), sink)).map(
|
|
188
|
+
(c) => c.kind,
|
|
189
|
+
);
|
|
190
|
+
expect(plain).not.toContain('spine.slot-dark-color-unsupported');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Builds a minimal but structurally faithful Spine 4.x `.skel`: hash, version, bounds, the nonessential
|
|
195
|
+
// flag, a string table, two bones, and one slot. The byte layout it writes is the layout verified
|
|
196
|
+
// byte-for-byte against a real 4.1.17 export (see the package status) — the encoder REPRODUCES a confirmed
|
|
197
|
+
// wire format rather than defining it, which is what keeps these tests a real check and not a round-trip
|
|
198
|
+
// against the importer's own assumptions. Real Spine assets are license-restricted and never committed, so
|
|
199
|
+
// the committed fixture is authored here.
|
|
200
|
+
function buildSpineBinary(
|
|
201
|
+
options: {
|
|
202
|
+
version?: string;
|
|
203
|
+
nonessential?: boolean;
|
|
204
|
+
transformMode?: number;
|
|
205
|
+
darkColor?: number;
|
|
206
|
+
ikConstraints?: number;
|
|
207
|
+
weightedMesh?: boolean;
|
|
208
|
+
animations?: boolean;
|
|
209
|
+
boneTimelineType?: number;
|
|
210
|
+
bezier?: boolean;
|
|
211
|
+
} = {},
|
|
212
|
+
): Uint8Array {
|
|
213
|
+
const version = options.version ?? '4.1.17';
|
|
214
|
+
const nonessential = options.nonessential ?? false;
|
|
215
|
+
const out: number[] = [];
|
|
216
|
+
for (let i = 0; i < 8; i++) out.push(0); // export hash
|
|
217
|
+
writeString(out, version);
|
|
218
|
+
for (let i = 0; i < 4; i++) writeFloat(out, 0); // x, y, width, height
|
|
219
|
+
out.push(nonessential ? 1 : 0);
|
|
220
|
+
if (nonessential) {
|
|
221
|
+
writeFloat(out, 30);
|
|
222
|
+
writeString(out, './images/');
|
|
223
|
+
writeString(out, '');
|
|
224
|
+
}
|
|
225
|
+
writeVarint(out, 2); // string table
|
|
226
|
+
writeString(out, 'body-attachment');
|
|
227
|
+
writeString(out, 'body-mesh');
|
|
228
|
+
|
|
229
|
+
writeVarint(out, 2); // bones
|
|
230
|
+
writeBone(out, { name: 'root', parentIndex: null, nonessential });
|
|
231
|
+
writeBone(out, {
|
|
232
|
+
length: 26.25,
|
|
233
|
+
name: 'hip',
|
|
234
|
+
nonessential,
|
|
235
|
+
parentIndex: 0,
|
|
236
|
+
rotation: 19.5,
|
|
237
|
+
scaleX: 2,
|
|
238
|
+
scaleY: 0.5,
|
|
239
|
+
shearX: 1.5,
|
|
240
|
+
shearY: -1.5,
|
|
241
|
+
transformMode: options.transformMode ?? 0,
|
|
242
|
+
x: 1.25,
|
|
243
|
+
y: 247.5,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
writeVarint(out, 1); // slots
|
|
247
|
+
writeString(out, 'body');
|
|
248
|
+
writeVarint(out, 1); // bone index
|
|
249
|
+
writeInt(out, 0x80c0ffff);
|
|
250
|
+
writeInt(out, options.darkColor ?? -1);
|
|
251
|
+
writeVarint(out, 1); // setup attachment -> string table entry 1, 'body-attachment'
|
|
252
|
+
writeVarint(out, 0); // blend mode: normal
|
|
253
|
+
|
|
254
|
+
// Constraint sections. Flight models no solvers, but the records sit between the slots and the skins, so
|
|
255
|
+
// the counts are always written even when empty.
|
|
256
|
+
writeVarint(out, options.ikConstraints ?? 0);
|
|
257
|
+
for (let i = 0; i < (options.ikConstraints ?? 0); i++) writeIkConstraint(out, 'ik' + i);
|
|
258
|
+
writeVarint(out, 0); // transform constraints
|
|
259
|
+
writeVarint(out, 0); // path constraints
|
|
260
|
+
|
|
261
|
+
// Default skin: one slot entry carrying a region and a mesh.
|
|
262
|
+
writeVarint(out, 1); // slot entries
|
|
263
|
+
writeVarint(out, 0); // slot index
|
|
264
|
+
writeVarint(out, 2); // attachments on it
|
|
265
|
+
writeVarint(out, 1); // key -> 'body-attachment'
|
|
266
|
+
writeVarint(out, 0); // name: absent, so the key is used
|
|
267
|
+
out.push(0); // type: region
|
|
268
|
+
writeVarint(out, 0); // atlas path
|
|
269
|
+
writeFloat(out, 12.5); // rotation
|
|
270
|
+
writeFloat(out, 3.5); // x
|
|
271
|
+
writeFloat(out, 4.5); // y
|
|
272
|
+
writeFloat(out, 1); // scaleX
|
|
273
|
+
writeFloat(out, 1); // scaleY
|
|
274
|
+
writeFloat(out, 64); // width
|
|
275
|
+
writeFloat(out, 32); // height
|
|
276
|
+
writeInt(out, 0xffffffff); // color
|
|
277
|
+
out.push(0); // sequence: absent
|
|
278
|
+
writeVarint(out, 2); // key -> 'body-mesh'
|
|
279
|
+
writeVarint(out, 0);
|
|
280
|
+
out.push(2); // type: mesh
|
|
281
|
+
writeVarint(out, 0); // atlas path
|
|
282
|
+
writeInt(out, 0xffffffff); // color
|
|
283
|
+
writeVarint(out, 3); // vertex count
|
|
284
|
+
for (const uv of [0, 0, 1, 0, 1, 1]) writeFloat(out, uv);
|
|
285
|
+
writeVarint(out, 3); // triangle index count
|
|
286
|
+
for (const t of [0, 1, 2]) writeShort(out, t);
|
|
287
|
+
out.push(options.weightedMesh ? 1 : 0);
|
|
288
|
+
if (options.weightedMesh) {
|
|
289
|
+
for (let v = 0; v < 3; v++) {
|
|
290
|
+
writeVarint(out, 1); // one influence
|
|
291
|
+
writeVarint(out, 1); // bone index
|
|
292
|
+
writeFloat(out, v);
|
|
293
|
+
writeFloat(out, v * 2);
|
|
294
|
+
writeFloat(out, 1); // weight
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
for (const xy of [0, 0, 10, 0, 10, 10]) writeFloat(out, xy);
|
|
298
|
+
}
|
|
299
|
+
writeVarint(out, 0); // hull length
|
|
300
|
+
out.push(0); // sequence: absent
|
|
301
|
+
|
|
302
|
+
writeVarint(out, 0); // alternate skins
|
|
303
|
+
writeVarint(out, 0); // event definitions
|
|
304
|
+
|
|
305
|
+
// Animations. One clip driving the hip bone, so the bone-timeline path is exercised end to end.
|
|
306
|
+
writeVarint(out, options.animations === false ? 0 : 1);
|
|
307
|
+
if (options.animations !== false) {
|
|
308
|
+
writeString(out, 'walk');
|
|
309
|
+
writeVarint(out, 1); // total timeline count
|
|
310
|
+
writeVarint(out, 0); // slot timelines
|
|
311
|
+
writeVarint(out, 1); // bone timelines
|
|
312
|
+
writeVarint(out, 1); // bone index
|
|
313
|
+
writeVarint(out, 1); // timelines on it
|
|
314
|
+
out.push(options.boneTimelineType ?? 0); // 0 = rotate
|
|
315
|
+
writeVarint(out, 2); // frame count
|
|
316
|
+
writeVarint(out, options.bezier ? 1 : 0); // bezier count
|
|
317
|
+
const values = options.boneTimelineType === 1 ? 2 : 1;
|
|
318
|
+
writeFloat(out, 0); // time
|
|
319
|
+
for (let v = 0; v < values; v++) writeFloat(out, 0);
|
|
320
|
+
writeFloat(out, 1); // next time
|
|
321
|
+
for (let v = 0; v < values; v++) writeFloat(out, 90);
|
|
322
|
+
if (options.bezier) {
|
|
323
|
+
out.push(2); // CURVE_BEZIER
|
|
324
|
+
for (let v = 0; v < values; v++) {
|
|
325
|
+
writeFloat(out, 0.42); // cx1 in absolute time units
|
|
326
|
+
writeFloat(out, 0); // cy1 in absolute value units
|
|
327
|
+
writeFloat(out, 1); // cx2
|
|
328
|
+
writeFloat(out, 90); // cy2
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
out.push(0); // CURVE_LINEAR
|
|
332
|
+
}
|
|
333
|
+
writeVarint(out, 0); // ik timelines
|
|
334
|
+
writeVarint(out, 0); // transform timelines
|
|
335
|
+
writeVarint(out, 0); // path timelines
|
|
336
|
+
writeVarint(out, 0); // deform timelines
|
|
337
|
+
writeVarint(out, 0); // draw order frames
|
|
338
|
+
writeVarint(out, 0); // event frames
|
|
339
|
+
}
|
|
340
|
+
return Uint8Array.from(out);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// An IK constraint record, written only so the parser has something real to walk past.
|
|
344
|
+
function writeIkConstraint(out: number[], name: string): void {
|
|
345
|
+
writeString(out, name);
|
|
346
|
+
writeVarint(out, 0); // order
|
|
347
|
+
out.push(0); // skinRequired
|
|
348
|
+
writeVarint(out, 1); // bone count
|
|
349
|
+
writeVarint(out, 0); // bone
|
|
350
|
+
writeVarint(out, 0); // target
|
|
351
|
+
writeFloat(out, 1); // mix
|
|
352
|
+
writeFloat(out, 0); // softness
|
|
353
|
+
out.push(1, 0, 0, 0); // bendDirection, compress, stretch, uniform
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function writeShort(out: number[], value: number): void {
|
|
357
|
+
out.push((value >> 8) & 0xff, value & 0xff);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function writeBone(
|
|
361
|
+
out: number[],
|
|
362
|
+
bone: {
|
|
363
|
+
name: string;
|
|
364
|
+
parentIndex: number | null;
|
|
365
|
+
nonessential: boolean;
|
|
366
|
+
rotation?: number;
|
|
367
|
+
x?: number;
|
|
368
|
+
y?: number;
|
|
369
|
+
scaleX?: number;
|
|
370
|
+
scaleY?: number;
|
|
371
|
+
shearX?: number;
|
|
372
|
+
shearY?: number;
|
|
373
|
+
length?: number;
|
|
374
|
+
transformMode?: number;
|
|
375
|
+
},
|
|
376
|
+
): void {
|
|
377
|
+
writeString(out, bone.name);
|
|
378
|
+
if (bone.parentIndex !== null) writeVarint(out, bone.parentIndex);
|
|
379
|
+
writeFloat(out, bone.rotation ?? 0);
|
|
380
|
+
writeFloat(out, bone.x ?? 0);
|
|
381
|
+
writeFloat(out, bone.y ?? 0);
|
|
382
|
+
writeFloat(out, bone.scaleX ?? 1);
|
|
383
|
+
writeFloat(out, bone.scaleY ?? 1);
|
|
384
|
+
writeFloat(out, bone.shearX ?? 0);
|
|
385
|
+
writeFloat(out, bone.shearY ?? 0);
|
|
386
|
+
writeFloat(out, bone.length ?? 0);
|
|
387
|
+
writeVarint(out, bone.transformMode ?? 0);
|
|
388
|
+
out.push(0); // skinRequired
|
|
389
|
+
if (bone.nonessential) writeInt(out, 0xff00ffff);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function writeFloat(out: number[], value: number): void {
|
|
393
|
+
const view = new DataView(new ArrayBuffer(4));
|
|
394
|
+
view.setFloat32(0, value, false);
|
|
395
|
+
for (let i = 0; i < 4; i++) out.push(view.getUint8(i));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function writeInt(out: number[], value: number): void {
|
|
399
|
+
const view = new DataView(new ArrayBuffer(4));
|
|
400
|
+
view.setInt32(0, value, false);
|
|
401
|
+
for (let i = 0; i < 4; i++) out.push(view.getUint8(i));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Spine's string encoding: a varint of `byteCount + 1` (0 would mean "absent"), then the UTF-8 bytes.
|
|
405
|
+
function writeString(out: number[], value: string): void {
|
|
406
|
+
const bytes = new TextEncoder().encode(value);
|
|
407
|
+
writeVarint(out, bytes.length + 1);
|
|
408
|
+
for (const byte of bytes) out.push(byte);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function writeVarint(out: number[], value: number): void {
|
|
412
|
+
let remaining = value >>> 0;
|
|
413
|
+
while (remaining > 0x7f) {
|
|
414
|
+
out.push((remaining & 0x7f) | 0x80);
|
|
415
|
+
remaining >>>= 7;
|
|
416
|
+
}
|
|
417
|
+
out.push(remaining);
|
|
418
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createSpineBinaryReader,
|
|
5
|
+
hasSpineBinaryBytes,
|
|
6
|
+
isSpineBinaryReaderOverrun,
|
|
7
|
+
readSpineBinaryBoolean,
|
|
8
|
+
readSpineBinaryByte,
|
|
9
|
+
readSpineBinaryFloat,
|
|
10
|
+
readSpineBinaryInt,
|
|
11
|
+
readSpineBinarySignedVarint,
|
|
12
|
+
readSpineBinaryString,
|
|
13
|
+
readSpineBinaryUnsignedShort,
|
|
14
|
+
readSpineBinaryVarint,
|
|
15
|
+
skipSpineBinaryBytes,
|
|
16
|
+
} from './spineBinaryReader';
|
|
17
|
+
|
|
18
|
+
describe('createSpineBinaryReader', () => {
|
|
19
|
+
it('starts at the beginning and is not overrun', () => {
|
|
20
|
+
const reader = createSpineBinaryReader(Uint8Array.from([1, 2, 3]));
|
|
21
|
+
expect(reader.offset).toBe(0);
|
|
22
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reads only the window of a subarray, not the whole backing buffer', () => {
|
|
26
|
+
// A caller who slices one file out of a larger buffer must not be able to read its neighbours.
|
|
27
|
+
const backing = Uint8Array.from([0xaa, 0xbb, 0x07, 0xcc]);
|
|
28
|
+
const reader = createSpineBinaryReader(backing.subarray(2, 3));
|
|
29
|
+
expect(reader.view.byteLength).toBe(1);
|
|
30
|
+
expect(readSpineBinaryByte(reader)).toBe(0x07);
|
|
31
|
+
// The neighbouring 0xcc is outside the window, so the next read overruns rather than leaking it.
|
|
32
|
+
expect(readSpineBinaryByte(reader)).toBe(0);
|
|
33
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('hasSpineBinaryBytes', () => {
|
|
38
|
+
it('reports the bytes remaining and stays false once overrun', () => {
|
|
39
|
+
const reader = createSpineBinaryReader(Uint8Array.from([1, 2]));
|
|
40
|
+
expect(hasSpineBinaryBytes(reader, 2)).toBe(true);
|
|
41
|
+
expect(hasSpineBinaryBytes(reader, 3)).toBe(false);
|
|
42
|
+
readSpineBinaryByte(reader);
|
|
43
|
+
expect(hasSpineBinaryBytes(reader, 1)).toBe(true);
|
|
44
|
+
expect(hasSpineBinaryBytes(reader, 2)).toBe(false);
|
|
45
|
+
readSpineBinaryFloat(reader); // overruns
|
|
46
|
+
expect(hasSpineBinaryBytes(reader, 0)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('isSpineBinaryReaderOverrun', () => {
|
|
51
|
+
it('treats a cursor resting exactly at the end as a clean end-of-stream', () => {
|
|
52
|
+
const reader = createSpineBinaryReader(Uint8Array.from([1]));
|
|
53
|
+
readSpineBinaryByte(reader);
|
|
54
|
+
expect(reader.offset).toBe(1);
|
|
55
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('is STICKY — one short read short-circuits every later read', () => {
|
|
59
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x41]));
|
|
60
|
+
expect(readSpineBinaryFloat(reader)).toBe(0); // needs 4, has 1
|
|
61
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
62
|
+
// The unread 0x41 is NOT handed out afterwards: the mark short-circuits everything downstream.
|
|
63
|
+
expect(readSpineBinaryByte(reader)).toBe(0);
|
|
64
|
+
expect(readSpineBinaryString(reader)).toBeNull();
|
|
65
|
+
expect(readSpineBinaryBoolean(reader)).toBe(false);
|
|
66
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('does not consume the bytes a short read could not satisfy', () => {
|
|
70
|
+
const reader = createSpineBinaryReader(Uint8Array.from([1, 2, 3]));
|
|
71
|
+
readSpineBinaryFloat(reader); // needs 4, has 3
|
|
72
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
73
|
+
expect(reader.offset).toBeGreaterThan(reader.view.byteLength);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('readSpineBinaryBoolean', () => {
|
|
78
|
+
it('reads one byte, zero false and any nonzero true', () => {
|
|
79
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0, 1, 0xff]));
|
|
80
|
+
expect(readSpineBinaryBoolean(reader)).toBe(false);
|
|
81
|
+
expect(readSpineBinaryBoolean(reader)).toBe(true);
|
|
82
|
+
expect(readSpineBinaryBoolean(reader)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('readSpineBinaryByte', () => {
|
|
87
|
+
it('reads unsigned bytes in order', () => {
|
|
88
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x00, 0x7f, 0x80, 0xff]));
|
|
89
|
+
expect(readSpineBinaryByte(reader)).toBe(0x00);
|
|
90
|
+
expect(readSpineBinaryByte(reader)).toBe(0x7f);
|
|
91
|
+
expect(readSpineBinaryByte(reader)).toBe(0x80);
|
|
92
|
+
expect(readSpineBinaryByte(reader)).toBe(0xff);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('readSpineBinaryFloat', () => {
|
|
97
|
+
it('reads BIG-endian 32-bit floats', () => {
|
|
98
|
+
// 1.0 is 0x3f800000 and −2.5 is 0xc0200000; byte-swapped input would read as garbage, which is the
|
|
99
|
+
// point of asserting the byte order explicitly.
|
|
100
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x3f, 0x80, 0x00, 0x00, 0xc0, 0x20, 0x00, 0x00]));
|
|
101
|
+
expect(readSpineBinaryFloat(reader)).toBe(1);
|
|
102
|
+
expect(readSpineBinaryFloat(reader)).toBe(-2.5);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('readSpineBinaryInt', () => {
|
|
107
|
+
it('reads BIG-endian signed 32-bit integers', () => {
|
|
108
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x00, 0x00, 0x01, 0x00, 0xff, 0xff, 0xff, 0xff]));
|
|
109
|
+
expect(readSpineBinaryInt(reader)).toBe(256);
|
|
110
|
+
expect(readSpineBinaryInt(reader)).toBe(-1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('readSpineBinarySignedVarint', () => {
|
|
115
|
+
it('undoes the zigzag fold so small negatives cost one byte', () => {
|
|
116
|
+
// Raw varints 0,1,2,3,4 fold back to 0,−1,1,−2,2.
|
|
117
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0, 1, 2, 3, 4]));
|
|
118
|
+
expect(readSpineBinarySignedVarint(reader)).toBe(0);
|
|
119
|
+
expect(readSpineBinarySignedVarint(reader)).toBe(-1);
|
|
120
|
+
expect(readSpineBinarySignedVarint(reader)).toBe(1);
|
|
121
|
+
expect(readSpineBinarySignedVarint(reader)).toBe(-2);
|
|
122
|
+
expect(readSpineBinarySignedVarint(reader)).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('folds the widest five-byte pattern to the most negative int32', () => {
|
|
126
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0x0f]));
|
|
127
|
+
expect(readSpineBinarySignedVarint(reader)).toBe(-2147483648);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('readSpineBinaryString', () => {
|
|
132
|
+
it('distinguishes an ABSENT string from an empty one by the 0/1 length prefix', () => {
|
|
133
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x00, 0x01]));
|
|
134
|
+
expect(readSpineBinaryString(reader)).toBeNull(); // 0 = no string at all
|
|
135
|
+
expect(readSpineBinaryString(reader)).toBe(''); // 1 = present but empty
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('reads `count − 1` bytes of UTF-8, including multi-byte code points', () => {
|
|
139
|
+
// 'abc' is 3 bytes so the prefix is 4; 'é' is the two bytes c3 a9 so its prefix is 3.
|
|
140
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x04, 0x61, 0x62, 0x63, 0x03, 0xc3, 0xa9]));
|
|
141
|
+
expect(readSpineBinaryString(reader)).toBe('abc');
|
|
142
|
+
expect(readSpineBinaryString(reader)).toBe('é');
|
|
143
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('reads a string out of a subarray window without leaking past it', () => {
|
|
147
|
+
const backing = Uint8Array.from([0xaa, 0x03, 0x68, 0x69, 0xbb]);
|
|
148
|
+
const reader = createSpineBinaryReader(backing.subarray(1, 4));
|
|
149
|
+
expect(readSpineBinaryString(reader)).toBe('hi');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns the null sentinel when the declared length runs past the end', () => {
|
|
153
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x09, 0x61, 0x62]));
|
|
154
|
+
expect(readSpineBinaryString(reader)).toBeNull();
|
|
155
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('readSpineBinaryUnsignedShort', () => {
|
|
160
|
+
it('reads BIG-endian unsigned 16-bit values', () => {
|
|
161
|
+
// 0xffff read unsigned is 65535, not -1 — the point of asserting this isn't the signed reading.
|
|
162
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x01, 0x00, 0xff, 0xff]));
|
|
163
|
+
expect(readSpineBinaryUnsignedShort(reader)).toBe(256);
|
|
164
|
+
expect(readSpineBinaryUnsignedShort(reader)).toBe(65535);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('readSpineBinaryVarint', () => {
|
|
169
|
+
it('reads the 7-bits-per-byte encoding across every byte-count boundary', () => {
|
|
170
|
+
const reader = createSpineBinaryReader(
|
|
171
|
+
Uint8Array.from([
|
|
172
|
+
0x00, // 0
|
|
173
|
+
0x7f, // 127 — the largest one-byte value
|
|
174
|
+
0x80,
|
|
175
|
+
0x01, // 128 — the smallest two-byte value
|
|
176
|
+
0xff,
|
|
177
|
+
0x7f, // 16383 — the largest two-byte value
|
|
178
|
+
0x80,
|
|
179
|
+
0x80,
|
|
180
|
+
0x01, // 16384
|
|
181
|
+
]),
|
|
182
|
+
);
|
|
183
|
+
expect(readSpineBinaryVarint(reader)).toBe(0);
|
|
184
|
+
expect(readSpineBinaryVarint(reader)).toBe(127);
|
|
185
|
+
expect(readSpineBinaryVarint(reader)).toBe(128);
|
|
186
|
+
expect(readSpineBinaryVarint(reader)).toBe(16383);
|
|
187
|
+
expect(readSpineBinaryVarint(reader)).toBe(16384);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns the widest five-byte pattern UNSIGNED rather than as a negative int32', () => {
|
|
191
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0x0f]));
|
|
192
|
+
expect(readSpineBinaryVarint(reader)).toBe(4294967295);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('stops at the buffer end instead of reading past it when a continuation byte is missing', () => {
|
|
196
|
+
const reader = createSpineBinaryReader(Uint8Array.from([0x80])); // says "more follows", but nothing does
|
|
197
|
+
expect(readSpineBinaryVarint(reader)).toBe(0);
|
|
198
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('skipSpineBinaryBytes', () => {
|
|
203
|
+
it('advances past unmodelled fields', () => {
|
|
204
|
+
const reader = createSpineBinaryReader(Uint8Array.from([1, 2, 3, 4]));
|
|
205
|
+
skipSpineBinaryBytes(reader, 3);
|
|
206
|
+
expect(readSpineBinaryByte(reader)).toBe(4);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('marks overrun rather than skipping past the end', () => {
|
|
210
|
+
const reader = createSpineBinaryReader(Uint8Array.from([1, 2]));
|
|
211
|
+
skipSpineBinaryBytes(reader, 5);
|
|
212
|
+
expect(isSpineBinaryReaderOverrun(reader)).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|