@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/dist/fbx.js ADDED
@@ -0,0 +1,792 @@
1
+ /**
2
+ * Binary FBX → `MeshData`. Tier 2: a real attempt, and explicitly not a guarantee.
3
+ *
4
+ * FBX is what an asset store hands you as the source, so reading it removes a round trip
5
+ * through an export dialog for the common case. The obstacle was never the format — it is
6
+ * Autodesk's *SDK licence*, and nothing here uses or ships any of it. The binary container
7
+ * is a self-describing tree of typed records, which is why a reader can be written against
8
+ * it at all.
9
+ *
10
+ * **Inflate is injected.** Array properties are usually deflate-compressed, and the only
11
+ * decompressor available without a dependency is Node's `zlib` — which must not be
12
+ * imported here, because this file is part of an engine that runs in a browser. So the
13
+ * caller supplies one. The baker passes `zlib.inflateSync`; a browser could pass
14
+ * `DecompressionStream`. Neither is this module's business.
15
+ *
16
+ * What it reads: geometry, its normals and UVs, and per-model transforms. What it refuses:
17
+ * anything it cannot understand, by name. It never returns the part of a scene it managed
18
+ * — a model with holes in it is a worse outcome than an error, because the error says what
19
+ * to do next.
20
+ *
21
+ * **It reports orientation and does not apply it.** The file's declared up axis comes back
22
+ * as `declaredUp`, and standing the asset up is `orient.ts`, one implementation shared by
23
+ * every format. That split is deliberate: a reader that quietly corrected its own files
24
+ * would make this format's habits into the engine's defaults, and the next reader would be
25
+ * written to match those rather than its own specification.
26
+ */
27
+ import { DrftError } from '@driftengine/drft';
28
+ import { parseUpAxis } from './orient.js';
29
+ import { FALLBACK_MATERIAL, readMaterials } from './fbxMaterial.js';
30
+ import { bareName, indexObjects, readClips, readSkin, writeInfluences } from './fbxRig.js';
31
+ /** One value repeated per vertex, for a material constant this format has nowhere else to put. */
32
+ function repeatTriple(count, value) {
33
+ const out = new Float32Array(count * 3);
34
+ for (let i = 0; i < count; i++) {
35
+ out[i * 3] = value[0];
36
+ out[i * 3 + 1] = value[1];
37
+ out[i * 3 + 2] = value[2];
38
+ }
39
+ return out;
40
+ }
41
+ const MAGIC = 'Kaydara FBX Binary ';
42
+ /** Parse the record tree. Structure only — nothing here knows what a mesh is. */
43
+ export function parseFbxTree(buffer, inflate) {
44
+ const bytes = new Uint8Array(buffer);
45
+ const view = new DataView(buffer);
46
+ const header = new TextDecoder('latin1').decode(bytes.subarray(0, MAGIC.length));
47
+ if (header !== MAGIC) {
48
+ throw new DrftError('fbx: not a binary FBX. ASCII FBX is not read — re-export as binary, or as glTF.');
49
+ }
50
+ const version = view.getUint32(23, true);
51
+ /*
52
+ * At 7500 the record offsets widened from 32 to 64 bits. It is the only structural
53
+ * break in the container's history, and getting it wrong reads garbage rather than
54
+ * failing, so it is decided once here from the file's own version.
55
+ */
56
+ const wide = version >= 7500;
57
+ const root = { name: '', properties: [], children: [] };
58
+ let at = 27;
59
+ const readNode = () => {
60
+ const endOffset = wide ? Number(view.getBigUint64(at, true)) : view.getUint32(at, true);
61
+ const propertyCount = wide
62
+ ? Number(view.getBigUint64(at + 8, true))
63
+ : view.getUint32(at + 4, true);
64
+ const headerBytes = wide ? 25 : 13;
65
+ const nameLength = view.getUint8(at + (wide ? 24 : 12));
66
+ /* A record of all zeroes is the terminator for a list of siblings. */
67
+ if (endOffset === 0) {
68
+ at += headerBytes;
69
+ return null;
70
+ }
71
+ let cursor = at + headerBytes;
72
+ const name = new TextDecoder('latin1').decode(bytes.subarray(cursor, cursor + nameLength));
73
+ cursor += nameLength;
74
+ const properties = [];
75
+ for (let i = 0; i < propertyCount; i++) {
76
+ const [value, next] = readProperty(view, bytes, cursor, inflate);
77
+ properties.push(value);
78
+ cursor = next;
79
+ }
80
+ const node = { name, properties, children: [] };
81
+ /* Anything left before the recorded end of this record is a nested list. */
82
+ while (cursor < endOffset) {
83
+ const saved = at;
84
+ at = cursor;
85
+ const child = readNode();
86
+ cursor = at;
87
+ at = saved;
88
+ if (child === null)
89
+ break;
90
+ node.children.push(child);
91
+ }
92
+ at = endOffset;
93
+ return node;
94
+ };
95
+ while (at < bytes.length - 16) {
96
+ const node = readNode();
97
+ if (node === null)
98
+ break;
99
+ root.children.push(node);
100
+ }
101
+ return { root, version };
102
+ }
103
+ function readProperty(view, bytes, at, inflate) {
104
+ const type = String.fromCharCode(view.getUint8(at));
105
+ const start = at + 1;
106
+ switch (type) {
107
+ case 'Y':
108
+ return [view.getInt16(start, true), start + 2];
109
+ case 'C':
110
+ return [view.getUint8(start) !== 0, start + 1];
111
+ case 'I':
112
+ return [view.getInt32(start, true), start + 4];
113
+ case 'F':
114
+ return [view.getFloat32(start, true), start + 4];
115
+ case 'D':
116
+ return [view.getFloat64(start, true), start + 8];
117
+ case 'L':
118
+ return [Number(view.getBigInt64(start, true)), start + 8];
119
+ case 'S':
120
+ case 'R': {
121
+ const length = view.getUint32(start, true);
122
+ const raw = bytes.subarray(start + 4, start + 4 + length);
123
+ return [type === 'S' ? new TextDecoder('latin1').decode(raw) : raw, start + 4 + length];
124
+ }
125
+ case 'f':
126
+ case 'd':
127
+ case 'l':
128
+ case 'i':
129
+ case 'b': {
130
+ const count = view.getUint32(start, true);
131
+ const encoding = view.getUint32(start + 4, true);
132
+ const compressedLength = view.getUint32(start + 8, true);
133
+ const elementBytes = type === 'd' || type === 'l' ? 8 : type === 'b' ? 1 : 4;
134
+ const payload = bytes.subarray(start + 12, start + 12 + compressedLength);
135
+ const raw = encoding === 1 ? inflate(payload, count * elementBytes) : payload;
136
+ return [decodeArray(type, raw, count), start + 12 + compressedLength];
137
+ }
138
+ default:
139
+ throw new DrftError(`fbx: property type "${type}" at ${at} is not one this reader knows`);
140
+ }
141
+ }
142
+ function decodeArray(type, raw, count) {
143
+ /* `slice` rather than a view: the payload may be unaligned within the file. */
144
+ const aligned = raw.byteOffset % 8 === 0 ? raw : raw.slice();
145
+ const buffer = aligned.buffer;
146
+ const offset = aligned.byteOffset;
147
+ switch (type) {
148
+ case 'f':
149
+ return new Float32Array(buffer, offset, count);
150
+ case 'd':
151
+ return new Float64Array(buffer, offset, count);
152
+ case 'i':
153
+ return new Int32Array(buffer, offset, count);
154
+ case 'l': {
155
+ const big = new BigInt64Array(buffer, offset, count);
156
+ const out = new Float64Array(count);
157
+ for (let i = 0; i < count; i++)
158
+ out[i] = Number(big[i]);
159
+ return out;
160
+ }
161
+ default:
162
+ return new Uint8Array(buffer, offset, count);
163
+ }
164
+ }
165
+ function findAll(node, name, out = []) {
166
+ for (const child of node.children) {
167
+ if (child.name === name)
168
+ out.push(child);
169
+ findAll(child, name, out);
170
+ }
171
+ return out;
172
+ }
173
+ function findChild(node, name) {
174
+ return node.children.find((child) => child.name === name);
175
+ }
176
+ function numbers(node) {
177
+ const value = node?.properties[0];
178
+ return value !== undefined && typeof value === 'object' && value !== null && 'length' in value
179
+ ? value
180
+ : null;
181
+ }
182
+ /**
183
+ * Which way this file *says* is up, from its own `GlobalSettings`.
184
+ *
185
+ * FBX records the convention rather than fixing one, and the tools disagree: 3ds Max and
186
+ * most game exports are Z-up, Maya is Y-up. Ignoring it does not produce a small error —
187
+ * a Z-up car imported as Y-up lies on its side, half sunk through the floor, with its
188
+ * longest span on the wrong axis. It was reported in exactly those words.
189
+ *
190
+ * **The sign is half of the answer and was previously dropped.** `UpAxis` names an axis and
191
+ * `UpAxisSign` says which end of it, so `1, -1` means up is *minus* Y — a file that reads
192
+ * as innocent while importing every model in it upside down, which is the same symptom as
193
+ * a rotation bug and none of the same cause. Reading the axis and discarding the sign is
194
+ * how a reader produces that silently.
195
+ *
196
+ * This only *reports*. Turning the asset is `orient.ts`, once, for every format, so no one
197
+ * format's habits become the engine's defaults.
198
+ */
199
+ /**
200
+ * Metres per file unit, from `GlobalSettings.UnitScaleFactor`.
201
+ *
202
+ * **The container's own unit is the centimetre**, and `UnitScaleFactor` counts how many of them one
203
+ * file unit is: one for a file authored in centimetres, a hundred for one authored in metres. So
204
+ * metres per unit is that figure over a hundred, and a file stating nothing is centimetres because
205
+ * that is what the container defines rather than what this reader would prefer.
206
+ *
207
+ * **It is read rather than applied**, on exactly the terms `declaredUp` beside it is: a reader that
208
+ * quietly rescaled its own files would make this format's habits into the engine's defaults. What
209
+ * it replaces is worse than a rescale — a consumer with no figure to scale by was dividing by a
210
+ * measured height, which is a guess dressed as arithmetic and is wrong for anything that is not a
211
+ * standing human.
212
+ */
213
+ function unitScaleOf(root) {
214
+ for (const settings of root.children.filter((child) => child.name === 'GlobalSettings')) {
215
+ const properties = findChild(settings, 'Properties70');
216
+ for (const property of properties?.children ?? []) {
217
+ if (property.properties[0] !== 'UnitScaleFactor')
218
+ continue;
219
+ const factor = property.properties[4];
220
+ /* A zero or a negative would scale a model to nothing or inside out, so it is refused in
221
+ favour of the container's default rather than trusted for being present. */
222
+ if (typeof factor === 'number' && Number.isFinite(factor) && factor > 0)
223
+ return factor / 100;
224
+ }
225
+ }
226
+ return 0.01;
227
+ }
228
+ function declaredUpOf(root) {
229
+ /* 0 is X, 1 is Y, 2 is Z. Anything else is treated as Y, which is the engine's own. */
230
+ let axis = 1;
231
+ let sign = 1;
232
+ for (const settings of root.children.filter((child) => child.name === 'GlobalSettings')) {
233
+ const properties = settings.children.find((child) => child.name === 'Properties70');
234
+ for (const property of properties?.children ?? []) {
235
+ const value = property.properties[4];
236
+ if (typeof value !== 'number')
237
+ continue;
238
+ if (property.properties[0] === 'UpAxis')
239
+ axis = value;
240
+ else if (property.properties[0] === 'UpAxisSign')
241
+ sign = value;
242
+ }
243
+ }
244
+ return parseUpAxis(`${sign < 0 ? '-' : '+'}${axis === 0 ? 'x' : axis === 2 ? 'z' : 'y'}`);
245
+ }
246
+ /**
247
+ * Where each geometry belongs, composed from the model hierarchy.
248
+ *
249
+ * **This is what a car looks like without it.** A `Geometry` record holds vertices in its
250
+ * *own* local space, and the transform that puts it on the car lives on a separate `Model`
251
+ * record joined to it through the file's `Connections` table. Skip that and all 187 parts
252
+ * arrive stacked at the origin, overlapping in a heap — which reads as random lines rather
253
+ * than as a missing transform, and is why it looked like a parsing fault.
254
+ *
255
+ * Connections are `C` records of `["OO", childId, parentId]`, so the graph is walked from
256
+ * a geometry up through its model's ancestors, and the transforms composed on the way.
257
+ */
258
+ function readTransforms(root) {
259
+ const models = new Map();
260
+ const geometryIds = new Map();
261
+ for (const objects of root.children.filter((child) => child.name === 'Objects')) {
262
+ for (const node of objects.children) {
263
+ const id = node.properties[0];
264
+ if (typeof id !== 'number')
265
+ continue;
266
+ if (node.name === 'Model')
267
+ models.set(id, node);
268
+ else if (node.name === 'Geometry')
269
+ geometryIds.set(node, id);
270
+ }
271
+ }
272
+ /*
273
+ * child -> parent, from the file's own connection table, **taking only parents that are models**.
274
+ *
275
+ * An object names every parent it has and the table is in whatever order the exporter wrote it,
276
+ * so "the first `OO` parent" is not the hierarchy. A bone names its skin cluster as well as its
277
+ * parent bone, and a cluster is not a model: taking it made the walk to the world stop there and
278
+ * silently return identity, which drops every ancestor above that bone. A prop parented to a
279
+ * hand then drew as though the hand were a root — right relative to the hand, in the wrong place
280
+ * in the scene, which reads as a rigging fault rather than as a connection read in the wrong
281
+ * order. Geometry is exempt because a geometry's parent is the model wearing it, which is the
282
+ * one edge this map is asked for that does not run between two models.
283
+ */
284
+ const parentOf = new Map();
285
+ for (const connections of root.children.filter((child) => child.name === 'Connections')) {
286
+ for (const c of connections.children) {
287
+ const [kind, child, parent] = c.properties;
288
+ if (kind !== 'OO' || typeof child !== 'number' || typeof parent !== 'number')
289
+ continue;
290
+ if (parentOf.has(child) || !models.has(parent))
291
+ continue;
292
+ parentOf.set(child, parent);
293
+ }
294
+ }
295
+ /** A model's own transform, from its `Properties70` entries. */
296
+ const localOf = (model) => {
297
+ let tx = 0;
298
+ let ty = 0;
299
+ let tz = 0;
300
+ let rx = 0;
301
+ let ry = 0;
302
+ let rz = 0;
303
+ let sx = 1;
304
+ let sy = 1;
305
+ let sz = 1;
306
+ const properties = model.children.find((child) => child.name === 'Properties70');
307
+ for (const property of properties?.children ?? []) {
308
+ const name = property.properties[0];
309
+ const a = property.properties[4];
310
+ const b = property.properties[5];
311
+ const c = property.properties[6];
312
+ if (typeof a !== 'number' || typeof b !== 'number' || typeof c !== 'number')
313
+ continue;
314
+ if (name === 'Lcl Translation')
315
+ [tx, ty, tz] = [a, b, c];
316
+ else if (name === 'Lcl Rotation')
317
+ [rx, ry, rz] = [a, b, c];
318
+ else if (name === 'Lcl Scaling')
319
+ [sx, sy, sz] = [a, b, c];
320
+ }
321
+ return compose(tx, ty, tz, rx, ry, rz, sx, sy, sz);
322
+ };
323
+ const worldCache = new Map();
324
+ const worldOf = (id, depth = 0) => {
325
+ const cached = worldCache.get(id);
326
+ if (cached !== undefined)
327
+ return cached;
328
+ const model = models.get(id);
329
+ if (model === undefined || depth > 32)
330
+ return IDENTITY_4;
331
+ const parent = parentOf.get(id);
332
+ const world = parent === undefined ? localOf(model) : multiply4(worldOf(parent, depth + 1), localOf(model));
333
+ worldCache.set(id, world);
334
+ return world;
335
+ };
336
+ const world = new Map();
337
+ const modelOf = new Map();
338
+ for (const [, id] of geometryIds) {
339
+ const model = parentOf.get(id);
340
+ world.set(id, model === undefined ? IDENTITY_4 : worldOf(model));
341
+ if (model !== undefined)
342
+ modelOf.set(id, model);
343
+ }
344
+ return { world, modelOf };
345
+ }
346
+ function readLayer(geometry, element, valueName, indexName, components) {
347
+ const node = findChild(geometry, element);
348
+ if (node === undefined)
349
+ return null;
350
+ const values = numbers(findChild(node, valueName));
351
+ if (values === null)
352
+ return null;
353
+ const reference = findChild(node, 'ReferenceInformationType')?.properties[0] ?? 'Direct';
354
+ const mapping = findChild(node, 'MappingInformationType')?.properties[0] ?? 'ByPolygonVertex';
355
+ const indices = reference.startsWith('Index') ? numbers(findChild(node, indexName)) : null;
356
+ return { values, indices, mapping, components };
357
+ }
358
+ /** Where this corner's datum starts in a layer's values, or -1 if the layer does not cover it. */
359
+ function layerAt(layer, polygonVertex, vertexIndex, polygon) {
360
+ const key = layer.mapping === 'ByVertice' || layer.mapping === 'ByVertex'
361
+ ? vertexIndex
362
+ : layer.mapping === 'ByPolygon'
363
+ ? polygon
364
+ : layer.mapping === 'AllSame'
365
+ ? 0
366
+ : polygonVertex;
367
+ const at = layer.indices === null ? key : Math.trunc(layer.indices[key]);
368
+ if (!(at >= 0))
369
+ return -1;
370
+ const offset = at * layer.components;
371
+ return offset + layer.components <= layer.values.length ? offset : -1;
372
+ }
373
+ const IDENTITY_4 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
374
+ function multiply4(a, b) {
375
+ const out = new Array(16);
376
+ for (let row = 0; row < 4; row++) {
377
+ for (let col = 0; col < 4; col++) {
378
+ let sum = 0;
379
+ for (let k = 0; k < 4; k++)
380
+ sum += a[k * 4 + row] * b[col * 4 + k];
381
+ out[col * 4 + row] = sum;
382
+ }
383
+ }
384
+ return out;
385
+ }
386
+ /** A model's matrix from its translation, rotation and scale. Degrees, column-major. */
387
+ function compose(tx, ty, tz, rx, ry, rz, sx, sy, sz) {
388
+ const toRad = Math.PI / 180;
389
+ const cx = Math.cos(rx * toRad);
390
+ const sxr = Math.sin(rx * toRad);
391
+ const cy = Math.cos(ry * toRad);
392
+ const syr = Math.sin(ry * toRad);
393
+ const cz = Math.cos(rz * toRad);
394
+ const szr = Math.sin(rz * toRad);
395
+ /*
396
+ * Rz * Ry * Rx, and the order is the bug this replaces.
397
+ *
398
+ * FBX's default is `eEulerXYZ`, which names the order the axes are rotated *in* — X,
399
+ * then Y, then Z — and therefore this product, not Rx * Ry * Rz. Backwards it does not
400
+ * tilt a model slightly: on a hierarchy whose parts carry 90 and 180 degree rotations it
401
+ * turns the assembly inside out, and the symptom is a car arriving upside down with its
402
+ * body hanging below the origin. Found only after the file's own `UpAxis` had been
403
+ * checked and was innocent.
404
+ */
405
+ const m00 = cz * cy;
406
+ const m01 = cz * syr * sxr - szr * cx;
407
+ const m02 = cz * syr * cx + szr * sxr;
408
+ const m10 = szr * cy;
409
+ const m11 = szr * syr * sxr + cz * cx;
410
+ const m12 = szr * syr * cx - cz * sxr;
411
+ const m20 = -syr;
412
+ const m21 = cy * sxr;
413
+ const m22 = cy * cx;
414
+ return [
415
+ m00 * sx,
416
+ m10 * sx,
417
+ m20 * sx,
418
+ 0,
419
+ m01 * sy,
420
+ m11 * sy,
421
+ m21 * sy,
422
+ 0,
423
+ m02 * sz,
424
+ m12 * sz,
425
+ m22 * sz,
426
+ 0,
427
+ tx,
428
+ ty,
429
+ tz,
430
+ 1,
431
+ ];
432
+ }
433
+ /**
434
+ * Geometry out of a parsed tree, one `MeshData` per `Geometry` record.
435
+ *
436
+ * FBX stores polygons of any size as a flat index list where a **negative index marks the
437
+ * last corner of a polygon**, encoded as its bitwise complement. Triangulating as a fan is
438
+ * correct for the convex polygons these files contain and is what every other reader does.
439
+ *
440
+ * Normals and UVs arrive as layer elements with their own mapping and reference modes, and
441
+ * the combination decides how to look a value up. Getting that wrong does not fail — it
442
+ * shades the model wrongly — so each supported combination is handled explicitly and an
443
+ * unsupported one is named rather than approximated.
444
+ */
445
+ export function fbxToMeshes(buffer, inflate) {
446
+ const { root, version } = parseFbxTree(buffer, inflate);
447
+ const warnings = [];
448
+ const meshes = [];
449
+ const perMesh = [];
450
+ const transforms = readTransforms(root);
451
+ const materials = readMaterials(root, warnings);
452
+ const declaredUp = declaredUpOf(root);
453
+ const unitScale = unitScaleOf(root);
454
+ const objects = indexObjects(root);
455
+ let seenGeometries = 0;
456
+ let emptyGeometries = 0;
457
+ const skins = [];
458
+ /** Model id to joint index, across every skin, so a clip's curves can find their tracks. */
459
+ const jointOfModel = new Map();
460
+ for (const geometry of findAll(root, 'Geometry')) {
461
+ const geometryId = geometry.properties[0];
462
+ /*
463
+ * The skin first, because it decides which space this geometry's vertices stay in. See the
464
+ * note on `skins` in the return type: a bound mesh is placed by its palette, so taking the
465
+ * model transform as well places it twice.
466
+ */
467
+ const meshWorld = transforms.world.get(geometryId) ?? IDENTITY_4;
468
+ const skin = readSkin(objects, geometryId, meshWorld, warnings);
469
+ if (skin !== null) {
470
+ skins.push({ joints: skin.joints, inverseBind: skin.inverseBind });
471
+ skin.modelOfJoint.forEach((model, joint) => jointOfModel.set(model, joint));
472
+ }
473
+ const world = skin !== null ? IDENTITY_4 : meshWorld;
474
+ const vertices = numbers(findChild(geometry, 'Vertices'));
475
+ const polygonIndex = numbers(findChild(geometry, 'PolygonVertexIndex'));
476
+ seenGeometries++;
477
+ if (vertices === null || polygonIndex === null) {
478
+ /* Counted rather than skipped in silence, so the refusal below can say which of the two
479
+ shapes of nothing this file is: no geometry records at all, or records carrying something
480
+ that is not a polygon mesh — a blend-shape target, a NURBS patch, a subdivision cage. */
481
+ emptyGeometries++;
482
+ continue;
483
+ }
484
+ /* Through the same helper the rig's names go through: a binary file tags a name with its class
485
+ after a `\0\x01`, and splitting on a space was a guess that happened to survive because the
486
+ result is only ever quoted in a warning. See `bareName`. */
487
+ const name = bareName(geometry.properties[1], 'geometry');
488
+ /* Corners, with the polygon boundaries resolved and fans emitted. The polygon a corner
489
+ came from travels with it, because that is what a per-polygon layer is keyed by. */
490
+ const corners = [];
491
+ const polygonOfCorner = [];
492
+ let polygonStart = 0;
493
+ let polygon = 0;
494
+ for (let i = 0; i < polygonIndex.length; i++) {
495
+ if (polygonIndex[i] >= 0)
496
+ continue;
497
+ const end = i;
498
+ for (let k = polygonStart + 1; k < end; k++) {
499
+ corners.push(polygonStart, k, k + 1);
500
+ polygonOfCorner.push(polygon, polygon, polygon);
501
+ }
502
+ polygonStart = end + 1;
503
+ polygon++;
504
+ }
505
+ if (corners.length === 0) {
506
+ warnings.push(`${name}: no polygons`);
507
+ continue;
508
+ }
509
+ const indexOf = (polygonVertex) => {
510
+ const raw = polygonIndex[polygonVertex];
511
+ return raw < 0 ? ~raw : raw;
512
+ };
513
+ const normalLayer = readLayer(geometry, 'LayerElementNormal', 'Normals', 'NormalsIndex', 3);
514
+ const uvLayer = readLayer(geometry, 'LayerElementUV', 'UV', 'UVIndex', 2);
515
+ const colorLayer = readLayer(geometry, 'LayerElementColor', 'Colors', 'ColorIndex', 4);
516
+ /*
517
+ * Which material each polygon wears.
518
+ *
519
+ * `LayerElementMaterial` is the one layer whose values *are* its indices, so it does not
520
+ * go through `readLayer`. `AllSame` means the whole geometry and is the common case;
521
+ * `ByPolygon` is what a mesh authored with several materials writes, and it is the reason
522
+ * the corners below are bucketed rather than emitted as a single mesh.
523
+ */
524
+ const materialElement = findChild(geometry, 'LayerElementMaterial');
525
+ const materialValues = materialElement === undefined ? null : numbers(findChild(materialElement, 'Materials'));
526
+ const perPolygon = materialElement !== undefined &&
527
+ findChild(materialElement, 'MappingInformationType')?.properties[0] ===
528
+ 'ByPolygon';
529
+ const slotOfPolygon = (at) => {
530
+ if (materialValues === null || materialValues.length === 0)
531
+ return 0;
532
+ const key = perPolygon ? Math.min(at, materialValues.length - 1) : 0;
533
+ const slot = Math.trunc(materialValues[key]);
534
+ return slot >= 0 ? slot : 0;
535
+ };
536
+ const modelId = transforms.modelOf.get(geometryId);
537
+ const worn = modelId === undefined ? [] : (materials.ofModel.get(modelId) ?? []);
538
+ /* One bucket of corners per material, so that a mesh carries exactly one surface. */
539
+ const buckets = new Map();
540
+ for (let c = 0; c < corners.length; c++) {
541
+ const slot = slotOfPolygon(polygonOfCorner[c]);
542
+ const bucket = buckets.get(slot);
543
+ if (bucket === undefined)
544
+ buckets.set(slot, [c]);
545
+ else
546
+ bucket.push(c);
547
+ }
548
+ for (const [slot, bucket] of buckets) {
549
+ const material = materials.byId.get(worn[slot] ?? -1) ?? FALLBACK_MATERIAL;
550
+ const base = material.color;
551
+ /*
552
+ * **Indexed, by the offsets a corner's attributes actually resolved to.**
553
+ *
554
+ * This loop used to write `indices[i] = i` over every corner it had fanned, so a mesh came
555
+ * back with one vertex per corner — a bought character at 64,518 vertices where its own file
556
+ * describes 12,216, five times the geometry for the same triangles. The baker welds, so a
557
+ * baked asset never showed it; anything calling `readModel` directly carried all of it.
558
+ *
559
+ * **Keyed on the values, and bucketed by control point to make that affordable.** Keying on
560
+ * the layer *offsets* instead is the obvious cheap answer and it deduplicates almost nothing:
561
+ * an exporter writing normals `ByPolygonVertex` `Direct` gives every corner its own offset
562
+ * into an array of repeated values, so a car whose corners are 1,958,016 came back as
563
+ * 1,958,002 vertices — fourteen shared out of two million. What a reader is asked for is
564
+ * whether two corners *are* the same vertex, which is a question about values.
565
+ *
566
+ * Comparing values could have been a string key per corner; it is a bucket per control point
567
+ * instead, because the candidates for any corner are the handful of vertices already emitted
568
+ * for its own control point — typically one, and never more than the seams meeting there. A
569
+ * control point split across a UV seam or a hard edge fails the comparison and stays two
570
+ * vertices, which is correct rather than merely conservative.
571
+ */
572
+ const positions = [];
573
+ const outNormals = [];
574
+ const outColors = [];
575
+ const outUvs = uvLayer === null ? null : [];
576
+ const outJoints = skin === null ? null : [];
577
+ const outWeights = skin === null ? null : [];
578
+ const indices = [];
579
+ /*
580
+ * A mesh with no normals is not deduplicated, and that is the geometry speaking rather than
581
+ * a shortcut: the normals below are computed per *face*, so two triangles meeting at a
582
+ * control point need two vertices to hold their two normals. Sharing one would average
583
+ * nothing and shade both faces with whichever was written last.
584
+ */
585
+ const shared = normalLayer === null ? null : new Map();
586
+ let droppedInfluences = false;
587
+ let rescaledInfluences = false;
588
+ for (const c of bucket) {
589
+ const polygonVertex = corners[c];
590
+ const at = polygonOfCorner[c];
591
+ const vertexIndex = indexOf(polygonVertex);
592
+ const normalAt = normalLayer === null ? -1 : layerAt(normalLayer, polygonVertex, vertexIndex, at);
593
+ const uvAt = uvLayer === null ? -1 : layerAt(uvLayer, polygonVertex, vertexIndex, at);
594
+ const colorAt = colorLayer === null ? -1 : layerAt(colorLayer, polygonVertex, vertexIndex, at);
595
+ const vx = vertices[vertexIndex * 3];
596
+ const vy = vertices[vertexIndex * 3 + 1];
597
+ const vz = vertices[vertexIndex * 3 + 2];
598
+ const px = world[0] * vx +
599
+ world[4] * vy +
600
+ world[8] * vz +
601
+ world[12];
602
+ const py = world[1] * vx +
603
+ world[5] * vy +
604
+ world[9] * vz +
605
+ world[13];
606
+ const pz = world[2] * vx +
607
+ world[6] * vy +
608
+ world[10] * vz +
609
+ world[14];
610
+ let normalX = 0;
611
+ let normalY = 0;
612
+ let normalZ = 0;
613
+ if (normalLayer !== null && normalAt >= 0) {
614
+ const nx = normalLayer.values[normalAt];
615
+ const ny = normalLayer.values[normalAt + 1];
616
+ const nz = normalLayer.values[normalAt + 2];
617
+ /* Rotated by the same basis. Scale is left out: these files scale uniformly or
618
+ not at all, and normalising after covers the rest. */
619
+ const tnx = world[0] * nx + world[4] * ny + world[8] * nz;
620
+ const tny = world[1] * nx + world[5] * ny + world[9] * nz;
621
+ const tnz = world[2] * nx + world[6] * ny + world[10] * nz;
622
+ const length = Math.hypot(tnx, tny, tnz) || 1;
623
+ normalX = tnx / length;
624
+ normalY = tny / length;
625
+ normalZ = tnz / length;
626
+ }
627
+ /* The material's colour, modulated by a vertex colour wherever the file painted one. */
628
+ let r = base[0];
629
+ let g = base[1];
630
+ let b = base[2];
631
+ if (colorLayer !== null && colorAt >= 0) {
632
+ r *= colorLayer.values[colorAt];
633
+ g *= colorLayer.values[colorAt + 1];
634
+ b *= colorLayer.values[colorAt + 2];
635
+ }
636
+ let u = 0;
637
+ let v = 0;
638
+ if (uvLayer !== null && uvAt >= 0) {
639
+ u = uvLayer.values[uvAt];
640
+ /*
641
+ * V is flipped once, here. FBX puts the origin at the bottom left and this engine
642
+ * uploads every texture with the origin at the top left, so a map applied without
643
+ * this arrives mirrored vertically. On a car that reads as badges upside down and
644
+ * tread running the wrong way, which looks like a bad texture rather than a bad
645
+ * coordinate, and so is looked for in the wrong place.
646
+ */
647
+ v = 1 - uvLayer.values[uvAt + 1];
648
+ }
649
+ const candidates = shared?.get(vertexIndex);
650
+ let found = -1;
651
+ for (const candidate of candidates ?? []) {
652
+ if (outNormals[candidate * 3] === normalX &&
653
+ outNormals[candidate * 3 + 1] === normalY &&
654
+ outNormals[candidate * 3 + 2] === normalZ &&
655
+ outColors[candidate * 3] === r &&
656
+ outColors[candidate * 3 + 1] === g &&
657
+ outColors[candidate * 3 + 2] === b &&
658
+ (outUvs === null || (outUvs[candidate * 2] === u && outUvs[candidate * 2 + 1] === v))) {
659
+ found = candidate;
660
+ break;
661
+ }
662
+ }
663
+ if (found >= 0) {
664
+ indices.push(found);
665
+ continue;
666
+ }
667
+ const emitted = positions.length / 3;
668
+ if (shared !== null) {
669
+ if (candidates === undefined)
670
+ shared.set(vertexIndex, [emitted]);
671
+ else
672
+ candidates.push(emitted);
673
+ }
674
+ indices.push(emitted);
675
+ positions.push(px, py, pz);
676
+ outNormals.push(normalX, normalY, normalZ);
677
+ outColors.push(r, g, b);
678
+ if (outUvs !== null)
679
+ outUvs.push(u, v);
680
+ if (outJoints !== null && outWeights !== null && skin !== null) {
681
+ outJoints.push(0, 0, 0, 0);
682
+ outWeights.push(0, 0, 0, 0);
683
+ const scratchJoints = new Float32Array(4);
684
+ const scratchWeights = new Float32Array(4);
685
+ const report = writeInfluences(skin.influences.get(vertexIndex), scratchJoints, scratchWeights, 0);
686
+ for (let k = 0; k < 4; k++) {
687
+ outJoints[emitted * 4 + k] = scratchJoints[k];
688
+ outWeights[emitted * 4 + k] = scratchWeights[k];
689
+ }
690
+ droppedInfluences ||= report.dropped;
691
+ rescaledInfluences ||= report.rescaled;
692
+ }
693
+ }
694
+ const count = positions.length / 3;
695
+ const outPositions = Float32Array.from(positions);
696
+ const normalsOut = Float32Array.from(outNormals);
697
+ if (normalLayer === null) {
698
+ warnings.push(`${name}: no normals, computed from the winding`);
699
+ computeFaceNormals(outPositions, normalsOut);
700
+ }
701
+ if (droppedInfluences) {
702
+ warnings.push(`${name}: a vertex is held by more than four joints; the four largest are kept and ` +
703
+ `renormalised, because four is what the skinning palette reads.`);
704
+ }
705
+ if (rescaledInfluences) {
706
+ warnings.push(`${name}: skin weights did not sum to one and were normalised.`);
707
+ }
708
+ /*
709
+ * A material's constants arrive as full arrays, which `weld` then shrinks where it can.
710
+ * That is the wrong shape for the data and knowingly so: a mesh now carries exactly one
711
+ * material, so each of these is one number repeated a million times. `MATL` is where it
712
+ * gets stored once instead, and where these become constant attributes.
713
+ */
714
+ meshes.push({
715
+ positions: outPositions,
716
+ normals: normalsOut,
717
+ colors: Float32Array.from(outColors),
718
+ emissive: new Float32Array(count).fill(material.emissive),
719
+ specular: new Float32Array(count).fill(material.specular),
720
+ roughness: new Float32Array(count).fill(material.roughness),
721
+ emissiveColor: repeatTriple(count, material.emissiveColor),
722
+ indices: Uint32Array.from(indices),
723
+ ...(outUvs === null ? {} : { uvs: Float32Array.from(outUvs) }),
724
+ ...(outJoints === null || outWeights === null
725
+ ? {}
726
+ : { joints: Float32Array.from(outJoints), weights: Float32Array.from(outWeights) }),
727
+ });
728
+ perMesh.push(material);
729
+ }
730
+ }
731
+ if (meshes.length === 0) {
732
+ /*
733
+ * **The refusal says what it found, because "no geometry" is three different faults.**
734
+ *
735
+ * It used to say only that there was none, which is where the trail ended for whoever met it:
736
+ * two rig-specific exports of one character both failed here and the message could not tell
737
+ * whether the records were missing, present but not polygon meshes, or present and empty. Each
738
+ * of those is a different next step, and the counts separate them without the reader having to
739
+ * guess which one it is looking at.
740
+ */
741
+ const found = seenGeometries === 0
742
+ ? `It carries no Geometry records at all, across ${objects.byId.size} objects: this is an ` +
743
+ `animation-only export, which is a skeleton and its curves with no mesh. The two that ` +
744
+ `ship beside a bought character for a particular engine are usually exactly that, and the ` +
745
+ `mesh is in the file next to them.`
746
+ : emptyGeometries === seenGeometries
747
+ ? `All ${seenGeometries} of its Geometry records lack Vertices or PolygonVertexIndex, so ` +
748
+ `they are blend-shape targets, patches or cages rather than polygon meshes.`
749
+ : `${seenGeometries} Geometry records were read and every one produced no polygons.`;
750
+ throw new DrftError(`fbx: no geometry found in a version ${version} file. ${found} This reader is experimental ` +
751
+ `(tier 2) — export glTF from your own software and bake that instead.`);
752
+ }
753
+ return {
754
+ meshes,
755
+ warnings,
756
+ version,
757
+ declaredUp,
758
+ materials: perMesh,
759
+ textures: [...materials.textures],
760
+ skins,
761
+ /* After the meshes, because a curve is matched to a joint and the joints are numbered by the
762
+ skins above. A file with takes and no skin has nothing for a track to drive. */
763
+ clips: skins.length === 0 ? [] : readClips(objects, jointOfModel, warnings),
764
+ unitScale,
765
+ };
766
+ }
767
+ /** A zero normal is not neutral: everything multiplies by it, so the mesh arrives black. */
768
+ function computeFaceNormals(positions, normals) {
769
+ for (let i = 0; i + 2 < positions.length / 3; i += 3) {
770
+ const ax = positions[i * 3];
771
+ const ay = positions[i * 3 + 1];
772
+ const az = positions[i * 3 + 2];
773
+ const ux = positions[(i + 1) * 3] - ax;
774
+ const uy = positions[(i + 1) * 3 + 1] - ay;
775
+ const uz = positions[(i + 1) * 3 + 2] - az;
776
+ const vx = positions[(i + 2) * 3] - ax;
777
+ const vy = positions[(i + 2) * 3 + 1] - ay;
778
+ const vz = positions[(i + 2) * 3 + 2] - az;
779
+ let nx = uy * vz - uz * vy;
780
+ let ny = uz * vx - ux * vz;
781
+ let nz = ux * vy - uy * vx;
782
+ const length = Math.hypot(nx, ny, nz) || 1;
783
+ nx /= length;
784
+ ny /= length;
785
+ nz /= length;
786
+ for (let c = 0; c < 3; c++) {
787
+ normals[(i + c) * 3] = nx;
788
+ normals[(i + c) * 3 + 1] = ny;
789
+ normals[(i + c) * 3 + 2] = nz;
790
+ }
791
+ }
792
+ }