@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.
- package/dist/awd2Inflate.d.ts +4 -0
- package/dist/awd2Inflate.d.ts.map +1 -0
- package/dist/awd2Inflate.js +249 -0
- package/dist/awd2Inflate.js.map +1 -0
- package/dist/awd2Parse.d.ts +7 -0
- package/dist/awd2Parse.d.ts.map +1 -0
- package/dist/awd2Parse.js +1533 -0
- package/dist/awd2Parse.js.map +1 -0
- package/dist/awd2Schema.d.ts +44 -0
- package/dist/awd2Schema.d.ts.map +1 -0
- package/dist/awd2Schema.js +67 -0
- package/dist/awd2Schema.js.map +1 -0
- package/dist/gltfParse.d.ts +9 -0
- package/dist/gltfParse.d.ts.map +1 -0
- package/dist/gltfParse.js +1456 -0
- package/dist/gltfParse.js.map +1 -0
- package/dist/gltfPunctualLights.d.ts +3 -0
- package/dist/gltfPunctualLights.d.ts.map +1 -0
- package/dist/gltfPunctualLights.js +103 -0
- package/dist/gltfPunctualLights.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/md2Parse.d.ts +5 -0
- package/dist/md2Parse.d.ts.map +1 -0
- package/dist/md2Parse.js +408 -0
- package/dist/md2Parse.js.map +1 -0
- package/dist/md2Schema.d.ts +12 -0
- package/dist/md2Schema.d.ts.map +1 -0
- package/dist/md2Schema.js +193 -0
- package/dist/md2Schema.js.map +1 -0
- package/dist/md5AnimParse.d.ts +3 -0
- package/dist/md5AnimParse.d.ts.map +1 -0
- package/dist/md5AnimParse.js +385 -0
- package/dist/md5AnimParse.js.map +1 -0
- package/dist/md5Parse.d.ts +6 -0
- package/dist/md5Parse.d.ts.map +1 -0
- package/dist/md5Parse.js +635 -0
- package/dist/md5Parse.js.map +1 -0
- package/dist/mtlParse.d.ts +3 -0
- package/dist/mtlParse.d.ts.map +1 -0
- package/dist/mtlParse.js +256 -0
- package/dist/mtlParse.js.map +1 -0
- package/dist/objParse.d.ts +5 -0
- package/dist/objParse.d.ts.map +1 -0
- package/dist/objParse.js +464 -0
- package/dist/objParse.js.map +1 -0
- package/dist/sceneSkeleton.d.ts +3 -0
- package/dist/sceneSkeleton.d.ts.map +1 -0
- package/dist/sceneSkeleton.js +34 -0
- package/dist/sceneSkeleton.js.map +1 -0
- package/dist/shared.d.ts +18 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +143 -0
- package/dist/shared.js.map +1 -0
- package/dist/threeDsParse.d.ts +4 -0
- package/dist/threeDsParse.d.ts.map +1 -0
- package/dist/threeDsParse.js +718 -0
- package/dist/threeDsParse.js.map +1 -0
- package/package.json +55 -0
- package/src/awd2Inflate.test.ts +118 -0
- package/src/awd2Parse.test.ts +2431 -0
- package/src/gltfParse.test.ts +2339 -0
- package/src/gltfPunctualLights.test.ts +184 -0
- package/src/gltfTreeShaking.test.ts +45 -0
- package/src/md2Parse.test.ts +1033 -0
- package/src/md5AnimParse.test.ts +673 -0
- package/src/md5Parse.test.ts +1094 -0
- package/src/mtlParse.test.ts +244 -0
- package/src/objParse.test.ts +690 -0
- package/src/sceneSkeleton.test.ts +35 -0
- package/src/shared.test.ts +210 -0
- package/src/threeDsParse.test.ts +1089 -0
|
@@ -0,0 +1,2431 @@
|
|
|
1
|
+
import { sampleAnimationTrack } from '@flighthq/animation';
|
|
2
|
+
import {
|
|
3
|
+
getMeshGeometryIndexCount,
|
|
4
|
+
getMeshGeometryVertexCount,
|
|
5
|
+
getMeshGeometryVertexNormal,
|
|
6
|
+
getMeshGeometryVertexPosition,
|
|
7
|
+
getMeshGeometryVertexUv0,
|
|
8
|
+
} from '@flighthq/mesh';
|
|
9
|
+
import { getNodeChildren, getNodeLocalMatrix4, getNodeParent } from '@flighthq/node';
|
|
10
|
+
import { createNode3D, isMesh } from '@flighthq/scene3d';
|
|
11
|
+
import type {
|
|
12
|
+
AnimationClip,
|
|
13
|
+
AwdDecompressor,
|
|
14
|
+
EmbeddedImageResourceReference,
|
|
15
|
+
ExternalImageResourceReference,
|
|
16
|
+
ImportDiagnostic,
|
|
17
|
+
Mesh,
|
|
18
|
+
Scene3DAnimationTarget,
|
|
19
|
+
Node3D,
|
|
20
|
+
ShadedMaterial,
|
|
21
|
+
} from '@flighthq/types';
|
|
22
|
+
import { ResourceResolutionState, ShadedMaterialKind } from '@flighthq/types';
|
|
23
|
+
|
|
24
|
+
import { registerAwd2DeflateDecompressor } from './awd2Inflate';
|
|
25
|
+
import { createScene3DFromAwd2, parseAwd2, parseAwd2SkeletonAnimations, registerAwd2Decompressor } from './awd2Parse';
|
|
26
|
+
import {
|
|
27
|
+
AWD2_BLOCK_CONTAINER,
|
|
28
|
+
AWD2_BLOCK_MATERIAL,
|
|
29
|
+
AWD2_BLOCK_MESH_INSTANCE,
|
|
30
|
+
AWD2_BLOCK_SKELETON,
|
|
31
|
+
AWD2_BLOCK_SKELETON_ANIMATION,
|
|
32
|
+
AWD2_BLOCK_SKELETON_POSE,
|
|
33
|
+
AWD2_BLOCK_TEXTURE,
|
|
34
|
+
AWD2_BLOCK_TRIANGLE_GEOMETRY,
|
|
35
|
+
AWD2_COMPRESSION_DEFLATE,
|
|
36
|
+
AWD2_COMPRESSION_LZMA,
|
|
37
|
+
AWD2_DATA_FLOAT32,
|
|
38
|
+
AWD2_DATA_UINT16,
|
|
39
|
+
AWD2_HEADER_BYTES,
|
|
40
|
+
AWD2_MATERIAL_PROP_ALPHA,
|
|
41
|
+
AWD2_MATERIAL_PROP_COLOR,
|
|
42
|
+
AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE,
|
|
43
|
+
AWD2_MATERIAL_PROP_NORMAL_TEXTURE,
|
|
44
|
+
AWD2_MATERIAL_TYPE_COLOR,
|
|
45
|
+
AWD2_MATERIAL_TYPE_TEXTURE,
|
|
46
|
+
AWD2_NAMESPACE_CORE,
|
|
47
|
+
AWD2_STREAM_INDICES,
|
|
48
|
+
AWD2_STREAM_JOINT_INDICES,
|
|
49
|
+
AWD2_STREAM_JOINT_WEIGHTS,
|
|
50
|
+
AWD2_STREAM_NORMALS,
|
|
51
|
+
AWD2_STREAM_POSITIONS,
|
|
52
|
+
AWD2_STREAM_TANGENTS,
|
|
53
|
+
AWD2_STREAM_UVS,
|
|
54
|
+
AWD2_TEXTURE_TYPE_EMBEDDED,
|
|
55
|
+
AWD2_TEXTURE_TYPE_EXTERNAL,
|
|
56
|
+
} from './awd2Schema';
|
|
57
|
+
|
|
58
|
+
function buildAwdHeader(bodyLength: number, compression = 0, flags = 0, versionMajor = 2): Uint8Array {
|
|
59
|
+
const header = new Uint8Array(12);
|
|
60
|
+
const view = new DataView(header.buffer);
|
|
61
|
+
header[0] = 0x41; // 'A'
|
|
62
|
+
header[1] = 0x57; // 'W'
|
|
63
|
+
header[2] = 0x44; // 'D'
|
|
64
|
+
header[3] = versionMajor; // version major
|
|
65
|
+
header[4] = 1; // version minor
|
|
66
|
+
view.setUint16(5, flags, true);
|
|
67
|
+
header[7] = compression;
|
|
68
|
+
view.setUint32(8, bodyLength, true);
|
|
69
|
+
return header;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildBlockHeader(
|
|
73
|
+
blockId: number,
|
|
74
|
+
blockType: number,
|
|
75
|
+
blockLength: number,
|
|
76
|
+
blockFlags = 0,
|
|
77
|
+
namespace = AWD2_NAMESPACE_CORE,
|
|
78
|
+
): Uint8Array {
|
|
79
|
+
const header = new Uint8Array(11);
|
|
80
|
+
const view = new DataView(header.buffer);
|
|
81
|
+
view.setUint32(0, blockId, true);
|
|
82
|
+
header[4] = namespace;
|
|
83
|
+
header[5] = blockType;
|
|
84
|
+
header[6] = blockFlags;
|
|
85
|
+
view.setUint32(7, blockLength, true);
|
|
86
|
+
return header;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildAwdString(s: string): Uint8Array {
|
|
90
|
+
const encoded = new TextEncoder().encode(s);
|
|
91
|
+
const result = new Uint8Array(2 + encoded.length);
|
|
92
|
+
const view = new DataView(result.buffer);
|
|
93
|
+
view.setUint16(0, encoded.length, true);
|
|
94
|
+
result.set(encoded, 2);
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Builds an empty attribute list: uint32(0) byte-length prefix = 4 bytes.
|
|
99
|
+
function buildEmptyAttrList(): Uint8Array {
|
|
100
|
+
return new Uint8Array(4);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Builds an attribute stream: streamType(1) + dataType(1) + byteLength(4) + data.
|
|
104
|
+
function buildStream(streamType: number, dataType: number, data: ArrayBufferView): Uint8Array {
|
|
105
|
+
const result = new Uint8Array(6 + data.byteLength);
|
|
106
|
+
const view = new DataView(result.buffer);
|
|
107
|
+
result[0] = streamType;
|
|
108
|
+
result[1] = dataType;
|
|
109
|
+
view.setUint32(2, data.byteLength, true);
|
|
110
|
+
result.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), 6);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Sub-mesh layout: totalByteLen(uint32) → NumAttrList → streams → UserAttrList
|
|
115
|
+
function buildTriangleGeometryBody(name: string, subMeshes: Array<{ streams: Uint8Array[] }>): Uint8Array {
|
|
116
|
+
const nameBytes = buildAwdString(name);
|
|
117
|
+
const numSubMeshesBytes = new Uint8Array(2);
|
|
118
|
+
new DataView(numSubMeshesBytes.buffer).setUint16(0, subMeshes.length, true);
|
|
119
|
+
const geoAttrList = buildEmptyAttrList();
|
|
120
|
+
|
|
121
|
+
const parts: Uint8Array[] = [nameBytes, numSubMeshesBytes, geoAttrList];
|
|
122
|
+
|
|
123
|
+
for (const subMesh of subMeshes) {
|
|
124
|
+
const subAttrList = buildEmptyAttrList();
|
|
125
|
+
const userAttrList = buildEmptyAttrList();
|
|
126
|
+
|
|
127
|
+
let streamsSize = 0;
|
|
128
|
+
for (const stream of subMesh.streams) streamsSize += stream.length;
|
|
129
|
+
const totalByteLen = subAttrList.length + streamsSize + userAttrList.length;
|
|
130
|
+
|
|
131
|
+
const lenBytes = new Uint8Array(4);
|
|
132
|
+
new DataView(lenBytes.buffer).setUint32(0, totalByteLen, true);
|
|
133
|
+
|
|
134
|
+
parts.push(lenBytes, subAttrList);
|
|
135
|
+
for (const stream of subMesh.streams) parts.push(stream);
|
|
136
|
+
parts.push(userAttrList);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return concatBytes(...parts);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Scene3DHeader layout: parentId(uint32) → matrix4x3(12×float32) → name(VarString)
|
|
143
|
+
// Container adds: NumAttrList → UserAttrList
|
|
144
|
+
function buildContainerBody(name: string, parentId: number, transform: number[]): Uint8Array {
|
|
145
|
+
const nameBytes = buildAwdString(name);
|
|
146
|
+
const numAttrList = buildEmptyAttrList();
|
|
147
|
+
const userAttrList = buildEmptyAttrList();
|
|
148
|
+
const result = new Uint8Array(4 + 12 * 4 + nameBytes.length + numAttrList.length + userAttrList.length);
|
|
149
|
+
const view = new DataView(result.buffer);
|
|
150
|
+
let offset = 0;
|
|
151
|
+
view.setUint32(offset, parentId, true);
|
|
152
|
+
offset += 4;
|
|
153
|
+
for (let i = 0; i < 12; i++) {
|
|
154
|
+
view.setFloat32(offset + i * 4, transform[i] ?? 0, true);
|
|
155
|
+
}
|
|
156
|
+
offset += 12 * 4;
|
|
157
|
+
result.set(nameBytes, offset);
|
|
158
|
+
offset += nameBytes.length;
|
|
159
|
+
result.set(numAttrList, offset);
|
|
160
|
+
offset += numAttrList.length;
|
|
161
|
+
result.set(userAttrList, offset);
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// MeshInstance layout: Scene3DHeader → geometryId(uint32) → numMaterials(uint16) → materialIds → NumAttrList → UserAttrList
|
|
166
|
+
function buildMeshInstanceBody(name: string, parentId: number, transform: number[], geometryId: number): Uint8Array {
|
|
167
|
+
const nameBytes = buildAwdString(name);
|
|
168
|
+
const numAttrList = buildEmptyAttrList();
|
|
169
|
+
const userAttrList = buildEmptyAttrList();
|
|
170
|
+
const result = new Uint8Array(4 + 12 * 4 + nameBytes.length + 4 + 2 + numAttrList.length + userAttrList.length);
|
|
171
|
+
const view = new DataView(result.buffer);
|
|
172
|
+
let offset = 0;
|
|
173
|
+
view.setUint32(offset, parentId, true);
|
|
174
|
+
offset += 4;
|
|
175
|
+
for (let i = 0; i < 12; i++) {
|
|
176
|
+
view.setFloat32(offset + i * 4, transform[i] ?? 0, true);
|
|
177
|
+
}
|
|
178
|
+
offset += 12 * 4;
|
|
179
|
+
result.set(nameBytes, offset);
|
|
180
|
+
offset += nameBytes.length;
|
|
181
|
+
view.setUint32(offset, geometryId, true);
|
|
182
|
+
offset += 4;
|
|
183
|
+
view.setUint16(offset, 0, true);
|
|
184
|
+
offset += 2;
|
|
185
|
+
result.set(numAttrList, offset);
|
|
186
|
+
offset += numAttrList.length;
|
|
187
|
+
result.set(userAttrList, offset);
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// MeshInstance with a material id per subset (numMaterials > 0), unlike buildMeshInstanceBody.
|
|
192
|
+
function buildMeshInstanceBodyWithMaterials(
|
|
193
|
+
name: string,
|
|
194
|
+
parentId: number,
|
|
195
|
+
transform: number[],
|
|
196
|
+
geometryId: number,
|
|
197
|
+
materialIds: number[],
|
|
198
|
+
): Uint8Array {
|
|
199
|
+
const nameBytes = buildAwdString(name);
|
|
200
|
+
const head = new Uint8Array(4 + 12 * 4 + nameBytes.length + 4);
|
|
201
|
+
const view = new DataView(head.buffer);
|
|
202
|
+
let offset = 0;
|
|
203
|
+
view.setUint32(offset, parentId, true);
|
|
204
|
+
offset += 4;
|
|
205
|
+
for (let i = 0; i < 12; i++) view.setFloat32(offset + i * 4, transform[i] ?? 0, true);
|
|
206
|
+
offset += 12 * 4;
|
|
207
|
+
head.set(nameBytes, offset);
|
|
208
|
+
offset += nameBytes.length;
|
|
209
|
+
view.setUint32(offset, geometryId, true);
|
|
210
|
+
|
|
211
|
+
const mats = new Uint8Array(2 + materialIds.length * 4);
|
|
212
|
+
const mv = new DataView(mats.buffer);
|
|
213
|
+
mv.setUint16(0, materialIds.length, true);
|
|
214
|
+
for (let i = 0; i < materialIds.length; i++) mv.setUint32(2 + i * 4, materialIds[i], true);
|
|
215
|
+
|
|
216
|
+
return concatBytes(head, mats, buildEmptyAttrList(), buildEmptyAttrList());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Material layout: name(VarString) → matType(uint8) → numMethods(uint8) → PropertyList → UserAttrList.
|
|
220
|
+
// Each property here is a uint32 value (key, value) — enough for the diffuse-texture and color keys; a
|
|
221
|
+
// float32 property (e.g. alpha) is passed as its uint32 bit pattern via `float32Bits`. `numMethods` sets
|
|
222
|
+
// the declared method count (the method bodies themselves are not emitted — the parser does not walk them).
|
|
223
|
+
function buildMaterialBody(name: string, matType: number, props: Array<[number, number]>, numMethods = 0): Uint8Array {
|
|
224
|
+
const nameBytes = buildAwdString(name);
|
|
225
|
+
const recordSize = 2 + 4 + 4; // key(uint16) + fieldLength(uint32) + value(uint32)
|
|
226
|
+
const propList = new Uint8Array(4 + props.length * recordSize);
|
|
227
|
+
const pv = new DataView(propList.buffer);
|
|
228
|
+
pv.setUint32(0, props.length * recordSize, true);
|
|
229
|
+
let o = 4;
|
|
230
|
+
for (const [key, value] of props) {
|
|
231
|
+
pv.setUint16(o, key, true);
|
|
232
|
+
o += 2;
|
|
233
|
+
pv.setUint32(o, 4, true);
|
|
234
|
+
o += 4;
|
|
235
|
+
pv.setUint32(o, value, true);
|
|
236
|
+
o += 4;
|
|
237
|
+
}
|
|
238
|
+
return concatBytes(nameBytes, new Uint8Array([matType, numMethods]), propList, buildEmptyAttrList());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The uint32 bit pattern of a float32 value, so a float property can ride buildMaterialBody's uint32 slot.
|
|
242
|
+
function float32Bits(value: number): number {
|
|
243
|
+
const buffer = new ArrayBuffer(4);
|
|
244
|
+
const dv = new DataView(buffer);
|
|
245
|
+
dv.setFloat32(0, value, true);
|
|
246
|
+
return dv.getUint32(0, true);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// A minimal AWD file (triangle geometry + one material block, id 2, bound by a single mesh instance) for
|
|
250
|
+
// exercising resolveAwdMaterial on `matBody` in isolation. Read the result via mesh.materials[0].
|
|
251
|
+
function buildSingleMaterialAwd(matBody: Uint8Array): Uint8Array {
|
|
252
|
+
const posStream = buildStream(
|
|
253
|
+
AWD2_STREAM_POSITIONS,
|
|
254
|
+
AWD2_DATA_FLOAT32,
|
|
255
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
256
|
+
);
|
|
257
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
258
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
259
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [2]);
|
|
260
|
+
const body = concatBytes(
|
|
261
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
262
|
+
geomBody,
|
|
263
|
+
buildBlockHeader(2, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
264
|
+
matBody,
|
|
265
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
266
|
+
miBody,
|
|
267
|
+
);
|
|
268
|
+
return concatBytes(buildAwdHeader(body.length), body);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Texture layout: name(VarString) → texType(uint8) → dataLen(uint32) → data → PropertyList → UserAttrList.
|
|
272
|
+
function buildTextureBody(name: string, texType: number, imageBytes: Uint8Array): Uint8Array {
|
|
273
|
+
const nameBytes = buildAwdString(name);
|
|
274
|
+
const head = new Uint8Array(1 + 4);
|
|
275
|
+
head[0] = texType;
|
|
276
|
+
new DataView(head.buffer).setUint32(1, imageBytes.length, true);
|
|
277
|
+
return concatBytes(nameBytes, head, imageBytes, buildEmptyAttrList(), buildEmptyAttrList());
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// A 4-byte PNG signature — enough for detectImageMimeType to type the payload 'image/png'. The
|
|
281
|
+
// bytes never reach a decoder in these tests (none is registered), so a full image is unnecessary.
|
|
282
|
+
const FAKE_PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
283
|
+
|
|
284
|
+
function concatBytes(...arrays: readonly Uint8Array[]): Uint8Array {
|
|
285
|
+
let total = 0;
|
|
286
|
+
for (const a of arrays) total += a.length;
|
|
287
|
+
const result = new Uint8Array(total);
|
|
288
|
+
let offset = 0;
|
|
289
|
+
for (const a of arrays) {
|
|
290
|
+
result.set(a, offset);
|
|
291
|
+
offset += a.length;
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const IDENTITY_TRANSFORM = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0];
|
|
297
|
+
|
|
298
|
+
// A complete skinned AWD: a 3-vertex sub-mesh with joint-index/weight streams (2 influences/vertex),
|
|
299
|
+
// a 2-joint skeleton, a mesh instance, and a two-pose translation animation — enough to exercise the
|
|
300
|
+
// full skin path (joints0/weights0 emit, mesh.skin binding) and the anim→skin identity. The joint
|
|
301
|
+
// index stream declares float32 in its header (as real AWD exporters do) but is packed as uint16.
|
|
302
|
+
const SKINNED_TRIANGLE_AWD = (() => {
|
|
303
|
+
const posStream = buildStream(
|
|
304
|
+
AWD2_STREAM_POSITIONS,
|
|
305
|
+
AWD2_DATA_FLOAT32,
|
|
306
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
307
|
+
);
|
|
308
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
309
|
+
const jointIndexStream = buildStream(
|
|
310
|
+
AWD2_STREAM_JOINT_INDICES,
|
|
311
|
+
AWD2_DATA_FLOAT32,
|
|
312
|
+
new Uint16Array([0, 1, 0, 1, 1, 0]),
|
|
313
|
+
);
|
|
314
|
+
const jointWeightStream = buildStream(
|
|
315
|
+
AWD2_STREAM_JOINT_WEIGHTS,
|
|
316
|
+
AWD2_DATA_FLOAT32,
|
|
317
|
+
new Float32Array([0.75, 0.25, 0.5, 0.5, 1, 0]),
|
|
318
|
+
);
|
|
319
|
+
const geomBody = buildTriangleGeometryBody('Skinned', [
|
|
320
|
+
{ streams: [posStream, idxStream, jointIndexStream, jointWeightStream] },
|
|
321
|
+
]);
|
|
322
|
+
const skelBody = buildSkeletonBody('Rig', [
|
|
323
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
324
|
+
{
|
|
325
|
+
name: 'Child',
|
|
326
|
+
parentIndex: 1,
|
|
327
|
+
transform: [1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0],
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
const miBody = buildMeshInstanceBody('SkinnedMesh', 0, IDENTITY_TRANSFORM, 1);
|
|
331
|
+
const pose0Body = buildSkeletonPoseBody('P0', [IDENTITY_TRANSFORM, IDENTITY_TRANSFORM]);
|
|
332
|
+
const pose1Body = buildSkeletonPoseBody('P1', [
|
|
333
|
+
[1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 0],
|
|
334
|
+
[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 10, 0],
|
|
335
|
+
]);
|
|
336
|
+
const animBody = buildSkeletonAnimationBody('Walk', [
|
|
337
|
+
{ duration: 500, poseBlockId: 5 },
|
|
338
|
+
{ duration: 500, poseBlockId: 6 },
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
const body = concatBytes(
|
|
342
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
343
|
+
geomBody,
|
|
344
|
+
buildBlockHeader(2, AWD2_BLOCK_SKELETON, skelBody.length),
|
|
345
|
+
skelBody,
|
|
346
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
347
|
+
miBody,
|
|
348
|
+
buildBlockHeader(5, AWD2_BLOCK_SKELETON_POSE, pose0Body.length),
|
|
349
|
+
pose0Body,
|
|
350
|
+
buildBlockHeader(6, AWD2_BLOCK_SKELETON_POSE, pose1Body.length),
|
|
351
|
+
pose1Body,
|
|
352
|
+
buildBlockHeader(7, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length),
|
|
353
|
+
animBody,
|
|
354
|
+
);
|
|
355
|
+
return concatBytes(buildAwdHeader(body.length), body);
|
|
356
|
+
})();
|
|
357
|
+
|
|
358
|
+
// The single clip a one-animation AWD fixture yields (undefined for a file with no clip).
|
|
359
|
+
const firstAwdClip = (
|
|
360
|
+
bytes: Readonly<Uint8Array>,
|
|
361
|
+
joints: readonly Node3D[],
|
|
362
|
+
diagnostics?: ImportDiagnostic[],
|
|
363
|
+
): AnimationClip | undefined => Object.values(parseAwd2SkeletonAnimations(bytes, joints, diagnostics))[0];
|
|
364
|
+
|
|
365
|
+
// Asserts EXACTLY ONE crumb of `kind` was recorded (guards the count) and returns it so a test can lock
|
|
366
|
+
// the full contract — severity, true origin, and detail — for that emitted diagnostic.
|
|
367
|
+
function expectOneCrumb(diagnostics: readonly ImportDiagnostic[], kind: string): ImportDiagnostic {
|
|
368
|
+
const matches = diagnostics.filter((d) => d.kind === kind);
|
|
369
|
+
expect(matches).toHaveLength(1);
|
|
370
|
+
return matches[0];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
describe('awd2 diagnostic crumb coverage', () => {
|
|
374
|
+
it.each(CREATE_PATH_TRUNCATIONS)(
|
|
375
|
+
'records $kind (field $field) on the createScene3DFromAwd2 path',
|
|
376
|
+
({ blockType, body, bytes, cut, field, kind, origin }) => {
|
|
377
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
378
|
+
createScene3DFromAwd2(truncatedAwd(blockType, body, cut), diagnostics);
|
|
379
|
+
expect(diagnostics).toHaveLength(1);
|
|
380
|
+
const crumb = expectOneCrumb(diagnostics, kind);
|
|
381
|
+
expect(crumb.severity).toBe('Drop');
|
|
382
|
+
expect(crumb.origin).toBe(origin);
|
|
383
|
+
expect(crumb.detail?.field).toBe(field);
|
|
384
|
+
expect(crumb.detail?.bytes).toBe(bytes); // undefined for every field variant except texture 'data'
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// The pose/animation truncation crumbs on the createScene3DFromAwd2 DOCUMENT path — the path the sink
|
|
389
|
+
// repair fixed. A valid skeleton drives buildAwdDocumentAnimations (and suppresses no-skeleton-blocks),
|
|
390
|
+
// so the truncated block's crumb is the ONLY diagnostic: assert the full set is exactly one, not merely
|
|
391
|
+
// that this kind appears among others.
|
|
392
|
+
it.each(ANIM_PATH_TRUNCATIONS)(
|
|
393
|
+
'records $kind (field $field) on the repaired createScene3DFromAwd2 document path',
|
|
394
|
+
({ blockType, body, cut, field, kind, origin }) => {
|
|
395
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
396
|
+
createScene3DFromAwd2(docPathTruncatedAwd(blockType, body, cut), diagnostics);
|
|
397
|
+
expect(diagnostics).toHaveLength(1);
|
|
398
|
+
const crumb = expectOneCrumb(diagnostics, kind);
|
|
399
|
+
expect(crumb.severity).toBe('Drop');
|
|
400
|
+
expect(crumb.origin).toBe(origin);
|
|
401
|
+
expect(crumb.detail?.field).toBe(field);
|
|
402
|
+
expect(crumb.detail?.bytes).toBeUndefined();
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
it('records multiple-skeletons (Drop, parseAwd2) when a file carries more than one skeleton', () => {
|
|
407
|
+
const skel = buildSkeletonBody('Rig', [{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
408
|
+
const body = concatBytes(
|
|
409
|
+
buildBlockHeader(1, AWD2_BLOCK_SKELETON, skel.length),
|
|
410
|
+
skel,
|
|
411
|
+
buildBlockHeader(2, AWD2_BLOCK_SKELETON, skel.length),
|
|
412
|
+
skel,
|
|
413
|
+
);
|
|
414
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
415
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
416
|
+
expect(diagnostics).toHaveLength(1);
|
|
417
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.multiple-skeletons');
|
|
418
|
+
expect(crumb.severity).toBe('Drop');
|
|
419
|
+
expect(crumb.origin).toBe('parseAwd2');
|
|
420
|
+
expect(crumb.detail?.skeletons).toBe(2);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('records submesh-no-positions (Drop, parseTriangleGeometryBlock) when a sub-mesh has no position stream', () => {
|
|
424
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
425
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [idxStream] }]);
|
|
426
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
427
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
428
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
429
|
+
expect(diagnostics).toHaveLength(1);
|
|
430
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.submesh-no-positions');
|
|
431
|
+
expect(crumb.detail).toBeUndefined();
|
|
432
|
+
expect(crumb.severity).toBe('Drop');
|
|
433
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('records geometry-truncated (field name) when the block ends before the geometry name', () => {
|
|
437
|
+
const geomBody = new Uint8Array(1); // less than the 2 bytes a VarString length needs
|
|
438
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
439
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
440
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
441
|
+
expect(diagnostics).toHaveLength(1);
|
|
442
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.geometry-truncated');
|
|
443
|
+
expect(crumb.severity).toBe('Drop');
|
|
444
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
445
|
+
expect(crumb.detail?.field).toBe('name');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('records geometry-truncated (field name) when the declared name PAYLOAD runs past the block', () => {
|
|
449
|
+
// Length prefix says 10 name bytes, but only one payload byte is present — the missing bytes belong to
|
|
450
|
+
// the name, so the field must be 'name', not 'num-submeshes'.
|
|
451
|
+
const geomBody = new Uint8Array([10, 0, 65]);
|
|
452
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
453
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
454
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
455
|
+
expect(diagnostics).toHaveLength(1);
|
|
456
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.geometry-truncated');
|
|
457
|
+
expect(crumb.severity).toBe('Drop');
|
|
458
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
459
|
+
expect(crumb.detail?.field).toBe('name');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('records geometry-truncated (field num-submeshes) when the block ends before the sub-mesh count', () => {
|
|
463
|
+
const geomBody = buildAwdString('Geo'); // name only, nothing after
|
|
464
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
465
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
466
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
467
|
+
expect(diagnostics).toHaveLength(1);
|
|
468
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.geometry-truncated');
|
|
469
|
+
expect(crumb.severity).toBe('Drop');
|
|
470
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
471
|
+
expect(crumb.detail?.field).toBe('num-submeshes');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('records submesh-truncated when a declared sub-mesh header is missing', () => {
|
|
475
|
+
const numSubMeshes = new Uint8Array(2);
|
|
476
|
+
new DataView(numSubMeshes.buffer).setUint16(0, 1, true); // declares one sub-mesh, supplies none
|
|
477
|
+
const geomBody = concatBytes(buildAwdString('Geo'), numSubMeshes, new Uint8Array(4));
|
|
478
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
479
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
480
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
481
|
+
expect(diagnostics).toHaveLength(1);
|
|
482
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.submesh-truncated');
|
|
483
|
+
expect(crumb.severity).toBe('Drop');
|
|
484
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
485
|
+
expect(crumb.detail?.firstSubMesh).toBe(0);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('records stream-data-past-end (Recover, parseTriangleGeometryBlock) when a stream declares more bytes than the block holds', () => {
|
|
489
|
+
const posStream = buildStream(
|
|
490
|
+
AWD2_STREAM_POSITIONS,
|
|
491
|
+
AWD2_DATA_FLOAT32,
|
|
492
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
493
|
+
);
|
|
494
|
+
// A stream header that declares 9999 payload bytes but carries only 4 — overshoots the block end.
|
|
495
|
+
const badStream = new Uint8Array(6 + 4);
|
|
496
|
+
badStream[0] = AWD2_STREAM_UVS;
|
|
497
|
+
badStream[1] = AWD2_DATA_FLOAT32;
|
|
498
|
+
new DataView(badStream.buffer).setUint32(2, 9999, true);
|
|
499
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, badStream] }]);
|
|
500
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
501
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
502
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
503
|
+
expect(diagnostics).toHaveLength(1);
|
|
504
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.stream-data-past-end');
|
|
505
|
+
expect(crumb.detail).toBeUndefined();
|
|
506
|
+
expect(crumb.severity).toBe('Recover');
|
|
507
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('records skin-streams-mismatch (Recover, parseTriangleGeometryBlock) when joint streams do not match the vertex count', () => {
|
|
511
|
+
const posStream = buildStream(
|
|
512
|
+
AWD2_STREAM_POSITIONS,
|
|
513
|
+
AWD2_DATA_FLOAT32,
|
|
514
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
515
|
+
);
|
|
516
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
517
|
+
// 3 vertices, weights imply 2 influences/vertex, but only 2 joint indices are present (need 6).
|
|
518
|
+
const jointIndexStream = buildStream(AWD2_STREAM_JOINT_INDICES, AWD2_DATA_FLOAT32, new Uint16Array([0, 1]));
|
|
519
|
+
const jointWeightStream = buildStream(
|
|
520
|
+
AWD2_STREAM_JOINT_WEIGHTS,
|
|
521
|
+
AWD2_DATA_FLOAT32,
|
|
522
|
+
new Float32Array([0.5, 0.5, 0.5, 0.5, 0.5, 0.5]),
|
|
523
|
+
);
|
|
524
|
+
const geomBody = buildTriangleGeometryBody('Skin', [
|
|
525
|
+
{ streams: [posStream, idxStream, jointIndexStream, jointWeightStream] },
|
|
526
|
+
]);
|
|
527
|
+
const body = concatBytes(buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length), geomBody);
|
|
528
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
529
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
530
|
+
expect(diagnostics).toHaveLength(1);
|
|
531
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.skin-streams-mismatch');
|
|
532
|
+
expect(crumb.detail).toBeUndefined();
|
|
533
|
+
expect(crumb.severity).toBe('Recover');
|
|
534
|
+
expect(crumb.origin).toBe('parseTriangleGeometryBlock');
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('records material-missing (Drop, resolveAwdMaterial) when a mesh references an absent material block', () => {
|
|
538
|
+
const posStream = buildStream(
|
|
539
|
+
AWD2_STREAM_POSITIONS,
|
|
540
|
+
AWD2_DATA_FLOAT32,
|
|
541
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
542
|
+
);
|
|
543
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
544
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
545
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [99]);
|
|
546
|
+
const body = concatBytes(
|
|
547
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
548
|
+
geomBody,
|
|
549
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
550
|
+
miBody,
|
|
551
|
+
);
|
|
552
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
553
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
554
|
+
expect(diagnostics).toHaveLength(1);
|
|
555
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.material-missing');
|
|
556
|
+
expect(crumb.severity).toBe('Drop');
|
|
557
|
+
expect(crumb.origin).toBe('resolveAwdMaterial');
|
|
558
|
+
expect(crumb.detail?.material).toBe(99);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('records texture-missing (Drop, resolveAwdTexture) when a material references an absent texture block', () => {
|
|
562
|
+
const posStream = buildStream(
|
|
563
|
+
AWD2_STREAM_POSITIONS,
|
|
564
|
+
AWD2_DATA_FLOAT32,
|
|
565
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
566
|
+
);
|
|
567
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
568
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
569
|
+
const matBody = buildMaterialBody('Mat', AWD2_MATERIAL_TYPE_TEXTURE, [[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 99]]);
|
|
570
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [2]);
|
|
571
|
+
const body = concatBytes(
|
|
572
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
573
|
+
geomBody,
|
|
574
|
+
buildBlockHeader(2, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
575
|
+
matBody,
|
|
576
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
577
|
+
miBody,
|
|
578
|
+
);
|
|
579
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
580
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
581
|
+
expect(diagnostics).toHaveLength(1);
|
|
582
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.texture-missing');
|
|
583
|
+
expect(crumb.severity).toBe('Drop');
|
|
584
|
+
expect(crumb.origin).toBe('resolveAwdTexture');
|
|
585
|
+
expect(crumb.detail?.texture).toBe(99);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('records texture-unrecognized-format (Recover, parseTextureBlock) for an embedded payload that is not an image', () => {
|
|
589
|
+
const posStream = buildStream(
|
|
590
|
+
AWD2_STREAM_POSITIONS,
|
|
591
|
+
AWD2_DATA_FLOAT32,
|
|
592
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
593
|
+
);
|
|
594
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
595
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
596
|
+
const texBody = buildTextureBody('bad.dat', AWD2_TEXTURE_TYPE_EMBEDDED, new Uint8Array([1, 2, 3, 4]));
|
|
597
|
+
const matBody = buildMaterialBody('Mat', AWD2_MATERIAL_TYPE_TEXTURE, [[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 2]]);
|
|
598
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [3]);
|
|
599
|
+
const body = concatBytes(
|
|
600
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
601
|
+
geomBody,
|
|
602
|
+
buildBlockHeader(2, AWD2_BLOCK_TEXTURE, texBody.length),
|
|
603
|
+
texBody,
|
|
604
|
+
buildBlockHeader(3, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
605
|
+
matBody,
|
|
606
|
+
buildBlockHeader(4, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
607
|
+
miBody,
|
|
608
|
+
);
|
|
609
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
610
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
611
|
+
expect(diagnostics).toHaveLength(1);
|
|
612
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.texture-unrecognized-format');
|
|
613
|
+
expect(crumb.detail).toBeUndefined();
|
|
614
|
+
expect(crumb.severity).toBe('Recover');
|
|
615
|
+
expect(crumb.origin).toBe('parseTextureBlock');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('records animation-no-poses (Drop, buildAwdSkeletonAnimationClip) on the live clip path', () => {
|
|
619
|
+
const skel = buildSkeletonBody('Rig', [{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
620
|
+
const anim = buildSkeletonAnimationBody('Anim', []);
|
|
621
|
+
const body = concatBytes(
|
|
622
|
+
buildBlockHeader(1, AWD2_BLOCK_SKELETON, skel.length),
|
|
623
|
+
skel,
|
|
624
|
+
buildBlockHeader(2, AWD2_BLOCK_SKELETON_ANIMATION, anim.length),
|
|
625
|
+
anim,
|
|
626
|
+
);
|
|
627
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
628
|
+
parseAwd2SkeletonAnimations(concatBytes(buildAwdHeader(body.length), body), [createNode3D()], diagnostics);
|
|
629
|
+
expect(diagnostics).toHaveLength(1);
|
|
630
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.animation-no-poses');
|
|
631
|
+
expect(crumb.detail).toBeUndefined();
|
|
632
|
+
expect(crumb.severity).toBe('Drop');
|
|
633
|
+
expect(crumb.origin).toBe('buildAwdSkeletonAnimationClip');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('records animation-no-poses (Drop, buildAwdDocumentAnimation) on the document path', () => {
|
|
637
|
+
const skel = buildSkeletonBody('Rig', [{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
638
|
+
const anim = buildSkeletonAnimationBody('Anim', []);
|
|
639
|
+
const body = concatBytes(
|
|
640
|
+
buildBlockHeader(1, AWD2_BLOCK_SKELETON, skel.length),
|
|
641
|
+
skel,
|
|
642
|
+
buildBlockHeader(2, AWD2_BLOCK_SKELETON_ANIMATION, anim.length),
|
|
643
|
+
anim,
|
|
644
|
+
);
|
|
645
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
646
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
647
|
+
expect(diagnostics).toHaveLength(1);
|
|
648
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.animation-no-poses');
|
|
649
|
+
expect(crumb.detail).toBeUndefined();
|
|
650
|
+
expect(crumb.severity).toBe('Drop');
|
|
651
|
+
expect(crumb.origin).toBe('buildAwdDocumentAnimation');
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('records pose-block-missing (Recover, buildAwdDocumentAnimation) on the document path', () => {
|
|
655
|
+
const skel = buildSkeletonBody('Rig', [{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
656
|
+
const anim = buildSkeletonAnimationBody('Anim', [{ duration: 100, poseBlockId: 99 }]);
|
|
657
|
+
const body = concatBytes(
|
|
658
|
+
buildBlockHeader(1, AWD2_BLOCK_SKELETON, skel.length),
|
|
659
|
+
skel,
|
|
660
|
+
buildBlockHeader(2, AWD2_BLOCK_SKELETON_ANIMATION, anim.length),
|
|
661
|
+
anim,
|
|
662
|
+
);
|
|
663
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
664
|
+
createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
665
|
+
expect(diagnostics).toHaveLength(1);
|
|
666
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.pose-block-missing');
|
|
667
|
+
expect(crumb.severity).toBe('Recover');
|
|
668
|
+
expect(crumb.origin).toBe('buildAwdDocumentAnimation');
|
|
669
|
+
expect(crumb.detail?.distinctPoseBlocks).toBe(1);
|
|
670
|
+
expect(crumb.detail?.firstPoseBlock).toBe(99);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('records block-length-past-end (Recover, parseAwd2SkeletonAnimations) when a block overruns the body', () => {
|
|
674
|
+
const blockHeader = buildBlockHeader(1, AWD2_BLOCK_SKELETON, 9999);
|
|
675
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
676
|
+
parseAwd2SkeletonAnimations(concatBytes(buildAwdHeader(blockHeader.length), blockHeader), [], diagnostics);
|
|
677
|
+
// The overrun aborts the walk with no skeleton parsed, so the full emitted set is exactly these two,
|
|
678
|
+
// in walk order: the block-length abort, then the empty-result no-skeleton-blocks reject. Lock both.
|
|
679
|
+
expect(diagnostics.map((d) => d.kind)).toEqual(['awd2.block-length-past-end', 'awd2.no-skeleton-blocks']);
|
|
680
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.block-length-past-end');
|
|
681
|
+
expect(crumb.detail).toBeUndefined();
|
|
682
|
+
expect(crumb.severity).toBe('Recover');
|
|
683
|
+
expect(crumb.origin).toBe('parseAwd2SkeletonAnimations');
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// Skeleton block body: name → jointCount(uint16) → NumAttrList → per joint:
|
|
688
|
+
// jointId(uint16) → parentId(uint16, 1-based, 0=root) → name → matrix4x3(float32) → NumAttrList → UserAttrList
|
|
689
|
+
function buildSkeletonBody(
|
|
690
|
+
name: string,
|
|
691
|
+
joints: Array<{ name: string; parentIndex: number; transform: number[] }>,
|
|
692
|
+
): Uint8Array {
|
|
693
|
+
const parts: Uint8Array[] = [buildAwdString(name)];
|
|
694
|
+
const jointCountBytes = new Uint8Array(2);
|
|
695
|
+
new DataView(jointCountBytes.buffer).setUint16(0, joints.length, true);
|
|
696
|
+
parts.push(jointCountBytes);
|
|
697
|
+
parts.push(buildEmptyAttrList());
|
|
698
|
+
|
|
699
|
+
for (let j = 0; j < joints.length; j++) {
|
|
700
|
+
const joint = joints[j];
|
|
701
|
+
const headerBytes = new Uint8Array(4);
|
|
702
|
+
const hv = new DataView(headerBytes.buffer);
|
|
703
|
+
hv.setUint16(0, j, true);
|
|
704
|
+
hv.setUint16(2, joint.parentIndex, true);
|
|
705
|
+
parts.push(headerBytes);
|
|
706
|
+
parts.push(buildAwdString(joint.name));
|
|
707
|
+
const transformBytes = new Uint8Array(12 * 4);
|
|
708
|
+
const transformView = new DataView(transformBytes.buffer);
|
|
709
|
+
for (let i = 0; i < 12; i++) {
|
|
710
|
+
transformView.setFloat32(i * 4, joint.transform[i] ?? 0, true);
|
|
711
|
+
}
|
|
712
|
+
parts.push(transformBytes);
|
|
713
|
+
parts.push(buildEmptyAttrList());
|
|
714
|
+
parts.push(buildEmptyAttrList());
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return concatBytes(...parts);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Skeleton-pose block body: name → jointCount(uint16) → NumAttrList → per joint:
|
|
721
|
+
// hasTransform(uint8) → optional matrix4x3(float32)
|
|
722
|
+
function buildSkeletonPoseBody(name: string, jointTransforms: (number[] | null)[]): Uint8Array {
|
|
723
|
+
const parts: Uint8Array[] = [buildAwdString(name)];
|
|
724
|
+
const jointCountBytes = new Uint8Array(2);
|
|
725
|
+
new DataView(jointCountBytes.buffer).setUint16(0, jointTransforms.length, true);
|
|
726
|
+
parts.push(jointCountBytes);
|
|
727
|
+
parts.push(buildEmptyAttrList());
|
|
728
|
+
|
|
729
|
+
for (const transform of jointTransforms) {
|
|
730
|
+
if (transform !== null) {
|
|
731
|
+
const flagAndTransform = new Uint8Array(1 + 12 * 4);
|
|
732
|
+
flagAndTransform[0] = 1;
|
|
733
|
+
const tv = new DataView(flagAndTransform.buffer);
|
|
734
|
+
for (let i = 0; i < 12; i++) {
|
|
735
|
+
tv.setFloat32(1 + i * 4, transform[i] ?? 0, true);
|
|
736
|
+
}
|
|
737
|
+
parts.push(flagAndTransform);
|
|
738
|
+
} else {
|
|
739
|
+
parts.push(new Uint8Array([0]));
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return concatBytes(...parts);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Skeleton-animation block body: name → frameCount(uint16) → NumAttrList → per frame:
|
|
747
|
+
// poseBlockId(uint32) → duration(uint16, ms)
|
|
748
|
+
function buildSkeletonAnimationBody(name: string, poses: Array<{ duration: number; poseBlockId: number }>): Uint8Array {
|
|
749
|
+
const parts: Uint8Array[] = [buildAwdString(name)];
|
|
750
|
+
const poseCountBytes = new Uint8Array(2);
|
|
751
|
+
new DataView(poseCountBytes.buffer).setUint16(0, poses.length, true);
|
|
752
|
+
parts.push(poseCountBytes);
|
|
753
|
+
parts.push(buildEmptyAttrList());
|
|
754
|
+
|
|
755
|
+
for (const pose of poses) {
|
|
756
|
+
const poseBytes = new Uint8Array(6);
|
|
757
|
+
const pv = new DataView(poseBytes.buffer);
|
|
758
|
+
pv.setUint32(0, pose.poseBlockId, true);
|
|
759
|
+
pv.setUint16(4, pose.duration, true);
|
|
760
|
+
parts.push(poseBytes);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return concatBytes(...parts);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
describe('createScene3DFromAwd2', () => {
|
|
767
|
+
it('parses a single triangle with positions and indices', () => {
|
|
768
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
769
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
770
|
+
|
|
771
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
772
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
773
|
+
const geomBody = buildTriangleGeometryBody('Triangle', [{ streams: [posStream, idxStream] }]);
|
|
774
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
775
|
+
|
|
776
|
+
const meshBody = buildMeshInstanceBody('TriMesh', 0, IDENTITY_TRANSFORM, 1);
|
|
777
|
+
const meshBlockHeader = buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
778
|
+
|
|
779
|
+
const body = concatBytes(geomBlockHeader, geomBody, meshBlockHeader, meshBody);
|
|
780
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
781
|
+
|
|
782
|
+
const scene = createScene3DFromAwd2(awd);
|
|
783
|
+
const children = getNodeChildren(scene.root);
|
|
784
|
+
expect(children).toHaveLength(1);
|
|
785
|
+
expect(isMesh(children[0] as Node3D)).toBe(true);
|
|
786
|
+
expect((children[0] as Node3D).name).toBe('TriMesh');
|
|
787
|
+
|
|
788
|
+
const geometry = (children[0] as Mesh).geometry;
|
|
789
|
+
expect(getMeshGeometryVertexCount(geometry)).toBe(3);
|
|
790
|
+
expect(getMeshGeometryIndexCount(geometry)).toBe(3);
|
|
791
|
+
|
|
792
|
+
const p = { x: 0, y: 0, z: 0 };
|
|
793
|
+
getMeshGeometryVertexPosition(p, geometry, 0);
|
|
794
|
+
expect([p.x, p.y, p.z]).toEqual([0, 0, 0]);
|
|
795
|
+
getMeshGeometryVertexPosition(p, geometry, 1);
|
|
796
|
+
expect([p.x, p.y, p.z]).toEqual([1, 0, 0]);
|
|
797
|
+
getMeshGeometryVertexPosition(p, geometry, 2);
|
|
798
|
+
expect([p.x, p.y, p.z]).toEqual([0, 1, 0]);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('parses geometry with UVs and normals', () => {
|
|
802
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
803
|
+
const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
|
804
|
+
const uvs = new Float32Array([0, 0, 1, 0, 0.5, 1]);
|
|
805
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
806
|
+
|
|
807
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
808
|
+
const normStream = buildStream(AWD2_STREAM_NORMALS, AWD2_DATA_FLOAT32, normals);
|
|
809
|
+
const uvStream = buildStream(AWD2_STREAM_UVS, AWD2_DATA_FLOAT32, uvs);
|
|
810
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
811
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, normStream, uvStream, idxStream] }]);
|
|
812
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
813
|
+
|
|
814
|
+
const meshBody = buildMeshInstanceBody('Mesh', 0, IDENTITY_TRANSFORM, 1);
|
|
815
|
+
const meshBlockHeader = buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
816
|
+
|
|
817
|
+
const body = concatBytes(geomBlockHeader, geomBody, meshBlockHeader, meshBody);
|
|
818
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
819
|
+
|
|
820
|
+
const scene = createScene3DFromAwd2(awd);
|
|
821
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
822
|
+
|
|
823
|
+
const n = { x: 0, y: 0, z: 0 };
|
|
824
|
+
getMeshGeometryVertexNormal(n, geometry, 0);
|
|
825
|
+
expect([n.x, n.y, n.z]).toEqual([0, 0, -1]);
|
|
826
|
+
|
|
827
|
+
const uv = { x: 0, y: 0 };
|
|
828
|
+
getMeshGeometryVertexUv0(uv, geometry, 1);
|
|
829
|
+
expect([uv.x, uv.y]).toEqual([1, 0]);
|
|
830
|
+
getMeshGeometryVertexUv0(uv, geometry, 2);
|
|
831
|
+
expect([uv.x, uv.y]).toEqual([0.5, 1]);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('writes the tangent xyz and a unit bitangent handedness into tangent.W', () => {
|
|
835
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
836
|
+
const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
|
837
|
+
// Tangents with a Z component so the left→right-handed Z-negation is observable in xyz.
|
|
838
|
+
const tangents = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
|
839
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
840
|
+
|
|
841
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
842
|
+
const normStream = buildStream(AWD2_STREAM_NORMALS, AWD2_DATA_FLOAT32, normals);
|
|
843
|
+
const tanStream = buildStream(AWD2_STREAM_TANGENTS, AWD2_DATA_FLOAT32, tangents);
|
|
844
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
845
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, normStream, tanStream, idxStream] }]);
|
|
846
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
847
|
+
const meshBody = buildMeshInstanceBody('Mesh', 0, IDENTITY_TRANSFORM, 1);
|
|
848
|
+
const meshBlockHeader = buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
849
|
+
const body = concatBytes(geomBlockHeader, geomBody, meshBlockHeader, meshBody);
|
|
850
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
851
|
+
|
|
852
|
+
const geometry = (getNodeChildren(createScene3DFromAwd2(awd).root)[0] as Mesh).geometry;
|
|
853
|
+
const floatsPerVertex = geometry.layout.stride / 4; // 12 for the canonical (non-skinned) layout
|
|
854
|
+
|
|
855
|
+
// Tangent xyz (float offset 6-8) carries the AWD tangent with Z negated by the handedness conversion.
|
|
856
|
+
expect([geometry.vertices[6], geometry.vertices[7], geometry.vertices[8]]).toEqual([0, 0, -1]);
|
|
857
|
+
// Tangent W (float offset 9) is the bitangent handedness — the bug was it staying 0; it is now unit.
|
|
858
|
+
expect(Math.abs(geometry.vertices[9])).toBe(1);
|
|
859
|
+
// Every vertex receives the same mesh-wide handedness sign.
|
|
860
|
+
for (let v = 0; v < 3; v++) expect(geometry.vertices[v * floatsPerVertex + 9]).toBe(geometry.vertices[9]);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it('synthesizes a tangent basis when the sub-mesh has UVs but no tangent stream', () => {
|
|
864
|
+
// Away3D commonly omits the tangent stream; without synthesis the mesh imports with a zero tangent
|
|
865
|
+
// frame and a normal-mapped material renders black. A triangle in the z=0 plane with a varying UV
|
|
866
|
+
// gradient must yield a real, non-degenerate tangent + a unit handedness W.
|
|
867
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
868
|
+
const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
|
|
869
|
+
const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
|
|
870
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
871
|
+
|
|
872
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
873
|
+
const normStream = buildStream(AWD2_STREAM_NORMALS, AWD2_DATA_FLOAT32, normals);
|
|
874
|
+
const uvStream = buildStream(AWD2_STREAM_UVS, AWD2_DATA_FLOAT32, uvs);
|
|
875
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
876
|
+
// No AWD2_STREAM_TANGENTS — the case Away3D exports hit.
|
|
877
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, normStream, uvStream, idxStream] }]);
|
|
878
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
879
|
+
const meshBody = buildMeshInstanceBody('Mesh', 0, IDENTITY_TRANSFORM, 1);
|
|
880
|
+
const meshBlockHeader = buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
881
|
+
const body = concatBytes(geomBlockHeader, geomBody, meshBlockHeader, meshBody);
|
|
882
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
883
|
+
|
|
884
|
+
const geometry = (getNodeChildren(createScene3DFromAwd2(awd).root)[0] as Mesh).geometry;
|
|
885
|
+
// Tangent xyz (float offset 6-8) is a real unit-length vector, not the zero a missing stream leaves.
|
|
886
|
+
const [tx, ty, tz] = [geometry.vertices[6], geometry.vertices[7], geometry.vertices[8]];
|
|
887
|
+
expect(Math.hypot(tx, ty, tz)).toBeCloseTo(1, 3);
|
|
888
|
+
// Tangent W (float offset 9) is a unit bitangent handedness (±1), not zero.
|
|
889
|
+
expect(Math.abs(geometry.vertices[9])).toBeCloseTo(1, 3);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it('builds container and mesh instance hierarchy', () => {
|
|
893
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
894
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
895
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
896
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
897
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, idxStream] }]);
|
|
898
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
899
|
+
|
|
900
|
+
const containerBody = buildContainerBody('Group', 0, IDENTITY_TRANSFORM);
|
|
901
|
+
const containerBlockHeader = buildBlockHeader(2, AWD2_BLOCK_CONTAINER, containerBody.length);
|
|
902
|
+
|
|
903
|
+
const meshBody = buildMeshInstanceBody('ChildMesh', 2, IDENTITY_TRANSFORM, 1);
|
|
904
|
+
const meshBlockHeader = buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
905
|
+
|
|
906
|
+
const body = concatBytes(geomBlockHeader, geomBody, containerBlockHeader, containerBody, meshBlockHeader, meshBody);
|
|
907
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
908
|
+
|
|
909
|
+
const scene = createScene3DFromAwd2(awd);
|
|
910
|
+
const roots = getNodeChildren(scene.root);
|
|
911
|
+
expect(roots).toHaveLength(1);
|
|
912
|
+
const container = roots[0] as Node3D;
|
|
913
|
+
expect(isMesh(container)).toBe(false);
|
|
914
|
+
|
|
915
|
+
const containerChildren = getNodeChildren(container);
|
|
916
|
+
expect(containerChildren).toHaveLength(1);
|
|
917
|
+
expect(isMesh(containerChildren[0] as Node3D)).toBe(true);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('records a diagnostic and returns empty scene for compressed AWD', () => {
|
|
921
|
+
const awd = buildAwdHeader(0, AWD2_COMPRESSION_DEFLATE);
|
|
922
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
923
|
+
const scene = createScene3DFromAwd2(awd, diagnostics);
|
|
924
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
925
|
+
expect(diagnostics).toHaveLength(1);
|
|
926
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.compression-no-decompressor');
|
|
927
|
+
expect(crumb.severity).toBe('Reject');
|
|
928
|
+
expect(crumb.origin).toBe('rehydrateAwdBody');
|
|
929
|
+
expect(crumb.detail?.compression).toBe(AWD2_COMPRESSION_DEFLATE);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('returns an empty scene and records a diagnostic for truncated input', () => {
|
|
933
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
934
|
+
const scene = createScene3DFromAwd2(new Uint8Array(4), diagnostics);
|
|
935
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
936
|
+
expect(diagnostics).toHaveLength(1);
|
|
937
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.header-too-short');
|
|
938
|
+
expect(crumb.detail).toBeUndefined();
|
|
939
|
+
expect(crumb.severity).toBe('Reject');
|
|
940
|
+
expect(crumb.origin).toBe('parseAwd2');
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it('returns an empty scene and records a diagnostic when magic is invalid', () => {
|
|
944
|
+
const bogus = new Uint8Array(12);
|
|
945
|
+
bogus[0] = 0x00;
|
|
946
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
947
|
+
const scene = createScene3DFromAwd2(bogus, diagnostics);
|
|
948
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
949
|
+
expect(diagnostics).toHaveLength(1);
|
|
950
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.bad-magic');
|
|
951
|
+
expect(crumb.detail).toBeUndefined();
|
|
952
|
+
expect(crumb.severity).toBe('Reject');
|
|
953
|
+
expect(crumb.origin).toBe('parseAwd2');
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it('rejects a version-3 (AWD3) file by version rather than misparsing it', () => {
|
|
957
|
+
// A version-3 file shares the 'AWD' magic but has a different block model; the AWD2 walk would
|
|
958
|
+
// silently produce an empty/garbage scene, so it must be rejected by version with an AWD3-naming diagnostic.
|
|
959
|
+
const awd3 = buildAwdHeader(0, 0, 0, 3);
|
|
960
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
961
|
+
const scene = createScene3DFromAwd2(awd3, diagnostics);
|
|
962
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
963
|
+
expect(diagnostics).toHaveLength(1);
|
|
964
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.unsupported-version');
|
|
965
|
+
expect(crumb.severity).toBe('Reject');
|
|
966
|
+
expect(crumb.origin).toBe('isAwd2Version');
|
|
967
|
+
expect(crumb.detail?.version).toBe(3);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('returns an empty scene for a valid header with no blocks', () => {
|
|
971
|
+
const awd = buildAwdHeader(0);
|
|
972
|
+
const scene = createScene3DFromAwd2(awd);
|
|
973
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('applies transform from mesh instance block', () => {
|
|
977
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
978
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
979
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
980
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
981
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, idxStream] }]);
|
|
982
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
983
|
+
|
|
984
|
+
const transform = [1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 20, 30];
|
|
985
|
+
const meshBody = buildMeshInstanceBody('Mesh', 0, transform, 1);
|
|
986
|
+
const meshBlockHeader = buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
987
|
+
|
|
988
|
+
const body = concatBytes(geomBlockHeader, geomBody, meshBlockHeader, meshBody);
|
|
989
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
990
|
+
|
|
991
|
+
const scene = createScene3DFromAwd2(awd);
|
|
992
|
+
const meshNode = getNodeChildren(scene.root)[0] as Node3D;
|
|
993
|
+
const m = getNodeLocalMatrix4(meshNode).m;
|
|
994
|
+
expect(m[12]).toBeCloseTo(10);
|
|
995
|
+
expect(m[13]).toBeCloseTo(20);
|
|
996
|
+
expect(m[14]).toBeCloseTo(-30);
|
|
997
|
+
expect(m[0]).toBeCloseTo(1);
|
|
998
|
+
expect(m[5]).toBeCloseTo(1);
|
|
999
|
+
expect(m[10]).toBeCloseTo(1);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('records a diagnostic when block length runs past the end of the body', () => {
|
|
1003
|
+
const blockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, 9999);
|
|
1004
|
+
const body = blockHeader;
|
|
1005
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1006
|
+
|
|
1007
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1008
|
+
createScene3DFromAwd2(awd, diagnostics);
|
|
1009
|
+
expect(diagnostics).toHaveLength(1);
|
|
1010
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.block-length-past-end');
|
|
1011
|
+
expect(crumb.detail).toBeUndefined();
|
|
1012
|
+
expect(crumb.severity).toBe('Recover');
|
|
1013
|
+
expect(crumb.origin).toBe('parseAwd2');
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('records a diagnostic when mesh instance references a nonexistent geometry block', () => {
|
|
1017
|
+
const meshBody = buildMeshInstanceBody('Mesh', 0, IDENTITY_TRANSFORM, 99);
|
|
1018
|
+
const meshBlockHeader = buildBlockHeader(1, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
1019
|
+
|
|
1020
|
+
const body = concatBytes(meshBlockHeader, meshBody);
|
|
1021
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1022
|
+
|
|
1023
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1024
|
+
const scene = createScene3DFromAwd2(awd, diagnostics);
|
|
1025
|
+
expect(getNodeChildren(scene.root)).toHaveLength(1);
|
|
1026
|
+
expect(diagnostics).toHaveLength(1);
|
|
1027
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.mesh-instance-missing-geometry');
|
|
1028
|
+
expect(crumb.severity).toBe('Recover');
|
|
1029
|
+
expect(crumb.origin).toBe('parseAwd2');
|
|
1030
|
+
expect(crumb.detail?.block).toBe(1);
|
|
1031
|
+
expect(crumb.detail?.geometry).toBe(99);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it('parses positions-only geometry without indices', () => {
|
|
1035
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1036
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
1037
|
+
const geomBody = buildTriangleGeometryBody('NoIdx', [{ streams: [posStream] }]);
|
|
1038
|
+
const geomBlockHeader = buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length);
|
|
1039
|
+
|
|
1040
|
+
const meshBody = buildMeshInstanceBody('Mesh', 0, IDENTITY_TRANSFORM, 1);
|
|
1041
|
+
const meshBlockHeader = buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length);
|
|
1042
|
+
|
|
1043
|
+
const body = concatBytes(geomBlockHeader, geomBody, meshBlockHeader, meshBody);
|
|
1044
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1045
|
+
|
|
1046
|
+
const scene = createScene3DFromAwd2(awd);
|
|
1047
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
1048
|
+
expect(getMeshGeometryVertexCount(geometry)).toBe(3);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it('attaches a textured ShadedMaterial from a material + embedded texture block', () => {
|
|
1052
|
+
const posStream = buildStream(
|
|
1053
|
+
AWD2_STREAM_POSITIONS,
|
|
1054
|
+
AWD2_DATA_FLOAT32,
|
|
1055
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1056
|
+
);
|
|
1057
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1058
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1059
|
+
const texBody = buildTextureBody('diffuse.png', AWD2_TEXTURE_TYPE_EMBEDDED, FAKE_PNG_BYTES);
|
|
1060
|
+
const matBody = buildMaterialBody('Mat', AWD2_MATERIAL_TYPE_TEXTURE, [[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 2]]);
|
|
1061
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [3]);
|
|
1062
|
+
|
|
1063
|
+
const body = concatBytes(
|
|
1064
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1065
|
+
geomBody,
|
|
1066
|
+
buildBlockHeader(2, AWD2_BLOCK_TEXTURE, texBody.length),
|
|
1067
|
+
texBody,
|
|
1068
|
+
buildBlockHeader(3, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1069
|
+
matBody,
|
|
1070
|
+
buildBlockHeader(4, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1071
|
+
miBody,
|
|
1072
|
+
);
|
|
1073
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1074
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
1075
|
+
|
|
1076
|
+
const mesh = getNodeChildren(scene.root)[0] as Mesh;
|
|
1077
|
+
expect(isMesh(mesh)).toBe(true);
|
|
1078
|
+
expect(mesh.materials).toHaveLength(1);
|
|
1079
|
+
const material = mesh.materials[0] as ShadedMaterial | null;
|
|
1080
|
+
expect(material).not.toBeNull();
|
|
1081
|
+
expect(material!.kind).toBe(ShadedMaterialKind);
|
|
1082
|
+
expect(material!.modifiers).toHaveLength(0); // method-less AWD material → empty modifier stack
|
|
1083
|
+
expect(material!.name).toBe('Mat'); // AWD material block name preserved as the authored identity
|
|
1084
|
+
expect(material!.diffuseMap).not.toBeNull();
|
|
1085
|
+
// The parser references, it does not decode: image stays null, a ref carries the source.
|
|
1086
|
+
expect(material!.diffuseMap!.image).toBeNull();
|
|
1087
|
+
const ref = material!.diffuseMap!.resource as EmbeddedImageResourceReference;
|
|
1088
|
+
expect(ref.kind).toBe('Embedded');
|
|
1089
|
+
expect(ref.mimeType).toBe('image/png');
|
|
1090
|
+
expect(ref.bytes).toEqual(FAKE_PNG_BYTES);
|
|
1091
|
+
expect(ref.state).toBe(ResourceResolutionState.Unresolved);
|
|
1092
|
+
expect(diagnostics).toHaveLength(0);
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('maps the AWD normal-texture property (3) to the material normalMap', () => {
|
|
1096
|
+
const posStream = buildStream(
|
|
1097
|
+
AWD2_STREAM_POSITIONS,
|
|
1098
|
+
AWD2_DATA_FLOAT32,
|
|
1099
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1100
|
+
);
|
|
1101
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1102
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1103
|
+
const texBody = buildTextureBody('normal.png', AWD2_TEXTURE_TYPE_EMBEDDED, FAKE_PNG_BYTES);
|
|
1104
|
+
// A material with a flat color plus a normal texture (block id 2, property 3).
|
|
1105
|
+
const matBody = buildMaterialBody('Mat', AWD2_MATERIAL_TYPE_TEXTURE, [
|
|
1106
|
+
[AWD2_MATERIAL_PROP_COLOR, 0x808080],
|
|
1107
|
+
[AWD2_MATERIAL_PROP_NORMAL_TEXTURE, 2],
|
|
1108
|
+
]);
|
|
1109
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [3]);
|
|
1110
|
+
|
|
1111
|
+
const body = concatBytes(
|
|
1112
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1113
|
+
geomBody,
|
|
1114
|
+
buildBlockHeader(2, AWD2_BLOCK_TEXTURE, texBody.length),
|
|
1115
|
+
texBody,
|
|
1116
|
+
buildBlockHeader(3, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1117
|
+
matBody,
|
|
1118
|
+
buildBlockHeader(4, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1119
|
+
miBody,
|
|
1120
|
+
);
|
|
1121
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1122
|
+
|
|
1123
|
+
const material = (getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial;
|
|
1124
|
+
expect(material.normalMap).not.toBeNull();
|
|
1125
|
+
expect(material.normalMap!.image).toBeNull(); // referenced, not decoded
|
|
1126
|
+
const ref = material.normalMap!.resource as EmbeddedImageResourceReference;
|
|
1127
|
+
expect(ref.kind).toBe('Embedded');
|
|
1128
|
+
expect(ref.mimeType).toBe('image/png');
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it('attaches a ShadedMaterial from a flat-color material block, widening color to opaque rgba', () => {
|
|
1132
|
+
const posStream = buildStream(
|
|
1133
|
+
AWD2_STREAM_POSITIONS,
|
|
1134
|
+
AWD2_DATA_FLOAT32,
|
|
1135
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1136
|
+
);
|
|
1137
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1138
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1139
|
+
const matBody = buildMaterialBody('ColorMat', AWD2_MATERIAL_TYPE_COLOR, [[AWD2_MATERIAL_PROP_COLOR, 0x336699]]);
|
|
1140
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [2]);
|
|
1141
|
+
|
|
1142
|
+
const body = concatBytes(
|
|
1143
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1144
|
+
geomBody,
|
|
1145
|
+
buildBlockHeader(2, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1146
|
+
matBody,
|
|
1147
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1148
|
+
miBody,
|
|
1149
|
+
);
|
|
1150
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1151
|
+
|
|
1152
|
+
const material = (getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial | null;
|
|
1153
|
+
expect(material).not.toBeNull();
|
|
1154
|
+
expect(material!.kind).toBe(ShadedMaterialKind);
|
|
1155
|
+
expect(material!.modifiers).toHaveLength(0);
|
|
1156
|
+
expect(material!.diffuse).toBe(0x336699ff);
|
|
1157
|
+
expect(material!.diffuseMap).toBeNull();
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it('folds the AWD alpha property (10) into the diffuse RGBA and blends when below 1', () => {
|
|
1161
|
+
const posStream = buildStream(
|
|
1162
|
+
AWD2_STREAM_POSITIONS,
|
|
1163
|
+
AWD2_DATA_FLOAT32,
|
|
1164
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1165
|
+
);
|
|
1166
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1167
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1168
|
+
// Color 0x336699 with alpha 0.5 → diffuse 0x33669980 (0x80 = round(0.5*255)) and blend coverage.
|
|
1169
|
+
const matBody = buildMaterialBody('Translucent', AWD2_MATERIAL_TYPE_COLOR, [
|
|
1170
|
+
[AWD2_MATERIAL_PROP_COLOR, 0x336699],
|
|
1171
|
+
[AWD2_MATERIAL_PROP_ALPHA, float32Bits(0.5)],
|
|
1172
|
+
]);
|
|
1173
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [2]);
|
|
1174
|
+
const body = concatBytes(
|
|
1175
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1176
|
+
geomBody,
|
|
1177
|
+
buildBlockHeader(2, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1178
|
+
matBody,
|
|
1179
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1180
|
+
miBody,
|
|
1181
|
+
);
|
|
1182
|
+
const material = (
|
|
1183
|
+
getNodeChildren(createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body)).root)[0] as Mesh
|
|
1184
|
+
).materials[0] as ShadedMaterial;
|
|
1185
|
+
expect(material.diffuse).toBe(0x33669980);
|
|
1186
|
+
expect(material.alphaMode).toBe('blend');
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
it('records a Skip diagnostic but still imports the base when an AWD material declares shading methods (numMethods > 0)', () => {
|
|
1190
|
+
const posStream = buildStream(
|
|
1191
|
+
AWD2_STREAM_POSITIONS,
|
|
1192
|
+
AWD2_DATA_FLOAT32,
|
|
1193
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1194
|
+
);
|
|
1195
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1196
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1197
|
+
// A method-bearing material: numMethods=2. The method bodies aren't emitted (the parser doesn't walk
|
|
1198
|
+
// them yet); the base color must still import, with a Skip diagnostic carrying the count.
|
|
1199
|
+
const matBody = buildMaterialBody(
|
|
1200
|
+
'WithMethods',
|
|
1201
|
+
AWD2_MATERIAL_TYPE_COLOR,
|
|
1202
|
+
[[AWD2_MATERIAL_PROP_COLOR, 0x112233]],
|
|
1203
|
+
2,
|
|
1204
|
+
);
|
|
1205
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [2]);
|
|
1206
|
+
const body = concatBytes(
|
|
1207
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1208
|
+
geomBody,
|
|
1209
|
+
buildBlockHeader(2, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1210
|
+
matBody,
|
|
1211
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1212
|
+
miBody,
|
|
1213
|
+
);
|
|
1214
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1215
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
1216
|
+
const material = (getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial;
|
|
1217
|
+
expect(material.kind).toBe(ShadedMaterialKind);
|
|
1218
|
+
expect(material.diffuse).toBe(0x112233ff); // base still imported despite the unmapped methods
|
|
1219
|
+
expect(material.modifiers).toHaveLength(0);
|
|
1220
|
+
expect(diagnostics).toHaveLength(1);
|
|
1221
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.material-methods-unsupported');
|
|
1222
|
+
expect(crumb.severity).toBe('Skip');
|
|
1223
|
+
expect(crumb.origin).toBe('resolveAwdMaterial');
|
|
1224
|
+
expect(crumb.detail?.methods).toBe(2);
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('imports an empty (no color, no textures) AWD material block as a default-white ShadedMaterial', () => {
|
|
1228
|
+
// An existing material block with no base properties is a valid material, not a drop — it defaults to
|
|
1229
|
+
// opaque white under the uniform-ShadedMaterial rule (regression: the early no-base-props return -1).
|
|
1230
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1231
|
+
const scene = createScene3DFromAwd2(
|
|
1232
|
+
buildSingleMaterialAwd(buildMaterialBody('Empty', AWD2_MATERIAL_TYPE_COLOR, [])),
|
|
1233
|
+
diagnostics,
|
|
1234
|
+
);
|
|
1235
|
+
const material = (getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial | null;
|
|
1236
|
+
expect(material).not.toBeNull();
|
|
1237
|
+
expect(material!.kind).toBe(ShadedMaterialKind);
|
|
1238
|
+
expect(material!.diffuse).toBe(0xffffffff);
|
|
1239
|
+
expect(material!.diffuseMap).toBeNull();
|
|
1240
|
+
expect(material!.normalMap).toBeNull();
|
|
1241
|
+
expect(material!.modifiers).toHaveLength(0);
|
|
1242
|
+
expect(material!.alphaMode).toBe('opaque');
|
|
1243
|
+
expect(diagnostics).toHaveLength(0);
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it('imports an alpha-only AWD material (no color) as white RGBA with alpha folded in and blend coverage', () => {
|
|
1247
|
+
// alpha 0.25 → white diffuse with alpha byte round(0.25*255)=64 (0x40) and blend coverage.
|
|
1248
|
+
const matBody = buildMaterialBody('AlphaOnly', AWD2_MATERIAL_TYPE_COLOR, [
|
|
1249
|
+
[AWD2_MATERIAL_PROP_ALPHA, float32Bits(0.25)],
|
|
1250
|
+
]);
|
|
1251
|
+
const material = (getNodeChildren(createScene3DFromAwd2(buildSingleMaterialAwd(matBody)).root)[0] as Mesh)
|
|
1252
|
+
.materials[0] as ShadedMaterial;
|
|
1253
|
+
expect(material.kind).toBe(ShadedMaterialKind);
|
|
1254
|
+
expect(material.diffuse).toBe(0xffffff40);
|
|
1255
|
+
expect(material.alphaMode).toBe('blend');
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('imports a method-only AWD material (numMethods > 0, no base props) as a ShadedMaterial and still records a Skip diagnostic', () => {
|
|
1259
|
+
// Regression: this case previously dropped BEFORE the diagnostic, so both the material AND its diagnostic
|
|
1260
|
+
// silently disappeared. It must import a default-white ShadedMaterial and still emit the method Skip crumb.
|
|
1261
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1262
|
+
const matBody = buildMaterialBody('MethodOnly', AWD2_MATERIAL_TYPE_COLOR, [], 3);
|
|
1263
|
+
const scene = createScene3DFromAwd2(buildSingleMaterialAwd(matBody), diagnostics);
|
|
1264
|
+
const material = (getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial | null;
|
|
1265
|
+
expect(material).not.toBeNull();
|
|
1266
|
+
expect(material!.kind).toBe(ShadedMaterialKind);
|
|
1267
|
+
expect(material!.diffuse).toBe(0xffffffff);
|
|
1268
|
+
expect(material!.modifiers).toHaveLength(0);
|
|
1269
|
+
expect(diagnostics).toHaveLength(1);
|
|
1270
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.material-methods-unsupported');
|
|
1271
|
+
expect(crumb.severity).toBe('Skip');
|
|
1272
|
+
expect(crumb.origin).toBe('resolveAwdMaterial');
|
|
1273
|
+
expect(crumb.detail?.methods).toBe(3);
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
it('emits an External ImageResourceReference for an external-URL texture', () => {
|
|
1277
|
+
const posStream = buildStream(
|
|
1278
|
+
AWD2_STREAM_POSITIONS,
|
|
1279
|
+
AWD2_DATA_FLOAT32,
|
|
1280
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1281
|
+
);
|
|
1282
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1283
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1284
|
+
const texBody = buildTextureBody('http://example.com/tex.png', AWD2_TEXTURE_TYPE_EXTERNAL, new Uint8Array(0));
|
|
1285
|
+
const matBody = buildMaterialBody('Mat', AWD2_MATERIAL_TYPE_TEXTURE, [[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 2]]);
|
|
1286
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [3]);
|
|
1287
|
+
|
|
1288
|
+
const body = concatBytes(
|
|
1289
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1290
|
+
geomBody,
|
|
1291
|
+
buildBlockHeader(2, AWD2_BLOCK_TEXTURE, texBody.length),
|
|
1292
|
+
texBody,
|
|
1293
|
+
buildBlockHeader(3, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1294
|
+
matBody,
|
|
1295
|
+
buildBlockHeader(4, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1296
|
+
miBody,
|
|
1297
|
+
);
|
|
1298
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1299
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body), diagnostics);
|
|
1300
|
+
|
|
1301
|
+
const material = (getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial;
|
|
1302
|
+
expect(material.kind).toBe(ShadedMaterialKind);
|
|
1303
|
+
expect(material.diffuseMap!.image).toBeNull();
|
|
1304
|
+
const ref = material.diffuseMap!.resource as ExternalImageResourceReference;
|
|
1305
|
+
expect(ref.kind).toBe('External');
|
|
1306
|
+
expect(ref.uri).toBe('http://example.com/tex.png');
|
|
1307
|
+
expect(ref.state).toBe(ResourceResolutionState.Unresolved);
|
|
1308
|
+
expect(diagnostics).toHaveLength(0);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it('emits one Unresolved ref per shared texture and never decodes during parse', () => {
|
|
1312
|
+
const posStream = buildStream(
|
|
1313
|
+
AWD2_STREAM_POSITIONS,
|
|
1314
|
+
AWD2_DATA_FLOAT32,
|
|
1315
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1316
|
+
);
|
|
1317
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1318
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1319
|
+
const texBody = buildTextureBody('diffuse.png', AWD2_TEXTURE_TYPE_EMBEDDED, FAKE_PNG_BYTES);
|
|
1320
|
+
const matBody = buildMaterialBody('Mat', AWD2_MATERIAL_TYPE_TEXTURE, [[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 2]]);
|
|
1321
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [3]);
|
|
1322
|
+
|
|
1323
|
+
const body = concatBytes(
|
|
1324
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1325
|
+
geomBody,
|
|
1326
|
+
buildBlockHeader(2, AWD2_BLOCK_TEXTURE, texBody.length),
|
|
1327
|
+
texBody,
|
|
1328
|
+
buildBlockHeader(3, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1329
|
+
matBody,
|
|
1330
|
+
buildBlockHeader(4, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1331
|
+
miBody,
|
|
1332
|
+
);
|
|
1333
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1334
|
+
|
|
1335
|
+
const texture = ((getNodeChildren(scene.root)[0] as Mesh).materials[0] as ShadedMaterial).diffuseMap!;
|
|
1336
|
+
expect(texture.image).toBeNull(); // parse never allocates or fills an ImageResource
|
|
1337
|
+
const ref = texture.resource as EmbeddedImageResourceReference;
|
|
1338
|
+
expect(ref.kind).toBe('Embedded');
|
|
1339
|
+
expect(ref.bytes).toEqual(FAKE_PNG_BYTES);
|
|
1340
|
+
expect(ref.mimeType).toBe('image/png');
|
|
1341
|
+
expect(ref.state).toBe(ResourceResolutionState.Unresolved);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it('emits joints0/weights0 into the skinned layout and binds the skeleton via mesh.skin', () => {
|
|
1345
|
+
const scene = createScene3DFromAwd2(SKINNED_TRIANGLE_AWD);
|
|
1346
|
+
const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
|
|
1347
|
+
expect(mesh).toBeTruthy();
|
|
1348
|
+
|
|
1349
|
+
// Skinned records interleave joints0/weights0 past uv0 → 20-float (80-byte) stride.
|
|
1350
|
+
expect(mesh.geometry.layout.stride).toBe(80);
|
|
1351
|
+
const floatsPerVertex = mesh.geometry.layout.stride / 4;
|
|
1352
|
+
|
|
1353
|
+
// Vertex 0's two influences (joint 0 @ 0.75, joint 1 @ 0.25), highest-weight first.
|
|
1354
|
+
expect(mesh.geometry.vertices[12]).toBe(0);
|
|
1355
|
+
expect(mesh.geometry.vertices[13]).toBe(1);
|
|
1356
|
+
expect(mesh.geometry.vertices[16]).toBeCloseTo(0.75);
|
|
1357
|
+
expect(mesh.geometry.vertices[17]).toBeCloseTo(0.25);
|
|
1358
|
+
|
|
1359
|
+
// Every vertex's four weights renormalize to 1 (vertex 2 has a single influence).
|
|
1360
|
+
for (let v = 0; v < 3; v++) {
|
|
1361
|
+
const base = v * floatsPerVertex;
|
|
1362
|
+
const weightSum =
|
|
1363
|
+
mesh.geometry.vertices[base + 16] +
|
|
1364
|
+
mesh.geometry.vertices[base + 17] +
|
|
1365
|
+
mesh.geometry.vertices[base + 18] +
|
|
1366
|
+
mesh.geometry.vertices[base + 19];
|
|
1367
|
+
expect(weightSum).toBeCloseTo(1);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
expect(mesh.skin).toBeTruthy();
|
|
1371
|
+
expect(mesh.skin?.skeleton.joints).toHaveLength(2);
|
|
1372
|
+
expect(mesh.skin?.skeleton.names).toEqual(['Root', 'Child']);
|
|
1373
|
+
// The skeleton hierarchy hangs under the "skeleton" group added to the scene. The document assembler
|
|
1374
|
+
// does not rethread the group as the skin's skeletonRoot (it stays null, matching every importer that
|
|
1375
|
+
// routes through createScene3DFromDocument); the group is still a scene-root child and the joints are
|
|
1376
|
+
// still parented under it.
|
|
1377
|
+
expect(mesh.skin?.skeletonRoot).toBeNull();
|
|
1378
|
+
expect(getNodeChildren(scene.root).find((c) => !isMesh(c as Node3D))?.name).toBe('skeleton');
|
|
1379
|
+
expect(getNodeParent(mesh.skin!.skeleton.joints[1])).toBe(mesh.skin!.skeleton.joints[0]);
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('binds the animation clip to the same joint nodes the mesh skins from (identity)', () => {
|
|
1383
|
+
const scene = createScene3DFromAwd2(SKINNED_TRIANGLE_AWD);
|
|
1384
|
+
const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
|
|
1385
|
+
const joints = mesh.skin!.skeleton.joints;
|
|
1386
|
+
|
|
1387
|
+
// The verify contract: parse the animation over the mesh's own skeleton joints, so posing the
|
|
1388
|
+
// clip deforms the skinned mesh — the animation, skeleton, and skin share one joint hierarchy.
|
|
1389
|
+
const clip = firstAwdClip(SKINNED_TRIANGLE_AWD, joints)!;
|
|
1390
|
+
expect(clip).toBeDefined();
|
|
1391
|
+
// Each joint gets a translation channel and a rotation channel, in joint order.
|
|
1392
|
+
expect(clip.channels).toHaveLength(4);
|
|
1393
|
+
expect((clip.channels[0].targetRef as Scene3DAnimationTarget).node).toBe(joints[0]);
|
|
1394
|
+
expect((clip.channels[2].targetRef as Scene3DAnimationTarget).node).toBe(joints[1]);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
it('leaves a non-skinned mesh with skin null even when the file carries a skeleton', () => {
|
|
1398
|
+
const posStream = buildStream(
|
|
1399
|
+
AWD2_STREAM_POSITIONS,
|
|
1400
|
+
AWD2_DATA_FLOAT32,
|
|
1401
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1402
|
+
);
|
|
1403
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1404
|
+
const geomBody = buildTriangleGeometryBody('Rigid', [{ streams: [posStream, idxStream] }]);
|
|
1405
|
+
const skelBody = buildSkeletonBody('Rig', [{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
1406
|
+
const miBody = buildMeshInstanceBody('RigidMesh', 0, IDENTITY_TRANSFORM, 1);
|
|
1407
|
+
|
|
1408
|
+
const body = concatBytes(
|
|
1409
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1410
|
+
geomBody,
|
|
1411
|
+
buildBlockHeader(2, AWD2_BLOCK_SKELETON, skelBody.length),
|
|
1412
|
+
skelBody,
|
|
1413
|
+
buildBlockHeader(3, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1414
|
+
miBody,
|
|
1415
|
+
);
|
|
1416
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1417
|
+
const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
|
|
1418
|
+
|
|
1419
|
+
expect(mesh.geometry.layout.stride).toBe(48); // canonical (non-skinned) layout
|
|
1420
|
+
expect(mesh.skin == null).toBe(true);
|
|
1421
|
+
});
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
describe('createScene3DFromAwd2 animations', () => {
|
|
1425
|
+
it('returns the scene plus the skeleton animation bound to the scene’s own joints', () => {
|
|
1426
|
+
const scene = createScene3DFromAwd2(SKINNED_TRIANGLE_AWD);
|
|
1427
|
+
expect(Object.keys(scene.animations)).toHaveLength(1);
|
|
1428
|
+
|
|
1429
|
+
// The clip binds the SAME joint nodes the imported scene's mesh skins from — no caller threading.
|
|
1430
|
+
const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
|
|
1431
|
+
const joints = mesh.skin!.skeleton.joints;
|
|
1432
|
+
const clip = Object.values(scene.animations)[0];
|
|
1433
|
+
// Each joint gets a translation channel and a rotation channel, in joint order.
|
|
1434
|
+
expect(clip.channels).toHaveLength(4);
|
|
1435
|
+
expect((clip.channels[0].targetRef as Scene3DAnimationTarget).node).toBe(joints[0]);
|
|
1436
|
+
expect((clip.channels[2].targetRef as Scene3DAnimationTarget).node).toBe(joints[1]);
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
it('returns no animations for a static AWD with no skeleton', () => {
|
|
1440
|
+
const posStream = buildStream(
|
|
1441
|
+
AWD2_STREAM_POSITIONS,
|
|
1442
|
+
AWD2_DATA_FLOAT32,
|
|
1443
|
+
new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
1444
|
+
);
|
|
1445
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1446
|
+
const geomBody = buildTriangleGeometryBody('Geo', [{ streams: [posStream, idxStream] }]);
|
|
1447
|
+
const miBody = buildMeshInstanceBody('Mesh', 0, IDENTITY_TRANSFORM, 1);
|
|
1448
|
+
const body = concatBytes(
|
|
1449
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1450
|
+
geomBody,
|
|
1451
|
+
buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1452
|
+
miBody,
|
|
1453
|
+
);
|
|
1454
|
+
const scene = createScene3DFromAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1455
|
+
expect(Object.keys(scene.animations)).toHaveLength(0);
|
|
1456
|
+
expect(getNodeChildren(scene.root).length).toBeGreaterThan(0);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it('imports skeleton animations from a compressed body, not just the mesh', () => {
|
|
1460
|
+
// Regression: the animation re-walk must run over the DECOMPRESSED body. Away3D exports compressed by
|
|
1461
|
+
// default, so walking the still-deflated bytes finds no skeleton/animation blocks and silently drops
|
|
1462
|
+
// every clip while the mesh (walked from the rehydrated source) still imports. The uncompressed twin
|
|
1463
|
+
// SKINNED_TRIANGLE_AWD yields exactly one clip; the compressed file must yield the same, not zero.
|
|
1464
|
+
const blockStream = SKINNED_TRIANGLE_AWD.subarray(AWD2_HEADER_BYTES);
|
|
1465
|
+
const onDiskPayload = new Uint8Array(blockStream.length); // stand-in "compressed" bytes the stub inflates
|
|
1466
|
+
const compressed = concatBytes(buildAwdHeader(onDiskPayload.length, AWD2_COMPRESSION_DEFLATE), onDiskPayload);
|
|
1467
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, () => blockStream);
|
|
1468
|
+
try {
|
|
1469
|
+
const scene = createScene3DFromAwd2(compressed);
|
|
1470
|
+
expect(Object.keys(scene.animations)).toHaveLength(1);
|
|
1471
|
+
const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
|
|
1472
|
+
expect(mesh.skin).not.toBeNull();
|
|
1473
|
+
} finally {
|
|
1474
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, null);
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
describe('parseAwd2', () => {
|
|
1480
|
+
it('returns a format-neutral document: a mesh node names its mesh by index, roots list it', () => {
|
|
1481
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1482
|
+
const indices = new Uint16Array([0, 1, 2]);
|
|
1483
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
1484
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, indices);
|
|
1485
|
+
const geomBody = buildTriangleGeometryBody('Triangle', [{ streams: [posStream, idxStream] }]);
|
|
1486
|
+
const meshBody = buildMeshInstanceBody('TriMesh', 0, IDENTITY_TRANSFORM, 1);
|
|
1487
|
+
const body = concatBytes(
|
|
1488
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1489
|
+
geomBody,
|
|
1490
|
+
buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, meshBody.length),
|
|
1491
|
+
meshBody,
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const doc = parseAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1495
|
+
expect(doc.meshes).toHaveLength(1);
|
|
1496
|
+
expect(doc.nodes).toHaveLength(1);
|
|
1497
|
+
expect(doc.nodes[0].name).toBe('TriMesh');
|
|
1498
|
+
expect(doc.nodes[0].mesh).toBe(0); // index into meshes
|
|
1499
|
+
expect(getMeshGeometryVertexCount(doc.meshes[0].geometry)).toBe(3);
|
|
1500
|
+
expect(doc.scenes).toHaveLength(1);
|
|
1501
|
+
expect(doc.scenes[0].rootNodes).toEqual([0]); // the mesh node is the sole root
|
|
1502
|
+
expect(doc.skins).toHaveLength(0);
|
|
1503
|
+
expect(doc.animations).toHaveLength(0);
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
it('wires container parenting through node children index lists', () => {
|
|
1507
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1508
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1509
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
1510
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, idxStream] }]);
|
|
1511
|
+
const containerBody = buildContainerBody('Parent', 0, IDENTITY_TRANSFORM);
|
|
1512
|
+
const meshBody = buildMeshInstanceBody('Child', 10, IDENTITY_TRANSFORM, 1);
|
|
1513
|
+
const body = concatBytes(
|
|
1514
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1515
|
+
geomBody,
|
|
1516
|
+
buildBlockHeader(10, AWD2_BLOCK_CONTAINER, containerBody.length),
|
|
1517
|
+
containerBody,
|
|
1518
|
+
buildBlockHeader(11, AWD2_BLOCK_MESH_INSTANCE, meshBody.length),
|
|
1519
|
+
meshBody,
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
const doc = parseAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1523
|
+
const parentIndex = doc.nodes.findIndex((n) => n.name === 'Parent');
|
|
1524
|
+
const childIndex = doc.nodes.findIndex((n) => n.name === 'Child');
|
|
1525
|
+
expect(parentIndex).toBeGreaterThanOrEqual(0);
|
|
1526
|
+
expect(childIndex).toBeGreaterThanOrEqual(0);
|
|
1527
|
+
// The child is reached through the parent's children index list, not the scene roots.
|
|
1528
|
+
expect(doc.nodes[parentIndex].children).toContain(childIndex);
|
|
1529
|
+
expect(doc.scenes[0].rootNodes).toContain(parentIndex);
|
|
1530
|
+
expect(doc.scenes[0].rootNodes).not.toContain(childIndex);
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it('decomposes a skeleton into skins (joints by node index) + node-index-bound animation channels', () => {
|
|
1534
|
+
const doc = parseAwd2(SKINNED_TRIANGLE_AWD);
|
|
1535
|
+
|
|
1536
|
+
// The skeleton becomes a skin whose joints are document node indices, with one inverse-bind per joint.
|
|
1537
|
+
expect(doc.skins).toHaveLength(1);
|
|
1538
|
+
expect(doc.skins[0].joints).toHaveLength(2);
|
|
1539
|
+
expect(doc.skins[0].inverseBind).toHaveLength(2);
|
|
1540
|
+
for (const jointNodeIndex of doc.skins[0].joints) {
|
|
1541
|
+
expect(jointNodeIndex).toBeGreaterThanOrEqual(0);
|
|
1542
|
+
expect(jointNodeIndex).toBeLessThan(doc.nodes.length);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// The skinned mesh names the skin by index.
|
|
1546
|
+
const skinnedMesh = doc.meshes.find((m) => m.skin !== undefined);
|
|
1547
|
+
expect(skinnedMesh?.skin).toBe(0);
|
|
1548
|
+
|
|
1549
|
+
// The animation's channels bind by joint node index (translation + rotation per joint).
|
|
1550
|
+
expect(doc.animations).toHaveLength(1);
|
|
1551
|
+
const channels = doc.animations[0].channels;
|
|
1552
|
+
expect(channels).toHaveLength(4);
|
|
1553
|
+
expect(channels[0].node).toBe(doc.skins[0].joints[0]);
|
|
1554
|
+
expect(channels[2].node).toBe(doc.skins[0].joints[1]);
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('appends resolved materials to the document materials table by index', () => {
|
|
1558
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1559
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1560
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
1561
|
+
const geomBody = buildTriangleGeometryBody('Geom', [{ streams: [posStream, idxStream] }]);
|
|
1562
|
+
const matBody = buildMaterialBody('Red', AWD2_MATERIAL_TYPE_COLOR, [[AWD2_MATERIAL_PROP_COLOR, 0xff0000]]);
|
|
1563
|
+
const miBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [20]);
|
|
1564
|
+
const body = concatBytes(
|
|
1565
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1566
|
+
geomBody,
|
|
1567
|
+
buildBlockHeader(20, AWD2_BLOCK_MATERIAL, matBody.length),
|
|
1568
|
+
matBody,
|
|
1569
|
+
buildBlockHeader(2, AWD2_BLOCK_MESH_INSTANCE, miBody.length),
|
|
1570
|
+
miBody,
|
|
1571
|
+
);
|
|
1572
|
+
|
|
1573
|
+
const doc = parseAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1574
|
+
expect(doc.materials).toHaveLength(1);
|
|
1575
|
+
expect(doc.materials[0].name).toBe('Red');
|
|
1576
|
+
// The mesh's subset references the material by its document index.
|
|
1577
|
+
expect(doc.meshes[0].materials).toEqual([0]);
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
it('records a texture block in resources and shares its reference across sampled Texture entities', () => {
|
|
1581
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
1582
|
+
const idxStream = buildStream(AWD2_STREAM_INDICES, AWD2_DATA_UINT16, new Uint16Array([0, 1, 2]));
|
|
1583
|
+
const posStream = buildStream(AWD2_STREAM_POSITIONS, AWD2_DATA_FLOAT32, positions);
|
|
1584
|
+
const geomBody = buildTriangleGeometryBody('Geom', [
|
|
1585
|
+
{ streams: [posStream, idxStream] },
|
|
1586
|
+
{ streams: [posStream, idxStream] },
|
|
1587
|
+
]);
|
|
1588
|
+
const texBody = buildTextureBody('diffuse.png', AWD2_TEXTURE_TYPE_EMBEDDED, FAKE_PNG_BYTES);
|
|
1589
|
+
const firstMaterial = buildMaterialBody('First', AWD2_MATERIAL_TYPE_TEXTURE, [
|
|
1590
|
+
[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 2],
|
|
1591
|
+
]);
|
|
1592
|
+
const secondMaterial = buildMaterialBody('Second', AWD2_MATERIAL_TYPE_TEXTURE, [
|
|
1593
|
+
[AWD2_MATERIAL_PROP_DIFFUSE_TEXTURE, 2],
|
|
1594
|
+
]);
|
|
1595
|
+
const meshBody = buildMeshInstanceBodyWithMaterials('Mesh', 0, IDENTITY_TRANSFORM, 1, [3, 4]);
|
|
1596
|
+
const body = concatBytes(
|
|
1597
|
+
buildBlockHeader(1, AWD2_BLOCK_TRIANGLE_GEOMETRY, geomBody.length),
|
|
1598
|
+
geomBody,
|
|
1599
|
+
buildBlockHeader(2, AWD2_BLOCK_TEXTURE, texBody.length),
|
|
1600
|
+
texBody,
|
|
1601
|
+
buildBlockHeader(3, AWD2_BLOCK_MATERIAL, firstMaterial.length),
|
|
1602
|
+
firstMaterial,
|
|
1603
|
+
buildBlockHeader(4, AWD2_BLOCK_MATERIAL, secondMaterial.length),
|
|
1604
|
+
secondMaterial,
|
|
1605
|
+
buildBlockHeader(5, AWD2_BLOCK_MESH_INSTANCE, meshBody.length),
|
|
1606
|
+
meshBody,
|
|
1607
|
+
);
|
|
1608
|
+
|
|
1609
|
+
const doc = parseAwd2(concatBytes(buildAwdHeader(body.length), body));
|
|
1610
|
+
const first = doc.materials[0] as ShadedMaterial;
|
|
1611
|
+
const second = doc.materials[1] as ShadedMaterial;
|
|
1612
|
+
expect(doc.resources).toHaveLength(1);
|
|
1613
|
+
expect(first.diffuseMap).not.toBe(second.diffuseMap);
|
|
1614
|
+
expect(first.diffuseMap!.resource).toBe(doc.resources[0]);
|
|
1615
|
+
expect(second.diffuseMap!.resource).toBe(doc.resources[0]);
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
it('returns an empty document with a diagnostic for compressed input', () => {
|
|
1619
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1620
|
+
const doc = parseAwd2(buildAwdHeader(0, AWD2_COMPRESSION_DEFLATE), diagnostics);
|
|
1621
|
+
expect(doc.nodes).toHaveLength(0);
|
|
1622
|
+
expect(doc.meshes).toHaveLength(0);
|
|
1623
|
+
expect(doc.scenes).toHaveLength(1);
|
|
1624
|
+
expect(doc.scenes[0].rootNodes).toHaveLength(0);
|
|
1625
|
+
expect(diagnostics).toHaveLength(1);
|
|
1626
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.compression-no-decompressor');
|
|
1627
|
+
expect(crumb.severity).toBe('Reject');
|
|
1628
|
+
expect(crumb.origin).toBe('rehydrateAwdBody');
|
|
1629
|
+
expect(crumb.detail?.compression).toBe(AWD2_COMPRESSION_DEFLATE);
|
|
1630
|
+
});
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
describe('parseAwd2SkeletonAnimations', () => {
|
|
1634
|
+
it('rejects a version-3 (AWD3) file by version with an empty map and an AWD3-naming diagnostic', () => {
|
|
1635
|
+
const awd3 = buildAwdHeader(0, 0, 0, 3);
|
|
1636
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1637
|
+
const clips = parseAwd2SkeletonAnimations(awd3, [], diagnostics);
|
|
1638
|
+
expect(Object.keys(clips)).toHaveLength(0);
|
|
1639
|
+
expect(diagnostics).toHaveLength(1);
|
|
1640
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.unsupported-version');
|
|
1641
|
+
expect(crumb.severity).toBe('Reject');
|
|
1642
|
+
expect(crumb.origin).toBe('isAwd2Version');
|
|
1643
|
+
expect(crumb.detail?.version).toBe(3);
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
it('binds channels to the provided joint nodes in skeleton order', () => {
|
|
1647
|
+
// parentIndex 0 = root (no parent); parentIndex 1 = parent is joint[0] (1-based).
|
|
1648
|
+
const skeletonBody = buildSkeletonBody('TestSkeleton', [
|
|
1649
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1650
|
+
{
|
|
1651
|
+
name: 'Child',
|
|
1652
|
+
parentIndex: 1,
|
|
1653
|
+
transform: [1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0],
|
|
1654
|
+
},
|
|
1655
|
+
]);
|
|
1656
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1657
|
+
|
|
1658
|
+
const pose0Body = buildSkeletonPoseBody('Pose0', [IDENTITY_TRANSFORM, [1, 0, 0, 0, 1, 0, 0, 0, 1, 5, 0, 0]]);
|
|
1659
|
+
const pose0Block = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, pose0Body.length);
|
|
1660
|
+
|
|
1661
|
+
const pose1Body = buildSkeletonPoseBody('Pose1', [IDENTITY_TRANSFORM, [1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 0]]);
|
|
1662
|
+
const pose1Block = buildBlockHeader(3, AWD2_BLOCK_SKELETON_POSE, pose1Body.length);
|
|
1663
|
+
|
|
1664
|
+
const animBody = buildSkeletonAnimationBody('Walk', [
|
|
1665
|
+
{ duration: 500, poseBlockId: 2 },
|
|
1666
|
+
{ duration: 500, poseBlockId: 3 },
|
|
1667
|
+
]);
|
|
1668
|
+
const animBlock = buildBlockHeader(4, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1669
|
+
|
|
1670
|
+
const body = concatBytes(
|
|
1671
|
+
skeletonBlock,
|
|
1672
|
+
skeletonBody,
|
|
1673
|
+
pose0Block,
|
|
1674
|
+
pose0Body,
|
|
1675
|
+
pose1Block,
|
|
1676
|
+
pose1Body,
|
|
1677
|
+
animBlock,
|
|
1678
|
+
animBody,
|
|
1679
|
+
);
|
|
1680
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1681
|
+
|
|
1682
|
+
const joints = [createNode3D(), createNode3D()];
|
|
1683
|
+
const clip = firstAwdClip(awd, joints);
|
|
1684
|
+
expect(clip).toBeDefined();
|
|
1685
|
+
|
|
1686
|
+
// Each joint gets a translation channel and a rotation channel, in joint order.
|
|
1687
|
+
expect(clip!.channels).toHaveLength(4);
|
|
1688
|
+
expect(clip!.duration).toBeCloseTo(1.0);
|
|
1689
|
+
expect((clip!.channels[0].targetRef as Scene3DAnimationTarget).node).toBe(joints[0]);
|
|
1690
|
+
expect((clip!.channels[0].targetRef as Scene3DAnimationTarget).path).toBe('Translation');
|
|
1691
|
+
expect((clip!.channels[1].targetRef as Scene3DAnimationTarget).path).toBe('Rotation');
|
|
1692
|
+
expect((clip!.channels[2].targetRef as Scene3DAnimationTarget).node).toBe(joints[1]);
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
it('samples animation clip translation values correctly', () => {
|
|
1696
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1697
|
+
{ name: 'Joint0', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1698
|
+
]);
|
|
1699
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1700
|
+
|
|
1701
|
+
const pose0Body = buildSkeletonPoseBody('P0', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]]);
|
|
1702
|
+
const pose0Block = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, pose0Body.length);
|
|
1703
|
+
|
|
1704
|
+
const pose1Body = buildSkeletonPoseBody('P1', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 20, 30]]);
|
|
1705
|
+
const pose1Block = buildBlockHeader(3, AWD2_BLOCK_SKELETON_POSE, pose1Body.length);
|
|
1706
|
+
|
|
1707
|
+
const animBody = buildSkeletonAnimationBody('Anim', [
|
|
1708
|
+
{ duration: 1000, poseBlockId: 2 },
|
|
1709
|
+
{ duration: 1000, poseBlockId: 3 },
|
|
1710
|
+
]);
|
|
1711
|
+
const animBlock = buildBlockHeader(4, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1712
|
+
|
|
1713
|
+
const body = concatBytes(
|
|
1714
|
+
skeletonBlock,
|
|
1715
|
+
skeletonBody,
|
|
1716
|
+
pose0Block,
|
|
1717
|
+
pose0Body,
|
|
1718
|
+
pose1Block,
|
|
1719
|
+
pose1Body,
|
|
1720
|
+
animBlock,
|
|
1721
|
+
animBody,
|
|
1722
|
+
);
|
|
1723
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1724
|
+
|
|
1725
|
+
const clip = firstAwdClip(awd, [createNode3D()])!;
|
|
1726
|
+
const track = clip.channels[0].track;
|
|
1727
|
+
|
|
1728
|
+
const out = [0, 0, 0];
|
|
1729
|
+
sampleAnimationTrack(out, track, 0);
|
|
1730
|
+
expect(out[0]).toBeCloseTo(0);
|
|
1731
|
+
expect(out[1]).toBeCloseTo(0);
|
|
1732
|
+
expect(out[2]).toBeCloseTo(0);
|
|
1733
|
+
|
|
1734
|
+
sampleAnimationTrack(out, track, 1);
|
|
1735
|
+
expect(out[0]).toBeCloseTo(10);
|
|
1736
|
+
expect(out[1]).toBeCloseTo(20);
|
|
1737
|
+
expect(out[2]).toBeCloseTo(-30);
|
|
1738
|
+
|
|
1739
|
+
sampleAnimationTrack(out, track, 0.5);
|
|
1740
|
+
expect(out[0]).toBeCloseTo(5);
|
|
1741
|
+
expect(out[1]).toBeCloseTo(10);
|
|
1742
|
+
expect(out[2]).toBeCloseTo(-15);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it('emits a per-joint scale channel with the decomposed pose scale', () => {
|
|
1746
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1747
|
+
{ name: 'Joint0', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1748
|
+
]);
|
|
1749
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1750
|
+
const pose0Body = buildSkeletonPoseBody('P0', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]]);
|
|
1751
|
+
const pose0Block = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, pose0Body.length);
|
|
1752
|
+
// A diagonal 2/3/4 scale basis. convertTransformLhToRh (S·M·S) preserves diagonal scale, so it
|
|
1753
|
+
// decomposes back to exactly (2,3,4).
|
|
1754
|
+
const pose1Body = buildSkeletonPoseBody('P1', [[2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0]]);
|
|
1755
|
+
const pose1Block = buildBlockHeader(3, AWD2_BLOCK_SKELETON_POSE, pose1Body.length);
|
|
1756
|
+
const animBody = buildSkeletonAnimationBody('Anim', [
|
|
1757
|
+
{ duration: 1000, poseBlockId: 2 },
|
|
1758
|
+
{ duration: 1000, poseBlockId: 3 },
|
|
1759
|
+
]);
|
|
1760
|
+
const animBlock = buildBlockHeader(4, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1761
|
+
const body = concatBytes(
|
|
1762
|
+
skeletonBlock,
|
|
1763
|
+
skeletonBody,
|
|
1764
|
+
pose0Block,
|
|
1765
|
+
pose0Body,
|
|
1766
|
+
pose1Block,
|
|
1767
|
+
pose1Body,
|
|
1768
|
+
animBlock,
|
|
1769
|
+
animBody,
|
|
1770
|
+
);
|
|
1771
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1772
|
+
|
|
1773
|
+
const clip = firstAwdClip(awd, [createNode3D()])!;
|
|
1774
|
+
const scaleChannel = clip.channels.find((c) => (c.targetRef as Scene3DAnimationTarget).path === 'Scale');
|
|
1775
|
+
expect(scaleChannel).toBeDefined();
|
|
1776
|
+
expect(scaleChannel!.track.components).toBe(3);
|
|
1777
|
+
|
|
1778
|
+
const out = [0, 0, 0];
|
|
1779
|
+
sampleAnimationTrack(out, scaleChannel!.track, 0);
|
|
1780
|
+
expect(out.map((v) => Number(v.toFixed(3)))).toEqual([1, 1, 1]);
|
|
1781
|
+
sampleAnimationTrack(out, scaleChannel!.track, 1);
|
|
1782
|
+
expect(out.map((v) => Number(v.toFixed(3)))).toEqual([2, 3, 4]);
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
it('omits the scale channel for identity-scale skeletons', () => {
|
|
1786
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1787
|
+
{ name: 'Joint0', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1788
|
+
]);
|
|
1789
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1790
|
+
const pose0Body = buildSkeletonPoseBody('P0', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]]);
|
|
1791
|
+
const pose0Block = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, pose0Body.length);
|
|
1792
|
+
const pose1Body = buildSkeletonPoseBody('P1', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 10, 0, 0]]);
|
|
1793
|
+
const pose1Block = buildBlockHeader(3, AWD2_BLOCK_SKELETON_POSE, pose1Body.length);
|
|
1794
|
+
const animBody = buildSkeletonAnimationBody('Anim', [
|
|
1795
|
+
{ duration: 1000, poseBlockId: 2 },
|
|
1796
|
+
{ duration: 1000, poseBlockId: 3 },
|
|
1797
|
+
]);
|
|
1798
|
+
const animBlock = buildBlockHeader(4, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1799
|
+
const body = concatBytes(
|
|
1800
|
+
skeletonBlock,
|
|
1801
|
+
skeletonBody,
|
|
1802
|
+
pose0Block,
|
|
1803
|
+
pose0Body,
|
|
1804
|
+
pose1Block,
|
|
1805
|
+
pose1Body,
|
|
1806
|
+
animBlock,
|
|
1807
|
+
animBody,
|
|
1808
|
+
);
|
|
1809
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1810
|
+
|
|
1811
|
+
const clip = firstAwdClip(awd, [createNode3D()])!;
|
|
1812
|
+
expect(clip.channels.some((c) => (c.targetRef as Scene3DAnimationTarget).path === 'Scale')).toBe(false);
|
|
1813
|
+
// Only translation + rotation for the single joint.
|
|
1814
|
+
expect(clip.channels).toHaveLength(2);
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it('uses Scene3DAnimationTarget as channel targetRef', () => {
|
|
1818
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1819
|
+
{ name: 'Bone', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1820
|
+
]);
|
|
1821
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1822
|
+
|
|
1823
|
+
const poseBody = buildSkeletonPoseBody('P0', [IDENTITY_TRANSFORM]);
|
|
1824
|
+
const poseBlock = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, poseBody.length);
|
|
1825
|
+
|
|
1826
|
+
const animBody = buildSkeletonAnimationBody('Anim', [{ duration: 100, poseBlockId: 2 }]);
|
|
1827
|
+
const animBlock = buildBlockHeader(3, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1828
|
+
|
|
1829
|
+
const body = concatBytes(skeletonBlock, skeletonBody, poseBlock, poseBody, animBlock, animBody);
|
|
1830
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1831
|
+
|
|
1832
|
+
const joints = [createNode3D()];
|
|
1833
|
+
const clip = firstAwdClip(awd, joints)!;
|
|
1834
|
+
const target = clip.channels[0].targetRef as Scene3DAnimationTarget;
|
|
1835
|
+
expect(target.node).toBe(joints[0]);
|
|
1836
|
+
expect(target.path).toBe('Translation');
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it('handles poses with missing transforms using identity translation', () => {
|
|
1840
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1841
|
+
{ name: 'Joint0', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1842
|
+
]);
|
|
1843
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1844
|
+
|
|
1845
|
+
const poseBody = buildSkeletonPoseBody('P0', [null]);
|
|
1846
|
+
const poseBlock = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, poseBody.length);
|
|
1847
|
+
|
|
1848
|
+
const animBody = buildSkeletonAnimationBody('Anim', [{ duration: 100, poseBlockId: 2 }]);
|
|
1849
|
+
const animBlock = buildBlockHeader(3, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1850
|
+
|
|
1851
|
+
const body = concatBytes(skeletonBlock, skeletonBody, poseBlock, poseBody, animBlock, animBody);
|
|
1852
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1853
|
+
|
|
1854
|
+
const clip = firstAwdClip(awd, [createNode3D()])!;
|
|
1855
|
+
const out = [0, 0, 0];
|
|
1856
|
+
sampleAnimationTrack(out, clip.channels[0].track, 0);
|
|
1857
|
+
expect(out).toEqual([0, 0, 0]);
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
it('returns null and records a diagnostic when the joints array is shorter than the skeleton', () => {
|
|
1861
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1862
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1863
|
+
{ name: 'Child', parentIndex: 1, transform: IDENTITY_TRANSFORM },
|
|
1864
|
+
]);
|
|
1865
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1866
|
+
|
|
1867
|
+
const poseBody = buildSkeletonPoseBody('P0', [IDENTITY_TRANSFORM, IDENTITY_TRANSFORM]);
|
|
1868
|
+
const poseBlock = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, poseBody.length);
|
|
1869
|
+
|
|
1870
|
+
const animBody = buildSkeletonAnimationBody('Anim', [{ duration: 100, poseBlockId: 2 }]);
|
|
1871
|
+
const animBlock = buildBlockHeader(3, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1872
|
+
|
|
1873
|
+
const body = concatBytes(skeletonBlock, skeletonBody, poseBlock, poseBody, animBlock, animBody);
|
|
1874
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1875
|
+
|
|
1876
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1877
|
+
const clip = firstAwdClip(awd, [createNode3D()], diagnostics);
|
|
1878
|
+
expect(clip).toBeUndefined();
|
|
1879
|
+
expect(diagnostics).toHaveLength(1);
|
|
1880
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.joint-count-mismatch');
|
|
1881
|
+
expect(crumb.severity).toBe('Reject');
|
|
1882
|
+
expect(crumb.origin).toBe('parseAwd2SkeletonAnimations');
|
|
1883
|
+
expect(crumb.detail?.jointNodes).toBe(1);
|
|
1884
|
+
expect(crumb.detail?.skeletonJoints).toBe(2);
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it('returns null and records a diagnostic when no skeleton blocks are found', () => {
|
|
1888
|
+
const awd = buildAwdHeader(0);
|
|
1889
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1890
|
+
const clip = firstAwdClip(awd, [], diagnostics);
|
|
1891
|
+
expect(clip).toBeUndefined();
|
|
1892
|
+
expect(diagnostics).toHaveLength(1);
|
|
1893
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.no-skeleton-blocks');
|
|
1894
|
+
expect(crumb.detail).toBeUndefined();
|
|
1895
|
+
expect(crumb.severity).toBe('Reject');
|
|
1896
|
+
expect(crumb.origin).toBe('parseAwd2SkeletonAnimations');
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it('returns null and records a diagnostic when no animation blocks are found', () => {
|
|
1900
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1901
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1902
|
+
]);
|
|
1903
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1904
|
+
|
|
1905
|
+
const body = concatBytes(skeletonBlock, skeletonBody);
|
|
1906
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1907
|
+
|
|
1908
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1909
|
+
const clip = firstAwdClip(awd, [createNode3D()], diagnostics);
|
|
1910
|
+
expect(clip).toBeUndefined();
|
|
1911
|
+
expect(diagnostics).toHaveLength(1);
|
|
1912
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.no-skeleton-animation-blocks');
|
|
1913
|
+
expect(crumb.detail).toBeUndefined();
|
|
1914
|
+
expect(crumb.severity).toBe('Reject');
|
|
1915
|
+
expect(crumb.origin).toBe('parseAwd2SkeletonAnimations');
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
it('returns null and records a diagnostic for truncated input', () => {
|
|
1919
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1920
|
+
const clip = firstAwdClip(new Uint8Array(4), [], diagnostics);
|
|
1921
|
+
expect(clip).toBeUndefined();
|
|
1922
|
+
expect(diagnostics).toHaveLength(1);
|
|
1923
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.header-too-short');
|
|
1924
|
+
expect(crumb.detail).toBeUndefined();
|
|
1925
|
+
expect(crumb.severity).toBe('Reject');
|
|
1926
|
+
expect(crumb.origin).toBe('parseAwd2SkeletonAnimations');
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
it('returns null and records a diagnostic for invalid magic', () => {
|
|
1930
|
+
const bogus = new Uint8Array(12);
|
|
1931
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1932
|
+
const clip = firstAwdClip(bogus, [], diagnostics);
|
|
1933
|
+
expect(clip).toBeUndefined();
|
|
1934
|
+
expect(diagnostics).toHaveLength(1);
|
|
1935
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.bad-magic');
|
|
1936
|
+
expect(crumb.detail).toBeUndefined();
|
|
1937
|
+
expect(crumb.severity).toBe('Reject');
|
|
1938
|
+
expect(crumb.origin).toBe('parseAwd2SkeletonAnimations');
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
it('records a diagnostic when animation references a missing pose block', () => {
|
|
1942
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1943
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1944
|
+
]);
|
|
1945
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1946
|
+
|
|
1947
|
+
const animBody = buildSkeletonAnimationBody('Anim', [{ duration: 100, poseBlockId: 99 }]);
|
|
1948
|
+
const animBlock = buildBlockHeader(2, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1949
|
+
|
|
1950
|
+
const body = concatBytes(skeletonBlock, skeletonBody, animBlock, animBody);
|
|
1951
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1952
|
+
|
|
1953
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1954
|
+
const clip = firstAwdClip(awd, [createNode3D()], diagnostics);
|
|
1955
|
+
expect(clip).toBeDefined();
|
|
1956
|
+
expect(diagnostics).toHaveLength(1);
|
|
1957
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.pose-block-missing');
|
|
1958
|
+
expect(crumb.severity).toBe('Recover');
|
|
1959
|
+
expect(crumb.origin).toBe('buildAwdSkeletonAnimationClip');
|
|
1960
|
+
expect(crumb.detail?.distinctPoseBlocks).toBe(1);
|
|
1961
|
+
expect(crumb.detail?.firstPoseBlock).toBe(99);
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
it('converts pose durations from milliseconds to seconds in keyframe times', () => {
|
|
1965
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
1966
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
1967
|
+
]);
|
|
1968
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
1969
|
+
|
|
1970
|
+
const pose0Body = buildSkeletonPoseBody('P0', [IDENTITY_TRANSFORM]);
|
|
1971
|
+
const pose0Block = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, pose0Body.length);
|
|
1972
|
+
|
|
1973
|
+
const pose1Body = buildSkeletonPoseBody('P1', [IDENTITY_TRANSFORM]);
|
|
1974
|
+
const pose1Block = buildBlockHeader(3, AWD2_BLOCK_SKELETON_POSE, pose1Body.length);
|
|
1975
|
+
|
|
1976
|
+
const animBody = buildSkeletonAnimationBody('Anim', [
|
|
1977
|
+
{ duration: 250, poseBlockId: 2 },
|
|
1978
|
+
{ duration: 750, poseBlockId: 3 },
|
|
1979
|
+
]);
|
|
1980
|
+
const animBlock = buildBlockHeader(4, AWD2_BLOCK_SKELETON_ANIMATION, animBody.length);
|
|
1981
|
+
|
|
1982
|
+
const body = concatBytes(
|
|
1983
|
+
skeletonBlock,
|
|
1984
|
+
skeletonBody,
|
|
1985
|
+
pose0Block,
|
|
1986
|
+
pose0Body,
|
|
1987
|
+
pose1Block,
|
|
1988
|
+
pose1Body,
|
|
1989
|
+
animBlock,
|
|
1990
|
+
animBody,
|
|
1991
|
+
);
|
|
1992
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
1993
|
+
|
|
1994
|
+
const clip = firstAwdClip(awd, [createNode3D()])!;
|
|
1995
|
+
expect(clip.duration).toBeCloseTo(1.0);
|
|
1996
|
+
|
|
1997
|
+
const track = clip.channels[0].track;
|
|
1998
|
+
expect(track.times[0]).toBeCloseTo(0);
|
|
1999
|
+
expect(track.times[1]).toBeCloseTo(0.25);
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it('keys every named animation block into the map', () => {
|
|
2003
|
+
const skeletonBody = buildSkeletonBody('Skeleton', [
|
|
2004
|
+
{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM },
|
|
2005
|
+
]);
|
|
2006
|
+
const skeletonBlock = buildBlockHeader(1, AWD2_BLOCK_SKELETON, skeletonBody.length);
|
|
2007
|
+
|
|
2008
|
+
// Two poses at distinct X so each animation's sampled translation is distinguishable.
|
|
2009
|
+
const idlePoseBody = buildSkeletonPoseBody('IdlePose', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, 0]]);
|
|
2010
|
+
const idlePoseBlock = buildBlockHeader(2, AWD2_BLOCK_SKELETON_POSE, idlePoseBody.length);
|
|
2011
|
+
const attackPoseBody = buildSkeletonPoseBody('AttackPose', [[1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0, 0]]);
|
|
2012
|
+
const attackPoseBlock = buildBlockHeader(3, AWD2_BLOCK_SKELETON_POSE, attackPoseBody.length);
|
|
2013
|
+
|
|
2014
|
+
// 'idle' first in file order, 'attack' second — order is what default selection falls back to.
|
|
2015
|
+
const idleAnimBody = buildSkeletonAnimationBody('idle', [{ duration: 100, poseBlockId: 2 }]);
|
|
2016
|
+
const idleAnimBlock = buildBlockHeader(4, AWD2_BLOCK_SKELETON_ANIMATION, idleAnimBody.length);
|
|
2017
|
+
const attackAnimBody = buildSkeletonAnimationBody('attack', [{ duration: 100, poseBlockId: 3 }]);
|
|
2018
|
+
const attackAnimBlock = buildBlockHeader(5, AWD2_BLOCK_SKELETON_ANIMATION, attackAnimBody.length);
|
|
2019
|
+
|
|
2020
|
+
const body = concatBytes(
|
|
2021
|
+
skeletonBlock,
|
|
2022
|
+
skeletonBody,
|
|
2023
|
+
idlePoseBlock,
|
|
2024
|
+
idlePoseBody,
|
|
2025
|
+
attackPoseBlock,
|
|
2026
|
+
attackPoseBody,
|
|
2027
|
+
idleAnimBlock,
|
|
2028
|
+
idleAnimBody,
|
|
2029
|
+
attackAnimBlock,
|
|
2030
|
+
attackAnimBody,
|
|
2031
|
+
);
|
|
2032
|
+
const awd = concatBytes(buildAwdHeader(body.length), body);
|
|
2033
|
+
|
|
2034
|
+
const sampleX = (clip: AnimationClip): number => {
|
|
2035
|
+
const out = [0, 0, 0];
|
|
2036
|
+
sampleAnimationTrack(out, clip.channels[0].track, 0);
|
|
2037
|
+
return out[0];
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// Every named block is keyed into the map, each sampling its own poses ('idle' X=3, 'attack' X=9).
|
|
2041
|
+
const clips = parseAwd2SkeletonAnimations(awd, [createNode3D()]);
|
|
2042
|
+
expect(Object.keys(clips).sort()).toEqual(['attack', 'idle']);
|
|
2043
|
+
expect(sampleX(clips.idle)).toBeCloseTo(3);
|
|
2044
|
+
expect(sampleX(clips.attack)).toBeCloseTo(9);
|
|
2045
|
+
});
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
// Wraps ONE block of `blockType` at a deliberately short declared length so the parser truncates INSIDE
|
|
2049
|
+
// it: the block carries only `fullBody`'s first `cut` bytes as its declared blockLength, and the AWD body
|
|
2050
|
+
// is sized to exactly that block — so the walk advances to the body end after the single truncated block
|
|
2051
|
+
// (one crumb, no trailing garbage parse). `cut` is the byte count of the fields BEFORE the truncated one.
|
|
2052
|
+
function truncatedAwd(blockType: number, fullBody: Uint8Array, cut: number): Uint8Array {
|
|
2053
|
+
const body = concatBytes(buildBlockHeader(1, blockType, cut), fullBody.slice(0, cut));
|
|
2054
|
+
return concatBytes(buildAwdHeader(body.length), body);
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// A VALID skeleton block followed by ONE truncated pose/animation block. Pose/animation blocks are not
|
|
2058
|
+
// parsed by parseAwd2's first walk, so the ONLY thing that parses them on the createScene3DFromAwd2 document
|
|
2059
|
+
// path is buildAwdDocumentAnimations — the exact call the sink-threading repair targeted. The valid
|
|
2060
|
+
// skeleton drives that second walk (and means no no-skeleton-blocks crumb is emitted), so the truncated
|
|
2061
|
+
// block's crumb is the sole diagnostic: this fixture regression-guards the repaired path directly.
|
|
2062
|
+
function docPathTruncatedAwd(blockType: number, fullBody: Uint8Array, cut: number): Uint8Array {
|
|
2063
|
+
const skel = buildSkeletonBody('Rig', [{ name: 'Root', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
2064
|
+
const body = concatBytes(
|
|
2065
|
+
buildBlockHeader(1, AWD2_BLOCK_SKELETON, skel.length),
|
|
2066
|
+
skel,
|
|
2067
|
+
buildBlockHeader(2, blockType, cut),
|
|
2068
|
+
fullBody.slice(0, cut),
|
|
2069
|
+
);
|
|
2070
|
+
return concatBytes(buildAwdHeader(body.length), body);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// Bodies whose first-walk block types are parsed by createScene3DFromAwd2/parseAwd2 directly. Field cut
|
|
2074
|
+
// offsets come from each parse function's documented layout (empty names keep every length deterministic).
|
|
2075
|
+
const CONTAINER_BODY = buildContainerBody('', 0, IDENTITY_TRANSFORM);
|
|
2076
|
+
const MESH_INSTANCE_BODY = buildMeshInstanceBody('', 0, IDENTITY_TRANSFORM, 1);
|
|
2077
|
+
const MATERIAL_BODY_T = buildMaterialBody('', AWD2_MATERIAL_TYPE_COLOR, []);
|
|
2078
|
+
const TEXTURE_BODY_T = buildTextureBody('', AWD2_TEXTURE_TYPE_EMBEDDED, FAKE_PNG_BYTES);
|
|
2079
|
+
const SKELETON_BODY_T = buildSkeletonBody('', [{ name: '', parentIndex: 0, transform: IDENTITY_TRANSFORM }]);
|
|
2080
|
+
|
|
2081
|
+
// (blockType, body, cut) → the exact Drop crumb the createScene3DFromAwd2 first walk records. Every field
|
|
2082
|
+
// variant of every per-block "truncated" kind, locking kind + severity + true origin + detail.field.
|
|
2083
|
+
const CREATE_PATH_TRUNCATIONS: Array<{
|
|
2084
|
+
blockType: number;
|
|
2085
|
+
body: Uint8Array;
|
|
2086
|
+
bytes?: number;
|
|
2087
|
+
cut: number;
|
|
2088
|
+
field: string;
|
|
2089
|
+
kind: string;
|
|
2090
|
+
origin: string;
|
|
2091
|
+
}> = [
|
|
2092
|
+
{
|
|
2093
|
+
blockType: AWD2_BLOCK_CONTAINER,
|
|
2094
|
+
body: CONTAINER_BODY,
|
|
2095
|
+
cut: 0,
|
|
2096
|
+
field: 'parentId',
|
|
2097
|
+
kind: 'awd2.container-truncated',
|
|
2098
|
+
origin: 'parseContainerBlock',
|
|
2099
|
+
},
|
|
2100
|
+
{
|
|
2101
|
+
blockType: AWD2_BLOCK_CONTAINER,
|
|
2102
|
+
body: CONTAINER_BODY,
|
|
2103
|
+
cut: 4,
|
|
2104
|
+
field: 'transform',
|
|
2105
|
+
kind: 'awd2.container-truncated',
|
|
2106
|
+
origin: 'parseContainerBlock',
|
|
2107
|
+
},
|
|
2108
|
+
{
|
|
2109
|
+
blockType: AWD2_BLOCK_CONTAINER,
|
|
2110
|
+
body: CONTAINER_BODY,
|
|
2111
|
+
cut: 52,
|
|
2112
|
+
field: 'name',
|
|
2113
|
+
kind: 'awd2.container-truncated',
|
|
2114
|
+
origin: 'parseContainerBlock',
|
|
2115
|
+
},
|
|
2116
|
+
{
|
|
2117
|
+
blockType: AWD2_BLOCK_MESH_INSTANCE,
|
|
2118
|
+
body: MESH_INSTANCE_BODY,
|
|
2119
|
+
cut: 0,
|
|
2120
|
+
field: 'parentId',
|
|
2121
|
+
kind: 'awd2.mesh-instance-truncated',
|
|
2122
|
+
origin: 'parseMeshInstanceBlock',
|
|
2123
|
+
},
|
|
2124
|
+
{
|
|
2125
|
+
blockType: AWD2_BLOCK_MESH_INSTANCE,
|
|
2126
|
+
body: MESH_INSTANCE_BODY,
|
|
2127
|
+
cut: 4,
|
|
2128
|
+
field: 'transform',
|
|
2129
|
+
kind: 'awd2.mesh-instance-truncated',
|
|
2130
|
+
origin: 'parseMeshInstanceBlock',
|
|
2131
|
+
},
|
|
2132
|
+
{
|
|
2133
|
+
blockType: AWD2_BLOCK_MESH_INSTANCE,
|
|
2134
|
+
body: MESH_INSTANCE_BODY,
|
|
2135
|
+
cut: 52,
|
|
2136
|
+
field: 'name',
|
|
2137
|
+
kind: 'awd2.mesh-instance-truncated',
|
|
2138
|
+
origin: 'parseMeshInstanceBlock',
|
|
2139
|
+
},
|
|
2140
|
+
{
|
|
2141
|
+
blockType: AWD2_BLOCK_MESH_INSTANCE,
|
|
2142
|
+
body: MESH_INSTANCE_BODY,
|
|
2143
|
+
cut: 54,
|
|
2144
|
+
field: 'geometryId',
|
|
2145
|
+
kind: 'awd2.mesh-instance-truncated',
|
|
2146
|
+
origin: 'parseMeshInstanceBlock',
|
|
2147
|
+
},
|
|
2148
|
+
{
|
|
2149
|
+
blockType: AWD2_BLOCK_MATERIAL,
|
|
2150
|
+
body: MATERIAL_BODY_T,
|
|
2151
|
+
cut: 0,
|
|
2152
|
+
field: 'name',
|
|
2153
|
+
kind: 'awd2.material-truncated',
|
|
2154
|
+
origin: 'parseMaterialBlock',
|
|
2155
|
+
},
|
|
2156
|
+
{
|
|
2157
|
+
blockType: AWD2_BLOCK_MATERIAL,
|
|
2158
|
+
body: MATERIAL_BODY_T,
|
|
2159
|
+
cut: 2,
|
|
2160
|
+
field: 'type',
|
|
2161
|
+
kind: 'awd2.material-truncated',
|
|
2162
|
+
origin: 'parseMaterialBlock',
|
|
2163
|
+
},
|
|
2164
|
+
{
|
|
2165
|
+
blockType: AWD2_BLOCK_TEXTURE,
|
|
2166
|
+
body: TEXTURE_BODY_T,
|
|
2167
|
+
cut: 0,
|
|
2168
|
+
field: 'name',
|
|
2169
|
+
kind: 'awd2.texture-truncated',
|
|
2170
|
+
origin: 'parseTextureBlock',
|
|
2171
|
+
},
|
|
2172
|
+
{
|
|
2173
|
+
blockType: AWD2_BLOCK_TEXTURE,
|
|
2174
|
+
body: TEXTURE_BODY_T,
|
|
2175
|
+
cut: 2,
|
|
2176
|
+
field: 'payload',
|
|
2177
|
+
kind: 'awd2.texture-truncated',
|
|
2178
|
+
origin: 'parseTextureBlock',
|
|
2179
|
+
},
|
|
2180
|
+
{
|
|
2181
|
+
blockType: AWD2_BLOCK_TEXTURE,
|
|
2182
|
+
body: TEXTURE_BODY_T,
|
|
2183
|
+
bytes: FAKE_PNG_BYTES.length,
|
|
2184
|
+
cut: 7,
|
|
2185
|
+
field: 'data',
|
|
2186
|
+
kind: 'awd2.texture-truncated',
|
|
2187
|
+
origin: 'parseTextureBlock',
|
|
2188
|
+
},
|
|
2189
|
+
{
|
|
2190
|
+
blockType: AWD2_BLOCK_SKELETON,
|
|
2191
|
+
body: SKELETON_BODY_T,
|
|
2192
|
+
cut: 0,
|
|
2193
|
+
field: 'name',
|
|
2194
|
+
kind: 'awd2.skeleton-truncated',
|
|
2195
|
+
origin: 'parseSkeletonBlock',
|
|
2196
|
+
},
|
|
2197
|
+
{
|
|
2198
|
+
blockType: AWD2_BLOCK_SKELETON,
|
|
2199
|
+
body: SKELETON_BODY_T,
|
|
2200
|
+
cut: 2,
|
|
2201
|
+
field: 'jointCount',
|
|
2202
|
+
kind: 'awd2.skeleton-truncated',
|
|
2203
|
+
origin: 'parseSkeletonBlock',
|
|
2204
|
+
},
|
|
2205
|
+
{
|
|
2206
|
+
blockType: AWD2_BLOCK_SKELETON,
|
|
2207
|
+
body: SKELETON_BODY_T,
|
|
2208
|
+
cut: 8,
|
|
2209
|
+
field: 'jointFields',
|
|
2210
|
+
kind: 'awd2.skeleton-truncated',
|
|
2211
|
+
origin: 'parseSkeletonBlock',
|
|
2212
|
+
},
|
|
2213
|
+
{
|
|
2214
|
+
blockType: AWD2_BLOCK_SKELETON,
|
|
2215
|
+
body: SKELETON_BODY_T,
|
|
2216
|
+
cut: 12,
|
|
2217
|
+
field: 'jointName',
|
|
2218
|
+
kind: 'awd2.skeleton-truncated',
|
|
2219
|
+
origin: 'parseSkeletonBlock',
|
|
2220
|
+
},
|
|
2221
|
+
{
|
|
2222
|
+
blockType: AWD2_BLOCK_SKELETON,
|
|
2223
|
+
body: SKELETON_BODY_T,
|
|
2224
|
+
cut: 14,
|
|
2225
|
+
field: 'jointTransform',
|
|
2226
|
+
kind: 'awd2.skeleton-truncated',
|
|
2227
|
+
origin: 'parseSkeletonBlock',
|
|
2228
|
+
},
|
|
2229
|
+
];
|
|
2230
|
+
|
|
2231
|
+
// Pose/animation blocks are NOT parsed by the first walk; parseAwd2SkeletonAnimations is their reporting
|
|
2232
|
+
// site (it also returns {} with a no-skeleton-blocks crumb, which expectOneCrumb ignores by kind).
|
|
2233
|
+
const POSE_BODY_T = buildSkeletonPoseBody('', [IDENTITY_TRANSFORM]);
|
|
2234
|
+
const ANIM_BODY_T = buildSkeletonAnimationBody('', [{ duration: 100, poseBlockId: 1 }]);
|
|
2235
|
+
const ANIM_PATH_TRUNCATIONS: Array<{
|
|
2236
|
+
blockType: number;
|
|
2237
|
+
body: Uint8Array;
|
|
2238
|
+
cut: number;
|
|
2239
|
+
field: string;
|
|
2240
|
+
kind: string;
|
|
2241
|
+
origin: string;
|
|
2242
|
+
}> = [
|
|
2243
|
+
{
|
|
2244
|
+
blockType: AWD2_BLOCK_SKELETON_POSE,
|
|
2245
|
+
body: POSE_BODY_T,
|
|
2246
|
+
cut: 0,
|
|
2247
|
+
field: 'name',
|
|
2248
|
+
kind: 'awd2.skeleton-pose-truncated',
|
|
2249
|
+
origin: 'parseSkeletonPoseBlock',
|
|
2250
|
+
},
|
|
2251
|
+
{
|
|
2252
|
+
blockType: AWD2_BLOCK_SKELETON_POSE,
|
|
2253
|
+
body: POSE_BODY_T,
|
|
2254
|
+
cut: 2,
|
|
2255
|
+
field: 'jointCount',
|
|
2256
|
+
kind: 'awd2.skeleton-pose-truncated',
|
|
2257
|
+
origin: 'parseSkeletonPoseBlock',
|
|
2258
|
+
},
|
|
2259
|
+
{
|
|
2260
|
+
blockType: AWD2_BLOCK_SKELETON_POSE,
|
|
2261
|
+
body: POSE_BODY_T,
|
|
2262
|
+
cut: 8,
|
|
2263
|
+
field: 'hasTransform',
|
|
2264
|
+
kind: 'awd2.skeleton-pose-truncated',
|
|
2265
|
+
origin: 'parseSkeletonPoseBlock',
|
|
2266
|
+
},
|
|
2267
|
+
{
|
|
2268
|
+
blockType: AWD2_BLOCK_SKELETON_POSE,
|
|
2269
|
+
body: POSE_BODY_T,
|
|
2270
|
+
cut: 9,
|
|
2271
|
+
field: 'jointTransform',
|
|
2272
|
+
kind: 'awd2.skeleton-pose-truncated',
|
|
2273
|
+
origin: 'parseSkeletonPoseBlock',
|
|
2274
|
+
},
|
|
2275
|
+
{
|
|
2276
|
+
blockType: AWD2_BLOCK_SKELETON_ANIMATION,
|
|
2277
|
+
body: ANIM_BODY_T,
|
|
2278
|
+
cut: 0,
|
|
2279
|
+
field: 'name',
|
|
2280
|
+
kind: 'awd2.skeleton-animation-truncated',
|
|
2281
|
+
origin: 'parseSkeletonAnimationBlock',
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
blockType: AWD2_BLOCK_SKELETON_ANIMATION,
|
|
2285
|
+
body: ANIM_BODY_T,
|
|
2286
|
+
cut: 2,
|
|
2287
|
+
field: 'frameCount',
|
|
2288
|
+
kind: 'awd2.skeleton-animation-truncated',
|
|
2289
|
+
origin: 'parseSkeletonAnimationBlock',
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
blockType: AWD2_BLOCK_SKELETON_ANIMATION,
|
|
2293
|
+
body: ANIM_BODY_T,
|
|
2294
|
+
cut: 8,
|
|
2295
|
+
field: 'poseBlockId',
|
|
2296
|
+
kind: 'awd2.skeleton-animation-truncated',
|
|
2297
|
+
origin: 'parseSkeletonAnimationBlock',
|
|
2298
|
+
},
|
|
2299
|
+
{
|
|
2300
|
+
blockType: AWD2_BLOCK_SKELETON_ANIMATION,
|
|
2301
|
+
body: ANIM_BODY_T,
|
|
2302
|
+
cut: 12,
|
|
2303
|
+
field: 'poseDuration',
|
|
2304
|
+
kind: 'awd2.skeleton-animation-truncated',
|
|
2305
|
+
origin: 'parseSkeletonAnimationBlock',
|
|
2306
|
+
},
|
|
2307
|
+
];
|
|
2308
|
+
|
|
2309
|
+
describe('registerAwd2Decompressor', () => {
|
|
2310
|
+
// A trivial reversible "codec": the compressed body is a 4-byte marker followed by the real block
|
|
2311
|
+
// stream, and the decompressor strips the marker. A compressed length that differs from the inflated
|
|
2312
|
+
// length exercises the header body-length rewrite the rehydration performs.
|
|
2313
|
+
const MARKER = [0xde, 0xad, 0xbe, 0xef];
|
|
2314
|
+
const stripMarker: AwdDecompressor = (compressed) => compressed.subarray(MARKER.length);
|
|
2315
|
+
|
|
2316
|
+
// Re-wraps an uncompressed AWD as a `method`-compressed file whose body is `MARKER + originalBody`.
|
|
2317
|
+
const asCompressed = (uncompressed: Uint8Array, method: number): Uint8Array => {
|
|
2318
|
+
const payload = concatBytes(new Uint8Array(MARKER), uncompressed.subarray(12));
|
|
2319
|
+
const header = uncompressed.slice(0, 12);
|
|
2320
|
+
header[7] = method;
|
|
2321
|
+
new DataView(header.buffer).setUint32(8, payload.length, true);
|
|
2322
|
+
return concatBytes(header, payload);
|
|
2323
|
+
};
|
|
2324
|
+
|
|
2325
|
+
afterEach(() => {
|
|
2326
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, null);
|
|
2327
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_LZMA, null);
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
it('inflates a compressed geometry file through the registered codec, matching the uncompressed parse', () => {
|
|
2331
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, stripMarker);
|
|
2332
|
+
const fromCompressed = parseAwd2(asCompressed(SKINNED_TRIANGLE_AWD, AWD2_COMPRESSION_DEFLATE));
|
|
2333
|
+
const fromUncompressed = parseAwd2(SKINNED_TRIANGLE_AWD);
|
|
2334
|
+
expect(fromCompressed.meshes).toHaveLength(fromUncompressed.meshes.length);
|
|
2335
|
+
expect(fromCompressed.nodes).toHaveLength(fromUncompressed.nodes.length);
|
|
2336
|
+
expect(getMeshGeometryVertexCount(fromCompressed.meshes[0].geometry)).toBe(
|
|
2337
|
+
getMeshGeometryVertexCount(fromUncompressed.meshes[0].geometry),
|
|
2338
|
+
);
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
it('routes compressed skeleton-animation files through the same seam', () => {
|
|
2342
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, stripMarker);
|
|
2343
|
+
const joints = [createNode3D(), createNode3D()];
|
|
2344
|
+
const compressed = asCompressed(SKINNED_TRIANGLE_AWD, AWD2_COMPRESSION_DEFLATE);
|
|
2345
|
+
const clip = firstAwdClip(compressed, joints);
|
|
2346
|
+
expect(clip).toBeDefined();
|
|
2347
|
+
expect(clip!.channels).toHaveLength(4); // 2 joints × (translation + rotation)
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
it('records a diagnostic and returns empty when the compression method has no registered codec', () => {
|
|
2351
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
2352
|
+
const scene = createScene3DFromAwd2(asCompressed(SKINNED_TRIANGLE_AWD, AWD2_COMPRESSION_DEFLATE), diagnostics);
|
|
2353
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
2354
|
+
expect(diagnostics).toHaveLength(1);
|
|
2355
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.compression-no-decompressor');
|
|
2356
|
+
expect(crumb.severity).toBe('Reject');
|
|
2357
|
+
expect(crumb.origin).toBe('rehydrateAwdBody');
|
|
2358
|
+
expect(crumb.detail?.compression).toBe(AWD2_COMPRESSION_DEFLATE);
|
|
2359
|
+
});
|
|
2360
|
+
|
|
2361
|
+
it('records a diagnostic and returns empty when the registered codec fails to inflate', () => {
|
|
2362
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, () => null);
|
|
2363
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
2364
|
+
const scene = createScene3DFromAwd2(asCompressed(SKINNED_TRIANGLE_AWD, AWD2_COMPRESSION_DEFLATE), diagnostics);
|
|
2365
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
2366
|
+
expect(diagnostics).toHaveLength(1);
|
|
2367
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.decompression-failed');
|
|
2368
|
+
expect(crumb.severity).toBe('Reject');
|
|
2369
|
+
expect(crumb.origin).toBe('rehydrateAwdBody');
|
|
2370
|
+
expect(crumb.detail?.compression).toBe(AWD2_COMPRESSION_DEFLATE);
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
it('clears a codec when registered with null', () => {
|
|
2374
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, stripMarker);
|
|
2375
|
+
registerAwd2Decompressor(AWD2_COMPRESSION_DEFLATE, null);
|
|
2376
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
2377
|
+
parseAwd2(asCompressed(SKINNED_TRIANGLE_AWD, AWD2_COMPRESSION_DEFLATE), diagnostics);
|
|
2378
|
+
expect(diagnostics).toHaveLength(1);
|
|
2379
|
+
const crumb = expectOneCrumb(diagnostics, 'awd2.compression-no-decompressor');
|
|
2380
|
+
expect(crumb.origin).toBe('rehydrateAwdBody');
|
|
2381
|
+
expect(crumb.detail?.compression).toBe(AWD2_COMPRESSION_DEFLATE);
|
|
2382
|
+
});
|
|
2383
|
+
|
|
2384
|
+
// The real end-to-end (Away3D zlib-compresses the body; the vendored inflater reconstructs the identical
|
|
2385
|
+
// scene) uses a fixture precompressed offline, since this browser-clean package's build carries no
|
|
2386
|
+
// node:zlib. Provenance — generated once with node v22.22.1 from the SKINNED_TRIANGLE_AWD body above:
|
|
2387
|
+
// Buffer.from(deflateSync(SKINNED_TRIANGLE_AWD.subarray(12))).toString('base64')
|
|
2388
|
+
// The assertion is self-checking: if SKINNED_TRIANGLE_AWD ever changes, the stale fixture inflates to a
|
|
2389
|
+
// different body and the scene-equivalence check fails rather than passing silently.
|
|
2390
|
+
const SKINNED_BODY_COMPRESSED =
|
|
2391
|
+
'eJxjZAACRoZGIMnOEJydmZeXmsIIEmPIY4AARnYVBnTQYI+Nz8TKBtHBwMTAxs4DZYMwiGRnlwCLOADVNtgBGfYQjDCLCUSkMkwAkswMQZnpTHDzWRiC8vNLcNmLmw/1ARCyMjhnZOakEGfEAgd0I5hBhDiDP9E2o/K5YUHrm1qcwYiikBVEpDHkMID8H2DABHUxiRaQrIENxVpDkqxVcCTDWogmdhCRzgBKCSwM4Yk52RCLQYHwhZENTAIA8+wq1A==';
|
|
2392
|
+
|
|
2393
|
+
// Minimal, dependency-free base64 decoder (no atob/Buffer) — keeps the test browser-clean.
|
|
2394
|
+
const decodeBase64 = (input: string): Uint8Array => {
|
|
2395
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
2396
|
+
const lookup = new Uint8Array(128);
|
|
2397
|
+
for (let i = 0; i < alphabet.length; i++) lookup[alphabet.charCodeAt(i)] = i;
|
|
2398
|
+
const clean = input.replace(/=+$/, '');
|
|
2399
|
+
const out = new Uint8Array((clean.length * 3) >> 2);
|
|
2400
|
+
let acc = 0;
|
|
2401
|
+
let bits = 0;
|
|
2402
|
+
let o = 0;
|
|
2403
|
+
for (let i = 0; i < clean.length; i++) {
|
|
2404
|
+
acc = (acc << 6) | lookup[clean.charCodeAt(i)];
|
|
2405
|
+
bits += 6;
|
|
2406
|
+
if (bits >= 8) {
|
|
2407
|
+
bits -= 8;
|
|
2408
|
+
out[o++] = (acc >> bits) & 0xff;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
return out;
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
it('reconstructs the identical scene from a genuinely zlib-compressed AWD via the vendored inflater', () => {
|
|
2415
|
+
registerAwd2DeflateDecompressor();
|
|
2416
|
+
const compressedBody = decodeBase64(SKINNED_BODY_COMPRESSED);
|
|
2417
|
+
const header = SKINNED_TRIANGLE_AWD.slice(0, 12);
|
|
2418
|
+
header[7] = AWD2_COMPRESSION_DEFLATE;
|
|
2419
|
+
new DataView(header.buffer).setUint32(8, compressedBody.length, true);
|
|
2420
|
+
|
|
2421
|
+
const fromCompressed = parseAwd2(concatBytes(header, compressedBody));
|
|
2422
|
+
const fromUncompressed = parseAwd2(SKINNED_TRIANGLE_AWD);
|
|
2423
|
+
expect(fromCompressed.meshes).toHaveLength(fromUncompressed.meshes.length);
|
|
2424
|
+
expect(fromCompressed.nodes).toHaveLength(fromUncompressed.nodes.length);
|
|
2425
|
+
expect(getMeshGeometryVertexCount(fromCompressed.meshes[0].geometry)).toBe(
|
|
2426
|
+
getMeshGeometryVertexCount(fromUncompressed.meshes[0].geometry),
|
|
2427
|
+
);
|
|
2428
|
+
// The skinned mesh's joints0/weights0 survive the real inflate too (skin binding intact).
|
|
2429
|
+
expect(fromCompressed.skins).toHaveLength(fromUncompressed.skins.length);
|
|
2430
|
+
});
|
|
2431
|
+
});
|