@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,1033 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMeshGeometryIndexCount,
|
|
3
|
+
getMeshGeometryVertexCount,
|
|
4
|
+
getMeshGeometryVertexNormal,
|
|
5
|
+
getMeshGeometryVertexPosition,
|
|
6
|
+
getMeshGeometryVertexUv0,
|
|
7
|
+
} from '@flighthq/mesh';
|
|
8
|
+
import { getNodeChildren } from '@flighthq/node';
|
|
9
|
+
import { isMesh } from '@flighthq/scene3d';
|
|
10
|
+
import type {
|
|
11
|
+
BlinnPhongMaterial,
|
|
12
|
+
ExternalImageResourceReference,
|
|
13
|
+
ImportDiagnostic,
|
|
14
|
+
Mesh,
|
|
15
|
+
Scene3DAnimationTarget,
|
|
16
|
+
Node3D,
|
|
17
|
+
} from '@flighthq/types';
|
|
18
|
+
import { BlinnPhongMaterialKind } from '@flighthq/types';
|
|
19
|
+
|
|
20
|
+
import { createScene3DFromMd2, parseMd2 } from './md2Parse';
|
|
21
|
+
import { MD2_ANORMS } from './md2Schema';
|
|
22
|
+
|
|
23
|
+
// Builds a minimal valid MD2 binary buffer with one frame and the given triangles, vertices, and
|
|
24
|
+
// texcoords. All offsets are computed from the data sizes.
|
|
25
|
+
function buildMd2(options: {
|
|
26
|
+
compressedVertices: readonly { normalIndex: number; x: number; y: number; z: number }[];
|
|
27
|
+
// Optional per-frame vertex data for frames 1..N (frame 0 always uses `compressedVertices`). When
|
|
28
|
+
// omitted, extra frames are zero-filled. Length should be numFrames-1 when provided.
|
|
29
|
+
extraFrames?: readonly (readonly { normalIndex: number; x: number; y: number; z: number }[])[];
|
|
30
|
+
// Optional per-frame 16-byte name labels (index 0..numFrames-1). Omitted names stay all-null (empty).
|
|
31
|
+
frameNames?: readonly string[];
|
|
32
|
+
// Optional per-frame scale / translate overrides (index 0..numFrames-1); each falls back to the shared
|
|
33
|
+
// `scale` / `translate` when absent. Used to exercise MD2's per-frame position requantization.
|
|
34
|
+
frameScales?: readonly (readonly [number, number, number])[];
|
|
35
|
+
frameTranslates?: readonly (readonly [number, number, number])[];
|
|
36
|
+
magic?: number;
|
|
37
|
+
numFrames?: number;
|
|
38
|
+
scale?: readonly [number, number, number];
|
|
39
|
+
skin?: string;
|
|
40
|
+
// Multiple skin records (alternate textures). Takes precedence over `skin` when provided.
|
|
41
|
+
skins?: readonly string[];
|
|
42
|
+
skinHeight?: number;
|
|
43
|
+
skinWidth?: number;
|
|
44
|
+
texCoords: readonly { s: number; t: number }[];
|
|
45
|
+
translate?: readonly [number, number, number];
|
|
46
|
+
triangles: readonly {
|
|
47
|
+
texIndices: readonly [number, number, number];
|
|
48
|
+
vertIndices: readonly [number, number, number];
|
|
49
|
+
}[];
|
|
50
|
+
version?: number;
|
|
51
|
+
}): Uint8Array {
|
|
52
|
+
const {
|
|
53
|
+
compressedVertices,
|
|
54
|
+
extraFrames = [],
|
|
55
|
+
frameNames = [],
|
|
56
|
+
frameScales = [],
|
|
57
|
+
frameTranslates = [],
|
|
58
|
+
magic = 0x32504449,
|
|
59
|
+
numFrames = 1,
|
|
60
|
+
scale = [1, 1, 1],
|
|
61
|
+
skin,
|
|
62
|
+
skins,
|
|
63
|
+
skinHeight = 64,
|
|
64
|
+
skinWidth = 64,
|
|
65
|
+
texCoords,
|
|
66
|
+
translate = [0, 0, 0],
|
|
67
|
+
triangles,
|
|
68
|
+
version = 8,
|
|
69
|
+
} = options;
|
|
70
|
+
|
|
71
|
+
const allSkins = skins ?? (skin !== undefined ? [skin] : []);
|
|
72
|
+
const numVertices = compressedVertices.length;
|
|
73
|
+
const numTexCoords = texCoords.length;
|
|
74
|
+
const numTriangles = triangles.length;
|
|
75
|
+
const numSkins = allSkins.length;
|
|
76
|
+
|
|
77
|
+
// Compute offsets: header(68) + skins + texcoords + triangles + frame(s).
|
|
78
|
+
const offSkins = 68;
|
|
79
|
+
const offTexCoords = offSkins + numSkins * 64;
|
|
80
|
+
const offTriangles = offTexCoords + numTexCoords * 4;
|
|
81
|
+
const frameSize = 40 + numVertices * 4;
|
|
82
|
+
const offFrames = offTriangles + numTriangles * 12;
|
|
83
|
+
const offEnd = offFrames + numFrames * frameSize;
|
|
84
|
+
|
|
85
|
+
const buffer = new ArrayBuffer(offEnd);
|
|
86
|
+
const view = new DataView(buffer);
|
|
87
|
+
const bytes = new Uint8Array(buffer);
|
|
88
|
+
|
|
89
|
+
// Header (68 bytes, 17 int32 fields).
|
|
90
|
+
view.setInt32(0, magic, true);
|
|
91
|
+
view.setInt32(4, version, true);
|
|
92
|
+
view.setInt32(8, skinWidth, true);
|
|
93
|
+
view.setInt32(12, skinHeight, true);
|
|
94
|
+
view.setInt32(16, frameSize, true);
|
|
95
|
+
view.setInt32(20, numSkins, true);
|
|
96
|
+
view.setInt32(24, numVertices, true);
|
|
97
|
+
view.setInt32(28, numTexCoords, true);
|
|
98
|
+
view.setInt32(32, numTriangles, true);
|
|
99
|
+
view.setInt32(36, 0, true); // numGlCommands
|
|
100
|
+
view.setInt32(40, numFrames, true);
|
|
101
|
+
view.setInt32(44, numSkins > 0 ? offSkins : 0, true);
|
|
102
|
+
view.setInt32(48, offTexCoords, true);
|
|
103
|
+
view.setInt32(52, offTriangles, true);
|
|
104
|
+
view.setInt32(56, offFrames, true);
|
|
105
|
+
view.setInt32(60, 0, true); // offGlCommands
|
|
106
|
+
view.setInt32(64, offEnd, true);
|
|
107
|
+
|
|
108
|
+
// Skin records: each a 64-byte null-padded ASCII path.
|
|
109
|
+
for (let s = 0; s < allSkins.length; s++) {
|
|
110
|
+
const path = allSkins[s];
|
|
111
|
+
for (let i = 0; i < path.length && i < 63; i++) bytes[offSkins + s * 64 + i] = path.charCodeAt(i);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Texcoords: int16 s, int16 t.
|
|
115
|
+
for (let i = 0; i < numTexCoords; i++) {
|
|
116
|
+
const base = offTexCoords + i * 4;
|
|
117
|
+
view.setInt16(base, texCoords[i].s, true);
|
|
118
|
+
view.setInt16(base + 2, texCoords[i].t, true);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Triangles: 3 uint16 vertex indices + 3 uint16 texcoord indices.
|
|
122
|
+
for (let i = 0; i < numTriangles; i++) {
|
|
123
|
+
const base = offTriangles + i * 12;
|
|
124
|
+
for (let c = 0; c < 3; c++) {
|
|
125
|
+
view.setUint16(base + c * 2, triangles[i].vertIndices[c], true);
|
|
126
|
+
}
|
|
127
|
+
for (let c = 0; c < 3; c++) {
|
|
128
|
+
view.setUint16(base + 6 + c * 2, triangles[i].texIndices[c], true);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Each frame: scale(3 float32) + translate(3 float32) + name(16 chars) + compressed vertices. Frame 0
|
|
133
|
+
// uses `compressedVertices`; frames 1..N use `extraFrames` when supplied (zero-filled otherwise). Each
|
|
134
|
+
// frame's scale/translate is `frameScales`/`frameTranslates` when given, else the shared scale/translate.
|
|
135
|
+
for (let f = 0; f < numFrames; f++) {
|
|
136
|
+
const frameBase = offFrames + f * frameSize;
|
|
137
|
+
const fScale = frameScales[f] ?? scale;
|
|
138
|
+
const fTranslate = frameTranslates[f] ?? translate;
|
|
139
|
+
view.setFloat32(frameBase, fScale[0], true);
|
|
140
|
+
view.setFloat32(frameBase + 4, fScale[1], true);
|
|
141
|
+
view.setFloat32(frameBase + 8, fScale[2], true);
|
|
142
|
+
view.setFloat32(frameBase + 12, fTranslate[0], true);
|
|
143
|
+
view.setFloat32(frameBase + 16, fTranslate[1], true);
|
|
144
|
+
view.setFloat32(frameBase + 20, fTranslate[2], true);
|
|
145
|
+
// Frame name: a 16-byte null-padded ASCII label at frameBase + 24.
|
|
146
|
+
const frameName = frameNames[f];
|
|
147
|
+
if (frameName !== undefined) {
|
|
148
|
+
for (let i = 0; i < frameName.length && i < 15; i++) bytes[frameBase + 24 + i] = frameName.charCodeAt(i);
|
|
149
|
+
}
|
|
150
|
+
const frameVerts = f === 0 ? compressedVertices : (extraFrames[f - 1] ?? null);
|
|
151
|
+
if (frameVerts === null) continue; // zero-filled frame
|
|
152
|
+
for (let i = 0; i < numVertices; i++) {
|
|
153
|
+
const vBase = frameBase + 40 + i * 4;
|
|
154
|
+
bytes[vBase] = frameVerts[i].x;
|
|
155
|
+
bytes[vBase + 1] = frameVerts[i].y;
|
|
156
|
+
bytes[vBase + 2] = frameVerts[i].z;
|
|
157
|
+
bytes[vBase + 3] = frameVerts[i].normalIndex;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return bytes;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Asserts EXACTLY ONE crumb of `kind` was recorded (guards the count) and returns it so a test can lock
|
|
165
|
+
// the full contract — severity, true origin, and detail — for that emitted diagnostic.
|
|
166
|
+
function expectOneCrumb(diagnostics: readonly ImportDiagnostic[], kind: string): ImportDiagnostic {
|
|
167
|
+
const matches = diagnostics.filter((d) => d.kind === kind);
|
|
168
|
+
expect(matches).toHaveLength(1);
|
|
169
|
+
return matches[0];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe('createScene3DFromMd2', () => {
|
|
173
|
+
it('deduplicates vertices sharing the same vertex/texcoord pair', () => {
|
|
174
|
+
const md2 = buildMd2({
|
|
175
|
+
compressedVertices: [
|
|
176
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
177
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
178
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
179
|
+
],
|
|
180
|
+
scale: [1, 1, 1],
|
|
181
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
182
|
+
translate: [0, 0, 0],
|
|
183
|
+
triangles: [
|
|
184
|
+
{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] },
|
|
185
|
+
{ texIndices: [0, 0, 0], vertIndices: [2, 1, 0] },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const scene = createScene3DFromMd2(md2);
|
|
190
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
191
|
+
// 3 unique vertex/texcoord combos, 6 indices (2 triangles).
|
|
192
|
+
expect(getMeshGeometryVertexCount(geometry)).toBe(3);
|
|
193
|
+
expect(getMeshGeometryIndexCount(geometry)).toBe(6);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('lookups normals from the Anorms table', () => {
|
|
197
|
+
// Use normal index 5 which is [0, 0, 1].
|
|
198
|
+
const md2 = buildMd2({
|
|
199
|
+
compressedVertices: [
|
|
200
|
+
{ normalIndex: 5, x: 0, y: 0, z: 0 },
|
|
201
|
+
{ normalIndex: 5, x: 10, y: 0, z: 0 },
|
|
202
|
+
{ normalIndex: 5, x: 0, y: 10, z: 0 },
|
|
203
|
+
],
|
|
204
|
+
scale: [1, 1, 1],
|
|
205
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
206
|
+
translate: [0, 0, 0],
|
|
207
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const scene = createScene3DFromMd2(md2);
|
|
211
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
212
|
+
const n = { x: 0, y: 0, z: 0 };
|
|
213
|
+
getMeshGeometryVertexNormal(n, geometry, 0);
|
|
214
|
+
const expected = MD2_ANORMS[5];
|
|
215
|
+
expect(n.x).toBeCloseTo(expected[0], 5);
|
|
216
|
+
expect(n.y).toBeCloseTo(expected[2], 5);
|
|
217
|
+
expect(n.z).toBeCloseTo(-expected[1], 5);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('parses a single triangle with vertex decompression', () => {
|
|
221
|
+
// Compressed vertices at (10, 20, 30) with scale [0.5, 0.5, 0.5] and translate [1, 2, 3]
|
|
222
|
+
// decompressed = (10*0.5+1, 20*0.5+2, 30*0.5+3) = (6, 12, 18)
|
|
223
|
+
const md2 = buildMd2({
|
|
224
|
+
compressedVertices: [
|
|
225
|
+
{ normalIndex: 0, x: 10, y: 20, z: 30 },
|
|
226
|
+
{ normalIndex: 0, x: 20, y: 0, z: 0 },
|
|
227
|
+
{ normalIndex: 0, x: 0, y: 40, z: 0 },
|
|
228
|
+
],
|
|
229
|
+
scale: [0.5, 0.5, 0.5],
|
|
230
|
+
texCoords: [
|
|
231
|
+
{ s: 0, t: 0 },
|
|
232
|
+
{ s: 32, t: 0 },
|
|
233
|
+
{ s: 0, t: 32 },
|
|
234
|
+
],
|
|
235
|
+
translate: [1, 2, 3],
|
|
236
|
+
triangles: [{ texIndices: [0, 1, 2], vertIndices: [0, 1, 2] }],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const scene = createScene3DFromMd2(md2);
|
|
240
|
+
const children = getNodeChildren(scene.root);
|
|
241
|
+
expect(children).toHaveLength(1);
|
|
242
|
+
expect(isMesh(children[0] as Node3D)).toBe(true);
|
|
243
|
+
|
|
244
|
+
const geometry = (children[0] as Mesh).geometry;
|
|
245
|
+
expect(getMeshGeometryVertexCount(geometry)).toBe(3);
|
|
246
|
+
expect(getMeshGeometryIndexCount(geometry)).toBe(3);
|
|
247
|
+
|
|
248
|
+
// Decompressed MD2 position: (10*0.5+1, 20*0.5+2, 30*0.5+3) = (6, 12, 18).
|
|
249
|
+
// Z-up to Y-up: (x, z, -y) = (6, 18, -12).
|
|
250
|
+
const p = { x: 0, y: 0, z: 0 };
|
|
251
|
+
getMeshGeometryVertexPosition(p, geometry, 0);
|
|
252
|
+
expect(p.x).toBeCloseTo(6, 5);
|
|
253
|
+
expect(p.y).toBeCloseTo(18, 5);
|
|
254
|
+
expect(p.z).toBeCloseTo(-12, 5);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("decodes the model's skin to a BlinnPhongMaterial referencing the skin path as a diffuseMap", () => {
|
|
258
|
+
const md2 = buildMd2({
|
|
259
|
+
compressedVertices: [
|
|
260
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
261
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
262
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
263
|
+
],
|
|
264
|
+
skin: 'players/hero/skin.pcx',
|
|
265
|
+
texCoords: [
|
|
266
|
+
{ s: 0, t: 0 },
|
|
267
|
+
{ s: 1, t: 0 },
|
|
268
|
+
{ s: 0, t: 1 },
|
|
269
|
+
],
|
|
270
|
+
triangles: [{ texIndices: [0, 1, 2], vertIndices: [0, 1, 2] }],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const mesh = getNodeChildren(createScene3DFromMd2(md2).root)[0] as Mesh;
|
|
274
|
+
expect(mesh.materials).toHaveLength(1);
|
|
275
|
+
const material = mesh.materials[0] as BlinnPhongMaterial;
|
|
276
|
+
expect(material.kind).toBe(BlinnPhongMaterialKind);
|
|
277
|
+
expect(material.name).toBe('players/hero/skin.pcx'); // MD2 skin path preserved as the authored identity
|
|
278
|
+
// The skin path is referenced, not decoded: an Unresolved External ref, image left null.
|
|
279
|
+
expect((material.diffuseMap!.resource as ExternalImageResourceReference).uri).toBe('players/hero/skin.pcx');
|
|
280
|
+
expect(material.diffuseMap!.image).toBeNull();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('leaves the mesh unmaterialed when the model declares no skin', () => {
|
|
284
|
+
const md2 = buildMd2({
|
|
285
|
+
compressedVertices: [
|
|
286
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
287
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
288
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
289
|
+
],
|
|
290
|
+
texCoords: [
|
|
291
|
+
{ s: 0, t: 0 },
|
|
292
|
+
{ s: 1, t: 0 },
|
|
293
|
+
{ s: 0, t: 1 },
|
|
294
|
+
],
|
|
295
|
+
triangles: [{ texIndices: [0, 1, 2], vertIndices: [0, 1, 2] }],
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const mesh = getNodeChildren(createScene3DFromMd2(md2).root)[0] as Mesh;
|
|
299
|
+
expect(mesh.materials).toHaveLength(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('returns an empty scene for input shorter than the header', () => {
|
|
303
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
304
|
+
const scene = createScene3DFromMd2(new Uint8Array(10), diagnostics);
|
|
305
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
306
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.header-too-short');
|
|
307
|
+
expect(crumb.severity).toBe('Reject');
|
|
308
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
309
|
+
expect(crumb.detail?.byteLength).toBe(10);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('returns an empty scene for invalid magic', () => {
|
|
313
|
+
const md2 = buildMd2({
|
|
314
|
+
compressedVertices: [
|
|
315
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
316
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
317
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
318
|
+
],
|
|
319
|
+
magic: 0x12345678,
|
|
320
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
321
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
325
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
326
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
327
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.bad-magic');
|
|
328
|
+
expect(crumb.severity).toBe('Reject');
|
|
329
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
330
|
+
expect(crumb.detail?.magic).toBe('12345678');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('returns an empty scene for unsupported version', () => {
|
|
334
|
+
const md2 = buildMd2({
|
|
335
|
+
compressedVertices: [
|
|
336
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
337
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
338
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
339
|
+
],
|
|
340
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
341
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
342
|
+
version: 99,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
346
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
347
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
348
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.unsupported-version');
|
|
349
|
+
expect(crumb.severity).toBe('Reject');
|
|
350
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
351
|
+
expect(crumb.detail?.version).toBe(99);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('returns an empty scene when the buffer is truncated', () => {
|
|
355
|
+
const md2 = buildMd2({
|
|
356
|
+
compressedVertices: [
|
|
357
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
358
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
359
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
360
|
+
],
|
|
361
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
362
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Truncate the buffer to cut off the frame data.
|
|
366
|
+
const truncated = md2.slice(0, 70);
|
|
367
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
368
|
+
const scene = createScene3DFromMd2(truncated, diagnostics);
|
|
369
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
370
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.truncated-data-region');
|
|
371
|
+
expect(crumb.severity).toBe('Reject');
|
|
372
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
373
|
+
expect(crumb.detail?.byteLength).toBe(70);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('returns an empty scene when numFrames is zero', () => {
|
|
377
|
+
const md2 = buildMd2({
|
|
378
|
+
compressedVertices: [
|
|
379
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
380
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
381
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
382
|
+
],
|
|
383
|
+
numFrames: 0,
|
|
384
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
385
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
389
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
390
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
391
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.no-frames');
|
|
392
|
+
expect(crumb.severity).toBe('Reject');
|
|
393
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
394
|
+
expect(crumb.detail).toBeUndefined();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('reports a Reject diagnostic and yields an empty scene when the model declares no triangles', () => {
|
|
398
|
+
const md2 = buildMd2({
|
|
399
|
+
compressedVertices: [{ normalIndex: 0, x: 0, y: 0, z: 0 }],
|
|
400
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
401
|
+
triangles: [], // numTriangles === 0 → the no-triangles reject (after the no-frames gate)
|
|
402
|
+
});
|
|
403
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
404
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
405
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
406
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.no-triangles');
|
|
407
|
+
expect(crumb.severity).toBe('Reject');
|
|
408
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
409
|
+
expect(crumb.detail).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('reports a Drop diagnostic when a skin record runs past the end of the buffer', () => {
|
|
413
|
+
// Build a valid single-skin model, then point the skins offset (header field @44) near the end so the
|
|
414
|
+
// skin record overruns — without disturbing the texcoord/triangle/frame regions the earlier
|
|
415
|
+
// truncation gate checks, so the skin-record branch is what fires.
|
|
416
|
+
const md2 = buildMd2({
|
|
417
|
+
compressedVertices: [
|
|
418
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
419
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
420
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
421
|
+
],
|
|
422
|
+
skin: 'players/hero.pcx',
|
|
423
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
424
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
425
|
+
});
|
|
426
|
+
new DataView(md2.buffer).setInt32(44, md2.length - 10, true); // offSkins past a full 64-byte skin record
|
|
427
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
428
|
+
createScene3DFromMd2(md2, diagnostics);
|
|
429
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.skin-record-truncated');
|
|
430
|
+
expect(crumb.severity).toBe('Drop');
|
|
431
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
432
|
+
expect(crumb.detail?.skin).toBe(0);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('reports a Drop diagnostic (aggregated) for skin records with an empty path', () => {
|
|
436
|
+
// Two skin slots counted in numSkins but with all-null (empty) paths → no material, aggregated to one
|
|
437
|
+
// crumb with a count.
|
|
438
|
+
const md2 = buildMd2({
|
|
439
|
+
compressedVertices: [
|
|
440
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
441
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
442
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
443
|
+
],
|
|
444
|
+
skins: ['', ''],
|
|
445
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
446
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
447
|
+
});
|
|
448
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
449
|
+
createScene3DFromMd2(md2, diagnostics);
|
|
450
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.skin-empty-path');
|
|
451
|
+
expect(crumb.severity).toBe('Drop');
|
|
452
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
453
|
+
expect(crumb.detail?.count).toBe(2);
|
|
454
|
+
expect(crumb.detail?.firstSkin).toBe(0);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('scales UV coordinates by skinWidth and skinHeight', () => {
|
|
458
|
+
const md2 = buildMd2({
|
|
459
|
+
compressedVertices: [
|
|
460
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
461
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
462
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
463
|
+
],
|
|
464
|
+
skinHeight: 128,
|
|
465
|
+
skinWidth: 256,
|
|
466
|
+
texCoords: [
|
|
467
|
+
{ s: 128, t: 64 },
|
|
468
|
+
{ s: 256, t: 128 },
|
|
469
|
+
{ s: 0, t: 0 },
|
|
470
|
+
],
|
|
471
|
+
translate: [0, 0, 0],
|
|
472
|
+
triangles: [{ texIndices: [0, 1, 2], vertIndices: [0, 1, 2] }],
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const scene = createScene3DFromMd2(md2);
|
|
476
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
477
|
+
|
|
478
|
+
const uv = { x: 0, y: 0 };
|
|
479
|
+
getMeshGeometryVertexUv0(uv, geometry, 0);
|
|
480
|
+
expect(uv.x).toBeCloseTo(128 / 256, 5); // 0.5
|
|
481
|
+
expect(uv.y).toBeCloseTo(64 / 128, 5); // 0.5
|
|
482
|
+
|
|
483
|
+
getMeshGeometryVertexUv0(uv, geometry, 1);
|
|
484
|
+
expect(uv.x).toBeCloseTo(1.0, 5);
|
|
485
|
+
expect(uv.y).toBeCloseTo(1.0, 5);
|
|
486
|
+
|
|
487
|
+
getMeshGeometryVertexUv0(uv, geometry, 2);
|
|
488
|
+
expect(uv.x).toBeCloseTo(0.0, 5);
|
|
489
|
+
expect(uv.y).toBeCloseTo(0.0, 5);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('splits vertices when the same position has different texcoords', () => {
|
|
493
|
+
const md2 = buildMd2({
|
|
494
|
+
compressedVertices: [
|
|
495
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
496
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
497
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
498
|
+
],
|
|
499
|
+
texCoords: [
|
|
500
|
+
{ s: 0, t: 0 },
|
|
501
|
+
{ s: 32, t: 32 },
|
|
502
|
+
],
|
|
503
|
+
translate: [0, 0, 0],
|
|
504
|
+
triangles: [
|
|
505
|
+
{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] },
|
|
506
|
+
{ texIndices: [1, 1, 1], vertIndices: [0, 1, 2] },
|
|
507
|
+
],
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const scene = createScene3DFromMd2(md2);
|
|
511
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
512
|
+
// 3 vertices * 2 texcoord variants = 6 unique vertices.
|
|
513
|
+
expect(getMeshGeometryVertexCount(geometry)).toBe(6);
|
|
514
|
+
expect(getMeshGeometryIndexCount(geometry)).toBe(6);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('reports an aggregated Drop diagnostic for out-of-range vertex indices without crashing', () => {
|
|
518
|
+
const md2 = buildMd2({
|
|
519
|
+
compressedVertices: [
|
|
520
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
521
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
522
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
523
|
+
],
|
|
524
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
525
|
+
translate: [0, 0, 0],
|
|
526
|
+
triangles: [
|
|
527
|
+
{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] },
|
|
528
|
+
{ texIndices: [0, 0, 0], vertIndices: [99, 1, 2] },
|
|
529
|
+
],
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
533
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
534
|
+
// First triangle should still produce a mesh.
|
|
535
|
+
expect(getNodeChildren(scene.root)).toHaveLength(1);
|
|
536
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.triangle-vertex-index-out-of-range');
|
|
537
|
+
expect(crumb.severity).toBe('Drop');
|
|
538
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
539
|
+
expect(crumb.detail?.corners).toBe(1); // one out-of-range corner (index 99)
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('reports an aggregated Drop diagnostic for out-of-range texcoord indices without crashing', () => {
|
|
543
|
+
const md2 = buildMd2({
|
|
544
|
+
compressedVertices: [
|
|
545
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
546
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
547
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
548
|
+
],
|
|
549
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
550
|
+
translate: [0, 0, 0],
|
|
551
|
+
triangles: [
|
|
552
|
+
{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] },
|
|
553
|
+
{ texIndices: [99, 0, 0], vertIndices: [0, 1, 2] },
|
|
554
|
+
],
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
558
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
559
|
+
expect(getNodeChildren(scene.root)).toHaveLength(1);
|
|
560
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.triangle-texcoord-index-out-of-range');
|
|
561
|
+
expect(crumb.severity).toBe('Drop');
|
|
562
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
563
|
+
expect(crumb.detail?.corners).toBe(1);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('reports a Recover diagnostic (origin readMd2Frames) for an out-of-range vertex normal index, leaving that normal zero', () => {
|
|
567
|
+
// normalIndex 200 is a valid uint8 but past the 162-entry Anorms table.
|
|
568
|
+
const md2 = buildMd2({
|
|
569
|
+
compressedVertices: [
|
|
570
|
+
{ normalIndex: 200, x: 0, y: 0, z: 0 },
|
|
571
|
+
{ normalIndex: 200, x: 10, y: 0, z: 0 },
|
|
572
|
+
{ normalIndex: 200, x: 0, y: 10, z: 0 },
|
|
573
|
+
],
|
|
574
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
575
|
+
translate: [0, 0, 0],
|
|
576
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
580
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
581
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
582
|
+
const n = { x: 1, y: 1, z: 1 };
|
|
583
|
+
getMeshGeometryVertexNormal(n, geometry, 0);
|
|
584
|
+
expect(n).toEqual({ x: 0, y: 0, z: 0 });
|
|
585
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.normal-index-out-of-range');
|
|
586
|
+
expect(crumb.severity).toBe('Recover'); // geometry survives; the normal is left zero
|
|
587
|
+
expect(crumb.origin).toBe('readMd2Frames'); // the TRUE emitting helper, not the parseMd2 wrapper
|
|
588
|
+
expect(crumb.detail?.firstIndex).toBe(200);
|
|
589
|
+
expect(crumb.detail?.distinctIndices).toBe(1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('reports a Reject diagnostic and yields an empty scene when every triangle has out-of-range vertex indices', () => {
|
|
593
|
+
// All three vertex indices of the only triangle are past the vertex count, so no valid index survives
|
|
594
|
+
// and the model produces no geometry — the all-invalid branch, distinct from the aggregated per-corner Drop
|
|
595
|
+
// (which keeps the valid triangles alongside it).
|
|
596
|
+
const md2 = buildMd2({
|
|
597
|
+
compressedVertices: [
|
|
598
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
599
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
600
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
601
|
+
],
|
|
602
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
603
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [99, 98, 97] }],
|
|
604
|
+
});
|
|
605
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
606
|
+
const scene = createScene3DFromMd2(md2, diagnostics);
|
|
607
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
608
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.no-valid-triangles');
|
|
609
|
+
expect(crumb.severity).toBe('Reject');
|
|
610
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
611
|
+
expect(crumb.detail).toBeUndefined();
|
|
612
|
+
// The same all-bad triangle also aggregates its 3 corners into one Drop crumb (origin parseMd2).
|
|
613
|
+
const drop = expectOneCrumb(diagnostics, 'md2.triangle-vertex-index-out-of-range');
|
|
614
|
+
expect(drop.severity).toBe('Drop');
|
|
615
|
+
expect(drop.detail?.corners).toBe(3);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('records NO diagnostics for a well-formed model even with a collector engaged', () => {
|
|
619
|
+
// The seam fires only inside a drop branch, so a valid parse reaches none: the engaged collector
|
|
620
|
+
// stays empty. (Without a collector the drop branches allocate nothing at all — the near-free path.)
|
|
621
|
+
const md2 = buildMd2({
|
|
622
|
+
compressedVertices: [
|
|
623
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
624
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
625
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
626
|
+
],
|
|
627
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
628
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
629
|
+
});
|
|
630
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
631
|
+
createScene3DFromMd2(md2, diagnostics);
|
|
632
|
+
expect(diagnostics).toHaveLength(0);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('aggregates out-of-range triangle corners into ONE Drop crumb carrying the count', () => {
|
|
636
|
+
// One valid triangle keeps geometry alive; one all-out-of-range triangle contributes 3 bad vertex
|
|
637
|
+
// corners — aggregated to a single Drop crumb (not three), per the hot-loop perf contract.
|
|
638
|
+
const md2 = buildMd2({
|
|
639
|
+
compressedVertices: [
|
|
640
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
641
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
642
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
643
|
+
],
|
|
644
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
645
|
+
triangles: [
|
|
646
|
+
{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] },
|
|
647
|
+
{ texIndices: [0, 0, 0], vertIndices: [99, 98, 97] },
|
|
648
|
+
],
|
|
649
|
+
});
|
|
650
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
651
|
+
createScene3DFromMd2(md2, diagnostics);
|
|
652
|
+
const drops = diagnostics.filter((d) => d.kind === 'md2.triangle-vertex-index-out-of-range');
|
|
653
|
+
expect(drops).toHaveLength(1);
|
|
654
|
+
expect(drops[0].severity).toBe('Drop');
|
|
655
|
+
expect(drops[0].detail?.corners).toBe(3);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe('createScene3DFromMd2 animations', () => {
|
|
660
|
+
const singleTriangleFrame0 = [
|
|
661
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
662
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
663
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
664
|
+
] as const;
|
|
665
|
+
const singleTriangleTexCoords = [
|
|
666
|
+
{ s: 0, t: 0 },
|
|
667
|
+
{ s: 1, t: 0 },
|
|
668
|
+
{ s: 0, t: 1 },
|
|
669
|
+
] as const;
|
|
670
|
+
const singleTriangle = [{ texIndices: [0, 1, 2] as const, vertIndices: [0, 1, 2] as const }] as const;
|
|
671
|
+
|
|
672
|
+
it('yields empty animations for a single-frame model (no vertex motion)', () => {
|
|
673
|
+
const md2 = buildMd2({
|
|
674
|
+
compressedVertices: singleTriangleFrame0,
|
|
675
|
+
texCoords: singleTriangleTexCoords,
|
|
676
|
+
triangles: singleTriangle,
|
|
677
|
+
});
|
|
678
|
+
const scene = createScene3DFromMd2(md2);
|
|
679
|
+
expect(Object.keys(scene.animations)).toHaveLength(0);
|
|
680
|
+
expect(getNodeChildren(scene.root)).toHaveLength(1);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('builds a mesh morph with one target per non-base frame (frame 1 delta from frame 0)', () => {
|
|
684
|
+
// Frame 1 shifts vertex 1 from x=1 to x=3 (delta +2 in x, which is +2 in Y-up x).
|
|
685
|
+
const md2 = buildMd2({
|
|
686
|
+
compressedVertices: singleTriangleFrame0,
|
|
687
|
+
extraFrames: [
|
|
688
|
+
[
|
|
689
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
690
|
+
{ normalIndex: 0, x: 3, y: 0, z: 0 },
|
|
691
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
692
|
+
],
|
|
693
|
+
],
|
|
694
|
+
numFrames: 2,
|
|
695
|
+
texCoords: singleTriangleTexCoords,
|
|
696
|
+
triangles: singleTriangle,
|
|
697
|
+
});
|
|
698
|
+
const mesh = getNodeChildren(createScene3DFromMd2(md2).root)[0] as Mesh;
|
|
699
|
+
expect(mesh.morph).not.toBeNull();
|
|
700
|
+
expect(mesh.morph!.targets).toHaveLength(1);
|
|
701
|
+
// The deduped vertex for source vertex 1 carries a +2 x position delta.
|
|
702
|
+
const posDeltas = mesh.morph!.targets[0].positionDeltas;
|
|
703
|
+
const hasShiftedVertex = Array.from({ length: posDeltas.length / 3 }, (_, v) => posDeltas[v * 3]).some(
|
|
704
|
+
(dx) => Math.abs(dx - 2) < 1e-5,
|
|
705
|
+
);
|
|
706
|
+
expect(hasShiftedVertex).toBe(true);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("applies each frame's own scale and translate when decompressing vertex positions", () => {
|
|
710
|
+
// Frames 0 and 1 carry IDENTICAL compressed bytes; only frame 1's scale/translate differ. The morph
|
|
711
|
+
// position delta must reflect frame 1's OWN requantization (x-byte 1 → 1*2+5=7 vs base 1*1+0=1 → +6),
|
|
712
|
+
// proving the per-frame scale/translate is applied, not frame 0's reused for every frame (which → 0).
|
|
713
|
+
const md2 = buildMd2({
|
|
714
|
+
compressedVertices: singleTriangleFrame0,
|
|
715
|
+
extraFrames: [singleTriangleFrame0.map((v) => ({ ...v }))],
|
|
716
|
+
frameScales: [
|
|
717
|
+
[1, 1, 1],
|
|
718
|
+
[2, 1, 1],
|
|
719
|
+
],
|
|
720
|
+
frameTranslates: [
|
|
721
|
+
[0, 0, 0],
|
|
722
|
+
[5, 0, 0],
|
|
723
|
+
],
|
|
724
|
+
numFrames: 2,
|
|
725
|
+
texCoords: singleTriangleTexCoords,
|
|
726
|
+
triangles: singleTriangle,
|
|
727
|
+
});
|
|
728
|
+
const target = (getNodeChildren(createScene3DFromMd2(md2).root)[0] as Mesh).morph!.targets[0];
|
|
729
|
+
const dxs = Array.from({ length: target.positionDeltas.length / 3 }, (_, v) => target.positionDeltas[v * 3]);
|
|
730
|
+
expect(dxs.some((dx) => Math.abs(dx - 6) < 1e-5)).toBe(true);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('records the per-frame normal delta on the morph target (frame normal minus base, Y-up)', () => {
|
|
734
|
+
// Vertex 0 uses Anorms[0] in the base frame and Anorms[5] in frame 1; positions are unchanged, so only
|
|
735
|
+
// its normalDelta is non-zero and must equal the Y-up-transformed difference of the two Anorms entries.
|
|
736
|
+
const frame1 = [
|
|
737
|
+
{ normalIndex: 5, x: 0, y: 0, z: 0 },
|
|
738
|
+
{ normalIndex: 0, x: 1, y: 0, z: 0 },
|
|
739
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
740
|
+
];
|
|
741
|
+
const md2 = buildMd2({
|
|
742
|
+
compressedVertices: singleTriangleFrame0,
|
|
743
|
+
extraFrames: [frame1],
|
|
744
|
+
numFrames: 2,
|
|
745
|
+
texCoords: singleTriangleTexCoords,
|
|
746
|
+
triangles: singleTriangle,
|
|
747
|
+
});
|
|
748
|
+
const target = (getNodeChildren(createScene3DFromMd2(md2).root)[0] as Mesh).morph!.targets[0];
|
|
749
|
+
const a0 = MD2_ANORMS[0];
|
|
750
|
+
const a5 = MD2_ANORMS[5];
|
|
751
|
+
// Y-up transform is (x, y, z) → (x, z, -y), so the per-component delta is (a5x-a0x, a5z-a0z, a0y-a5y).
|
|
752
|
+
const expected = [a5[0] - a0[0], a5[2] - a0[2], a0[1] - a5[1]];
|
|
753
|
+
const nd = target.normalDeltas!;
|
|
754
|
+
const changed = Array.from({ length: nd.length / 3 }, (_, v) => v).find(
|
|
755
|
+
(v) => Math.abs(nd[v * 3]) + Math.abs(nd[v * 3 + 1]) + Math.abs(nd[v * 3 + 2]) > 1e-6,
|
|
756
|
+
);
|
|
757
|
+
expect(changed).not.toBeUndefined();
|
|
758
|
+
expect(nd[changed! * 3]).toBeCloseTo(expected[0], 5);
|
|
759
|
+
expect(nd[changed! * 3 + 1]).toBeCloseTo(expected[1], 5);
|
|
760
|
+
expect(nd[changed! * 3 + 2]).toBeCloseTo(expected[2], 5);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('builds a weights clip whose track width is the frame-target count and times step by 1/fps', () => {
|
|
764
|
+
const md2 = buildMd2({
|
|
765
|
+
compressedVertices: singleTriangleFrame0,
|
|
766
|
+
extraFrames: [
|
|
767
|
+
[
|
|
768
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
769
|
+
{ normalIndex: 0, x: 3, y: 0, z: 0 },
|
|
770
|
+
{ normalIndex: 0, x: 0, y: 1, z: 0 },
|
|
771
|
+
],
|
|
772
|
+
],
|
|
773
|
+
numFrames: 2,
|
|
774
|
+
texCoords: singleTriangleTexCoords,
|
|
775
|
+
triangles: singleTriangle,
|
|
776
|
+
});
|
|
777
|
+
const scene = createScene3DFromMd2(md2);
|
|
778
|
+
expect(Object.keys(scene.animations)).toHaveLength(1);
|
|
779
|
+
const clip = Object.values(scene.animations)[0];
|
|
780
|
+
expect(clip.channels).toHaveLength(1);
|
|
781
|
+
const channel = clip.channels[0];
|
|
782
|
+
expect((channel.targetRef as Scene3DAnimationTarget).path).toBe('Weights');
|
|
783
|
+
expect(channel.track.components).toBe(1); // one non-base frame → one target weight
|
|
784
|
+
// Two frames → times [0, 0.1] at 10 fps.
|
|
785
|
+
expect(channel.track.times).toHaveLength(2);
|
|
786
|
+
expect(channel.track.times[0]).toBeCloseTo(0);
|
|
787
|
+
expect(channel.track.times[1]).toBeCloseTo(0.1);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// A five-frame model whose frame names carry two action prefixes exercises the segmentation path.
|
|
791
|
+
const fiveFrameActions = () =>
|
|
792
|
+
buildMd2({
|
|
793
|
+
compressedVertices: singleTriangleFrame0,
|
|
794
|
+
extraFrames: [
|
|
795
|
+
[
|
|
796
|
+
{ normalIndex: 0, x: 0, y: 0, z: 1 },
|
|
797
|
+
{ normalIndex: 0, x: 1, y: 0, z: 1 },
|
|
798
|
+
{ normalIndex: 0, x: 0, y: 1, z: 1 },
|
|
799
|
+
],
|
|
800
|
+
[
|
|
801
|
+
{ normalIndex: 0, x: 0, y: 0, z: 2 },
|
|
802
|
+
{ normalIndex: 0, x: 1, y: 0, z: 2 },
|
|
803
|
+
{ normalIndex: 0, x: 0, y: 1, z: 2 },
|
|
804
|
+
],
|
|
805
|
+
[
|
|
806
|
+
{ normalIndex: 0, x: 0, y: 0, z: 3 },
|
|
807
|
+
{ normalIndex: 0, x: 1, y: 0, z: 3 },
|
|
808
|
+
{ normalIndex: 0, x: 0, y: 1, z: 3 },
|
|
809
|
+
],
|
|
810
|
+
[
|
|
811
|
+
{ normalIndex: 0, x: 0, y: 0, z: 4 },
|
|
812
|
+
{ normalIndex: 0, x: 1, y: 0, z: 4 },
|
|
813
|
+
{ normalIndex: 0, x: 0, y: 1, z: 4 },
|
|
814
|
+
],
|
|
815
|
+
],
|
|
816
|
+
frameNames: ['stand01', 'stand02', 'run01', 'run02', 'run03'],
|
|
817
|
+
numFrames: 5,
|
|
818
|
+
texCoords: singleTriangleTexCoords,
|
|
819
|
+
triangles: singleTriangle,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it('segments contiguous same-prefix frame runs into named clips with per-action frame ranges', () => {
|
|
823
|
+
const document = parseMd2(fiveFrameActions());
|
|
824
|
+
expect(document.animations.map((a) => a.name)).toEqual(['stand', 'run']);
|
|
825
|
+
const stand = document.animations[0];
|
|
826
|
+
const run = document.animations[1];
|
|
827
|
+
// 'stand' spans frames 0-1 (2 keyframes); 'run' spans frames 2-4 (3 keyframes).
|
|
828
|
+
expect(stand.channels[0].track.times).toHaveLength(2);
|
|
829
|
+
expect(run.channels[0].track.times).toHaveLength(3);
|
|
830
|
+
// Every clip's weight track keeps the full morph width (4 targets for a 5-frame model).
|
|
831
|
+
expect(stand.channels[0].track.components).toBe(4);
|
|
832
|
+
expect(run.channels[0].track.components).toBe(4);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('starts every clip at local time zero regardless of its absolute frame offset', () => {
|
|
836
|
+
const document = parseMd2(fiveFrameActions());
|
|
837
|
+
const run = document.animations[1];
|
|
838
|
+
// 'run' begins at absolute frame 2, but its own timeline starts at 0 and steps by 1/fps.
|
|
839
|
+
expect(Array.from(run.channels[0].track.times)).toEqual([0, 0.1, 0.2].map((t) => expect.closeTo(t)));
|
|
840
|
+
expect(run.duration).toBeCloseTo(0.2);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('activates each frame’s own morph target within its clip and leaves the base frame all-zero', () => {
|
|
844
|
+
const document = parseMd2(fiveFrameActions());
|
|
845
|
+
const targetCount = 4;
|
|
846
|
+
const stand = Array.from(document.animations[0].channels[0].track.values);
|
|
847
|
+
// 'stand' keyframe 0 is base frame 0 → all weights zero; keyframe 1 is frame 1 → target 0 active.
|
|
848
|
+
expect(stand.slice(0, targetCount)).toEqual([0, 0, 0, 0]);
|
|
849
|
+
expect(stand.slice(targetCount, 2 * targetCount)).toEqual([1, 0, 0, 0]);
|
|
850
|
+
// 'run' keyframes are frames 2,3,4 → targets 1,2,3 active on the diagonal, full width each.
|
|
851
|
+
const run = Array.from(document.animations[1].channels[0].track.values);
|
|
852
|
+
expect(run.slice(0, targetCount)).toEqual([0, 1, 0, 0]);
|
|
853
|
+
expect(run.slice(targetCount, 2 * targetCount)).toEqual([0, 0, 1, 0]);
|
|
854
|
+
expect(run.slice(2 * targetCount, 3 * targetCount)).toEqual([0, 0, 0, 1]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('keeps off-diagonal weights zero across a single >2-frame clip (identity-diagonal hat function)', () => {
|
|
858
|
+
// A 4-frame unnamed model → one 'default' clip, 3 morph targets. Each keyframe activates only its own
|
|
859
|
+
// target: base row all-zero, then the identity diagonal. Asserts the FULL matrix, so any cross-talk
|
|
860
|
+
// (a non-zero off-diagonal weight) in a >2-frame clip fails — the tighter check the 2-clip case can't make.
|
|
861
|
+
const frame = (z: number) => [
|
|
862
|
+
{ normalIndex: 0, x: 0, y: 0, z },
|
|
863
|
+
{ normalIndex: 0, x: 1, y: 0, z },
|
|
864
|
+
{ normalIndex: 0, x: 0, y: 1, z },
|
|
865
|
+
];
|
|
866
|
+
const md2 = buildMd2({
|
|
867
|
+
compressedVertices: frame(0),
|
|
868
|
+
extraFrames: [frame(1), frame(2), frame(3)],
|
|
869
|
+
numFrames: 4,
|
|
870
|
+
texCoords: singleTriangleTexCoords,
|
|
871
|
+
triangles: singleTriangle,
|
|
872
|
+
});
|
|
873
|
+
const document = parseMd2(md2);
|
|
874
|
+
expect(document.animations).toHaveLength(1);
|
|
875
|
+
const track = document.animations[0].channels[0].track;
|
|
876
|
+
expect(track.components).toBe(3);
|
|
877
|
+
const values = Array.from(track.values);
|
|
878
|
+
expect(values.slice(0, 3)).toEqual([0, 0, 0]);
|
|
879
|
+
expect(values.slice(3, 6)).toEqual([1, 0, 0]);
|
|
880
|
+
expect(values.slice(6, 9)).toEqual([0, 1, 0]);
|
|
881
|
+
expect(values.slice(9, 12)).toEqual([0, 0, 1]);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('collapses unnamed frames into a single default-named clip (MD2 without frame labels)', () => {
|
|
885
|
+
const md2 = buildMd2({
|
|
886
|
+
compressedVertices: singleTriangleFrame0,
|
|
887
|
+
extraFrames: [
|
|
888
|
+
[
|
|
889
|
+
{ normalIndex: 0, x: 0, y: 0, z: 5 },
|
|
890
|
+
{ normalIndex: 0, x: 1, y: 0, z: 5 },
|
|
891
|
+
{ normalIndex: 0, x: 0, y: 1, z: 5 },
|
|
892
|
+
],
|
|
893
|
+
],
|
|
894
|
+
numFrames: 2,
|
|
895
|
+
texCoords: singleTriangleTexCoords,
|
|
896
|
+
triangles: singleTriangle,
|
|
897
|
+
});
|
|
898
|
+
const document = parseMd2(md2);
|
|
899
|
+
expect(document.animations).toHaveLength(1);
|
|
900
|
+
expect(document.animations[0].name).toBe('default');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('disambiguates duplicate action names from two non-adjacent same-prefix runs', () => {
|
|
904
|
+
const md2 = buildMd2({
|
|
905
|
+
compressedVertices: singleTriangleFrame0,
|
|
906
|
+
extraFrames: [
|
|
907
|
+
[
|
|
908
|
+
{ normalIndex: 0, x: 0, y: 0, z: 1 },
|
|
909
|
+
{ normalIndex: 0, x: 1, y: 0, z: 1 },
|
|
910
|
+
{ normalIndex: 0, x: 0, y: 1, z: 1 },
|
|
911
|
+
],
|
|
912
|
+
[
|
|
913
|
+
{ normalIndex: 0, x: 0, y: 0, z: 2 },
|
|
914
|
+
{ normalIndex: 0, x: 1, y: 0, z: 2 },
|
|
915
|
+
{ normalIndex: 0, x: 0, y: 1, z: 2 },
|
|
916
|
+
],
|
|
917
|
+
],
|
|
918
|
+
// walk → jump → walk again: the two walk runs are not contiguous, so both clips are named 'walk'.
|
|
919
|
+
frameNames: ['walk01', 'jump01', 'walk01'],
|
|
920
|
+
numFrames: 3,
|
|
921
|
+
texCoords: singleTriangleTexCoords,
|
|
922
|
+
triangles: singleTriangle,
|
|
923
|
+
});
|
|
924
|
+
const document = parseMd2(md2);
|
|
925
|
+
expect(document.animations.map((a) => a.name)).toEqual(['walk', 'jump', 'walk.2']);
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
describe('MD2_ANORMS', () => {
|
|
930
|
+
it('is the full 162-entry Quake 2 Anorms table', () => {
|
|
931
|
+
expect(MD2_ANORMS).toHaveLength(162);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('stores only unit-length normals', () => {
|
|
935
|
+
for (const [x, y, z] of MD2_ANORMS) {
|
|
936
|
+
expect(Math.hypot(x, y, z)).toBeCloseTo(1, 4);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('has no duplicate directions — each Anorms index is a distinct normal', () => {
|
|
941
|
+
const distinct = new Set(MD2_ANORMS.map(([x, y, z]) => `${x},${y},${z}`));
|
|
942
|
+
expect(distinct.size).toBe(162);
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
describe('parseMd2', () => {
|
|
947
|
+
it('decomposes a model into a single mesh node document with inline geometry', () => {
|
|
948
|
+
const md2 = buildMd2({
|
|
949
|
+
compressedVertices: [
|
|
950
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
951
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
952
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
953
|
+
],
|
|
954
|
+
scale: [1, 1, 1],
|
|
955
|
+
skin: 'players/hero/skin.pcx',
|
|
956
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
957
|
+
translate: [0, 0, 0],
|
|
958
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
const document = parseMd2(md2);
|
|
962
|
+
expect(document.meshes).toHaveLength(1);
|
|
963
|
+
expect(getMeshGeometryVertexCount(document.meshes[0].geometry)).toBe(3);
|
|
964
|
+
expect(document.nodes).toHaveLength(1);
|
|
965
|
+
expect(document.nodes[0].mesh).toBe(0);
|
|
966
|
+
expect(document.scenes[0].rootNodes).toEqual([0]);
|
|
967
|
+
expect(document.resources).toHaveLength(1);
|
|
968
|
+
expect(document.resources[0]).toBe((document.materials[0] as BlinnPhongMaterial).diffuseMap!.resource);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it('emits every skin as a material and binds the first to the mesh', () => {
|
|
972
|
+
const md2 = buildMd2({
|
|
973
|
+
compressedVertices: [
|
|
974
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
975
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
976
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
977
|
+
],
|
|
978
|
+
skins: ['skins/red.pcx', 'skins/blue.pcx', 'skins/green.pcx'],
|
|
979
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
980
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const document = parseMd2(md2);
|
|
984
|
+
// All three alternate skins register as materials; the mesh's single subset binds the first.
|
|
985
|
+
expect(document.materials).toHaveLength(3);
|
|
986
|
+
expect((document.materials[0] as BlinnPhongMaterial).name).toBe('skins/red.pcx');
|
|
987
|
+
expect((document.materials[1] as BlinnPhongMaterial).name).toBe('skins/blue.pcx');
|
|
988
|
+
expect((document.materials[2] as BlinnPhongMaterial).name).toBe('skins/green.pcx');
|
|
989
|
+
expect(document.meshes[0].materials).toEqual([0]);
|
|
990
|
+
// One unresolved texture resource per skin.
|
|
991
|
+
expect(document.resources).toHaveLength(3);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('carries per-frame vertex animation as a weights channel bound to the mesh node index', () => {
|
|
995
|
+
const md2 = buildMd2({
|
|
996
|
+
compressedVertices: [
|
|
997
|
+
{ normalIndex: 0, x: 0, y: 0, z: 0 },
|
|
998
|
+
{ normalIndex: 0, x: 10, y: 0, z: 0 },
|
|
999
|
+
{ normalIndex: 0, x: 0, y: 10, z: 0 },
|
|
1000
|
+
],
|
|
1001
|
+
extraFrames: [
|
|
1002
|
+
[
|
|
1003
|
+
{ normalIndex: 0, x: 0, y: 0, z: 5 },
|
|
1004
|
+
{ normalIndex: 0, x: 10, y: 0, z: 5 },
|
|
1005
|
+
{ normalIndex: 0, x: 0, y: 10, z: 5 },
|
|
1006
|
+
],
|
|
1007
|
+
],
|
|
1008
|
+
numFrames: 2,
|
|
1009
|
+
scale: [1, 1, 1],
|
|
1010
|
+
texCoords: [{ s: 0, t: 0 }],
|
|
1011
|
+
translate: [0, 0, 0],
|
|
1012
|
+
triangles: [{ texIndices: [0, 0, 0], vertIndices: [0, 1, 2] }],
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const document = parseMd2(md2);
|
|
1016
|
+
expect(document.meshes[0].morph).not.toBeNull();
|
|
1017
|
+
expect(document.animations).toHaveLength(1);
|
|
1018
|
+
expect(document.animations[0].channels[0].node).toBe(0);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it('returns an empty document (every table present) for malformed input', () => {
|
|
1022
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1023
|
+
const document = parseMd2(new Uint8Array(10), diagnostics);
|
|
1024
|
+
expect(document.nodes).toEqual([]);
|
|
1025
|
+
expect(document.meshes).toEqual([]);
|
|
1026
|
+
expect(document.scenes).toEqual([{ rootNodes: [] }]);
|
|
1027
|
+
// parseMd2 (not the createScene3DFromMd2 wrapper) is the true origin even on the document-returning path.
|
|
1028
|
+
const crumb = expectOneCrumb(diagnostics, 'md2.header-too-short');
|
|
1029
|
+
expect(crumb.severity).toBe('Reject');
|
|
1030
|
+
expect(crumb.origin).toBe('parseMd2');
|
|
1031
|
+
expect(crumb.detail?.byteLength).toBe(10);
|
|
1032
|
+
});
|
|
1033
|
+
});
|