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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
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, MeshAttachment2DKind, RegionAttachment2DKind, Skeleton2DAnimationPath, Skeleton2DSlotAnimationPath, TransformMode2D, } from '@flighthq/types/contract';
6
+ // Parses a Spine skeleton `.json` document (text) into a Skeleton2DImport — the setup-pose Skeleton2D
7
+ // plus its named animations. Tolerant and best-effort: a malformed / non-Spine document returns the
8
+ // sentinel `null` (the expected "unrecognized format" failure); a recognized document with missing or
9
+ // unmodeled pieces yields best-effort data and reports `ImportDiagnostic`s through the optional
10
+ // `diagnostics` sink. Names mirror Spine's vocabulary (bone/slot/skin/attachment/timeline).
11
+ //
12
+ // This first landing parses the bone hierarchy; slots, attachments, skins, and animation timelines are
13
+ // layered on in the same tolerant shape, and Spine features Flight does not model (IK/transform/path
14
+ // constraints, clipping/path/point attachments, events) emit `ImportDiagnosticSeverity.Skip` crumbs.
15
+ export function parseSpineSkeleton(json, diagnostics) {
16
+ let doc;
17
+ try {
18
+ doc = JSON.parse(json);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ if (doc === null || typeof doc !== 'object')
24
+ return null;
25
+ const record = doc;
26
+ const bones = parseSpineBones(record.bones, diagnostics);
27
+ const { attachmentNames, slots } = parseSpineSlots(record.slots, bones);
28
+ const skins = parseSpineSkins(record.skins, slots, diagnostics);
29
+ resolveSpineSetupAttachments(slots, attachmentNames, skins);
30
+ const animations = parseSpineAnimations(record.animations, bones, slots, skins, diagnostics);
31
+ const skeleton = createSkeleton2D(bones, slots);
32
+ if (skins.length > 0)
33
+ skeleton.skins = skins;
34
+ return { animations, skeleton };
35
+ }
36
+ function numberOr(value, fallback) {
37
+ return typeof value === 'number' ? value : fallback;
38
+ }
39
+ // Spine bones are authored parent-before-child, and reference their parent by name — so a parent's index
40
+ // is resolvable from the bones already accumulated. Returns -1 (a root) when there is no parent or it is
41
+ // not yet known (a forward reference, which a well-formed Spine file never produces).
42
+ //
43
+ // The bone array is POSITIONALLY REFERENCED — a weighted mesh's vertex influences carry file-order bone
44
+ // indices into it (see parseSpineWeightedVertices). So a malformed entry must NOT be dropped: dropping it
45
+ // would shift every later bone down one slot and silently re-point every weighted-mesh influence at the
46
+ // wrong bone. Instead an inert placeholder bone holds the slot, keeping all indices aligned, and the
47
+ // recovery is recorded.
48
+ function parseSpineBones(raw, diagnostics) {
49
+ const bones = [];
50
+ if (!Array.isArray(raw))
51
+ return bones;
52
+ for (const entry of raw) {
53
+ if (entry === null || typeof entry !== 'object') {
54
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'spine.malformed-bone-recovered', 'parseSpineSkeleton', { bones: 1 });
55
+ bones.push(createPlaceholderBone2D());
56
+ continue;
57
+ }
58
+ const bone = entry;
59
+ const name = typeof bone.name === 'string' ? bone.name : null;
60
+ let parentIndex = -1;
61
+ if (typeof bone.parent === 'string') {
62
+ for (let i = bones.length - 1; i >= 0; i--) {
63
+ if (bones[i].name === bone.parent) {
64
+ parentIndex = i;
65
+ break;
66
+ }
67
+ }
68
+ }
69
+ bones.push({
70
+ length: numberOr(bone.length, 0),
71
+ name,
72
+ parentIndex,
73
+ rotation: numberOr(bone.rotation, 0),
74
+ scaleX: numberOr(bone.scaleX, 1),
75
+ scaleY: numberOr(bone.scaleY, 1),
76
+ shearX: numberOr(bone.shearX, 0),
77
+ shearY: numberOr(bone.shearY, 0),
78
+ transformMode: spineTransformMode(bone.transform),
79
+ x: numberOr(bone.x, 0),
80
+ y: numberOr(bone.y, 0),
81
+ });
82
+ }
83
+ return bones;
84
+ }
85
+ // An inert root bone that holds a slot in the bone array when an entry is malformed, so file-order bone
86
+ // indices (weighted-mesh influences) stay aligned. Identity transform, no parent, no name.
87
+ function createPlaceholderBone2D() {
88
+ return {
89
+ length: 0,
90
+ name: null,
91
+ parentIndex: -1,
92
+ rotation: 0,
93
+ scaleX: 1,
94
+ scaleY: 1,
95
+ shearX: 0,
96
+ shearY: 0,
97
+ transformMode: TransformMode2D.Normal,
98
+ x: 0,
99
+ y: 0,
100
+ };
101
+ }
102
+ // Parses one Spine attachment (identified by its `name` in the skin) into an Attachment2D, or returns
103
+ // null for a recognized-but-unmodeled type (bounding box / path / clipping / point / linked mesh) after
104
+ // emitting a Skip crumb. Spine omits `type` for a region attachment (the default).
105
+ function parseSpineAttachment(name, raw, diagnostics) {
106
+ const type = typeof raw.type === 'string' ? raw.type : 'region';
107
+ if (type === 'region')
108
+ return parseSpineRegionAttachment(name, raw);
109
+ if (type === 'mesh')
110
+ return parseSpineMeshAttachment(name, raw, diagnostics);
111
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, `spine.${type}-attachment-unsupported`, 'parseSpineSkeleton', { name: 1 });
112
+ return null;
113
+ }
114
+ // Parses every named skin the file declares, in file order, into the rig's wardrobe. Spine keys each skin's
115
+ // attachments by SLOT NAME, so the slot array is needed to resolve those to the slot indices
116
+ // `AttachmentSkin2D` stores — which is why this runs after slots rather than before.
117
+ //
118
+ // Both spellings are accepted: the Spine 4.x array-of-skins form and the older object form (`{ default: … }`).
119
+ // The `default` skin is not special here beyond being the one whose attachments a slot's own `attachment`
120
+ // field resolves against; it is returned in the wardrobe alongside the alternates, because a rig that layers
121
+ // `goblin` over `default` needs the base to still be addressable by name.
122
+ function parseSpineSkins(raw, slots, diagnostics) {
123
+ const skins = [];
124
+ const named = [];
125
+ if (Array.isArray(raw)) {
126
+ for (const skin of raw) {
127
+ if (skin === null || typeof skin !== 'object')
128
+ continue;
129
+ const s = skin;
130
+ named.push([typeof s.name === 'string' ? s.name : 'default', s.attachments]);
131
+ }
132
+ }
133
+ else if (raw !== null && typeof raw === 'object') {
134
+ for (const [name, attachments] of Object.entries(raw))
135
+ named.push([name, attachments]);
136
+ }
137
+ for (const [name, rawAttachments] of named) {
138
+ if (rawAttachments === null || typeof rawAttachments !== 'object')
139
+ continue;
140
+ const attachments = [];
141
+ for (const [slotName, slotAttachments] of Object.entries(rawAttachments)) {
142
+ if (slotAttachments === null || typeof slotAttachments !== 'object')
143
+ continue;
144
+ const slotIndex = indexOfSpineSlot(slots, slotName);
145
+ for (const [attachmentName, rawAttachment] of Object.entries(slotAttachments)) {
146
+ if (rawAttachment === null || typeof rawAttachment !== 'object')
147
+ continue;
148
+ const attachment = parseSpineAttachment(attachmentName, rawAttachment, diagnostics);
149
+ // A skin entry for a slot the skeleton does not have cannot be applied, so it is dropped rather
150
+ // than stored with a -1 index that `setSkeleton2DSkin` would have to re-check every wardrobe change.
151
+ if (attachment !== null && slotIndex >= 0)
152
+ attachments.push({ attachment, name: attachmentName, slotIndex });
153
+ }
154
+ }
155
+ skins.push({ attachments, name });
156
+ }
157
+ return skins;
158
+ }
159
+ function indexOfSpineSlot(slots, name) {
160
+ for (let i = 0; i < slots.length; i++) {
161
+ if (slots[i].name === name)
162
+ return i;
163
+ }
164
+ return -1;
165
+ }
166
+ // A Spine mesh attachment. Unweighted when the `vertices` stream is exactly 2 per vertex (positions local
167
+ // to the slot's bone); weighted (Spine format `[boneCount, (boneIndex, x, y, weight)×boneCount]` per
168
+ // vertex) otherwise, producing a Skin2D whose bone indices are global skeleton bone indices.
169
+ function parseSpineMeshAttachment(name, raw, diagnostics) {
170
+ const uvs = toFloat32Array(raw.uvs);
171
+ const triangles = toUint16Array(raw.triangles);
172
+ const rawVerts = Array.isArray(raw.vertices) ? raw.vertices : [];
173
+ const vertexCount = uvs.length >> 1;
174
+ if (rawVerts.length === vertexCount * 2) {
175
+ return {
176
+ kind: MeshAttachment2DKind,
177
+ name,
178
+ skin: null,
179
+ triangles,
180
+ uvs,
181
+ vertexCount,
182
+ vertices: Float32Array.from(rawVerts),
183
+ };
184
+ }
185
+ return {
186
+ kind: MeshAttachment2DKind,
187
+ name,
188
+ skin: parseSpineWeightedVertices(rawVerts, vertexCount, diagnostics),
189
+ triangles,
190
+ uvs,
191
+ vertexCount,
192
+ vertices: null,
193
+ };
194
+ }
195
+ function parseSpineRegionAttachment(name, raw) {
196
+ return {
197
+ height: numberOr(raw.height, 0),
198
+ kind: RegionAttachment2DKind,
199
+ name,
200
+ rotation: numberOr(raw.rotation, 0),
201
+ scaleX: numberOr(raw.scaleX, 1),
202
+ scaleY: numberOr(raw.scaleY, 1),
203
+ width: numberOr(raw.width, 0),
204
+ x: numberOr(raw.x, 0),
205
+ y: numberOr(raw.y, 0),
206
+ };
207
+ }
208
+ // Slots bind a bone to its currently-shown attachment; their array order is the draw order. `boneIndex`
209
+ // resolves the slot's bone name; the shown attachment is the slot's `attachment` name looked up in the
210
+ // default skin. `color` is the Spine "rrggbbaa" tint (default opaque white).
211
+ function parseSpineSlots(raw, bones) {
212
+ const attachmentNames = [];
213
+ const slots = [];
214
+ if (!Array.isArray(raw))
215
+ return { attachmentNames, slots };
216
+ for (const entry of raw) {
217
+ if (entry === null || typeof entry !== 'object')
218
+ continue;
219
+ const slot = entry;
220
+ const name = typeof slot.name === 'string' ? slot.name : null;
221
+ let boneIndex = -1;
222
+ if (typeof slot.bone === 'string') {
223
+ for (let i = 0; i < bones.length; i++) {
224
+ if (bones[i].name === slot.bone) {
225
+ boneIndex = i;
226
+ break;
227
+ }
228
+ }
229
+ }
230
+ attachmentNames.push(typeof slot.attachment === 'string' ? slot.attachment : null);
231
+ slots.push({ attachment: null, boneIndex, color: parseSpineColor(slot.color), name });
232
+ }
233
+ return { attachmentNames, slots };
234
+ }
235
+ // Resolves each slot's setup attachment out of the DEFAULT skin, now that both exist. A slot names the
236
+ // attachment it shows, but the art lives in a skin — and skins are keyed by slot, so neither can be built
237
+ // without the other. Slots are therefore built bare and dressed here.
238
+ function resolveSpineSetupAttachments(slots, attachmentNames, skins) {
239
+ const setup = skins.find((skin) => skin.name === SPINE_DEFAULT_SKIN_NAME) ?? skins[0];
240
+ if (setup === undefined)
241
+ return;
242
+ for (const entry of setup.attachments) {
243
+ if (entry.slotIndex < slots.length && attachmentNames[entry.slotIndex] === entry.name) {
244
+ slots[entry.slotIndex].attachment = entry.attachment;
245
+ }
246
+ }
247
+ }
248
+ // A Spine "rrggbbaa" hex color to a packed RGBA integer; opaque white (0xffffffff) when absent/invalid.
249
+ function parseSpineColor(value) {
250
+ if (typeof value !== 'string' || value.length !== 8)
251
+ return 0xffffffff;
252
+ const parsed = Number.parseInt(value, 16);
253
+ return Number.isNaN(parsed) ? 0xffffffff : parsed >>> 0;
254
+ }
255
+ // Decodes Spine's variable-influence weighted-vertex stream: per vertex a `boneCount` followed by that many
256
+ // `(boneIndex, x, y, weight)` quads. Both the vertex count (from `uvs`) and each `boneCount` are declared IN
257
+ // the file, independent of the stream's actual length — so every read is bounded against `rawVerts.length`
258
+ // (the independent address anchor): a `boneCount` that would run past the end is clamped to what remains,
259
+ // and the recovery is recorded. Without this a corrupt count reads `undefined` (→ NaN) or, with a huge
260
+ // value, spins a near-unbounded push loop. `boneIndex` values are file-order indices into the bone array;
261
+ // parseSpineBones keeps that array aligned (it never drops a slot) so they stay valid.
262
+ function parseSpineWeightedVertices(rawVerts, vertexCount, diagnostics) {
263
+ const influenceCounts = new Uint16Array(vertexCount);
264
+ const influences = [];
265
+ let truncated = false;
266
+ let r = 0;
267
+ for (let v = 0; v < vertexCount; v++) {
268
+ if (r >= rawVerts.length) {
269
+ truncated = true;
270
+ break;
271
+ }
272
+ const declared = rawVerts[r++] | 0;
273
+ const available = Math.max(0, (rawVerts.length - r) >> 2);
274
+ const boneCount = Math.min(Math.max(declared, 0), available);
275
+ if (boneCount !== declared)
276
+ truncated = true;
277
+ influenceCounts[v] = boneCount;
278
+ for (let k = 0; k < boneCount; k++) {
279
+ influences.push(rawVerts[r], rawVerts[r + 1], rawVerts[r + 2], rawVerts[r + 3]);
280
+ r += 4;
281
+ }
282
+ }
283
+ if (truncated) {
284
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'spine.weighted-vertices-truncated', 'parseSpineSkeleton', { vertices: 1 });
285
+ }
286
+ return { influenceCounts, influences: Float32Array.from(influences) };
287
+ }
288
+ function toFloat32Array(value) {
289
+ return Array.isArray(value) ? Float32Array.from(value) : new Float32Array();
290
+ }
291
+ function toUint16Array(value) {
292
+ return Array.isArray(value) ? Uint16Array.from(value) : new Uint16Array();
293
+ }
294
+ // Adds one bone-timeline channel to `channels`: builds an AnimationTrack from the Spine keyframes (times
295
+ // + `extract`ed component values, with the setup pose already baked into `extract`) targeting the bone's
296
+ // `path`. A timeline whose every keyframe is `curve: 'stepped'` is a Step track; otherwise Linear.
297
+ //
298
+ // A keyframe may instead carry `curve` as an array of cubic-bezier control points, which becomes a
299
+ // per-interval `segmentEasings` entry on the track (see buildSpineSegmentEasings).
300
+ function addSpineBoneChannel(channels, rawKeys, boneIndex, path, components, extract, diagnostics) {
301
+ if (!Array.isArray(rawKeys) || rawKeys.length === 0)
302
+ return;
303
+ // Malformed entries are dropped, so the accepted keys are collected alongside the times/values they
304
+ // produced — segment i must index the key that actually wrote keyframe i, not the raw array position.
305
+ const keys = [];
306
+ const times = [];
307
+ const values = [];
308
+ let allStepped = true;
309
+ for (const key of rawKeys) {
310
+ if (key === null || typeof key !== 'object')
311
+ continue;
312
+ const k = key;
313
+ keys.push(k);
314
+ times.push(numberOr(k.time, 0));
315
+ for (const component of extract(k))
316
+ values.push(component);
317
+ if (k.curve !== 'stepped')
318
+ allStepped = false;
319
+ }
320
+ const interpolation = allStepped ? AnimationInterpolationStep : AnimationInterpolationLinear;
321
+ const segmentEasings = buildSpineSegmentEasings(keys, times, values, components, diagnostics);
322
+ const track = createAnimationTrack({ components, interpolation, segmentEasings, times, values });
323
+ channels.push(createAnimationChannel(track, { boneIndex, path }));
324
+ }
325
+ // Converts Spine's per-keyframe bezier `curve` arrays into one `EasingFunction` per INTERVAL, which is the
326
+ // shape `AnimationTrack.segmentEasings` takes (entry i reshapes the alpha of the segment from key i to
327
+ // i+1). Returns `null` when no interval carries a curve, so an uncurved timeline allocates nothing.
328
+ //
329
+ // Spine writes the control points in ABSOLUTE time/value units — not normalized — and writes FOUR numbers
330
+ // PER COMPONENT, in component order (a 1-component `rotate` carries 4, a 2-component `translate` carries 8).
331
+ // So each control point is rebased onto the segment to get the unit-square curve `easeCubicBezier` wants:
332
+ // `x = (cx − t1) / (t2 − t1)` and `y = (cy − v1) / (v2 − v1)`.
333
+ //
334
+ // PER-COMPONENT DIVERGENCE. Spine permits a different curve per component, but a Flight track carries one
335
+ // easing per interval, so the FIRST component's curve wins and a divergence is Skip-crumbed rather than
336
+ // silently dropped [decision 2026-07-30]. Divergence is measured on the NORMALIZED control points, not the
337
+ // raw numbers: two components with the same curve shape but different value ranges write different raw
338
+ // `cy`s, so a raw comparison would report divergence on essentially every multi-component timeline.
339
+ // A component whose value does not change across the segment is skipped when picking the winner — its
340
+ // curve carries no shape (the rebase would divide by zero) — so "first" means first MEANINGFUL component.
341
+ function buildSpineSegmentEasings(keys, times, values, components, diagnostics) {
342
+ const segments = times.length - 1;
343
+ if (segments < 1)
344
+ return null;
345
+ const easings = [];
346
+ let curved = false;
347
+ let clampedSegments = 0;
348
+ let divergentSegments = 0;
349
+ for (let i = 0; i < segments; i++) {
350
+ const curve = keys[i].curve;
351
+ const span = times[i + 1] - times[i];
352
+ if (!Array.isArray(curve) || span <= 0) {
353
+ easings.push(null);
354
+ continue;
355
+ }
356
+ // Pick the component with the LARGEST value change to supply the easing. The rebase divides by that
357
+ // change, so a near-constant component is a near-zero denominator: it amplifies ordinary float noise
358
+ // into control points far outside the unit square and yields a curve that is not the authored shape at
359
+ // all. Choosing the dominant component is both the numerically stable option and the honest one — it is
360
+ // the channel that actually carries the segment's motion.
361
+ let winner = -1;
362
+ let widest = 0;
363
+ for (let c = 0; c < components && (c + 1) * 4 <= curve.length; c++) {
364
+ const rise = Math.abs(values[(i + 1) * components + c] - values[i * components + c]);
365
+ if (rise > widest) {
366
+ widest = rise;
367
+ winner = c;
368
+ }
369
+ }
370
+ // The winner's control points are resolved FIRST, then every other component is compared against them.
371
+ // Comparing inside a single pass would measure components that precede the winner against zeros.
372
+ const rebase = (c) => {
373
+ const from = values[i * components + c];
374
+ const rise = values[(i + 1) * components + c] - from;
375
+ if (rise === 0)
376
+ return null;
377
+ const offset = c * 4;
378
+ return [
379
+ (numberOr(curve[offset], 0) - times[i]) / span,
380
+ (numberOr(curve[offset + 1], 0) - from) / rise,
381
+ (numberOr(curve[offset + 2], 0) - times[i]) / span,
382
+ (numberOr(curve[offset + 3], 0) - from) / rise,
383
+ ];
384
+ };
385
+ const won = winner < 0 ? null : rebase(winner);
386
+ let diverged = false;
387
+ if (won !== null) {
388
+ for (let c = 0; c < components && (c + 1) * 4 <= curve.length; c++) {
389
+ if (c === winner)
390
+ continue;
391
+ const other = rebase(c);
392
+ if (other === null)
393
+ continue;
394
+ for (let k = 0; k < 4; k++) {
395
+ if (Math.abs(other[k] - won[k]) > SPINE_CURVE_EPSILON)
396
+ diverged = true;
397
+ }
398
+ }
399
+ }
400
+ const chosen = won !== null;
401
+ const x1 = won === null ? 0 : won[0];
402
+ const y1 = won === null ? 0 : won[1];
403
+ const x2 = won === null ? 0 : won[2];
404
+ const y2 = won === null ? 0 : won[3];
405
+ if (diverged)
406
+ divergentSegments++;
407
+ if (chosen) {
408
+ curved = true;
409
+ // Spine lets a control point sit OUTSIDE its segment in time, which a CSS-style cubic bezier cannot
410
+ // represent: `easeCubicBezier` inverts x→parameter, and that inversion is only well defined while x
411
+ // stays monotonic over [0,1]. So the x components are clamped and the loss is recorded. The y
412
+ // components are deliberately left unclamped — a y outside [0,1] is legitimate overshoot/anticipation
413
+ // and the solver handles it, since y is the output value rather than the thing being inverted.
414
+ const clampedX1 = clampUnit(x1);
415
+ const clampedX2 = clampUnit(x2);
416
+ if (clampedX1 !== x1 || clampedX2 !== x2)
417
+ clampedSegments++;
418
+ easings.push(easeCubicBezier(clampedX1, y1, clampedX2, y2));
419
+ }
420
+ else {
421
+ easings.push(null);
422
+ }
423
+ }
424
+ if (clampedSegments > 0) {
425
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'spine.curve-time-overshoot-clamped', 'parseSpineSkeleton', { segments: clampedSegments });
426
+ }
427
+ if (divergentSegments > 0) {
428
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'spine.per-component-curve-easing-unsupported', 'parseSpineSkeleton', { segments: divergentSegments });
429
+ }
430
+ return curved ? easings : null;
431
+ }
432
+ function indexOfBone(bones, name) {
433
+ for (let i = 0; i < bones.length; i++) {
434
+ if (bones[i].name === name)
435
+ return i;
436
+ }
437
+ return -1;
438
+ }
439
+ // Builds one AnimationClip per Spine animation from its bone rotate/translate/scale/shear timelines.
440
+ // Spine bone timelines are RELATIVE to the setup pose (rotate/translate/shear are offsets, scale is a
441
+ // multiplier), and clips are emitted as those RAW relative deltas — `applyAnimationClipToSkeleton2D`
442
+ // composes them onto the setup pose per frame (add / multiply, keyed by `path`). Keeping deltas relative
443
+ // (rather than baking setup into keys) is what lets a mixer blend clips as `setup + Σ wᵢ·deltaᵢ`.
444
+ // Constraint (ik/transform/path), event, deform, draw-order, and slot timelines are recognized-but-
445
+ // unmodeled and Skip-crumbed. (Spine per-keyframe bezier curves approximate to Linear — a P1 fidelity
446
+ // limit noted in the package status.)
447
+ function parseSpineAnimations(raw, bones, slots, skins, diagnostics) {
448
+ const animations = [];
449
+ if (raw === null || typeof raw !== 'object')
450
+ return animations;
451
+ for (const [name, animEntry] of Object.entries(raw)) {
452
+ if (animEntry === null || typeof animEntry !== 'object')
453
+ continue;
454
+ const anim = animEntry;
455
+ const channels = [];
456
+ if (anim.bones !== null && typeof anim.bones === 'object') {
457
+ for (const [boneName, timelinesEntry] of Object.entries(anim.bones)) {
458
+ const boneIndex = indexOfBone(bones, boneName);
459
+ if (boneIndex < 0 || timelinesEntry === null || typeof timelinesEntry !== 'object')
460
+ continue;
461
+ const timelines = timelinesEntry;
462
+ addSpineBoneChannel(channels, timelines.rotate, boneIndex, Skeleton2DAnimationPath.Rotation, 1, (k) => [numberOr(k.value, 0)], diagnostics);
463
+ addSpineBoneChannel(channels, timelines.translate, boneIndex, Skeleton2DAnimationPath.Translation, 2, (k) => [numberOr(k.x, 0), numberOr(k.y, 0)], diagnostics);
464
+ addSpineBoneChannel(channels, timelines.scale, boneIndex, Skeleton2DAnimationPath.Scale, 2, (k) => [numberOr(k.x, 1), numberOr(k.y, 1)], diagnostics);
465
+ addSpineBoneChannel(channels, timelines.shear, boneIndex, Skeleton2DAnimationPath.Shear, 2, (k) => [numberOr(k.x, 0), numberOr(k.y, 0)], diagnostics);
466
+ }
467
+ }
468
+ parseSpineSlotTimelines(channels, anim.slots, slots, skins, diagnostics);
469
+ skipCrumbSpineTimelineGroup(diagnostics, anim.ik, 'spine.ik-timeline-unsupported');
470
+ skipCrumbSpineTimelineGroup(diagnostics, anim.transform, 'spine.transform-timeline-unsupported');
471
+ skipCrumbSpineTimelineGroup(diagnostics, anim.path, 'spine.path-timeline-unsupported');
472
+ skipCrumbSpineTimelineGroup(diagnostics, anim.deform, 'spine.deform-timeline-unsupported');
473
+ skipCrumbSpineTimelineGroup(diagnostics, anim.events, 'spine.event-timeline-unsupported');
474
+ skipCrumbSpineTimelineGroup(diagnostics, anim.drawOrder ?? anim.draworder, 'spine.draworder-timeline-unsupported');
475
+ animations.push({ clip: createAnimationClip(channels), name });
476
+ }
477
+ return animations;
478
+ }
479
+ // Slot timelines. Only `rgba` is modeled — it becomes a four-component 0..1 colour channel on a
480
+ // `Skeleton2DSlotAnimationTarget`. Spine writes the colour as an "rrggbbaa" hex string per keyframe and its
481
+ // curve control points in that same 0..1 space, so the track carries normalized channels rather than bytes.
482
+ //
483
+ // `rgb`, `alpha`, and the two dark-colour variants are recognized but not modeled: `Slot2D` has one packed
484
+ // colour and no dark colour, so a partial-channel timeline cannot be represented without inventing a setup
485
+ // blend. `attachment` swaps are a separate landing (index track + lookup table). Each is Skip-crumbed.
486
+ function parseSpineSlotTimelines(channels, raw, slots, skins, diagnostics) {
487
+ if (raw === null || typeof raw !== 'object')
488
+ return;
489
+ const unmodeled = new Map();
490
+ for (const [slotName, timelinesEntry] of Object.entries(raw)) {
491
+ if (timelinesEntry === null || typeof timelinesEntry !== 'object')
492
+ continue;
493
+ const slotIndex = indexOfSpineSlot(slots, slotName);
494
+ for (const [kind, keys] of Object.entries(timelinesEntry)) {
495
+ if (kind !== 'rgba' && kind !== 'attachment') {
496
+ unmodeled.set(kind, (unmodeled.get(kind) ?? 0) + 1);
497
+ continue;
498
+ }
499
+ if (slotIndex < 0 || !Array.isArray(keys) || keys.length === 0)
500
+ continue;
501
+ if (kind === 'attachment')
502
+ addSpineSlotAttachmentChannel(channels, keys, slotIndex, slotName, skins);
503
+ else
504
+ addSpineSlotColorChannel(channels, keys, slotIndex, diagnostics);
505
+ }
506
+ }
507
+ for (const [kind, count] of unmodeled) {
508
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, `spine.slot-${kind}-timeline-unsupported`, 'parseSpineSkeleton', { timelines: count });
509
+ }
510
+ }
511
+ // One `rgba` slot timeline → a Color channel. Reuses the shared bezier rebase, which is why the values must
512
+ // be in the same 0..1 space the curve control points are authored in.
513
+ function addSpineSlotColorChannel(channels, rawKeys, slotIndex, diagnostics) {
514
+ const keys = [];
515
+ const times = [];
516
+ const values = [];
517
+ let allStepped = true;
518
+ for (const key of rawKeys) {
519
+ if (key === null || typeof key !== 'object')
520
+ continue;
521
+ const k = key;
522
+ keys.push(k);
523
+ times.push(numberOr(k.time, 0));
524
+ const packed = parseSpineColor(k.color);
525
+ values.push(((packed >>> 24) & 0xff) / 255, ((packed >>> 16) & 0xff) / 255, ((packed >>> 8) & 0xff) / 255, (packed & 0xff) / 255);
526
+ if (k.curve !== 'stepped')
527
+ allStepped = false;
528
+ }
529
+ if (times.length === 0)
530
+ return;
531
+ const interpolation = allStepped ? AnimationInterpolationStep : AnimationInterpolationLinear;
532
+ const segmentEasings = buildSpineSegmentEasings(keys, times, values, 4, diagnostics);
533
+ const track = createAnimationTrack({ components: 4, interpolation, segmentEasings, times, values });
534
+ channels.push(createAnimationChannel(track, { path: Skeleton2DSlotAnimationPath.Color, slotIndex }));
535
+ }
536
+ // One `attachment` slot timeline → a STEP channel of indices into a per-channel attachment table.
537
+ //
538
+ // The keyframes name attachments by string; the table resolves each name ONCE here, against the setup skin,
539
+ // and the track then carries only the index. A keyframe with no name becomes `-1` — Spine's way of hiding a
540
+ // slot, which spineboy's `shoot` uses to extinguish muzzle flashes. Names that the setup skin does not
541
+ // supply also become `-1` rather than being dropped, because dropping a keyframe would shift the timing of
542
+ // every later swap.
543
+ //
544
+ // The table is deduplicated: a flash cycling through four images and back writes each attachment once.
545
+ function addSpineSlotAttachmentChannel(channels, rawKeys, slotIndex, slotName, skins) {
546
+ const setup = skins.find((skin) => skin.name === SPINE_DEFAULT_SKIN_NAME) ?? skins[0];
547
+ const attachments = [];
548
+ const indexByName = new Map();
549
+ const times = [];
550
+ const values = [];
551
+ for (const key of rawKeys) {
552
+ if (key === null || typeof key !== 'object')
553
+ continue;
554
+ const k = key;
555
+ times.push(numberOr(k.time, 0));
556
+ const name = typeof k.name === 'string' ? k.name : null;
557
+ if (name === null) {
558
+ values.push(SPINE_NO_ATTACHMENT_INDEX);
559
+ continue;
560
+ }
561
+ let index = indexByName.get(name);
562
+ if (index === undefined) {
563
+ const found = setup?.attachments.find((entry) => entry.slotIndex === slotIndex && entry.name === name);
564
+ index = found === undefined ? SPINE_NO_ATTACHMENT_INDEX : attachments.push(found.attachment) - 1;
565
+ indexByName.set(name, index);
566
+ }
567
+ values.push(index);
568
+ }
569
+ if (times.length === 0)
570
+ return;
571
+ const track = createAnimationTrack({
572
+ components: 1,
573
+ interpolation: AnimationInterpolationStep,
574
+ times,
575
+ values,
576
+ });
577
+ channels.push(createAnimationChannel(track, { attachments, path: Skeleton2DSlotAnimationPath.Attachment, slotIndex }));
578
+ }
579
+ // Reports one aggregated Skip crumb for an unmodeled animation timeline group, with the group's element
580
+ // count. An absent or empty group is silent.
581
+ function skipCrumbSpineTimelineGroup(diagnostics, raw, kind) {
582
+ let count = 0;
583
+ if (Array.isArray(raw))
584
+ count = raw.length;
585
+ else if (raw !== null && typeof raw === 'object')
586
+ count = Object.keys(raw).length;
587
+ if (count > 0)
588
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, kind, 'parseSpineSkeleton', { count });
589
+ }
590
+ // Clamps a normalized bezier x component into the unit interval the curve solver can invert over.
591
+ function clampUnit(value) {
592
+ return value < 0 ? 0 : value > 1 ? 1 : value;
593
+ }
594
+ // Two normalized bezier control points closer than this are the same curve shape. Float rebasing through
595
+ // differing per-component value ranges introduces small error, so an exact comparison would report
596
+ // divergence on curves that are actually identical.
597
+ const SPINE_CURVE_EPSILON = 1e-6;
598
+ // Spine's name for the base skin every rig has; alternates layer over it.
599
+ const SPINE_DEFAULT_SKIN_NAME = 'default';
600
+ // The index an attachment channel uses for "show nothing" — Spine's nameless keyframe, and any name the
601
+ // setup skin cannot supply.
602
+ const SPINE_NO_ATTACHMENT_INDEX = -1;
603
+ // Maps a Spine bone `transform` string to a TransformMode2D. Spine omits the field for the default,
604
+ // so an absent/unknown value is `Normal`.
605
+ function spineTransformMode(value) {
606
+ switch (value) {
607
+ case 'onlyTranslation':
608
+ return TransformMode2D.OnlyTranslation;
609
+ case 'noRotationOrReflection':
610
+ return TransformMode2D.NoRotationOrReflection;
611
+ case 'noScale':
612
+ return TransformMode2D.NoScale;
613
+ case 'noScaleOrReflection':
614
+ return TransformMode2D.NoScaleOrReflection;
615
+ default:
616
+ return TransformMode2D.Normal;
617
+ }
618
+ }
619
+ //# sourceMappingURL=spineParse.js.map