@flighthq/scene3d-formats 0.2.1-next.840.857425c

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 (74) hide show
  1. package/dist/awd2Inflate.d.ts +4 -0
  2. package/dist/awd2Inflate.d.ts.map +1 -0
  3. package/dist/awd2Inflate.js +249 -0
  4. package/dist/awd2Inflate.js.map +1 -0
  5. package/dist/awd2Parse.d.ts +7 -0
  6. package/dist/awd2Parse.d.ts.map +1 -0
  7. package/dist/awd2Parse.js +1533 -0
  8. package/dist/awd2Parse.js.map +1 -0
  9. package/dist/awd2Schema.d.ts +44 -0
  10. package/dist/awd2Schema.d.ts.map +1 -0
  11. package/dist/awd2Schema.js +67 -0
  12. package/dist/awd2Schema.js.map +1 -0
  13. package/dist/gltfParse.d.ts +9 -0
  14. package/dist/gltfParse.d.ts.map +1 -0
  15. package/dist/gltfParse.js +1456 -0
  16. package/dist/gltfParse.js.map +1 -0
  17. package/dist/gltfPunctualLights.d.ts +3 -0
  18. package/dist/gltfPunctualLights.d.ts.map +1 -0
  19. package/dist/gltfPunctualLights.js +103 -0
  20. package/dist/gltfPunctualLights.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/md2Parse.d.ts +5 -0
  26. package/dist/md2Parse.d.ts.map +1 -0
  27. package/dist/md2Parse.js +408 -0
  28. package/dist/md2Parse.js.map +1 -0
  29. package/dist/md2Schema.d.ts +12 -0
  30. package/dist/md2Schema.d.ts.map +1 -0
  31. package/dist/md2Schema.js +193 -0
  32. package/dist/md2Schema.js.map +1 -0
  33. package/dist/md5AnimParse.d.ts +3 -0
  34. package/dist/md5AnimParse.d.ts.map +1 -0
  35. package/dist/md5AnimParse.js +385 -0
  36. package/dist/md5AnimParse.js.map +1 -0
  37. package/dist/md5Parse.d.ts +6 -0
  38. package/dist/md5Parse.d.ts.map +1 -0
  39. package/dist/md5Parse.js +635 -0
  40. package/dist/md5Parse.js.map +1 -0
  41. package/dist/mtlParse.d.ts +3 -0
  42. package/dist/mtlParse.d.ts.map +1 -0
  43. package/dist/mtlParse.js +256 -0
  44. package/dist/mtlParse.js.map +1 -0
  45. package/dist/objParse.d.ts +5 -0
  46. package/dist/objParse.d.ts.map +1 -0
  47. package/dist/objParse.js +464 -0
  48. package/dist/objParse.js.map +1 -0
  49. package/dist/sceneSkeleton.d.ts +3 -0
  50. package/dist/sceneSkeleton.d.ts.map +1 -0
  51. package/dist/sceneSkeleton.js +34 -0
  52. package/dist/sceneSkeleton.js.map +1 -0
  53. package/dist/shared.d.ts +18 -0
  54. package/dist/shared.d.ts.map +1 -0
  55. package/dist/shared.js +143 -0
  56. package/dist/shared.js.map +1 -0
  57. package/dist/threeDsParse.d.ts +4 -0
  58. package/dist/threeDsParse.d.ts.map +1 -0
  59. package/dist/threeDsParse.js +718 -0
  60. package/dist/threeDsParse.js.map +1 -0
  61. package/package.json +55 -0
  62. package/src/awd2Inflate.test.ts +118 -0
  63. package/src/awd2Parse.test.ts +2431 -0
  64. package/src/gltfParse.test.ts +2339 -0
  65. package/src/gltfPunctualLights.test.ts +184 -0
  66. package/src/gltfTreeShaking.test.ts +45 -0
  67. package/src/md2Parse.test.ts +1033 -0
  68. package/src/md5AnimParse.test.ts +673 -0
  69. package/src/md5Parse.test.ts +1094 -0
  70. package/src/mtlParse.test.ts +244 -0
  71. package/src/objParse.test.ts +690 -0
  72. package/src/sceneSkeleton.test.ts +35 -0
  73. package/src/shared.test.ts +210 -0
  74. package/src/threeDsParse.test.ts +1089 -0
