@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,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
|