@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,690 @@
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
+ Node3D,
16
+ } from '@flighthq/types';
17
+ import { BlinnPhongMaterialKind } from '@flighthq/types';
18
+
19
+ import { parseObjMaterialLibrary } from './mtlParse';
20
+ import { createScene3DFromObj, parseObj } from './objParse';
21
+
22
+ // Asserts EXACTLY ONE crumb of `kind` was recorded (guards the count) and returns it so a test can lock
23
+ // the full contract — severity, true origin, and detail — for that emitted diagnostic.
24
+ function expectOneCrumb(diagnostics: readonly ImportDiagnostic[], kind: string): ImportDiagnostic {
25
+ const matches = diagnostics.filter((d) => d.kind === kind);
26
+ expect(matches).toHaveLength(1);
27
+ return matches[0];
28
+ }
29
+
30
+ describe('createScene3DFromObj', () => {
31
+ it('parses a single triangle with positions only', () => {
32
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3'].join('\n');
33
+
34
+ const scene = createScene3DFromObj(obj);
35
+ const children = getNodeChildren(scene.root);
36
+ expect(children).toHaveLength(1);
37
+ expect(isMesh(children[0] as Node3D)).toBe(true);
38
+
39
+ const geometry = (children[0] as Mesh).geometry;
40
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
41
+ expect(getMeshGeometryIndexCount(geometry)).toBe(3);
42
+
43
+ const p = { x: 0, y: 0, z: 0 };
44
+ getMeshGeometryVertexPosition(p, geometry, 0);
45
+ expect([p.x, p.y, p.z]).toEqual([0, 0, 0]);
46
+ getMeshGeometryVertexPosition(p, geometry, 1);
47
+ expect([p.x, p.y, p.z]).toEqual([1, 0, 0]);
48
+ getMeshGeometryVertexPosition(p, geometry, 2);
49
+ expect([p.x, p.y, p.z]).toEqual([0, 1, 0]);
50
+ });
51
+
52
+ it('parses multiple faces sharing vertices', () => {
53
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 1 1 0', 'v 0 1 0', 'f 1 2 3', 'f 1 3 4'].join('\n');
54
+
55
+ const scene = createScene3DFromObj(obj);
56
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
57
+ // 4 unique vertices, 6 indices (2 triangles).
58
+ expect(getMeshGeometryVertexCount(geometry)).toBe(4);
59
+ expect(getMeshGeometryIndexCount(geometry)).toBe(6);
60
+ });
61
+
62
+ it('fan-triangulates a quad face', () => {
63
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 1 1 0', 'v 0 1 0', 'f 1 2 3 4'].join('\n');
64
+
65
+ const scene = createScene3DFromObj(obj);
66
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
67
+ // A quad becomes 2 triangles = 6 indices.
68
+ expect(getMeshGeometryIndexCount(geometry)).toBe(6);
69
+ });
70
+
71
+ it('fan-triangulates an N-gon face (pentagon)', () => {
72
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 1.5 0.5 0', 'v 1 1 0', 'v 0 1 0', 'f 1 2 3 4 5'].join('\n');
73
+
74
+ const scene = createScene3DFromObj(obj);
75
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
76
+ // A pentagon becomes 3 triangles = 9 indices.
77
+ expect(getMeshGeometryIndexCount(geometry)).toBe(9);
78
+ });
79
+
80
+ it('handles independent position/uv/normal indices', () => {
81
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'vn 0 0 1', 'vt 0 0', 'vt 1 0', 'vt 0 1', 'f 1/1/1 2/2/1 3/3/1'].join(
82
+ '\n',
83
+ );
84
+
85
+ const scene = createScene3DFromObj(obj);
86
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
87
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
88
+
89
+ const n = { x: 0, y: 0, z: 0 };
90
+ getMeshGeometryVertexNormal(n, geometry, 0);
91
+ expect([n.x, n.y, n.z]).toEqual([0, 0, 1]);
92
+
93
+ const uv = { x: 0, y: 0 };
94
+ getMeshGeometryVertexUv0(uv, geometry, 0);
95
+ expect([uv.x, uv.y]).toEqual([0, 1]);
96
+ getMeshGeometryVertexUv0(uv, geometry, 1);
97
+ expect([uv.x, uv.y]).toEqual([1, 1]);
98
+ });
99
+
100
+ it('handles v//vn syntax (position and normal, no uv)', () => {
101
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'vn 0 0 -1', 'f 1//1 2//1 3//1'].join('\n');
102
+
103
+ const scene = createScene3DFromObj(obj);
104
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
105
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
106
+
107
+ const n = { x: 0, y: 0, z: 0 };
108
+ getMeshGeometryVertexNormal(n, geometry, 0);
109
+ expect([n.x, n.y, n.z]).toEqual([0, 0, -1]);
110
+ });
111
+
112
+ it('handles v/vt syntax (position and uv, no normal)', () => {
113
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'vt 0.5 0.5', 'f 1/1 2/1 3/1'].join('\n');
114
+
115
+ const scene = createScene3DFromObj(obj);
116
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
117
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
118
+
119
+ const uv = { x: 0, y: 0 };
120
+ getMeshGeometryVertexUv0(uv, geometry, 0);
121
+ expect([uv.x, uv.y]).toEqual([0.5, 0.5]);
122
+ });
123
+
124
+ it('resolves negative (relative) vertex indices', () => {
125
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f -3 -2 -1'].join('\n');
126
+
127
+ const scene = createScene3DFromObj(obj);
128
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
129
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
130
+
131
+ const p = { x: 0, y: 0, z: 0 };
132
+ getMeshGeometryVertexPosition(p, geometry, 0);
133
+ expect([p.x, p.y, p.z]).toEqual([0, 0, 0]);
134
+ getMeshGeometryVertexPosition(p, geometry, 2);
135
+ expect([p.x, p.y, p.z]).toEqual([0, 1, 0]);
136
+ });
137
+
138
+ it('emits each single-material group as a bare named Mesh', () => {
139
+ const obj = [
140
+ 'v 0 0 0',
141
+ 'v 1 0 0',
142
+ 'v 0 1 0',
143
+ 'v 2 0 0',
144
+ 'v 2 1 0',
145
+ 'v 3 0 0',
146
+ 'g GroupA',
147
+ 'f 1 2 3',
148
+ 'g GroupB',
149
+ 'f 4 5 6',
150
+ ].join('\n');
151
+
152
+ const scene = createScene3DFromObj(obj);
153
+ const roots = getNodeChildren(scene.root);
154
+ expect(roots).toHaveLength(2);
155
+
156
+ // Each single-material group becomes one bare Mesh carrying the group name —
157
+ // getNodeChildren(scene.root) returns Mesh nodes, not transform-only wrappers.
158
+ const groupA = roots[0] as Node3D;
159
+ expect(isMesh(groupA)).toBe(true);
160
+ expect(groupA.name).toBe('GroupA');
161
+
162
+ const groupB = roots[1] as Node3D;
163
+ expect(isMesh(groupB)).toBe(true);
164
+ expect(groupB.name).toBe('GroupB');
165
+ });
166
+
167
+ it('emits a single-object group as a bare Mesh carrying the object name', () => {
168
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'o Cube', 'f 1 2 3'].join('\n');
169
+
170
+ const scene = createScene3DFromObj(obj);
171
+ const roots = getNodeChildren(scene.root);
172
+ expect(roots).toHaveLength(1);
173
+ expect(isMesh(roots[0] as Node3D)).toBe(true);
174
+ expect((roots[0] as Node3D).name).toBe('Cube');
175
+ });
176
+
177
+ it('emits a multi-material group as one Mesh with a subset per material', () => {
178
+ const obj = [
179
+ 'v 0 0 0',
180
+ 'v 1 0 0',
181
+ 'v 0 1 0',
182
+ 'v 2 0 0',
183
+ 'v 2 1 0',
184
+ 'v 3 0 0',
185
+ 'g Body',
186
+ 'usemtl Red',
187
+ 'f 1 2 3',
188
+ 'usemtl Blue',
189
+ 'f 4 5 6',
190
+ ].join('\n');
191
+
192
+ const scene = createScene3DFromObj(obj);
193
+ // The group "Body" is one bare Mesh, not a wrapper over per-material child meshes.
194
+ const roots = getNodeChildren(scene.root);
195
+ expect(roots).toHaveLength(1);
196
+ const mesh = roots[0] as Mesh;
197
+ expect(isMesh(mesh)).toBe(true);
198
+ expect(mesh.name).toBe('Body');
199
+
200
+ // One MeshSubset per material, each addressing that material's contiguous index range, plus a
201
+ // positional materials slot per subset (null here — no library supplied).
202
+ const subsets = mesh.geometry.subsets;
203
+ expect(subsets).toHaveLength(2);
204
+ expect(subsets[0]).toEqual({ indexCount: 3, indexOffset: 0 });
205
+ expect(subsets[1]).toEqual({ indexCount: 3, indexOffset: 3 });
206
+ expect(mesh.materials).toEqual([null, null]);
207
+ expect(getMeshGeometryIndexCount(mesh.geometry)).toBe(6);
208
+ });
209
+
210
+ it('treats a bare g as the default (unnamed) group, closing the prior named group', () => {
211
+ // Wavefront spec: a group with no name is the default group. A bare `g` must close group A and enter it,
212
+ // not leave the following face in A. This is a defined state, not a drop — no diagnostic.
213
+ const obj = [
214
+ 'v 0 0 0',
215
+ 'v 1 0 0',
216
+ 'v 0 1 0',
217
+ 'v 2 0 0',
218
+ 'v 2 1 0',
219
+ 'v 3 0 0',
220
+ 'g A',
221
+ 'f 1 2 3',
222
+ 'g',
223
+ 'f 4 5 6',
224
+ ].join('\n');
225
+ const diagnostics: ImportDiagnostic[] = [];
226
+ const scene = createScene3DFromObj(obj, undefined, diagnostics);
227
+ const roots = getNodeChildren(scene.root);
228
+ expect(roots).toHaveLength(2);
229
+ expect((roots[0] as Node3D).name).toBe('A');
230
+ expect((roots[1] as Node3D).name).toBeNull(); // the default (unnamed) group after the bare g
231
+ expect(diagnostics).toHaveLength(0);
232
+ });
233
+
234
+ it('recovers a bare o as an unnamed boundary AND records object-name-missing (Recover, parseObj)', () => {
235
+ // `o` always takes a name in the spec, so a bare `o` is undefined input: the importer recovers by closing
236
+ // the prior object and starting an unnamed one — and because that is substitute-and-continue, the collector
237
+ // contract requires it be recorded as a Recover crumb (unlike the spec-defined, silent bare g/usemtl).
238
+ const obj = [
239
+ 'v 0 0 0',
240
+ 'v 1 0 0',
241
+ 'v 0 1 0',
242
+ 'v 2 0 0',
243
+ 'v 2 1 0',
244
+ 'v 3 0 0',
245
+ 'o Cube',
246
+ 'f 1 2 3',
247
+ 'o',
248
+ 'f 4 5 6',
249
+ ].join('\n');
250
+ const diagnostics: ImportDiagnostic[] = [];
251
+ const roots = getNodeChildren(createScene3DFromObj(obj, undefined, diagnostics).root);
252
+ // Exact output: the recovery still happens — two nodes, the named Cube then the unnamed boundary.
253
+ expect(roots).toHaveLength(2);
254
+ expect((roots[0] as Node3D).name).toBe('Cube');
255
+ expect((roots[1] as Node3D).name).toBeNull();
256
+ // Exact crumb: one Recover for the missing object name, emitted from parseObj.
257
+ expect(diagnostics).toHaveLength(1);
258
+ const crumb = expectOneCrumb(diagnostics, 'obj.object-name-missing');
259
+ expect(crumb.severity).toBe('Recover');
260
+ expect(crumb.origin).toBe('parseObj');
261
+ expect(crumb.detail?.firstLine).toBe(9); // the bare `o` line
262
+ expect(crumb.detail?.count).toBe(1);
263
+ });
264
+
265
+ it('treats a bare usemtl as the default material, clearing the previously active one', () => {
266
+ // Wavefront spec: `usemtl` with no name is the default (white / no) material. A bare `usemtl` must clear
267
+ // the active material so the following faces are NOT bound to the stale one. A defined state, not a drop.
268
+ const library = parseObjMaterialLibrary('newmtl Red\nKd 1 0 0\n');
269
+ const obj = [
270
+ 'v 0 0 0',
271
+ 'v 1 0 0',
272
+ 'v 0 1 0',
273
+ 'v 2 0 0',
274
+ 'v 2 1 0',
275
+ 'v 3 0 0',
276
+ 'usemtl Red',
277
+ 'f 1 2 3',
278
+ 'usemtl',
279
+ 'f 4 5 6',
280
+ ].join('\n');
281
+ const diagnostics: ImportDiagnostic[] = [];
282
+ const mesh = getNodeChildren(createScene3DFromObj(obj, library, diagnostics).root)[0] as Mesh;
283
+ // One group, two subsets: the Red material then the default (no material) after the bare usemtl — the
284
+ // second slot must be null, NOT a stale Red.
285
+ expect(mesh.geometry.subsets).toHaveLength(2);
286
+ expect(mesh.materials).toHaveLength(2);
287
+ expect(mesh.materials[0]).not.toBeNull();
288
+ expect((mesh.materials[0] as BlinnPhongMaterial).kind).toBe(BlinnPhongMaterialKind);
289
+ expect(mesh.materials[1]).toBeNull();
290
+ expect(diagnostics).toHaveLength(0);
291
+ });
292
+
293
+ it('returns an empty scene for empty input', () => {
294
+ const scene = createScene3DFromObj('');
295
+ expect(getNodeChildren(scene.root)).toHaveLength(0);
296
+ });
297
+
298
+ it('returns an empty scene for comment-only input', () => {
299
+ const scene = createScene3DFromObj('# just a comment\n');
300
+ expect(getNodeChildren(scene.root)).toHaveLength(0);
301
+ });
302
+
303
+ it('records a diagnostic on faces with fewer than 3 vertices', () => {
304
+ const obj = 'v 0 0 0\nv 1 0 0\nf 1 2\n';
305
+ const diagnostics: ImportDiagnostic[] = [];
306
+ createScene3DFromObj(obj, undefined, diagnostics);
307
+ expect(diagnostics).toHaveLength(1);
308
+ const crumb = expectOneCrumb(diagnostics, 'obj.face-too-few-vertices');
309
+ expect(crumb.severity).toBe('Drop');
310
+ expect(crumb.origin).toBe('parseObj');
311
+ expect(crumb.detail?.firstLine).toBe(3);
312
+ expect(crumb.detail?.count).toBe(1);
313
+ });
314
+
315
+ it('records a diagnostic on out-of-range position indices', () => {
316
+ const obj = 'v 0 0 0\nf 1 2 3\n';
317
+ const diagnostics: ImportDiagnostic[] = [];
318
+ createScene3DFromObj(obj, undefined, diagnostics);
319
+ expect(diagnostics).toHaveLength(1);
320
+ const crumb = expectOneCrumb(diagnostics, 'obj.position-index-out-of-range');
321
+ expect(crumb.severity).toBe('Drop');
322
+ expect(crumb.origin).toBe('parseObj');
323
+ expect(crumb.detail?.firstLine).toBe(2);
324
+ expect(crumb.detail?.firstIndex).toBe(2);
325
+ expect(crumb.detail?.count).toBe(1);
326
+ });
327
+
328
+ it('records a diagnostic on non-numeric position components', () => {
329
+ const obj = 'v abc def ghi\n';
330
+ const diagnostics: ImportDiagnostic[] = [];
331
+ createScene3DFromObj(obj, undefined, diagnostics);
332
+ expect(diagnostics).toHaveLength(1);
333
+ const crumb = expectOneCrumb(diagnostics, 'obj.vertex-malformed');
334
+ expect(crumb.severity).toBe('Drop');
335
+ expect(crumb.origin).toBe('parseObj');
336
+ expect(crumb.detail?.firstLine).toBe(1);
337
+ expect(crumb.detail?.reason).toBe('non-numeric');
338
+ expect(crumb.detail?.count).toBe(1);
339
+ });
340
+
341
+ it('deduplicates vertices sharing the same position/uv/normal tuple', () => {
342
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3', 'f 3 2 1'].join('\n');
343
+
344
+ const scene = createScene3DFromObj(obj);
345
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
346
+ // Same 3 unique vertex combos used by both faces.
347
+ expect(getMeshGeometryVertexCount(geometry)).toBe(3);
348
+ expect(getMeshGeometryIndexCount(geometry)).toBe(6);
349
+ });
350
+
351
+ it('creates distinct vertices when the same position has different normals', () => {
352
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'vn 0 0 1', 'vn 0 0 -1', 'f 1//1 2//1 3//1', 'f 1//2 2//2 3//2'].join(
353
+ '\n',
354
+ );
355
+
356
+ const scene = createScene3DFromObj(obj);
357
+ const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
358
+ // 3 pos * 2 normals = 6 unique vertices.
359
+ expect(getMeshGeometryVertexCount(geometry)).toBe(6);
360
+ });
361
+
362
+ it('attaches a BlinnPhongMaterial resolved from the MTL library by usemtl name', () => {
363
+ const mtl = 'newmtl RedMat\nKd 1 0 0\n';
364
+ const lib = parseObjMaterialLibrary(mtl);
365
+
366
+ const obj = ['mtllib materials.mtl', 'v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'usemtl RedMat', 'f 1 2 3'].join('\n');
367
+
368
+ const scene = createScene3DFromObj(obj, lib);
369
+ const mesh = getNodeChildren(scene.root)[0] as Mesh;
370
+ expect(isMesh(mesh)).toBe(true);
371
+ expect(mesh.materials).toHaveLength(1);
372
+ const material = mesh.materials[0] as BlinnPhongMaterial;
373
+ expect(material.kind).toBe(BlinnPhongMaterialKind);
374
+ expect(material.diffuse).toBe(0xff0000ff); // Kd 1 0 0, opaque (d defaults to 1)
375
+ expect(material.name).toBe('RedMat'); // MTL newmtl name preserved as the authored identity
376
+ });
377
+
378
+ it("maps MTL's own Kd/Ks/Ns/d/maps onto BlinnPhong fields, referencing map filenames unresolved", () => {
379
+ const mtl = [
380
+ 'newmtl Shiny',
381
+ 'Kd 0.8 0.4 0.2',
382
+ 'Ks 1 1 1',
383
+ 'Ns 64',
384
+ 'd 0.5',
385
+ 'map_Kd wood.png',
386
+ 'map_Ks spec.png',
387
+ 'bump normal.png',
388
+ ].join('\n');
389
+ const lib = parseObjMaterialLibrary(mtl);
390
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'usemtl Shiny', 'f 1 2 3'].join('\n');
391
+
392
+ const material = (getNodeChildren(createScene3DFromObj(obj, lib).root)[0] as Mesh)
393
+ .materials[0] as BlinnPhongMaterial;
394
+ expect(material.kind).toBe(BlinnPhongMaterialKind);
395
+ expect(material.diffuse).toBe(0xcc6633_80 >>> 0); // Kd 0.8,0.4,0.2 with d=0.5 alpha
396
+ expect(material.specular).toBe(0xffffffff); // Ks 1,1,1 opaque
397
+ expect(material.shininess).toBe(64); // Ns
398
+ expect(material.alphaMode).toBe('blend'); // d < 1
399
+ // Texture maps are referenced by filename, not decoded.
400
+ expect((material.diffuseMap!.resource as ExternalImageResourceReference).uri).toBe('wood.png');
401
+ expect((material.specularMap!.resource as ExternalImageResourceReference).uri).toBe('spec.png');
402
+ expect((material.normalMap!.resource as ExternalImageResourceReference).uri).toBe('normal.png');
403
+ expect(material.diffuseMap!.image).toBeNull();
404
+ });
405
+
406
+ it('leaves a subset slot null when usemtl names a material absent from the library', () => {
407
+ const lib = parseObjMaterialLibrary('newmtl Known\nKd 1 1 1\n');
408
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'usemtl Missing', 'f 1 2 3'].join('\n');
409
+
410
+ const mesh = getNodeChildren(createScene3DFromObj(obj, lib).root)[0] as Mesh;
411
+ // One subset, one positional slot — null (resolves to DefaultMaterialKind at draw time).
412
+ expect(mesh.materials).toEqual([null]);
413
+ });
414
+
415
+ it('persists the active material across a group boundary (g/o does not reset usemtl)', () => {
416
+ const lib = parseObjMaterialLibrary('newmtl RedMat\nKd 1 0 0\n');
417
+ const obj = [
418
+ 'v 0 0 0',
419
+ 'v 1 0 0',
420
+ 'v 0 1 0',
421
+ 'v 2 0 0',
422
+ 'v 2 1 0',
423
+ 'v 3 0 0',
424
+ 'usemtl RedMat',
425
+ 'g A',
426
+ 'f 1 2 3',
427
+ 'g B',
428
+ 'f 4 5 6',
429
+ ].join('\n');
430
+
431
+ const roots = getNodeChildren(createScene3DFromObj(obj, lib).root);
432
+ expect(roots).toHaveLength(2);
433
+ // Group B declares no usemtl of its own; per the OBJ spec it inherits RedMat set before group A.
434
+ const groupB = roots[1] as Mesh;
435
+ const material = groupB.materials[0] as BlinnPhongMaterial;
436
+ expect(material.kind).toBe(BlinnPhongMaterialKind);
437
+ expect(material.diffuse).toBe(0xff0000ff);
438
+ });
439
+
440
+ it('handles faces before any group or object (top-level geometry)', () => {
441
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3'].join('\n');
442
+
443
+ const scene = createScene3DFromObj(obj);
444
+ // Mesh should be a direct child of scene (no wrapper group).
445
+ const children = getNodeChildren(scene.root);
446
+ expect(children).toHaveLength(1);
447
+ expect(isMesh(children[0] as Node3D)).toBe(true);
448
+ });
449
+ });
450
+
451
+ describe('createScene3DFromObj animations', () => {
452
+ it('carries no animations (OBJ has none)', () => {
453
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3'].join('\n');
454
+ expect(Object.keys(createScene3DFromObj(obj).animations)).toHaveLength(0);
455
+ });
456
+ });
457
+
458
+ describe('obj diagnostic crumb coverage', () => {
459
+ it.each(OBJ_MALFORMED_CASES)(
460
+ 'records $kind (reason $reason) as a Drop from parseObj',
461
+ ({ obj, kind, line, reason }) => {
462
+ const diagnostics: ImportDiagnostic[] = [];
463
+ createScene3DFromObj(obj, undefined, diagnostics);
464
+ expect(diagnostics).toHaveLength(1);
465
+ const crumb = expectOneCrumb(diagnostics, kind);
466
+ expect(crumb.severity).toBe('Drop');
467
+ expect(crumb.origin).toBe('parseObj');
468
+ expect(crumb.detail?.firstLine).toBe(line);
469
+ expect(crumb.detail?.reason).toBe(reason);
470
+ expect(crumb.detail?.count).toBe(1);
471
+ },
472
+ );
473
+
474
+ it('records face-vertex-invalid (Drop, parseObj) for a zero/non-numeric face index token', () => {
475
+ // Index 0 is invalid in OBJ (1-based); the token is dropped and the face vertex loop breaks.
476
+ const diagnostics: ImportDiagnostic[] = [];
477
+ createScene3DFromObj('v 0 0 0\nf 0 1 2\n', undefined, diagnostics);
478
+ expect(diagnostics).toHaveLength(1);
479
+ const crumb = expectOneCrumb(diagnostics, 'obj.face-vertex-invalid');
480
+ expect(crumb.severity).toBe('Drop');
481
+ expect(crumb.origin).toBe('parseObj');
482
+ expect(crumb.detail?.firstLine).toBe(2);
483
+ expect(crumb.detail?.firstToken).toBe('0');
484
+ expect(crumb.detail?.count).toBe(1);
485
+ });
486
+
487
+ it('records uv-index-invalid (Recover, parseObj) — the vertex still emits without that uv', () => {
488
+ // One vt (index 1); the face references uv index 9. The vertex is kept (uv falls back), so Recover.
489
+ const diagnostics: ImportDiagnostic[] = [];
490
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nf 1/1 2/9 3/1\n', undefined, diagnostics);
491
+ expect(diagnostics).toHaveLength(1);
492
+ const crumb = expectOneCrumb(diagnostics, 'obj.uv-index-invalid');
493
+ expect(crumb.severity).toBe('Recover');
494
+ expect(crumb.origin).toBe('parseObj');
495
+ expect(crumb.detail?.firstLine).toBe(5);
496
+ expect(crumb.detail?.firstToken).toBe('9');
497
+ expect(crumb.detail?.count).toBe(1);
498
+ });
499
+
500
+ it('records uv-index-invalid (Recover, parseObj) for a NON-NUMERIC uv token — previously a silent drop', () => {
501
+ const diagnostics: ImportDiagnostic[] = [];
502
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nf 1/1 2/x 3/1\n', undefined, diagnostics);
503
+ expect(diagnostics).toHaveLength(1);
504
+ const crumb = expectOneCrumb(diagnostics, 'obj.uv-index-invalid');
505
+ expect(crumb.severity).toBe('Recover');
506
+ expect(crumb.origin).toBe('parseObj');
507
+ expect(crumb.detail?.firstToken).toBe('x');
508
+ expect(crumb.detail?.count).toBe(1);
509
+ });
510
+
511
+ it('records normal-index-invalid (Recover, parseObj) — the vertex still emits without that normal', () => {
512
+ const diagnostics: ImportDiagnostic[] = [];
513
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1//1 2//9 3//1\n', undefined, diagnostics);
514
+ expect(diagnostics).toHaveLength(1);
515
+ const crumb = expectOneCrumb(diagnostics, 'obj.normal-index-invalid');
516
+ expect(crumb.severity).toBe('Recover');
517
+ expect(crumb.origin).toBe('parseObj');
518
+ expect(crumb.detail?.firstLine).toBe(5);
519
+ expect(crumb.detail?.firstToken).toBe('9');
520
+ expect(crumb.detail?.count).toBe(1);
521
+ });
522
+
523
+ it('records normal-index-invalid (Recover, parseObj) for a NON-NUMERIC normal token — previously a silent drop', () => {
524
+ const diagnostics: ImportDiagnostic[] = [];
525
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nvn 0 0 1\nf 1//1 2//x 3//1\n', undefined, diagnostics);
526
+ expect(diagnostics).toHaveLength(1);
527
+ const crumb = expectOneCrumb(diagnostics, 'obj.normal-index-invalid');
528
+ expect(crumb.origin).toBe('parseObj');
529
+ expect(crumb.detail?.firstToken).toBe('x');
530
+ expect(crumb.detail?.count).toBe(1);
531
+ });
532
+
533
+ it('aggregates repeated malformed lines into ONE crumb with the total count and the first line', () => {
534
+ // Three non-numeric `v` lines — the collector contract requires ONE aggregated crumb (count 3), not three.
535
+ const diagnostics: ImportDiagnostic[] = [];
536
+ createScene3DFromObj('v a b c\nv 0 0 0\nv d e f\nv g h i\n', undefined, diagnostics);
537
+ expect(diagnostics).toHaveLength(1);
538
+ const crumb = expectOneCrumb(diagnostics, 'obj.vertex-malformed');
539
+ expect(crumb.detail?.reason).toBe('non-numeric');
540
+ expect(crumb.detail?.firstLine).toBe(1);
541
+ expect(crumb.detail?.count).toBe(3);
542
+ });
543
+
544
+ it('aggregates a multi-invalid N-gon face into ONE uv-index crumb, not one per token', () => {
545
+ // The regression review flagged: f 1/9 2/8 3/7 has three out-of-range uv refs; a per-token report would
546
+ // emit three crumbs. It must aggregate to ONE with count 3 and the first offending index.
547
+ const diagnostics: ImportDiagnostic[] = [];
548
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nf 1/9 2/8 3/7\n', undefined, diagnostics);
549
+ expect(diagnostics).toHaveLength(1);
550
+ const crumb = expectOneCrumb(diagnostics, 'obj.uv-index-invalid');
551
+ expect(crumb.severity).toBe('Recover');
552
+ expect(crumb.origin).toBe('parseObj');
553
+ expect(crumb.detail?.firstLine).toBe(5);
554
+ expect(crumb.detail?.firstToken).toBe('9');
555
+ expect(crumb.detail?.count).toBe(3);
556
+ });
557
+
558
+ it('records material-missing (Drop, resolveObjMaterial) when a usemtl name is absent from a supplied library', () => {
559
+ const library = parseObjMaterialLibrary('newmtl Present\nKd 1 0 0\n');
560
+ const diagnostics: ImportDiagnostic[] = [];
561
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Absent\nf 1 2 3\n', library, diagnostics);
562
+ expect(diagnostics).toHaveLength(1);
563
+ const crumb = expectOneCrumb(diagnostics, 'obj.material-missing');
564
+ expect(crumb.severity).toBe('Drop');
565
+ expect(crumb.origin).toBe('resolveObjMaterial');
566
+ expect(crumb.detail?.name).toBe('Absent');
567
+ });
568
+
569
+ it('reports material-missing once per distinct name (cache-deduped) and stays quiet with no library', () => {
570
+ // Two groups both referencing the same absent name → ONE crumb: the name cache dedupes the drop.
571
+ const library = parseObjMaterialLibrary('newmtl Present\n');
572
+ const deduped: ImportDiagnostic[] = [];
573
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Absent\ng A\nf 1 2 3\ng B\nf 1 2 3\n', library, deduped);
574
+ expect(deduped.filter((d) => d.kind === 'obj.material-missing')).toHaveLength(1);
575
+
576
+ // No library supplied is the documented intentional path — an unknown usemtl records nothing.
577
+ const noLibrary: ImportDiagnostic[] = [];
578
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nusemtl Absent\nf 1 2 3\n', undefined, noLibrary);
579
+ expect(noLibrary).toHaveLength(0);
580
+ });
581
+
582
+ // A BARE recognized geometry directive (no arguments at all) is an implicit malformed no-op the old code
583
+ // skipped silently. Probe each: the announced vertex/normal/uv/face still records a drop.
584
+ it.each([
585
+ { kind: 'obj.vertex-malformed', obj: 'v\n', reason: 'too-few-components' },
586
+ { kind: 'obj.normal-malformed', obj: 'vn\n', reason: 'too-few-components' },
587
+ { kind: 'obj.uv-malformed', obj: 'vt\n', reason: 'too-few-components' },
588
+ ])('records $kind (Drop, parseObj) for a bare $kind directive', ({ kind, obj, reason }) => {
589
+ const diagnostics: ImportDiagnostic[] = [];
590
+ createScene3DFromObj(obj, undefined, diagnostics);
591
+ expect(diagnostics).toHaveLength(1);
592
+ const crumb = expectOneCrumb(diagnostics, kind);
593
+ expect(crumb.severity).toBe('Drop');
594
+ expect(crumb.origin).toBe('parseObj');
595
+ expect(crumb.detail?.firstLine).toBe(1);
596
+ expect(crumb.detail?.reason).toBe(reason);
597
+ expect(crumb.detail?.count).toBe(1);
598
+ });
599
+
600
+ it('records face-too-few-vertices (Drop, parseObj) for a bare f directive', () => {
601
+ const diagnostics: ImportDiagnostic[] = [];
602
+ createScene3DFromObj('f\n', undefined, diagnostics);
603
+ expect(diagnostics).toHaveLength(1);
604
+ const crumb = expectOneCrumb(diagnostics, 'obj.face-too-few-vertices');
605
+ expect(crumb.severity).toBe('Drop');
606
+ expect(crumb.origin).toBe('parseObj');
607
+ expect(crumb.detail?.firstLine).toBe(1);
608
+ expect(crumb.detail?.count).toBe(1);
609
+ });
610
+
611
+ it('records obj.directive-unsupported (Skip, parseObj) for a recognized-but-unmodeled directive (s)', () => {
612
+ const diagnostics: ImportDiagnostic[] = [];
613
+ createScene3DFromObj('s 1\n', undefined, diagnostics);
614
+ const crumb = expectOneCrumb(diagnostics, 'obj.directive-unsupported');
615
+ expect(crumb.severity).toBe('Skip');
616
+ expect(crumb.origin).toBe('parseObj');
617
+ expect(crumb.detail?.directive).toBe('s');
618
+ expect(crumb.detail?.count).toBe(1);
619
+ });
620
+
621
+ it('records no diagnostics for a well-formed OBJ even with a collector engaged', () => {
622
+ const diagnostics: ImportDiagnostic[] = [];
623
+ createScene3DFromObj('v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n', undefined, diagnostics);
624
+ expect(diagnostics).toHaveLength(0);
625
+ });
626
+ });
627
+
628
+ // (source, kind, severity, origin, detail) → every remaining OBJ diagnostic site, one row per emitting
629
+ // variant, complementing the three converted createScene3DFromObj cases above. Each locks the full crumb.
630
+ const OBJ_MALFORMED_CASES: Array<{ obj: string; kind: string; line: number; reason: string }> = [
631
+ { obj: 'v 0 0\n', kind: 'obj.vertex-malformed', line: 1, reason: 'too-few-components' },
632
+ { obj: 'vn 0 0\n', kind: 'obj.normal-malformed', line: 1, reason: 'too-few-components' },
633
+ { obj: 'vn a b c\n', kind: 'obj.normal-malformed', line: 1, reason: 'non-numeric' },
634
+ { obj: 'vt 0\n', kind: 'obj.uv-malformed', line: 1, reason: 'too-few-components' },
635
+ { obj: 'vt a b\n', kind: 'obj.uv-malformed', line: 1, reason: 'non-numeric' },
636
+ ];
637
+
638
+ describe('parseObj', () => {
639
+ it('decomposes each group into a document mesh node with inline geometry', () => {
640
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3'].join('\n');
641
+ const document = parseObj(obj);
642
+ expect(document.meshes).toHaveLength(1);
643
+ expect(getMeshGeometryVertexCount(document.meshes[0].geometry)).toBe(3);
644
+ expect(document.nodes[0].mesh).toBe(0);
645
+ expect(document.scenes[0].rootNodes).toEqual([0]);
646
+ });
647
+
648
+ it('registers a usemtl material into the document materials table by index', () => {
649
+ const mtl = ['newmtl red', 'Kd 1 0 0'].join('\n');
650
+ const library = parseObjMaterialLibrary(mtl);
651
+ const obj = ['usemtl red', 'v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3'].join('\n');
652
+ const document = parseObj(obj, library);
653
+ expect(document.materials).toHaveLength(1);
654
+ expect((document.materials[0] as BlinnPhongMaterial).name).toBe('red');
655
+ expect(document.meshes[0].materials).toEqual([0]);
656
+ });
657
+
658
+ it('records each shared MTL image identity once while retaining separate Texture entities', () => {
659
+ const library = parseObjMaterialLibrary(
660
+ ['newmtl first', 'map_Kd shared.png', 'newmtl second', 'map_Kd shared.png'].join('\n'),
661
+ );
662
+ const obj = [
663
+ 'v 0 0 0',
664
+ 'v 1 0 0',
665
+ 'v 0 1 0',
666
+ 'usemtl first',
667
+ 'g first',
668
+ 'f 1 2 3',
669
+ 'usemtl second',
670
+ 'g second',
671
+ 'f 1 2 3',
672
+ ].join('\n');
673
+ const document = parseObj(obj, library);
674
+ const first = document.materials[0] as BlinnPhongMaterial;
675
+ const second = document.materials[1] as BlinnPhongMaterial;
676
+
677
+ expect(document.resources).toHaveLength(1);
678
+ expect((document.resources[0] as ExternalImageResourceReference).uri).toBe('shared.png');
679
+ expect(first.diffuseMap).not.toBe(second.diffuseMap);
680
+ expect(first.diffuseMap!.resource).toBe(document.resources[0]);
681
+ expect(second.diffuseMap!.resource).toBe(document.resources[0]);
682
+ });
683
+
684
+ it('uses a -1 material index for an unmaterialed subset', () => {
685
+ const obj = ['v 0 0 0', 'v 1 0 0', 'v 0 1 0', 'f 1 2 3'].join('\n');
686
+ const document = parseObj(obj);
687
+ expect(document.materials).toHaveLength(0);
688
+ expect(document.meshes[0].materials).toEqual([-1]);
689
+ });
690
+ });