@threenative/ueformat 0.1.1

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/index.js ADDED
@@ -0,0 +1,861 @@
1
+ import { gunzipSync } from 'fflate';
2
+ import { BufferGeometry, Float32BufferAttribute, LOD, Group, Loader, FileLoader, MeshStandardMaterial, Uint16BufferAttribute, Mesh } from 'three';
3
+
4
+ // src/errors.ts
5
+ var UEFormatError = class extends Error {
6
+ code;
7
+ offset;
8
+ constructor(code, message, offset = -1, cause) {
9
+ super(message, cause === void 0 ? void 0 : { cause });
10
+ this.name = "UEFormatError";
11
+ this.code = code;
12
+ this.offset = offset;
13
+ }
14
+ };
15
+
16
+ // src/binary.ts
17
+ var BinaryReader = class _BinaryReader {
18
+ bytes;
19
+ limits;
20
+ baseOffset;
21
+ view;
22
+ offset = 0;
23
+ constructor(bytes, limits, baseOffset = 0) {
24
+ this.bytes = bytes;
25
+ this.limits = limits;
26
+ this.baseOffset = baseOffset;
27
+ this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
28
+ }
29
+ get remaining() {
30
+ return this.bytes.length - this.offset;
31
+ }
32
+ get absoluteOffset() {
33
+ return this.baseOffset + this.offset;
34
+ }
35
+ ensure(size) {
36
+ if (!Number.isSafeInteger(size) || size < 0 || size > this.remaining) {
37
+ throw new UEFormatError(
38
+ "TRUNCATED_FILE",
39
+ `Need ${size} byte(s), but only ${this.remaining} remain`,
40
+ this.absoluteOffset
41
+ );
42
+ }
43
+ }
44
+ uint8() {
45
+ this.ensure(1);
46
+ return this.view.getUint8(this.offset++);
47
+ }
48
+ bool() {
49
+ const value = this.uint8();
50
+ if (value > 1) {
51
+ throw new UEFormatError(
52
+ "INVALID_LENGTH",
53
+ `Invalid boolean value ${value}`,
54
+ this.absoluteOffset - 1
55
+ );
56
+ }
57
+ return value === 1;
58
+ }
59
+ uint16() {
60
+ this.ensure(2);
61
+ const value = this.view.getUint16(this.offset, true);
62
+ this.offset += 2;
63
+ return value;
64
+ }
65
+ int32() {
66
+ this.ensure(4);
67
+ const value = this.view.getInt32(this.offset, true);
68
+ this.offset += 4;
69
+ return value;
70
+ }
71
+ uint32() {
72
+ this.ensure(4);
73
+ const value = this.view.getUint32(this.offset, true);
74
+ this.offset += 4;
75
+ return value;
76
+ }
77
+ float32() {
78
+ this.ensure(4);
79
+ const value = this.view.getFloat32(this.offset, true);
80
+ this.offset += 4;
81
+ return value;
82
+ }
83
+ raw(size) {
84
+ this.ensure(size);
85
+ const result = this.bytes.subarray(this.offset, this.offset + size);
86
+ this.offset += size;
87
+ return result;
88
+ }
89
+ fixedString(size) {
90
+ return new TextDecoder("utf-8", { fatal: true }).decode(this.raw(size)).replace(/\0+$/, "");
91
+ }
92
+ string() {
93
+ const start = this.absoluteOffset;
94
+ const size = this.int32();
95
+ if (size < 0 || size > this.limits.maxStringBytes) {
96
+ throw new UEFormatError("INVALID_LENGTH", `Invalid string byte length ${size}`, start);
97
+ }
98
+ try {
99
+ return new TextDecoder("utf-8", { fatal: true }).decode(this.raw(size)).replace(/\0+$/, "");
100
+ } catch (error) {
101
+ if (error instanceof UEFormatError) throw error;
102
+ throw new UEFormatError("INVALID_LENGTH", "String is not valid UTF-8", start, error);
103
+ }
104
+ }
105
+ count(label) {
106
+ const start = this.absoluteOffset;
107
+ const count = this.int32();
108
+ if (count < 0 || count > this.limits.maxArrayElements) {
109
+ throw new UEFormatError("INVALID_COUNT", `Invalid ${label} count ${count}`, start);
110
+ }
111
+ return count;
112
+ }
113
+ vector2() {
114
+ return { u: this.float32(), v: this.float32() };
115
+ }
116
+ vector3() {
117
+ return { x: this.float32(), y: this.float32(), z: this.float32() };
118
+ }
119
+ quaternion() {
120
+ return { ...this.vector3(), w: this.float32() };
121
+ }
122
+ array(label, read) {
123
+ const count = this.count(label);
124
+ const values = new Array(count);
125
+ for (let index = 0; index < count; index++) values[index] = read(this, index);
126
+ return values;
127
+ }
128
+ chunk(size) {
129
+ const start = this.absoluteOffset;
130
+ return new _BinaryReader(this.raw(size), this.limits, start);
131
+ }
132
+ };
133
+ function toUint8Array(input) {
134
+ return input instanceof ArrayBuffer ? new Uint8Array(input) : new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
135
+ }
136
+ function crc32(bytes) {
137
+ let crc = 4294967295;
138
+ for (const byte of bytes) {
139
+ crc ^= byte;
140
+ for (let bit = 0; bit < 8; bit++) {
141
+ crc = crc >>> 1 ^ 3988292384 & -(crc & 1);
142
+ }
143
+ }
144
+ return (crc ^ 4294967295) >>> 0;
145
+ }
146
+ function validateGzipTrailer(compressed, decoded) {
147
+ if (compressed.length < 18)
148
+ throw new TypeError("GZIP payload is shorter than its minimum header and trailer");
149
+ const trailer = new DataView(
150
+ compressed.buffer,
151
+ compressed.byteOffset + compressed.byteLength - 8,
152
+ 8
153
+ );
154
+ const expectedCrc = trailer.getUint32(0, true);
155
+ const expectedSize = trailer.getUint32(4, true);
156
+ if (crc32(decoded) !== expectedCrc || decoded.length >>> 0 !== expectedSize) {
157
+ throw new TypeError("GZIP CRC32 or ISIZE trailer check failed");
158
+ }
159
+ }
160
+ function decompressBody(format, compressed, uncompressedSize, zstdDecoder) {
161
+ let result;
162
+ try {
163
+ if (format === "GZIP") {
164
+ result = gunzipSync(compressed);
165
+ validateGzipTrailer(compressed, result);
166
+ } else if (format === "ZSTD") {
167
+ if (!zstdDecoder) {
168
+ throw new UEFormatError(
169
+ "INVALID_COMPRESSION",
170
+ "ZSTD body requires ParseUEModelOptions.zstdDecoder"
171
+ );
172
+ }
173
+ result = zstdDecoder(compressed, uncompressedSize);
174
+ if (!(result instanceof Uint8Array)) {
175
+ throw new TypeError("ZSTD decoder must return Uint8Array");
176
+ }
177
+ } else {
178
+ throw new UEFormatError(
179
+ "INVALID_COMPRESSION",
180
+ `Unsupported compression format ${JSON.stringify(format)}`
181
+ );
182
+ }
183
+ } catch (error) {
184
+ if (error instanceof UEFormatError) throw error;
185
+ throw new UEFormatError(
186
+ "DECOMPRESSION_FAILED",
187
+ `Failed to decompress ${format} body`,
188
+ -1,
189
+ error
190
+ );
191
+ }
192
+ if (result.length !== uncompressedSize) {
193
+ throw new UEFormatError(
194
+ "SIZE_MISMATCH",
195
+ `Decoded body has ${result.length} byte(s); header declares ${uncompressedSize}`
196
+ );
197
+ }
198
+ return result;
199
+ }
200
+
201
+ // src/parser.ts
202
+ var DEFAULT_LIMITS = {
203
+ maxStringBytes: 16 * 1024 * 1024,
204
+ maxArrayElements: 5e7,
205
+ maxAttributes: 1e5
206
+ };
207
+ function readAttributes(reader, handlers, unknown) {
208
+ const count = reader.int32();
209
+ if (count < 0 || count > reader.limits.maxAttributes) {
210
+ throw new UEFormatError(
211
+ "INVALID_COUNT",
212
+ `Invalid attribute count ${count}`,
213
+ reader.absoluteOffset - 4
214
+ );
215
+ }
216
+ for (let index = 0; index < count; index++) {
217
+ const name = reader.string();
218
+ const byteSizeOffset = reader.absoluteOffset;
219
+ const byteSize = reader.int32();
220
+ if (byteSize < 0) {
221
+ throw new UEFormatError(
222
+ "INVALID_LENGTH",
223
+ `Invalid byte size ${byteSize} for attribute ${name}`,
224
+ byteSizeOffset
225
+ );
226
+ }
227
+ const payload = reader.chunk(byteSize);
228
+ const handler = handlers[name];
229
+ if (!handler) {
230
+ unknown.push(name);
231
+ continue;
232
+ }
233
+ handler(payload);
234
+ if (payload.remaining !== 0) {
235
+ throw new UEFormatError(
236
+ "ATTRIBUTE_SIZE_MISMATCH",
237
+ `Attribute ${name} has ${payload.remaining} unread byte(s)`,
238
+ payload.absoluteOffset
239
+ );
240
+ }
241
+ }
242
+ }
243
+ function readVectorArray(reader, label) {
244
+ return reader.array(label, (item) => item.vector3());
245
+ }
246
+ function readLod(reader) {
247
+ const lod = {
248
+ name: reader.string(),
249
+ vertices: [],
250
+ normals: [],
251
+ tangents: [],
252
+ texCoords: [],
253
+ indices: [],
254
+ vertexColors: [],
255
+ materials: [],
256
+ weights: [],
257
+ morphTargets: [],
258
+ unknownAttributes: []
259
+ };
260
+ readAttributes(
261
+ reader,
262
+ {
263
+ VERTICES(payload) {
264
+ lod.vertices = readVectorArray(payload, "vertex");
265
+ },
266
+ INDICES(payload) {
267
+ lod.indices = payload.array("index", (item) => item.uint32());
268
+ },
269
+ NORMALS(payload) {
270
+ lod.normals = payload.array("normal", (item) => ({
271
+ binormalSign: item.float32(),
272
+ ...item.vector3()
273
+ }));
274
+ },
275
+ TANGENTS(payload) {
276
+ lod.tangents = readVectorArray(payload, "tangent");
277
+ },
278
+ TEXCOORDS(payload) {
279
+ lod.texCoords = payload.array("texture-coordinate channel", (item) => ({
280
+ name: item.string(),
281
+ uvs: item.array("texture coordinate", (uvReader) => uvReader.vector2())
282
+ }));
283
+ },
284
+ VERTEXCOLORS(payload) {
285
+ lod.vertexColors = payload.array("vertex-color channel", (item) => ({
286
+ name: item.string(),
287
+ colors: item.array("vertex color", (colorReader) => ({
288
+ r: colorReader.uint8(),
289
+ g: colorReader.uint8(),
290
+ b: colorReader.uint8(),
291
+ a: colorReader.uint8()
292
+ }))
293
+ }));
294
+ },
295
+ MATERIALS(payload) {
296
+ lod.materials = payload.array("material", (item) => ({
297
+ materialName: item.string(),
298
+ materialPath: item.string(),
299
+ firstIndex: item.int32(),
300
+ numFaces: item.int32()
301
+ }));
302
+ },
303
+ WEIGHTS(payload) {
304
+ lod.weights = payload.array("weight", (item) => ({
305
+ boneIndex: item.uint16(),
306
+ vertexIndex: item.int32(),
307
+ weight: item.float32()
308
+ }));
309
+ },
310
+ MORPHTARGETS(payload) {
311
+ lod.morphTargets = payload.array("morph target", (item) => ({
312
+ name: item.string(),
313
+ deltas: item.array("morph delta", (deltaReader) => ({
314
+ positionDelta: deltaReader.vector3(),
315
+ tangentZDelta: deltaReader.vector3(),
316
+ vertexIndex: deltaReader.uint32()
317
+ }))
318
+ }));
319
+ }
320
+ },
321
+ lod.unknownAttributes
322
+ );
323
+ return lod;
324
+ }
325
+ function readBone(reader) {
326
+ return {
327
+ name: reader.string(),
328
+ parentIndex: reader.int32(),
329
+ position: reader.vector3(),
330
+ orientation: reader.quaternion(),
331
+ scale: reader.vector3()
332
+ };
333
+ }
334
+ function readSkeleton(reader) {
335
+ const skeleton = {
336
+ metadata: "",
337
+ bones: [],
338
+ sockets: [],
339
+ virtualBones: [],
340
+ unknownAttributes: []
341
+ };
342
+ readAttributes(
343
+ reader,
344
+ {
345
+ METADATA(payload) {
346
+ skeleton.metadata = payload.string();
347
+ },
348
+ BONES(payload) {
349
+ skeleton.bones = payload.array("bone", (item) => readBone(item));
350
+ },
351
+ SOCKETS(payload) {
352
+ skeleton.sockets = payload.array("socket", (item) => ({
353
+ name: item.string(),
354
+ boneName: item.string(),
355
+ relativeLocation: item.vector3(),
356
+ relativeRotation: item.quaternion(),
357
+ relativeScale: item.vector3()
358
+ }));
359
+ },
360
+ VIRTUALBONES(payload) {
361
+ skeleton.virtualBones = payload.array("virtual bone", (item) => ({
362
+ sourceBoneName: item.string(),
363
+ targetBoneName: item.string(),
364
+ virtualBoneName: item.string()
365
+ }));
366
+ }
367
+ },
368
+ skeleton.unknownAttributes
369
+ );
370
+ return skeleton;
371
+ }
372
+ function readCollision(reader) {
373
+ return {
374
+ name: reader.string(),
375
+ vertices: readVectorArray(reader, "collision vertex"),
376
+ indices: reader.array("collision index", (item) => item.uint32())
377
+ };
378
+ }
379
+ function parseUEModel(input, options = {}) {
380
+ const limits = {
381
+ maxStringBytes: options.maxStringBytes ?? DEFAULT_LIMITS.maxStringBytes,
382
+ maxArrayElements: options.maxArrayElements ?? DEFAULT_LIMITS.maxArrayElements,
383
+ maxAttributes: options.maxAttributes ?? DEFAULT_LIMITS.maxAttributes
384
+ };
385
+ const reader = new BinaryReader(toUint8Array(input), limits);
386
+ const magic = reader.fixedString(8);
387
+ if (magic !== "UEFORMAT") {
388
+ throw new UEFormatError(
389
+ "INVALID_MAGIC",
390
+ `Expected UEFORMAT, received ${JSON.stringify(magic)}`,
391
+ 0
392
+ );
393
+ }
394
+ const identifier = reader.string();
395
+ if (identifier !== "UEMODEL") {
396
+ throw new UEFormatError(
397
+ "INVALID_IDENTIFIER",
398
+ `Expected UEMODEL, received ${JSON.stringify(identifier)}`,
399
+ 8
400
+ );
401
+ }
402
+ const fileVersion = reader.uint8();
403
+ if (fileVersion !== 10) {
404
+ throw new UEFormatError(
405
+ "UNSUPPORTED_VERSION",
406
+ `Only UEFormat v10 is supported; received v${fileVersion}`,
407
+ reader.absoluteOffset - 1
408
+ );
409
+ }
410
+ const objectName = reader.string();
411
+ const objectPath = reader.string();
412
+ const compressed = reader.bool();
413
+ let bodyReader = reader;
414
+ let compression = null;
415
+ if (compressed) {
416
+ const format = reader.string();
417
+ const uncompressedSize = reader.int32();
418
+ const compressedSize = reader.int32();
419
+ if (uncompressedSize < 0 || compressedSize < 0) {
420
+ throw new UEFormatError(
421
+ "INVALID_LENGTH",
422
+ "Compression sizes must be non-negative",
423
+ reader.absoluteOffset - 8
424
+ );
425
+ }
426
+ if (compressedSize !== reader.remaining) {
427
+ throw new UEFormatError(
428
+ "SIZE_MISMATCH",
429
+ `Compressed body has ${reader.remaining} byte(s); header declares ${compressedSize}`,
430
+ reader.absoluteOffset
431
+ );
432
+ }
433
+ if (format !== "GZIP" && format !== "ZSTD") {
434
+ throw new UEFormatError(
435
+ "INVALID_COMPRESSION",
436
+ `Unsupported compression format ${JSON.stringify(format)}`,
437
+ reader.absoluteOffset
438
+ );
439
+ }
440
+ const decoded = decompressBody(
441
+ format,
442
+ reader.raw(compressedSize),
443
+ uncompressedSize,
444
+ options.zstdDecoder
445
+ );
446
+ bodyReader = new BinaryReader(decoded, limits);
447
+ compression = { format, uncompressedSize, compressedSize };
448
+ }
449
+ const header = {
450
+ magic: "UEFORMAT",
451
+ identifier: "UEMODEL",
452
+ fileVersion: 10,
453
+ objectName,
454
+ objectPath,
455
+ compression
456
+ };
457
+ const model = {
458
+ header,
459
+ lods: [],
460
+ skeleton: null,
461
+ collision: [],
462
+ unknownAttributes: []
463
+ };
464
+ readAttributes(
465
+ bodyReader,
466
+ {
467
+ LODS(payload) {
468
+ model.lods = payload.array("LOD", (item) => readLod(item));
469
+ },
470
+ SKELETON(payload) {
471
+ model.skeleton = readSkeleton(payload);
472
+ },
473
+ COLLISION(payload) {
474
+ model.collision = payload.array("collision mesh", (item) => readCollision(item));
475
+ }
476
+ },
477
+ model.unknownAttributes
478
+ );
479
+ if (bodyReader.remaining !== 0) {
480
+ throw new UEFormatError(
481
+ "ATTRIBUTE_SIZE_MISMATCH",
482
+ `Model has ${bodyReader.remaining} trailing byte(s)`,
483
+ bodyReader.absoluteOffset
484
+ );
485
+ }
486
+ return model;
487
+ }
488
+ function resolveOptions(options) {
489
+ const coordinateSystem = options.coordinateSystem ?? "three-y-up";
490
+ const unitScale = options.unitScale ?? 0.01;
491
+ if (!Number.isFinite(unitScale) || unitScale <= 0) {
492
+ throw new UEFormatError(
493
+ "INVALID_GEOMETRY",
494
+ `unitScale must be positive; received ${unitScale}`
495
+ );
496
+ }
497
+ const repairWinding = options.repairWinding ?? "auto";
498
+ const flipWinding = repairWinding === "always" || repairWinding === "auto" && coordinateSystem === "three-y-up";
499
+ return {
500
+ coordinateSystem,
501
+ unitScale,
502
+ flipV: options.flipV ?? coordinateSystem === "three-y-up",
503
+ flipWinding,
504
+ ...options.lodDistances === void 0 ? {} : { lodDistances: options.lodDistances },
505
+ // The default matches three.js's own GLTFLoader: a plain MeshStandardMaterial named after
506
+ // the slot. No colour, roughness or metalness is decided here — every look parameter comes
507
+ // from the game, through materialFactory.
508
+ materialFactory: options.materialFactory ?? ((slot) => new MeshStandardMaterial({ name: slot.materialName }))
509
+ };
510
+ }
511
+ function convertVector(value, options, scale) {
512
+ const amount = scale;
513
+ const clean = (component) => Object.is(component, -0) ? 0 : component;
514
+ if (options.coordinateSystem === "unreal-z-up") {
515
+ return [clean(value.x * amount), clean(value.y * amount), clean(value.z * amount)];
516
+ }
517
+ return [clean(value.y * amount), clean(value.z * amount), clean(-value.x * amount)];
518
+ }
519
+ function flattenVectors(values, options, scale) {
520
+ const result = new Array(values.length * 3);
521
+ let offset = 0;
522
+ for (const value of values) {
523
+ const converted = convertVector(value, options, scale);
524
+ result[offset++] = converted[0];
525
+ result[offset++] = converted[1];
526
+ result[offset++] = converted[2];
527
+ }
528
+ return result;
529
+ }
530
+ function transformedIndices(indices, flipWinding) {
531
+ const result = Array.from(indices);
532
+ if (flipWinding) {
533
+ for (let index = 0; index + 2 < result.length; index += 3) {
534
+ const second = result[index + 1] ?? 0;
535
+ result[index + 1] = result[index + 2] ?? 0;
536
+ result[index + 2] = second;
537
+ }
538
+ }
539
+ return result;
540
+ }
541
+ function validateLod(lod) {
542
+ const vertexCount = lod.vertices.length;
543
+ if (vertexCount === 0) {
544
+ throw new UEFormatError("INVALID_GEOMETRY", `${lod.name} contains no vertices`);
545
+ }
546
+ if (lod.indices.length === 0 || lod.indices.length % 3 !== 0) {
547
+ throw new UEFormatError(
548
+ "INVALID_GEOMETRY",
549
+ `${lod.name} index count must be a non-zero multiple of 3`
550
+ );
551
+ }
552
+ for (const index of lod.indices) {
553
+ if (!Number.isInteger(index) || index < 0 || index >= vertexCount) {
554
+ throw new UEFormatError(
555
+ "INVALID_GEOMETRY",
556
+ `${lod.name} contains out-of-range index ${index}`
557
+ );
558
+ }
559
+ }
560
+ for (const [label, count] of [
561
+ ["normal", lod.normals.length],
562
+ ["tangent", lod.tangents.length]
563
+ ]) {
564
+ if (count !== 0 && count !== vertexCount) {
565
+ throw new UEFormatError(
566
+ "INVALID_GEOMETRY",
567
+ `${lod.name} ${label} count ${count} does not match ${vertexCount} vertices`
568
+ );
569
+ }
570
+ }
571
+ for (const channel of lod.texCoords) {
572
+ if (channel.uvs.length !== vertexCount) {
573
+ throw new UEFormatError(
574
+ "INVALID_GEOMETRY",
575
+ `${lod.name} UV channel ${channel.name} has the wrong length`
576
+ );
577
+ }
578
+ }
579
+ for (const channel of lod.vertexColors) {
580
+ if (channel.colors.length !== vertexCount) {
581
+ throw new UEFormatError(
582
+ "INVALID_GEOMETRY",
583
+ `${lod.name} color channel ${channel.name} has the wrong length`
584
+ );
585
+ }
586
+ }
587
+ for (const section of lod.materials) {
588
+ const count = section.numFaces * 3;
589
+ if (!Number.isInteger(section.firstIndex) || !Number.isInteger(section.numFaces) || section.firstIndex < 0 || section.numFaces < 0 || section.firstIndex + count > lod.indices.length) {
590
+ throw new UEFormatError(
591
+ "INVALID_GEOMETRY",
592
+ `${lod.name} has an invalid material section ${section.materialName}`
593
+ );
594
+ }
595
+ }
596
+ for (const target of lod.morphTargets) {
597
+ for (const delta of target.deltas) {
598
+ if (delta.vertexIndex < 0 || delta.vertexIndex >= vertexCount) {
599
+ throw new UEFormatError(
600
+ "INVALID_GEOMETRY",
601
+ `${lod.name} morph ${target.name} references vertex ${delta.vertexIndex}`
602
+ );
603
+ }
604
+ }
605
+ }
606
+ for (const weight of lod.weights) {
607
+ if (weight.vertexIndex < 0 || weight.vertexIndex >= vertexCount || weight.boneIndex < 0) {
608
+ throw new UEFormatError("INVALID_GEOMETRY", `${lod.name} contains an invalid skin weight`);
609
+ }
610
+ }
611
+ }
612
+ function addUvAttributes(geometry, lod, options) {
613
+ lod.texCoords.forEach((channel, channelIndex) => {
614
+ const values = new Array(channel.uvs.length * 2);
615
+ let offset = 0;
616
+ for (const uv of channel.uvs) {
617
+ values[offset++] = uv.u;
618
+ values[offset++] = options.flipV ? 1 - uv.v : uv.v;
619
+ }
620
+ geometry.setAttribute(
621
+ channelIndex === 0 ? "uv" : `uv${channelIndex}`,
622
+ new Float32BufferAttribute(values, 2)
623
+ );
624
+ });
625
+ }
626
+ function addColorAttributes(geometry, lod) {
627
+ lod.vertexColors.forEach((channel, channelIndex) => {
628
+ const values = new Array(channel.colors.length * 3);
629
+ let offset = 0;
630
+ for (const color of channel.colors) {
631
+ values[offset++] = color.r / 255;
632
+ values[offset++] = color.g / 255;
633
+ values[offset++] = color.b / 255;
634
+ }
635
+ geometry.setAttribute(
636
+ channelIndex === 0 ? "color" : `color${channelIndex}`,
637
+ new Float32BufferAttribute(values, 3)
638
+ );
639
+ });
640
+ }
641
+ function addMorphAttributes(geometry, lod, options) {
642
+ if (lod.morphTargets.length === 0) return;
643
+ geometry.morphAttributes.position = lod.morphTargets.map((target) => {
644
+ const values = new Float32Array(lod.vertices.length * 3);
645
+ for (const delta of target.deltas) {
646
+ const converted = convertVector(delta.positionDelta, options, options.unitScale);
647
+ const offset = delta.vertexIndex * 3;
648
+ values[offset] = converted[0];
649
+ values[offset + 1] = converted[1];
650
+ values[offset + 2] = converted[2];
651
+ }
652
+ const attribute = new Float32BufferAttribute(values, 3);
653
+ attribute.name = target.name;
654
+ return attribute;
655
+ });
656
+ geometry.morphTargetsRelative = true;
657
+ }
658
+ function addSkinAttributes(geometry, lod) {
659
+ if (lod.weights.length === 0) return;
660
+ const perVertex = /* @__PURE__ */ new Map();
661
+ for (const weight of lod.weights) {
662
+ const influences = perVertex.get(weight.vertexIndex) ?? [];
663
+ influences.push({ bone: weight.boneIndex, weight: weight.weight });
664
+ perVertex.set(weight.vertexIndex, influences);
665
+ }
666
+ const indices = new Uint16Array(lod.vertices.length * 4);
667
+ const weights = new Float32Array(lod.vertices.length * 4);
668
+ perVertex.forEach((influences, vertexIndex) => {
669
+ const selected = influences.filter((influence) => Number.isFinite(influence.weight) && influence.weight > 0).sort((left, right) => right.weight - left.weight).slice(0, 4);
670
+ const total = selected.reduce((sum, influence) => sum + influence.weight, 0);
671
+ selected.forEach((influence, influenceIndex) => {
672
+ const offset = vertexIndex * 4 + influenceIndex;
673
+ indices[offset] = influence.bone;
674
+ weights[offset] = total === 0 ? 0 : influence.weight / total;
675
+ });
676
+ });
677
+ geometry.setAttribute("skinIndex", new Uint16BufferAttribute(indices, 4));
678
+ geometry.setAttribute("skinWeight", new Float32BufferAttribute(weights, 4));
679
+ }
680
+ function createThreeGeometry(lod, adapterOptions = {}) {
681
+ validateLod(lod);
682
+ const options = resolveOptions(adapterOptions);
683
+ const geometry = new BufferGeometry();
684
+ geometry.name = lod.name;
685
+ geometry.setAttribute(
686
+ "position",
687
+ new Float32BufferAttribute(flattenVectors(lod.vertices, options, options.unitScale), 3)
688
+ );
689
+ geometry.setIndex(transformedIndices(lod.indices, options.flipWinding));
690
+ if (lod.normals.length > 0) {
691
+ geometry.setAttribute(
692
+ "normal",
693
+ new Float32BufferAttribute(flattenVectors(lod.normals, options, 1), 3)
694
+ );
695
+ } else {
696
+ geometry.computeVertexNormals();
697
+ }
698
+ if (lod.tangents.length > 0) {
699
+ const tangents = new Array(lod.tangents.length * 4);
700
+ let offset = 0;
701
+ lod.tangents.forEach((tangent, index) => {
702
+ const converted = convertVector(tangent, options, 1);
703
+ tangents[offset++] = converted[0];
704
+ tangents[offset++] = converted[1];
705
+ tangents[offset++] = converted[2];
706
+ tangents[offset++] = (lod.normals[index]?.binormalSign ?? 1) * (options.flipWinding ? -1 : 1);
707
+ });
708
+ geometry.setAttribute("tangent", new Float32BufferAttribute(tangents, 4));
709
+ }
710
+ addUvAttributes(geometry, lod, options);
711
+ addColorAttributes(geometry, lod);
712
+ addMorphAttributes(geometry, lod, options);
713
+ addSkinAttributes(geometry, lod);
714
+ lod.materials.forEach((section, materialIndex) => {
715
+ geometry.addGroup(section.firstIndex, section.numFaces * 3, materialIndex);
716
+ });
717
+ geometry.userData.ue = {
718
+ lodName: lod.name,
719
+ materialSlots: lod.materials,
720
+ vertexColorChannels: lod.vertexColors.map((channel) => channel.name),
721
+ uvChannels: lod.texCoords.map((channel) => channel.name)
722
+ };
723
+ geometry.computeBoundingBox();
724
+ geometry.computeBoundingSphere();
725
+ return geometry;
726
+ }
727
+ function materialSlots(lod) {
728
+ if (lod.materials.length > 0) return lod.materials;
729
+ return [
730
+ {
731
+ materialName: "DefaultMaterial",
732
+ materialPath: "",
733
+ firstIndex: 0,
734
+ numFaces: lod.indices.length / 3
735
+ }
736
+ ];
737
+ }
738
+ function createMesh(lod, options) {
739
+ const geometry = createThreeGeometry(lod, options);
740
+ const materials = materialSlots(lod).map(
741
+ (slot, index) => options.materialFactory(slot, index, lod)
742
+ );
743
+ const mesh = new Mesh(geometry, materials.length === 1 ? materials[0] : materials);
744
+ mesh.name = lod.name;
745
+ mesh.userData.ue = { lod };
746
+ return mesh;
747
+ }
748
+ function createCollisionGeometry(collision, options) {
749
+ const pseudoLod = {
750
+ name: collision.name,
751
+ vertices: collision.vertices,
752
+ normals: [],
753
+ tangents: [],
754
+ texCoords: [],
755
+ indices: collision.indices,
756
+ vertexColors: [],
757
+ materials: [],
758
+ weights: [],
759
+ morphTargets: []};
760
+ return createThreeGeometry(pseudoLod, options);
761
+ }
762
+ function createThreeObject(model, adapterOptions = {}) {
763
+ const options = resolveOptions(adapterOptions);
764
+ let object;
765
+ if (model.lods.length > 1) {
766
+ const lodObject = new LOD();
767
+ model.lods.forEach((lod, index) => {
768
+ const defaultDistance = index === 0 ? 0 : 10 * 2 ** (index - 1);
769
+ const distance = options.lodDistances?.[index] ?? defaultDistance;
770
+ if (!Number.isFinite(distance) || distance < 0) {
771
+ throw new UEFormatError("INVALID_GEOMETRY", `LOD distance ${distance} is invalid`);
772
+ }
773
+ lodObject.addLevel(createMesh(lod, options), distance);
774
+ });
775
+ object = lodObject;
776
+ } else {
777
+ const group = new Group();
778
+ if (model.lods[0]) group.add(createMesh(model.lods[0], options));
779
+ object = group;
780
+ }
781
+ object.name = model.header.objectName;
782
+ const collisionGeometries = model.collision.map(
783
+ (collision) => createCollisionGeometry(collision, options)
784
+ );
785
+ object.userData.ue = {
786
+ header: model.header,
787
+ skeleton: model.skeleton,
788
+ collisionGeometries,
789
+ source: model
790
+ };
791
+ return object;
792
+ }
793
+ var UEFormatLoader = class extends Loader {
794
+ options;
795
+ constructor(manager, options = {}) {
796
+ super(manager);
797
+ this.options = options;
798
+ }
799
+ parse(data) {
800
+ return createThreeObject(parseUEModel(data, this.options.parse), this.options.three);
801
+ }
802
+ load(url, onLoad, onProgress, onError) {
803
+ const fileLoader = new FileLoader(this.manager);
804
+ fileLoader.setPath(this.path);
805
+ fileLoader.setRequestHeader(this.requestHeader);
806
+ fileLoader.setWithCredentials(this.withCredentials);
807
+ fileLoader.setResponseType("arraybuffer");
808
+ fileLoader.load(
809
+ url,
810
+ (data) => {
811
+ try {
812
+ if (!(data instanceof ArrayBuffer))
813
+ throw new TypeError("Expected FileLoader to return an ArrayBuffer");
814
+ onLoad(this.parse(data));
815
+ } catch (error) {
816
+ onError?.(error);
817
+ }
818
+ },
819
+ onProgress,
820
+ onError
821
+ );
822
+ return this;
823
+ }
824
+ };
825
+
826
+ // src/summary.ts
827
+ function summarizeUEModel(model) {
828
+ return {
829
+ objectName: model.header.objectName,
830
+ objectPath: model.header.objectPath,
831
+ formatVersion: model.header.fileVersion,
832
+ compression: model.header.compression?.format ?? null,
833
+ lods: model.lods.map((lod) => ({
834
+ name: lod.name,
835
+ vertices: lod.vertices.length,
836
+ triangles: lod.indices.length / 3,
837
+ materials: lod.materials.map((material) => material.materialName),
838
+ uvChannels: lod.texCoords.map((channel) => channel.name),
839
+ vertexColorChannels: lod.vertexColors.map((channel) => channel.name),
840
+ weights: lod.weights.length,
841
+ morphTargets: lod.morphTargets.map((target) => target.name)
842
+ })),
843
+ skeleton: model.skeleton === null ? null : {
844
+ bones: model.skeleton.bones.length,
845
+ sockets: model.skeleton.sockets.length,
846
+ virtualBones: model.skeleton.virtualBones.length
847
+ },
848
+ collisionMeshes: model.collision.map((collision) => ({
849
+ name: collision.name,
850
+ vertices: collision.vertices.length,
851
+ triangles: collision.indices.length / 3
852
+ })),
853
+ unknownAttributes: [
854
+ ...model.unknownAttributes,
855
+ ...model.lods.flatMap((lod) => lod.unknownAttributes.map((name) => `${lod.name}:${name}`)),
856
+ ...model.skeleton?.unknownAttributes.map((name) => `SKELETON:${name}`) ?? []
857
+ ]
858
+ };
859
+ }
860
+
861
+ export { UEFormatError, UEFormatLoader, createThreeGeometry, createThreeObject, parseUEModel, summarizeUEModel };