@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,867 @@
|
|
|
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, Skeleton2DSlotAnimationPath, ImportDiagnosticSeverity, MeshAttachment2DKind, RegionAttachment2DKind, Skeleton2DAnimationPath, } from '@flighthq/types/contract';
|
|
6
|
+
// Parses a DragonBones `.json` skeleton document (text) into a Skeleton2DImport. Tolerant and best-effort,
|
|
7
|
+
// mirroring parseSpineSkeleton: a malformed / non-DragonBones document returns the sentinel `null`, and a
|
|
8
|
+
// recognized document with unmodeled pieces yields best-effort data plus `ImportDiagnostic` Skip crumbs.
|
|
9
|
+
// Field names follow DragonBones' vocabulary (armature / bone / slot / skin / animation).
|
|
10
|
+
//
|
|
11
|
+
// Parses the first armature's bone hierarchy, slots, and default-skin displays. DragonBones differs from
|
|
12
|
+
// Spine in ways the charter (open-direction 4) records: an `armature` container (multiple armatures
|
|
13
|
+
// possible), a nested `transform` block with `skX`/`skY` (or newer `rotate`/`skew`) skew angles rather than
|
|
14
|
+
// Spine's flat fields, bones NOT guaranteed parent-before-child (so they are topologically sorted here), a
|
|
15
|
+
// four-boolean inheritance model (inheritRotation/Scale/Reflection/Translation) mapped straight onto Flight's
|
|
16
|
+
// vendor-neutral TransformInherit2D (every combination expressible — no gap), and slots whose shown attachment
|
|
17
|
+
// is a `displayIndex` into a per-slot display list (so that list is position-preserving — see
|
|
18
|
+
// parseDragonBonesDefaultSkin). Image displays become region attachments; unweighted AND weighted mesh
|
|
19
|
+
// displays become mesh attachments (weighted via bonePose/slotPose → Skin2D offsets with the topo-sort
|
|
20
|
+
// bone-index remap — see parseDragonBonesWeightedMesh). Each `animation` becomes an @flighthq/animation
|
|
21
|
+
// clip of RELATIVE bone deltas built from the frame-based translate/rotate/scale timelines (see
|
|
22
|
+
// parseDragonBonesAnimations). Armature/bounding-box/path displays, shared and legacy-weighted meshes,
|
|
23
|
+
// additional armatures, alternate skins, IK constraints, and the non-bone timelines are recognized-but-
|
|
24
|
+
// unmodeled and Skip-crumbed.
|
|
25
|
+
export function parseDragonBonesSkeleton(json, diagnostics) {
|
|
26
|
+
let doc;
|
|
27
|
+
try {
|
|
28
|
+
doc = JSON.parse(json);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (doc === null || typeof doc !== 'object')
|
|
34
|
+
return null;
|
|
35
|
+
const armatures = doc.armature;
|
|
36
|
+
if (!Array.isArray(armatures) || armatures.length === 0)
|
|
37
|
+
return null; // not a DragonBones document
|
|
38
|
+
if (armatures.length > 1) {
|
|
39
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.multi-armature-unsupported', 'parseDragonBonesSkeleton', { armatures: armatures.length - 1 });
|
|
40
|
+
}
|
|
41
|
+
const first = armatures[0];
|
|
42
|
+
if (first === null || typeof first !== 'object')
|
|
43
|
+
return null;
|
|
44
|
+
const armature = first;
|
|
45
|
+
const { bones, rawIndexToOutput } = parseDragonBonesBones(armature.bone, diagnostics);
|
|
46
|
+
const boneIndexByName = buildBoneIndexByName(bones);
|
|
47
|
+
const remapBoneIndex = buildDragonBonesBoneRemap(rawIndexToOutput);
|
|
48
|
+
const slotOrder = buildDragonBonesSlotOrder(armature.slot);
|
|
49
|
+
const { skins, table } = parseDragonBonesSkins(armature.skin, slotOrder, remapBoneIndex, diagnostics);
|
|
50
|
+
const slots = parseDragonBonesSlots(armature.slot, boneIndexByName, table, diagnostics);
|
|
51
|
+
const frameRate = dragonBonesFrameRate(armature, doc);
|
|
52
|
+
const animations = parseDragonBonesAnimations(armature.animation, boneIndexByName, slotOrder, table, frameRate, diagnostics);
|
|
53
|
+
skipCrumbDragonBonesGroup(diagnostics, armature.ik, 'dragonbones.ik-constraint-unsupported');
|
|
54
|
+
const skeleton = createSkeleton2D(bones, slots);
|
|
55
|
+
if (skins.length > 0)
|
|
56
|
+
skeleton.skins = skins;
|
|
57
|
+
return { animations, skeleton };
|
|
58
|
+
}
|
|
59
|
+
// Rebuilds the bone-name → output-index lookup from the (already topologically sorted) bone array, so slot
|
|
60
|
+
// `parent` references and future weighted-mesh bone indices resolve to the FINAL emitted position rather
|
|
61
|
+
// than the file's authoring order.
|
|
62
|
+
function buildBoneIndexByName(bones) {
|
|
63
|
+
const byName = new Map();
|
|
64
|
+
for (let i = 0; i < bones.length; i++) {
|
|
65
|
+
const name = bones[i].name;
|
|
66
|
+
if (typeof name === 'string')
|
|
67
|
+
byName.set(name, i);
|
|
68
|
+
}
|
|
69
|
+
return byName;
|
|
70
|
+
}
|
|
71
|
+
// Maps a DragonBones armature-FILE-ORDER bone index (the space weighted-mesh `weights`/`bonePose` reference
|
|
72
|
+
// bones in) to the FINAL topo-sorted OUTPUT bone index — the read-integrity axis-12 remap the topo-sort
|
|
73
|
+
// makes necessary. Backed by the identity-preserving `rawIndexToOutput` table built during emit (NOT
|
|
74
|
+
// reconstructed by name, which would collide duplicate names). Returns -1 for an out-of-range or dropped
|
|
75
|
+
// raw index; callers must treat -1 as an unresolved influence and drop it, never emit it as a bone index.
|
|
76
|
+
function buildDragonBonesBoneRemap(rawIndexToOutput) {
|
|
77
|
+
return (rawBoneIndex) => rawBoneIndex >= 0 && rawBoneIndex < rawIndexToOutput.length ? rawIndexToOutput[rawBoneIndex] : -1;
|
|
78
|
+
}
|
|
79
|
+
// Builds one AnimationClip per DragonBones `animation` from its per-bone frame timelines. DragonBones bone
|
|
80
|
+
// timelines are RELATIVE to the setup pose exactly as Spine's are — `translateFrame` x/y are offsets (default
|
|
81
|
+
// 0), `rotateFrame` rotate/skew are angle offsets in degrees (default 0), `scaleFrame` x/y are multipliers
|
|
82
|
+
// (default 1) — so clips are emitted as those raw deltas and `applyAnimationClipToSkeleton2D` composes them
|
|
83
|
+
// onto the setup pose per frame (add / multiply, keyed by `path`). Keeping deltas relative is what lets a
|
|
84
|
+
// mixer blend clips as `setup + Σ wᵢ·deltaᵢ`.
|
|
85
|
+
//
|
|
86
|
+
// The one structural difference from Spine is the TIME AXIS: Spine keys carry absolute `time` in seconds,
|
|
87
|
+
// while DragonBones keys carry a `duration` in FRAMES and the armature carries the `frameRate` — so times are
|
|
88
|
+
// the running duration sum ÷ frameRate (see dragonBonesFrameTimes). The clip's own duration comes from the
|
|
89
|
+
// animation's declared `duration` (also in frames), which may outlast the last keyframe when the animation
|
|
90
|
+
// holds. Slot, FFD (deform), IK, and z-order timelines, and the legacy combined `frame` bone timeline, are
|
|
91
|
+
// recognized-but-unmodeled and Skip-crumbed. A timeline naming a bone this armature does not have is dropped
|
|
92
|
+
// best-effort and Recover-crumbed once for the whole document.
|
|
93
|
+
function parseDragonBonesAnimations(raw, boneIndexByName, slotOrder, displayTable, frameRate, diagnostics) {
|
|
94
|
+
const animations = [];
|
|
95
|
+
if (!Array.isArray(raw))
|
|
96
|
+
return animations;
|
|
97
|
+
const unmodeled = new Map();
|
|
98
|
+
let blendTrees = 0;
|
|
99
|
+
let unresolvedBones = 0;
|
|
100
|
+
for (const entry of raw) {
|
|
101
|
+
if (entry === null || typeof entry !== 'object')
|
|
102
|
+
continue;
|
|
103
|
+
const animation = entry;
|
|
104
|
+
// DragonBones 5.6 also stores BLEND TREES under `animation` — `type: 'tree'` with a `timeline` array
|
|
105
|
+
// instead of the bone/slot arrays a keyframe animation carries. Flight models no blend tree here, so
|
|
106
|
+
// such an entry yields an empty clip. The name is still emitted (so the rig's animation list stays
|
|
107
|
+
// complete and honest about what exists) but the emptiness is CRUMBED rather than left silent: an empty
|
|
108
|
+
// clip that plays and does nothing is exactly the silent sentinel the diagnostics rule exists to catch.
|
|
109
|
+
if (animation.type === DRAGONBONES_BLEND_TREE_TYPE)
|
|
110
|
+
blendTrees++;
|
|
111
|
+
const channels = [];
|
|
112
|
+
if (Array.isArray(animation.bone)) {
|
|
113
|
+
for (const rawTimeline of animation.bone) {
|
|
114
|
+
if (rawTimeline === null || typeof rawTimeline !== 'object')
|
|
115
|
+
continue;
|
|
116
|
+
const timeline = rawTimeline;
|
|
117
|
+
const boneIndex = typeof timeline.name === 'string' ? (boneIndexByName.get(timeline.name) ?? -1) : -1;
|
|
118
|
+
if (boneIndex < 0) {
|
|
119
|
+
unresolvedBones++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
parseDragonBonesBoneTimeline(channels, timeline, boneIndex, frameRate, diagnostics);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
parseDragonBonesSlotTimelines(channels, animation.slot, slotOrder, displayTable, frameRate, unmodeled);
|
|
126
|
+
skipCrumbDragonBonesGroup(diagnostics, animation.ffd, 'dragonbones.deform-timeline-unsupported');
|
|
127
|
+
skipCrumbDragonBonesGroup(diagnostics, animation.ik, 'dragonbones.ik-timeline-unsupported');
|
|
128
|
+
skipCrumbDragonBonesGroup(diagnostics, animation.zOrder, 'dragonbones.zorder-timeline-unsupported');
|
|
129
|
+
const duration = numberOr(animation.duration, 0) / frameRate;
|
|
130
|
+
animations.push({
|
|
131
|
+
clip: createAnimationClip(channels, Number.isFinite(duration) && duration > 0 ? duration : undefined),
|
|
132
|
+
name: typeof animation.name === 'string' ? animation.name : DEFAULT_DRAGONBONES_ANIMATION_NAME,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (blendTrees > 0) {
|
|
136
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.blend-tree-animation-unsupported', 'parseDragonBonesSkeleton', { animations: blendTrees });
|
|
137
|
+
}
|
|
138
|
+
for (const [kind, count] of unmodeled) {
|
|
139
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, `dragonbones.${kind}-timeline-unsupported`, 'parseDragonBonesSkeleton', { timelines: count });
|
|
140
|
+
}
|
|
141
|
+
if (unresolvedBones > 0) {
|
|
142
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'dragonbones.animation-bone-unresolved', 'parseDragonBonesSkeleton', { bones: unresolvedBones });
|
|
143
|
+
}
|
|
144
|
+
return animations;
|
|
145
|
+
}
|
|
146
|
+
// DragonBones slot timelines. `displayFrame` becomes a Step attachment-swap channel and `colorFrame` a
|
|
147
|
+
// four-component 0..1 colour channel, matching what the Spine parsers produce — the two formats differ in
|
|
148
|
+
// spelling, not in what they animate.
|
|
149
|
+
//
|
|
150
|
+
// DragonBones addresses a display by INDEX into the slot's display list, which is already the shape the
|
|
151
|
+
// attachment-swap track wants, so the lookup table IS that display list and no name resolution is needed.
|
|
152
|
+
// A negative index means "show nothing", exactly as the track's own -1 convention does.
|
|
153
|
+
//
|
|
154
|
+
// Older exports spell the frame lists `display`/`color` and carry the value inline rather than under
|
|
155
|
+
// `value`; both spellings are accepted, since a self-describing format costs nothing to be tolerant with.
|
|
156
|
+
function parseDragonBonesSlotTimelines(channels, raw, slotOrder, displayTable, frameRate, unmodeled) {
|
|
157
|
+
if (!Array.isArray(raw))
|
|
158
|
+
return;
|
|
159
|
+
for (const entry of raw) {
|
|
160
|
+
if (entry === null || typeof entry !== 'object')
|
|
161
|
+
continue;
|
|
162
|
+
const timeline = entry;
|
|
163
|
+
const name = typeof timeline.name === 'string' ? timeline.name : null;
|
|
164
|
+
const slotIndex = name === null ? -1 : (slotOrder.get(name) ?? -1);
|
|
165
|
+
if (slotIndex < 0) {
|
|
166
|
+
unmodeled.set('slot', (unmodeled.get('slot') ?? 0) + 1);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const displayFrames = dragonBonesFrames(timeline.displayFrame ?? timeline.display, undefined);
|
|
170
|
+
if (displayFrames.length > 0) {
|
|
171
|
+
addDragonBonesDisplayChannel(channels, displayFrames, slotIndex, displayTable.get(name ?? '') ?? [], frameRate);
|
|
172
|
+
}
|
|
173
|
+
const colorFrames = dragonBonesFrames(timeline.colorFrame ?? timeline.color, undefined);
|
|
174
|
+
if (colorFrames.length > 0)
|
|
175
|
+
addDragonBonesSlotColorChannel(channels, colorFrames, slotIndex, frameRate);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// A `displayFrame` list → a Step channel of indices into the slot's display list. The list is the table
|
|
179
|
+
// verbatim: DragonBones already addresses displays positionally, so an index needs no translation.
|
|
180
|
+
function addDragonBonesDisplayChannel(channels, frames, slotIndex, displays, frameRate) {
|
|
181
|
+
const times = dragonBonesFrameTimes(frames, frameRate);
|
|
182
|
+
const values = [];
|
|
183
|
+
for (const frame of frames) {
|
|
184
|
+
const index = numberOr(frame.value, numberOr(frame.displayIndex, 0)) | 0;
|
|
185
|
+
values.push(index >= 0 && index < displays.length && displays[index] !== null ? index : -1);
|
|
186
|
+
}
|
|
187
|
+
const track = createAnimationTrack({ components: 1, interpolation: AnimationInterpolationStep, times, values });
|
|
188
|
+
channels.push(createAnimationChannel(track, {
|
|
189
|
+
attachments: displays.slice(),
|
|
190
|
+
path: Skeleton2DSlotAnimationPath.Attachment,
|
|
191
|
+
slotIndex,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
// A `colorFrame` list → a Color channel. DragonBones stores a ColorTransform whose multiply channels are
|
|
195
|
+
// 0–100 PERCENT, so they normalize by 100 rather than 255 to reach the track's 0..1 space. Additive offsets
|
|
196
|
+
// have no `Slot2D` representation and are ignored here (the setup-pose path already crumbs them).
|
|
197
|
+
function addDragonBonesSlotColorChannel(channels, frames, slotIndex, frameRate) {
|
|
198
|
+
const times = dragonBonesFrameTimes(frames, frameRate);
|
|
199
|
+
const values = [];
|
|
200
|
+
for (const frame of frames) {
|
|
201
|
+
const raw = frame.value ?? frame.color;
|
|
202
|
+
const color = raw !== null && typeof raw === 'object' ? raw : {};
|
|
203
|
+
values.push(colorPercent(color.rM), colorPercent(color.gM), colorPercent(color.bM), colorPercent(color.aM));
|
|
204
|
+
}
|
|
205
|
+
const track = createAnimationTrack({
|
|
206
|
+
components: 4,
|
|
207
|
+
interpolation: dragonBonesInterpolation(frames, undefined),
|
|
208
|
+
segmentEasings: buildDragonBonesSegmentEasings(frames),
|
|
209
|
+
times,
|
|
210
|
+
values,
|
|
211
|
+
});
|
|
212
|
+
channels.push(createAnimationChannel(track, { path: Skeleton2DSlotAnimationPath.Color, slotIndex }));
|
|
213
|
+
}
|
|
214
|
+
// One DragonBones multiply-colour channel (0–100 percent) → the track's 0..1 space, clamped.
|
|
215
|
+
function colorPercent(value) {
|
|
216
|
+
const percent = numberOr(value, 100) / 100;
|
|
217
|
+
return percent <= 0 ? 0 : percent >= 1 ? 1 : percent;
|
|
218
|
+
}
|
|
219
|
+
// Adds one DragonBones bone timeline's channels to `channels`. The three frame lists are independent — each
|
|
220
|
+
// carries its own durations, so each gets its own time axis — which is why they are built separately rather
|
|
221
|
+
// than zipped onto one shared key list.
|
|
222
|
+
function parseDragonBonesBoneTimeline(channels, timeline, boneIndex, frameRate, diagnostics) {
|
|
223
|
+
addDragonBonesVectorChannel(channels, timeline.translateFrame, boneIndex, Skeleton2DAnimationPath.Translation, frameRate, diagnostics);
|
|
224
|
+
addDragonBonesRotateChannels(channels, timeline.rotateFrame, boneIndex, frameRate, diagnostics);
|
|
225
|
+
addDragonBonesVectorChannel(channels, timeline.scaleFrame, boneIndex, Skeleton2DAnimationPath.Scale, frameRate, diagnostics);
|
|
226
|
+
skipCrumbDragonBonesGroup(diagnostics, timeline.frame, 'dragonbones.legacy-bone-frame-unsupported');
|
|
227
|
+
}
|
|
228
|
+
// Adds a two-component bone channel (`translateFrame` → Translation, `scaleFrame` → Scale) whose per-frame
|
|
229
|
+
// values are DragonBones' `x`/`y`. The omitted-value default is the path's IDENTITY delta — 0 for a
|
|
230
|
+
// translation offset, 1 for a scale multiplier — so an absent field composes to "unchanged from setup".
|
|
231
|
+
function addDragonBonesVectorChannel(channels, raw, boneIndex, path, frameRate, diagnostics) {
|
|
232
|
+
const frames = dragonBonesFrames(raw, diagnostics);
|
|
233
|
+
if (frames.length === 0)
|
|
234
|
+
return;
|
|
235
|
+
const fallback = path === Skeleton2DAnimationPath.Scale ? 1 : 0;
|
|
236
|
+
const values = [];
|
|
237
|
+
for (const frame of frames)
|
|
238
|
+
values.push(numberOr(frame.x, fallback), numberOr(frame.y, fallback));
|
|
239
|
+
addDragonBonesBoneChannel(channels, dragonBonesFrameTimes(frames, frameRate), values, 2, dragonBonesInterpolation(frames, diagnostics), boneIndex, path, buildDragonBonesSegmentEasings(frames));
|
|
240
|
+
}
|
|
241
|
+
// Adds the channels a DragonBones `rotateFrame` list drives. One frame list feeds TWO Flight paths, because
|
|
242
|
+
// DragonBones packs both angles of its Transform into it: `rotate` → Rotation, and `skew` → Shear as
|
|
243
|
+
// (shearX 0, shearY skew), the same split the setup-pose transform uses (parseDragonBonesBoneTransform).
|
|
244
|
+
// The Shear channel is emitted only when some frame actually skews, so the common no-skew rig does not pay
|
|
245
|
+
// for a channel of zeroes on every bone.
|
|
246
|
+
//
|
|
247
|
+
// `rotate` is UNWRAPPED across the sequence, replicating ObjectDataParser._parseBoneRotateFrame: each frame
|
|
248
|
+
// after the first is re-expressed as the previous frame's angle plus the shortest signed step to the authored
|
|
249
|
+
// angle, and a nonzero `clockwise` on the previous frame adds that many whole turns (consuming one turn per
|
|
250
|
+
// frame that already passes the previous angle). This is a correctness requirement, not a fidelity nicety —
|
|
251
|
+
// authored angles are wrapped, so 170° followed by −170° must tween the authored 20° step rather than the
|
|
252
|
+
// 340° long way round through zero. Angles stay in degrees throughout (Flight's authoring layer), so
|
|
253
|
+
// DragonBones' 2π turn is 360.
|
|
254
|
+
function addDragonBonesRotateChannels(channels, raw, boneIndex, frameRate, diagnostics) {
|
|
255
|
+
const frames = dragonBonesFrames(raw, diagnostics);
|
|
256
|
+
if (frames.length === 0)
|
|
257
|
+
return;
|
|
258
|
+
const times = dragonBonesFrameTimes(frames, frameRate);
|
|
259
|
+
const interpolation = dragonBonesInterpolation(frames, diagnostics);
|
|
260
|
+
const rotations = [];
|
|
261
|
+
const shears = [];
|
|
262
|
+
let skewed = false;
|
|
263
|
+
let previousRotation = 0;
|
|
264
|
+
let previousClockwise = 0;
|
|
265
|
+
for (let i = 0; i < frames.length; i++) {
|
|
266
|
+
const frame = frames[i];
|
|
267
|
+
let rotation = numberOr(frame.rotate, 0);
|
|
268
|
+
// `times[i] !== 0` is DragonBones' own `frameStart !== 0` guard: the frame at the sequence origin is
|
|
269
|
+
// taken as authored and anchors the unwrap; every later frame is unwrapped against the one before it.
|
|
270
|
+
if (times[i] !== 0) {
|
|
271
|
+
if (previousClockwise === 0) {
|
|
272
|
+
rotation = previousRotation + normalizeDegrees(rotation - previousRotation);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
if (previousClockwise > 0 ? rotation >= previousRotation : rotation <= previousRotation) {
|
|
276
|
+
previousClockwise = previousClockwise > 0 ? previousClockwise - 1 : previousClockwise + 1;
|
|
277
|
+
}
|
|
278
|
+
rotation += 360 * previousClockwise;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
previousClockwise = numberOr(frame.clockwise, 0) | 0;
|
|
282
|
+
previousRotation = rotation;
|
|
283
|
+
const skew = numberOr(frame.skew, 0);
|
|
284
|
+
if (skew !== 0)
|
|
285
|
+
skewed = true;
|
|
286
|
+
rotations.push(rotation);
|
|
287
|
+
shears.push(0, skew);
|
|
288
|
+
}
|
|
289
|
+
const easings = buildDragonBonesSegmentEasings(frames);
|
|
290
|
+
const rotationPath = Skeleton2DAnimationPath.Rotation;
|
|
291
|
+
addDragonBonesBoneChannel(channels, times, rotations, 1, interpolation, boneIndex, rotationPath, easings);
|
|
292
|
+
if (skewed) {
|
|
293
|
+
const shearPath = Skeleton2DAnimationPath.Shear;
|
|
294
|
+
addDragonBonesBoneChannel(channels, times, shears, 2, interpolation, boneIndex, shearPath, easings);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// The shared tail of every bone-channel builder: wraps the extracted keys in an AnimationTrack bound to
|
|
298
|
+
// (`boneIndex`, `path`). `times` is COPIED because one frame list can feed two channels (rotate → Rotation +
|
|
299
|
+
// Shear) and an AnimationTrack owns its buffers — sharing one array would silently alias the two tracks.
|
|
300
|
+
function addDragonBonesBoneChannel(channels, times, values, components, interpolation, boneIndex, path, segmentEasings = null) {
|
|
301
|
+
const track = createAnimationTrack({ components, interpolation, segmentEasings, times: times.slice(), values });
|
|
302
|
+
channels.push(createAnimationChannel(track, { boneIndex, path }));
|
|
303
|
+
}
|
|
304
|
+
// The keyframe time axis of one frame list. DragonBones authors each frame's `duration` in FRAMES (default 1
|
|
305
|
+
// — its parser's own fallback), so a key's time is the running sum of the durations BEFORE it divided by the
|
|
306
|
+
// armature's frame rate. A negative duration is clamped to 0, keeping the times ascending as AnimationTrack
|
|
307
|
+
// requires; a zero duration leaves two keys at one instant, which sampling already handles (the later key
|
|
308
|
+
// wins) so it needs no collapsing.
|
|
309
|
+
function dragonBonesFrameTimes(frames, frameRate) {
|
|
310
|
+
const times = [];
|
|
311
|
+
let elapsedFrames = 0;
|
|
312
|
+
for (const frame of frames) {
|
|
313
|
+
times.push(elapsedFrames / frameRate);
|
|
314
|
+
elapsedFrames += Math.max(0, numberOr(frame.duration, 1));
|
|
315
|
+
}
|
|
316
|
+
return times;
|
|
317
|
+
}
|
|
318
|
+
// Normalizes a raw frame list so the TIME AXIS survives malformed input: a non-object entry becomes an empty
|
|
319
|
+
// frame (all-default values, the default one-frame duration) rather than being dropped, because dropping it
|
|
320
|
+
// would swallow its duration and pull every later keyframe earlier — the same read-integrity discipline the
|
|
321
|
+
// display list and the bone array use.
|
|
322
|
+
function dragonBonesFrames(raw, diagnostics) {
|
|
323
|
+
if (!Array.isArray(raw))
|
|
324
|
+
return [];
|
|
325
|
+
const frames = [];
|
|
326
|
+
let recovered = 0;
|
|
327
|
+
for (const entry of raw) {
|
|
328
|
+
if (entry !== null && typeof entry === 'object') {
|
|
329
|
+
frames.push(entry);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
frames.push(EMPTY_DRAGONBONES_FRAME);
|
|
333
|
+
recovered++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (recovered > 0) {
|
|
337
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'dragonbones.malformed-frame-recovered', 'parseDragonBonesSkeleton', { frames: recovered });
|
|
338
|
+
}
|
|
339
|
+
return frames;
|
|
340
|
+
}
|
|
341
|
+
// One AnimationTrack carries a single interpolation, so a DragonBones frame list is Step only when EVERY
|
|
342
|
+
// tweening segment is a no-tween frame, and Linear otherwise. Only frames before the last one open a segment
|
|
343
|
+
// — DragonBones gives the final frame TweenType.None itself — so a trailing frame's easing never decides the
|
|
344
|
+
// track. A bezier `curve` IS honored (see buildDragonBonesSegmentEasings); the QUADRATIC `tweenEasing`
|
|
345
|
+
// variants are not, and still collapse to Linear with a Skip crumb — the corpus contains no non-zero
|
|
346
|
+
// tweenEasing at all, so implementing them would be format semantics written from memory rather than
|
|
347
|
+
// verified against a real rig.
|
|
348
|
+
function dragonBonesInterpolation(frames, diagnostics) {
|
|
349
|
+
let stepped = true;
|
|
350
|
+
let approximated = 0;
|
|
351
|
+
for (let i = 0; i + 1 < frames.length; i++) {
|
|
352
|
+
const frame = frames[i];
|
|
353
|
+
if (!isDragonBonesFrameStepped(frame))
|
|
354
|
+
stepped = false;
|
|
355
|
+
if (!('curve' in frame)) {
|
|
356
|
+
const easing = frame.tweenEasing;
|
|
357
|
+
if (typeof easing === 'number' && easing !== 0 && easing !== DRAGONBONES_NO_TWEEN)
|
|
358
|
+
approximated++;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (approximated > 0) {
|
|
362
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.tween-easing-unsupported', 'parseDragonBonesSkeleton', { frames: approximated });
|
|
363
|
+
}
|
|
364
|
+
return stepped ? AnimationInterpolationStep : AnimationInterpolationLinear;
|
|
365
|
+
}
|
|
366
|
+
// Converts DragonBones' per-frame bezier `curve` into one `EasingFunction` per INTERVAL. Returns `null` when
|
|
367
|
+
// no interval is curved, so a linear timeline allocates nothing.
|
|
368
|
+
//
|
|
369
|
+
// DragonBones stores FOUR control values ALREADY NORMALIZED to the unit square — unlike Spine, which writes
|
|
370
|
+
// absolute time/value units and four numbers PER COMPONENT. So there is no rebasing to do here and no
|
|
371
|
+
// dominant-component question: one curve covers the whole frame and maps straight onto the CSS-style cubic
|
|
372
|
+
// bezier `easeCubicBezier` expects. Verified against the MIT DragonBones corpus — every curve across all
|
|
373
|
+
// three rigs is exactly 4 values, all within [0,1].
|
|
374
|
+
//
|
|
375
|
+
// The x components are still clamped, for the same reason as the Spine path: the solver inverts x, which is
|
|
376
|
+
// only defined while x stays monotonic over [0,1]. y is left free so an overshoot curve keeps its shape.
|
|
377
|
+
function buildDragonBonesSegmentEasings(frames) {
|
|
378
|
+
const segments = frames.length - 1;
|
|
379
|
+
if (segments < 1)
|
|
380
|
+
return null;
|
|
381
|
+
const easings = [];
|
|
382
|
+
let curved = false;
|
|
383
|
+
for (let i = 0; i < segments; i++) {
|
|
384
|
+
const curve = frames[i].curve;
|
|
385
|
+
if (!Array.isArray(curve) || curve.length < 4) {
|
|
386
|
+
easings.push(null);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
curved = true;
|
|
390
|
+
easings.push(easeCubicBezier(clampDragonBonesUnit(numberOr(curve[0], 0)), numberOr(curve[1], 0), clampDragonBonesUnit(numberOr(curve[2], 1)), numberOr(curve[3], 1)));
|
|
391
|
+
}
|
|
392
|
+
return curved ? easings : null;
|
|
393
|
+
}
|
|
394
|
+
function clampDragonBonesUnit(value) {
|
|
395
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
396
|
+
}
|
|
397
|
+
// Whether a frame holds its value to the next key instead of tweening. DragonBones spells "no tween" as
|
|
398
|
+
// `tweenEasing: null` (what its exporter writes) or the sentinel 100; an ABSENT `tweenEasing` means linear,
|
|
399
|
+
// not stepped. A `curve` is tested first because a bezier always tweens, matching its parser's branch order.
|
|
400
|
+
function isDragonBonesFrameStepped(frame) {
|
|
401
|
+
if ('curve' in frame)
|
|
402
|
+
return false;
|
|
403
|
+
if (!('tweenEasing' in frame))
|
|
404
|
+
return false;
|
|
405
|
+
const easing = frame.tweenEasing;
|
|
406
|
+
return easing === null || easing === DRAGONBONES_NO_TWEEN;
|
|
407
|
+
}
|
|
408
|
+
// The frame rate the armature's frame-based timelines convert through: the armature's own `frameRate`, else
|
|
409
|
+
// the document's, else DragonBones' 24 default. A missing, zero, or non-finite rate would divide every
|
|
410
|
+
// keyframe time into Infinity or NaN, so it falls back rather than propagating a poisoned time axis.
|
|
411
|
+
function dragonBonesFrameRate(armature, doc) {
|
|
412
|
+
const armatureRate = numberOr(armature.frameRate, 0);
|
|
413
|
+
if (Number.isFinite(armatureRate) && armatureRate > 0)
|
|
414
|
+
return armatureRate;
|
|
415
|
+
const documentRate = numberOr(doc.frameRate, 0);
|
|
416
|
+
if (Number.isFinite(documentRate) && documentRate > 0)
|
|
417
|
+
return documentRate;
|
|
418
|
+
return DEFAULT_DRAGONBONES_FRAME_RATE;
|
|
419
|
+
}
|
|
420
|
+
// The shortest signed representation of an angle delta, in (−180, 180] — DragonBones' Transform
|
|
421
|
+
// .normalizeRadian expressed in the authoring layer's degrees.
|
|
422
|
+
function normalizeDegrees(degrees) {
|
|
423
|
+
const wrapped = (degrees + 180) % 360;
|
|
424
|
+
return wrapped + (wrapped > 0 ? -180 : 180);
|
|
425
|
+
}
|
|
426
|
+
// Maps a DragonBones slot ColorTransform to a packed RGBA int (Slot2D.color). DragonBones color is the
|
|
427
|
+
// multiply channels aM/rM/gM/bM (0–100 percent) plus additive offsets aO/rO/gO/bO. Only the multiply tint
|
|
428
|
+
// maps to a packed color; a nonzero offset cannot be represented and is Skip-crumbed. Absent color = opaque
|
|
429
|
+
// white (0xffffffff), matching the packed RR GG BB AA convention parseSpineColor uses.
|
|
430
|
+
function parseDragonBonesColor(raw, diagnostics) {
|
|
431
|
+
if (raw === null || typeof raw !== 'object')
|
|
432
|
+
return 0xffffffff;
|
|
433
|
+
const color = raw;
|
|
434
|
+
const r = colorChannel(color.rM);
|
|
435
|
+
const g = colorChannel(color.gM);
|
|
436
|
+
const b = colorChannel(color.bM);
|
|
437
|
+
const a = colorChannel(color.aM);
|
|
438
|
+
if (numberOr(color.rO, 0) !== 0 ||
|
|
439
|
+
numberOr(color.gO, 0) !== 0 ||
|
|
440
|
+
numberOr(color.bO, 0) !== 0 ||
|
|
441
|
+
numberOr(color.aO, 0) !== 0) {
|
|
442
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.color-offset-unsupported', 'parseDragonBonesSkeleton', { slots: 1 });
|
|
443
|
+
}
|
|
444
|
+
return ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
|
|
445
|
+
}
|
|
446
|
+
// The armature's skins. DragonBones names its base skin "default" (or leaves it empty) and any others are
|
|
447
|
+
// alternates; all of them become the rig's wardrobe. Returns BOTH the wardrobe and the default skin's
|
|
448
|
+
// per-slot display table, because the two are addressed differently: the wardrobe keys attachments by NAME
|
|
449
|
+
// (what `setSkeleton2DSkin` writes), while a slot's setup attachment is a `displayIndex` POSITION into its
|
|
450
|
+
// display list. The display list is therefore position-preserving — an unmodeled or malformed display holds
|
|
451
|
+
// its slot as `null` rather than being dropped, mirroring the DragonBones runtime's own addDisplay(slot,
|
|
452
|
+
// null) — so indices stay aligned even though the wardrobe skips those entries.
|
|
453
|
+
function parseDragonBonesSkins(raw, slotOrder, remapBoneIndex, diagnostics) {
|
|
454
|
+
const table = new Map();
|
|
455
|
+
const skins = [];
|
|
456
|
+
if (!Array.isArray(raw))
|
|
457
|
+
return { skins, table };
|
|
458
|
+
for (const rawSkin of raw) {
|
|
459
|
+
if (rawSkin === null || typeof rawSkin !== 'object')
|
|
460
|
+
continue;
|
|
461
|
+
const skin = rawSkin;
|
|
462
|
+
const skinName = typeof skin.name === 'string' && skin.name.length > 0 ? skin.name : DEFAULT_DRAGONBONES_SKIN_NAME;
|
|
463
|
+
if (!Array.isArray(skin.slot))
|
|
464
|
+
continue;
|
|
465
|
+
const attachments = [];
|
|
466
|
+
for (const rawSlot of skin.slot) {
|
|
467
|
+
if (rawSlot === null || typeof rawSlot !== 'object')
|
|
468
|
+
continue;
|
|
469
|
+
const slot = rawSlot;
|
|
470
|
+
if (typeof slot.name !== 'string')
|
|
471
|
+
continue;
|
|
472
|
+
const displays = parseDragonBonesDisplayList(slot.display, remapBoneIndex, diagnostics);
|
|
473
|
+
if (skinName === DEFAULT_DRAGONBONES_SKIN_NAME)
|
|
474
|
+
table.set(slot.name, displays);
|
|
475
|
+
const slotIndex = slotOrder.get(slot.name) ?? -1;
|
|
476
|
+
if (slotIndex < 0)
|
|
477
|
+
continue;
|
|
478
|
+
for (const display of displays) {
|
|
479
|
+
// An unnamed display cannot be addressed by a wardrobe change, so it stays positional-only.
|
|
480
|
+
const displayName = display?.name;
|
|
481
|
+
if (display !== null && typeof displayName === 'string') {
|
|
482
|
+
attachments.push({ attachment: display, name: displayName, slotIndex });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
skins.push({ attachments, name: skinName });
|
|
487
|
+
}
|
|
488
|
+
return { skins, table };
|
|
489
|
+
}
|
|
490
|
+
// Parses one DragonBones display into an Attachment2D, or `null` (holding its displayIndex slot) for a
|
|
491
|
+
// malformed entry or an unmodeled type. DragonBones omits `type` for an image display (the default).
|
|
492
|
+
function parseDragonBonesDisplay(raw, remapBoneIndex, diagnostics) {
|
|
493
|
+
if (raw === null || typeof raw !== 'object')
|
|
494
|
+
return null;
|
|
495
|
+
const display = raw;
|
|
496
|
+
const type = typeof display.type === 'string' ? display.type : 'image';
|
|
497
|
+
if (type === 'image')
|
|
498
|
+
return parseDragonBonesRegionDisplay(display);
|
|
499
|
+
if (type === 'mesh')
|
|
500
|
+
return parseDragonBonesMeshDisplay(display, remapBoneIndex, diagnostics);
|
|
501
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, `dragonbones.${type}-display-unsupported`, 'parseDragonBonesSkeleton', { displays: 1 });
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
// A DragonBones mesh display → MeshAttachment2D. UNWEIGHTED (rigid, single-slot-bone): its `vertices` are
|
|
505
|
+
// positions in the slot bone's local space, mapped directly like a Spine unweighted mesh. WEIGHTED (a
|
|
506
|
+
// `weights` stream with `bonePose`/`slotPose` bind matrices): converted to Skin2D per-bone offsets (see
|
|
507
|
+
// parseDragonBonesWeightedMesh). A `share`d mesh (geometry borrowed from another display) is not modeled and
|
|
508
|
+
// is Skip-crumbed, held at its displayIndex (returns null).
|
|
509
|
+
function parseDragonBonesMeshDisplay(display, remapBoneIndex, diagnostics) {
|
|
510
|
+
if ('share' in display) {
|
|
511
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.shared-mesh-unsupported', 'parseDragonBonesSkeleton', { displays: 1 });
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
if ('weights' in display) {
|
|
515
|
+
if ('bonePose' in display)
|
|
516
|
+
return parseDragonBonesWeightedMesh(display, remapBoneIndex, diagnostics);
|
|
517
|
+
// A `weights` stream without `bonePose` is DragonBones' older bind-matrix-less weighting; not modeled.
|
|
518
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.legacy-weighted-mesh-unsupported', 'parseDragonBonesSkeleton', { displays: 1 });
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const uvs = toFloat32Array(display.uvs);
|
|
522
|
+
return {
|
|
523
|
+
kind: MeshAttachment2DKind,
|
|
524
|
+
name: typeof display.name === 'string' ? display.name : null,
|
|
525
|
+
skin: null,
|
|
526
|
+
triangles: toUint16Array(display.triangles),
|
|
527
|
+
uvs,
|
|
528
|
+
vertexCount: uvs.length >> 1,
|
|
529
|
+
vertices: toFloat32Array(display.vertices),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
// Converts a DragonBones WEIGHTED mesh to a MeshAttachment2D whose Skin2D carries per-bone LOCAL offsets, by
|
|
533
|
+
// replicating the DragonBones runtime's geometry bake (ObjectDataParser._parseGeometry): each raw vertex is
|
|
534
|
+
// pushed through `slotPose`, then through the INVERSE of each influencing bone's `bonePose` bind matrix, and
|
|
535
|
+
// the result is that vertex expressed in the bone's bind-local frame — exactly what Skin2D + Flight's deform
|
|
536
|
+
// consume (Σ w·(boneWorld·localOffset)). All matrices share Flight's `x'=a·x+c·y` convention (as does
|
|
537
|
+
// DragonBones), so no transposition; the offsets are computed wholly within DragonBones' own space, so the
|
|
538
|
+
// global y-down↔y-up question (charter #4) is orthogonal and unaffected here.
|
|
539
|
+
//
|
|
540
|
+
// The `weights` stream references bones by ARMATURE FILE-ORDER index; `remapBoneIndex` re-points each
|
|
541
|
+
// influence at the topo-sorted OUTPUT bone (the axis-12 remap). Every read is bounded against the actual
|
|
542
|
+
// stream length (axis 13); a truncated stream, an unresolvable bone, or a degenerate bind matrix drops that
|
|
543
|
+
// influence/vertex best-effort and emits a Recover crumb.
|
|
544
|
+
function parseDragonBonesWeightedMesh(display, remapBoneIndex, diagnostics) {
|
|
545
|
+
const uvs = toFloat32Array(display.uvs);
|
|
546
|
+
const vertexCount = uvs.length >> 1;
|
|
547
|
+
const verts = numberArray(display.vertices);
|
|
548
|
+
const weights = numberArray(display.weights);
|
|
549
|
+
const bonePose = numberArray(display.bonePose);
|
|
550
|
+
const slotPose = numberArray(display.slotPose);
|
|
551
|
+
const spA = numAt(slotPose, 0, 1);
|
|
552
|
+
const spB = numAt(slotPose, 1, 0);
|
|
553
|
+
const spC = numAt(slotPose, 2, 0);
|
|
554
|
+
const spD = numAt(slotPose, 3, 1);
|
|
555
|
+
const spTx = numAt(slotPose, 4, 0);
|
|
556
|
+
const spTy = numAt(slotPose, 5, 0);
|
|
557
|
+
const usedBoneCount = Math.floor(bonePose.length / 7);
|
|
558
|
+
const influenceCounts = new Uint16Array(vertexCount);
|
|
559
|
+
const influences = [];
|
|
560
|
+
let recovered = false;
|
|
561
|
+
let iW = 0;
|
|
562
|
+
for (let v = 0; v < vertexCount; v++) {
|
|
563
|
+
if (iW >= weights.length) {
|
|
564
|
+
recovered = true;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
const declaredCount = weights[iW++] | 0;
|
|
568
|
+
const vx = numAt(verts, v * 2, 0);
|
|
569
|
+
const vy = numAt(verts, v * 2 + 1, 0);
|
|
570
|
+
const sx = spA * vx + spC * vy + spTx;
|
|
571
|
+
const sy = spB * vx + spD * vy + spTy;
|
|
572
|
+
let realCount = 0;
|
|
573
|
+
for (let j = 0; j < declaredCount; j++) {
|
|
574
|
+
if (iW + 1 >= weights.length) {
|
|
575
|
+
recovered = true;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
const rawBoneIndex = weights[iW++] | 0;
|
|
579
|
+
const weight = weights[iW++];
|
|
580
|
+
// The influence pair is consumed above, so every early-out below keeps the flat stream aligned. Drop
|
|
581
|
+
// (recover) an influence that: targets no output bone (remap −1 — never emit −1, the deformer would
|
|
582
|
+
// index the world buffer from a negative offset and produce NaNs); references a bone this mesh's bind
|
|
583
|
+
// pose omits (ordinal −1); has a degenerate bind matrix (det 0); or would overflow the Uint16 count
|
|
584
|
+
// (an influence past the representable maximum, which would wrap influenceCounts and break the
|
|
585
|
+
// deformer's `influences.length === 4 × Σ influenceCounts` invariant).
|
|
586
|
+
const outputBone = remapBoneIndex(rawBoneIndex);
|
|
587
|
+
const ordinal = findBonePoseOrdinal(bonePose, usedBoneCount, rawBoneIndex);
|
|
588
|
+
if (outputBone < 0 || ordinal < 0 || realCount >= MAX_INFLUENCES_PER_VERTEX) {
|
|
589
|
+
recovered = true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const o = ordinal * 7;
|
|
593
|
+
const ba = bonePose[o + 1];
|
|
594
|
+
const bb = bonePose[o + 2];
|
|
595
|
+
const bc = bonePose[o + 3];
|
|
596
|
+
const bd = bonePose[o + 4];
|
|
597
|
+
const btx = bonePose[o + 5];
|
|
598
|
+
const bty = bonePose[o + 6];
|
|
599
|
+
const det = ba * bd - bb * bc;
|
|
600
|
+
if (det === 0) {
|
|
601
|
+
recovered = true;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const inv = 1 / det;
|
|
605
|
+
// Inverse of the bind matrix, then apply it to the slotPose-transformed vertex → bind-local offset.
|
|
606
|
+
const ia = bd * inv;
|
|
607
|
+
const ib = -bb * inv;
|
|
608
|
+
const ic = -bc * inv;
|
|
609
|
+
const id = ba * inv;
|
|
610
|
+
const itx = (bc * bty - bd * btx) * inv;
|
|
611
|
+
const ity = (bb * btx - ba * bty) * inv;
|
|
612
|
+
influences.push(outputBone, ia * sx + ic * sy + itx, ib * sx + id * sy + ity, weight);
|
|
613
|
+
realCount++;
|
|
614
|
+
}
|
|
615
|
+
influenceCounts[v] = realCount;
|
|
616
|
+
}
|
|
617
|
+
if (recovered) {
|
|
618
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'dragonbones.weighted-mesh-recovered', 'parseDragonBonesSkeleton', { meshes: 1 });
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
kind: MeshAttachment2DKind,
|
|
622
|
+
name: typeof display.name === 'string' ? display.name : null,
|
|
623
|
+
skin: { influenceCounts, influences: Float32Array.from(influences) },
|
|
624
|
+
triangles: toUint16Array(display.triangles),
|
|
625
|
+
uvs,
|
|
626
|
+
vertexCount,
|
|
627
|
+
vertices: null,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
// Maps a slot's `display` array to Flight attachments, POSITION-PRESERVING: result index i is displayIndex
|
|
631
|
+
// i. Image → RegionAttachment2D; every other display type is unmodeled in this increment and held as `null`
|
|
632
|
+
// + a Skip crumb (via parseDragonBonesDisplay), so displayIndex stays aligned.
|
|
633
|
+
function parseDragonBonesDisplayList(raw, remapBoneIndex, diagnostics) {
|
|
634
|
+
const displays = [];
|
|
635
|
+
if (!Array.isArray(raw))
|
|
636
|
+
return displays;
|
|
637
|
+
for (const rawDisplay of raw)
|
|
638
|
+
displays.push(parseDragonBonesDisplay(rawDisplay, remapBoneIndex, diagnostics));
|
|
639
|
+
return displays;
|
|
640
|
+
}
|
|
641
|
+
// A DragonBones image display → RegionAttachment2D. Its `transform` places the region in the slot's local
|
|
642
|
+
// space; width/height come from the texture atlas (the `.atlas` sidecar, spritesheet-formats' domain) and
|
|
643
|
+
// are left 0 here to be resolved at atlas-binding time, mirroring how a display references its region by name.
|
|
644
|
+
function parseDragonBonesRegionDisplay(display) {
|
|
645
|
+
const transform = parseDragonBonesBoneTransform(display.transform);
|
|
646
|
+
return {
|
|
647
|
+
height: 0,
|
|
648
|
+
kind: RegionAttachment2DKind,
|
|
649
|
+
name: typeof display.name === 'string' ? display.name : null,
|
|
650
|
+
rotation: transform.rotation,
|
|
651
|
+
scaleX: transform.scaleX,
|
|
652
|
+
scaleY: transform.scaleY,
|
|
653
|
+
width: 0,
|
|
654
|
+
x: transform.x,
|
|
655
|
+
y: transform.y,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
// DragonBones slots bind a bone to their shown display; `slot` array order is the draw order. `boneIndex`
|
|
659
|
+
// resolves the slot's `parent` (a bone name) to the output bone index; the shown attachment is the display
|
|
660
|
+
// at `displayIndex` (default 0; negative = none) in the default skin's display list for this slot; `color`
|
|
661
|
+
// is the slot's ColorTransform tint.
|
|
662
|
+
function parseDragonBonesSlots(raw, boneIndexByName, skin, diagnostics) {
|
|
663
|
+
const slots = [];
|
|
664
|
+
if (!Array.isArray(raw))
|
|
665
|
+
return slots;
|
|
666
|
+
for (const entry of raw) {
|
|
667
|
+
if (entry === null || typeof entry !== 'object')
|
|
668
|
+
continue;
|
|
669
|
+
const slot = entry;
|
|
670
|
+
const name = typeof slot.name === 'string' ? slot.name : null;
|
|
671
|
+
const boneIndex = typeof slot.parent === 'string' ? (boneIndexByName.get(slot.parent) ?? -1) : -1;
|
|
672
|
+
const displayIndex = numberOr(slot.displayIndex, 0) | 0;
|
|
673
|
+
let attachment = null;
|
|
674
|
+
if (name !== null && displayIndex >= 0) {
|
|
675
|
+
const displays = skin.get(name);
|
|
676
|
+
if (displays !== undefined && displayIndex < displays.length)
|
|
677
|
+
attachment = displays[displayIndex];
|
|
678
|
+
}
|
|
679
|
+
slots.push({ attachment, boneIndex, color: parseDragonBonesColor(slot.color, diagnostics), name });
|
|
680
|
+
}
|
|
681
|
+
return slots;
|
|
682
|
+
}
|
|
683
|
+
// The draw-order position of each named slot, needed before the skins are read so a skin entry can record
|
|
684
|
+
// the slot INDEX it dresses rather than a name the runtime would have to resolve on every wardrobe change.
|
|
685
|
+
function buildDragonBonesSlotOrder(raw) {
|
|
686
|
+
const order = new Map();
|
|
687
|
+
if (!Array.isArray(raw))
|
|
688
|
+
return order;
|
|
689
|
+
let index = 0;
|
|
690
|
+
for (const entry of raw) {
|
|
691
|
+
if (entry === null || typeof entry !== 'object')
|
|
692
|
+
continue;
|
|
693
|
+
const name = entry.name;
|
|
694
|
+
if (typeof name === 'string')
|
|
695
|
+
order.set(name, index);
|
|
696
|
+
index++;
|
|
697
|
+
}
|
|
698
|
+
return order;
|
|
699
|
+
}
|
|
700
|
+
function boolOr(value, fallback) {
|
|
701
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
702
|
+
}
|
|
703
|
+
// One DragonBones multiply-color channel (0–100 percent) → an 0–255 byte, clamped.
|
|
704
|
+
function colorChannel(value) {
|
|
705
|
+
return Math.max(0, Math.min(255, Math.round((numberOr(value, 100) / 100) * 255)));
|
|
706
|
+
}
|
|
707
|
+
// The ordinal (0-based position in `bonePose`, 7 numbers each: [rawBoneIndex, a, b, c, d, tx, ty]) of the
|
|
708
|
+
// used bone whose armature file-order index is `rawBoneIndex`, or -1 if this mesh's bind pose omits it.
|
|
709
|
+
function findBonePoseOrdinal(bonePose, usedBoneCount, rawBoneIndex) {
|
|
710
|
+
for (let i = 0; i < usedBoneCount; i++) {
|
|
711
|
+
if (bonePose[i * 7] === rawBoneIndex)
|
|
712
|
+
return i;
|
|
713
|
+
}
|
|
714
|
+
return -1;
|
|
715
|
+
}
|
|
716
|
+
function numAt(values, index, fallback) {
|
|
717
|
+
return index >= 0 && index < values.length && typeof values[index] === 'number' ? values[index] : fallback;
|
|
718
|
+
}
|
|
719
|
+
function numberArray(value) {
|
|
720
|
+
return Array.isArray(value) ? value : [];
|
|
721
|
+
}
|
|
722
|
+
function toFloat32Array(value) {
|
|
723
|
+
return Array.isArray(value) ? Float32Array.from(value) : new Float32Array();
|
|
724
|
+
}
|
|
725
|
+
function toUint16Array(value) {
|
|
726
|
+
return Array.isArray(value) ? Uint16Array.from(value) : new Uint16Array();
|
|
727
|
+
}
|
|
728
|
+
// Maps a DragonBones bone's nested `transform` block to Flight's local TRS + shear fields. DragonBones stores
|
|
729
|
+
// two skew angles in degrees: `skX`/`skY` (older) or `rotate`/`skew` (5.x) — its `Transform` reads
|
|
730
|
+
// rotation = rotate (else skY) and skew = skew (else skX − skY). Its toMatrix
|
|
731
|
+
// (a=sX·cos(rotation), b=sX·sin(rotation), c=−sY·sin(rotation+skew), d=sY·cos(rotation+skew)) equals Flight's
|
|
732
|
+
// Bone2D local matrix under `Bone2D.rotation = rotation`, `shearX = 0`, `shearY = skew` (see charter #4).
|
|
733
|
+
function parseDragonBonesBoneTransform(raw) {
|
|
734
|
+
const t = raw !== null && typeof raw === 'object' ? raw : {};
|
|
735
|
+
let rotation;
|
|
736
|
+
let shearY;
|
|
737
|
+
if ('rotate' in t || 'skew' in t) {
|
|
738
|
+
rotation = numberOr(t.rotate, 0);
|
|
739
|
+
shearY = numberOr(t.skew, 0);
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
rotation = numberOr(t.skY, 0);
|
|
743
|
+
shearY = numberOr(t.skX, 0) - rotation;
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
rotation,
|
|
747
|
+
scaleX: numberOr(t.scX, 1),
|
|
748
|
+
scaleY: numberOr(t.scY, 1),
|
|
749
|
+
shearY,
|
|
750
|
+
x: numberOr(t.x, 0),
|
|
751
|
+
y: numberOr(t.y, 0),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
// DragonBones lists bones in no guaranteed parent order and references parents by name, so bones are emitted
|
|
755
|
+
// in topological order (each parent before its children) with `parentIndex` resolved against the already-
|
|
756
|
+
// emitted set — the invariant `computeSkeleton2DWorldTransforms` and `validateSkeleton2D` require. Bones whose
|
|
757
|
+
// parent never resolves (a dangling reference or a cycle) are emitted last as roots and Skip-crumbed.
|
|
758
|
+
function parseDragonBonesBones(raw, diagnostics) {
|
|
759
|
+
const rawArray = Array.isArray(raw) ? raw : [];
|
|
760
|
+
// rawIndexToOutput[fileOrderIndex] = the bone's final output index (-1 for a dropped/malformed raw entry).
|
|
761
|
+
// Carrying each raw entry's IDENTITY through to its output position — rather than reconstructing the map
|
|
762
|
+
// by name — is what keeps weighted-mesh bone references correct when two bones share a name (a name-based
|
|
763
|
+
// remap is last-write-wins, so both would collide onto one output bone). Parent links still resolve by
|
|
764
|
+
// name, which is DragonBones' own reference model.
|
|
765
|
+
const rawIndexToOutput = new Array(rawArray.length).fill(-1);
|
|
766
|
+
const pending = [];
|
|
767
|
+
for (let ri = 0; ri < rawArray.length; ri++) {
|
|
768
|
+
const entry = rawArray[ri];
|
|
769
|
+
if (entry === null || typeof entry !== 'object')
|
|
770
|
+
continue;
|
|
771
|
+
const b = entry;
|
|
772
|
+
const transform = parseDragonBonesBoneTransform(b.transform);
|
|
773
|
+
pending.push({
|
|
774
|
+
bone: {
|
|
775
|
+
length: numberOr(b.length, 0),
|
|
776
|
+
name: typeof b.name === 'string' ? b.name : null,
|
|
777
|
+
parentIndex: -1,
|
|
778
|
+
rotation: transform.rotation,
|
|
779
|
+
scaleX: transform.scaleX,
|
|
780
|
+
scaleY: transform.scaleY,
|
|
781
|
+
shearX: 0,
|
|
782
|
+
shearY: transform.shearY,
|
|
783
|
+
transformMode: dragonBonesTransformMode(b),
|
|
784
|
+
x: transform.x,
|
|
785
|
+
y: transform.y,
|
|
786
|
+
},
|
|
787
|
+
parentName: typeof b.parent === 'string' ? b.parent : null,
|
|
788
|
+
rawIndex: ri,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const bones = [];
|
|
792
|
+
const indexByName = new Map();
|
|
793
|
+
let advanced = true;
|
|
794
|
+
while (pending.length > 0 && advanced) {
|
|
795
|
+
advanced = false;
|
|
796
|
+
for (let i = 0; i < pending.length;) {
|
|
797
|
+
const entry = pending[i];
|
|
798
|
+
if (entry.parentName === null || indexByName.has(entry.parentName)) {
|
|
799
|
+
entry.bone.parentIndex = entry.parentName === null ? -1 : indexByName.get(entry.parentName);
|
|
800
|
+
if (typeof entry.bone.name === 'string')
|
|
801
|
+
indexByName.set(entry.bone.name, bones.length);
|
|
802
|
+
rawIndexToOutput[entry.rawIndex] = bones.length;
|
|
803
|
+
bones.push(entry.bone);
|
|
804
|
+
pending.splice(i, 1);
|
|
805
|
+
advanced = true;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
i++;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (pending.length > 0) {
|
|
813
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'dragonbones.unresolved-bone-parent', 'parseDragonBonesSkeleton', { count: pending.length });
|
|
814
|
+
for (const entry of pending) {
|
|
815
|
+
entry.bone.parentIndex = -1;
|
|
816
|
+
if (typeof entry.bone.name === 'string')
|
|
817
|
+
indexByName.set(entry.bone.name, bones.length);
|
|
818
|
+
rawIndexToOutput[entry.rawIndex] = bones.length;
|
|
819
|
+
bones.push(entry.bone);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return { bones, rawIndexToOutput };
|
|
823
|
+
}
|
|
824
|
+
// Maps DragonBones' four independent inheritance booleans straight to the vendor-neutral TransformInherit2D
|
|
825
|
+
// (all default true = Normal). Every combination is now expressible — the two rotation/scale/reflection
|
|
826
|
+
// combos that had no value in the old five-mode enum, and `inheritTranslation:false` — so nothing is
|
|
827
|
+
// Skip-crumbed here; the factoring of the inherit axes removed the per-vendor gap.
|
|
828
|
+
function dragonBonesTransformMode(bone) {
|
|
829
|
+
return {
|
|
830
|
+
reflection: boolOr(bone.inheritReflection, true),
|
|
831
|
+
rotation: boolOr(bone.inheritRotation, true),
|
|
832
|
+
scale: boolOr(bone.inheritScale, true),
|
|
833
|
+
translation: boolOr(bone.inheritTranslation, true),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function numberOr(value, fallback) {
|
|
837
|
+
return typeof value === 'number' ? value : fallback;
|
|
838
|
+
}
|
|
839
|
+
// Reports one aggregated Skip crumb for an unmodeled DragonBones section (slot / skin / animation), with its
|
|
840
|
+
// element count. An absent or empty section is silent.
|
|
841
|
+
function skipCrumbDragonBonesGroup(diagnostics, raw, kind) {
|
|
842
|
+
let count = 0;
|
|
843
|
+
if (Array.isArray(raw))
|
|
844
|
+
count = raw.length;
|
|
845
|
+
else if (raw !== null && typeof raw === 'object')
|
|
846
|
+
count = Object.keys(raw).length;
|
|
847
|
+
if (count > 0)
|
|
848
|
+
reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, kind, 'parseDragonBonesSkeleton', { count });
|
|
849
|
+
}
|
|
850
|
+
// DragonBones' own fallbacks: an unnamed animation is "default", and a document that declares no frame rate
|
|
851
|
+
// runs at 24fps.
|
|
852
|
+
const DEFAULT_DRAGONBONES_ANIMATION_NAME = 'default';
|
|
853
|
+
const DEFAULT_DRAGONBONES_FRAME_RATE = 24;
|
|
854
|
+
// DragonBones' name for the base skin; an unnamed skin is that one.
|
|
855
|
+
const DEFAULT_DRAGONBONES_SKIN_NAME = 'default';
|
|
856
|
+
// DragonBones 5.6 marks a blend-tree animation with this `type`; a keyframe animation carries no `type`.
|
|
857
|
+
const DRAGONBONES_BLEND_TREE_TYPE = 'tree';
|
|
858
|
+
// The `tweenEasing` sentinel DragonBones uses for "hold this value to the next key" alongside a literal null.
|
|
859
|
+
const DRAGONBONES_NO_TWEEN = 100;
|
|
860
|
+
// Stands in for a malformed keyframe so the frame list keeps its length and its time axis. Read-only: every
|
|
861
|
+
// lookup through it falls back to the field's default.
|
|
862
|
+
const EMPTY_DRAGONBONES_FRAME = {};
|
|
863
|
+
// Skin2D stores per-vertex influence counts in a Uint16Array, so a vertex cannot carry more influences than
|
|
864
|
+
// this without wrapping the count (and breaking `influences.length === 4 × Σ influenceCounts`). No real rig
|
|
865
|
+
// approaches it; the cap only guards adversarial input.
|
|
866
|
+
const MAX_INFLUENCES_PER_VERTEX = 0xffff;
|
|
867
|
+
//# sourceMappingURL=dragonBonesParse.js.map
|