@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,874 @@
|
|
|
1
|
+
import { createAnimationChannel, createAnimationClip, createAnimationTrack } from '@flighthq/animation/contract';
|
|
2
|
+
import { easeCubicBezier } from '@flighthq/easing/contract';
|
|
3
|
+
import { reportImportDiagnostic } from '@flighthq/importdiagnostics/contract';
|
|
4
|
+
import { createSkeleton2D } from '@flighthq/skeleton2d/contract';
|
|
5
|
+
import { AnimationInterpolationLinear, AnimationInterpolationStep, ImportDiagnosticSeverity, Skeleton2DSlotAnimationPath, MeshAttachment2DKind, RegionAttachment2DKind, Skeleton2DAnimationPath, TransformMode2D, } from '@flighthq/types/contract';
|
|
6
|
+
import { createSpineBinaryReader, isSpineBinaryReaderOverrun, readSpineBinaryBoolean, readSpineBinaryByte, readSpineBinaryFloat, readSpineBinaryInt, readSpineBinaryString, readSpineBinaryUnsignedShort, readSpineBinaryVarint, skipSpineBinaryBytes, } from './spineBinaryReader';
|
|
7
|
+
// Parses Spine's `.skel` BINARY skeleton into the same `Skeleton2DImport` `parseSpineSkeleton` produces from
|
|
8
|
+
// `.json` — the binary sibling of that parser, mirroring how `parseGlb` sits beside `parseGltf`. Tolerant and
|
|
9
|
+
// best-effort on the same terms: `null` is reserved for the "this is not a file we can read" failure
|
|
10
|
+
// (unreadable header, unsupported version), and a readable file with unmodeled pieces yields best-effort
|
|
11
|
+
// data plus `ImportDiagnostic` crumbs. Wire decoding lives in `spineBinaryReader`; this file owns only the
|
|
12
|
+
// RECORD LAYOUT — which field follows which.
|
|
13
|
+
//
|
|
14
|
+
// The binary is stream-positional in a way JSON is not: records have no keys and no lengths, so a reader
|
|
15
|
+
// cannot skip a section it does not model — it can only CONSUME it or stop. That is why constraint records,
|
|
16
|
+
// slot colour timelines, deform timelines, and draw-order/event timelines are all walked field-for-field
|
|
17
|
+
// even though Flight models none of them: each stands between something this importer does want and the
|
|
18
|
+
// next thing after it. The whole file is consumed, and what is not modeled is Skip-crumbed rather than
|
|
19
|
+
// skipped over.
|
|
20
|
+
//
|
|
21
|
+
// VERSION GATE. The layout below is Spine 4.x's and was verified byte-for-byte against a real 4.1.17 export
|
|
22
|
+
// (see the package status). Spine changed record layouts across major versions, so a file outside 4.x is
|
|
23
|
+
// REJECTED with its version in the crumb instead of being decoded by a layout that does not describe it —
|
|
24
|
+
// a wrong layout does not fail loudly, it silently yields plausible garbage.
|
|
25
|
+
export function parseSpineSkeletonBinary(bytes, diagnostics) {
|
|
26
|
+
const reader = createSpineBinaryReader(bytes);
|
|
27
|
+
// The 8-byte hash identifies the export; it carries no skeleton data, so it is stepped over rather than read.
|
|
28
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_HASH_BYTES);
|
|
29
|
+
const version = readSpineBinaryString(reader);
|
|
30
|
+
if (isSpineBinaryReaderOverrun(reader) || version === null) {
|
|
31
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'spine.binary-header-unreadable', 'parseSpineSkeletonBinary', { bytes: bytes.byteLength });
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!isSupportedSpineBinaryVersion(version)) {
|
|
35
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'spine.binary-version-unsupported', 'parseSpineSkeletonBinary', { version });
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
// Skeleton bounds (x, y, width, height) describe the authoring canvas, which Skeleton2D does not model.
|
|
39
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_BOUNDS_BYTES);
|
|
40
|
+
// "Nonessential" data is what Spine writes only for editor round-tripping: the authoring frame rate, the
|
|
41
|
+
// images/audio folder paths, and a per-bone editor color. Its PRESENCE changes the record layout below,
|
|
42
|
+
// so the flag has to be carried down even though none of the values are modeled.
|
|
43
|
+
const nonessential = readSpineBinaryBoolean(reader);
|
|
44
|
+
if (nonessential) {
|
|
45
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_FPS_BYTES);
|
|
46
|
+
readSpineBinaryString(reader); // images path
|
|
47
|
+
readSpineBinaryString(reader); // audio path
|
|
48
|
+
}
|
|
49
|
+
const strings = readSpineBinaryStringTable(reader);
|
|
50
|
+
const bones = parseSpineBinaryBones(reader, nonessential);
|
|
51
|
+
const { attachmentNames, slots } = parseSpineBinarySlots(reader, strings, diagnostics);
|
|
52
|
+
skipSpineBinaryConstraints(reader, diagnostics);
|
|
53
|
+
const skins = parseSpineBinarySkins(reader, strings, nonessential, diagnostics);
|
|
54
|
+
// A slot names its setup attachment BEFORE the skin that defines it has been read, so resolution waits
|
|
55
|
+
// until here — the file orders slots first, but the name only means something once the skins exist.
|
|
56
|
+
const setup = skins.find((skin) => skin.name === SPINE_BINARY_DEFAULT_SKIN_NAME);
|
|
57
|
+
if (setup !== undefined) {
|
|
58
|
+
for (const entry of setup.attachments) {
|
|
59
|
+
if (entry.slotIndex < slots.length && attachmentNames[entry.slotIndex] === entry.name) {
|
|
60
|
+
slots[entry.slotIndex].attachment = entry.attachment;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
skipSpineBinaryEvents(reader, diagnostics);
|
|
65
|
+
const animations = parseSpineBinaryAnimations(reader, strings, setup, diagnostics);
|
|
66
|
+
if (isSpineBinaryReaderOverrun(reader)) {
|
|
67
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'spine.binary-truncated', 'parseSpineSkeletonBinary', { bones: bones.length, slots: slots.length });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'spine.binary-tail-unparsed', 'parseSpineSkeletonBinary', { bytes: bytes.byteLength - reader.offset });
|
|
71
|
+
}
|
|
72
|
+
const skeleton = createSkeleton2D(bones, slots);
|
|
73
|
+
if (skins.length > 0)
|
|
74
|
+
skeleton.skins = skins;
|
|
75
|
+
return { animations, skeleton };
|
|
76
|
+
}
|
|
77
|
+
// The event DEFINITIONS a file declares (name plus default int/float/string/audio payload). Flight's
|
|
78
|
+
// Skeleton2DImport carries no event vocabulary, so these are consumed and Skip-crumbed — but consumed they
|
|
79
|
+
// must be, since the animation section follows them in a stream with no keys or lengths.
|
|
80
|
+
function skipSpineBinaryEvents(reader, diagnostics) {
|
|
81
|
+
const count = readSpineBinaryVarint(reader);
|
|
82
|
+
for (let i = 0; i < count && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
83
|
+
readSpineBinaryVarint(reader); // name reference
|
|
84
|
+
readSpineBinaryVarint(reader); // int value
|
|
85
|
+
skipSpineBinaryBytes(reader, 4); // float value
|
|
86
|
+
readSpineBinaryString(reader); // string value
|
|
87
|
+
// An audio path is what gates the trailing volume/balance pair, so its presence changes the record width.
|
|
88
|
+
if (readSpineBinaryString(reader) !== null)
|
|
89
|
+
skipSpineBinaryBytes(reader, 8);
|
|
90
|
+
}
|
|
91
|
+
reportSpineBinaryCrumb(diagnostics, count, 'spine.event-unsupported', 'events');
|
|
92
|
+
}
|
|
93
|
+
// Builds one AnimationClip per animation from its BONE timelines, mirroring what `parseSpineSkeleton` does
|
|
94
|
+
// for `.json` — relative deltas over `Skeleton2DAnimationTarget`, composed onto the setup pose by
|
|
95
|
+
// `applyAnimationClipToSkeleton2D`.
|
|
96
|
+
//
|
|
97
|
+
// Every other timeline family (slot attachment/colour, IK, transform, path, deform, draw order, event) is
|
|
98
|
+
// unmodeled, yet each is still walked field-for-field: the animation record is positional, so reaching the
|
|
99
|
+
// NEXT animation requires consuming this one completely. An animation opens with its total timeline count,
|
|
100
|
+
// which this importer does not need but must read.
|
|
101
|
+
function parseSpineBinaryAnimations(reader, strings, setup, diagnostics) {
|
|
102
|
+
const animations = [];
|
|
103
|
+
const count = readSpineBinaryVarint(reader);
|
|
104
|
+
const unmodeled = new Map();
|
|
105
|
+
for (let i = 0; i < count && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
106
|
+
const name = readSpineBinaryString(reader);
|
|
107
|
+
readSpineBinaryVarint(reader); // total timeline count across all families
|
|
108
|
+
const channels = [];
|
|
109
|
+
parseSpineBinarySlotTimelines(reader, channels, strings, setup, unmodeled, diagnostics);
|
|
110
|
+
parseSpineBinaryBoneTimelines(reader, channels, diagnostics);
|
|
111
|
+
skipSpineBinaryConstraintTimelines(reader, unmodeled);
|
|
112
|
+
skipSpineBinaryDeformTimelines(reader, unmodeled);
|
|
113
|
+
skipSpineBinaryDrawOrderTimelines(reader, unmodeled);
|
|
114
|
+
skipSpineBinaryEventTimelines(reader, unmodeled);
|
|
115
|
+
animations.push({ clip: createAnimationClip(channels), name: name ?? '' });
|
|
116
|
+
}
|
|
117
|
+
for (const [kind, tally] of unmodeled) {
|
|
118
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, `spine.${kind}-timeline-unsupported`, 'parseSpineSkeletonBinary', { timelines: tally });
|
|
119
|
+
}
|
|
120
|
+
return animations;
|
|
121
|
+
}
|
|
122
|
+
// The bone timelines of one animation. Spine splits each transform group into a combined form and per-axis
|
|
123
|
+
// forms (`translate` vs `translateX`/`translateY`); a per-axis timeline becomes a two-component channel
|
|
124
|
+
// whose OTHER axis holds the identity delta — 0 for translate/shear, 1 for the scale multiplier — so it
|
|
125
|
+
// composes onto the setup pose as "this axis moves, the other does not".
|
|
126
|
+
function parseSpineBinaryBoneTimelines(reader, channels, diagnostics) {
|
|
127
|
+
const bones = readSpineBinaryVarint(reader);
|
|
128
|
+
for (let i = 0; i < bones && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
129
|
+
const boneIndex = readSpineBinaryVarint(reader);
|
|
130
|
+
const timelines = readSpineBinaryVarint(reader);
|
|
131
|
+
for (let j = 0; j < timelines && !isSpineBinaryReaderOverrun(reader); j++) {
|
|
132
|
+
const ordinal = readSpineBinaryByte(reader);
|
|
133
|
+
const frameCount = readSpineBinaryVarint(reader);
|
|
134
|
+
readSpineBinaryVarint(reader); // bezier count — a capacity hint, not needed to read the frames
|
|
135
|
+
const kind = ordinal < SPINE_BINARY_BONE_TIMELINES.length ? SPINE_BINARY_BONE_TIMELINES[ordinal] : null;
|
|
136
|
+
if (kind === null) {
|
|
137
|
+
// The payload width of an unknown timeline is unknowable, so the stream cannot continue past it.
|
|
138
|
+
skipSpineBinaryBytes(reader, reader.view.byteLength + 1);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const timeline = readSpineBinaryValueTimeline(reader, frameCount, kind.values);
|
|
142
|
+
channels.push(buildSpineBinaryBoneChannel(timeline, kind, boneIndex, diagnostics));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Reads a curve timeline: a leading keyframe, then per gap another keyframe preceded by a curve tag. The
|
|
147
|
+
// tag is per-SEGMENT, and a bezier tag carries four floats per animated value.
|
|
148
|
+
function readSpineBinaryValueTimeline(reader, frameCount, values) {
|
|
149
|
+
const times = [];
|
|
150
|
+
const flat = [];
|
|
151
|
+
const curves = [];
|
|
152
|
+
if (frameCount <= 0)
|
|
153
|
+
return { curves, times, values: flat };
|
|
154
|
+
times.push(readSpineBinaryFloat(reader));
|
|
155
|
+
for (let v = 0; v < values; v++)
|
|
156
|
+
flat.push(readSpineBinaryFloat(reader));
|
|
157
|
+
for (let frame = 0; frame + 1 < frameCount && !isSpineBinaryReaderOverrun(reader); frame++) {
|
|
158
|
+
times.push(readSpineBinaryFloat(reader));
|
|
159
|
+
for (let v = 0; v < values; v++)
|
|
160
|
+
flat.push(readSpineBinaryFloat(reader));
|
|
161
|
+
const tag = readSpineBinaryByte(reader);
|
|
162
|
+
if (tag === SPINE_BINARY_CURVE_BEZIER) {
|
|
163
|
+
const points = [];
|
|
164
|
+
for (let v = 0; v < values * 4; v++)
|
|
165
|
+
points.push(readSpineBinaryFloat(reader));
|
|
166
|
+
curves.push(points);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
curves.push(null); // linear, or stepped — which Flight expresses per track, not per segment
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { curves, times, values: flat };
|
|
173
|
+
}
|
|
174
|
+
// Turns one decoded bone timeline into an AnimationChannel. A per-axis timeline is widened to the path's
|
|
175
|
+
// full component count by filling the untouched axis with its identity delta, and any bezier segments
|
|
176
|
+
// become per-interval easings under the same absolute-units rebase the `.json` parser uses.
|
|
177
|
+
function buildSpineBinaryBoneChannel(timeline, kind, boneIndex, diagnostics) {
|
|
178
|
+
const frames = timeline.times.length;
|
|
179
|
+
const components = kind.components;
|
|
180
|
+
const values = new Array(frames * components);
|
|
181
|
+
for (let f = 0; f < frames; f++) {
|
|
182
|
+
for (let c = 0; c < components; c++)
|
|
183
|
+
values[f * components + c] = kind.identity;
|
|
184
|
+
if (kind.axis < 0) {
|
|
185
|
+
for (let c = 0; c < kind.values; c++)
|
|
186
|
+
values[f * components + c] = timeline.values[f * kind.values + c];
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
values[f * components + kind.axis] = timeline.values[f];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const track = createAnimationTrack({
|
|
193
|
+
components,
|
|
194
|
+
interpolation: AnimationInterpolationLinear,
|
|
195
|
+
segmentEasings: buildSpineBinarySegmentEasings(timeline, kind.values, diagnostics),
|
|
196
|
+
times: timeline.times,
|
|
197
|
+
values,
|
|
198
|
+
});
|
|
199
|
+
return createAnimationChannel(track, { boneIndex, path: kind.path });
|
|
200
|
+
}
|
|
201
|
+
// Rebases each bezier segment's absolute control points onto its own segment, exactly as the `.json` parser
|
|
202
|
+
// does — Spine stores them in time/value units and four numbers per animated value. The first MEANINGFUL
|
|
203
|
+
// value (one that actually changes across the segment) supplies the easing; a divergent sibling is crumbed
|
|
204
|
+
// rather than silently dropped, and x is clamped so the curve stays invertible.
|
|
205
|
+
function buildSpineBinarySegmentEasings(timeline, values, diagnostics) {
|
|
206
|
+
const easings = [];
|
|
207
|
+
let curved = false;
|
|
208
|
+
let divergent = 0;
|
|
209
|
+
for (let i = 0; i < timeline.curves.length; i++) {
|
|
210
|
+
const points = timeline.curves[i];
|
|
211
|
+
const span = timeline.times[i + 1] - timeline.times[i];
|
|
212
|
+
if (points === null || span <= 0) {
|
|
213
|
+
easings.push(null);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// Same rule as the `.json` path: the component with the LARGEST value change supplies the easing,
|
|
217
|
+
// because the rebase divides by that change and a near-constant component is a near-zero denominator.
|
|
218
|
+
let winner = -1;
|
|
219
|
+
let widest = 0;
|
|
220
|
+
for (let v = 0; v < values && (v + 1) * 4 <= points.length; v++) {
|
|
221
|
+
const rise = Math.abs(timeline.values[(i + 1) * values + v] - timeline.values[i * values + v]);
|
|
222
|
+
if (rise > widest) {
|
|
223
|
+
widest = rise;
|
|
224
|
+
winner = v;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Winner first, then compare — a single pass would measure earlier components against zeros.
|
|
228
|
+
const rebase = (v) => {
|
|
229
|
+
const from = timeline.values[i * values + v];
|
|
230
|
+
const rise = timeline.values[(i + 1) * values + v] - from;
|
|
231
|
+
if (rise === 0)
|
|
232
|
+
return null;
|
|
233
|
+
return [
|
|
234
|
+
(points[v * 4] - timeline.times[i]) / span,
|
|
235
|
+
(points[v * 4 + 1] - from) / rise,
|
|
236
|
+
(points[v * 4 + 2] - timeline.times[i]) / span,
|
|
237
|
+
(points[v * 4 + 3] - from) / rise,
|
|
238
|
+
];
|
|
239
|
+
};
|
|
240
|
+
const won = winner < 0 ? null : rebase(winner);
|
|
241
|
+
if (won !== null) {
|
|
242
|
+
for (let v = 0; v < values && (v + 1) * 4 <= points.length; v++) {
|
|
243
|
+
if (v === winner)
|
|
244
|
+
continue;
|
|
245
|
+
const other = rebase(v);
|
|
246
|
+
if (other === null)
|
|
247
|
+
continue;
|
|
248
|
+
for (let k = 0; k < 4; k++) {
|
|
249
|
+
if (Math.abs(other[k] - won[k]) > SPINE_BINARY_CURVE_EPSILON)
|
|
250
|
+
divergent++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const chosen = won !== null;
|
|
255
|
+
const x1 = won === null ? 0 : won[0];
|
|
256
|
+
const y1 = won === null ? 0 : won[1];
|
|
257
|
+
const x2 = won === null ? 0 : won[2];
|
|
258
|
+
const y2 = won === null ? 0 : won[3];
|
|
259
|
+
if (!chosen) {
|
|
260
|
+
easings.push(null);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
curved = true;
|
|
264
|
+
easings.push(easeCubicBezier(clampSpineBinaryUnit(x1), y1, clampSpineBinaryUnit(x2), y2));
|
|
265
|
+
}
|
|
266
|
+
if (divergent > 0) {
|
|
267
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'spine.per-component-curve-easing-unsupported', 'parseSpineSkeletonBinary', { segments: divergent });
|
|
268
|
+
}
|
|
269
|
+
return curved ? easings : null;
|
|
270
|
+
}
|
|
271
|
+
// Slot timelines. `attachment` becomes a Step index channel plus a lookup table; `rgba` becomes a
|
|
272
|
+
// four-component 0..1 colour channel. The remaining colour variants (rgb, alpha, and the two dark forms)
|
|
273
|
+
// are consumed and Skip-crumbed — `Slot2D` carries one packed colour and no dark colour.
|
|
274
|
+
//
|
|
275
|
+
// Colour components are stored as single BYTES here while the bezier control points around them are floats
|
|
276
|
+
// already in 0..1 (Spine divides by 255 before recording a curve), so the bytes are normalized on read and
|
|
277
|
+
// the curve rebase then matches the `.json` path exactly.
|
|
278
|
+
function parseSpineBinarySlotTimelines(reader, channels, strings, setup, unmodeled, diagnostics) {
|
|
279
|
+
const slots = readSpineBinaryVarint(reader);
|
|
280
|
+
for (let i = 0; i < slots && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
281
|
+
const slotIndex = readSpineBinaryVarint(reader);
|
|
282
|
+
const timelines = readSpineBinaryVarint(reader);
|
|
283
|
+
for (let j = 0; j < timelines && !isSpineBinaryReaderOverrun(reader); j++) {
|
|
284
|
+
const type = readSpineBinaryByte(reader);
|
|
285
|
+
const frameCount = readSpineBinaryVarint(reader);
|
|
286
|
+
if (type === SPINE_BINARY_SLOT_ATTACHMENT) {
|
|
287
|
+
addSpineBinaryAttachmentChannel(reader, channels, strings, setup, slotIndex, frameCount);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
readSpineBinaryVarint(reader); // bezier count
|
|
291
|
+
const count = SPINE_BINARY_SLOT_COLOR_CHANNELS[type] ?? 1;
|
|
292
|
+
if (type !== SPINE_BINARY_SLOT_RGBA) {
|
|
293
|
+
tally(unmodeled, 'slot-color');
|
|
294
|
+
skipSpineBinaryCurveFrames(reader, frameCount, count, count);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const timeline = readSpineBinaryColorTimeline(reader, frameCount, count);
|
|
298
|
+
const track = createAnimationTrack({
|
|
299
|
+
components: count,
|
|
300
|
+
interpolation: AnimationInterpolationLinear,
|
|
301
|
+
segmentEasings: buildSpineBinarySegmentEasings(timeline, count, diagnostics),
|
|
302
|
+
times: timeline.times,
|
|
303
|
+
values: timeline.values,
|
|
304
|
+
});
|
|
305
|
+
channels.push(createAnimationChannel(track, { path: Skeleton2DSlotAnimationPath.Color, slotIndex }));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// An attachment-swap timeline: per frame a time and a string-table reference, `null` meaning hide. Names
|
|
310
|
+
// resolve against the setup skin ONCE into a deduplicated table, and the track carries only the index.
|
|
311
|
+
function addSpineBinaryAttachmentChannel(reader, channels, strings, setup, slotIndex, frameCount) {
|
|
312
|
+
const attachments = [];
|
|
313
|
+
const indexByName = new Map();
|
|
314
|
+
const times = [];
|
|
315
|
+
const values = [];
|
|
316
|
+
for (let f = 0; f < frameCount && !isSpineBinaryReaderOverrun(reader); f++) {
|
|
317
|
+
times.push(readSpineBinaryFloat(reader));
|
|
318
|
+
const name = readSpineBinaryStringReference(reader, strings);
|
|
319
|
+
if (name === null) {
|
|
320
|
+
values.push(SPINE_BINARY_NO_ATTACHMENT_INDEX);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
let index = indexByName.get(name);
|
|
324
|
+
if (index === undefined) {
|
|
325
|
+
const found = setup?.attachments.find((entry) => entry.slotIndex === slotIndex && entry.name === name);
|
|
326
|
+
index = found === undefined ? SPINE_BINARY_NO_ATTACHMENT_INDEX : attachments.push(found.attachment) - 1;
|
|
327
|
+
indexByName.set(name, index);
|
|
328
|
+
}
|
|
329
|
+
values.push(index);
|
|
330
|
+
}
|
|
331
|
+
if (times.length === 0)
|
|
332
|
+
return;
|
|
333
|
+
const track = createAnimationTrack({ components: 1, interpolation: AnimationInterpolationStep, times, values });
|
|
334
|
+
channels.push(createAnimationChannel(track, { attachments, path: Skeleton2DSlotAnimationPath.Attachment, slotIndex }));
|
|
335
|
+
}
|
|
336
|
+
// A colour curve timeline: a time float then one byte per channel, with a per-segment curve tag. Bytes are
|
|
337
|
+
// normalized to 0..1 so they share the track space (and therefore the curve rebase) with the `.json` path.
|
|
338
|
+
function readSpineBinaryColorTimeline(reader, frameCount, channelCount) {
|
|
339
|
+
const times = [];
|
|
340
|
+
const values = [];
|
|
341
|
+
const curves = [];
|
|
342
|
+
if (frameCount <= 0)
|
|
343
|
+
return { curves, times, values };
|
|
344
|
+
times.push(readSpineBinaryFloat(reader));
|
|
345
|
+
for (let c = 0; c < channelCount; c++)
|
|
346
|
+
values.push(readSpineBinaryByte(reader) / 255);
|
|
347
|
+
for (let f = 0; f + 1 < frameCount && !isSpineBinaryReaderOverrun(reader); f++) {
|
|
348
|
+
times.push(readSpineBinaryFloat(reader));
|
|
349
|
+
for (let c = 0; c < channelCount; c++)
|
|
350
|
+
values.push(readSpineBinaryByte(reader) / 255);
|
|
351
|
+
const tag = readSpineBinaryByte(reader);
|
|
352
|
+
if (tag === SPINE_BINARY_CURVE_BEZIER) {
|
|
353
|
+
const points = [];
|
|
354
|
+
for (let v = 0; v < channelCount * 4; v++)
|
|
355
|
+
points.push(readSpineBinaryFloat(reader));
|
|
356
|
+
curves.push(points);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
curves.push(null);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { curves, times, values };
|
|
363
|
+
}
|
|
364
|
+
// IK, transform, and path constraint timelines.
|
|
365
|
+
function skipSpineBinaryConstraintTimelines(reader, unmodeled) {
|
|
366
|
+
const ik = readSpineBinaryVarint(reader);
|
|
367
|
+
for (let i = 0; i < ik && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
368
|
+
tally(unmodeled, 'ik');
|
|
369
|
+
readSpineBinaryVarint(reader); // constraint index
|
|
370
|
+
const frameCount = readSpineBinaryVarint(reader);
|
|
371
|
+
readSpineBinaryVarint(reader); // bezier count
|
|
372
|
+
skipSpineBinaryBytes(reader, 12); // time, mix, softness
|
|
373
|
+
for (let f = 0; f < frameCount && !isSpineBinaryReaderOverrun(reader); f++) {
|
|
374
|
+
skipSpineBinaryBytes(reader, 3); // bend direction, compress, stretch
|
|
375
|
+
if (f === frameCount - 1)
|
|
376
|
+
break;
|
|
377
|
+
skipSpineBinaryBytes(reader, 12);
|
|
378
|
+
skipSpineBinaryCurveTag(reader, 2);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const transform = readSpineBinaryVarint(reader);
|
|
382
|
+
for (let i = 0; i < transform && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
383
|
+
tally(unmodeled, 'transform');
|
|
384
|
+
readSpineBinaryVarint(reader);
|
|
385
|
+
const frameCount = readSpineBinaryVarint(reader);
|
|
386
|
+
readSpineBinaryVarint(reader);
|
|
387
|
+
skipSpineBinaryCurveFrames(reader, frameCount, 24, 6);
|
|
388
|
+
}
|
|
389
|
+
const path = readSpineBinaryVarint(reader);
|
|
390
|
+
for (let i = 0; i < path && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
391
|
+
readSpineBinaryVarint(reader);
|
|
392
|
+
const timelines = readSpineBinaryVarint(reader);
|
|
393
|
+
for (let j = 0; j < timelines && !isSpineBinaryReaderOverrun(reader); j++) {
|
|
394
|
+
tally(unmodeled, 'path');
|
|
395
|
+
const type = readSpineBinaryByte(reader);
|
|
396
|
+
const frameCount = readSpineBinaryVarint(reader);
|
|
397
|
+
readSpineBinaryVarint(reader);
|
|
398
|
+
const values = type === SPINE_BINARY_PATH_MIX ? 3 : 1;
|
|
399
|
+
skipSpineBinaryCurveFrames(reader, frameCount, values * 4, values);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Deform (mesh vertex offset) and attachment-sequence timelines, nested skin → slot → attachment.
|
|
404
|
+
function skipSpineBinaryDeformTimelines(reader, unmodeled) {
|
|
405
|
+
const skins = readSpineBinaryVarint(reader);
|
|
406
|
+
for (let i = 0; i < skins && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
407
|
+
readSpineBinaryVarint(reader); // skin index
|
|
408
|
+
const slots = readSpineBinaryVarint(reader);
|
|
409
|
+
for (let j = 0; j < slots && !isSpineBinaryReaderOverrun(reader); j++) {
|
|
410
|
+
readSpineBinaryVarint(reader); // slot index
|
|
411
|
+
const attachments = readSpineBinaryVarint(reader);
|
|
412
|
+
for (let k = 0; k < attachments && !isSpineBinaryReaderOverrun(reader); k++) {
|
|
413
|
+
readSpineBinaryVarint(reader); // attachment name reference
|
|
414
|
+
const type = readSpineBinaryByte(reader);
|
|
415
|
+
const frameCount = readSpineBinaryVarint(reader);
|
|
416
|
+
if (type === SPINE_BINARY_ATTACHMENT_SEQUENCE) {
|
|
417
|
+
tally(unmodeled, 'attachment-sequence');
|
|
418
|
+
skipSpineBinaryBytes(reader, frameCount * 12); // time, packed mode+index, delay
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
tally(unmodeled, 'deform');
|
|
422
|
+
readSpineBinaryVarint(reader); // bezier count
|
|
423
|
+
skipSpineBinaryBytes(reader, 4); // first time
|
|
424
|
+
for (let f = 0; f < frameCount && !isSpineBinaryReaderOverrun(reader); f++) {
|
|
425
|
+
// A run length of zero means "the attachment's own vertices", carrying no payload at all.
|
|
426
|
+
const run = readSpineBinaryVarint(reader);
|
|
427
|
+
if (run !== 0) {
|
|
428
|
+
readSpineBinaryVarint(reader); // start offset into the vertex array
|
|
429
|
+
skipSpineBinaryBytes(reader, run * 4);
|
|
430
|
+
}
|
|
431
|
+
if (f === frameCount - 1)
|
|
432
|
+
break;
|
|
433
|
+
skipSpineBinaryBytes(reader, 4); // next time
|
|
434
|
+
skipSpineBinaryCurveTag(reader, 1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// The draw-order timeline: per frame, a time and a list of slot-index/offset pairs.
|
|
441
|
+
function skipSpineBinaryDrawOrderTimelines(reader, unmodeled) {
|
|
442
|
+
const frames = readSpineBinaryVarint(reader);
|
|
443
|
+
if (frames > 0)
|
|
444
|
+
tally(unmodeled, 'draworder');
|
|
445
|
+
for (let i = 0; i < frames && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
446
|
+
skipSpineBinaryBytes(reader, 4); // time
|
|
447
|
+
const offsets = readSpineBinaryVarint(reader);
|
|
448
|
+
for (let j = 0; j < offsets && !isSpineBinaryReaderOverrun(reader); j++) {
|
|
449
|
+
readSpineBinaryVarint(reader); // slot index
|
|
450
|
+
readSpineBinaryVarint(reader); // draw-order offset
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// The event timeline: per frame, a time, the event it fires, and any values overriding the definition.
|
|
455
|
+
function skipSpineBinaryEventTimelines(reader, unmodeled) {
|
|
456
|
+
const frames = readSpineBinaryVarint(reader);
|
|
457
|
+
if (frames > 0)
|
|
458
|
+
tally(unmodeled, 'event');
|
|
459
|
+
for (let i = 0; i < frames && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
460
|
+
skipSpineBinaryBytes(reader, 4); // time
|
|
461
|
+
readSpineBinaryVarint(reader); // event index
|
|
462
|
+
readSpineBinaryVarint(reader); // int value
|
|
463
|
+
skipSpineBinaryBytes(reader, 4); // float value
|
|
464
|
+
// A flag says whether this frame overrides the definition's string; only then is one written.
|
|
465
|
+
if (readSpineBinaryBoolean(reader))
|
|
466
|
+
readSpineBinaryString(reader);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Walks a curve timeline whose values are consumed rather than kept. `payloadBytes` is the per-keyframe
|
|
470
|
+
// value payload IN BYTES, excluding the 4-byte time — it is not a value count, because the two families
|
|
471
|
+
// differ in width: a constraint timeline stores floats, while a slot COLOUR timeline stores one byte per
|
|
472
|
+
// channel. `curveValues` is how many bezier curves a tagged segment carries, which tracks the value count
|
|
473
|
+
// either way (a colour's curves are still floats).
|
|
474
|
+
function skipSpineBinaryCurveFrames(reader, frameCount, payloadBytes, curveValues) {
|
|
475
|
+
if (frameCount <= 0)
|
|
476
|
+
return;
|
|
477
|
+
skipSpineBinaryBytes(reader, 4 + payloadBytes);
|
|
478
|
+
for (let f = 0; f + 1 < frameCount && !isSpineBinaryReaderOverrun(reader); f++) {
|
|
479
|
+
skipSpineBinaryBytes(reader, 4 + payloadBytes);
|
|
480
|
+
skipSpineBinaryCurveTag(reader, curveValues);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// One per-segment curve tag, plus the four floats per value a bezier tag carries.
|
|
484
|
+
function skipSpineBinaryCurveTag(reader, curveValues) {
|
|
485
|
+
if (readSpineBinaryByte(reader) === SPINE_BINARY_CURVE_BEZIER) {
|
|
486
|
+
skipSpineBinaryBytes(reader, curveValues * 16);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function clampSpineBinaryUnit(value) {
|
|
490
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
491
|
+
}
|
|
492
|
+
function tally(counts, kind) {
|
|
493
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
494
|
+
}
|
|
495
|
+
// Whether this importer's record layout describes `version`. Only the 4.x line is claimed: it is what the
|
|
496
|
+
// layout was verified against. Anything else (3.8 and earlier, or a future major) is rejected rather than
|
|
497
|
+
// guessed, because a mismatched layout desynchronizes the stream and yields plausible-looking garbage.
|
|
498
|
+
function isSupportedSpineBinaryVersion(version) {
|
|
499
|
+
return version.startsWith('4.');
|
|
500
|
+
}
|
|
501
|
+
// Spine's bone records, in file order — the order weighted-mesh influences and slot bone references index
|
|
502
|
+
// into, and the order that guarantees a parent precedes its children (bone 0 is the root and writes no
|
|
503
|
+
// parent index at all).
|
|
504
|
+
function parseSpineBinaryBones(reader, nonessential) {
|
|
505
|
+
const count = readSpineBinaryVarint(reader);
|
|
506
|
+
const bones = [];
|
|
507
|
+
for (let i = 0; i < count; i++) {
|
|
508
|
+
if (isSpineBinaryReaderOverrun(reader))
|
|
509
|
+
break;
|
|
510
|
+
const name = readSpineBinaryString(reader);
|
|
511
|
+
const parentIndex = i === 0 ? -1 : readSpineBinaryVarint(reader);
|
|
512
|
+
const rotation = readSpineBinaryFloat(reader);
|
|
513
|
+
const x = readSpineBinaryFloat(reader);
|
|
514
|
+
const y = readSpineBinaryFloat(reader);
|
|
515
|
+
const scaleX = readSpineBinaryFloat(reader);
|
|
516
|
+
const scaleY = readSpineBinaryFloat(reader);
|
|
517
|
+
const shearX = readSpineBinaryFloat(reader);
|
|
518
|
+
const shearY = readSpineBinaryFloat(reader);
|
|
519
|
+
const length = readSpineBinaryFloat(reader);
|
|
520
|
+
const transformMode = spineBinaryTransformMode(readSpineBinaryVarint(reader));
|
|
521
|
+
readSpineBinaryBoolean(reader); // skinRequired — a skin-set feature, not modeled
|
|
522
|
+
if (nonessential)
|
|
523
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES); // editor bone color
|
|
524
|
+
bones.push({ length, name, parentIndex, rotation, scaleX, scaleY, shearX, shearY, transformMode, x, y });
|
|
525
|
+
}
|
|
526
|
+
return bones;
|
|
527
|
+
}
|
|
528
|
+
// Spine's slot records, in draw order. `color`/`darkColor` are rgba8888 ints, matching `Slot2D.color`'s
|
|
529
|
+
// packed convention directly; a dark color of -1 means "none". The setup attachment is a STRING-TABLE
|
|
530
|
+
// REFERENCE naming an attachment inside a skin, which the file has not written yet — so the NAME is returned
|
|
531
|
+
// alongside the slots and the caller resolves it once the skin is read.
|
|
532
|
+
function parseSpineBinarySlots(reader, strings, diagnostics) {
|
|
533
|
+
const count = readSpineBinaryVarint(reader);
|
|
534
|
+
const attachmentNames = [];
|
|
535
|
+
const slots = [];
|
|
536
|
+
let darkColors = 0;
|
|
537
|
+
for (let i = 0; i < count; i++) {
|
|
538
|
+
if (isSpineBinaryReaderOverrun(reader))
|
|
539
|
+
break;
|
|
540
|
+
const name = readSpineBinaryString(reader);
|
|
541
|
+
const boneIndex = readSpineBinaryVarint(reader);
|
|
542
|
+
const color = readSpineBinaryInt(reader) >>> 0;
|
|
543
|
+
if (readSpineBinaryInt(reader) !== SPINE_BINARY_NO_DARK_COLOR)
|
|
544
|
+
darkColors++;
|
|
545
|
+
attachmentNames.push(readSpineBinaryStringReference(reader, strings));
|
|
546
|
+
readSpineBinaryVarint(reader); // blend mode — Slot2D carries no per-slot blend today
|
|
547
|
+
slots.push({ attachment: null, boneIndex, color, name });
|
|
548
|
+
}
|
|
549
|
+
if (darkColors > 0) {
|
|
550
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'spine.slot-dark-color-unsupported', 'parseSpineSkeletonBinary', { slots: darkColors });
|
|
551
|
+
}
|
|
552
|
+
return { attachmentNames, slots };
|
|
553
|
+
}
|
|
554
|
+
// The IK, transform, and path constraint sections. Flight models no constraint solvers (a skeleton2d P2
|
|
555
|
+
// concern), but the stream is positional — these records carry no keys and no lengths — so they must be
|
|
556
|
+
// CONSUMED field-for-field to reach the skins that follow. Reading them is not optional the way ignoring a
|
|
557
|
+
// JSON key is; a single miscounted field desynchronizes every later section.
|
|
558
|
+
function skipSpineBinaryConstraints(reader, diagnostics) {
|
|
559
|
+
const ik = readSpineBinaryVarint(reader);
|
|
560
|
+
for (let i = 0; i < ik && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
561
|
+
skipSpineBinaryConstraintHead(reader);
|
|
562
|
+
readSpineBinaryVarint(reader); // target bone
|
|
563
|
+
skipSpineBinaryBytes(reader, 8); // mix, softness
|
|
564
|
+
skipSpineBinaryBytes(reader, 4); // bendDirection byte + compress/stretch/uniform booleans
|
|
565
|
+
}
|
|
566
|
+
const transform = readSpineBinaryVarint(reader);
|
|
567
|
+
for (let i = 0; i < transform && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
568
|
+
skipSpineBinaryConstraintHead(reader);
|
|
569
|
+
readSpineBinaryVarint(reader); // target bone
|
|
570
|
+
skipSpineBinaryBytes(reader, 2); // local, relative
|
|
571
|
+
skipSpineBinaryBytes(reader, 48); // six offsets + six mix weights
|
|
572
|
+
}
|
|
573
|
+
const path = readSpineBinaryVarint(reader);
|
|
574
|
+
for (let i = 0; i < path && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
575
|
+
skipSpineBinaryConstraintHead(reader);
|
|
576
|
+
readSpineBinaryVarint(reader); // target slot
|
|
577
|
+
readSpineBinaryVarint(reader); // position mode
|
|
578
|
+
readSpineBinaryVarint(reader); // spacing mode
|
|
579
|
+
readSpineBinaryVarint(reader); // rotate mode
|
|
580
|
+
skipSpineBinaryBytes(reader, 24); // offsetRotation, position, spacing, mixRotate, mixX, mixY
|
|
581
|
+
}
|
|
582
|
+
reportSpineBinaryCrumb(diagnostics, ik, 'spine.ik-constraint-unsupported', 'constraints');
|
|
583
|
+
reportSpineBinaryCrumb(diagnostics, transform, 'spine.transform-constraint-unsupported', 'constraints');
|
|
584
|
+
reportSpineBinaryCrumb(diagnostics, path, 'spine.path-constraint-unsupported', 'constraints');
|
|
585
|
+
}
|
|
586
|
+
// The head every constraint record shares: name, ordering index, skin-required flag, then its bone list.
|
|
587
|
+
function skipSpineBinaryConstraintHead(reader) {
|
|
588
|
+
readSpineBinaryString(reader);
|
|
589
|
+
readSpineBinaryVarint(reader); // order
|
|
590
|
+
readSpineBinaryBoolean(reader); // skinRequired
|
|
591
|
+
const bones = readSpineBinaryVarint(reader);
|
|
592
|
+
for (let i = 0; i < bones && !isSpineBinaryReaderOverrun(reader); i++)
|
|
593
|
+
readSpineBinaryVarint(reader);
|
|
594
|
+
}
|
|
595
|
+
// The rig's wardrobe. The DEFAULT skin is written first in an abbreviated form — just its slot count, with
|
|
596
|
+
// no name and no bone/constraint lists — and the named alternates follow, each carrying a name plus the
|
|
597
|
+
// bone and constraint indices it requires. Both forms share the same slot → attachment body.
|
|
598
|
+
//
|
|
599
|
+
// Region and mesh attachments are modeled; bounding-box, path, point, clipping, and linked-mesh entries are
|
|
600
|
+
// recognized — and still fully consumed, since skipping their bytes is not possible — then Skip-crumbed.
|
|
601
|
+
function parseSpineBinarySkins(reader, strings, nonessential, diagnostics) {
|
|
602
|
+
const skins = [];
|
|
603
|
+
const unmodeled = new Map();
|
|
604
|
+
const defaultSlots = readSpineBinaryVarint(reader);
|
|
605
|
+
if (defaultSlots > 0) {
|
|
606
|
+
skins.push({
|
|
607
|
+
attachments: readSpineBinarySkinBody(reader, strings, defaultSlots, nonessential, unmodeled),
|
|
608
|
+
name: SPINE_BINARY_DEFAULT_SKIN_NAME,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const alternates = readSpineBinaryVarint(reader);
|
|
612
|
+
for (let i = 0; i < alternates && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
613
|
+
const name = readSpineBinaryStringReference(reader, strings);
|
|
614
|
+
// A named skin declares the bones and the IK / transform / path constraints it requires, as four index
|
|
615
|
+
// lists, before its slots. Flight applies a skin as a slot write, so these are consumed for position only.
|
|
616
|
+
for (let list = 0; list < SPINE_BINARY_SKIN_REQUIREMENT_LISTS; list++) {
|
|
617
|
+
const required = readSpineBinaryVarint(reader);
|
|
618
|
+
for (let j = 0; j < required && !isSpineBinaryReaderOverrun(reader); j++)
|
|
619
|
+
readSpineBinaryVarint(reader);
|
|
620
|
+
}
|
|
621
|
+
const slotCount = readSpineBinaryVarint(reader);
|
|
622
|
+
skins.push({
|
|
623
|
+
attachments: readSpineBinarySkinBody(reader, strings, slotCount, nonessential, unmodeled),
|
|
624
|
+
name: name ?? '',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
for (const [type, count] of unmodeled) {
|
|
628
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, `spine.${type}-attachment-unsupported`, 'parseSpineSkeletonBinary', { attachments: count });
|
|
629
|
+
}
|
|
630
|
+
return skins;
|
|
631
|
+
}
|
|
632
|
+
// One skin's slot → attachment body, shared by the default and named forms. Entries carry an explicit slot
|
|
633
|
+
// index and are NOT written in slot order.
|
|
634
|
+
function readSpineBinarySkinBody(reader, strings, slotCount, nonessential, unmodeled) {
|
|
635
|
+
const attachments = [];
|
|
636
|
+
for (let i = 0; i < slotCount && !isSpineBinaryReaderOverrun(reader); i++) {
|
|
637
|
+
const slotIndex = readSpineBinaryVarint(reader);
|
|
638
|
+
const entries = readSpineBinaryVarint(reader);
|
|
639
|
+
for (let j = 0; j < entries && !isSpineBinaryReaderOverrun(reader); j++) {
|
|
640
|
+
const key = readSpineBinaryStringReference(reader, strings);
|
|
641
|
+
const attachment = readSpineBinaryAttachment(reader, strings, key, nonessential, unmodeled);
|
|
642
|
+
if (attachment !== null && key !== null)
|
|
643
|
+
attachments.push({ attachment, name: key, slotIndex });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return attachments;
|
|
647
|
+
}
|
|
648
|
+
// One attachment record. Its own name overrides the skin key when present (a slot can show the same image
|
|
649
|
+
// under a different key). The type is an ORDINAL into Spine's attachment-type enum, so the order of
|
|
650
|
+
// SPINE_BINARY_ATTACHMENT_TYPES is load-bearing.
|
|
651
|
+
function readSpineBinaryAttachment(reader, strings, key, nonessential, unmodeled) {
|
|
652
|
+
const name = readSpineBinaryStringReference(reader, strings) ?? key;
|
|
653
|
+
const ordinal = readSpineBinaryByte(reader);
|
|
654
|
+
const type = ordinal < SPINE_BINARY_ATTACHMENT_TYPES.length ? SPINE_BINARY_ATTACHMENT_TYPES[ordinal] : null;
|
|
655
|
+
if (type === 'region')
|
|
656
|
+
return readSpineBinaryRegionAttachment(reader, strings, name);
|
|
657
|
+
if (type === 'mesh')
|
|
658
|
+
return readSpineBinaryMeshAttachment(reader, strings, name, nonessential);
|
|
659
|
+
const label = type ?? 'unknown';
|
|
660
|
+
unmodeled.set(label, (unmodeled.get(label) ?? 0) + 1);
|
|
661
|
+
if (type === 'boundingbox') {
|
|
662
|
+
skipSpineBinaryVertices(reader, readSpineBinaryVarint(reader));
|
|
663
|
+
if (nonessential)
|
|
664
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
665
|
+
}
|
|
666
|
+
else if (type === 'clipping') {
|
|
667
|
+
readSpineBinaryVarint(reader); // end slot
|
|
668
|
+
skipSpineBinaryVertices(reader, readSpineBinaryVarint(reader));
|
|
669
|
+
if (nonessential)
|
|
670
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
671
|
+
}
|
|
672
|
+
else if (type === 'point') {
|
|
673
|
+
skipSpineBinaryBytes(reader, 12); // rotation, x, y
|
|
674
|
+
if (nonessential)
|
|
675
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
676
|
+
}
|
|
677
|
+
else if (type === 'linkedmesh') {
|
|
678
|
+
readSpineBinaryVarint(reader); // path
|
|
679
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
680
|
+
readSpineBinaryVarint(reader); // skin name
|
|
681
|
+
readSpineBinaryVarint(reader); // parent mesh
|
|
682
|
+
readSpineBinaryBoolean(reader); // inherit timelines
|
|
683
|
+
skipSpineBinarySequence(reader);
|
|
684
|
+
if (nonessential)
|
|
685
|
+
skipSpineBinaryBytes(reader, 8); // width, height
|
|
686
|
+
}
|
|
687
|
+
else if (type === 'path') {
|
|
688
|
+
skipSpineBinaryBytes(reader, 2); // closed, constantSpeed
|
|
689
|
+
const vertexCount = readSpineBinaryVarint(reader);
|
|
690
|
+
skipSpineBinaryVertices(reader, vertexCount);
|
|
691
|
+
skipSpineBinaryBytes(reader, Math.floor(vertexCount / 3) * 4); // per-curve lengths
|
|
692
|
+
if (nonessential)
|
|
693
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
694
|
+
}
|
|
695
|
+
// An unknown ordinal cannot be stepped over — its payload width is unknown — so the stream is abandoned
|
|
696
|
+
// by marking overrun rather than guessing and emitting garbage for everything after it.
|
|
697
|
+
if (type === null)
|
|
698
|
+
skipSpineBinaryBytes(reader, reader.view.byteLength + 1);
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
// A mesh attachment. `uvs` and `triangles` map straight across; the vertex stream is either rigid positions
|
|
702
|
+
// (local to the slot's bone) or a weighted `Skin2D` whose influences are already in Flight's
|
|
703
|
+
// `[boneIndex, x, y, weight]` layout, so no re-packing is needed.
|
|
704
|
+
function readSpineBinaryMeshAttachment(reader, strings, name, nonessential) {
|
|
705
|
+
readSpineBinaryVarint(reader); // atlas region path — resolved at atlas-binding time
|
|
706
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
707
|
+
const vertexCount = readSpineBinaryVarint(reader);
|
|
708
|
+
const uvs = new Float32Array(vertexCount * 2);
|
|
709
|
+
for (let i = 0; i < uvs.length; i++)
|
|
710
|
+
uvs[i] = readSpineBinaryFloat(reader);
|
|
711
|
+
const triangleCount = readSpineBinaryVarint(reader);
|
|
712
|
+
const triangles = new Uint16Array(triangleCount);
|
|
713
|
+
for (let i = 0; i < triangleCount; i++)
|
|
714
|
+
triangles[i] = readSpineBinaryUnsignedShort(reader);
|
|
715
|
+
const geometry = readSpineBinaryVertices(reader, vertexCount);
|
|
716
|
+
readSpineBinaryVarint(reader); // hull length — a rendering hint Flight does not model
|
|
717
|
+
skipSpineBinarySequence(reader);
|
|
718
|
+
if (nonessential) {
|
|
719
|
+
const edges = readSpineBinaryVarint(reader);
|
|
720
|
+
skipSpineBinaryBytes(reader, edges * 2 + 8); // editor edge list, then width and height
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
kind: MeshAttachment2DKind,
|
|
724
|
+
name,
|
|
725
|
+
skin: geometry.skin,
|
|
726
|
+
triangles,
|
|
727
|
+
uvs,
|
|
728
|
+
vertexCount,
|
|
729
|
+
vertices: geometry.vertices,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
// A region attachment. Width/height are the source region's size in the atlas; `path` names the atlas region
|
|
733
|
+
// and is resolved when the `.atlas` sidecar binds, which is `@flighthq/spritesheet-formats`' domain.
|
|
734
|
+
function readSpineBinaryRegionAttachment(reader, strings, name) {
|
|
735
|
+
readSpineBinaryVarint(reader); // atlas region path
|
|
736
|
+
const rotation = readSpineBinaryFloat(reader);
|
|
737
|
+
const x = readSpineBinaryFloat(reader);
|
|
738
|
+
const y = readSpineBinaryFloat(reader);
|
|
739
|
+
const scaleX = readSpineBinaryFloat(reader);
|
|
740
|
+
const scaleY = readSpineBinaryFloat(reader);
|
|
741
|
+
const width = readSpineBinaryFloat(reader);
|
|
742
|
+
const height = readSpineBinaryFloat(reader);
|
|
743
|
+
skipSpineBinaryBytes(reader, SPINE_BINARY_COLOR_BYTES);
|
|
744
|
+
skipSpineBinarySequence(reader);
|
|
745
|
+
return { height, kind: RegionAttachment2DKind, name, rotation, scaleX, scaleY, width, x, y };
|
|
746
|
+
}
|
|
747
|
+
// A vertex stream: a leading flag picks rigid positions (2 floats per vertex, in the slot bone's space) or
|
|
748
|
+
// weighted influences (per vertex, a count then that many bone/x/y/weight quads).
|
|
749
|
+
function readSpineBinaryVertices(reader, vertexCount) {
|
|
750
|
+
if (!readSpineBinaryBoolean(reader)) {
|
|
751
|
+
const vertices = new Float32Array(vertexCount * 2);
|
|
752
|
+
for (let i = 0; i < vertices.length; i++)
|
|
753
|
+
vertices[i] = readSpineBinaryFloat(reader);
|
|
754
|
+
return { skin: null, vertices };
|
|
755
|
+
}
|
|
756
|
+
const influenceCounts = new Uint16Array(vertexCount);
|
|
757
|
+
const influences = [];
|
|
758
|
+
for (let v = 0; v < vertexCount && !isSpineBinaryReaderOverrun(reader); v++) {
|
|
759
|
+
const count = readSpineBinaryVarint(reader);
|
|
760
|
+
influenceCounts[v] = count;
|
|
761
|
+
for (let i = 0; i < count; i++) {
|
|
762
|
+
influences.push(readSpineBinaryVarint(reader), readSpineBinaryFloat(reader), readSpineBinaryFloat(reader), readSpineBinaryFloat(reader));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return { skin: { influenceCounts, influences: Float32Array.from(influences) }, vertices: null };
|
|
766
|
+
}
|
|
767
|
+
// Consumes a vertex stream whose geometry is not kept (an unmodeled attachment type still occupies bytes).
|
|
768
|
+
function skipSpineBinaryVertices(reader, vertexCount) {
|
|
769
|
+
readSpineBinaryVertices(reader, vertexCount);
|
|
770
|
+
}
|
|
771
|
+
// Spine 4.1 added an optional `sequence` block to image-backed attachments, describing a numbered frame set.
|
|
772
|
+
// Flight does not model it, but its PRESENCE FLAG is always written, so it must be consumed — this single
|
|
773
|
+
// byte is what desynchronizes every later record if it is missed.
|
|
774
|
+
function skipSpineBinarySequence(reader) {
|
|
775
|
+
if (!readSpineBinaryBoolean(reader))
|
|
776
|
+
return;
|
|
777
|
+
readSpineBinaryVarint(reader); // frame count
|
|
778
|
+
readSpineBinaryVarint(reader); // start index
|
|
779
|
+
readSpineBinaryVarint(reader); // digit count
|
|
780
|
+
readSpineBinaryVarint(reader); // setup index
|
|
781
|
+
}
|
|
782
|
+
// Resolves a 1-based string-table index (0 meaning "no string") into its pooled string.
|
|
783
|
+
function readSpineBinaryStringReference(reader, strings) {
|
|
784
|
+
const index = readSpineBinaryVarint(reader);
|
|
785
|
+
return index > 0 && index <= strings.length ? strings[index - 1] : null;
|
|
786
|
+
}
|
|
787
|
+
// Reports one aggregated Skip crumb for a recognized-but-unmodeled section, keyed by its element count.
|
|
788
|
+
function reportSpineBinaryCrumb(diagnostics, count, kind, unit) {
|
|
789
|
+
if (count > 0) {
|
|
790
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, kind, 'parseSpineSkeletonBinary', {
|
|
791
|
+
[unit]: count,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// The file-wide string pool that later sections reference by 1-based index (0 = no string). Reading it is
|
|
796
|
+
// what makes those references resolvable, so it is consumed even though this landing resolves none yet.
|
|
797
|
+
function readSpineBinaryStringTable(reader) {
|
|
798
|
+
const count = readSpineBinaryVarint(reader);
|
|
799
|
+
const strings = [];
|
|
800
|
+
for (let i = 0; i < count && !isSpineBinaryReaderOverrun(reader); i++)
|
|
801
|
+
strings.push(readSpineBinaryString(reader));
|
|
802
|
+
return strings;
|
|
803
|
+
}
|
|
804
|
+
// Spine writes the bone transform mode as an ORDINAL into its own enum, so the mapping is positional rather
|
|
805
|
+
// than by name (the `.json` sibling reads the same modes as strings). An out-of-range ordinal — a file from a
|
|
806
|
+
// version with more modes — falls back to Normal rather than producing an undefined inherit rule.
|
|
807
|
+
function spineBinaryTransformMode(ordinal) {
|
|
808
|
+
return ordinal >= 0 && ordinal < SPINE_BINARY_TRANSFORM_MODES.length
|
|
809
|
+
? SPINE_BINARY_TRANSFORM_MODES[ordinal]
|
|
810
|
+
: TransformMode2D.Normal;
|
|
811
|
+
}
|
|
812
|
+
// Fixed-width header fields the importer steps over: the 8-byte export hash, the four floats of the
|
|
813
|
+
// authoring bounds, the nonessential frame rate, and a packed rgba8888 color.
|
|
814
|
+
const SPINE_BINARY_BOUNDS_BYTES = 16;
|
|
815
|
+
const SPINE_BINARY_COLOR_BYTES = 4;
|
|
816
|
+
const SPINE_BINARY_FPS_BYTES = 4;
|
|
817
|
+
const SPINE_BINARY_HASH_BYTES = 8;
|
|
818
|
+
// Spine's attachment types in its own enum ORDER — the file writes an ordinal into this list, so the order
|
|
819
|
+
// is load-bearing and must not be alphabetized.
|
|
820
|
+
const SPINE_BINARY_ATTACHMENT_TYPES = [
|
|
821
|
+
'region',
|
|
822
|
+
'boundingbox',
|
|
823
|
+
'mesh',
|
|
824
|
+
'linkedmesh',
|
|
825
|
+
'path',
|
|
826
|
+
'point',
|
|
827
|
+
'clipping',
|
|
828
|
+
];
|
|
829
|
+
// Spine's bone timeline ORDINALS, in its own enum order — the file writes an index into this table, so the
|
|
830
|
+
// order is load-bearing and must not be alphabetized. `values` is how many numbers a keyframe carries;
|
|
831
|
+
// `axis` is which component a per-axis form drives (-1 for the combined form); `identity` is the delta that
|
|
832
|
+
// leaves the untouched axis at its setup value.
|
|
833
|
+
const SPINE_BINARY_BONE_TIMELINES = [
|
|
834
|
+
{ axis: -1, components: 1, identity: 0, path: Skeleton2DAnimationPath.Rotation, values: 1 },
|
|
835
|
+
{ axis: -1, components: 2, identity: 0, path: Skeleton2DAnimationPath.Translation, values: 2 },
|
|
836
|
+
{ axis: 0, components: 2, identity: 0, path: Skeleton2DAnimationPath.Translation, values: 1 },
|
|
837
|
+
{ axis: 1, components: 2, identity: 0, path: Skeleton2DAnimationPath.Translation, values: 1 },
|
|
838
|
+
{ axis: -1, components: 2, identity: 1, path: Skeleton2DAnimationPath.Scale, values: 2 },
|
|
839
|
+
{ axis: 0, components: 2, identity: 1, path: Skeleton2DAnimationPath.Scale, values: 1 },
|
|
840
|
+
{ axis: 1, components: 2, identity: 1, path: Skeleton2DAnimationPath.Scale, values: 1 },
|
|
841
|
+
{ axis: -1, components: 2, identity: 0, path: Skeleton2DAnimationPath.Shear, values: 2 },
|
|
842
|
+
{ axis: 0, components: 2, identity: 0, path: Skeleton2DAnimationPath.Shear, values: 1 },
|
|
843
|
+
{ axis: 1, components: 2, identity: 0, path: Skeleton2DAnimationPath.Shear, values: 1 },
|
|
844
|
+
];
|
|
845
|
+
// A slot colour timeline's channel count, indexed by its timeline ordinal (1 = RGBA, 2 = RGB, 3 = RGBA with
|
|
846
|
+
// a dark colour, 4 = RGB with a dark colour, 5 = alpha only). Ordinal 0 is the attachment-swap timeline.
|
|
847
|
+
const SPINE_BINARY_SLOT_COLOR_CHANNELS = [0, 4, 3, 7, 6, 1];
|
|
848
|
+
// Per-segment curve tags. Linear (0) and stepped (1) carry no payload; bezier carries four floats per value.
|
|
849
|
+
const SPINE_BINARY_CURVE_BEZIER = 2;
|
|
850
|
+
const SPINE_BINARY_SLOT_ATTACHMENT = 0;
|
|
851
|
+
const SPINE_BINARY_SLOT_RGBA = 1;
|
|
852
|
+
// The index an attachment channel uses for "show nothing": Spine's null name, or a name the setup skin lacks.
|
|
853
|
+
const SPINE_BINARY_NO_ATTACHMENT_INDEX = -1;
|
|
854
|
+
const SPINE_BINARY_ATTACHMENT_SEQUENCE = 1;
|
|
855
|
+
const SPINE_BINARY_PATH_MIX = 2;
|
|
856
|
+
// Normalized control points closer than this are the same curve shape; see the `.json` parser for why the
|
|
857
|
+
// comparison must happen after rebasing rather than on the raw numbers.
|
|
858
|
+
const SPINE_BINARY_CURVE_EPSILON = 1e-6;
|
|
859
|
+
// Spine writes -1 into a slot's dark color to mean "this slot has none".
|
|
860
|
+
const SPINE_BINARY_NO_DARK_COLOR = -1;
|
|
861
|
+
// Spine writes the base skin first, unnamed; this is the name it is filed under in the wardrobe.
|
|
862
|
+
const SPINE_BINARY_DEFAULT_SKIN_NAME = 'default';
|
|
863
|
+
// A named skin lists what it requires as four index lists: bones, then IK, transform, and path constraints.
|
|
864
|
+
const SPINE_BINARY_SKIN_REQUIREMENT_LISTS = 4;
|
|
865
|
+
// The bone transform modes in Spine's own enum ORDER — the ordinal written in the file indexes this array,
|
|
866
|
+
// so the order is load-bearing and must not be alphabetized.
|
|
867
|
+
const SPINE_BINARY_TRANSFORM_MODES = [
|
|
868
|
+
TransformMode2D.Normal,
|
|
869
|
+
TransformMode2D.OnlyTranslation,
|
|
870
|
+
TransformMode2D.NoRotationOrReflection,
|
|
871
|
+
TransformMode2D.NoScale,
|
|
872
|
+
TransformMode2D.NoScaleOrReflection,
|
|
873
|
+
];
|
|
874
|
+
//# sourceMappingURL=spineBinaryParse.js.map
|