@driftengine/assets 3.61.0

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.
Files changed (100) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +9 -0
  4. package/dist/assetPath.d.ts +51 -0
  5. package/dist/assetPath.js +59 -0
  6. package/dist/coarseLevel.d.ts +94 -0
  7. package/dist/coarseLevel.js +606 -0
  8. package/dist/dds.d.ts +41 -0
  9. package/dist/dds.js +425 -0
  10. package/dist/drawKey.d.ts +64 -0
  11. package/dist/drawKey.js +42 -0
  12. package/dist/drftLoader.d.ts +420 -0
  13. package/dist/drftLoader.js +996 -0
  14. package/dist/drftTextures.d.ts +87 -0
  15. package/dist/drftTextures.js +138 -0
  16. package/dist/fbx.d.ts +86 -0
  17. package/dist/fbx.js +792 -0
  18. package/dist/fbxHarness.d.ts +68 -0
  19. package/dist/fbxHarness.js +185 -0
  20. package/dist/fbxInflate.d.ts +60 -0
  21. package/dist/fbxInflate.js +204 -0
  22. package/dist/fbxMaterial.d.ts +87 -0
  23. package/dist/fbxMaterial.js +345 -0
  24. package/dist/fbxRig.d.ts +130 -0
  25. package/dist/fbxRig.js +601 -0
  26. package/dist/gltf.d.ts +242 -0
  27. package/dist/gltf.js +904 -0
  28. package/dist/gltfAccessor.d.ts +2 -0
  29. package/dist/gltfAccessor.js +106 -0
  30. package/dist/gltfSkin.d.ts +32 -0
  31. package/dist/gltfSkin.js +144 -0
  32. package/dist/iesPhotometry.d.ts +44 -0
  33. package/dist/iesPhotometry.js +94 -0
  34. package/dist/imageInfo.d.ts +31 -0
  35. package/dist/imageInfo.js +119 -0
  36. package/dist/index.d.ts +58 -0
  37. package/dist/index.js +132 -0
  38. package/dist/kn5.d.ts +158 -0
  39. package/dist/kn5.js +557 -0
  40. package/dist/loadProgress.d.ts +118 -0
  41. package/dist/loadProgress.js +1 -0
  42. package/dist/localise.d.ts +35 -0
  43. package/dist/localise.js +253 -0
  44. package/dist/obj.d.ts +54 -0
  45. package/dist/obj.js +335 -0
  46. package/dist/orient.d.ts +104 -0
  47. package/dist/orient.js +241 -0
  48. package/dist/radianceHdr.d.ts +36 -0
  49. package/dist/radianceHdr.js +172 -0
  50. package/dist/readModel.d.ts +153 -0
  51. package/dist/readModel.js +386 -0
  52. package/dist/recognise.d.ts +38 -0
  53. package/dist/recognise.js +97 -0
  54. package/dist/stl.d.ts +22 -0
  55. package/dist/stl.js +131 -0
  56. package/dist/tangentOrder.d.ts +24 -0
  57. package/dist/tangentOrder.js +31 -0
  58. package/dist/threemf.d.ts +46 -0
  59. package/dist/threemf.js +254 -0
  60. package/dist/uploadBudget.d.ts +82 -0
  61. package/dist/uploadBudget.js +86 -0
  62. package/dist/usd.d.ts +49 -0
  63. package/dist/usd.js +542 -0
  64. package/dist/weld.d.ts +44 -0
  65. package/dist/weld.js +237 -0
  66. package/dist/zip.d.ts +35 -0
  67. package/dist/zip.js +110 -0
  68. package/package.json +61 -0
  69. package/src/assetPath.ts +79 -0
  70. package/src/coarseLevel.ts +720 -0
  71. package/src/dds.ts +507 -0
  72. package/src/drawKey.ts +91 -0
  73. package/src/drftLoader.ts +1197 -0
  74. package/src/drftTextures.ts +153 -0
  75. package/src/fbx.ts +934 -0
  76. package/src/fbxHarness.ts +215 -0
  77. package/src/fbxInflate.ts +258 -0
  78. package/src/fbxMaterial.ts +410 -0
  79. package/src/fbxRig.ts +652 -0
  80. package/src/gltf.ts +1161 -0
  81. package/src/gltfAccessor.ts +127 -0
  82. package/src/gltfSkin.ts +210 -0
  83. package/src/iesPhotometry.ts +136 -0
  84. package/src/imageInfo.ts +130 -0
  85. package/src/index.ts +162 -0
  86. package/src/kn5.ts +705 -0
  87. package/src/loadProgress.ts +131 -0
  88. package/src/localise.ts +275 -0
  89. package/src/obj.ts +386 -0
  90. package/src/orient.ts +262 -0
  91. package/src/radianceHdr.ts +195 -0
  92. package/src/readModel.ts +515 -0
  93. package/src/recognise.ts +124 -0
  94. package/src/stl.ts +147 -0
  95. package/src/tangentOrder.ts +32 -0
  96. package/src/threemf.ts +303 -0
  97. package/src/uploadBudget.ts +90 -0
  98. package/src/usd.ts +642 -0
  99. package/src/weld.ts +264 -0
  100. package/src/zip.ts +130 -0
