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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/awd2Inflate.d.ts +4 -0
  2. package/dist/awd2Inflate.d.ts.map +1 -0
  3. package/dist/awd2Inflate.js +249 -0
  4. package/dist/awd2Inflate.js.map +1 -0
  5. package/dist/awd2Parse.d.ts +7 -0
  6. package/dist/awd2Parse.d.ts.map +1 -0
  7. package/dist/awd2Parse.js +1533 -0
  8. package/dist/awd2Parse.js.map +1 -0
  9. package/dist/awd2Schema.d.ts +44 -0
  10. package/dist/awd2Schema.d.ts.map +1 -0
  11. package/dist/awd2Schema.js +67 -0
  12. package/dist/awd2Schema.js.map +1 -0
  13. package/dist/gltfParse.d.ts +9 -0
  14. package/dist/gltfParse.d.ts.map +1 -0
  15. package/dist/gltfParse.js +1456 -0
  16. package/dist/gltfParse.js.map +1 -0
  17. package/dist/gltfPunctualLights.d.ts +3 -0
  18. package/dist/gltfPunctualLights.d.ts.map +1 -0
  19. package/dist/gltfPunctualLights.js +103 -0
  20. package/dist/gltfPunctualLights.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/md2Parse.d.ts +5 -0
  26. package/dist/md2Parse.d.ts.map +1 -0
  27. package/dist/md2Parse.js +408 -0
  28. package/dist/md2Parse.js.map +1 -0
  29. package/dist/md2Schema.d.ts +12 -0
  30. package/dist/md2Schema.d.ts.map +1 -0
  31. package/dist/md2Schema.js +193 -0
  32. package/dist/md2Schema.js.map +1 -0
  33. package/dist/md5AnimParse.d.ts +3 -0
  34. package/dist/md5AnimParse.d.ts.map +1 -0
  35. package/dist/md5AnimParse.js +385 -0
  36. package/dist/md5AnimParse.js.map +1 -0
  37. package/dist/md5Parse.d.ts +6 -0
  38. package/dist/md5Parse.d.ts.map +1 -0
  39. package/dist/md5Parse.js +635 -0
  40. package/dist/md5Parse.js.map +1 -0
  41. package/dist/mtlParse.d.ts +3 -0
  42. package/dist/mtlParse.d.ts.map +1 -0
  43. package/dist/mtlParse.js +256 -0
  44. package/dist/mtlParse.js.map +1 -0
  45. package/dist/objParse.d.ts +5 -0
  46. package/dist/objParse.d.ts.map +1 -0
  47. package/dist/objParse.js +464 -0
  48. package/dist/objParse.js.map +1 -0
  49. package/dist/sceneSkeleton.d.ts +3 -0
  50. package/dist/sceneSkeleton.d.ts.map +1 -0
  51. package/dist/sceneSkeleton.js +34 -0
  52. package/dist/sceneSkeleton.js.map +1 -0
  53. package/dist/shared.d.ts +18 -0
  54. package/dist/shared.d.ts.map +1 -0
  55. package/dist/shared.js +143 -0
  56. package/dist/shared.js.map +1 -0
  57. package/dist/threeDsParse.d.ts +4 -0
  58. package/dist/threeDsParse.d.ts.map +1 -0
  59. package/dist/threeDsParse.js +718 -0
  60. package/dist/threeDsParse.js.map +1 -0
  61. package/package.json +55 -0
  62. package/src/awd2Inflate.test.ts +118 -0
  63. package/src/awd2Parse.test.ts +2431 -0
  64. package/src/gltfParse.test.ts +2339 -0
  65. package/src/gltfPunctualLights.test.ts +184 -0
  66. package/src/gltfTreeShaking.test.ts +45 -0
  67. package/src/md2Parse.test.ts +1033 -0
  68. package/src/md5AnimParse.test.ts +673 -0
  69. package/src/md5Parse.test.ts +1094 -0
  70. package/src/mtlParse.test.ts +244 -0
  71. package/src/objParse.test.ts +690 -0
  72. package/src/sceneSkeleton.test.ts +35 -0
  73. package/src/shared.test.ts +210 -0
  74. package/src/threeDsParse.test.ts +1089 -0
