@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,1089 @@
|
|
|
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, ImportDiagnosticSeverity } from '@flighthq/types';
|
|
18
|
+
import {
|
|
19
|
+
THREE_DS_CHUNK_HEADER_BYTES,
|
|
20
|
+
THREE_DS_COLOR_BYTE,
|
|
21
|
+
THREE_DS_COLOR_FLOAT,
|
|
22
|
+
THREE_DS_EDITOR,
|
|
23
|
+
THREE_DS_FACE_MATERIAL,
|
|
24
|
+
THREE_DS_FACES,
|
|
25
|
+
THREE_DS_MAIN,
|
|
26
|
+
THREE_DS_MATERIAL,
|
|
27
|
+
THREE_DS_MATERIAL_BUMP_MAP,
|
|
28
|
+
THREE_DS_MATERIAL_DIFFUSE,
|
|
29
|
+
THREE_DS_MATERIAL_NAME,
|
|
30
|
+
THREE_DS_MATERIAL_SHININESS,
|
|
31
|
+
THREE_DS_MATERIAL_SPECULAR,
|
|
32
|
+
THREE_DS_MATERIAL_TEXTURE_FILENAME,
|
|
33
|
+
THREE_DS_MATERIAL_TEXTURE_MAP,
|
|
34
|
+
THREE_DS_MATERIAL_TRANSPARENCY,
|
|
35
|
+
THREE_DS_OBJECT,
|
|
36
|
+
THREE_DS_PERCENT_INT,
|
|
37
|
+
THREE_DS_SMOOTH_GROUP,
|
|
38
|
+
THREE_DS_TRIMESH,
|
|
39
|
+
THREE_DS_UV_COORDS,
|
|
40
|
+
THREE_DS_VERTICES,
|
|
41
|
+
} from '@flighthq/types';
|
|
42
|
+
|
|
43
|
+
import { createScene3DFrom3ds, parse3ds } from './threeDsParse';
|
|
44
|
+
|
|
45
|
+
// Builds a minimal valid 3DS binary from helper functions. The 3DS format is a recursive chunk tree:
|
|
46
|
+
// each chunk has a uint16 ID + uint32 length (including the 6-byte header) + payload.
|
|
47
|
+
|
|
48
|
+
function writeChunk(id: number, payload: Uint8Array): Uint8Array {
|
|
49
|
+
const total = THREE_DS_CHUNK_HEADER_BYTES + payload.byteLength;
|
|
50
|
+
const out = new Uint8Array(total);
|
|
51
|
+
const view = new DataView(out.buffer);
|
|
52
|
+
view.setUint16(0, id, true);
|
|
53
|
+
view.setUint32(2, total, true);
|
|
54
|
+
out.set(payload, THREE_DS_CHUNK_HEADER_BYTES);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
|
59
|
+
let totalLength = 0;
|
|
60
|
+
for (let i = 0; i < arrays.length; i++) totalLength += arrays[i].byteLength;
|
|
61
|
+
const result = new Uint8Array(totalLength);
|
|
62
|
+
let offset = 0;
|
|
63
|
+
for (let i = 0; i < arrays.length; i++) {
|
|
64
|
+
result.set(arrays[i], offset);
|
|
65
|
+
offset += arrays[i].byteLength;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeNullTerminatedString(s: string): Uint8Array {
|
|
71
|
+
const out = new Uint8Array(s.length + 1);
|
|
72
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
73
|
+
out[s.length] = 0;
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Builds a vertex sub-chunk (0x4110): uint16 count + count * 3 * float32 (x, y, z in Z-up).
|
|
78
|
+
function writeVertices(positions: readonly number[]): Uint8Array {
|
|
79
|
+
const count = positions.length / 3;
|
|
80
|
+
const payload = new Uint8Array(2 + count * 3 * 4);
|
|
81
|
+
const view = new DataView(payload.buffer);
|
|
82
|
+
view.setUint16(0, count, true);
|
|
83
|
+
let offset = 2;
|
|
84
|
+
for (let i = 0; i < positions.length; i++) {
|
|
85
|
+
view.setFloat32(offset, positions[i], true);
|
|
86
|
+
offset += 4;
|
|
87
|
+
}
|
|
88
|
+
return writeChunk(THREE_DS_VERTICES, payload);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Builds a face sub-chunk (0x4120): uint16 count + count * 4 * uint16 (v0, v1, v2, flags).
|
|
92
|
+
function writeFaces(indices: readonly number[]): Uint8Array {
|
|
93
|
+
const count = indices.length / 3;
|
|
94
|
+
const payload = new Uint8Array(2 + count * 4 * 2);
|
|
95
|
+
const view = new DataView(payload.buffer);
|
|
96
|
+
view.setUint16(0, count, true);
|
|
97
|
+
let offset = 2;
|
|
98
|
+
for (let i = 0; i < count; i++) {
|
|
99
|
+
view.setUint16(offset, indices[i * 3], true);
|
|
100
|
+
view.setUint16(offset + 2, indices[i * 3 + 1], true);
|
|
101
|
+
view.setUint16(offset + 4, indices[i * 3 + 2], true);
|
|
102
|
+
view.setUint16(offset + 6, 0, true); // flags
|
|
103
|
+
offset += 8;
|
|
104
|
+
}
|
|
105
|
+
return writeChunk(THREE_DS_FACES, payload);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Builds a UV sub-chunk (0x4140): uint16 count + count * 2 * float32 (u, v).
|
|
109
|
+
function writeUvCoords(uvs: readonly number[]): Uint8Array {
|
|
110
|
+
const count = uvs.length / 2;
|
|
111
|
+
const payload = new Uint8Array(2 + count * 2 * 4);
|
|
112
|
+
const view = new DataView(payload.buffer);
|
|
113
|
+
view.setUint16(0, count, true);
|
|
114
|
+
let offset = 2;
|
|
115
|
+
for (let i = 0; i < uvs.length; i++) {
|
|
116
|
+
view.setFloat32(offset, uvs[i], true);
|
|
117
|
+
offset += 4;
|
|
118
|
+
}
|
|
119
|
+
return writeChunk(THREE_DS_UV_COORDS, payload);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Builds a complete 3DS file with one or more meshes.
|
|
123
|
+
function buildTriangle3ds(
|
|
124
|
+
name: string,
|
|
125
|
+
positions: readonly number[],
|
|
126
|
+
indices: readonly number[],
|
|
127
|
+
uvs?: readonly number[],
|
|
128
|
+
): Uint8Array {
|
|
129
|
+
const trimeshPayload =
|
|
130
|
+
uvs !== undefined
|
|
131
|
+
? concatBytes(writeVertices(positions), writeFaces(indices), writeUvCoords(uvs))
|
|
132
|
+
: concatBytes(writeVertices(positions), writeFaces(indices));
|
|
133
|
+
|
|
134
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, trimeshPayload);
|
|
135
|
+
const objectPayload = concatBytes(writeNullTerminatedString(name), trimesh);
|
|
136
|
+
const object = writeChunk(THREE_DS_OBJECT, objectPayload);
|
|
137
|
+
const editor = writeChunk(THREE_DS_EDITOR, object);
|
|
138
|
+
return writeChunk(THREE_DS_MAIN, editor);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildMultiMesh3ds(
|
|
142
|
+
meshes: readonly { indices: readonly number[]; name: string; positions: readonly number[] }[],
|
|
143
|
+
): Uint8Array {
|
|
144
|
+
const objects: Uint8Array[] = [];
|
|
145
|
+
for (let i = 0; i < meshes.length; i++) {
|
|
146
|
+
const m = meshes[i];
|
|
147
|
+
const trimeshPayload = concatBytes(writeVertices(m.positions), writeFaces(m.indices));
|
|
148
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, trimeshPayload);
|
|
149
|
+
const objectPayload = concatBytes(writeNullTerminatedString(m.name), trimesh);
|
|
150
|
+
objects.push(writeChunk(THREE_DS_OBJECT, objectPayload));
|
|
151
|
+
}
|
|
152
|
+
const editor = writeChunk(THREE_DS_EDITOR, concatBytes(...objects));
|
|
153
|
+
return writeChunk(THREE_DS_MAIN, editor);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Builds a COLOR_BYTE color sub-chunk (0x0011): 3 uint8 channels.
|
|
157
|
+
function writeColorByte(r: number, g: number, b: number): Uint8Array {
|
|
158
|
+
return writeChunk(THREE_DS_COLOR_BYTE, new Uint8Array([r, g, b]));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Builds a COLOR_FLOAT color sub-chunk (0x0010): 3 float32 channels in [0,1].
|
|
162
|
+
function writeColorFloat(r: number, g: number, b: number): Uint8Array {
|
|
163
|
+
const payload = new Uint8Array(12);
|
|
164
|
+
const view = new DataView(payload.buffer);
|
|
165
|
+
view.setFloat32(0, r, true);
|
|
166
|
+
view.setFloat32(4, g, true);
|
|
167
|
+
view.setFloat32(8, b, true);
|
|
168
|
+
return writeChunk(THREE_DS_COLOR_FLOAT, payload);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Builds an INT_PERCENTAGE sub-chunk (0x0030): a uint16 in [0,100].
|
|
172
|
+
function writePercentInt(percent: number): Uint8Array {
|
|
173
|
+
const payload = new Uint8Array(2);
|
|
174
|
+
new DataView(payload.buffer).setUint16(0, percent, true);
|
|
175
|
+
return writeChunk(THREE_DS_PERCENT_INT, payload);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Builds a material block (0xAFFF) with a name, diffuse/specular colors, and optional texture/shininess/
|
|
179
|
+
// transparency/bump sub-chunks. `diffuseFloat` writes the diffuse color via COLOR_FLOAT instead of COLOR_BYTE.
|
|
180
|
+
function writeMaterial(opts: {
|
|
181
|
+
bumpFilename?: string;
|
|
182
|
+
diffuse: readonly [number, number, number];
|
|
183
|
+
diffuseFloat?: boolean;
|
|
184
|
+
name: string;
|
|
185
|
+
shininessPercent?: number;
|
|
186
|
+
specular?: readonly [number, number, number];
|
|
187
|
+
textureFilename?: string;
|
|
188
|
+
transparencyPercent?: number;
|
|
189
|
+
}): Uint8Array {
|
|
190
|
+
const diffuseColor = opts.diffuseFloat ? writeColorFloat(...opts.diffuse) : writeColorByte(...opts.diffuse);
|
|
191
|
+
const parts: Uint8Array[] = [
|
|
192
|
+
writeChunk(THREE_DS_MATERIAL_NAME, writeNullTerminatedString(opts.name)),
|
|
193
|
+
writeChunk(THREE_DS_MATERIAL_DIFFUSE, diffuseColor),
|
|
194
|
+
];
|
|
195
|
+
if (opts.specular !== undefined) parts.push(writeChunk(THREE_DS_MATERIAL_SPECULAR, writeColorByte(...opts.specular)));
|
|
196
|
+
if (opts.shininessPercent !== undefined) {
|
|
197
|
+
parts.push(writeChunk(THREE_DS_MATERIAL_SHININESS, writePercentInt(opts.shininessPercent)));
|
|
198
|
+
}
|
|
199
|
+
if (opts.transparencyPercent !== undefined) {
|
|
200
|
+
parts.push(writeChunk(THREE_DS_MATERIAL_TRANSPARENCY, writePercentInt(opts.transparencyPercent)));
|
|
201
|
+
}
|
|
202
|
+
if (opts.textureFilename !== undefined) {
|
|
203
|
+
const filename = writeChunk(THREE_DS_MATERIAL_TEXTURE_FILENAME, writeNullTerminatedString(opts.textureFilename));
|
|
204
|
+
parts.push(writeChunk(THREE_DS_MATERIAL_TEXTURE_MAP, filename));
|
|
205
|
+
}
|
|
206
|
+
if (opts.bumpFilename !== undefined) {
|
|
207
|
+
const filename = writeChunk(THREE_DS_MATERIAL_TEXTURE_FILENAME, writeNullTerminatedString(opts.bumpFilename));
|
|
208
|
+
parts.push(writeChunk(THREE_DS_MATERIAL_BUMP_MAP, filename));
|
|
209
|
+
}
|
|
210
|
+
return writeChunk(THREE_DS_MATERIAL, concatBytes(...parts));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Builds a face sub-chunk (0x4120) whose face array is followed by a FACE_MATERIAL sub-chunk (0x4130)
|
|
214
|
+
// naming the material every face uses.
|
|
215
|
+
function writeFacesWithMaterial(indices: readonly number[], materialName: string): Uint8Array {
|
|
216
|
+
const count = indices.length / 3;
|
|
217
|
+
const faceArray = new Uint8Array(2 + count * 4 * 2);
|
|
218
|
+
const faceView = new DataView(faceArray.buffer);
|
|
219
|
+
faceView.setUint16(0, count, true);
|
|
220
|
+
for (let i = 0; i < count; i++) {
|
|
221
|
+
const o = 2 + i * 8;
|
|
222
|
+
faceView.setUint16(o, indices[i * 3], true);
|
|
223
|
+
faceView.setUint16(o + 2, indices[i * 3 + 1], true);
|
|
224
|
+
faceView.setUint16(o + 4, indices[i * 3 + 2], true);
|
|
225
|
+
faceView.setUint16(o + 6, 0, true); // flags
|
|
226
|
+
}
|
|
227
|
+
const faceRefs = new Uint8Array(2 + count * 2);
|
|
228
|
+
const refView = new DataView(faceRefs.buffer);
|
|
229
|
+
refView.setUint16(0, count, true);
|
|
230
|
+
for (let i = 0; i < count; i++) refView.setUint16(2 + i * 2, i, true);
|
|
231
|
+
const faceMaterial = writeChunk(
|
|
232
|
+
THREE_DS_FACE_MATERIAL,
|
|
233
|
+
concatBytes(writeNullTerminatedString(materialName), faceRefs),
|
|
234
|
+
);
|
|
235
|
+
return writeChunk(THREE_DS_FACES, concatBytes(faceArray, faceMaterial));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Builds a face sub-chunk (0x4120) followed by any number of FACE_MATERIAL (0x4130) groups and an
|
|
239
|
+
// optional SMOOTH_GROUP (0x4150) per-face bitmask list — the general form the subset/smoothing tests use.
|
|
240
|
+
function writeFacesWithGroups(
|
|
241
|
+
indices: readonly number[],
|
|
242
|
+
opts: { groups?: readonly { faces: readonly number[]; name: string }[]; smoothing?: readonly number[] } = {},
|
|
243
|
+
): Uint8Array {
|
|
244
|
+
const count = indices.length / 3;
|
|
245
|
+
const faceArray = new Uint8Array(2 + count * 4 * 2);
|
|
246
|
+
const faceView = new DataView(faceArray.buffer);
|
|
247
|
+
faceView.setUint16(0, count, true);
|
|
248
|
+
for (let i = 0; i < count; i++) {
|
|
249
|
+
const o = 2 + i * 8;
|
|
250
|
+
faceView.setUint16(o, indices[i * 3], true);
|
|
251
|
+
faceView.setUint16(o + 2, indices[i * 3 + 1], true);
|
|
252
|
+
faceView.setUint16(o + 4, indices[i * 3 + 2], true);
|
|
253
|
+
faceView.setUint16(o + 6, 0, true); // flags
|
|
254
|
+
}
|
|
255
|
+
const parts: Uint8Array[] = [faceArray];
|
|
256
|
+
for (const group of opts.groups ?? []) {
|
|
257
|
+
const refs = new Uint8Array(2 + group.faces.length * 2);
|
|
258
|
+
const refView = new DataView(refs.buffer);
|
|
259
|
+
refView.setUint16(0, group.faces.length, true);
|
|
260
|
+
group.faces.forEach((f, i) => refView.setUint16(2 + i * 2, f, true));
|
|
261
|
+
parts.push(writeChunk(THREE_DS_FACE_MATERIAL, concatBytes(writeNullTerminatedString(group.name), refs)));
|
|
262
|
+
}
|
|
263
|
+
if (opts.smoothing !== undefined) {
|
|
264
|
+
const masks = new Uint8Array(opts.smoothing.length * 4);
|
|
265
|
+
const maskView = new DataView(masks.buffer);
|
|
266
|
+
opts.smoothing.forEach((m, i) => maskView.setUint32(i * 4, m >>> 0, true));
|
|
267
|
+
parts.push(writeChunk(THREE_DS_SMOOTH_GROUP, masks));
|
|
268
|
+
}
|
|
269
|
+
return writeChunk(THREE_DS_FACES, concatBytes(...parts));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Wraps a vertex list + a prebuilt faces chunk and any material chunks into a full one-object 3DS file.
|
|
273
|
+
function buildScene3D3ds(opts: {
|
|
274
|
+
facesChunk: Uint8Array;
|
|
275
|
+
materials?: readonly Uint8Array[];
|
|
276
|
+
meshName: string;
|
|
277
|
+
positions: readonly number[];
|
|
278
|
+
}): Uint8Array {
|
|
279
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, concatBytes(writeVertices(opts.positions), opts.facesChunk));
|
|
280
|
+
const object = writeChunk(THREE_DS_OBJECT, concatBytes(writeNullTerminatedString(opts.meshName), trimesh));
|
|
281
|
+
const editor = writeChunk(THREE_DS_EDITOR, concatBytes(...(opts.materials ?? []), object));
|
|
282
|
+
return writeChunk(THREE_DS_MAIN, editor);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Builds a 3DS file with one material and one mesh whose faces reference it by name.
|
|
286
|
+
function buildMaterialScene3D3ds(opts: {
|
|
287
|
+
faceMaterialName: string;
|
|
288
|
+
indices: readonly number[];
|
|
289
|
+
material: Uint8Array;
|
|
290
|
+
meshName: string;
|
|
291
|
+
positions: readonly number[];
|
|
292
|
+
}): Uint8Array {
|
|
293
|
+
const trimeshPayload = concatBytes(
|
|
294
|
+
writeVertices(opts.positions),
|
|
295
|
+
writeFacesWithMaterial(opts.indices, opts.faceMaterialName),
|
|
296
|
+
);
|
|
297
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, trimeshPayload);
|
|
298
|
+
const object = writeChunk(THREE_DS_OBJECT, concatBytes(writeNullTerminatedString(opts.meshName), trimesh));
|
|
299
|
+
const editor = writeChunk(THREE_DS_EDITOR, concatBytes(opts.material, object));
|
|
300
|
+
return writeChunk(THREE_DS_MAIN, editor);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function findDiagnostic(diagnostics: readonly ImportDiagnostic[], kind: string): ImportDiagnostic | undefined {
|
|
304
|
+
return diagnostics.find((diagnostic) => diagnostic.kind === kind);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Wraps a raw trimesh payload (any sub-chunks) into a full one-object 3DS file so parse3ds reaches it.
|
|
308
|
+
function wrapTrimesh(name: string, trimeshPayload: Uint8Array): Uint8Array {
|
|
309
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, trimeshPayload);
|
|
310
|
+
const object = writeChunk(THREE_DS_OBJECT, concatBytes(writeNullTerminatedString(name), trimesh));
|
|
311
|
+
const editor = writeChunk(THREE_DS_EDITOR, object);
|
|
312
|
+
return writeChunk(THREE_DS_MAIN, editor);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// A VERTICES sub-chunk whose declared count does not match the payload — for truncation coverage.
|
|
316
|
+
function rawVerticesChunk(declaredCount: number, floats: readonly number[]): Uint8Array {
|
|
317
|
+
const payload = new Uint8Array(2 + floats.length * 4);
|
|
318
|
+
const view = new DataView(payload.buffer);
|
|
319
|
+
view.setUint16(0, declaredCount, true);
|
|
320
|
+
floats.forEach((f, i) => view.setFloat32(2 + i * 4, f, true));
|
|
321
|
+
return writeChunk(THREE_DS_VERTICES, payload);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A FACES sub-chunk whose declared count does not match the payload — for truncation coverage.
|
|
325
|
+
function rawFacesChunk(declaredCount: number, indexBytes: number): Uint8Array {
|
|
326
|
+
const payload = new Uint8Array(2 + indexBytes);
|
|
327
|
+
new DataView(payload.buffer).setUint16(0, declaredCount, true);
|
|
328
|
+
return writeChunk(THREE_DS_FACES, payload);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// A UV_COORDS sub-chunk whose declared count does not match the payload — for truncation coverage.
|
|
332
|
+
function rawUvChunk(declaredCount: number, floats: readonly number[]): Uint8Array {
|
|
333
|
+
const payload = new Uint8Array(2 + floats.length * 4);
|
|
334
|
+
const view = new DataView(payload.buffer);
|
|
335
|
+
view.setUint16(0, declaredCount, true);
|
|
336
|
+
floats.forEach((f, i) => view.setFloat32(2 + i * 4, f, true));
|
|
337
|
+
return writeChunk(THREE_DS_UV_COORDS, payload);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Overwrites a chunk's uint32 length field (at offset+2) in place — for boundary-exceed coverage.
|
|
341
|
+
function tamperChunkLength(chunk: Uint8Array, newLength: number): Uint8Array {
|
|
342
|
+
const copy = chunk.slice();
|
|
343
|
+
new DataView(copy.buffer).setUint32(2, newLength, true);
|
|
344
|
+
return copy;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
describe('createScene3DFrom3ds', () => {
|
|
348
|
+
it('decodes a material to BlinnPhong and attaches it to the mesh that references it by name', () => {
|
|
349
|
+
const material = writeMaterial({
|
|
350
|
+
diffuse: [204, 102, 51],
|
|
351
|
+
name: 'Skin',
|
|
352
|
+
specular: [255, 255, 255],
|
|
353
|
+
textureFilename: 'skin.png',
|
|
354
|
+
});
|
|
355
|
+
const scene = createScene3DFrom3ds(
|
|
356
|
+
buildMaterialScene3D3ds({
|
|
357
|
+
faceMaterialName: 'Skin',
|
|
358
|
+
indices: [0, 1, 2],
|
|
359
|
+
material,
|
|
360
|
+
meshName: 'Cube',
|
|
361
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const mesh = getNodeChildren(scene.root)[0] as Mesh;
|
|
366
|
+
expect(mesh.materials).toHaveLength(1);
|
|
367
|
+
const mat = mesh.materials[0] as BlinnPhongMaterial;
|
|
368
|
+
expect(mat.kind).toBe(BlinnPhongMaterialKind);
|
|
369
|
+
expect(mat.diffuse).toBe(0xcc6633ff); // 204,102,51 → opaque
|
|
370
|
+
expect(mat.specular).toBe(0xffffffff);
|
|
371
|
+
expect(mat.name).toBe('Skin'); // 3DS material chunk name preserved as the authored identity
|
|
372
|
+
// Texture filename is referenced, not decoded.
|
|
373
|
+
expect((mat.diffuseMap!.resource as ExternalImageResourceReference).uri).toBe('skin.png');
|
|
374
|
+
expect(mat.diffuseMap!.image).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Builds a one-material scene from a material chunk and returns that material as a BlinnPhongMaterial.
|
|
378
|
+
const materialFrom3ds = (material: Uint8Array): BlinnPhongMaterial => {
|
|
379
|
+
const scene = createScene3DFrom3ds(
|
|
380
|
+
buildMaterialScene3D3ds({
|
|
381
|
+
faceMaterialName: 'M',
|
|
382
|
+
indices: [0, 1, 2],
|
|
383
|
+
material,
|
|
384
|
+
meshName: 'Cube',
|
|
385
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
return (getNodeChildren(scene.root)[0] as Mesh).materials[0] as BlinnPhongMaterial;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
it('maps the shininess percentage to a Blinn-Phong specular exponent', () => {
|
|
392
|
+
const mat = materialFrom3ds(writeMaterial({ diffuse: [128, 128, 128], name: 'M', shininessPercent: 50 }));
|
|
393
|
+
expect(mat.shininess).toBeCloseTo(64); // 50% → 0.5 × 128
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('passes an explicit 0% shininess through rather than reverting to the material default', () => {
|
|
397
|
+
// An explicit MAT_SHININESS of 0 is a valid matte value; it must survive as 0, not become the
|
|
398
|
+
// createBlinnPhongMaterial default.
|
|
399
|
+
const explicit = materialFrom3ds(writeMaterial({ diffuse: [128, 128, 128], name: 'M', shininessPercent: 0 }));
|
|
400
|
+
expect(explicit.shininess).toBe(0);
|
|
401
|
+
// An absent MAT_SHININESS leaves the material's default in place (non-zero).
|
|
402
|
+
const absent = materialFrom3ds(writeMaterial({ diffuse: [128, 128, 128], name: 'M' }));
|
|
403
|
+
expect(absent.shininess).not.toBe(0);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('does not bind the legacy bump (height) map to normalMap', () => {
|
|
407
|
+
// MAT_BUMPMAP is a grayscale height map, not a tangent-space normal map — it must not land in
|
|
408
|
+
// normalMap (which shaders sample as RGB*2-1 normals). Parsed as metadata only.
|
|
409
|
+
const mat = materialFrom3ds(writeMaterial({ bumpFilename: 'bump.png', diffuse: [128, 128, 128], name: 'M' }));
|
|
410
|
+
expect(mat.normalMap).toBeNull();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('folds transparency into the diffuse alpha and a blend alphaMode', () => {
|
|
414
|
+
const mat = materialFrom3ds(writeMaterial({ diffuse: [255, 0, 0], name: 'M', transparencyPercent: 25 }));
|
|
415
|
+
// 25% transparent → 0.75 opacity → alpha 0xBF; diffuse RGB unchanged.
|
|
416
|
+
expect(mat.diffuse).toBe(0xff0000bf);
|
|
417
|
+
expect(mat.alphaMode).toBe('blend');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('leaves a fully-opaque material at alphaMode opaque', () => {
|
|
421
|
+
const mat = materialFrom3ds(writeMaterial({ diffuse: [255, 0, 0], name: 'M' }));
|
|
422
|
+
expect(mat.diffuse).toBe(0xff0000ff);
|
|
423
|
+
expect(mat.alphaMode).not.toBe('blend');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('reads a diffuse color from the COL_FLOAT sub-chunk', () => {
|
|
427
|
+
const mat = materialFrom3ds(writeMaterial({ diffuse: [1, 0.4, 0.2], diffuseFloat: true, name: 'M' }));
|
|
428
|
+
// 1, 0.4, 0.2 → 255, 102, 51 → 0xff6633ff
|
|
429
|
+
expect(mat.diffuse).toBe(0xff6633ff);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('leaves a mesh unmaterialed when it references no material', () => {
|
|
433
|
+
const mesh = getNodeChildren(
|
|
434
|
+
createScene3DFrom3ds(buildTriangle3ds('Tri', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2])).root,
|
|
435
|
+
)[0] as Mesh;
|
|
436
|
+
// A mesh with no FACE_MATERIAL group is one default subset with a null (default-material) binding.
|
|
437
|
+
expect(mesh.materials).toEqual([null]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('splits a multi-material mesh into one MeshSubset per FACE_MATERIAL group', () => {
|
|
441
|
+
const red = writeMaterial({ diffuse: [255, 0, 0], name: 'Red' });
|
|
442
|
+
const blue = writeMaterial({ diffuse: [0, 0, 255], name: 'Blue' });
|
|
443
|
+
// A quad (two triangles): face 0 → Red, face 1 → Blue.
|
|
444
|
+
const facesChunk = writeFacesWithGroups([0, 1, 2, 0, 2, 3], {
|
|
445
|
+
groups: [
|
|
446
|
+
{ faces: [0], name: 'Red' },
|
|
447
|
+
{ faces: [1], name: 'Blue' },
|
|
448
|
+
],
|
|
449
|
+
});
|
|
450
|
+
const scene = createScene3DFrom3ds(
|
|
451
|
+
buildScene3D3ds({
|
|
452
|
+
facesChunk,
|
|
453
|
+
materials: [red, blue],
|
|
454
|
+
meshName: 'Quad',
|
|
455
|
+
positions: [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0],
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const mesh = getNodeChildren(scene.root)[0] as Mesh;
|
|
460
|
+
// Two contiguous subsets, one per material, each covering its triangle's 3 indices.
|
|
461
|
+
expect(mesh.geometry.subsets).toEqual([
|
|
462
|
+
{ indexCount: 3, indexOffset: 0 },
|
|
463
|
+
{ indexCount: 3, indexOffset: 3 },
|
|
464
|
+
]);
|
|
465
|
+
expect(mesh.materials).toHaveLength(2);
|
|
466
|
+
expect((mesh.materials[0] as BlinnPhongMaterial).name).toBe('Red');
|
|
467
|
+
expect((mesh.materials[1] as BlinnPhongMaterial).name).toBe('Blue');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('gives faces belonging to no material group a trailing default subset', () => {
|
|
471
|
+
const red = writeMaterial({ diffuse: [255, 0, 0], name: 'Red' });
|
|
472
|
+
// Face 0 → Red; face 1 is in no group.
|
|
473
|
+
const facesChunk = writeFacesWithGroups([0, 1, 2, 0, 2, 3], { groups: [{ faces: [0], name: 'Red' }] });
|
|
474
|
+
const scene = createScene3DFrom3ds(
|
|
475
|
+
buildScene3D3ds({
|
|
476
|
+
facesChunk,
|
|
477
|
+
materials: [red],
|
|
478
|
+
meshName: 'Quad',
|
|
479
|
+
positions: [0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0],
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const mesh = getNodeChildren(scene.root)[0] as Mesh;
|
|
484
|
+
expect(mesh.geometry.subsets).toHaveLength(2);
|
|
485
|
+
// The Red subset then the unassigned faces as a null-material default subset.
|
|
486
|
+
expect((mesh.materials[0] as BlinnPhongMaterial).name).toBe('Red');
|
|
487
|
+
expect(mesh.materials[1]).toBeNull();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('splits shared vertices across smoothing groups so a crease stays hard', () => {
|
|
491
|
+
// Two perpendicular triangles sharing edge v1–v2 (a 90° fold): face 0 in the z=0 plane, face 1 in x=1.
|
|
492
|
+
const positions = [0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1];
|
|
493
|
+
const indices = [0, 1, 2, 1, 3, 2];
|
|
494
|
+
|
|
495
|
+
// Same smoothing group → the shared edge is smoothed, so v1/v2 stay merged: 4 output vertices.
|
|
496
|
+
const smooth = createScene3DFrom3ds(
|
|
497
|
+
buildScene3D3ds({
|
|
498
|
+
facesChunk: writeFacesWithGroups(indices, { smoothing: [1, 1] }),
|
|
499
|
+
meshName: 'Fold',
|
|
500
|
+
positions,
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
expect(getMeshGeometryVertexCount((getNodeChildren(smooth.root)[0] as Mesh).geometry)).toBe(4);
|
|
504
|
+
|
|
505
|
+
// Different smoothing groups → the shared edge is a hard crease, so v1/v2 split: 6 output vertices.
|
|
506
|
+
const hard = createScene3DFrom3ds(
|
|
507
|
+
buildScene3D3ds({
|
|
508
|
+
facesChunk: writeFacesWithGroups(indices, { smoothing: [1, 2] }),
|
|
509
|
+
meshName: 'Fold',
|
|
510
|
+
positions,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
const hardGeom = (getNodeChildren(hard.root)[0] as Mesh).geometry;
|
|
514
|
+
expect(getMeshGeometryVertexCount(hardGeom)).toBe(6);
|
|
515
|
+
|
|
516
|
+
// Flat shading: face 0's corner (vertex 0) and face 1's corner (vertex 3) carry their own, differing
|
|
517
|
+
// face normals — the perpendicular faces do not blend into a rounded edge.
|
|
518
|
+
const n0 = { x: 0, y: 0, z: 0 };
|
|
519
|
+
const n3 = { x: 0, y: 0, z: 0 };
|
|
520
|
+
getMeshGeometryVertexNormal(n0, hardGeom, 0);
|
|
521
|
+
getMeshGeometryVertexNormal(n3, hardGeom, 3);
|
|
522
|
+
expect([n0.x, n0.y, n0.z]).not.toEqual([n3.x, n3.y, n3.z]);
|
|
523
|
+
expect(Math.hypot(n0.x, n0.y, n0.z)).toBeCloseTo(1);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('smooths every shared vertex when the mesh carries no smoothing chunk', () => {
|
|
527
|
+
// The same fold without a SMOOTH_GROUP chunk smooths across the crease (legacy behavior): 4 vertices.
|
|
528
|
+
const positions = [0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 0, 1];
|
|
529
|
+
const scene = createScene3DFrom3ds(
|
|
530
|
+
buildScene3D3ds({ facesChunk: writeFacesWithGroups([0, 1, 2, 1, 3, 2]), meshName: 'Fold', positions }),
|
|
531
|
+
);
|
|
532
|
+
expect(getMeshGeometryVertexCount((getNodeChildren(scene.root)[0] as Mesh).geometry)).toBe(4);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('converts Z-up to Y-up coordinates', () => {
|
|
536
|
+
// A triangle in 3DS Z-up: (0,0,0), (1,0,0), (0,0,1) → Y-up: (0,0,0), (1,0,0), (0,1,0)
|
|
537
|
+
const bytes = buildTriangle3ds('Tri', [0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 1, 2]);
|
|
538
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
539
|
+
const children = getNodeChildren(scene.root);
|
|
540
|
+
expect(children).toHaveLength(1);
|
|
541
|
+
|
|
542
|
+
// A named 3DS object is returned as a bare Mesh carrying the name, not a transform wrapper.
|
|
543
|
+
const mesh = children[0] as Node3D;
|
|
544
|
+
expect(isMesh(mesh)).toBe(true);
|
|
545
|
+
expect(mesh.name).toBe('Tri');
|
|
546
|
+
|
|
547
|
+
const geometry = (mesh as Mesh).geometry;
|
|
548
|
+
const p = { x: 0, y: 0, z: 0 };
|
|
549
|
+
|
|
550
|
+
getMeshGeometryVertexPosition(p, geometry, 0);
|
|
551
|
+
expect([p.x, p.y, p.z]).toEqual([0, 0, 0]);
|
|
552
|
+
|
|
553
|
+
getMeshGeometryVertexPosition(p, geometry, 1);
|
|
554
|
+
expect([p.x, p.y, p.z]).toEqual([1, 0, 0]);
|
|
555
|
+
|
|
556
|
+
// Z-up (0,0,1) → Y-up (0,1,0): the 3DS Z component becomes the Y component.
|
|
557
|
+
getMeshGeometryVertexPosition(p, geometry, 2);
|
|
558
|
+
expect([p.x, p.y, p.z]).toEqual([0, 1, 0]);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('parses a single mesh with a triangle', () => {
|
|
562
|
+
// 3DS Z-up vertices: (0,0,0), (1,0,0), (0,1,0) — note Y is the "forward" axis in Z-up.
|
|
563
|
+
const bytes = buildTriangle3ds('Triangle', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]);
|
|
564
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
565
|
+
const children = getNodeChildren(scene.root);
|
|
566
|
+
expect(children).toHaveLength(1);
|
|
567
|
+
|
|
568
|
+
const mesh = children[0] as Node3D;
|
|
569
|
+
expect(isMesh(mesh)).toBe(true);
|
|
570
|
+
expect(mesh.name).toBe('Triangle');
|
|
571
|
+
|
|
572
|
+
const geometry = (mesh as Mesh).geometry;
|
|
573
|
+
expect(getMeshGeometryVertexCount(geometry)).toBe(3);
|
|
574
|
+
expect(getMeshGeometryIndexCount(geometry)).toBe(3);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('parses multiple meshes', () => {
|
|
578
|
+
const bytes = buildMultiMesh3ds([
|
|
579
|
+
{ indices: [0, 1, 2], name: 'MeshA', positions: [0, 0, 0, 1, 0, 0, 0, 1, 0] },
|
|
580
|
+
{ indices: [0, 1, 2], name: 'MeshB', positions: [2, 0, 0, 3, 0, 0, 2, 1, 0] },
|
|
581
|
+
]);
|
|
582
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
583
|
+
const children = getNodeChildren(scene.root);
|
|
584
|
+
expect(children).toHaveLength(2);
|
|
585
|
+
|
|
586
|
+
// Each named object is a bare Mesh child of the scene, carrying its name.
|
|
587
|
+
expect(isMesh(children[0] as Node3D)).toBe(true);
|
|
588
|
+
expect((children[0] as Node3D).name).toBe('MeshA');
|
|
589
|
+
expect(isMesh(children[1] as Node3D)).toBe(true);
|
|
590
|
+
expect((children[1] as Node3D).name).toBe('MeshB');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('parses UV coordinates', () => {
|
|
594
|
+
const bytes = buildTriangle3ds('UvTri', [0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 1, 2], [0, 0, 1, 0, 0.5, 1]);
|
|
595
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
596
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
597
|
+
|
|
598
|
+
const uv = { x: 0, y: 0 };
|
|
599
|
+
getMeshGeometryVertexUv0(uv, geometry, 0);
|
|
600
|
+
expect([uv.x, uv.y]).toEqual([0, 1]);
|
|
601
|
+
getMeshGeometryVertexUv0(uv, geometry, 1);
|
|
602
|
+
expect([uv.x, uv.y]).toEqual([1, 1]);
|
|
603
|
+
getMeshGeometryVertexUv0(uv, geometry, 2);
|
|
604
|
+
expect([uv.x, uv.y]).toEqual([0.5, 0]);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('computes face normals facing outward, not inverted', () => {
|
|
608
|
+
// A flat triangle wound CCW in the Z-up XY plane: its outward normal is +Z in Z-up, which
|
|
609
|
+
// must become +Y after the -90°-about-X rotation. A reflection-based conversion would flip the
|
|
610
|
+
// winding and produce -Y — the "holes into the model" / inverted-lighting symptom — so this
|
|
611
|
+
// asserts the sign, not just the magnitude.
|
|
612
|
+
const bytes = buildTriangle3ds('NormalTri', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]);
|
|
613
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
614
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
615
|
+
|
|
616
|
+
const n = { x: 0, y: 0, z: 0 };
|
|
617
|
+
getMeshGeometryVertexNormal(n, geometry, 0);
|
|
618
|
+
expect(n.y).toBeGreaterThan(0.9);
|
|
619
|
+
expect(Math.abs(n.x)).toBeLessThan(0.1);
|
|
620
|
+
expect(Math.abs(n.z)).toBeLessThan(0.1);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('converts Z-up to Y-up by rotation, not reflection', () => {
|
|
624
|
+
// A vertex with a non-zero Y (forward) component distinguishes a rotation from a mirror: the
|
|
625
|
+
// rotation (x, y, z) → (x, z, -y) negates the forward axis, whereas a Y↔Z swap would keep it
|
|
626
|
+
// positive and mirror the model. Vertex (0, 1, 0) in 3DS Z-up must land at (0, 0, -1).
|
|
627
|
+
const bytes = buildTriangle3ds('RotTri', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]);
|
|
628
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
629
|
+
const geometry = (getNodeChildren(scene.root)[0] as Mesh).geometry;
|
|
630
|
+
|
|
631
|
+
const p = { x: 0, y: 0, z: 0 };
|
|
632
|
+
getMeshGeometryVertexPosition(p, geometry, 2);
|
|
633
|
+
expect([p.x, p.y, p.z]).toEqual([0, 0, -1]);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('returns an empty scene for empty input', () => {
|
|
637
|
+
const scene = createScene3DFrom3ds(new Uint8Array(0));
|
|
638
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('rejects and reports 3ds.input-too-small on empty input', () => {
|
|
642
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
643
|
+
createScene3DFrom3ds(new Uint8Array(0), diagnostics);
|
|
644
|
+
expect(diagnostics).toHaveLength(1);
|
|
645
|
+
expect(diagnostics[0].kind).toBe('3ds.input-too-small');
|
|
646
|
+
expect(diagnostics[0].severity).toBe(ImportDiagnosticSeverity.Reject);
|
|
647
|
+
expect(diagnostics[0].origin).toBe('parse3ds');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('rejects and reports 3ds.wrong-main-chunk on an invalid main chunk ID', () => {
|
|
651
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
652
|
+
const bytes = new Uint8Array(6);
|
|
653
|
+
const view = new DataView(bytes.buffer);
|
|
654
|
+
view.setUint16(0, 0x1234, true);
|
|
655
|
+
view.setUint32(2, 6, true);
|
|
656
|
+
createScene3DFrom3ds(bytes, diagnostics);
|
|
657
|
+
expect(diagnostics).toHaveLength(1);
|
|
658
|
+
expect(diagnostics[0].kind).toBe('3ds.wrong-main-chunk');
|
|
659
|
+
expect(diagnostics[0].severity).toBe(ImportDiagnosticSeverity.Reject);
|
|
660
|
+
expect(diagnostics[0].origin).toBe('parse3ds');
|
|
661
|
+
expect(diagnostics[0].detail?.foundId).toBe(0x1234);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('reports a diagnostic on truncated chunk data and returns a partial result', () => {
|
|
665
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
666
|
+
// Build a valid 3DS but truncate it.
|
|
667
|
+
const full = buildTriangle3ds('Trunc', [0, 0, 0, 1, 0, 0, 0, 0, 1], [0, 1, 2]);
|
|
668
|
+
// Cut off a significant portion of the end.
|
|
669
|
+
const truncated = full.slice(0, Math.floor(full.byteLength * 0.5));
|
|
670
|
+
createScene3DFrom3ds(truncated, diagnostics);
|
|
671
|
+
expect(diagnostics.length).toBeGreaterThan(0);
|
|
672
|
+
// Every crumb carries the top-level emitter origin and a count from its aggregation.
|
|
673
|
+
for (const diagnostic of diagnostics) {
|
|
674
|
+
expect(diagnostic.origin).toBe('parse3ds');
|
|
675
|
+
expect(diagnostic.detail?.count).toBeGreaterThanOrEqual(1);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('drops and reports 3ds.mesh-missing-geometry for a mesh with no faces sub-chunk', () => {
|
|
680
|
+
// Build a trimesh chunk with vertices but no faces sub-chunk.
|
|
681
|
+
const trimeshPayload = writeVertices([0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
|
682
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, trimeshPayload);
|
|
683
|
+
const objectPayload = concatBytes(writeNullTerminatedString('NoFaces'), trimesh);
|
|
684
|
+
const object = writeChunk(THREE_DS_OBJECT, objectPayload);
|
|
685
|
+
const editor = writeChunk(THREE_DS_EDITOR, object);
|
|
686
|
+
const bytes = writeChunk(THREE_DS_MAIN, editor);
|
|
687
|
+
|
|
688
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
689
|
+
const scene = createScene3DFrom3ds(bytes, diagnostics);
|
|
690
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
691
|
+
const crumb = findDiagnostic(diagnostics, '3ds.mesh-missing-geometry');
|
|
692
|
+
expect(crumb).toBeDefined();
|
|
693
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
694
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
695
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
696
|
+
expect(crumb!.detail?.missing).toBe('faces');
|
|
697
|
+
expect(crumb!.detail?.firstName).toBe('NoFaces');
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
describe('createScene3DFrom3ds animations', () => {
|
|
702
|
+
it('wraps the parsed scene with no animation and a one-element scenes array', () => {
|
|
703
|
+
const bytes = buildTriangle3ds('Tri', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]);
|
|
704
|
+
const scene = createScene3DFrom3ds(bytes);
|
|
705
|
+
|
|
706
|
+
expect(Object.keys(scene.animations)).toHaveLength(0);
|
|
707
|
+
expect(getNodeChildren(scene.root)).toHaveLength(1);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('returns an empty import (no scene children) for non-3DS input', () => {
|
|
711
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
712
|
+
const scene = createScene3DFrom3ds(new Uint8Array([0, 0, 0, 0, 0, 0]), diagnostics);
|
|
713
|
+
|
|
714
|
+
expect(Object.keys(scene.animations)).toHaveLength(0);
|
|
715
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
716
|
+
expect(diagnostics.length).toBeGreaterThan(0);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
describe('parse3ds', () => {
|
|
721
|
+
it('decomposes each trimesh into a document mesh node with inline geometry', () => {
|
|
722
|
+
const document = parse3ds(buildTriangle3ds('Tri', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 2]));
|
|
723
|
+
expect(document.meshes).toHaveLength(1);
|
|
724
|
+
expect(getMeshGeometryVertexCount(document.meshes[0].geometry)).toBe(3);
|
|
725
|
+
expect(document.nodes[0].mesh).toBe(0);
|
|
726
|
+
expect(document.nodes[0].name).toBe('Tri');
|
|
727
|
+
expect(document.scenes[0].rootNodes).toEqual([0]);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('registers a referenced material into the document materials table by index', () => {
|
|
731
|
+
const material = writeMaterial({
|
|
732
|
+
diffuse: [204, 102, 51],
|
|
733
|
+
name: 'Skin',
|
|
734
|
+
specular: [255, 255, 255],
|
|
735
|
+
textureFilename: 'skin.png',
|
|
736
|
+
});
|
|
737
|
+
const document = parse3ds(
|
|
738
|
+
buildMaterialScene3D3ds({
|
|
739
|
+
faceMaterialName: 'Skin',
|
|
740
|
+
indices: [0, 1, 2],
|
|
741
|
+
material,
|
|
742
|
+
meshName: 'Cube',
|
|
743
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
expect(document.materials).toHaveLength(1);
|
|
747
|
+
expect((document.materials[0] as BlinnPhongMaterial).name).toBe('Skin');
|
|
748
|
+
expect(document.meshes[0].materials).toEqual([0]);
|
|
749
|
+
expect(document.resources).toHaveLength(1);
|
|
750
|
+
expect(document.resources[0]).toBe((document.materials[0] as BlinnPhongMaterial).diffuseMap!.resource);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('returns an empty document with a diagnostic for non-3DS input', () => {
|
|
754
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
755
|
+
const document = parse3ds(new Uint8Array([0, 0, 0, 0, 0, 0]), diagnostics);
|
|
756
|
+
expect(document.nodes).toHaveLength(0);
|
|
757
|
+
expect(diagnostics.length).toBeGreaterThan(0);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
describe('parse3ds diagnostics', () => {
|
|
762
|
+
it('drops and reports 3ds.vertices-truncated (no-count) for a vertex chunk too small for its count', () => {
|
|
763
|
+
const bytes = wrapTrimesh(
|
|
764
|
+
'V',
|
|
765
|
+
concatBytes(writeChunk(THREE_DS_VERTICES, new Uint8Array(1)), writeFaces([0, 1, 2])),
|
|
766
|
+
);
|
|
767
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
768
|
+
parse3ds(bytes, diagnostics);
|
|
769
|
+
const crumb = findDiagnostic(diagnostics, '3ds.vertices-truncated');
|
|
770
|
+
expect(crumb).toBeDefined();
|
|
771
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
772
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
773
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
774
|
+
expect(crumb!.detail?.reason).toBe('no-count');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('drops and reports 3ds.vertices-truncated (truncated) with the declared count', () => {
|
|
778
|
+
const bytes = wrapTrimesh('V', concatBytes(rawVerticesChunk(100, [0, 0, 0]), writeFaces([0, 1, 2])));
|
|
779
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
780
|
+
parse3ds(bytes, diagnostics);
|
|
781
|
+
const crumb = findDiagnostic(diagnostics, '3ds.vertices-truncated');
|
|
782
|
+
expect(crumb).toBeDefined();
|
|
783
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
784
|
+
expect(crumb!.detail?.reason).toBe('truncated');
|
|
785
|
+
expect(crumb!.detail?.firstCount).toBe(100);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('drops and reports 3ds.faces-truncated (truncated) with the declared count', () => {
|
|
789
|
+
const bytes = wrapTrimesh('F', concatBytes(writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]), rawFacesChunk(100, 8)));
|
|
790
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
791
|
+
parse3ds(bytes, diagnostics);
|
|
792
|
+
const crumb = findDiagnostic(diagnostics, '3ds.faces-truncated');
|
|
793
|
+
expect(crumb).toBeDefined();
|
|
794
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
795
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
796
|
+
expect(crumb!.detail?.firstCount).toBe(100);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it('recovers and reports 3ds.uv-truncated (truncated) for a UV chunk short of its count', () => {
|
|
800
|
+
const bytes = wrapTrimesh(
|
|
801
|
+
'U',
|
|
802
|
+
concatBytes(writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]), writeFaces([0, 1, 2]), rawUvChunk(100, [0, 0])),
|
|
803
|
+
);
|
|
804
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
805
|
+
parse3ds(bytes, diagnostics);
|
|
806
|
+
const crumb = findDiagnostic(diagnostics, '3ds.uv-truncated');
|
|
807
|
+
expect(crumb).toBeDefined();
|
|
808
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
809
|
+
expect(crumb!.detail?.firstCount).toBe(100);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('drops and reports 3ds.face-index-out-of-range for a face referencing a missing vertex', () => {
|
|
813
|
+
const bytes = buildTriangle3ds('OOR', [0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 5]);
|
|
814
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
815
|
+
parse3ds(bytes, diagnostics);
|
|
816
|
+
const crumb = findDiagnostic(diagnostics, '3ds.face-index-out-of-range');
|
|
817
|
+
expect(crumb).toBeDefined();
|
|
818
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
819
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
820
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
821
|
+
expect(crumb!.detail?.firstDroppedFaces).toBe(1);
|
|
822
|
+
expect(crumb!.detail?.firstVertexCount).toBe(3);
|
|
823
|
+
expect(crumb!.detail?.firstName).toBe('OOR');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('recovers and reports 3ds.material-group-face-out-of-range for a group face index past the mesh', () => {
|
|
827
|
+
const bytes = buildScene3D3ds({
|
|
828
|
+
facesChunk: writeFacesWithGroups([0, 1, 2], { groups: [{ faces: [0, 9], name: 'Red' }] }),
|
|
829
|
+
materials: [writeMaterial({ diffuse: [255, 0, 0], name: 'Red' })],
|
|
830
|
+
meshName: 'MG',
|
|
831
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
832
|
+
});
|
|
833
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
834
|
+
parse3ds(bytes, diagnostics);
|
|
835
|
+
const crumb = findDiagnostic(diagnostics, '3ds.material-group-face-out-of-range');
|
|
836
|
+
expect(crumb).toBeDefined();
|
|
837
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
838
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
839
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
840
|
+
expect(crumb!.detail?.firstFaceCount).toBe(1);
|
|
841
|
+
expect(crumb!.detail?.firstName).toBe('Red');
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('recovers and reports 3ds.smoothing-truncated for a mask list shorter than the face count', () => {
|
|
845
|
+
const bytes = buildScene3D3ds({
|
|
846
|
+
facesChunk: writeFacesWithGroups([0, 1, 2, 0, 2, 1], { smoothing: [1] }),
|
|
847
|
+
meshName: 'SM',
|
|
848
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
849
|
+
});
|
|
850
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
851
|
+
parse3ds(bytes, diagnostics);
|
|
852
|
+
const crumb = findDiagnostic(diagnostics, '3ds.smoothing-truncated');
|
|
853
|
+
expect(crumb).toBeDefined();
|
|
854
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
855
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
856
|
+
expect(crumb!.detail?.firstFaceCount).toBe(2);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('drops and reports 3ds.material-group-truncated (no-count) for a group missing its face count', () => {
|
|
860
|
+
// A FACE_MATERIAL sub-chunk carrying only a name, no uint16 count.
|
|
861
|
+
const faceArray = new Uint8Array(2 + 1 * 4 * 2);
|
|
862
|
+
new DataView(faceArray.buffer).setUint16(0, 1, true);
|
|
863
|
+
const faceMaterial = writeChunk(THREE_DS_FACE_MATERIAL, writeNullTerminatedString('Red'));
|
|
864
|
+
const facesChunk = writeChunk(THREE_DS_FACES, concatBytes(faceArray, faceMaterial));
|
|
865
|
+
const bytes = buildScene3D3ds({
|
|
866
|
+
facesChunk,
|
|
867
|
+
materials: [writeMaterial({ diffuse: [255, 0, 0], name: 'Red' })],
|
|
868
|
+
meshName: 'MGT',
|
|
869
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
870
|
+
});
|
|
871
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
872
|
+
parse3ds(bytes, diagnostics);
|
|
873
|
+
const crumb = findDiagnostic(diagnostics, '3ds.material-group-truncated');
|
|
874
|
+
expect(crumb).toBeDefined();
|
|
875
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
876
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
877
|
+
expect(crumb!.detail?.reason).toBe('no-count');
|
|
878
|
+
expect(crumb!.detail?.firstName).toBe('Red');
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('drops and reports 3ds.faces-truncated (no-count) for a face chunk too small to read its count', () => {
|
|
882
|
+
const bytes = wrapTrimesh(
|
|
883
|
+
'F',
|
|
884
|
+
concatBytes(writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]), writeChunk(THREE_DS_FACES, new Uint8Array(1))),
|
|
885
|
+
);
|
|
886
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
887
|
+
parse3ds(bytes, diagnostics);
|
|
888
|
+
const crumb = findDiagnostic(diagnostics, '3ds.faces-truncated');
|
|
889
|
+
expect(crumb).toBeDefined();
|
|
890
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
891
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
892
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
893
|
+
expect(crumb!.detail?.reason).toBe('no-count');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('recovers and reports 3ds.uv-truncated (no-count) for a UV chunk too small to read its count', () => {
|
|
897
|
+
const bytes = wrapTrimesh(
|
|
898
|
+
'U',
|
|
899
|
+
concatBytes(
|
|
900
|
+
writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]),
|
|
901
|
+
writeFaces([0, 1, 2]),
|
|
902
|
+
writeChunk(THREE_DS_UV_COORDS, new Uint8Array(1)),
|
|
903
|
+
),
|
|
904
|
+
);
|
|
905
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
906
|
+
parse3ds(bytes, diagnostics);
|
|
907
|
+
const crumb = findDiagnostic(diagnostics, '3ds.uv-truncated');
|
|
908
|
+
expect(crumb).toBeDefined();
|
|
909
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
910
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
911
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
912
|
+
expect(crumb!.detail?.reason).toBe('no-count');
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it('drops and reports 3ds.material-group-truncated (truncated) for a group short of its declared refs', () => {
|
|
916
|
+
// A FACE_MATERIAL sub-chunk whose declared ref count (100) exceeds its actual ref payload.
|
|
917
|
+
const faceArray = new Uint8Array(2 + 1 * 4 * 2);
|
|
918
|
+
new DataView(faceArray.buffer).setUint16(0, 1, true);
|
|
919
|
+
const refs = new Uint8Array(2 + 2); // declares 100 refs but only room for 1
|
|
920
|
+
new DataView(refs.buffer).setUint16(0, 100, true);
|
|
921
|
+
const faceMaterial = writeChunk(THREE_DS_FACE_MATERIAL, concatBytes(writeNullTerminatedString('Red'), refs));
|
|
922
|
+
const facesChunk = writeChunk(THREE_DS_FACES, concatBytes(faceArray, faceMaterial));
|
|
923
|
+
const bytes = buildScene3D3ds({
|
|
924
|
+
facesChunk,
|
|
925
|
+
materials: [writeMaterial({ diffuse: [255, 0, 0], name: 'Red' })],
|
|
926
|
+
meshName: 'MGT',
|
|
927
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
928
|
+
});
|
|
929
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
930
|
+
parse3ds(bytes, diagnostics);
|
|
931
|
+
const crumb = findDiagnostic(diagnostics, '3ds.material-group-truncated');
|
|
932
|
+
expect(crumb).toBeDefined();
|
|
933
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
934
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
935
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
936
|
+
expect(crumb!.detail?.reason).toBe('truncated');
|
|
937
|
+
expect(crumb!.detail?.firstCount).toBe(100);
|
|
938
|
+
expect(crumb!.detail?.firstName).toBe('Red');
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('drops and reports 3ds.mesh-missing-geometry (missing=vertices) for a trimesh with faces but no vertices', () => {
|
|
942
|
+
const bytes = wrapTrimesh('NoVerts', writeFaces([0, 1, 2]));
|
|
943
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
944
|
+
parse3ds(bytes, diagnostics);
|
|
945
|
+
const crumb = findDiagnostic(diagnostics, '3ds.mesh-missing-geometry');
|
|
946
|
+
expect(crumb).toBeDefined();
|
|
947
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
948
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
949
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
950
|
+
expect(crumb!.detail?.missing).toBe('vertices');
|
|
951
|
+
expect(crumb!.detail?.firstName).toBe('NoVerts');
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it('recovers and reports 3ds.chunk-exceeds-parent for a child chunk declaring length past its parent', () => {
|
|
955
|
+
const object = writeChunk(
|
|
956
|
+
THREE_DS_OBJECT,
|
|
957
|
+
concatBytes(
|
|
958
|
+
writeNullTerminatedString('X'),
|
|
959
|
+
writeChunk(THREE_DS_TRIMESH, concatBytes(writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]), writeFaces([0, 1, 2]))),
|
|
960
|
+
),
|
|
961
|
+
);
|
|
962
|
+
const editor = writeChunk(THREE_DS_EDITOR, tamperChunkLength(object, object.byteLength + 1000));
|
|
963
|
+
const bytes = writeChunk(THREE_DS_MAIN, editor);
|
|
964
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
965
|
+
parse3ds(bytes, diagnostics);
|
|
966
|
+
const crumb = findDiagnostic(diagnostics, '3ds.chunk-exceeds-parent');
|
|
967
|
+
expect(crumb).toBeDefined();
|
|
968
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
969
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
970
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('recovers and reports 3ds.subchunk-exceeds-object for a sub-chunk declaring length past its object', () => {
|
|
974
|
+
const trimesh = writeChunk(
|
|
975
|
+
THREE_DS_TRIMESH,
|
|
976
|
+
concatBytes(writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]), writeFaces([0, 1, 2])),
|
|
977
|
+
);
|
|
978
|
+
const objectPayload = concatBytes(
|
|
979
|
+
writeNullTerminatedString('X'),
|
|
980
|
+
tamperChunkLength(trimesh, trimesh.byteLength + 1000),
|
|
981
|
+
);
|
|
982
|
+
const object = writeChunk(THREE_DS_OBJECT, objectPayload);
|
|
983
|
+
const editor = writeChunk(THREE_DS_EDITOR, object);
|
|
984
|
+
const bytes = writeChunk(THREE_DS_MAIN, editor);
|
|
985
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
986
|
+
parse3ds(bytes, diagnostics);
|
|
987
|
+
const crumb = findDiagnostic(diagnostics, '3ds.subchunk-exceeds-object');
|
|
988
|
+
expect(crumb).toBeDefined();
|
|
989
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
990
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it('recovers and reports 3ds.subchunk-exceeds-trimesh for a sub-chunk declaring length past its trimesh', () => {
|
|
994
|
+
const vertices = writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
995
|
+
const trimeshPayload = concatBytes(tamperChunkLength(vertices, vertices.byteLength + 1000), writeFaces([0, 1, 2]));
|
|
996
|
+
const bytes = wrapTrimesh('X', trimeshPayload);
|
|
997
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
998
|
+
parse3ds(bytes, diagnostics);
|
|
999
|
+
const crumb = findDiagnostic(diagnostics, '3ds.subchunk-exceeds-trimesh');
|
|
1000
|
+
expect(crumb).toBeDefined();
|
|
1001
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
1002
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
1003
|
+
expect(crumb!.detail?.firstName).toBe('X');
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('aggregates repeated mesh-missing-geometry drops across meshes into one crumb with a count', () => {
|
|
1007
|
+
// Two objects, each a trimesh with vertices but no faces sub-chunk.
|
|
1008
|
+
const makeObject = (name: string): Uint8Array => {
|
|
1009
|
+
const trimesh = writeChunk(THREE_DS_TRIMESH, writeVertices([0, 0, 0, 1, 0, 0, 0, 1, 0]));
|
|
1010
|
+
return writeChunk(THREE_DS_OBJECT, concatBytes(writeNullTerminatedString(name), trimesh));
|
|
1011
|
+
};
|
|
1012
|
+
const editor = writeChunk(THREE_DS_EDITOR, concatBytes(makeObject('A'), makeObject('B')));
|
|
1013
|
+
const bytes = writeChunk(THREE_DS_MAIN, editor);
|
|
1014
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1015
|
+
parse3ds(bytes, diagnostics);
|
|
1016
|
+
const matching = diagnostics.filter((d) => d.kind === '3ds.mesh-missing-geometry');
|
|
1017
|
+
expect(matching).toHaveLength(1);
|
|
1018
|
+
expect(matching[0].detail?.count).toBe(2);
|
|
1019
|
+
expect(matching[0].detail?.firstName).toBe('A');
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('emits no diagnostics when no collector array is supplied', () => {
|
|
1023
|
+
const bytes = wrapTrimesh('X', concatBytes(rawVerticesChunk(100, [0, 0, 0]), rawFacesChunk(100, 8)));
|
|
1024
|
+
// Exercising the crumb paths without a sink must not throw and must be side-effect-free.
|
|
1025
|
+
expect(() => parse3ds(bytes)).not.toThrow();
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it('drops and reports 3ds.mesh-empty for a parsed trimesh with zero vertices/faces', () => {
|
|
1029
|
+
const bytes = wrapTrimesh('Empty', concatBytes(writeVertices([]), writeFaces([])));
|
|
1030
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1031
|
+
const scene = createScene3DFrom3ds(bytes, diagnostics);
|
|
1032
|
+
expect(getNodeChildren(scene.root)).toHaveLength(0);
|
|
1033
|
+
const crumb = findDiagnostic(diagnostics, '3ds.mesh-empty');
|
|
1034
|
+
expect(crumb).toBeDefined();
|
|
1035
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
1036
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
1037
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
1038
|
+
expect(crumb!.detail?.firstName).toBe('Empty');
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it('drops and reports 3ds.material-missing for a FACE_MATERIAL group naming an unknown material', () => {
|
|
1042
|
+
const bytes = buildScene3D3ds({
|
|
1043
|
+
facesChunk: writeFacesWithGroups([0, 1, 2], { groups: [{ faces: [0], name: 'Ghost' }] }),
|
|
1044
|
+
meshName: 'MM',
|
|
1045
|
+
positions: [0, 0, 0, 1, 0, 0, 0, 1, 0],
|
|
1046
|
+
});
|
|
1047
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1048
|
+
parse3ds(bytes, diagnostics);
|
|
1049
|
+
const crumb = findDiagnostic(diagnostics, '3ds.material-missing');
|
|
1050
|
+
expect(crumb).toBeDefined();
|
|
1051
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Drop);
|
|
1052
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
1053
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
1054
|
+
expect(crumb!.detail?.firstName).toBe('Ghost');
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('skips and reports 3ds.non-mesh-object for a named object with no trimesh (light/camera)', () => {
|
|
1058
|
+
// An OBJECT chunk carrying only a name, no TRIMESH sub-chunk — Flight imports meshes only.
|
|
1059
|
+
const object = writeChunk(THREE_DS_OBJECT, writeNullTerminatedString('Light'));
|
|
1060
|
+
const editor = writeChunk(THREE_DS_EDITOR, object);
|
|
1061
|
+
const bytes = writeChunk(THREE_DS_MAIN, editor);
|
|
1062
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1063
|
+
createScene3DFrom3ds(bytes, diagnostics);
|
|
1064
|
+
const crumb = findDiagnostic(diagnostics, '3ds.non-mesh-object');
|
|
1065
|
+
expect(crumb).toBeDefined();
|
|
1066
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Skip);
|
|
1067
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
1068
|
+
expect(crumb!.detail?.count).toBe(1);
|
|
1069
|
+
expect(crumb!.detail?.firstName).toBe('Light');
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('recovers and reports 3ds.face-subchunk-exceeds for a face sub-chunk declaring length past the chunk', () => {
|
|
1073
|
+
// A FACE_MATERIAL sub-chunk header inside the FACES chunk whose declared length runs past the chunk end.
|
|
1074
|
+
const faceArray = new Uint8Array(2 + 1 * 4 * 2);
|
|
1075
|
+
new DataView(faceArray.buffer).setUint16(0, 1, true); // one face
|
|
1076
|
+
const badSub = new Uint8Array(THREE_DS_CHUNK_HEADER_BYTES);
|
|
1077
|
+
const badView = new DataView(badSub.buffer);
|
|
1078
|
+
badView.setUint16(0, THREE_DS_FACE_MATERIAL, true);
|
|
1079
|
+
badView.setUint32(2, 10000, true); // length far past the faces chunk
|
|
1080
|
+
const facesChunk = writeChunk(THREE_DS_FACES, concatBytes(faceArray, badSub));
|
|
1081
|
+
const bytes = buildScene3D3ds({ facesChunk, meshName: 'FS', positions: [0, 0, 0, 1, 0, 0, 0, 1, 0] });
|
|
1082
|
+
const diagnostics: ImportDiagnostic[] = [];
|
|
1083
|
+
parse3ds(bytes, diagnostics);
|
|
1084
|
+
const crumb = findDiagnostic(diagnostics, '3ds.face-subchunk-exceeds');
|
|
1085
|
+
expect(crumb).toBeDefined();
|
|
1086
|
+
expect(crumb!.severity).toBe(ImportDiagnosticSeverity.Recover);
|
|
1087
|
+
expect(crumb!.origin).toBe('parse3ds');
|
|
1088
|
+
});
|
|
1089
|
+
});
|