package/src/fbxRig.ts ADDED
@@ -0,0 +1,652 @@
1
+ /**
2
+ * The rig inside an FBX: a skeleton, what it is bound to, and the takes that move it.
3
+ *
4
+ * **Split from `fbx.ts` on the terms `fbxMaterial.ts` already set.** That file reads geometry and
5
+ * knows nothing about deformers; this one reads deformers and knows nothing about polygons. They
6
+ * meet over two small things — a control point's influences, and which model each joint is — and
7
+ * keeping them apart is what stops the mesh loop growing a second subject.
8
+ *
9
+ * **What FBX calls a skin is three record kinds and four connections.** A `Deformer` of subtype
10
+ * `Skin` hangs off a `Geometry`; a `Deformer` of subtype `Cluster` hangs off that skin, one per
11
+ * bone; and a `Model` of subtype `LimbNode` hangs off each cluster. The weights live on the
12
+ * cluster, keyed by control point, which is why they are read here and applied where the corners
13
+ * are walked.
14
+ *
15
+ * **The bind pose is two matrices and not one.** A cluster carries `TransformLink`, where the bone
16
+ * was when the mesh was bound, and `Transform`, where the *mesh* was. The engine's `inverseBind`
17
+ * takes a vertex from model space into the joint's bind space, so it is the inverse of the first
18
+ * times the second. Taking `TransformLink` alone is right exactly while the mesh sits at the
19
+ * origin with no rotation, which is what a hand-made test file does and what a bought character
20
+ * does not — the failure is a figure that is correctly posed and standing in the wrong place.
21
+ *
22
+ * **This is tier 2 and says so.** What it does not read: `PostRotation`, rotation orders other than
23
+ * the default, blend-shape channels driven by curves, and the `Take` records of pre-7000 files.
24
+ * Each is named where it is refused rather than silently approximated.
25
+ */
26
+
27
+ import type { AnimationClip, Joint, JointTrack, TrackPath } from '@driftengine/drft';
28
+ import type { FbxNode } from './fbx.ts';
29
+
30
+ /** One second, in the integer unit `KeyTime` counts. FBX's own constant. */
31
+ const KTIME_PER_SECOND = 46186158000;
32
+
33
+ /** The most influences a vertex may carry, which is what the palette shader reads. */
34
+ const MAX_INFLUENCES = 4;
35
+
36
+ const IDENTITY: readonly number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
37
+
38
+ function findChild(node: FbxNode, name: string): FbxNode | undefined {
39
+ return node.children.find((child) => child.name === name);
40
+ }
41
+
42
+ function numbers(node: FbxNode | undefined): ArrayLike<number> | null {
43
+ const value = node?.properties[0];
44
+ return value !== undefined && typeof value === 'object' && value !== null && 'length' in value
45
+ ? (value as ArrayLike<number>)
46
+ : null;
47
+ }
48
+
49
+ /**
50
+ * An object's own name, with the class the container tags it with taken off.
51
+ *
52
+ * **The two forms are not the same string, and only one of them looks like a separator.** ASCII FBX
53
+ * writes `Model::spine_01`, which is what a reader tends to be written against because it is what
54
+ * the specification's examples show. Binary FBX — which is the only form this reader accepts —
55
+ * writes `spine_01\0\x01Model`: the name first, then a null and a `\x01`, then the class. Knowing
56
+ * only the ASCII form left every joint of a bought character named
57
+ * `rp_nathan_animated_003_walking_spine_01\0\x01Model` and its take named `Take 001\0\x01AnimStack`.
58
+ *
59
+ * Nothing fails on a name, which is why it survived: `Joint.name` is what **retargeting matches
60
+ * on**, so a rig whose every joint carries a class suffix matches nothing, one subsystem away from
61
+ * the reader that wrote it.
62
+ */
63
+ export function bareName(raw: unknown, fallback: string): string {
64
+ if (typeof raw !== 'string') return fallback;
65
+ const binary = raw.indexOf('\u0000\u0001');
66
+ const name =
67
+ binary >= 0
68
+ ? raw.slice(0, binary)
69
+ : raw.includes('::')
70
+ ? raw.slice(raw.indexOf('::') + 2)
71
+ : raw;
72
+ return name.length > 0 ? name : fallback;
73
+ }
74
+
75
+ function multiply(a: readonly number[], b: readonly number[]): number[] {
76
+ const out = new Array<number>(16);
77
+ for (let row = 0; row < 4; row++) {
78
+ for (let col = 0; col < 4; col++) {
79
+ let sum = 0;
80
+ for (let k = 0; k < 4; k++) sum += (a[k * 4 + row] as number) * (b[col * 4 + k] as number);
81
+ out[col * 4 + row] = sum;
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * A full 4x4 inverse, because a bind matrix is not guaranteed to be a rigid motion.
89
+ *
90
+ * The cheap inverse — transpose the basis, negate the translation through it — is correct only
91
+ * without scale, and character rigs carry scale far more often than a car does: a mirrored limb is
92
+ * a negative scale on one axis, and inverting that as if it were a rotation turns the limb inside
93
+ * out. Singular matrices come back as identity rather than as `NaN`, because a `NaN` in a bind
94
+ * matrix takes every vertex the joint touches with it.
95
+ */
96
+ function invert(m: readonly number[]): number[] {
97
+ const a = (i: number): number => m[i] as number;
98
+ const s0 = a(0) * a(5) - a(4) * a(1);
99
+ const s1 = a(0) * a(6) - a(4) * a(2);
100
+ const s2 = a(0) * a(7) - a(4) * a(3);
101
+ const s3 = a(1) * a(6) - a(5) * a(2);
102
+ const s4 = a(1) * a(7) - a(5) * a(3);
103
+ const s5 = a(2) * a(7) - a(6) * a(3);
104
+ const c5 = a(10) * a(15) - a(14) * a(11);
105
+ const c4 = a(9) * a(15) - a(13) * a(11);
106
+ const c3 = a(9) * a(14) - a(13) * a(10);
107
+ const c2 = a(8) * a(15) - a(12) * a(11);
108
+ const c1 = a(8) * a(14) - a(12) * a(10);
109
+ const c0 = a(8) * a(13) - a(12) * a(9);
110
+ const determinant = s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0;
111
+ if (!Number.isFinite(determinant) || Math.abs(determinant) < 1e-20) return [...IDENTITY];
112
+ const d = 1 / determinant;
113
+ return [
114
+ (a(5) * c5 - a(6) * c4 + a(7) * c3) * d,
115
+ (-a(1) * c5 + a(2) * c4 - a(3) * c3) * d,
116
+ (a(13) * s5 - a(14) * s4 + a(15) * s3) * d,
117
+ (-a(9) * s5 + a(10) * s4 - a(11) * s3) * d,
118
+ (-a(4) * c5 + a(6) * c2 - a(7) * c1) * d,
119
+ (a(0) * c5 - a(2) * c2 + a(3) * c1) * d,
120
+ (-a(12) * s5 + a(14) * s2 - a(15) * s1) * d,
121
+ (a(8) * s5 - a(10) * s2 + a(11) * s1) * d,
122
+ (a(4) * c4 - a(5) * c2 + a(7) * c0) * d,
123
+ (-a(0) * c4 + a(1) * c2 - a(3) * c0) * d,
124
+ (a(12) * s4 - a(13) * s2 + a(15) * s0) * d,
125
+ (-a(8) * s4 + a(9) * s2 - a(11) * s0) * d,
126
+ (-a(4) * c3 + a(5) * c1 - a(6) * c0) * d,
127
+ (a(0) * c3 - a(1) * c1 + a(2) * c0) * d,
128
+ (-a(12) * s3 + a(13) * s1 - a(14) * s0) * d,
129
+ (a(8) * s3 - a(9) * s1 + a(10) * s0) * d,
130
+ ];
131
+ }
132
+
133
+ /** Every `Objects` child, by id, with the class the record's third property names. */
134
+ export interface FbxObjects {
135
+ readonly byId: Map<number, FbxNode>;
136
+ /** child id to every parent it names, in file order. A bone names its cluster *and* its parent. */
137
+ readonly parentsOf: Map<number, number[]>;
138
+ /** `OP` connections: child id to the [parent, property] pairs it drives. */
139
+ readonly drives: Map<number, { readonly parent: number; readonly property: string }[]>;
140
+ }
141
+
142
+ export function indexObjects(root: FbxNode): FbxObjects {
143
+ const byId = new Map<number, FbxNode>();
144
+ for (const objects of root.children.filter((child) => child.name === 'Objects')) {
145
+ for (const child of objects.children) {
146
+ const id = child.properties[0];
147
+ if (typeof id === 'number') byId.set(id, child);
148
+ }
149
+ }
150
+ const parentsOf = new Map<number, number[]>();
151
+ const drives = new Map<number, { parent: number; property: string }[]>();
152
+ for (const connections of root.children.filter((child) => child.name === 'Connections')) {
153
+ for (const c of connections.children) {
154
+ const [kind, child, parent, property] = c.properties;
155
+ if (typeof child !== 'number' || typeof parent !== 'number') continue;
156
+ if (kind === 'OO') {
157
+ const list = parentsOf.get(child);
158
+ if (list === undefined) parentsOf.set(child, [parent]);
159
+ else list.push(parent);
160
+ } else if (kind === 'OP' && typeof property === 'string') {
161
+ const list = drives.get(child);
162
+ const entry = { parent, property };
163
+ if (list === undefined) drives.set(child, [entry]);
164
+ else list.push(entry);
165
+ }
166
+ }
167
+ }
168
+ return { byId, parentsOf, drives };
169
+ }
170
+
171
+ /** What a cluster says about one bone's hold on one mesh. */
172
+ interface Cluster {
173
+ readonly bone: number;
174
+ readonly indexes: ArrayLike<number>;
175
+ readonly weights: ArrayLike<number>;
176
+ /** `inverse(TransformLink) * meshWorld`, already composed. See `readSkin`. */
177
+ readonly inverseBind: readonly number[];
178
+ }
179
+
180
+ /** A skin, as this reader hands it on: the skeleton, and the influences keyed by control point. */
181
+ export interface FbxSkin {
182
+ readonly joints: readonly Joint[];
183
+ readonly inverseBind: Float32Array;
184
+ /**
185
+ * Control point index to its influences, as [joint, weight] pairs.
186
+ *
187
+ * A map rather than a dense array because a cluster covers the control points it covers: a
188
+ * character's rig leaves nothing unweighted, and a prop bound to one bone leaves almost
189
+ * everything out of every cluster but one.
190
+ */
191
+ readonly influences: Map<number, { joint: number; weight: number }[]>;
192
+ /** Model id of each joint, in the sorted order, so a curve can find the track it belongs to. */
193
+ readonly modelOfJoint: readonly number[];
194
+ }
195
+
196
+ /**
197
+ * The skin bound to one geometry, or null where nothing is.
198
+ *
199
+ * **The joints are sorted parents-first here**, because this is also the layer that can remap
200
+ * every index naming one — the mesh's own joint attribute is written from `influences` afterwards.
201
+ * `Skeleton` refuses an unsorted rig rather than sorting it, for exactly that reason.
202
+ */
203
+ export function readSkin(
204
+ objects: FbxObjects,
205
+ geometryId: number,
206
+ /**
207
+ * Where the mesh this skin deforms sits, from the node graph.
208
+ *
209
+ * **This is the half a cluster's own `Transform` claims to be and cannot be trusted for.** The
210
+ * SDK documents `Transform` as the mesh's global transform at bind time, which makes it one
211
+ * matrix for the whole skin — and a bought character measured here has **sixty-eight of its
212
+ * sixty-nine weight-bearing clusters disagreeing about it**, each holding something close to the
213
+ * inverse of its own `TransformLink` instead. Composed as documented, the bone's frame goes in
214
+ * twice: the inverse bind for that character's hip came back as a pure translation of
215
+ * `(0, -94.4, +94.4)` where the joint is at `(0, 94.4, 0)`, so inverting it put every joint on a
216
+ * mirror of the skeleton that no geometry occupies.
217
+ *
218
+ * So the mesh's bind transform is taken from the one authority this reader already has for where
219
+ * anything is, which is the same walk every static mesh goes through. It is also what the other
220
+ * readers of this format settle on, and for the same reason.
221
+ */
222
+ meshWorld: readonly number[],
223
+ warnings: string[],
224
+ ): FbxSkin | null {
225
+ const skinIds: number[] = [];
226
+ for (const [id, node] of objects.byId) {
227
+ if (node.name !== 'Deformer' || node.properties[2] !== 'Skin') continue;
228
+ if ((objects.parentsOf.get(id) ?? []).includes(geometryId)) skinIds.push(id);
229
+ }
230
+ if (skinIds.length === 0) return null;
231
+ if (skinIds.length > 1) {
232
+ warnings.push(
233
+ `fbx: a geometry carries ${skinIds.length} skins and only the first is read; the rest are ` +
234
+ `blend shapes or a second bind, neither of which this reader knows.`,
235
+ );
236
+ }
237
+ const skinId = skinIds[0] as number;
238
+
239
+ const clusters: Cluster[] = [];
240
+ for (const [id, node] of objects.byId) {
241
+ if (node.name !== 'Deformer' || node.properties[2] !== 'Cluster') continue;
242
+ if (!(objects.parentsOf.get(id) ?? []).includes(skinId)) continue;
243
+ /* The bone is the cluster's *child* in FBX's direction: it names the cluster as a parent. */
244
+ let bone = -1;
245
+ for (const [candidate, parents] of objects.parentsOf) {
246
+ if (!parents.includes(id)) continue;
247
+ const model = objects.byId.get(candidate);
248
+ if (model?.name === 'Model') {
249
+ bone = candidate;
250
+ break;
251
+ }
252
+ }
253
+ if (bone < 0) continue;
254
+ const indexes = numbers(findChild(node, 'Indexes'));
255
+ const weights = numbers(findChild(node, 'Weights'));
256
+ const transformLink = numbers(findChild(node, 'TransformLink'));
257
+ /*
258
+ * A cluster with no `Indexes` is legal and common: it is a bone in the skeleton that holds no
259
+ * vertices. It still belongs to the skeleton, because a clip drives it and its children hang
260
+ * off it, so it takes a joint with no influences rather than being dropped.
261
+ */
262
+ const link = transformLink === null ? IDENTITY : Array.from(transformLink).slice(0, 16);
263
+ clusters.push({
264
+ bone,
265
+ indexes: indexes ?? [],
266
+ weights: weights ?? [],
267
+ inverseBind: multiply(invert(link), meshWorld),
268
+ });
269
+ }
270
+ if (clusters.length === 0) return null;
271
+
272
+ /* Bone id to its parent bone, taking only parents that are themselves models in this skeleton. */
273
+ const bones = new Set(clusters.map((cluster) => cluster.bone));
274
+ const parentBone = new Map<number, number>();
275
+ for (const bone of bones) {
276
+ for (const parent of objects.parentsOf.get(bone) ?? []) {
277
+ if (bones.has(parent)) {
278
+ parentBone.set(bone, parent);
279
+ break;
280
+ }
281
+ }
282
+ }
283
+
284
+ /*
285
+ * Parents before children, by a walk from each root rather than by a comparator.
286
+ *
287
+ * "Before its parent" is not a total order, so a sort would have to invent one; visiting a bone
288
+ * only after its parent is what a depth-first walk gives for free. `gltfSkin.ts` reaches the
289
+ * same conclusion in the same words, and the two orders have to agree because both feed the same
290
+ * `Skeleton`.
291
+ */
292
+ const order: number[] = [];
293
+ const placed = new Set<number>();
294
+ const place = (bone: number, depth: number): void => {
295
+ if (placed.has(bone) || depth > 512) return;
296
+ const parent = parentBone.get(bone);
297
+ if (parent !== undefined && !placed.has(parent)) place(parent, depth + 1);
298
+ if (placed.has(bone)) return;
299
+ placed.add(bone);
300
+ order.push(bone);
301
+ };
302
+ for (const bone of bones) place(bone, 0);
303
+
304
+ const jointOfBone = new Map<number, number>();
305
+ order.forEach((bone, at) => jointOfBone.set(bone, at));
306
+
307
+ const joints: Joint[] = order.map((bone) => {
308
+ const parent = parentBone.get(bone);
309
+ return {
310
+ parent: parent === undefined ? -1 : (jointOfBone.get(parent) ?? -1),
311
+ name: bareName(objects.byId.get(bone)?.properties[1], `joint ${bone}`),
312
+ };
313
+ });
314
+
315
+ const inverseBind = new Float32Array(order.length * 16);
316
+ const influences = new Map<number, { joint: number; weight: number }[]>();
317
+ for (const cluster of clusters) {
318
+ const joint = jointOfBone.get(cluster.bone);
319
+ if (joint === undefined) continue;
320
+ inverseBind.set(cluster.inverseBind, joint * 16);
321
+ const count = Math.min(cluster.indexes.length, cluster.weights.length);
322
+ for (let i = 0; i < count; i++) {
323
+ const point = Math.trunc(cluster.indexes[i] as number);
324
+ const weight = cluster.weights[i] as number;
325
+ if (!(weight > 0) || point < 0) continue;
326
+ const list = influences.get(point);
327
+ if (list === undefined) influences.set(point, [{ joint, weight }]);
328
+ else list.push({ joint, weight });
329
+ }
330
+ }
331
+
332
+ return { joints, inverseBind, influences, modelOfJoint: order };
333
+ }
334
+
335
+ /**
336
+ * One control point's four influences, largest first, normalised, written into a caller's arrays.
337
+ *
338
+ * **Four is the shader's number and the file's is unbounded.** A rig authored with six influences
339
+ * on a shoulder is ordinary, so the four largest are kept and renormalised rather than the first
340
+ * four taken: dropping by *order* would discard whichever the exporter happened to write last,
341
+ * which on a shoulder is as likely to be the dominant one as not.
342
+ *
343
+ * Returns true where something was dropped or rescaled, so the caller can say so once for the mesh
344
+ * rather than once per vertex.
345
+ */
346
+ export function writeInfluences(
347
+ influences: readonly { joint: number; weight: number }[] | undefined,
348
+ joints: Float32Array,
349
+ weights: Float32Array,
350
+ at: number,
351
+ ): { dropped: boolean; rescaled: boolean } {
352
+ if (influences === undefined || influences.length === 0)
353
+ return { dropped: false, rescaled: false };
354
+ const sorted = [...influences].sort((a, b) => b.weight - a.weight);
355
+ const kept = sorted.slice(0, MAX_INFLUENCES);
356
+ let sum = 0;
357
+ for (const influence of kept) sum += influence.weight;
358
+ if (sum <= 0) return { dropped: false, rescaled: false };
359
+ for (let i = 0; i < kept.length; i++) {
360
+ const influence = kept[i] as { joint: number; weight: number };
361
+ joints[at * MAX_INFLUENCES + i] = influence.joint;
362
+ weights[at * MAX_INFLUENCES + i] = influence.weight / sum;
363
+ }
364
+ let total = 0;
365
+ for (const influence of sorted) total += influence.weight;
366
+ return {
367
+ dropped: sorted.length > MAX_INFLUENCES,
368
+ /* A tenth of a per cent, which is wider than float noise and narrower than an authoring slip. */
369
+ rescaled: Math.abs(total - 1) > 1e-3,
370
+ };
371
+ }
372
+
373
+ /** Euler degrees in the file's default XYZ order, as a quaternion. */
374
+ function eulerToQuaternion(x: number, y: number, z: number): [number, number, number, number] {
375
+ const half = Math.PI / 360;
376
+ const cx = Math.cos(x * half);
377
+ const sx = Math.sin(x * half);
378
+ const cy = Math.cos(y * half);
379
+ const sy = Math.sin(y * half);
380
+ const cz = Math.cos(z * half);
381
+ const sz = Math.sin(z * half);
382
+ /* Rz * Ry * Rx, which is what `eEulerXYZ` composes to — the same product `compose` builds in
383
+ `fbx.ts`, and the same trap: written the other way round it turns a limb inside out. */
384
+ return [
385
+ sx * cy * cz + cx * sy * sz,
386
+ cx * sy * cz - sx * cy * sz,
387
+ cx * cy * sz + sx * sy * cz,
388
+ cx * cy * cz - sx * sy * sz,
389
+ ];
390
+ }
391
+
392
+ /** Hamilton product, which is how two rotations compose. */
393
+ function multiplyQuaternion(
394
+ a: readonly [number, number, number, number],
395
+ b: readonly [number, number, number, number],
396
+ ): [number, number, number, number] {
397
+ return [
398
+ a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
399
+ a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
400
+ a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
401
+ a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
402
+ ];
403
+ }
404
+
405
+ /** A joint's fixed rotations, which sit either side of the one a curve drives. */
406
+ interface RotationFrame {
407
+ readonly pre: [number, number, number, number];
408
+ /** Already conjugated, because FBX's chain uses the *inverse* of the post-rotation. */
409
+ readonly postInverse: [number, number, number, number];
410
+ readonly composed: boolean;
411
+ }
412
+
413
+ const NO_ROTATION: RotationFrame = {
414
+ pre: [0, 0, 0, 1],
415
+ postInverse: [0, 0, 0, 1],
416
+ composed: false,
417
+ };
418
+
419
+ /**
420
+ * A joint's `PreRotation` and `PostRotation`, as the two quaternions the chain needs.
421
+ *
422
+ * **FBX's local rotation is `PreRotation * R * inverse(PostRotation)`**, not `R`. Both are fixed
423
+ * per joint and written on the model rather than in the curve, so a reader that takes the curve
424
+ * alone plays a clip in which every joint carrying one is turned by a constant amount — twenty-nine
425
+ * of eighty-eight on the bought character this was measured against. Every key interpolates, the
426
+ * take runs, and the figure is wrong in a way that reads as a bad export.
427
+ */
428
+ function rotationFrame(model: FbxNode | undefined): RotationFrame {
429
+ if (model === undefined) return NO_ROTATION;
430
+ const properties = findChild(model, 'Properties70');
431
+ let pre: [number, number, number, number] | null = null;
432
+ let post: [number, number, number, number] | null = null;
433
+ for (const property of properties?.children ?? []) {
434
+ const name = property.properties[0];
435
+ if (name !== 'PreRotation' && name !== 'PostRotation') continue;
436
+ const x = property.properties[4];
437
+ const y = property.properties[5];
438
+ const z = property.properties[6];
439
+ if (typeof x !== 'number' || typeof y !== 'number' || typeof z !== 'number') continue;
440
+ if (x === 0 && y === 0 && z === 0) continue;
441
+ if (name === 'PreRotation') pre = eulerToQuaternion(x, y, z);
442
+ else post = eulerToQuaternion(x, y, z);
443
+ }
444
+ if (pre === null && post === null) return NO_ROTATION;
445
+ return {
446
+ pre: pre ?? [0, 0, 0, 1],
447
+ postInverse: post === null ? [0, 0, 0, 1] : [-post[0], -post[1], -post[2], post[3]],
448
+ composed: true,
449
+ };
450
+ }
451
+
452
+ /** Which of the three transforms an `OP` property name drives, or null for one we do not animate. */
453
+ function pathOf(property: string): TrackPath | null {
454
+ if (property === 'Lcl Translation') return 'translation';
455
+ if (property === 'Lcl Rotation') return 'rotation';
456
+ if (property === 'Lcl Scaling') return 'scale';
457
+ return null;
458
+ }
459
+
460
+ /** A curve node's per-channel default, which is the value of a channel nothing curves. */
461
+ function channelDefaults(node: FbxNode): [number, number, number] {
462
+ const out: [number, number, number] = [0, 0, 0];
463
+ const properties = findChild(node, 'Properties70');
464
+ for (const property of properties?.children ?? []) {
465
+ const name = property.properties[0];
466
+ const value = property.properties[4];
467
+ if (typeof value !== 'number') continue;
468
+ if (name === 'd|X') out[0] = value;
469
+ else if (name === 'd|Y') out[1] = value;
470
+ else if (name === 'd|Z') out[2] = value;
471
+ }
472
+ return out;
473
+ }
474
+
475
+ /**
476
+ * Every take in the file, as clips over the joints a skin named.
477
+ *
478
+ * **A curve is per channel and a track is per joint**, so the three curves under one curve node are
479
+ * merged onto one timeline: the union of their key times, each channel read at that time. A key a
480
+ * channel does not have is held at its neighbour rather than interpolated, which is what a stepped
481
+ * read of a curve gives and is exact wherever the exporter wrote all three channels together —
482
+ * which is what every exporter does for a joint it is baking.
483
+ *
484
+ * **Rotation arrives as Euler degrees and leaves as a quaternion**, in the file's default XYZ
485
+ * order. A joint carrying a `PreRotation` or a non-default `RotationOrder` is named in a warning
486
+ * rather than approximated: both are silent when read wrongly, and a limb that is ninety degrees
487
+ * out reads as a broken clip rather than as an unread field.
488
+ */
489
+ export function readClips(
490
+ objects: FbxObjects,
491
+ jointOfModel: ReadonlyMap<number, number>,
492
+ warnings: string[],
493
+ ): AnimationClip[] {
494
+ const stacks = [...objects.byId].filter(([, node]) => node.name === 'AnimationStack');
495
+ if (stacks.length === 0) return [];
496
+
497
+ /*
498
+ * The one part of the rotation chain that is still not read.
499
+ *
500
+ * `PreRotation` and `PostRotation` are composed below. A `RotationOrder` other than the default
501
+ * `eEulerXYZ` is not: it would change which product the three Euler angles build, and getting it
502
+ * wrong is silent in exactly the way the pre-rotation was. Named rather than approximated.
503
+ */
504
+ const orders = new Set<number>();
505
+ for (const [model] of jointOfModel) {
506
+ const properties = findChild(
507
+ objects.byId.get(model) ?? { name: '', properties: [], children: [] },
508
+ 'Properties70',
509
+ );
510
+ for (const property of properties?.children ?? []) {
511
+ if (property.properties[0] !== 'RotationOrder') continue;
512
+ const order = property.properties[4];
513
+ if (typeof order === 'number' && order !== 0) orders.add(order);
514
+ }
515
+ }
516
+ if (orders.size > 0) {
517
+ warnings.push(
518
+ `fbx: joints declare rotation order ${[...orders].sort().join(', ')} and this reader composes ` +
519
+ `the default XYZ only, so their rotations may be wrong. Export as glTF if the clip matters.`,
520
+ );
521
+ }
522
+
523
+ const clips: AnimationClip[] = [];
524
+ for (const [stackId, stack] of stacks) {
525
+ /* Layers under the stack, and the curve nodes under those. Only the first layer is read: the
526
+ rest are additive or override layers, which is a blend this reader does not perform. */
527
+ const layers = [...objects.parentsOf]
528
+ .filter(([, parents]) => parents.includes(stackId))
529
+ .map(([id]) => id)
530
+ .filter((id) => objects.byId.get(id)?.name === 'AnimationLayer');
531
+ if (layers.length > 1) {
532
+ warnings.push(
533
+ `fbx: "${bareName(stack.properties[1], 'take')}" has ${layers.length} animation layers ` +
534
+ `and only the first is read; the others would have to be blended.`,
535
+ );
536
+ }
537
+ const layer = layers[0];
538
+ if (layer === undefined) continue;
539
+
540
+ const tracks: JointTrack[] = [];
541
+ let duration = 0;
542
+
543
+ for (const [curveNodeId, parents] of objects.parentsOf) {
544
+ if (!parents.includes(layer)) continue;
545
+ const curveNode = objects.byId.get(curveNodeId);
546
+ if (curveNode?.name !== 'AnimationCurveNode') continue;
547
+
548
+ /* What this node drives, which is an `OP` connection naming the property by its own name. */
549
+ let joint: number | undefined;
550
+ let path: TrackPath | null = null;
551
+ let modelOfTrack = -1;
552
+ for (const target of objects.drives.get(curveNodeId) ?? []) {
553
+ const candidate = jointOfModel.get(target.parent);
554
+ const kind = pathOf(target.property);
555
+ if (candidate !== undefined && kind !== null) {
556
+ joint = candidate;
557
+ path = kind;
558
+ modelOfTrack = target.parent;
559
+ break;
560
+ }
561
+ }
562
+ if (joint === undefined || path === null) continue;
563
+
564
+ /* The three channels, each an `AnimationCurve` naming `d|X`, `d|Y` or `d|Z`. */
565
+ const channels: ({ times: number[]; values: number[] } | null)[] = [null, null, null];
566
+ for (const [curveId, targets] of objects.drives) {
567
+ if (objects.byId.get(curveId)?.name !== 'AnimationCurve') continue;
568
+ for (const target of targets) {
569
+ if (target.parent !== curveNodeId) continue;
570
+ const axis =
571
+ target.property === 'd|X'
572
+ ? 0
573
+ : target.property === 'd|Y'
574
+ ? 1
575
+ : target.property === 'd|Z'
576
+ ? 2
577
+ : -1;
578
+ if (axis < 0) continue;
579
+ const curve = objects.byId.get(curveId) as FbxNode;
580
+ const times = numbers(findChild(curve, 'KeyTime'));
581
+ const values = numbers(findChild(curve, 'KeyValueFloat'));
582
+ if (times === null || values === null) continue;
583
+ const count = Math.min(times.length, values.length);
584
+ const t: number[] = [];
585
+ const v: number[] = [];
586
+ for (let i = 0; i < count; i++) {
587
+ t.push((times[i] as number) / KTIME_PER_SECOND);
588
+ v.push(values[i] as number);
589
+ }
590
+ channels[axis] = { times: t, values: v };
591
+ }
592
+ }
593
+ if (channels.every((channel) => channel === null)) continue;
594
+
595
+ const merged = new Set<number>();
596
+ for (const channel of channels) for (const time of channel?.times ?? []) merged.add(time);
597
+ const times = [...merged].sort((a, b) => a - b);
598
+ if (times.length === 0) continue;
599
+ duration = Math.max(duration, times[times.length - 1] as number);
600
+
601
+ const defaults = channelDefaults(curveNode);
602
+ /** A channel's value at a time, held at the last key at or before it. */
603
+ const at = (axis: number, time: number): number => {
604
+ const channel = channels[axis];
605
+ if (channel === null || channel === undefined || channel.times.length === 0) {
606
+ return defaults[axis] as number;
607
+ }
608
+ let index = 0;
609
+ while (index + 1 < channel.times.length && (channel.times[index + 1] as number) <= time)
610
+ index++;
611
+ return channel.values[index] as number;
612
+ };
613
+
614
+ const stride = path === 'rotation' ? 4 : 3;
615
+ const frame =
616
+ path === 'rotation' ? rotationFrame(objects.byId.get(modelOfTrack)) : NO_ROTATION;
617
+ const values = new Float32Array(times.length * stride);
618
+ times.forEach((time, key) => {
619
+ const x = at(0, time);
620
+ const y = at(1, time);
621
+ const z = at(2, time);
622
+ if (path !== 'rotation') {
623
+ values.set([x, y, z], key * 3);
624
+ return;
625
+ }
626
+ const curved = eulerToQuaternion(x, y, z);
627
+ values.set(
628
+ frame.composed
629
+ ? multiplyQuaternion(multiplyQuaternion(frame.pre, curved), frame.postInverse)
630
+ : curved,
631
+ key * 4,
632
+ );
633
+ });
634
+
635
+ tracks.push({ joint, path, times: new Float32Array(times), values });
636
+ }
637
+
638
+ if (tracks.length === 0) continue;
639
+ /* The stack's own stop time where it states one, since a clip may end after its last key. */
640
+ const properties = findChild(stack, 'Properties70');
641
+ for (const property of properties?.children ?? []) {
642
+ if (property.properties[0] !== 'LocalStop') continue;
643
+ const raw = property.properties[4];
644
+ if (typeof raw === 'number') duration = Math.max(duration, raw / KTIME_PER_SECOND);
645
+ }
646
+ /* Sorted, so a clip's tracks are in a stable order whatever order the connection table was in
647
+ — two bakes of one file that differ only in track order are two different `.drft` files. */
648
+ tracks.sort((a, b) => a.joint - b.joint || a.path.localeCompare(b.path));
649
+ clips.push({ name: bareName(stack.properties[1], 'take'), durationSec: duration, tracks });
650
+ }
651
+ return clips;
652
+ }