@@ -0,0 +1,1094 @@
1
+ import { getMatrix4Position } from '@flighthq/geometry';
2
+ import {
3
+ getMeshGeometryIndexCount,
4
+ getMeshGeometryVertexCount,
5
+ getMeshGeometryVertexPosition,
6
+ getMeshGeometryVertexUv0,
7
+ } from '@flighthq/mesh';
8
+ import { getNodeChildren, getNodeLocalMatrix4 } 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, ImportDiagnosticSeverity } from '@flighthq/types';
19
+
20
+ import { parseMd5Anim } from './md5AnimParse';
21
+ import { createScene3DFromMd5Mesh, importMd5Mesh, parseMd5Mesh } from './md5Parse';
22
+ import { findScene3DSkeletonJoints } from './sceneSkeleton';
23
+
24
+ function findDiagnostic(diagnostics: readonly ImportDiagnostic[], kind: string): ImportDiagnostic | undefined {
25
+ return diagnostics.find((diagnostic) => diagnostic.kind === kind);
26
+ }
27
+
28
+ // A one-joint .md5anim matching SINGLE_TRIANGLE's single "root" joint, translating it per frame.
29
+ const SINGLE_JOINT_ANIM = [
30
+ 'MD5Version 10',
31
+ 'commandline ""',
32
+ 'numFrames 1',
33
+ 'numJoints 1',
34
+ 'frameRate 24',
35
+ 'numAnimatedComponents 0',
36
+ 'hierarchy {',
37
+ ' "root" -1 0 0',
38
+ '}',
39
+ 'bounds {',
40
+ ' ( -1 -1 -1 ) ( 1 1 1 )',
41
+ '}',
42
+ 'baseframe {',
43
+ ' ( 5 10 15 ) ( 0 0 0 )',
44
+ '}',
45
+ 'frame 0 {',
46
+ '}',
47
+ ].join('\n');
48
+
49
+ // Minimal valid MD5 mesh with a single joint at the origin (identity orientation) and a single
50
+ // triangle whose three vertices each reference one weight with bias 1.0 at known positions.
51
+ const SINGLE_TRIANGLE = [
52
+ 'MD5Version 10',
53
+ 'commandline ""',
54
+ '',
55
+ 'numJoints 1',
56
+ 'numMeshes 1',
57
+ '',
58
+ 'joints {',
59
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
60
+ '}',
61
+ '',
62
+ 'mesh {',
63
+ ' shader "textures/default"',
64
+ '',
65
+ ' numverts 3',
66
+ ' vert 0 ( 0.0 0.0 ) 0 1',
67
+ ' vert 1 ( 1.0 0.0 ) 1 1',
68
+ ' vert 2 ( 0.0 1.0 ) 2 1',
69
+ '',
70
+ ' numtris 1',
71
+ ' tri 0 0 1 2',
72
+ '',
73
+ ' numweights 3',
74
+ ' weight 0 0 1.0 ( 0 0 0 )',
75
+ ' weight 1 0 1.0 ( 1 0 0 )',
76
+ ' weight 2 0 1.0 ( 0 1 0 )',
77
+ '}',
78
+ ].join('\n');
79
+
80
+ // MD5 mesh with two joints forming a parent-child hierarchy.
81
+ const MULTI_JOINT_HIERARCHY = [
82
+ 'MD5Version 10',
83
+ 'commandline ""',
84
+ '',
85
+ 'numJoints 3',
86
+ 'numMeshes 1',
87
+ '',
88
+ 'joints {',
89
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
90
+ ' "child_a" 0 ( 1 0 0 ) ( 0 0 0 )',
91
+ ' "child_b" 0 ( 0 1 0 ) ( 0 0 0 )',
92
+ '}',
93
+ '',
94
+ 'mesh {',
95
+ ' shader "textures/body"',
96
+ '',
97
+ ' numverts 3',
98
+ ' vert 0 ( 0.0 0.0 ) 0 1',
99
+ ' vert 1 ( 1.0 0.0 ) 1 1',
100
+ ' vert 2 ( 0.5 0.5 ) 2 1',
101
+ '',
102
+ ' numtris 1',
103
+ ' tri 0 0 1 2',
104
+ '',
105
+ ' numweights 3',
106
+ ' weight 0 0 1.0 ( 0 0 0 )',
107
+ ' weight 1 1 1.0 ( 0 0 0 )',
108
+ ' weight 2 2 1.0 ( 0 0 0 )',
109
+ '}',
110
+ ].join('\n');
111
+
112
+ // MD5 mesh where a vertex is influenced by two joints with different weights.
113
+ const WEIGHTED_VERTICES = [
114
+ 'MD5Version 10',
115
+ 'commandline ""',
116
+ '',
117
+ 'numJoints 2',
118
+ 'numMeshes 1',
119
+ '',
120
+ 'joints {',
121
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
122
+ ' "arm" 0 ( 10 0 0 ) ( 0 0 0 )',
123
+ '}',
124
+ '',
125
+ 'mesh {',
126
+ ' shader "textures/arm"',
127
+ '',
128
+ ' numverts 3',
129
+ ' vert 0 ( 0.0 0.0 ) 0 2',
130
+ ' vert 1 ( 1.0 0.0 ) 2 1',
131
+ ' vert 2 ( 0.5 1.0 ) 3 1',
132
+ '',
133
+ ' numtris 1',
134
+ ' tri 0 0 1 2',
135
+ '',
136
+ ' numweights 4',
137
+ ' weight 0 0 0.5 ( 0 0 0 )',
138
+ ' weight 1 1 0.5 ( 0 0 0 )',
139
+ ' weight 2 0 1.0 ( 1 0 0 )',
140
+ ' weight 3 1 1.0 ( 0 1 0 )',
141
+ '}',
142
+ ].join('\n');
143
+
144
+ // MD5 mesh whose vertex 0 is influenced by five joints — one more than linear-blend skinning's four
145
+ // slots. The five biases (0.4/0.3/0.2/0.1/0.05) select the first four; the fifth, at a far-away joint,
146
+ // is dropped. The kept four already sum to 1.0, so their bind position bakes to a clean (3,4,4) Z-up
147
+ // while a naive all-influence bake would be dragged toward the far joint (x≈53).
148
+ const OVER_INFLUENCED_VERTEX = [
149
+ 'MD5Version 10',
150
+ 'commandline ""',
151
+ '',
152
+ 'numJoints 5',
153
+ 'numMeshes 1',
154
+ '',
155
+ 'joints {',
156
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
157
+ ' "j1" 0 ( 10 0 0 ) ( 0 0 0 )',
158
+ ' "j2" 0 ( 0 20 0 ) ( 0 0 0 )',
159
+ ' "j3" 0 ( 0 0 40 ) ( 0 0 0 )',
160
+ ' "far" 0 ( 1000 0 0 ) ( 0 0 0 )',
161
+ '}',
162
+ '',
163
+ 'mesh {',
164
+ ' shader "textures/over"',
165
+ '',
166
+ ' numverts 3',
167
+ ' vert 0 ( 0.0 0.0 ) 0 5',
168
+ ' vert 1 ( 1.0 0.0 ) 5 1',
169
+ ' vert 2 ( 0.0 1.0 ) 6 1',
170
+ '',
171
+ ' numtris 1',
172
+ ' tri 0 0 1 2',
173
+ '',
174
+ ' numweights 7',
175
+ ' weight 0 0 0.4 ( 0 0 0 )',
176
+ ' weight 1 1 0.3 ( 0 0 0 )',
177
+ ' weight 2 2 0.2 ( 0 0 0 )',
178
+ ' weight 3 3 0.1 ( 0 0 0 )',
179
+ ' weight 4 4 0.05 ( 0 0 0 )',
180
+ ' weight 5 0 1.0 ( 5 0 0 )',
181
+ ' weight 6 0 1.0 ( 0 5 0 )',
182
+ '}',
183
+ ].join('\n');
184
+
185
+ describe('createScene3DFromMd5Mesh', () => {
186
+ it('parses a single triangle with one joint', () => {
187
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
188
+ const children = getNodeChildren(scene.root);
189
+ // Skeleton group + one mesh.
190
+ expect(children).toHaveLength(2);
191
+
192
+ // The skeleton is the first child.
193
+ const skeleton = children[0] as Node3D;
194
+ expect(isMesh(skeleton)).toBe(false);
195
+
196
+ // The mesh is the second child.
197
+ const meshNode = children[1] as Node3D;
198
+ expect(isMesh(meshNode)).toBe(true);
199
+
200
+ const geometry = (meshNode as unknown as Mesh).geometry;
201
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
202
+ expect(getMeshGeometryIndexCount(geometry)).toBe(3);
203
+
204
+ const p = { x: 0, y: 0, z: 0 };
205
+ getMeshGeometryVertexPosition(p, geometry, 0);
206
+ expect(p.x).toBeCloseTo(0);
207
+ expect(p.y).toBeCloseTo(0);
208
+ expect(p.z).toBeCloseTo(0);
209
+
210
+ getMeshGeometryVertexPosition(p, geometry, 1);
211
+ expect(p.x).toBeCloseTo(1);
212
+ expect(p.y).toBeCloseTo(0);
213
+ expect(p.z).toBeCloseTo(0);
214
+
215
+ getMeshGeometryVertexPosition(p, geometry, 2);
216
+ expect(p.x).toBeCloseTo(0);
217
+ expect(p.y).toBeCloseTo(0);
218
+ expect(p.z).toBeCloseTo(-1);
219
+ });
220
+
221
+ it('emits a skin binding the mesh to a skeleton over the parsed joints', () => {
222
+ const scene = createScene3DFromMd5Mesh(MULTI_JOINT_HIERARCHY);
223
+ const meshNode = getNodeChildren(scene.root)[1] as unknown as Mesh;
224
+
225
+ expect(meshNode.skin).toBeTruthy();
226
+ // The skin's skeleton exposes the joint nodes parseMd5Anim needs, one per parsed joint.
227
+ expect(meshNode.skin?.skeleton.joints).toHaveLength(3);
228
+ expect(meshNode.skin?.skeleton.names).toEqual(['root', 'child_a', 'child_b']);
229
+ // The document assembler does not rethread the skeleton group as the skin's skeletonRoot (it stays
230
+ // null, matching every importer that routes through createScene3DFromDocument); the "skeleton" group is
231
+ // still a scene-root child (children[0]).
232
+ expect(meshNode.skin?.skeletonRoot).toBeNull();
233
+ expect(getNodeChildren(scene.root)[0].name).toBe('skeleton');
234
+ });
235
+
236
+ it('emits joints0/weights0 into an 80-byte skinned layout with weights renormalized to 1', () => {
237
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
238
+ const geometry = (getNodeChildren(scene.root)[1] as unknown as Mesh).geometry;
239
+
240
+ expect(geometry.layout.stride).toBe(80);
241
+ const floatsPerVertex = geometry.layout.stride / 4;
242
+ // weights0 is the last float32x4 in the record; vertex 0's first weight is the full influence.
243
+ expect(geometry.vertices[16]).toBeCloseTo(1);
244
+ // Every vertex's four weights sum to 1 (renormalized), and joint index 0 is referenced.
245
+ for (let v = 0; v < 3; v++) {
246
+ const base = v * floatsPerVertex;
247
+ const weightSum =
248
+ geometry.vertices[base + 16] +
249
+ geometry.vertices[base + 17] +
250
+ geometry.vertices[base + 18] +
251
+ geometry.vertices[base + 19];
252
+ expect(weightSum).toBeCloseTo(1);
253
+ }
254
+ });
255
+
256
+ it('regenerates vertex normals the MD5 mesh does not carry', () => {
257
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
258
+ const geometry = (getNodeChildren(scene.root)[1] as unknown as Mesh).geometry;
259
+ const floatsPerVertex = geometry.layout.stride / 4;
260
+ // Normal is at float offset 3; a flat triangle yields a unit face normal, not the zero-fill.
261
+ const nx = geometry.vertices[3];
262
+ const ny = geometry.vertices[4];
263
+ const nz = geometry.vertices[5];
264
+ expect(Math.hypot(nx, ny, nz)).toBeCloseTo(1);
265
+ // With triangle winding reversed to Flight's CCW-front convention, the derived normal points
266
+ // −Y for this triangle (v0=(0,0,0), v1=(1,0,0), v2=(0,0,−1) in Y-up): (v2−v0)×(v1−v0) = (0,−1,0).
267
+ // Without the reversal it would point +Y (inward for a real model — the winding bug).
268
+ expect(nx).toBeCloseTo(0);
269
+ expect(ny).toBeCloseTo(-1);
270
+ expect(nz).toBeCloseTo(0);
271
+ });
272
+
273
+ it('reverses MD5 triangle winding to Flight CCW-front (front faces stay front under culling)', () => {
274
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
275
+ const geometry = (getNodeChildren(scene.root)[1] as unknown as Mesh).geometry;
276
+ // MD5 declares "tri 0 0 1 2"; id Tech 4 winds clockwise, and the Z-up→Y-up conversion is a
277
+ // determinant-+1 rotation that preserves winding, so the parser reverses each triangle (swaps
278
+ // v1/v2) to land CCW-front. The index buffer therefore reads 0, 2, 1 rather than 0, 1, 2.
279
+ expect(Array.from(geometry.indices!)).toEqual([0, 2, 1]);
280
+ });
281
+
282
+ it('preserves UV coordinates', () => {
283
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
284
+ const children = getNodeChildren(scene.root);
285
+ const meshNode = children[1] as unknown as Mesh;
286
+ const geometry = meshNode.geometry;
287
+
288
+ const uv = { x: 0, y: 0 };
289
+ getMeshGeometryVertexUv0(uv, geometry, 0);
290
+ expect(uv.x).toBeCloseTo(0);
291
+ expect(uv.y).toBeCloseTo(0);
292
+
293
+ getMeshGeometryVertexUv0(uv, geometry, 1);
294
+ expect(uv.x).toBeCloseTo(1);
295
+ expect(uv.y).toBeCloseTo(0);
296
+
297
+ getMeshGeometryVertexUv0(uv, geometry, 2);
298
+ expect(uv.x).toBeCloseTo(0);
299
+ expect(uv.y).toBeCloseTo(1);
300
+ });
301
+
302
+ it('converts an absolute .md5mesh joint to a parent-relative local transform', () => {
303
+ // Two joints where the child's PARENT sits away from the origin. .md5mesh joints are ABSOLUTE, but
304
+ // a nested joint's LOCAL transform must be parent-relative so parent × child rebuilds the absolute.
305
+ // Absolute child (10,5,0) → Y-up (10,0,-5); parent (10,0,0) → Y-up (10,0,0); so the child's local
306
+ // position is the difference (0,0,-5). The bug this guards against set the absolute (10,0,-5)
307
+ // directly as the local, which double-accumulates through the parent and explodes under animation.
308
+ const chain = [
309
+ 'MD5Version 10',
310
+ 'numJoints 2',
311
+ 'numMeshes 1',
312
+ 'joints {',
313
+ ' "root" -1 ( 10 0 0 ) ( 0 0 0 )',
314
+ ' "child" 0 ( 10 5 0 ) ( 0 0 0 )',
315
+ '}',
316
+ 'mesh {',
317
+ ' shader "t"',
318
+ ' numverts 1',
319
+ ' vert 0 ( 0 0 ) 0 1',
320
+ ' numtris 0',
321
+ ' numweights 1',
322
+ ' weight 0 1 1.0 ( 0 0 0 )',
323
+ '}',
324
+ ].join('\n');
325
+
326
+ const scene = createScene3DFromMd5Mesh(chain);
327
+ // Nested: skeleton → root → child.
328
+ const root = getNodeChildren(getNodeChildren(scene.root)[0] as Node3D)[0] as Node3D;
329
+ const child = getNodeChildren(root)[0] as Node3D;
330
+
331
+ const rootLocal = { x: 0, y: 0, z: 0 };
332
+ const childLocal = { x: 0, y: 0, z: 0 };
333
+ getMatrix4Position(rootLocal, getNodeLocalMatrix4(root));
334
+ getMatrix4Position(childLocal, getNodeLocalMatrix4(child));
335
+ // Root keeps its absolute transform; child is parent-relative.
336
+ expect([rootLocal.x, rootLocal.y, rootLocal.z]).toEqual([10, 0, 0]);
337
+ expect(childLocal.x).toBeCloseTo(0);
338
+ expect(childLocal.y).toBeCloseTo(0);
339
+ expect(childLocal.z).toBeCloseTo(-5);
340
+ });
341
+
342
+ it('nests joints by parent index — child joints under their parent', () => {
343
+ // The skeleton is a real nested hierarchy: parent × child composition rebuilds each joint's
344
+ // absolute world transform from the parent-relative locals the bind conversion produced. The
345
+ // .md5anim frames (parent-relative) then drive these same nested joints. MULTI_JOINT_HIERARCHY has
346
+ // root (parent -1) with child_a and child_b both parented to root.
347
+ const scene = createScene3DFromMd5Mesh(MULTI_JOINT_HIERARCHY);
348
+ const skeleton = getNodeChildren(scene.root)[0] as Node3D;
349
+
350
+ const rootJoints = getNodeChildren(skeleton);
351
+ expect(rootJoints).toHaveLength(1);
352
+ expect(getNodeChildren(rootJoints[0] as Node3D)).toHaveLength(2);
353
+ });
354
+
355
+ it('computes vertex positions from weights referencing different joints', () => {
356
+ const scene = createScene3DFromMd5Mesh(MULTI_JOINT_HIERARCHY);
357
+ // Find the mesh node (second child after skeleton).
358
+ const meshNode = getNodeChildren(scene.root)[1] as unknown as Mesh;
359
+ const geometry = meshNode.geometry;
360
+
361
+ const p = { x: 0, y: 0, z: 0 };
362
+
363
+ // Vert 0: weight 0 references joint 0 (root at origin), bias=1, offset (0,0,0) => (0,0,0)
364
+ getMeshGeometryVertexPosition(p, geometry, 0);
365
+ expect(p.x).toBeCloseTo(0);
366
+ expect(p.y).toBeCloseTo(0);
367
+ expect(p.z).toBeCloseTo(0);
368
+
369
+ // Vert 1: weight 1 references joint 1 (child_a at (1,0,0)), bias=1, offset (0,0,0) => (1,0,0)
370
+ getMeshGeometryVertexPosition(p, geometry, 1);
371
+ expect(p.x).toBeCloseTo(1);
372
+ expect(p.y).toBeCloseTo(0);
373
+ expect(p.z).toBeCloseTo(0);
374
+
375
+ // Vert 2: weight 2 references joint 2 (child_b at (0,1,0) in MD5), bias=1, offset (0,0,0)
376
+ // MD5 result (0,1,0) → Flight (0,0,-1)
377
+ getMeshGeometryVertexPosition(p, geometry, 2);
378
+ expect(p.x).toBeCloseTo(0);
379
+ expect(p.y).toBeCloseTo(0);
380
+ expect(p.z).toBeCloseTo(-1);
381
+ });
382
+
383
+ it('blends vertex positions from multiple weighted joints', () => {
384
+ const scene = createScene3DFromMd5Mesh(WEIGHTED_VERTICES);
385
+ const meshNode = getNodeChildren(scene.root)[1] as unknown as Mesh;
386
+ const geometry = meshNode.geometry;
387
+
388
+ const p = { x: 0, y: 0, z: 0 };
389
+
390
+ // Vert 0: weight 0 (joint 0 at origin, bias 0.5, offset (0,0,0)) = 0.5*(0,0,0)
391
+ // + weight 1 (joint 1 at (10,0,0), bias 0.5, offset (0,0,0)) = 0.5*(10,0,0)
392
+ // = (5, 0, 0)
393
+ getMeshGeometryVertexPosition(p, geometry, 0);
394
+ expect(p.x).toBeCloseTo(5);
395
+ expect(p.y).toBeCloseTo(0);
396
+ expect(p.z).toBeCloseTo(0);
397
+
398
+ // Vert 1: weight 2 (joint 0 at origin, bias 1, offset (1,0,0)) = 1*(0+1, 0, 0) = (1, 0, 0)
399
+ getMeshGeometryVertexPosition(p, geometry, 1);
400
+ expect(p.x).toBeCloseTo(1);
401
+ expect(p.y).toBeCloseTo(0);
402
+ expect(p.z).toBeCloseTo(0);
403
+
404
+ // Vert 2: weight 3 (joint 1 at (10,0,0), bias 1, offset (0,1,0)) MD5 result (10,1,0) → Flight (10,0,-1)
405
+ getMeshGeometryVertexPosition(p, geometry, 2);
406
+ expect(p.x).toBeCloseTo(10);
407
+ expect(p.y).toBeCloseTo(0);
408
+ expect(p.z).toBeCloseTo(-1);
409
+ });
410
+
411
+ it('reduces a >4-influence vertex to its 4 highest-weight influences and reports md5mesh.vertex-over-influenced', () => {
412
+ const diagnostics: ImportDiagnostic[] = [];
413
+ const scene = createScene3DFromMd5Mesh(OVER_INFLUENCED_VERTEX, diagnostics);
414
+ const geometry = (getNodeChildren(scene.root)[1] as unknown as Mesh).geometry;
415
+ const floatsPerVertex = geometry.layout.stride / 4;
416
+
417
+ // joints0/weights0 keep the four highest-bias joints (0,1,2,3), renormalized (they already sum to 1);
418
+ // the fifth (joint 4, bias 0.05) is dropped.
419
+ expect(Array.from(geometry.vertices.slice(12, 16))).toEqual([0, 1, 2, 3]);
420
+ expect(geometry.vertices[16]).toBeCloseTo(0.4);
421
+ expect(geometry.vertices[17]).toBeCloseTo(0.3);
422
+ expect(geometry.vertices[18]).toBeCloseTo(0.2);
423
+ expect(geometry.vertices[19]).toBeCloseTo(0.1);
424
+ const weightSum = geometry.vertices[16] + geometry.vertices[17] + geometry.vertices[18] + geometry.vertices[19];
425
+ expect(weightSum).toBeCloseTo(1);
426
+
427
+ // The bind position is baked from that SAME reduced top-4 set: Z-up (3,4,4) → Flight Y-up (3,4,-4).
428
+ // A naive all-influence bake would drag x toward the far joint (≈53), so this pins the fix.
429
+ const p = { x: 0, y: 0, z: 0 };
430
+ getMeshGeometryVertexPosition(p, geometry, 0);
431
+ expect(p.x).toBeCloseTo(3);
432
+ expect(p.y).toBeCloseTo(4);
433
+ expect(p.z).toBeCloseTo(-4);
434
+ expect(p.x).toBeLessThan(10); // far joint (x=1000) was truly dropped, not blended in
435
+
436
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.vertex-over-influenced');
437
+ expect(crumb).toBeDefined();
438
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
439
+ expect(crumb!.origin).toBe('parseMd5Mesh');
440
+ expect(crumb!.detail?.count).toBe(1);
441
+ expect(crumb!.detail?.maxInfluences).toBe(5);
442
+ // Vertices 1 and 2 have a single influence — the layout stays 80-byte skinned regardless.
443
+ expect(floatsPerVertex).toBe(20);
444
+ });
445
+
446
+ it('handles multiple mesh sections', () => {
447
+ const source = [
448
+ 'MD5Version 10',
449
+ 'commandline ""',
450
+ 'numJoints 1',
451
+ 'numMeshes 2',
452
+ 'joints {',
453
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
454
+ '}',
455
+ 'mesh {',
456
+ ' shader "body"',
457
+ ' numverts 3',
458
+ ' vert 0 ( 0 0 ) 0 1',
459
+ ' vert 1 ( 1 0 ) 1 1',
460
+ ' vert 2 ( 0 1 ) 2 1',
461
+ ' numtris 1',
462
+ ' tri 0 0 1 2',
463
+ ' numweights 3',
464
+ ' weight 0 0 1.0 ( 0 0 0 )',
465
+ ' weight 1 0 1.0 ( 1 0 0 )',
466
+ ' weight 2 0 1.0 ( 0 1 0 )',
467
+ '}',
468
+ 'mesh {',
469
+ ' shader "head"',
470
+ ' numverts 3',
471
+ ' vert 0 ( 0 0 ) 0 1',
472
+ ' vert 1 ( 1 0 ) 1 1',
473
+ ' vert 2 ( 0 1 ) 2 1',
474
+ ' numtris 1',
475
+ ' tri 0 0 1 2',
476
+ ' numweights 3',
477
+ ' weight 0 0 1.0 ( 2 0 0 )',
478
+ ' weight 1 0 1.0 ( 3 0 0 )',
479
+ ' weight 2 0 1.0 ( 2 1 0 )',
480
+ '}',
481
+ ].join('\n');
482
+
483
+ const scene = createScene3DFromMd5Mesh(source);
484
+ const children = getNodeChildren(scene.root);
485
+ // Skeleton + 2 meshes.
486
+ expect(children).toHaveLength(3);
487
+ expect(isMesh(children[1] as Node3D)).toBe(true);
488
+ expect(isMesh(children[2] as Node3D)).toBe(true);
489
+ });
490
+
491
+ it("decodes each section's shader to a BlinnPhongMaterial referencing the shader path as a diffuseMap", () => {
492
+ const mesh = getNodeChildren(createScene3DFromMd5Mesh(SINGLE_TRIANGLE).root)[1] as Mesh;
493
+ expect(mesh.materials).toHaveLength(1);
494
+ const material = mesh.materials[0] as BlinnPhongMaterial;
495
+ expect(material.kind).toBe(BlinnPhongMaterialKind);
496
+ expect(material.name).toBe('textures/default'); // MD5 shader path preserved as the authored identity
497
+ // The shader path is referenced, not decoded: an Unresolved External ref, image left null.
498
+ expect((material.diffuseMap!.resource as ExternalImageResourceReference).uri).toBe('textures/default');
499
+ expect(material.diffuseMap!.image).toBeNull();
500
+ });
501
+
502
+ it('returns an empty scene for empty input', () => {
503
+ const scene = createScene3DFromMd5Mesh('');
504
+ expect(getNodeChildren(scene.root)).toHaveLength(0);
505
+ });
506
+
507
+ it('returns an empty scene for comment-only input', () => {
508
+ const scene = createScene3DFromMd5Mesh('// just a comment\n');
509
+ expect(getNodeChildren(scene.root)).toHaveLength(0);
510
+ });
511
+
512
+ it('drops and reports md5mesh.malformed-joint for a bad joint line', () => {
513
+ const source = ['MD5Version 10', 'numJoints 1', 'numMeshes 0', 'joints {', ' bad joint line', '}'].join('\n');
514
+
515
+ const diagnostics: ImportDiagnostic[] = [];
516
+ createScene3DFromMd5Mesh(source, diagnostics);
517
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.malformed-joint');
518
+ expect(crumb).toBeDefined();
519
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
520
+ expect(crumb!.origin).toBe('parseMd5Mesh');
521
+ expect(crumb!.detail?.count).toBe(1);
522
+ expect(crumb!.detail?.reason).toBe('missing-name-quotes');
523
+ expect(crumb!.detail?.firstLine).toBe(5);
524
+ });
525
+
526
+ it('drops and reports md5mesh.malformed-vert for a bad vert line', () => {
527
+ const source = [
528
+ 'MD5Version 10',
529
+ 'numJoints 1',
530
+ 'numMeshes 1',
531
+ 'joints {',
532
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
533
+ '}',
534
+ 'mesh {',
535
+ ' shader "default"',
536
+ ' numverts 1',
537
+ ' vert 0 ( abc def ) 0 1',
538
+ ' numtris 0',
539
+ ' numweights 0',
540
+ '}',
541
+ ].join('\n');
542
+
543
+ const diagnostics: ImportDiagnostic[] = [];
544
+ createScene3DFromMd5Mesh(source, diagnostics);
545
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.malformed-vert');
546
+ expect(crumb).toBeDefined();
547
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
548
+ expect(crumb!.origin).toBe('parseMd5Mesh');
549
+ expect(crumb!.detail?.count).toBe(1);
550
+ expect(crumb!.detail?.reason).toBe('non-numeric');
551
+ });
552
+
553
+ it('drops and reports md5mesh.malformed-tri for a bad tri line', () => {
554
+ const source = [
555
+ 'MD5Version 10',
556
+ 'numJoints 1',
557
+ 'numMeshes 1',
558
+ 'joints {',
559
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
560
+ '}',
561
+ 'mesh {',
562
+ ' shader "default"',
563
+ ' numverts 0',
564
+ ' numtris 1',
565
+ ' tri 0 abc',
566
+ ' numweights 0',
567
+ '}',
568
+ ].join('\n');
569
+
570
+ const diagnostics: ImportDiagnostic[] = [];
571
+ createScene3DFromMd5Mesh(source, diagnostics);
572
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.malformed-tri');
573
+ expect(crumb).toBeDefined();
574
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
575
+ expect(crumb!.origin).toBe('parseMd5Mesh');
576
+ expect(crumb!.detail?.count).toBe(1);
577
+ expect(crumb!.detail?.reason).toBe('not-enough-components');
578
+ });
579
+
580
+ it('drops and reports md5mesh.malformed-weight for a bad weight line', () => {
581
+ const source = [
582
+ 'MD5Version 10',
583
+ 'numJoints 1',
584
+ 'numMeshes 1',
585
+ 'joints {',
586
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
587
+ '}',
588
+ 'mesh {',
589
+ ' shader "default"',
590
+ ' numverts 0',
591
+ ' numtris 0',
592
+ ' numweights 1',
593
+ ' weight 0',
594
+ '}',
595
+ ].join('\n');
596
+
597
+ const diagnostics: ImportDiagnostic[] = [];
598
+ createScene3DFromMd5Mesh(source, diagnostics);
599
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.malformed-weight');
600
+ expect(crumb).toBeDefined();
601
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
602
+ expect(crumb!.origin).toBe('parseMd5Mesh');
603
+ expect(crumb!.detail?.count).toBe(1);
604
+ expect(crumb!.detail?.reason).toBe('not-enough-components');
605
+ });
606
+
607
+ it('recovers and reports md5mesh.unsupported-version', () => {
608
+ const source = ['MD5Version 11', 'numJoints 0', 'numMeshes 0'].join('\n');
609
+
610
+ const diagnostics: ImportDiagnostic[] = [];
611
+ createScene3DFromMd5Mesh(source, diagnostics);
612
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.unsupported-version');
613
+ expect(crumb).toBeDefined();
614
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
615
+ expect(crumb!.origin).toBe('parseMd5Mesh');
616
+ expect(crumb!.detail).toEqual({ count: 1, version: 11 });
617
+ });
618
+
619
+ it('handles empty joints and mesh sections gracefully', () => {
620
+ const source = [
621
+ 'MD5Version 10',
622
+ 'numJoints 0',
623
+ 'numMeshes 1',
624
+ 'joints {',
625
+ '}',
626
+ 'mesh {',
627
+ ' shader "empty"',
628
+ ' numverts 0',
629
+ ' numtris 0',
630
+ ' numweights 0',
631
+ '}',
632
+ ].join('\n');
633
+
634
+ const scene = createScene3DFromMd5Mesh(source);
635
+ // No skeleton node (no joints), no mesh node (no indices).
636
+ expect(getNodeChildren(scene.root)).toHaveLength(0);
637
+ });
638
+
639
+ it('skips comment lines inside blocks', () => {
640
+ const source = [
641
+ 'MD5Version 10',
642
+ 'numJoints 1',
643
+ 'numMeshes 1',
644
+ 'joints {',
645
+ ' // This is a comment inside joints',
646
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
647
+ '}',
648
+ 'mesh {',
649
+ ' // This is a comment inside mesh',
650
+ ' shader "default"',
651
+ ' numverts 3',
652
+ ' vert 0 ( 0 0 ) 0 1',
653
+ ' vert 1 ( 1 0 ) 1 1',
654
+ ' vert 2 ( 0 1 ) 2 1',
655
+ ' numtris 1',
656
+ ' tri 0 0 1 2',
657
+ ' numweights 3',
658
+ ' weight 0 0 1.0 ( 0 0 0 )',
659
+ ' weight 1 0 1.0 ( 1 0 0 )',
660
+ ' weight 2 0 1.0 ( 0 1 0 )',
661
+ '}',
662
+ ].join('\n');
663
+
664
+ const diagnostics: ImportDiagnostic[] = [];
665
+ const scene = createScene3DFromMd5Mesh(source, diagnostics);
666
+ expect(diagnostics).toHaveLength(0);
667
+ expect(getNodeChildren(scene.root)).toHaveLength(2);
668
+ });
669
+
670
+ it('computes quaternion W from XYZ with non-zero orientation', () => {
671
+ // Joint with orientation (0.5, 0.5, 0.5) — w = -sqrt(1 - 0.75) = -0.5
672
+ // Weight position (1, 0, 0) rotated by q(0.5, 0.5, 0.5, -0.5) should produce a rotated result.
673
+ const source = [
674
+ 'MD5Version 10',
675
+ 'numJoints 1',
676
+ 'numMeshes 1',
677
+ 'joints {',
678
+ ' "root" -1 ( 0 0 0 ) ( 0.5 0.5 0.5 )',
679
+ '}',
680
+ 'mesh {',
681
+ ' shader "test"',
682
+ ' numverts 3',
683
+ ' vert 0 ( 0 0 ) 0 1',
684
+ ' vert 1 ( 0 0 ) 1 1',
685
+ ' vert 2 ( 0 0 ) 2 1',
686
+ ' numtris 1',
687
+ ' tri 0 0 1 2',
688
+ ' numweights 3',
689
+ ' weight 0 0 1.0 ( 1 0 0 )',
690
+ ' weight 1 0 1.0 ( 0 1 0 )',
691
+ ' weight 2 0 1.0 ( 0 0 1 )',
692
+ '}',
693
+ ].join('\n');
694
+
695
+ const scene = createScene3DFromMd5Mesh(source);
696
+ const meshNode = getNodeChildren(scene.root)[1] as unknown as Mesh;
697
+ const geometry = meshNode.geometry;
698
+
699
+ const p = { x: 0, y: 0, z: 0 };
700
+
701
+ // With q = (0.5, 0.5, 0.5, -0.5), rotating (1,0,0) gives MD5 (0,0,1) → Flight (0,1,0).
702
+ getMeshGeometryVertexPosition(p, geometry, 0);
703
+ expect(p.x).toBeCloseTo(0);
704
+ expect(p.y).toBeCloseTo(1);
705
+ expect(p.z).toBeCloseTo(0);
706
+
707
+ // Rotating (0,1,0) gives MD5 (1,0,0) → Flight (1,0,0).
708
+ getMeshGeometryVertexPosition(p, geometry, 1);
709
+ expect(p.x).toBeCloseTo(1);
710
+ expect(p.y).toBeCloseTo(0);
711
+ expect(p.z).toBeCloseTo(0);
712
+
713
+ // Rotating (0,0,1) gives MD5 (0,1,0) → Flight (0,0,-1).
714
+ getMeshGeometryVertexPosition(p, geometry, 2);
715
+ expect(p.x).toBeCloseTo(0);
716
+ expect(p.y).toBeCloseTo(0);
717
+ expect(p.z).toBeCloseTo(-1);
718
+ });
719
+
720
+ it('recovers and reports md5mesh.weight-joint-out-of-range for a bad weight joint index', () => {
721
+ const source = [
722
+ 'MD5Version 10',
723
+ 'numJoints 1',
724
+ 'numMeshes 1',
725
+ 'joints {',
726
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
727
+ '}',
728
+ 'mesh {',
729
+ ' shader "test"',
730
+ ' numverts 3',
731
+ ' vert 0 ( 0 0 ) 0 1',
732
+ ' vert 1 ( 0 0 ) 1 1',
733
+ ' vert 2 ( 0 0 ) 2 1',
734
+ ' numtris 1',
735
+ ' tri 0 0 1 2',
736
+ ' numweights 3',
737
+ ' weight 0 99 1.0 ( 0 0 0 )',
738
+ ' weight 1 0 1.0 ( 1 0 0 )',
739
+ ' weight 2 0 1.0 ( 0 1 0 )',
740
+ '}',
741
+ ].join('\n');
742
+
743
+ const diagnostics: ImportDiagnostic[] = [];
744
+ createScene3DFromMd5Mesh(source, diagnostics);
745
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.weight-joint-out-of-range');
746
+ expect(crumb).toBeDefined();
747
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
748
+ expect(crumb!.origin).toBe('parseMd5Mesh');
749
+ expect(crumb!.detail?.count).toBe(1);
750
+ expect(crumb!.detail?.firstIndex).toBe(99);
751
+ });
752
+
753
+ it('handles joint with no weights gracefully (zero-position vertex)', () => {
754
+ const source = [
755
+ 'MD5Version 10',
756
+ 'numJoints 1',
757
+ 'numMeshes 1',
758
+ 'joints {',
759
+ ' "root" -1 ( 5 5 5 ) ( 0 0 0 )',
760
+ '}',
761
+ 'mesh {',
762
+ ' shader "test"',
763
+ ' numverts 3',
764
+ ' vert 0 ( 0 0 ) 0 0',
765
+ ' vert 1 ( 0 0 ) 0 1',
766
+ ' vert 2 ( 0 0 ) 1 1',
767
+ ' numtris 1',
768
+ ' tri 0 0 1 2',
769
+ ' numweights 2',
770
+ ' weight 0 0 1.0 ( 0 0 0 )',
771
+ ' weight 1 0 1.0 ( 1 0 0 )',
772
+ '}',
773
+ ].join('\n');
774
+
775
+ const scene = createScene3DFromMd5Mesh(source);
776
+ const meshNode = getNodeChildren(scene.root)[1] as unknown as Mesh;
777
+ const geometry = meshNode.geometry;
778
+
779
+ const p = { x: 0, y: 0, z: 0 };
780
+
781
+ // Vert 0: countWeights=0, so position stays at (0,0,0).
782
+ getMeshGeometryVertexPosition(p, geometry, 0);
783
+ expect(p.x).toBeCloseTo(0);
784
+ expect(p.y).toBeCloseTo(0);
785
+ expect(p.z).toBeCloseTo(0);
786
+
787
+ // Vert 1: weight 0, joint at (5,5,5) in MD5, offset (0,0,0) → MD5 (5,5,5) → Flight (5,5,-5).
788
+ getMeshGeometryVertexPosition(p, geometry, 1);
789
+ expect(p.x).toBeCloseTo(5);
790
+ expect(p.y).toBeCloseTo(5);
791
+ expect(p.z).toBeCloseTo(-5);
792
+ });
793
+
794
+ it('recovers and reports md5mesh.joints-block-unclosed', () => {
795
+ const source = ['MD5Version 10', 'numJoints 1', 'numMeshes 0', 'joints {', ' "root" -1 ( 0 0 0 ) ( 0 0 0 )'].join(
796
+ '\n',
797
+ );
798
+ const diagnostics: ImportDiagnostic[] = [];
799
+ createScene3DFromMd5Mesh(source, diagnostics);
800
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.joints-block-unclosed');
801
+ expect(crumb).toBeDefined();
802
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
803
+ expect(crumb!.origin).toBe('parseMd5Mesh');
804
+ });
805
+
806
+ it('recovers and reports md5mesh.mesh-block-unclosed', () => {
807
+ const source = [
808
+ 'MD5Version 10',
809
+ 'numJoints 1',
810
+ 'numMeshes 1',
811
+ 'joints {',
812
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
813
+ '}',
814
+ 'mesh {',
815
+ ' shader "default"',
816
+ ' numverts 0',
817
+ ' numtris 0',
818
+ ' numweights 0',
819
+ ].join('\n');
820
+ const diagnostics: ImportDiagnostic[] = [];
821
+ createScene3DFromMd5Mesh(source, diagnostics);
822
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.mesh-block-unclosed');
823
+ expect(crumb).toBeDefined();
824
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
825
+ expect(crumb!.origin).toBe('parseMd5Mesh');
826
+ });
827
+
828
+ it('recovers and reports md5mesh.shader-unquoted for a shader directive with no quoted name', () => {
829
+ // Real .md5mesh files sometimes write the shader path unquoted; the mesh is then emitted with no
830
+ // material. The geometry survives, so this is a Recover.
831
+ const source = SINGLE_TRIANGLE.replace('shader "textures/default"', 'shader textures/default');
832
+ const diagnostics: ImportDiagnostic[] = [];
833
+ const scene = createScene3DFromMd5Mesh(source, diagnostics);
834
+ const mesh = getNodeChildren(scene.root)[1] as unknown as Mesh;
835
+ expect(mesh.materials).toHaveLength(0); // material binding was lost
836
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.shader-unquoted');
837
+ expect(crumb).toBeDefined();
838
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
839
+ expect(crumb!.origin).toBe('parseMd5Mesh');
840
+ expect(crumb!.detail?.count).toBe(1);
841
+ });
842
+
843
+ it('drops and reports md5mesh.mesh-empty for a mesh that yields no triangles', () => {
844
+ const source = [
845
+ 'MD5Version 10',
846
+ 'numJoints 1',
847
+ 'numMeshes 1',
848
+ 'joints {',
849
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
850
+ '}',
851
+ 'mesh {',
852
+ ' shader "default"',
853
+ ' numverts 1',
854
+ ' vert 0 ( 0 0 ) 0 1',
855
+ ' numtris 0',
856
+ ' numweights 1',
857
+ ' weight 0 0 1.0 ( 0 0 0 )',
858
+ '}',
859
+ ].join('\n');
860
+ const diagnostics: ImportDiagnostic[] = [];
861
+ createScene3DFromMd5Mesh(source, diagnostics);
862
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.mesh-empty');
863
+ expect(crumb).toBeDefined();
864
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
865
+ expect(crumb!.origin).toBe('parseMd5Mesh');
866
+ expect(crumb!.detail?.count).toBe(1);
867
+ });
868
+
869
+ it('recovers and reports md5mesh.joint-parent-out-of-range', () => {
870
+ const source = [
871
+ 'MD5Version 10',
872
+ 'numJoints 1',
873
+ 'numMeshes 0',
874
+ 'joints {',
875
+ ' "root" 9 ( 0 0 0 ) ( 0 0 0 )',
876
+ '}',
877
+ ].join('\n');
878
+ const diagnostics: ImportDiagnostic[] = [];
879
+ createScene3DFromMd5Mesh(source, diagnostics);
880
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.joint-parent-out-of-range');
881
+ expect(crumb).toBeDefined();
882
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
883
+ expect(crumb!.origin).toBe('parseMd5Mesh');
884
+ expect(crumb!.detail?.count).toBe(1);
885
+ expect(crumb!.detail?.firstParent).toBe(9);
886
+ });
887
+
888
+ it('recovers and reports md5mesh.vertex-weight-out-of-range for an out-of-range weight index', () => {
889
+ const source = [
890
+ 'MD5Version 10',
891
+ 'numJoints 1',
892
+ 'numMeshes 1',
893
+ 'joints {',
894
+ ' "root" -1 ( 0 0 0 ) ( 0 0 0 )',
895
+ '}',
896
+ 'mesh {',
897
+ ' shader "default"',
898
+ ' numverts 3',
899
+ ' vert 0 ( 0 0 ) 50 1',
900
+ ' vert 1 ( 0 0 ) 0 1',
901
+ ' vert 2 ( 0 0 ) 0 1',
902
+ ' numtris 1',
903
+ ' tri 0 0 1 2',
904
+ ' numweights 1',
905
+ ' weight 0 0 1.0 ( 0 0 0 )',
906
+ '}',
907
+ ].join('\n');
908
+ const diagnostics: ImportDiagnostic[] = [];
909
+ createScene3DFromMd5Mesh(source, diagnostics);
910
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.vertex-weight-out-of-range');
911
+ expect(crumb).toBeDefined();
912
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
913
+ expect(crumb!.origin).toBe('parseMd5Mesh');
914
+ expect(crumb!.detail?.count).toBe(1);
915
+ expect(crumb!.detail?.firstIndex).toBe(50);
916
+ });
917
+
918
+ it('aggregates repeated malformed-joint drops into one crumb with a count', () => {
919
+ const source = [
920
+ 'MD5Version 10',
921
+ 'numJoints 3',
922
+ 'numMeshes 0',
923
+ 'joints {',
924
+ ' bad one',
925
+ ' bad two',
926
+ ' bad three',
927
+ '}',
928
+ ].join('\n');
929
+ const diagnostics: ImportDiagnostic[] = [];
930
+ createScene3DFromMd5Mesh(source, diagnostics);
931
+ const matching = diagnostics.filter((d) => d.kind === 'md5mesh.malformed-joint');
932
+ expect(matching).toHaveLength(1);
933
+ expect(matching[0].detail?.count).toBe(3);
934
+ expect(matching[0].detail?.firstLine).toBe(5);
935
+ });
936
+
937
+ it('emits no diagnostics when no collector array is supplied', () => {
938
+ const source = [
939
+ 'MD5Version 11',
940
+ 'numJoints 1',
941
+ 'numMeshes 1',
942
+ 'joints {',
943
+ ' "root" 9 ( 0 0 0 ) ( 0 0 0 )',
944
+ ' bad line',
945
+ '}',
946
+ 'mesh {',
947
+ ' shader "default"',
948
+ ' numverts 1',
949
+ ' vert 0 ( 0 0 ) 50 1',
950
+ ' numtris 0',
951
+ ' numweights 0',
952
+ ].join('\n');
953
+ // Exercising every crumb path without a sink must not throw and must be side-effect-free.
954
+ expect(() => createScene3DFromMd5Mesh(source)).not.toThrow();
955
+ });
956
+ });
957
+
958
+ describe('createScene3DFromMd5Mesh animations', () => {
959
+ it('returns the mesh scene with an empty animations map (the .md5anim is a separate file)', () => {
960
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
961
+ expect(Object.keys(scene.animations)).toHaveLength(0);
962
+ });
963
+
964
+ it('composes a paired .md5anim into a named clip bound to the scene’s own skeleton joints', () => {
965
+ const scene = createScene3DFromMd5Mesh(SINGLE_TRIANGLE);
966
+ const joints = findScene3DSkeletonJoints(scene.root)!;
967
+ scene.animations.walk = parseMd5Anim(SINGLE_JOINT_ANIM, joints)!;
968
+ expect(Object.keys(scene.animations)).toEqual(['walk']);
969
+
970
+ const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
971
+ const meshJoints = mesh.skin!.skeleton.joints;
972
+ const channel = scene.animations.walk.channels[0];
973
+ // The clip binds the SAME joint node the imported mesh skins from — no caller threading.
974
+ expect((channel.targetRef as Scene3DAnimationTarget).node).toBe(meshJoints[0]);
975
+ });
976
+ });
977
+
978
+ // A degenerate MD5 mesh with no skeleton (numJoints 0, weightless verts): importMd5Mesh has no joints to
979
+ // bind an animation to.
980
+ const JOINTLESS_MESH = [
981
+ 'MD5Version 10',
982
+ 'commandline ""',
983
+ '',
984
+ 'numJoints 0',
985
+ 'numMeshes 1',
986
+ '',
987
+ 'joints {',
988
+ '}',
989
+ '',
990
+ 'mesh {',
991
+ ' shader "textures/none"',
992
+ '',
993
+ ' numverts 3',
994
+ ' vert 0 ( 0.0 0.0 ) 0 0',
995
+ ' vert 1 ( 1.0 0.0 ) 0 0',
996
+ ' vert 2 ( 0.0 1.0 ) 0 0',
997
+ '',
998
+ ' numtris 1',
999
+ ' tri 0 0 1 2',
1000
+ '',
1001
+ ' numweights 0',
1002
+ '}',
1003
+ ].join('\n');
1004
+
1005
+ describe('importMd5Mesh', () => {
1006
+ it('imports the mesh only when no animation source is given', () => {
1007
+ const scene = importMd5Mesh(SINGLE_TRIANGLE);
1008
+ // Skeleton group + mesh, and no animation.
1009
+ expect(getNodeChildren(scene.root)).toHaveLength(2);
1010
+ expect(Object.keys(scene.animations)).toHaveLength(0);
1011
+ });
1012
+
1013
+ it('composes the .md5mesh and .md5anim into one scene with the clip bound to the mesh skeleton', () => {
1014
+ const scene = importMd5Mesh(SINGLE_TRIANGLE, SINGLE_JOINT_ANIM);
1015
+ expect(Object.keys(scene.animations)).toEqual(['default']);
1016
+
1017
+ const mesh = getNodeChildren(scene.root).find((c) => isMesh(c as Node3D)) as unknown as Mesh;
1018
+ const channel = scene.animations.default.channels[0];
1019
+ // The composer bound the clip to the SAME joint node the mesh skins from — no caller threading.
1020
+ expect((channel.targetRef as Scene3DAnimationTarget).node).toBe(mesh.skin!.skeleton.joints[0]);
1021
+ });
1022
+
1023
+ it('treats a null animation source the same as omitting it', () => {
1024
+ const scene = importMd5Mesh(SINGLE_TRIANGLE, null);
1025
+ expect(Object.keys(scene.animations)).toHaveLength(0);
1026
+ });
1027
+
1028
+ it('skips the animation and reports md5mesh.animation-no-skeleton when the mesh has no skeleton', () => {
1029
+ const diagnostics: ImportDiagnostic[] = [];
1030
+ const scene = importMd5Mesh(JOINTLESS_MESH, SINGLE_JOINT_ANIM, diagnostics);
1031
+ expect(Object.keys(scene.animations)).toHaveLength(0);
1032
+ const crumb = findDiagnostic(diagnostics, 'md5mesh.animation-no-skeleton');
1033
+ expect(crumb).toBeDefined();
1034
+ expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Skip);
1035
+ expect(crumb!.origin).toBe('importMd5Mesh');
1036
+ });
1037
+ });
1038
+
1039
+ describe('parseMd5Mesh', () => {
1040
+ it('returns a format-neutral document: a skeleton group + joint nodes, a skinned mesh node, scene roots', () => {
1041
+ const doc = parseMd5Mesh(SINGLE_TRIANGLE);
1042
+
1043
+ // The skeleton group and each joint are document nodes; the mesh is one more.
1044
+ const skeletonIndex = doc.nodes.findIndex((n) => n.name === 'skeleton');
1045
+ expect(skeletonIndex).toBeGreaterThanOrEqual(0);
1046
+ expect(doc.meshes).toHaveLength(1);
1047
+ const meshNodeIndex = doc.nodes.findIndex((n) => n.mesh !== undefined);
1048
+ expect(meshNodeIndex).toBeGreaterThanOrEqual(0);
1049
+ expect(doc.nodes[meshNodeIndex].mesh).toBe(0); // index into meshes
1050
+ expect(getMeshGeometryVertexCount(doc.meshes[0].geometry)).toBe(3);
1051
+
1052
+ // Both the skeleton group and the mesh node are scene roots; the joint hangs under the group.
1053
+ expect(doc.scenes[0].rootNodes).toContain(skeletonIndex);
1054
+ expect(doc.scenes[0].rootNodes).toContain(meshNodeIndex);
1055
+ expect(doc.nodes[skeletonIndex].children).toHaveLength(1);
1056
+ });
1057
+
1058
+ it('decomposes the skeleton into a skin: joints by node index + one inverse-bind per joint', () => {
1059
+ const doc = parseMd5Mesh(MULTI_JOINT_HIERARCHY);
1060
+
1061
+ expect(doc.skins).toHaveLength(1);
1062
+ expect(doc.skins[0].joints).toHaveLength(3);
1063
+ expect(doc.skins[0].inverseBind).toHaveLength(3);
1064
+ // Each joint is a valid node index, and each is named.
1065
+ for (const jointNodeIndex of doc.skins[0].joints) {
1066
+ expect(jointNodeIndex).toBeGreaterThanOrEqual(0);
1067
+ expect(jointNodeIndex).toBeLessThan(doc.nodes.length);
1068
+ }
1069
+ expect(doc.skins[0].joints.map((j) => doc.nodes[j].name)).toEqual(['root', 'child_a', 'child_b']);
1070
+
1071
+ // The mesh names the skin by index.
1072
+ expect(doc.meshes[0].skin).toBe(0);
1073
+
1074
+ // The child joints are parented under their parent joint (parent-relative local transforms).
1075
+ const rootJointIndex = doc.skins[0].joints[0];
1076
+ expect(doc.nodes[rootJointIndex].children).toContain(doc.skins[0].joints[1]);
1077
+ expect(doc.nodes[rootJointIndex].children).toContain(doc.skins[0].joints[2]);
1078
+ });
1079
+
1080
+ it('appends the section shader material to the document materials table by index', () => {
1081
+ const doc = parseMd5Mesh(SINGLE_TRIANGLE);
1082
+ expect(doc.materials).toHaveLength(1);
1083
+ // The mesh's subset references the material by its document index.
1084
+ expect(doc.meshes[0].materials).toEqual([0]);
1085
+ expect((doc.materials[0] as unknown as BlinnPhongMaterial).kind).toBe(BlinnPhongMaterialKind);
1086
+ expect(doc.resources).toHaveLength(1);
1087
+ expect(doc.resources[0]).toBe((doc.materials[0] as unknown as BlinnPhongMaterial).diffuseMap!.resource);
1088
+ });
1089
+
1090
+ it('leaves the animations table empty (the .md5anim is a separate file)', () => {
1091
+ const doc = parseMd5Mesh(SINGLE_TRIANGLE);
1092
+ expect(doc.animations).toHaveLength(0);
1093
+ });
1094
+ });