@@ -0,0 +1,1533 @@
1
+ import { createAnimationChannel, createAnimationClip, createAnimationTrack } from '@flighthq/animation';
2
+ import { copyMatrix4, createMatrix4, createTransform3D, decomposeMatrix4ToTransform3D, inverseMatrix4, multiplyMatrix4, } from '@flighthq/geometry';
3
+ import { detectImageMimeType } from '@flighthq/image-codec';
4
+ import { reportImportDiagnostic } from '@flighthq/importdiagnostics';
5
+ import { CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT, computeMeshGeometryNormals, computeMeshGeometryTangents, createMeshGeometry, } from '@flighthq/mesh';
6
+ import { createScene3DFromDocument } from '@flighthq/scene3d';
7
+ import { createShadedMaterial } from '@flighthq/shading';
8
+ import { ImportDiagnosticSeverity, MeshKind, Scene3DAnimationPathRotation, Scene3DAnimationPathScale, Scene3DAnimationPathTranslation, Node3DKind, } from '@flighthq/types';
9
+ import { AWD2_BLOCK_CONTAINER, AWD2_BLOCK_HEADER_BYTES, AWD2_BLOCK_MATERIAL, AWD2_BLOCK_MESH_INSTANCE, AWD2_BLOCK_SKELETON, AWD2_BLOCK_SKELETON_ANIMATION, AWD2_BLOCK_SKELETON_POSE, AWD2_BLOCK_TEXTURE, AWD2_BLOCK_TRIANGLE_GEOMETRY, AWD2_COMPRESSION_NONE, AWD2_DATA_FLOAT32, AWD2_DATA_FLOAT64, AWD2_DATA_INT16, AWD2_DATA_INT32, AWD2_DATA_INT8, AWD2_DATA_UINT16, AWD2_DATA_UINT32, AWD2_DATA_UINT8, AWD2_FORMAT_VERSION, AWD2_HEADER_BYTES, AWD2_MAGIC_0, AWD2_MAGIC_1, AWD2_MAGIC_2, AWD2_MATERIAL_PROP_ALPHA, AWD2_MATERIAL_PROP_COLOR, AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, AWD2_MATERIAL_PROP_NORMAL_TEXTURE, AWD2_NAMESPACE_CORE, AWD2_STREAM_INDICES, AWD2_STREAM_JOINT_INDICES, AWD2_STREAM_JOINT_WEIGHTS, AWD2_STREAM_NORMALS, AWD2_STREAM_POSITIONS, AWD2_STREAM_TANGENTS, AWD2_STREAM_UVS, AWD2_TEXTURE_TYPE_EMBEDDED, AWD2_VERSION_MAJOR_OFFSET, } from './awd2Schema';
10
+ import { CANONICAL_FLOATS_PER_VERTEX, CANONICAL_LAYOUT, convertTransformLhToRh, createEmbeddedTextureRef, createExternalTextureRef, negateVec3Z, packSkinInfluences, reverseTriangleWinding, SKINNED_FLOATS_PER_VERTEX, } from './shared';
11
+ // Parses an Away3D AWD 2.x binary file into a Scene3D. Convenience over `createScene3DFromDocument(parseAwd2
12
+ // (bytes, diagnostics))`. See parseAwd2 for the import model.
13
+ export function createScene3DFromAwd2(bytes, diagnostics) {
14
+ return createScene3DFromDocument(parseAwd2(bytes, diagnostics));
15
+ }
16
+ // Parses an Away3D AWD 2.x binary file into a format-neutral Scene3DDocument. The 12-byte header (magic
17
+ // `AWD`, version, flags, compression, body length) is validated, then the block stream is walked to
18
+ // extract geometry blocks (type 1), container blocks (type 22), mesh-instance blocks (type 23), material
19
+ // blocks (type 81), texture blocks (type 82), and the skeleton block (type 101). Mesh instances reference
20
+ // geometry and material blocks by block ID; materials reference texture blocks the same way.
21
+ //
22
+ // The file's skeleton (if any) becomes a skeleton-group + joint node subtree in the document node table
23
+ // (`nodes`) plus one entry in `skins` (its `joints` are those joint node indices, its `inverseBind` the
24
+ // AWD joint matrices, which ARE the inverse bind pose); each skinned sub-mesh's document mesh names that
25
+ // skin by index. Containers become group nodes, mesh instances become mesh nodes (one geometry → one mesh
26
+ // node carrying the instance name; several geometries → a named group of anonymous per-subset mesh nodes),
27
+ // and parenting is expressed through `children` index lists with the unparented nodes as scene roots. Each
28
+ // AWD material becomes a ShadedMaterial in the document `materials` table — AWD's AwayJS MethodMaterial model
29
+ // (BlinnPhong base + method array) maps structurally onto ShadedMaterial (base + modifier stack); see the
30
+ // resolveAwdMaterial note for why the mapping is uniform. It carries the diffuse color+alpha and/or a
31
+ // diffuseMap/normalMap referencing an unresolved AWD texture ImageResourceReference — the parser references,
32
+ // it does not fetch or decode; the resolution is
33
+ // @flighthq/scene3d-resources's explicit pass. The file's skeleton animations become document `animations`
34
+ // whose channels bind by joint node index. Assemble into a live Scene3D with `createScene3DFromDocument`.
35
+ //
36
+ // A compressed body (Away3D's exporter default — deflate or LZMA) is inflated first when a decompressor
37
+ // has been registered for that compression method via `registerAwd2Decompressor`; with no codec registered
38
+ // the file records a diagnostic and returns an empty document. Malformed input records a diagnostic and returns empty rather
39
+ // than throwing.
40
+ export function parseAwd2(bytes, diagnostics) {
41
+ const input = bytes;
42
+ if (input.byteLength < AWD2_HEADER_BYTES) {
43
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.header-too-short', 'parseAwd2');
44
+ return emptyAwdDocument();
45
+ }
46
+ if (input[0] !== AWD2_MAGIC_0 || input[1] !== AWD2_MAGIC_1 || input[2] !== AWD2_MAGIC_2) {
47
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.bad-magic', 'parseAwd2');
48
+ return emptyAwdDocument();
49
+ }
50
+ if (!isAwd2Version(input, diagnostics))
51
+ return emptyAwdDocument();
52
+ // A compressed body is inflated (via a registered decompressor) and spliced back behind the header so
53
+ // the block walk below is identical for compressed and uncompressed input; bails to empty when no codec
54
+ // is registered for the file's compression method.
55
+ const rehydrated = rehydrateAwdBody(input, diagnostics);
56
+ if (rehydrated === null)
57
+ return emptyAwdDocument();
58
+ const source = rehydrated.source;
59
+ const view = rehydrated.view;
60
+ const bodyLength = view.getUint32(8, true);
61
+ const bodyEnd = Math.min(AWD2_HEADER_BYTES + bodyLength, source.byteLength);
62
+ const geometryBlocks = new Map();
63
+ const containerBlocks = new Map();
64
+ const meshInstanceBlocks = new Map();
65
+ const materialBlocks = new Map();
66
+ const textureBlocks = new Map();
67
+ const skeletonBlocks = new Map();
68
+ let offset = AWD2_HEADER_BYTES;
69
+ while (offset + AWD2_BLOCK_HEADER_BYTES <= bodyEnd) {
70
+ const blockId = view.getUint32(offset, true);
71
+ const namespace = source[offset + 4];
72
+ const blockType = source[offset + 5];
73
+ const blockFlags = source[offset + 6];
74
+ const blockLength = view.getUint32(offset + 7, true);
75
+ const blockDataStart = offset + AWD2_BLOCK_HEADER_BYTES;
76
+ if (blockDataStart + blockLength > bodyEnd) {
77
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.block-length-past-end', 'parseAwd2');
78
+ break;
79
+ }
80
+ const matrixWide = (blockFlags & 1) !== 0;
81
+ const geometryWide = (blockFlags & 2) !== 0;
82
+ if (namespace === AWD2_NAMESPACE_CORE) {
83
+ if (blockType === AWD2_BLOCK_TRIANGLE_GEOMETRY) {
84
+ const geoms = parseTriangleGeometryBlock(view, source, blockDataStart, blockDataStart + blockLength, geometryWide, diagnostics);
85
+ geometryBlocks.set(blockId, geoms);
86
+ }
87
+ else if (blockType === AWD2_BLOCK_CONTAINER) {
88
+ const container = parseContainerBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide, diagnostics);
89
+ if (container !== null)
90
+ containerBlocks.set(blockId, container);
91
+ }
92
+ else if (blockType === AWD2_BLOCK_MESH_INSTANCE) {
93
+ const meshInst = parseMeshInstanceBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide, diagnostics);
94
+ if (meshInst !== null)
95
+ meshInstanceBlocks.set(blockId, meshInst);
96
+ }
97
+ else if (blockType === AWD2_BLOCK_MATERIAL) {
98
+ const material = parseMaterialBlock(view, source, blockDataStart, blockDataStart + blockLength, diagnostics);
99
+ if (material !== null)
100
+ materialBlocks.set(blockId, material);
101
+ }
102
+ else if (blockType === AWD2_BLOCK_TEXTURE) {
103
+ const texture = parseTextureBlock(view, source, blockDataStart, blockDataStart + blockLength, diagnostics);
104
+ if (texture !== null)
105
+ textureBlocks.set(blockId, texture);
106
+ }
107
+ else if (blockType === AWD2_BLOCK_SKELETON) {
108
+ const skeleton = parseSkeletonBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide, diagnostics);
109
+ if (skeleton !== null)
110
+ skeletonBlocks.set(blockId, skeleton);
111
+ }
112
+ }
113
+ offset = blockDataStart + blockLength;
114
+ }
115
+ const document = emptyAwdDocument();
116
+ // Maps an AWD block id to the document node index it produced (containers + mesh instances), so parenting
117
+ // can rewire the flat node array by index the way the scene graph did by Node3D reference.
118
+ const nodeIndexForBlock = new Map();
119
+ // Build the file's skeleton (if any) once as document nodes: a skeleton-group node + joint nodes with
120
+ // their bind-pose local transforms, and one skin entry whose joints are those node indices. AWD binds a
121
+ // mesh to a skeleton through an animator block Flight does not parse yet; with the common single-skeleton
122
+ // file (the shambler) every skinned mesh is bound to that one skeleton.
123
+ let skinIndex;
124
+ let skeletonJointNodeIndices = [];
125
+ if (skeletonBlocks.size > 0) {
126
+ const built = buildAwdSkeletonDocument(skeletonBlocks.values().next().value, document);
127
+ skeletonJointNodeIndices = built.jointNodeIndices;
128
+ skinIndex = document.skins.length;
129
+ document.skins.push(built.skin);
130
+ document.scenes[0].rootNodes.push(built.skeletonRootIndex);
131
+ if (skeletonBlocks.size > 1) {
132
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.multiple-skeletons', 'parseAwd2', {
133
+ skeletons: skeletonBlocks.size,
134
+ });
135
+ }
136
+ }
137
+ for (const [blockId, container] of containerBlocks) {
138
+ const nodeIndex = document.nodes.length;
139
+ document.nodes.push({
140
+ children: [],
141
+ kind: Node3DKind,
142
+ name: container.name || undefined,
143
+ transform: awdTransformToTransform3D(container.transform),
144
+ });
145
+ nodeIndexForBlock.set(blockId, nodeIndex);
146
+ }
147
+ // One Flight Material per AWD material block, shared across every subset that references it (and thus one
148
+ // Texture + one ImageResource per shared texture). Keyed by AWD material block id → document material index.
149
+ const resolvedMaterials = new Map();
150
+ const materialForSubset = (meshInst, subsetIndex) => {
151
+ const materialId = subsetIndex < meshInst.materialIds.length ? meshInst.materialIds[subsetIndex] : 0;
152
+ const index = resolveAwdMaterial(materialId, materialBlocks, textureBlocks, resolvedMaterials, document, diagnostics);
153
+ return index >= 0 ? [index] : [];
154
+ };
155
+ for (const [blockId, meshInst] of meshInstanceBlocks) {
156
+ const geometries = geometryBlocks.get(meshInst.geometryId);
157
+ const transform = awdTransformToTransform3D(meshInst.transform);
158
+ let nodeIndex;
159
+ if (geometries !== undefined && geometries.length > 0) {
160
+ if (geometries.length === 1) {
161
+ // The single mesh node carries the instance name directly; the multi-geometry branch instead names
162
+ // the wrapping group (its subset meshes stay anonymous parts).
163
+ const meshIndex = document.meshes.length;
164
+ const mesh = {
165
+ geometry: geometries[0].geometry,
166
+ materials: materialForSubset(meshInst, 0),
167
+ };
168
+ if (skinIndex !== undefined && geometries[0].skinned)
169
+ mesh.skin = skinIndex;
170
+ document.meshes.push(mesh);
171
+ nodeIndex = document.nodes.length;
172
+ document.nodes.push({
173
+ children: [],
174
+ kind: MeshKind,
175
+ mesh: meshIndex,
176
+ name: meshInst.name || undefined,
177
+ transform,
178
+ });
179
+ }
180
+ else {
181
+ nodeIndex = document.nodes.length;
182
+ const group = {
183
+ children: [],
184
+ kind: Node3DKind,
185
+ name: meshInst.name || undefined,
186
+ transform,
187
+ };
188
+ document.nodes.push(group);
189
+ for (let i = 0; i < geometries.length; i++) {
190
+ const meshIndex = document.meshes.length;
191
+ const mesh = {
192
+ geometry: geometries[i].geometry,
193
+ materials: materialForSubset(meshInst, i),
194
+ };
195
+ if (skinIndex !== undefined && geometries[i].skinned)
196
+ mesh.skin = skinIndex;
197
+ document.meshes.push(mesh);
198
+ const childIndex = document.nodes.length;
199
+ document.nodes.push({
200
+ children: [],
201
+ kind: MeshKind,
202
+ mesh: meshIndex,
203
+ transform: createTransform3D(),
204
+ });
205
+ group.children.push(childIndex);
206
+ }
207
+ }
208
+ }
209
+ else {
210
+ nodeIndex = document.nodes.length;
211
+ document.nodes.push({
212
+ children: [],
213
+ kind: Node3DKind,
214
+ name: meshInst.name || undefined,
215
+ transform,
216
+ });
217
+ if (meshInst.geometryId !== 0) {
218
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.mesh-instance-missing-geometry', 'parseAwd2', { block: blockId, geometry: meshInst.geometryId });
219
+ }
220
+ }
221
+ nodeIndexForBlock.set(blockId, nodeIndex);
222
+ }
223
+ const parented = new Set();
224
+ for (const [blockId, container] of containerBlocks) {
225
+ if (container.parentId !== 0) {
226
+ const parentIndex = nodeIndexForBlock.get(container.parentId);
227
+ if (parentIndex !== undefined) {
228
+ document.nodes[parentIndex].children.push(nodeIndexForBlock.get(blockId));
229
+ parented.add(blockId);
230
+ }
231
+ }
232
+ }
233
+ for (const [blockId, meshInst] of meshInstanceBlocks) {
234
+ if (meshInst.parentId !== 0) {
235
+ const parentIndex = nodeIndexForBlock.get(meshInst.parentId);
236
+ if (parentIndex !== undefined) {
237
+ document.nodes[parentIndex].children.push(nodeIndexForBlock.get(blockId));
238
+ parented.add(blockId);
239
+ }
240
+ }
241
+ }
242
+ for (const blockId of nodeIndexForBlock.keys()) {
243
+ if (!parented.has(blockId))
244
+ document.scenes[0].rootNodes.push(nodeIndexForBlock.get(blockId));
245
+ }
246
+ // The file's skeleton animations become document animations whose channels bind by joint node index. Uses
247
+ // the same block-walk as the live parseAwd2SkeletonAnimations, but emits node-index-bound document channels.
248
+ // Walks the rehydrated `source` (not the original `bytes`): a compressed file — Away3D's export default —
249
+ // still has a deflated body in `bytes`, so re-walking that would find no blocks and silently drop every
250
+ // animation. `source` is already inflated with the compression byte + body-length rewritten by rehydrate.
251
+ if (skeletonJointNodeIndices.length > 0) {
252
+ document.animations.push(...buildAwdDocumentAnimations(source, skeletonJointNodeIndices, diagnostics));
253
+ }
254
+ return document;
255
+ }
256
+ // Parses every named skeleton-animation block in an AWD file into a name→clip map. Each clip drives the
257
+ // given joint Node3Ds — the joints createScene3DFromAwd2 built and exposed as mesh.skin.skeleton.joints —
258
+ // so binding to those same nodes (rather than freshly-created ones) is what lets a clip deform the skinned
259
+ // mesh: animation, skeleton, and skin all reference one joint hierarchy. Mirrors parseMd5Anim(source,
260
+ // joints). A file may carry several named animations (idle/walk/attack); each is keyed by its block name
261
+ // (or `animation${i}` in file order when unnamed), so a caller selects one with `clips['walk']`. Returns
262
+ // an empty map when the header is invalid or no skeleton/animation blocks are found. The `joints` array
263
+ // must be in AWD skeleton order (index j = joint j); a length mismatch with the file's skeleton records a diagnostic.
264
+ export function parseAwd2SkeletonAnimations(bytes, joints, diagnostics) {
265
+ const input = bytes;
266
+ if (input.byteLength < AWD2_HEADER_BYTES) {
267
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.header-too-short', 'parseAwd2SkeletonAnimations');
268
+ return {};
269
+ }
270
+ if (input[0] !== AWD2_MAGIC_0 || input[1] !== AWD2_MAGIC_1 || input[2] !== AWD2_MAGIC_2) {
271
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.bad-magic', 'parseAwd2SkeletonAnimations');
272
+ return {};
273
+ }
274
+ if (!isAwd2Version(input, diagnostics))
275
+ return {};
276
+ // Inflate a compressed body and splice it back behind the header so the walk is identical to the
277
+ // uncompressed path; bails to an empty result when no codec is registered for the compression method.
278
+ const rehydrated = rehydrateAwdBody(input, diagnostics);
279
+ if (rehydrated === null)
280
+ return {};
281
+ const source = rehydrated.source;
282
+ const view = rehydrated.view;
283
+ const bodyLength = view.getUint32(8, true);
284
+ const bodyEnd = Math.min(AWD2_HEADER_BYTES + bodyLength, source.byteLength);
285
+ const skeletonBlocks = new Map();
286
+ const poseBlocks = new Map();
287
+ const animationBlocks = new Map();
288
+ let offset = AWD2_HEADER_BYTES;
289
+ while (offset + AWD2_BLOCK_HEADER_BYTES <= bodyEnd) {
290
+ const blockId = view.getUint32(offset, true);
291
+ const namespace = source[offset + 4];
292
+ const blockType = source[offset + 5];
293
+ const blockFlags = source[offset + 6];
294
+ const blockLength = view.getUint32(offset + 7, true);
295
+ const blockDataStart = offset + AWD2_BLOCK_HEADER_BYTES;
296
+ if (blockDataStart + blockLength > bodyEnd) {
297
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.block-length-past-end', 'parseAwd2SkeletonAnimations');
298
+ break;
299
+ }
300
+ const matrixWide = (blockFlags & 1) !== 0;
301
+ if (namespace === AWD2_NAMESPACE_CORE) {
302
+ if (blockType === AWD2_BLOCK_SKELETON) {
303
+ const skeleton = parseSkeletonBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide, diagnostics);
304
+ if (skeleton !== null)
305
+ skeletonBlocks.set(blockId, skeleton);
306
+ }
307
+ else if (blockType === AWD2_BLOCK_SKELETON_POSE) {
308
+ const pose = parseSkeletonPoseBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide, diagnostics);
309
+ if (pose !== null)
310
+ poseBlocks.set(blockId, pose);
311
+ }
312
+ else if (blockType === AWD2_BLOCK_SKELETON_ANIMATION) {
313
+ const anim = parseSkeletonAnimationBlock(view, source, blockDataStart, blockDataStart + blockLength, diagnostics);
314
+ if (anim !== null)
315
+ animationBlocks.set(blockId, anim);
316
+ }
317
+ }
318
+ offset = blockDataStart + blockLength;
319
+ }
320
+ if (skeletonBlocks.size === 0) {
321
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.no-skeleton-blocks', 'parseAwd2SkeletonAnimations');
322
+ return {};
323
+ }
324
+ if (animationBlocks.size === 0) {
325
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.no-skeleton-animation-blocks', 'parseAwd2SkeletonAnimations');
326
+ return {};
327
+ }
328
+ const parsedSkeleton = skeletonBlocks.values().next().value;
329
+ if (joints.length < parsedSkeleton.joints.length) {
330
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.joint-count-mismatch', 'parseAwd2SkeletonAnimations', {
331
+ jointNodes: joints.length,
332
+ skeletonJoints: parsedSkeleton.joints.length,
333
+ });
334
+ return {};
335
+ }
336
+ // A file can carry several named animations (idle/walk/attack); build a clip for each and key it by the
337
+ // block's name (falling back to file order when unnamed). All bind to the one shared joint hierarchy.
338
+ const out = {};
339
+ let index = 0;
340
+ for (const parsedAnimation of animationBlocks.values()) {
341
+ const clip = buildAwdSkeletonAnimationClip(parsedAnimation, parsedSkeleton.joints.length, poseBlocks, joints, diagnostics);
342
+ if (clip !== null)
343
+ out[parsedAnimation.name || `animation${index}`] = clip;
344
+ index++;
345
+ }
346
+ return out;
347
+ }
348
+ // Registers a decompressor for an AWD compression method (AWD2_COMPRESSION_DEFLATE / _LZMA), enabling
349
+ // `parseAwd2`/`parseAwd2SkeletonAnimations` to import compressed files. Kept as an opt-in registry so the
350
+ // codec stays tree-shakable — a bundle that only reads uncompressed AWD never pulls an inflate into its
351
+ // output. Last registration for a method wins; passing null clears it. Codecs are keyed by the header's
352
+ // compression byte, so a vendor can supply its own (e.g. a host-native inflate) for either method.
353
+ export function registerAwd2Decompressor(compression, decompressor) {
354
+ if (decompressor === null)
355
+ awdDecompressors.delete(compression);
356
+ else
357
+ awdDecompressors.set(compression, decompressor);
358
+ }
359
+ // Builds one AnimationClip from a parsed AWD skeleton-animation block: samples each pose's per-joint local
360
+ // matrix into a translation + rotation track bound to the matching joint node. Null when it has no poses.
361
+ function buildAwdSkeletonAnimationClip(parsedAnimation, jointCount, poseBlocks, joints, diagnostics) {
362
+ const poseCount = parsedAnimation.poses.length;
363
+ if (poseCount === 0) {
364
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.animation-no-poses', 'buildAwdSkeletonAnimationClip');
365
+ return null;
366
+ }
367
+ const times = [];
368
+ let timeAccumulator = 0;
369
+ for (let p = 0; p < poseCount; p++) {
370
+ times.push(timeAccumulator);
371
+ timeAccumulator += parsedAnimation.poses[p].duration / 1000;
372
+ }
373
+ // Each AWD pose carries a full local joint matrix (rotation + translation). A skeletal animation is
374
+ // driven mostly by joint rotation; emitting only translation leaves every joint at its bind-pose
375
+ // orientation, which compounds down the chain into a wildly deformed mesh. So decompose each pose's
376
+ // 3×3 into a quaternion and drive both the joint's rotation and translation.
377
+ const poseMatrix = createMatrix4();
378
+ const poseTransform = createTransform3D();
379
+ const channels = [];
380
+ // A pose block referenced by the animation can be missing; the same block is re-hit for every joint,
381
+ // so collect the distinct offenders and report once. Only allocated when a collector is engaged.
382
+ const missingPoseBlocks = diagnostics ? new Set() : null;
383
+ for (let j = 0; j < jointCount; j++) {
384
+ const translationValues = [];
385
+ const rotationValues = [];
386
+ const scaleValues = [];
387
+ let hasScale = false;
388
+ for (let p = 0; p < poseCount; p++) {
389
+ const poseBlockId = parsedAnimation.poses[p].poseBlockId;
390
+ const pose = poseBlocks.get(poseBlockId);
391
+ if (pose === undefined) {
392
+ missingPoseBlocks?.add(poseBlockId);
393
+ translationValues.push(0, 0, 0);
394
+ rotationValues.push(0, 0, 0, 1);
395
+ scaleValues.push(1, 1, 1);
396
+ }
397
+ else if (j < pose.jointTransforms.length && pose.jointTransforms[j] !== null) {
398
+ const transform = pose.jointTransforms[j];
399
+ translationValues.push(transform[9], transform[10], transform[11]);
400
+ // Decompose the pose's 3×3 basis into rotation AND scale. A plain setQuaternionFromMatrix4 would
401
+ // read a skewed quaternion off a scaled basis and drop the scale entirely; decompose normalizes
402
+ // the basis first, so both come out correct. The lifted matrix's translation is unused here.
403
+ awdTransformToMatrix4(poseMatrix, transform);
404
+ decomposeMatrix4ToTransform3D(poseTransform, poseMatrix);
405
+ rotationValues.push(poseTransform.rotation.x, poseTransform.rotation.y, poseTransform.rotation.z, poseTransform.rotation.w);
406
+ scaleValues.push(poseTransform.scale.x, poseTransform.scale.y, poseTransform.scale.z);
407
+ if (hasNonUnitScale(poseTransform.scale.x, poseTransform.scale.y, poseTransform.scale.z))
408
+ hasScale = true;
409
+ }
410
+ else {
411
+ translationValues.push(0, 0, 0);
412
+ rotationValues.push(0, 0, 0, 1);
413
+ scaleValues.push(1, 1, 1);
414
+ }
415
+ }
416
+ const translationTrack = createAnimationTrack({
417
+ components: 3,
418
+ times,
419
+ values: translationValues,
420
+ });
421
+ channels.push(createAnimationChannel(translationTrack, {
422
+ node: joints[j],
423
+ path: Scene3DAnimationPathTranslation,
424
+ }));
425
+ const rotationTrack = createAnimationTrack({
426
+ components: 4,
427
+ quaternion: true,
428
+ times,
429
+ values: rotationValues,
430
+ });
431
+ channels.push(createAnimationChannel(rotationTrack, {
432
+ node: joints[j],
433
+ path: Scene3DAnimationPathRotation,
434
+ }));
435
+ // Only emit a scale track when some pose actually scales the joint — AWD skeletons rarely do, and a
436
+ // redundant unit-scale channel would bloat every clip.
437
+ if (hasScale) {
438
+ const scaleTrack = createAnimationTrack({
439
+ components: 3,
440
+ times,
441
+ values: scaleValues,
442
+ });
443
+ channels.push(createAnimationChannel(scaleTrack, {
444
+ node: joints[j],
445
+ path: Scene3DAnimationPathScale,
446
+ }));
447
+ }
448
+ }
449
+ if (missingPoseBlocks !== null && missingPoseBlocks.size > 0) {
450
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.pose-block-missing', 'buildAwdSkeletonAnimationClip', {
451
+ distinctPoseBlocks: missingPoseBlocks.size,
452
+ firstPoseBlock: Math.min(...missingPoseBlocks),
453
+ });
454
+ }
455
+ return createAnimationClip(channels, timeAccumulator);
456
+ }
457
+ // Builds the document animation table from an AWD file's skeleton-animation blocks, binding each channel by
458
+ // the joint's document node index (in `jointNodeIndices`, AWD joint order). The block walk mirrors the live
459
+ // parseAwd2SkeletonAnimations; the difference is only the sink — document channels carry a node index + path
460
+ // + track rather than a live-node-bound AnimationChannel. Every named animation (idle/walk/attack) becomes
461
+ // one Scene3DDocumentAnimation keyed by its block name (or `animation${i}` in file order when unnamed). Returns
462
+ // an empty array when no skeleton/animation blocks are found. `bytes` must be the REHYDRATED body (compression
463
+ // byte NONE, inflated), not the raw file — this helper does not decompress, unlike parseAwd2SkeletonAnimations.
464
+ function buildAwdDocumentAnimations(bytes, jointNodeIndices, diagnostics) {
465
+ const source = bytes;
466
+ const view = new DataView(source.buffer, source.byteOffset, source.byteLength);
467
+ const bodyLength = view.getUint32(8, true);
468
+ const bodyEnd = Math.min(AWD2_HEADER_BYTES + bodyLength, source.byteLength);
469
+ const skeletonBlocks = new Map();
470
+ const poseBlocks = new Map();
471
+ const animationBlocks = new Map();
472
+ let offset = AWD2_HEADER_BYTES;
473
+ while (offset + AWD2_BLOCK_HEADER_BYTES <= bodyEnd) {
474
+ const namespace = source[offset + 4];
475
+ const blockType = source[offset + 5];
476
+ const blockFlags = source[offset + 6];
477
+ const blockLength = view.getUint32(offset + 7, true);
478
+ const blockDataStart = offset + AWD2_BLOCK_HEADER_BYTES;
479
+ if (blockDataStart + blockLength > bodyEnd)
480
+ break;
481
+ const blockId = view.getUint32(offset, true);
482
+ const matrixWide = (blockFlags & 1) !== 0;
483
+ if (namespace === AWD2_NAMESPACE_CORE) {
484
+ if (blockType === AWD2_BLOCK_SKELETON) {
485
+ const skeleton = parseSkeletonBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide);
486
+ if (skeleton !== null)
487
+ skeletonBlocks.set(blockId, skeleton);
488
+ }
489
+ else if (blockType === AWD2_BLOCK_SKELETON_POSE) {
490
+ // Pose/animation blocks are NOT parsed by parseAwd2's first walk, so this document-path walk is
491
+ // their only reporting site — thread the sink. The skeleton block above is deliberately left
492
+ // sink-less: the first walk already parses (and reports) it, so threading here would double-report.
493
+ const pose = parseSkeletonPoseBlock(view, source, blockDataStart, blockDataStart + blockLength, matrixWide, diagnostics);
494
+ if (pose !== null)
495
+ poseBlocks.set(blockId, pose);
496
+ }
497
+ else if (blockType === AWD2_BLOCK_SKELETON_ANIMATION) {
498
+ const anim = parseSkeletonAnimationBlock(view, source, blockDataStart, blockDataStart + blockLength, diagnostics);
499
+ if (anim !== null)
500
+ animationBlocks.set(blockId, anim);
501
+ }
502
+ }
503
+ offset = blockDataStart + blockLength;
504
+ }
505
+ if (skeletonBlocks.size === 0 || animationBlocks.size === 0)
506
+ return [];
507
+ const parsedSkeleton = skeletonBlocks.values().next().value;
508
+ const jointCount = parsedSkeleton.joints.length;
509
+ const animations = [];
510
+ let index = 0;
511
+ for (const parsedAnimation of animationBlocks.values()) {
512
+ const built = buildAwdDocumentAnimation(parsedAnimation, jointCount, poseBlocks, jointNodeIndices, diagnostics);
513
+ if (built !== null) {
514
+ built.name = parsedAnimation.name || `animation${index}`;
515
+ animations.push(built);
516
+ }
517
+ index++;
518
+ }
519
+ return animations;
520
+ }
521
+ // Builds one Scene3DDocumentAnimation from a parsed AWD skeleton-animation block: samples each pose's per-joint
522
+ // local matrix into a translation + rotation track bound to the matching joint node INDEX. Null when it has
523
+ // no poses. Mirrors buildAwdSkeletonAnimationClip's per-joint sampling, emitting document channels.
524
+ function buildAwdDocumentAnimation(parsedAnimation, jointCount, poseBlocks, jointNodeIndices, diagnostics) {
525
+ const poseCount = parsedAnimation.poses.length;
526
+ if (poseCount === 0) {
527
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.animation-no-poses', 'buildAwdDocumentAnimation');
528
+ return null;
529
+ }
530
+ const times = [];
531
+ let timeAccumulator = 0;
532
+ for (let p = 0; p < poseCount; p++) {
533
+ times.push(timeAccumulator);
534
+ timeAccumulator += parsedAnimation.poses[p].duration / 1000;
535
+ }
536
+ const poseMatrix = createMatrix4();
537
+ const poseTransform = createTransform3D();
538
+ const channels = [];
539
+ // See buildAwdSkeletonAnimationClip: aggregate the distinct missing pose blocks, report once.
540
+ const missingPoseBlocks = diagnostics ? new Set() : null;
541
+ for (let j = 0; j < jointCount; j++) {
542
+ if (j >= jointNodeIndices.length)
543
+ break;
544
+ const translationValues = [];
545
+ const rotationValues = [];
546
+ const scaleValues = [];
547
+ let hasScale = false;
548
+ for (let p = 0; p < poseCount; p++) {
549
+ const poseBlockId = parsedAnimation.poses[p].poseBlockId;
550
+ const pose = poseBlocks.get(poseBlockId);
551
+ if (pose === undefined) {
552
+ missingPoseBlocks?.add(poseBlockId);
553
+ translationValues.push(0, 0, 0);
554
+ rotationValues.push(0, 0, 0, 1);
555
+ scaleValues.push(1, 1, 1);
556
+ }
557
+ else if (j < pose.jointTransforms.length && pose.jointTransforms[j] !== null) {
558
+ const transform = pose.jointTransforms[j];
559
+ translationValues.push(transform[9], transform[10], transform[11]);
560
+ // Decompose rotation + scale from the pose basis (see the live-clip path for why decompose,
561
+ // not setQuaternionFromMatrix4 alone).
562
+ awdTransformToMatrix4(poseMatrix, transform);
563
+ decomposeMatrix4ToTransform3D(poseTransform, poseMatrix);
564
+ rotationValues.push(poseTransform.rotation.x, poseTransform.rotation.y, poseTransform.rotation.z, poseTransform.rotation.w);
565
+ scaleValues.push(poseTransform.scale.x, poseTransform.scale.y, poseTransform.scale.z);
566
+ if (hasNonUnitScale(poseTransform.scale.x, poseTransform.scale.y, poseTransform.scale.z))
567
+ hasScale = true;
568
+ }
569
+ else {
570
+ translationValues.push(0, 0, 0);
571
+ rotationValues.push(0, 0, 0, 1);
572
+ scaleValues.push(1, 1, 1);
573
+ }
574
+ }
575
+ const translationTrack = createAnimationTrack({
576
+ components: 3,
577
+ times,
578
+ values: translationValues,
579
+ });
580
+ channels.push({
581
+ node: jointNodeIndices[j],
582
+ path: Scene3DAnimationPathTranslation,
583
+ track: translationTrack,
584
+ });
585
+ const rotationTrack = createAnimationTrack({
586
+ components: 4,
587
+ quaternion: true,
588
+ times,
589
+ values: rotationValues,
590
+ });
591
+ channels.push({
592
+ node: jointNodeIndices[j],
593
+ path: Scene3DAnimationPathRotation,
594
+ track: rotationTrack,
595
+ });
596
+ if (hasScale) {
597
+ const scaleTrack = createAnimationTrack({
598
+ components: 3,
599
+ times,
600
+ values: scaleValues,
601
+ });
602
+ channels.push({
603
+ node: jointNodeIndices[j],
604
+ path: Scene3DAnimationPathScale,
605
+ track: scaleTrack,
606
+ });
607
+ }
608
+ }
609
+ if (missingPoseBlocks !== null && missingPoseBlocks.size > 0) {
610
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.pose-block-missing', 'buildAwdDocumentAnimation', {
611
+ distinctPoseBlocks: missingPoseBlocks.size,
612
+ firstPoseBlock: Math.min(...missingPoseBlocks),
613
+ });
614
+ }
615
+ return { channels, duration: timeAccumulator, name: parsedAnimation.name };
616
+ }
617
+ // Validates the header version-major byte before the block walk. An 'AWD'-magic file with a version other
618
+ // than 2 (in practice version 3 — AWD3, AwayJS's Scene3DGraph format) has an entirely different block model,
619
+ // so the AWD2 block walk would silently misparse it to an empty/garbage document. Reject it by name here
620
+ // instead, pointing at AWD3 as a recognized but not-yet-implemented future format.
621
+ function isAwd2Version(input, diagnostics) {
622
+ const versionMajor = input[AWD2_VERSION_MAJOR_OFFSET];
623
+ if (versionMajor === AWD2_FORMAT_VERSION)
624
+ return true;
625
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.unsupported-version', 'isAwd2Version', {
626
+ version: versionMajor,
627
+ });
628
+ return false;
629
+ }
630
+ // The empty Scene3DDocument returned when AWD parsing fails or before assembly begins — every table present.
631
+ function emptyAwdDocument() {
632
+ return {
633
+ animations: [],
634
+ cameras: [],
635
+ lights: [],
636
+ materials: [],
637
+ meshes: [],
638
+ metadata: null,
639
+ nodes: [],
640
+ resources: [],
641
+ scenes: [{ rootNodes: [] }],
642
+ skins: [],
643
+ };
644
+ }
645
+ // Emits an AWD skeleton block into a Scene3DDocument as a "skeleton" group node + one joint node per AWD
646
+ // joint (with its bind-pose local transform), plus a Scene3DDocumentSkin whose joints are those node indices
647
+ // and whose inverseBind are the AWD joint matrices (which ARE the inverse bind pose). Appends the nodes to
648
+ // `document.nodes` and returns the skeleton-group node index, the joint node indices (in AWD joint order,
649
+ // for animation binding), and the skin. The parent chain is wired through the joint nodes' `children` index
650
+ // lists (AWD parent index is 1-based, 0 = root; roots hang under the group). Each joint's LOCAL transform
651
+ // is seeded to the bind pose so the skinned mesh renders undeformed until the animation poses the joints —
652
+ // the same math the live scene path used, decomposed to a Transform3D for the document node.
653
+ function buildAwdSkeletonDocument(parsedSkeleton, document) {
654
+ const jointCount = parsedSkeleton.joints.length;
655
+ const skeletonRootIndex = document.nodes.length;
656
+ document.nodes.push({
657
+ children: [],
658
+ kind: Node3DKind,
659
+ name: 'skeleton',
660
+ transform: createTransform3D(),
661
+ });
662
+ const jointNodeIndices = [];
663
+ for (let j = 0; j < jointCount; j++) {
664
+ jointNodeIndices.push(document.nodes.length);
665
+ document.nodes.push({
666
+ children: [],
667
+ kind: Node3DKind,
668
+ name: parsedSkeleton.joints[j].name || undefined,
669
+ transform: createTransform3D(),
670
+ });
671
+ }
672
+ for (let j = 0; j < jointCount; j++) {
673
+ const parentIndex1 = parsedSkeleton.joints[j].parentIndex;
674
+ if (parentIndex1 > 0 && parentIndex1 - 1 < jointCount) {
675
+ document.nodes[jointNodeIndices[parentIndex1 - 1]].children.push(jointNodeIndices[j]);
676
+ }
677
+ else {
678
+ document.nodes[skeletonRootIndex].children.push(jointNodeIndices[j]);
679
+ }
680
+ }
681
+ // The AWD skeleton joint matrix is the joint's INVERSE bind pose (model→joint at bind), not a local
682
+ // transform — see the AWD format and AwayJS's AWDParser (joint.inverseBindPose). So carry it as the skin's
683
+ // explicit inverse-bind palette, and seed each joint's LOCAL transform to the bind pose so the rig renders
684
+ // undeformed until the animation poses the joints. Bind world = inverseBind⁻¹, and a joint's bind-local =
685
+ // parentBindWorld⁻¹ · jointBindWorld (roots use bind world directly). The animation clip then overrides
686
+ // these locals per frame; the skinning palette is jointWorld · inverseBind, which is identity at bind
687
+ // (undeformed) and the pose delta once animated.
688
+ const inverseBind = [];
689
+ const bindWorld = [];
690
+ for (let j = 0; j < jointCount; j++) {
691
+ const invBind = createMatrix4();
692
+ awdTransformToMatrix4(invBind, parsedSkeleton.joints[j].transform);
693
+ inverseBind.push(invBind);
694
+ const bw = createMatrix4();
695
+ inverseMatrix4(bw, invBind);
696
+ bindWorld.push(bw);
697
+ }
698
+ const invParent = createMatrix4();
699
+ const local = createMatrix4();
700
+ for (let j = 0; j < jointCount; j++) {
701
+ const parentIndex1 = parsedSkeleton.joints[j].parentIndex;
702
+ if (parentIndex1 > 0 && parentIndex1 - 1 < jointCount) {
703
+ inverseMatrix4(invParent, bindWorld[parentIndex1 - 1]);
704
+ multiplyMatrix4(local, invParent, bindWorld[j]);
705
+ }
706
+ else {
707
+ copyMatrix4(local, bindWorld[j]);
708
+ }
709
+ decomposeMatrix4ToTransform3D(document.nodes[jointNodeIndices[j]].transform, local);
710
+ }
711
+ const skin = { inverseBind, joints: jointNodeIndices };
712
+ return { jointNodeIndices, skeletonRootIndex, skin };
713
+ }
714
+ function readAwdString(view, source, offset) {
715
+ const length = view.getUint16(offset, true);
716
+ const stringBytes = source.subarray(offset + 2, offset + 2 + length);
717
+ const value = new TextDecoder().decode(stringBytes);
718
+ return { end: offset + 2 + length, value };
719
+ }
720
+ function readAwdTransform(view, offset, widePrecision) {
721
+ const dv = view;
722
+ const transform = new Float64Array(12);
723
+ const floatSize = widePrecision ? 8 : 4;
724
+ for (let i = 0; i < 12; i++) {
725
+ transform[i] = widePrecision
726
+ ? dv.getFloat64(offset + i * floatSize, true)
727
+ : dv.getFloat32(offset + i * floatSize, true);
728
+ }
729
+ convertTransformLhToRh(transform);
730
+ return { end: offset + 12 * floatSize, transform };
731
+ }
732
+ // Decomposes an AWD 12-float column-major transform into a document node's authored TRS Transform3D
733
+ // (lossy only on shear, which AWD authoring does not produce).
734
+ function awdTransformToTransform3D(transform) {
735
+ awdTransformToMatrix4(_awdTransformScratch, transform);
736
+ const out = createTransform3D();
737
+ decomposeMatrix4ToTransform3D(out, _awdTransformScratch);
738
+ return out;
739
+ }
740
+ const _awdTransformScratch = createMatrix4();
741
+ // AWD stores transforms as 12 column-major floats: [c0x,c0y,c0z, c1x,c1y,c1z, c2x,c2y,c2z, tx,ty,tz] →
742
+ // 4×4 column-major with w-column [0,0,0,1]. Lifts one into `out` (its runtime binding is left intact).
743
+ function awdTransformToMatrix4(out, transform) {
744
+ const m = out.m;
745
+ m[0] = transform[0];
746
+ m[1] = transform[1];
747
+ m[2] = transform[2];
748
+ m[3] = 0;
749
+ m[4] = transform[3];
750
+ m[5] = transform[4];
751
+ m[6] = transform[5];
752
+ m[7] = 0;
753
+ m[8] = transform[6];
754
+ m[9] = transform[7];
755
+ m[10] = transform[8];
756
+ m[11] = 0;
757
+ m[12] = transform[9];
758
+ m[13] = transform[10];
759
+ m[14] = transform[11];
760
+ m[15] = 1;
761
+ }
762
+ function awdDataTypeByteSize(dataType) {
763
+ switch (dataType) {
764
+ case AWD2_DATA_INT8:
765
+ case AWD2_DATA_UINT8:
766
+ return 1;
767
+ case AWD2_DATA_INT16:
768
+ case AWD2_DATA_UINT16:
769
+ return 2;
770
+ case AWD2_DATA_INT32:
771
+ case AWD2_DATA_UINT32:
772
+ case AWD2_DATA_FLOAT32:
773
+ return 4;
774
+ case AWD2_DATA_FLOAT64:
775
+ return 8;
776
+ default:
777
+ return 4;
778
+ }
779
+ }
780
+ function readAwdDataValue(view, offset, dataType) {
781
+ const dv = view;
782
+ switch (dataType) {
783
+ case AWD2_DATA_INT8:
784
+ return dv.getInt8(offset);
785
+ case AWD2_DATA_INT16:
786
+ return dv.getInt16(offset, true);
787
+ case AWD2_DATA_INT32:
788
+ return dv.getInt32(offset, true);
789
+ case AWD2_DATA_UINT8:
790
+ return dv.getUint8(offset);
791
+ case AWD2_DATA_UINT16:
792
+ return dv.getUint16(offset, true);
793
+ case AWD2_DATA_UINT32:
794
+ return dv.getUint32(offset, true);
795
+ case AWD2_DATA_FLOAT32:
796
+ return dv.getFloat32(offset, true);
797
+ case AWD2_DATA_FLOAT64:
798
+ return dv.getFloat64(offset, true);
799
+ default:
800
+ return dv.getFloat32(offset, true);
801
+ }
802
+ }
803
+ // Skips an AWD attribute list (NumAttrList or UserAttrList). The list is a uint32 byte-length
804
+ // prefix followed by that many bytes of attribute data.
805
+ function skipAwdAttrList(view, offset, end) {
806
+ if (offset + 4 > end)
807
+ return offset;
808
+ const byteLength = view.getUint32(offset, true);
809
+ return offset + 4 + byteLength;
810
+ }
811
+ // Parses a TriangleGeometry block (type 1). Layout:
812
+ // name(VarString) → numSubMeshes(uint16) → NumAttrList → per sub-mesh:
813
+ // totalByteLen(uint32) → NumAttrList → streams → UserAttrList
814
+ function parseTriangleGeometryBlock(view, source, start, end, geometryWide, diagnostics) {
815
+ const dv = view;
816
+ let offset = start;
817
+ // Guard the name in two steps: the 2-byte VarString length prefix, then its declared payload — otherwise a
818
+ // truncated name payload slips past readAwdString (which does not bound-check) and mislabels the next
819
+ // guard's field as 'num-submeshes' when the missing bytes actually belong to the name.
820
+ if (offset + 2 > end || offset + 2 + dv.getUint16(offset, true) > end) {
821
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.geometry-truncated', 'parseTriangleGeometryBlock', {
822
+ field: 'name',
823
+ });
824
+ return [];
825
+ }
826
+ const nameResult = readAwdString(view, source, offset);
827
+ offset = nameResult.end;
828
+ if (offset + 2 > end) {
829
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.geometry-truncated', 'parseTriangleGeometryBlock', {
830
+ field: 'num-submeshes',
831
+ });
832
+ return [];
833
+ }
834
+ const numSubMeshes = dv.getUint16(offset, true);
835
+ offset += 2;
836
+ offset = skipAwdAttrList(view, offset, end);
837
+ const geometries = [];
838
+ for (let s = 0; s < numSubMeshes; s++) {
839
+ if (offset + 4 > end) {
840
+ // The sub-mesh header is truncated: this and every remaining sub-mesh of the block are omitted.
841
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.submesh-truncated', 'parseTriangleGeometryBlock', {
842
+ firstSubMesh: s,
843
+ });
844
+ break;
845
+ }
846
+ const subMeshByteLen = dv.getUint32(offset, true);
847
+ const subMeshEnd = offset + 4 + subMeshByteLen;
848
+ offset += 4;
849
+ // NumAttrList for sub-mesh properties.
850
+ offset = skipAwdAttrList(view, offset, end);
851
+ let positions = null;
852
+ let indices = null;
853
+ let uvs = null;
854
+ let normals = null;
855
+ let tangents = null;
856
+ let jointIndices = null;
857
+ let jointWeights = null;
858
+ // Read streams until we reach the sub-mesh byte boundary (leaving room for UserAttrList).
859
+ while (offset + 6 <= subMeshEnd) {
860
+ const streamType = dv.getUint8(offset);
861
+ offset += 1;
862
+ const dataType = dv.getUint8(offset);
863
+ offset += 1;
864
+ const streamByteLength = dv.getUint32(offset, true);
865
+ offset += 4;
866
+ if (offset + streamByteLength > end) {
867
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.stream-data-past-end', 'parseTriangleGeometryBlock');
868
+ break;
869
+ }
870
+ // AWD stores per-vertex joint indices as uint16 (Away3D reads them with readUnsignedShort),
871
+ // regardless of the stream's declared data type — exporters write float32 in that field but
872
+ // pack the payload as tight uint16 indices. Read them as uint16 by byte length; the paired
873
+ // weight stream is genuine float32 and goes through the generic reader below.
874
+ if (streamType === AWD2_STREAM_JOINT_INDICES) {
875
+ const jointCount = Math.floor(streamByteLength / 2);
876
+ const values = [];
877
+ for (let i = 0; i < jointCount; i++)
878
+ values.push(dv.getUint16(offset + i * 2, true));
879
+ jointIndices = values;
880
+ offset += streamByteLength;
881
+ continue;
882
+ }
883
+ const elementSize = awdDataTypeByteSize(dataType);
884
+ const count = Math.floor(streamByteLength / elementSize);
885
+ const values = [];
886
+ for (let i = 0; i < count; i++) {
887
+ values.push(readAwdDataValue(view, offset + i * elementSize, dataType));
888
+ }
889
+ offset += streamByteLength;
890
+ switch (streamType) {
891
+ case AWD2_STREAM_POSITIONS:
892
+ positions = values;
893
+ break;
894
+ case AWD2_STREAM_INDICES:
895
+ indices = values;
896
+ break;
897
+ case AWD2_STREAM_UVS:
898
+ uvs = values;
899
+ break;
900
+ case AWD2_STREAM_NORMALS:
901
+ normals = values;
902
+ break;
903
+ case AWD2_STREAM_TANGENTS:
904
+ tangents = values;
905
+ break;
906
+ case AWD2_STREAM_JOINT_WEIGHTS:
907
+ jointWeights = values;
908
+ break;
909
+ default:
910
+ break;
911
+ }
912
+ }
913
+ // UserAttrList for sub-mesh.
914
+ offset = skipAwdAttrList(view, offset, end);
915
+ if (positions === null || positions.length < 3) {
916
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.submesh-no-positions', 'parseTriangleGeometryBlock');
917
+ continue;
918
+ }
919
+ // Convert from AWD's left-handed Y-up to Flight's right-handed Y-up. Joint indices/weights are
920
+ // index/scalar data — unaffected by the handedness flip, which the skeleton transforms mirror.
921
+ negateVec3Z(positions);
922
+ if (normals !== null)
923
+ negateVec3Z(normals);
924
+ if (tangents !== null)
925
+ negateVec3Z(tangents);
926
+ if (indices !== null)
927
+ reverseTriangleWinding(indices);
928
+ const vertexCount = positions.length / 3;
929
+ // A sub-mesh is skinned when it carries both influence streams; it then emits the skinned layout
930
+ // (joints0/weights0 past uv0) and feeds the shared packSkinInfluences path. AWD lists an arbitrary
931
+ // number of influences per vertex (shambler uses 8); the top four by weight are kept, renormalized.
932
+ let jointsPerVertex = 0;
933
+ if (jointIndices !== null && jointWeights !== null && vertexCount > 0) {
934
+ jointsPerVertex = Math.floor(jointWeights.length / vertexCount);
935
+ if (jointsPerVertex < 1 || jointIndices.length < vertexCount * jointsPerVertex) {
936
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.skin-streams-mismatch', 'parseTriangleGeometryBlock');
937
+ jointsPerVertex = 0;
938
+ }
939
+ }
940
+ const skinned = jointsPerVertex > 0;
941
+ const floatsPerVertex = skinned ? SKINNED_FLOATS_PER_VERTEX : CANONICAL_FLOATS_PER_VERTEX;
942
+ const vertices = new Float32Array(vertexCount * floatsPerVertex);
943
+ const jointScratch = [0, 0, 0, 0];
944
+ const weightScratch = [0, 0, 0, 0];
945
+ for (let v = 0; v < vertexCount; v++) {
946
+ const o = v * floatsPerVertex;
947
+ vertices[o] = positions[v * 3];
948
+ vertices[o + 1] = positions[v * 3 + 1];
949
+ vertices[o + 2] = positions[v * 3 + 2];
950
+ if (normals !== null && v * 3 + 2 < normals.length) {
951
+ vertices[o + 3] = normals[v * 3];
952
+ vertices[o + 4] = normals[v * 3 + 1];
953
+ vertices[o + 5] = normals[v * 3 + 2];
954
+ }
955
+ if (tangents !== null && v * 3 + 2 < tangents.length) {
956
+ vertices[o + 6] = tangents[v * 3];
957
+ vertices[o + 7] = tangents[v * 3 + 1];
958
+ vertices[o + 8] = tangents[v * 3 + 2];
959
+ // Tangent W is the bitangent handedness the shader reconstructs the bitangent with (B = W·N×T).
960
+ // AWD's tangent stream is xyz only, so W must be synthesized: Away3D derives the bitangent as
961
+ // N×T in its LEFT-handed space (one handedness for the whole mesh), and the left→right-handed
962
+ // conversion above (negateVec3Z on N and T is a det=-1 reflection) flips that handedness. So the
963
+ // correct sign in Flight's right-handed space is AWD2_TANGENT_HANDEDNESS. Left 0 (no bitangent) for
964
+ // a vertex the tangent stream does not cover.
965
+ vertices[o + 9] = AWD2_TANGENT_HANDEDNESS;
966
+ }
967
+ if (uvs !== null && v * 2 + 1 < uvs.length) {
968
+ vertices[o + 10] = uvs[v * 2];
969
+ vertices[o + 11] = uvs[v * 2 + 1];
970
+ }
971
+ if (skinned) {
972
+ const influences = [];
973
+ for (let k = 0; k < jointsPerVertex; k++) {
974
+ const weight = jointWeights[v * jointsPerVertex + k];
975
+ if (weight > 0)
976
+ influences.push({
977
+ jointIndex: jointIndices[v * jointsPerVertex + k],
978
+ weight,
979
+ });
980
+ }
981
+ packSkinInfluences(influences, jointScratch, weightScratch);
982
+ vertices[o + 12] = jointScratch[0];
983
+ vertices[o + 13] = jointScratch[1];
984
+ vertices[o + 14] = jointScratch[2];
985
+ vertices[o + 15] = jointScratch[3];
986
+ vertices[o + 16] = weightScratch[0];
987
+ vertices[o + 17] = weightScratch[1];
988
+ vertices[o + 18] = weightScratch[2];
989
+ vertices[o + 19] = weightScratch[3];
990
+ }
991
+ }
992
+ const indexArray = indices !== null ? Uint32Array.from(indices) : undefined;
993
+ const geometry = createMeshGeometry({
994
+ indices: indexArray,
995
+ layout: skinned ? CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT : CANONICAL_LAYOUT,
996
+ vertices,
997
+ });
998
+ // Regenerate normals only when the sub-mesh carried none, matching the shared emitter; authored
999
+ // AWD normals (present on skinned models like the shambler) are kept.
1000
+ if (normals === null && indexArray !== undefined)
1001
+ computeMeshGeometryNormals(geometry, geometry);
1002
+ // Away3D commonly ships meshes with UVs but NO tangent stream and derives tangents at load time. With
1003
+ // no stream the tangent frame above is left zero, so a normal-mapped material renders black (its
1004
+ // sampled normal has no basis to transform through). Synthesize a tangent basis (xyz + handedness W)
1005
+ // from positions/normals/UVs — the -1 W this yields for real AWD geometry is render-confirmed correct.
1006
+ // Only when the stream is absent and there are UVs (and indices, for the per-triangle UV gradient);
1007
+ // an authored tangent stream is kept untouched.
1008
+ if (tangents === null && uvs !== null && indexArray !== undefined) {
1009
+ computeMeshGeometryTangents(geometry, geometry);
1010
+ }
1011
+ geometries.push({ geometry, skinned });
1012
+ }
1013
+ return geometries;
1014
+ }
1015
+ // Parses a Container block (type 22). AWD Scene3DHeader layout:
1016
+ // parentId(uint32) → matrix4x3(12 × floatSize) → name(VarString) → NumAttrList → UserAttrList
1017
+ function parseContainerBlock(view, source, start, end, matrixWide, diagnostics) {
1018
+ const dv = view;
1019
+ let offset = start;
1020
+ if (offset + 4 > end) {
1021
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.container-truncated', 'parseContainerBlock', {
1022
+ field: 'parentId',
1023
+ });
1024
+ return null;
1025
+ }
1026
+ const parentId = dv.getUint32(offset, true);
1027
+ offset += 4;
1028
+ const floatSize = matrixWide ? 8 : 4;
1029
+ if (offset + 12 * floatSize > end) {
1030
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.container-truncated', 'parseContainerBlock', {
1031
+ field: 'transform',
1032
+ });
1033
+ return null;
1034
+ }
1035
+ const transformResult = readAwdTransform(view, offset, matrixWide);
1036
+ offset = transformResult.end;
1037
+ if (offset + 2 > end) {
1038
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.container-truncated', 'parseContainerBlock', {
1039
+ field: 'name',
1040
+ });
1041
+ return null;
1042
+ }
1043
+ const nameResult = readAwdString(view, source, offset);
1044
+ offset = nameResult.end;
1045
+ offset = skipAwdAttrList(view, offset, end);
1046
+ offset = skipAwdAttrList(view, offset, end);
1047
+ return {
1048
+ name: nameResult.value,
1049
+ parentId,
1050
+ transform: transformResult.transform,
1051
+ };
1052
+ }
1053
+ // Parses a MeshInstance block (type 23). Layout:
1054
+ // Scene3DHeader(parentId → matrix → name) → geometryId(uint32)
1055
+ // → numMaterials(uint16) → materialIds(uint32 × N) → NumAttrList → UserAttrList
1056
+ function parseMeshInstanceBlock(view, source, start, end, matrixWide, diagnostics) {
1057
+ const dv = view;
1058
+ let offset = start;
1059
+ if (offset + 4 > end) {
1060
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.mesh-instance-truncated', 'parseMeshInstanceBlock', {
1061
+ field: 'parentId',
1062
+ });
1063
+ return null;
1064
+ }
1065
+ const parentId = dv.getUint32(offset, true);
1066
+ offset += 4;
1067
+ const floatSize = matrixWide ? 8 : 4;
1068
+ if (offset + 12 * floatSize > end) {
1069
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.mesh-instance-truncated', 'parseMeshInstanceBlock', {
1070
+ field: 'transform',
1071
+ });
1072
+ return null;
1073
+ }
1074
+ const transformResult = readAwdTransform(view, offset, matrixWide);
1075
+ offset = transformResult.end;
1076
+ if (offset + 2 > end) {
1077
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.mesh-instance-truncated', 'parseMeshInstanceBlock', {
1078
+ field: 'name',
1079
+ });
1080
+ return null;
1081
+ }
1082
+ const nameResult = readAwdString(view, source, offset);
1083
+ offset = nameResult.end;
1084
+ if (offset + 4 > end) {
1085
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.mesh-instance-truncated', 'parseMeshInstanceBlock', {
1086
+ field: 'geometryId',
1087
+ });
1088
+ return null;
1089
+ }
1090
+ const geometryId = dv.getUint32(offset, true);
1091
+ offset += 4;
1092
+ // Material block ids, positional per geometry sub-mesh. Previously read-and-discarded; kept now so
1093
+ // the diffuse material/texture for each subset can be resolved and attached.
1094
+ const materialIds = [];
1095
+ if (offset + 2 <= end) {
1096
+ const numMaterials = dv.getUint16(offset, true);
1097
+ offset += 2;
1098
+ for (let i = 0; i < numMaterials && offset + 4 <= end; i++) {
1099
+ materialIds.push(dv.getUint32(offset, true));
1100
+ offset += 4;
1101
+ }
1102
+ }
1103
+ // NumAttrList (block properties) and UserAttrList.
1104
+ offset = skipAwdAttrList(view, offset, end);
1105
+ offset = skipAwdAttrList(view, offset, end);
1106
+ return {
1107
+ geometryId,
1108
+ materialIds,
1109
+ name: nameResult.value,
1110
+ parentId,
1111
+ transform: transformResult.transform,
1112
+ };
1113
+ }
1114
+ // Parses a Material block (type 81). Layout:
1115
+ // name(VarString) → matType(uint8) → numMethods(uint8) → PropertyList → methods → UserAttrList.
1116
+ // The base PropertyList carries the flat color (property 1), diffuse texture id (property 2), normal
1117
+ // texture id (property 3), and alpha (property 10) — the properties Flight maps onto the ShadedMaterial
1118
+ // base. `numMethods` is captured so the resolver can record a diagnostic that a method-bearing material's shading methods
1119
+ // (fog/env/fresnel/specular in Away3D's MethodMaterial model) are not yet imported as modifiers; the
1120
+ // method bodies themselves (which follow the base PropertyList) are left unwalked pending a real
1121
+ // method-bearing fixture and the verified AWD2 method-type layout — every AWD2 asset in the reference
1122
+ // corpus is `numMethods == 0`, so there is nothing to observe or test the walk against yet.
1123
+ function parseMaterialBlock(view, source, start, end, diagnostics) {
1124
+ let offset = start;
1125
+ if (offset + 2 > end) {
1126
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.material-truncated', 'parseMaterialBlock', {
1127
+ field: 'name',
1128
+ });
1129
+ return null;
1130
+ }
1131
+ const nameResult = readAwdString(view, source, offset);
1132
+ offset = nameResult.end;
1133
+ if (offset + 2 > end) {
1134
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.material-truncated', 'parseMaterialBlock', {
1135
+ field: 'type',
1136
+ });
1137
+ return null;
1138
+ }
1139
+ offset += 1; // matType (uint8) — texture vs color is inferred from which properties are present
1140
+ const numMethods = source[offset];
1141
+ offset += 1; // numMethods (uint8)
1142
+ const props = readAwdProperties(view, offset, end);
1143
+ const diffuseTextureId = readAwdPropertyUint32(view, props.values, AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE) ?? 0;
1144
+ const normalTextureId = readAwdPropertyUint32(view, props.values, AWD2_MATERIAL_PROP_NORMAL_TEXTURE) ?? 0;
1145
+ const color = readAwdPropertyUint32(view, props.values, AWD2_MATERIAL_PROP_COLOR);
1146
+ const alpha = readAwdPropertyFloat32(view, props.values, AWD2_MATERIAL_PROP_ALPHA);
1147
+ return {
1148
+ alpha,
1149
+ color,
1150
+ diffuseTextureId,
1151
+ name: nameResult.value,
1152
+ normalTextureId,
1153
+ numMethods,
1154
+ };
1155
+ }
1156
+ // Parses a Texture block (type 82). Layout:
1157
+ // name(VarString) → texType(uint8) → dataLen(uint32) → data(dataLen bytes) → PropertyList → UserAttrList.
1158
+ // The embedded form carries a self-describing image payload (PNG/JPEG/…); the external form stores a
1159
+ // URL Flight cannot fetch at parse time and is returned as an unresolved (byte-less) slot.
1160
+ function parseTextureBlock(view, source, start, end, diagnostics) {
1161
+ const dv = view;
1162
+ let offset = start;
1163
+ if (offset + 2 > end) {
1164
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.texture-truncated', 'parseTextureBlock', {
1165
+ field: 'name',
1166
+ });
1167
+ return null;
1168
+ }
1169
+ const nameResult = readAwdString(view, source, offset);
1170
+ offset = nameResult.end;
1171
+ if (offset + 5 > end) {
1172
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.texture-truncated', 'parseTextureBlock', {
1173
+ field: 'payload',
1174
+ });
1175
+ return null;
1176
+ }
1177
+ const texType = source[offset];
1178
+ offset += 1;
1179
+ const dataLen = dv.getUint32(offset, true);
1180
+ offset += 4;
1181
+ if (offset + dataLen > end) {
1182
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.texture-truncated', 'parseTextureBlock', {
1183
+ bytes: dataLen,
1184
+ field: 'data',
1185
+ });
1186
+ return null;
1187
+ }
1188
+ if (texType !== AWD2_TEXTURE_TYPE_EMBEDDED) {
1189
+ // AWD's external form carries the image URL as the block name; emit it as an External ref for
1190
+ // the resolver to fetch, rather than dropping it.
1191
+ return {
1192
+ bytes: null,
1193
+ mimeType: null,
1194
+ name: nameResult.value,
1195
+ url: nameResult.value,
1196
+ };
1197
+ }
1198
+ const bytes = source.slice(offset, offset + dataLen);
1199
+ const mimeType = detectImageMimeType(bytes);
1200
+ if (mimeType === null) {
1201
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'awd2.texture-unrecognized-format', 'parseTextureBlock');
1202
+ return { bytes: null, mimeType: null, name: nameResult.value, url: null };
1203
+ }
1204
+ return { bytes, mimeType, name: nameResult.value, url: null };
1205
+ }
1206
+ // Reads an AWD typed-property list: a uint32 byte-length prefix followed by `uint16 key, uint32
1207
+ // fieldLength, <value>` records. Returns each key's value span so callers decode only the keys they
1208
+ // know; unknown keys are stepped over by their length.
1209
+ function readAwdProperties(view, offset, end) {
1210
+ const dv = view;
1211
+ const values = new Map();
1212
+ if (offset + 4 > end)
1213
+ return { end: offset, values };
1214
+ const listLength = dv.getUint32(offset, true);
1215
+ offset += 4;
1216
+ const listEnd = Math.min(offset + listLength, end);
1217
+ while (offset + 6 <= listEnd) {
1218
+ const key = dv.getUint16(offset, true);
1219
+ offset += 2;
1220
+ const fieldLength = dv.getUint32(offset, true);
1221
+ offset += 4;
1222
+ if (offset + fieldLength > listEnd)
1223
+ break;
1224
+ values.set(key, { length: fieldLength, offset });
1225
+ offset += fieldLength;
1226
+ }
1227
+ return { end: listEnd, values };
1228
+ }
1229
+ function readAwdPropertyFloat32(view, values, key) {
1230
+ const entry = values.get(key);
1231
+ if (entry === undefined || entry.length < 4)
1232
+ return null;
1233
+ return view.getFloat32(entry.offset, true);
1234
+ }
1235
+ function readAwdPropertyUint32(view, values, key) {
1236
+ const entry = values.get(key);
1237
+ if (entry === undefined || entry.length < 4)
1238
+ return null;
1239
+ return view.getUint32(entry.offset, true);
1240
+ }
1241
+ // Resolves an AWD material block id to a document material INDEX (appended to `document.materials`),
1242
+ // memoized so a material shared by several subsets registers one entry (and one Texture/ImageResource per
1243
+ // shared texture). An EXISTING material block ALWAYS produces a ShadedMaterial — a block with no base
1244
+ // properties (no color, no textures, alpha-only, or method-only) is a valid material and defaults to
1245
+ // opaque white, honoring the uniform-ShadedMaterial rule. Only the sentinel id 0 (subset declares no
1246
+ // material) and a referenced-but-missing block id return -1 (the assembler resolves that to a null material).
1247
+ //
1248
+ // AWD materials import as ShadedMaterial UNIFORMLY — including numMethods=0 (empty modifiers[]). AWD's
1249
+ // material model is a MethodMaterial = a BlinnPhong base + a METHODS ARRAY; ShadedMaterial (base + an
1250
+ // ordered modifiers[]) is its structural image. An empty stack honestly encodes a method-less material
1251
+ // and compiles to the same base program as BlinnPhong (zero pixel cost), while (a) preserving losslessness
1252
+ // if a file/exporter DOES carry methods, and (b) letting a demo author reproduce the original Away3D look
1253
+ // by APPENDING a fresnel/fog modifier — no material-kind conversion. Do NOT collapse method-less materials
1254
+ // to BlinnPhong: that discards the format's array-shaped intent to save a type. (Callers who genuinely want
1255
+ // the leaner kind can down-convert a modifier-less ShadedMaterial to BlinnPhong themselves.)
1256
+ //
1257
+ // A method-bearing material (numMethods > 0) records a Skip diagnostic and imports its base only: the AWD2 method-block layout
1258
+ // is unverified in-sandbox (the whole reference corpus is numMethods=0), so shipping a blind method→modifier
1259
+ // walk would be speculative. The base carries color(1), diffuseTex(2), normalTex(3), alpha(10) — the only
1260
+ // base properties real AWD2 files use; specular/gloss/ambient are METHODS in Away3D's model, not base props.
1261
+ function resolveAwdMaterial(materialId, materialBlocks, textureBlocks, cache, document, diagnostics) {
1262
+ if (materialId === 0)
1263
+ return -1;
1264
+ const cached = cache.get(materialId);
1265
+ if (cached !== undefined)
1266
+ return cached;
1267
+ const parsed = materialBlocks.get(materialId);
1268
+ if (parsed === undefined) {
1269
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.material-missing', 'resolveAwdMaterial', {
1270
+ material: materialId,
1271
+ });
1272
+ cache.set(materialId, -1);
1273
+ return -1;
1274
+ }
1275
+ const diffuseTexture = parsed.diffuseTextureId !== 0
1276
+ ? resolveAwdTexture(parsed.diffuseTextureId, textureBlocks, document, diagnostics)
1277
+ : null;
1278
+ const normalTexture = parsed.normalTextureId !== 0
1279
+ ? resolveAwdTexture(parsed.normalTextureId, textureBlocks, document, diagnostics)
1280
+ : null;
1281
+ // A method-bearing material imports its base only (see the header note) — record a diagnostic rather than silently drop.
1282
+ if (parsed.numMethods > 0) {
1283
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Skip, 'awd2.material-methods-unsupported', 'resolveAwdMaterial', { methods: parsed.numMethods });
1284
+ }
1285
+ const diffuse = getAwdDiffuseRgba(parsed.color, parsed.alpha);
1286
+ const material = createShadedMaterial({
1287
+ diffuse,
1288
+ diffuseMap: diffuseTexture,
1289
+ normalMap: normalTexture,
1290
+ });
1291
+ // An alpha below 1 must actually blend; ShadedMaterial defaults to opaque coverage.
1292
+ if (parsed.alpha !== null && parsed.alpha < 1)
1293
+ material.alphaMode = 'blend';
1294
+ // Preserve the AWD material block name as the material's authored name (empty → anonymous).
1295
+ material.name = parsed.name.length > 0 ? parsed.name : null;
1296
+ const index = document.materials.length;
1297
+ document.materials.push(material);
1298
+ cache.set(materialId, index);
1299
+ return index;
1300
+ }
1301
+ // Resolves an AWD texture block id to a Flight Texture carrying an unresolved ImageResourceReference, or
1302
+ // null when the texture is missing or its embedded payload was an unrecognized image format. The
1303
+ // parser references — it does not decode: an embedded block emits an Embedded ref holding the encoded
1304
+ // bytes; an external block emits an External ref holding the URL. The Texture's `image` stays null
1305
+ // until @flighthq/scene3d-resources resolves the ref.
1306
+ function resolveAwdTexture(textureId, textureBlocks, document, diagnostics) {
1307
+ const parsed = textureBlocks.get(textureId);
1308
+ if (parsed === undefined) {
1309
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.texture-missing', 'resolveAwdTexture', {
1310
+ texture: textureId,
1311
+ });
1312
+ return null;
1313
+ }
1314
+ if (parsed.bytes !== null && parsed.mimeType !== null) {
1315
+ return createEmbeddedTextureRef(parsed.bytes, parsed.mimeType, document.resources);
1316
+ }
1317
+ if (parsed.url !== null) {
1318
+ return createExternalTextureRef(parsed.url, null, document.resources);
1319
+ }
1320
+ return null;
1321
+ }
1322
+ // Packs an AWD material's diffuse into a Flight 0xrrggbbaa color. AWD stores the color as 24-bit 0xrrggbb
1323
+ // (property 1) and opacity as a separate float32 alpha (property 10); Flight folds them into one packed
1324
+ // RGBA. A missing color defaults to white, a missing alpha to fully opaque.
1325
+ function getAwdDiffuseRgba(color, alpha) {
1326
+ const rgb = color ?? 0xffffff;
1327
+ const alphaByte = alpha !== null ? Math.max(0, Math.min(255, Math.round(alpha * 255))) : 0xff;
1328
+ return (((rgb << 8) >>> 0) | alphaByte) >>> 0;
1329
+ }
1330
+ // Parses a Skeleton block (type 101). Layout:
1331
+ // name(VarString) → jointCount(uint16) → NumAttrList → per joint:
1332
+ // jointId(uint16) → parentId(uint16, 1-based, 0=root) → name(VarString)
1333
+ // → matrix4x3(12 × floatSize) → NumAttrList → UserAttrList
1334
+ function parseSkeletonBlock(view, source, start, end, matrixWide, diagnostics) {
1335
+ const dv = view;
1336
+ let offset = start;
1337
+ if (offset + 2 > end) {
1338
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-truncated', 'parseSkeletonBlock', {
1339
+ field: 'name',
1340
+ });
1341
+ return null;
1342
+ }
1343
+ const nameResult = readAwdString(view, source, offset);
1344
+ offset = nameResult.end;
1345
+ if (offset + 2 > end) {
1346
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-truncated', 'parseSkeletonBlock', {
1347
+ field: 'jointCount',
1348
+ });
1349
+ return null;
1350
+ }
1351
+ const jointCount = dv.getUint16(offset, true);
1352
+ offset += 2;
1353
+ offset = skipAwdAttrList(view, offset, end);
1354
+ const joints = [];
1355
+ for (let j = 0; j < jointCount; j++) {
1356
+ // Joint ID (sequential, 0-based).
1357
+ if (offset + 4 > end) {
1358
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-truncated', 'parseSkeletonBlock', {
1359
+ field: 'jointFields',
1360
+ });
1361
+ return null;
1362
+ }
1363
+ offset += 2; // skip jointId (implicit from array position)
1364
+ const parentIndex = dv.getUint16(offset, true);
1365
+ offset += 2;
1366
+ if (offset + 2 > end) {
1367
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-truncated', 'parseSkeletonBlock', {
1368
+ field: 'jointName',
1369
+ });
1370
+ return null;
1371
+ }
1372
+ const jointNameResult = readAwdString(view, source, offset);
1373
+ offset = jointNameResult.end;
1374
+ const floatSize = matrixWide ? 8 : 4;
1375
+ if (offset + 12 * floatSize > end) {
1376
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-truncated', 'parseSkeletonBlock', {
1377
+ field: 'jointTransform',
1378
+ });
1379
+ return null;
1380
+ }
1381
+ const transformResult = readAwdTransform(view, offset, matrixWide);
1382
+ offset = transformResult.end;
1383
+ offset = skipAwdAttrList(view, offset, end);
1384
+ offset = skipAwdAttrList(view, offset, end);
1385
+ joints.push({
1386
+ name: jointNameResult.value,
1387
+ parentIndex,
1388
+ transform: transformResult.transform,
1389
+ });
1390
+ }
1391
+ return { joints, name: nameResult.value };
1392
+ }
1393
+ // Parses a SkeletonPose block (type 102). Layout:
1394
+ // name(VarString) → jointCount(uint16) → NumAttrList → per joint:
1395
+ // hasTransform(uint8) → optional matrix4x3(12 × floatSize)
1396
+ function parseSkeletonPoseBlock(view, source, start, end, matrixWide, diagnostics) {
1397
+ const dv = view;
1398
+ let offset = start;
1399
+ if (offset + 2 > end) {
1400
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-pose-truncated', 'parseSkeletonPoseBlock', {
1401
+ field: 'name',
1402
+ });
1403
+ return null;
1404
+ }
1405
+ const nameResult = readAwdString(view, source, offset);
1406
+ offset = nameResult.end;
1407
+ if (offset + 2 > end) {
1408
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-pose-truncated', 'parseSkeletonPoseBlock', {
1409
+ field: 'jointCount',
1410
+ });
1411
+ return null;
1412
+ }
1413
+ const jointCount = dv.getUint16(offset, true);
1414
+ offset += 2;
1415
+ offset = skipAwdAttrList(view, offset, end);
1416
+ const jointTransforms = [];
1417
+ for (let j = 0; j < jointCount; j++) {
1418
+ if (offset + 1 > end) {
1419
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-pose-truncated', 'parseSkeletonPoseBlock', {
1420
+ field: 'hasTransform',
1421
+ });
1422
+ return null;
1423
+ }
1424
+ const hasTransform = dv.getUint8(offset);
1425
+ offset += 1;
1426
+ if (hasTransform !== 0) {
1427
+ const floatSize = matrixWide ? 8 : 4;
1428
+ if (offset + 12 * floatSize > end) {
1429
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-pose-truncated', 'parseSkeletonPoseBlock', {
1430
+ field: 'jointTransform',
1431
+ });
1432
+ return null;
1433
+ }
1434
+ const transformResult = readAwdTransform(view, offset, matrixWide);
1435
+ offset = transformResult.end;
1436
+ jointTransforms.push(transformResult.transform);
1437
+ }
1438
+ else {
1439
+ jointTransforms.push(null);
1440
+ }
1441
+ }
1442
+ return { jointTransforms, name: nameResult.value };
1443
+ }
1444
+ // Parses a SkeletonAnimation block (type 103). Layout:
1445
+ // name(VarString) → frameCount(uint16) → NumAttrList → per frame:
1446
+ // poseBlockId(uint32) → duration(uint16, milliseconds)
1447
+ function parseSkeletonAnimationBlock(view, source, start, end, diagnostics) {
1448
+ const dv = view;
1449
+ let offset = start;
1450
+ if (offset + 2 > end) {
1451
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-animation-truncated', 'parseSkeletonAnimationBlock', {
1452
+ field: 'name',
1453
+ });
1454
+ return null;
1455
+ }
1456
+ const nameResult = readAwdString(view, source, offset);
1457
+ offset = nameResult.end;
1458
+ if (offset + 2 > end) {
1459
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-animation-truncated', 'parseSkeletonAnimationBlock', {
1460
+ field: 'frameCount',
1461
+ });
1462
+ return null;
1463
+ }
1464
+ const poseCount = dv.getUint16(offset, true);
1465
+ offset += 2;
1466
+ offset = skipAwdAttrList(view, offset, end);
1467
+ const poses = [];
1468
+ for (let p = 0; p < poseCount; p++) {
1469
+ if (offset + 4 > end) {
1470
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-animation-truncated', 'parseSkeletonAnimationBlock', {
1471
+ field: 'poseBlockId',
1472
+ });
1473
+ return null;
1474
+ }
1475
+ const poseBlockId = dv.getUint32(offset, true);
1476
+ offset += 4;
1477
+ if (offset + 2 > end) {
1478
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Drop, 'awd2.skeleton-animation-truncated', 'parseSkeletonAnimationBlock', {
1479
+ field: 'poseDuration',
1480
+ });
1481
+ return null;
1482
+ }
1483
+ const duration = dv.getUint16(offset, true);
1484
+ offset += 2;
1485
+ poses.push({ duration, poseBlockId });
1486
+ }
1487
+ return { name: nameResult.value, poses };
1488
+ }
1489
+ // Resolves the block-stream buffer to walk: the source unchanged for an uncompressed body, or the 12-byte
1490
+ // header spliced in front of the inflated body for a compressed one — so the caller's walk is identical
1491
+ // either way (compression byte rewritten to NONE, body-length field to the inflated length). Returns null
1492
+ // (after recording a diagnostic) when the compression method has no registered decompressor or the codec fails.
1493
+ function rehydrateAwdBody(input, diagnostics) {
1494
+ const view = new DataView(input.buffer, input.byteOffset, input.byteLength);
1495
+ const compression = input[7];
1496
+ if (compression === AWD2_COMPRESSION_NONE)
1497
+ return { source: input, view };
1498
+ const decompressor = awdDecompressors.get(compression);
1499
+ if (decompressor === undefined) {
1500
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.compression-no-decompressor', 'rehydrateAwdBody', { compression });
1501
+ return null;
1502
+ }
1503
+ // The header's body-length field is the on-disk (compressed) length; the compressed stream is the bytes
1504
+ // from the end of the 12-byte header to there.
1505
+ const compressedEnd = Math.min(AWD2_HEADER_BYTES + view.getUint32(8, true), input.byteLength);
1506
+ const inflated = decompressor(input.subarray(AWD2_HEADER_BYTES, compressedEnd));
1507
+ if (inflated === null) {
1508
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'awd2.decompression-failed', 'rehydrateAwdBody', { compression });
1509
+ return null;
1510
+ }
1511
+ const rehydrated = new Uint8Array(AWD2_HEADER_BYTES + inflated.byteLength);
1512
+ rehydrated.set(input.subarray(0, AWD2_HEADER_BYTES), 0);
1513
+ rehydrated.set(inflated, AWD2_HEADER_BYTES);
1514
+ const rehydratedView = new DataView(rehydrated.buffer);
1515
+ rehydrated[7] = AWD2_COMPRESSION_NONE;
1516
+ rehydratedView.setUint32(8, inflated.byteLength, true);
1517
+ return { source: rehydrated, view: rehydratedView };
1518
+ }
1519
+ // Bitangent handedness written into every AWD tangent's W (B = W·normal×tangent). AWD carries no W and
1520
+ // Away3D uses a single mesh-wide handedness (bitangent = normal×tangent in its left-handed space); the
1521
+ // left→right-handed conversion (negateVec3Z, det = -1) inverts it, making -1 the correct Flight-space
1522
+ // sign. Kept as one named constant so a render proof against a normal-mapped fixture (shambler) can flip
1523
+ // it in one place if Away3D's convention proves to be the opposite chirality.
1524
+ const AWD2_TANGENT_HANDEDNESS = -1;
1525
+ // Opt-in decompressor registry keyed by the header compression byte. Empty by default so an inflate/LZMA
1526
+ // codec is only pulled into a bundle when the caller registers one (registerAwd2Decompressor).
1527
+ const awdDecompressors = new Map();
1528
+ // Whether a decomposed pose scale departs from unit within tolerance — the gate for emitting a scale
1529
+ // animation channel (skipped for the common identity-scale skeletons so clips stay lean).
1530
+ function hasNonUnitScale(sx, sy, sz) {
1531
+ return Math.abs(sx - 1) > 1e-4 || Math.abs(sy - 1) > 1e-4 || Math.abs(sz - 1) > 1e-4;
1532
+ }
1533
+ //# sourceMappingURL=awd2Parse.js.map