@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,1456 @@
1
+ import { createAnimationTrack } from '@flighthq/animation';
2
+ import { packLinearToColor } from '@flighthq/color';
3
+ import { composeMatrix4FromTransform3D, createMatrix4, createTransform3D, decomposeMatrix4ToTransform3D, multiplyMatrix4, } from '@flighthq/geometry';
4
+ import { detectImageMimeType } from '@flighthq/image-codec';
5
+ import { reportImportDiagnostic } from '@flighthq/importdiagnostics';
6
+ import { createStandardPbrMaterial } from '@flighthq/materials';
7
+ import { CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT, createMeshGeometry, getMeshGeometryVertexCount } from '@flighthq/mesh';
8
+ import { createScene3DFromDocument, createScene3DsFromDocument } from '@flighthq/scene3d';
9
+ import { createTexture } from '@flighthq/texture';
10
+ import { ImportDiagnosticSeverity, MeshKind, Scene3DAnimationPathRotation, Scene3DAnimationPathScale, Scene3DAnimationPathTranslation, Scene3DAnimationPathWeights, Node3DKind, } from '@flighthq/types';
11
+ // Parses a binary glTF (`.glb`) container into a Scene3D — the file's default scene (`doc.scene`).
12
+ // Convenience over `createScene3DFromDocument(parseGlb(bytes), defaultScene3D)`; malformed containers return an
13
+ // empty Scene3D.
14
+ export function createScene3DFromGlb(bytes, diagnostics, options) {
15
+ const container = readGlbContainer(bytes, diagnostics);
16
+ if (container === null)
17
+ return createScene3DFromDocument(createEmptyGltfDocument());
18
+ return createScene3DFromDocument(buildGltfDocument(container.document, container.binary, options, diagnostics), container.document.scene ?? 0);
19
+ }
20
+ // Parses a glTF 2.0 document (JSON string or already-parsed object) into a Scene3D — the file's default scene
21
+ // (`doc.scene`). Convenience over `createScene3DFromDocument(parseGltf(source), defaultScene3D)`; a malformed
22
+ // JSON string returns an empty Scene3D.
23
+ export function createScene3DFromGltf(source, diagnostics, options) {
24
+ const doc = parseGltfSource(source, diagnostics);
25
+ if (doc === null)
26
+ return createScene3DFromDocument(createEmptyGltfDocument());
27
+ return createScene3DFromDocument(buildGltfDocument(doc, null, options, diagnostics), doc.scene ?? 0);
28
+ }
29
+ // Parses a binary glTF (`.glb`) container into every scene it declares (`Scene3D[]`), each carrying its
30
+ // geometry; the file's animation clips are attached to the default scene. Malformed containers return an
31
+ // empty array.
32
+ export function createScene3DsFromGlb(bytes, diagnostics, options) {
33
+ return createScene3DsFromDocument(parseGlb(bytes, diagnostics, options));
34
+ }
35
+ // Parses a glTF 2.0 document into every scene it declares (`Scene3D[]`), each carrying its geometry; the
36
+ // file's animation clips are attached to the default scene. Reach for this over createScene3DFromGltf when the
37
+ // file declares multiple scenes. A malformed JSON string returns an empty array.
38
+ export function createScene3DsFromGltf(source, diagnostics, options) {
39
+ return createScene3DsFromDocument(parseGltf(source, diagnostics, options));
40
+ }
41
+ // Parses a binary glTF (`.glb`) container into a format-neutral Scene3DDocument. The 12-byte header (magic
42
+ // `glTF`, version, length) is validated, then the chunk stream is walked to extract the embedded JSON
43
+ // document and the optional BIN chunk; the BIN chunk backs any buffer that has no `uri`. `options` supplies
44
+ // external buffer bytes and a base path for any external URIs the GLB still references. Malformed containers
45
+ // return an empty document and push a warning rather than throwing. Assemble it into a live Scene3D with
46
+ // `createScene3DFromDocument`.
47
+ export function parseGlb(bytes, diagnostics, options) {
48
+ const container = readGlbContainer(bytes, diagnostics);
49
+ if (container === null)
50
+ return createEmptyGltfDocument();
51
+ return buildGltfDocument(container.document, container.binary, options, diagnostics);
52
+ }
53
+ // Parses a glTF 2.0 document (JSON string or already-parsed object) into a format-neutral Scene3DDocument:
54
+ // the node hierarchy with transforms, meshes (inline geometry + materials), skins, morph, and animation.
55
+ // A malformed JSON string returns an empty document and pushes a warning rather than throwing. Assemble it
56
+ // into a live Scene3D with `createScene3DFromDocument`.
57
+ //
58
+ // Imported today: POSITION + optional NORMAL / TANGENT / TEXCOORD_0 + indices, interleaved into the
59
+ // canonical PBR vertex layout (or the skinned layout when JOINTS_0/WEIGHTS_0 are present); skins (joint
60
+ // hierarchy + inverse-bind matrices); every `primitives[]` entry of a mesh (multi-primitive → sub-mesh
61
+ // child nodes); strided (`byteStride`) and normalized-integer accessors; sparse accessors; materials
62
+ // (metallic-roughness PBR → StandardPbrMaterial); textures with their sampler (wrap/filter), color space
63
+ // (srgb for baseColor/emissive, linear for data maps), and KHR_texture_transform UV remap, resolving
64
+ // embedded bytes to Embedded refs and external URIs to External refs (against `options.basePath`); external
65
+ // (`.bin`) buffers via `options.externalBuffers`.
66
+ export function parseGltf(source, diagnostics, options) {
67
+ const doc = parseGltfSource(source, diagnostics);
68
+ if (doc === null)
69
+ return createEmptyGltfDocument();
70
+ return buildGltfDocument(doc, null, options, diagnostics);
71
+ }
72
+ // Parses the JSON string or accepts the already-parsed object, returning null (with a warning) on invalid
73
+ // JSON or a non-object document.
74
+ function parseGltfSource(source, diagnostics) {
75
+ let doc;
76
+ if (typeof source === 'string') {
77
+ try {
78
+ doc = JSON.parse(source);
79
+ }
80
+ catch {
81
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'gltf.invalid-json', 'parseGltfSource');
82
+ return null;
83
+ }
84
+ }
85
+ else {
86
+ doc = source;
87
+ }
88
+ if (doc === null || typeof doc !== 'object') {
89
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'gltf.not-an-object', 'parseGltfSource');
90
+ return null;
91
+ }
92
+ return doc;
93
+ }
94
+ // The empty Scene3DDocument returned when parsing fails — every table present and empty, so callers and the
95
+ // assembler never special-case a partial document.
96
+ function createEmptyGltfDocument() {
97
+ return {
98
+ animations: [],
99
+ cameras: [],
100
+ lights: [],
101
+ materials: [],
102
+ meshes: [],
103
+ metadata: null,
104
+ nodes: [],
105
+ resources: [],
106
+ scenes: [],
107
+ skins: [],
108
+ };
109
+ }
110
+ // Builds the format-neutral Scene3DDocument from a parsed glTF document plus an optional GLB binary chunk
111
+ // (null for the JSON path). This is the decomposition the importer stops at: inline mesh geometry, resolved
112
+ // materials, node tables with index refs, skins by joint index, and node-index-bound animation channels —
113
+ // `createScene3DFromDocument` assembles it into live entities. A multi-primitive glTF mesh expands into a
114
+ // group node with one child mesh node per primitive, so every document mesh carries exactly one geometry.
115
+ function buildGltfDocument(doc, binary, options, diagnostics) {
116
+ // buildGltfDocument is the single physical emitter for every aggregated document-build crumb (hence the
117
+ // origin); the tallies store no origin. Every per-node/per-primitive/per-accessor fault it fans out to
118
+ // aggregates here and flushes once at the end. The pre-parse gates (parseGltfSource/readGlbContainer)
119
+ // report their whole-input Rejects directly with their own origins.
120
+ const gltfDrops = diagnostics ? new Map() : null;
121
+ const version = doc.asset?.version;
122
+ if (version === undefined || !isSupportedGltfVersion(version)) {
123
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.unsupported-version', '', {
124
+ version: version ?? '(missing)',
125
+ });
126
+ }
127
+ if (doc.extensionsRequired !== undefined) {
128
+ for (const extension of doc.extensionsRequired) {
129
+ if (isSupportedGltfExtension(extension, options?.extensionHandlers))
130
+ continue;
131
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Skip, 'gltf.unsupported-required-extension', '', {
132
+ firstExtension: extension,
133
+ });
134
+ }
135
+ }
136
+ const buffers = (doc.buffers ?? []).map((buffer) => decodeGltfBuffer(buffer, binary, options, gltfDrops));
137
+ const imageResources = (doc.images ?? []).map((image, i) => buildGltfImageResourceReference(doc, buffers, image, options, i, gltfDrops));
138
+ const resources = imageResources.filter((resource) => resource !== null);
139
+ const materials = (doc.materials ?? []).map((material) => gltfMaterialToPbr(doc, imageResources, material, gltfDrops));
140
+ // One document mesh per glTF primitive (inline geometry + morph + material indices). Track, per glTF
141
+ // mesh, the list of document-mesh indices it expands to, so a node can point at the right ones.
142
+ const meshes = [];
143
+ const gltfMeshToDocMeshes = (doc.meshes ?? []).map((gltfMesh) => {
144
+ const docMeshIndices = [];
145
+ for (let p = 0; p < gltfMesh.primitives.length; p++) {
146
+ const primitive = gltfMesh.primitives[p];
147
+ const geometry = primitiveToGeometry(doc, buffers, primitive, gltfDrops);
148
+ // A primitive with no usable position data yields no geometry and is dropped (its gltf.primitive-no-
149
+ // position crumb already fired) — it produces no document mesh, so a mesh whose every primitive drops
150
+ // becomes a bare node with no children.
151
+ if (geometry === null)
152
+ continue;
153
+ const morph = buildGltfMorph(doc, buffers, primitive, gltfMesh.weights, getMeshGeometryVertexCount(geometry), gltfDrops);
154
+ const documentMesh = {
155
+ geometry,
156
+ materials: primitive.material !== undefined ? [primitive.material] : [],
157
+ };
158
+ if (morph !== null)
159
+ documentMesh.morph = morph;
160
+ docMeshIndices.push(meshes.length);
161
+ meshes.push(documentMesh);
162
+ }
163
+ return docMeshIndices;
164
+ });
165
+ const gltfNodes = doc.nodes ?? [];
166
+ const nodes = [];
167
+ // Maps a glTF node index to the document node index that carries its transform + hierarchy. For a
168
+ // multi-primitive mesh the group node holds the transform and the extra primitives become its children.
169
+ const gltfNodeToDocNode = new Array(gltfNodes.length);
170
+ // For a glTF node whose mesh has N>1 primitives, the document node indices of its per-primitive child
171
+ // mesh nodes (so an animation weights channel can fan out to each). Empty for single-primitive nodes.
172
+ const gltfNodePrimitiveNodes = gltfNodes.map(() => []);
173
+ for (let i = 0; i < gltfNodes.length; i++) {
174
+ const gltfNode = gltfNodes[i];
175
+ const transform = gltfNodeTransform(gltfNode);
176
+ const docMeshes = gltfNode.mesh !== undefined ? gltfMeshToDocMeshes[gltfNode.mesh] : undefined;
177
+ const nodeIndex = nodes.length;
178
+ gltfNodeToDocNode[i] = nodeIndex;
179
+ if (docMeshes !== undefined && docMeshes.length === 1) {
180
+ nodes.push({ children: [], kind: MeshKind, mesh: docMeshes[0], name: gltfNode.name, transform });
181
+ }
182
+ else if (docMeshes !== undefined && docMeshes.length > 1) {
183
+ // Group node holds the transform; one child mesh node per primitive (identity transform).
184
+ const group = { children: [], kind: Node3DKind, name: gltfNode.name, transform };
185
+ nodes.push(group);
186
+ for (let m = 0; m < docMeshes.length; m++) {
187
+ const childIndex = nodes.length;
188
+ group.children.push(childIndex);
189
+ gltfNodePrimitiveNodes[i].push(childIndex);
190
+ nodes.push({ children: [], kind: MeshKind, mesh: docMeshes[m], transform: createIdentityTransform() });
191
+ }
192
+ }
193
+ else {
194
+ nodes.push({ children: [], kind: Node3DKind, name: gltfNode.name, transform });
195
+ }
196
+ }
197
+ // Wire the glTF child hierarchy onto the document group/leaf nodes (each glTF node's own doc node).
198
+ for (let i = 0; i < gltfNodes.length; i++) {
199
+ const children = gltfNodes[i].children;
200
+ if (children === undefined)
201
+ continue;
202
+ const parent = nodes[gltfNodeToDocNode[i]];
203
+ for (let c = 0; c < children.length; c++)
204
+ parent.children.push(gltfNodeToDocNode[children[c]]);
205
+ }
206
+ const skins = buildGltfSkins(doc, buffers, gltfNodeToDocNode, gltfDrops);
207
+ // Bind each glTF node's skin onto the document mesh(es) it produced (mesh.skin = skin index).
208
+ for (let i = 0; i < gltfNodes.length; i++) {
209
+ const skinIndex = gltfNodes[i].skin;
210
+ if (skinIndex === undefined || gltfNodes[i].mesh === undefined)
211
+ continue;
212
+ const meshIndicesForNode = gltfMeshToDocMeshes[gltfNodes[i].mesh] ?? [];
213
+ for (let m = 0; m < meshIndicesForNode.length; m++)
214
+ meshes[meshIndicesForNode[m]].skin = skinIndex;
215
+ }
216
+ const scenes = (doc.scenes ?? [{ nodes: topLevelNodeIndices(gltfNodes) }]).map((scene) => ({
217
+ name: scene.name,
218
+ rootNodes: (scene.nodes ?? []).map((n) => gltfNodeToDocNode[n]),
219
+ }));
220
+ const animations = buildGltfAnimations(doc, buffers, gltfNodeToDocNode, gltfNodePrimitiveNodes, nodes, meshes, gltfDrops);
221
+ const nodeWorldTransforms = buildGltfNodeWorldTransforms(gltfNodes, gltfDrops);
222
+ const cameras = buildGltfCameras(doc, gltfNodes, gltfNodeToDocNode, nodeWorldTransforms, gltfDrops);
223
+ const document = {
224
+ animations,
225
+ cameras,
226
+ lights: [],
227
+ materials,
228
+ meshes,
229
+ metadata: buildGltfMetadata(doc),
230
+ nodes,
231
+ resources,
232
+ scenes,
233
+ skins,
234
+ };
235
+ applyGltfExtensionHandlers(document, doc, gltfNodeToDocNode, nodeWorldTransforms, options?.extensionHandlers, gltfDrops, diagnostics);
236
+ if (gltfDrops !== null) {
237
+ for (const tally of gltfDrops.values()) {
238
+ reportImportDiagnostic(diagnostics, tally.severity, tally.kind, 'buildGltfDocument', {
239
+ ...tally.detail,
240
+ count: tally.count,
241
+ });
242
+ }
243
+ }
244
+ return document;
245
+ }
246
+ // Builds one placed document camera per glTF node that references a camera definition. Clip distances
247
+ // remain explicit document facts; an omitted perspective zfar is retained as the glTF infinite-far model.
248
+ // The projection's stored aspect is only the authored fallback—the draw-time viewport remains authoritative.
249
+ function buildGltfCameras(doc, nodes, nodeIndices, nodeWorldTransforms, gltfDrops) {
250
+ const cameras = [];
251
+ const definitions = doc.cameras ?? [];
252
+ for (let node = 0; node < nodes.length; node++) {
253
+ const cameraIndex = nodes[node].camera;
254
+ if (cameraIndex === undefined)
255
+ continue;
256
+ const definition = definitions[cameraIndex];
257
+ if (definition === undefined) {
258
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.camera-missing', '', {
259
+ firstCamera: cameraIndex,
260
+ firstNode: node,
261
+ });
262
+ continue;
263
+ }
264
+ if (definition.type === 'perspective' && definition.perspective !== undefined) {
265
+ const perspective = definition.perspective;
266
+ if (!(perspective.yfov > 0) ||
267
+ perspective.yfov >= Math.PI ||
268
+ !(perspective.znear > 0) ||
269
+ (perspective.zfar !== undefined && !(perspective.zfar > perspective.znear)) ||
270
+ (perspective.aspectRatio !== undefined && !(perspective.aspectRatio > 0))) {
271
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.camera-invalid-perspective', '', {
272
+ firstCamera: cameraIndex,
273
+ });
274
+ continue;
275
+ }
276
+ cameras.push({
277
+ far: perspective.zfar ?? Number.POSITIVE_INFINITY,
278
+ name: definition.name,
279
+ near: perspective.znear,
280
+ node: nodeIndices[node],
281
+ projection: {
282
+ aspect: perspective.aspectRatio ?? 1,
283
+ fovY: perspective.yfov,
284
+ kind: 'perspective',
285
+ },
286
+ transform: cloneGltfTransform(nodeWorldTransforms[node]),
287
+ });
288
+ continue;
289
+ }
290
+ if (definition.type === 'orthographic' && definition.orthographic !== undefined) {
291
+ const orthographic = definition.orthographic;
292
+ if (!(orthographic.xmag > 0) ||
293
+ !(orthographic.ymag > 0) ||
294
+ !(orthographic.znear >= 0) ||
295
+ !(orthographic.zfar > orthographic.znear)) {
296
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.camera-invalid-orthographic', '', {
297
+ firstCamera: cameraIndex,
298
+ });
299
+ continue;
300
+ }
301
+ cameras.push({
302
+ far: orthographic.zfar,
303
+ name: definition.name,
304
+ near: orthographic.znear,
305
+ node: nodeIndices[node],
306
+ projection: {
307
+ halfHeight: orthographic.ymag,
308
+ halfWidth: orthographic.xmag,
309
+ kind: 'orthographic',
310
+ },
311
+ transform: cloneGltfTransform(nodeWorldTransforms[node]),
312
+ });
313
+ continue;
314
+ }
315
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.camera-missing-descriptor', '', {
316
+ firstCamera: cameraIndex,
317
+ firstType: definition.type,
318
+ });
319
+ }
320
+ return cameras;
321
+ }
322
+ function applyGltfExtensionHandlers(document, source, nodeIndices, nodeWorldTransforms, handlers, gltfDrops, diagnostics) {
323
+ if (handlers === undefined || handlers.length === 0)
324
+ return;
325
+ const selected = new Map();
326
+ for (const handler of handlers) {
327
+ if (selected.has(handler.kind)) {
328
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.duplicate-extension-handler', '', {
329
+ firstKind: handler.kind,
330
+ });
331
+ }
332
+ selected.set(handler.kind, handler);
333
+ }
334
+ // Handlers push structured crumbs straight onto the raw diagnostics array (aggregating their own
335
+ // per-element faults, as the built-in punctual-lights handler does); this is why the context carries the
336
+ // array, not the parser-private tally.
337
+ const context = {
338
+ buildNodeTransform(node) {
339
+ return cloneGltfTransform(nodeWorldTransforms[node] ?? createIdentityTransform());
340
+ },
341
+ diagnostics,
342
+ document,
343
+ nodeIndices,
344
+ source,
345
+ };
346
+ for (const handler of selected.values())
347
+ handler.apply(context);
348
+ }
349
+ // Resolves the authored local TRS hierarchy into one world-space placement per glTF node. Camera/light
350
+ // document tables are standalone placements, so their transform must remain useful even before a caller
351
+ // binds the optional node index. A malformed cycle or second parent degrades deterministically with a
352
+ // warning instead of recursing forever.
353
+ function buildGltfNodeWorldTransforms(nodes, gltfDrops) {
354
+ const parents = new Int32Array(nodes.length);
355
+ parents.fill(-1);
356
+ for (let parent = 0; parent < nodes.length; parent++) {
357
+ for (const child of nodes[parent].children ?? []) {
358
+ if (child < 0 || child >= nodes.length) {
359
+ // A child index outside the node table — the malformed reference is ignored (the rest of the
360
+ // hierarchy still resolves), so this is a Recover, like the multiple-parents/cycle guards below.
361
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.node-child-out-of-range', '', {
362
+ firstChild: child,
363
+ });
364
+ continue;
365
+ }
366
+ if (parents[child] !== -1) {
367
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.node-multiple-parents', '', {
368
+ firstChild: child,
369
+ firstParent: parents[child],
370
+ });
371
+ continue;
372
+ }
373
+ parents[child] = parent;
374
+ }
375
+ }
376
+ const localMatrices = nodes.map((node) => {
377
+ const matrix = createMatrix4();
378
+ composeMatrix4FromTransform3D(matrix, gltfNodeTransform(node));
379
+ return matrix;
380
+ });
381
+ const worldMatrices = nodes.map(() => createMatrix4());
382
+ const state = new Uint8Array(nodes.length);
383
+ const stack = [];
384
+ const transforms = [];
385
+ for (let start = 0; start < nodes.length; start++) {
386
+ while (state[start] !== 2) {
387
+ stack.length = 0;
388
+ let node = start;
389
+ let cycle = false;
390
+ while (node >= 0 && state[node] !== 2) {
391
+ if (state[node] === 1) {
392
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.node-hierarchy-cycle', '', {
393
+ firstNode: node,
394
+ });
395
+ parents[node] = -1;
396
+ for (let i = 0; i < stack.length; i++)
397
+ state[stack[i]] = 0;
398
+ cycle = true;
399
+ break;
400
+ }
401
+ state[node] = 1;
402
+ stack.push(node);
403
+ node = parents[node];
404
+ }
405
+ if (cycle)
406
+ continue;
407
+ while (stack.length > 0) {
408
+ node = stack.pop();
409
+ const parent = parents[node];
410
+ if (parent >= 0) {
411
+ multiplyMatrix4(worldMatrices[node], worldMatrices[parent], localMatrices[node]);
412
+ }
413
+ else {
414
+ worldMatrices[node].m.set(localMatrices[node].m);
415
+ }
416
+ state[node] = 2;
417
+ }
418
+ }
419
+ }
420
+ for (let node = 0; node < nodes.length; node++) {
421
+ const transform = createTransform3D();
422
+ decomposeMatrix4ToTransform3D(transform, worldMatrices[node]);
423
+ transforms.push(transform);
424
+ }
425
+ return transforms;
426
+ }
427
+ function cloneGltfTransform(source) {
428
+ const transform = createTransform3D();
429
+ transform.position.x = source.position.x;
430
+ transform.position.y = source.position.y;
431
+ transform.position.z = source.position.z;
432
+ transform.rotation.x = source.rotation.x;
433
+ transform.rotation.y = source.rotation.y;
434
+ transform.rotation.z = source.rotation.z;
435
+ transform.rotation.w = source.rotation.w;
436
+ transform.scale.x = source.scale.x;
437
+ transform.scale.y = source.scale.y;
438
+ transform.scale.z = source.scale.z;
439
+ return transform;
440
+ }
441
+ // Builds the document's skin table: each glTF `skins[]` entry becomes a Scene3DDocumentSkin whose `joints` are
442
+ // document node indices and whose `inverseBind` is one Matrix4 per joint (identity per the spec when the
443
+ // accessor is absent).
444
+ function buildGltfSkins(doc, buffers, gltfNodeToDocNode, gltfDrops) {
445
+ return (doc.skins ?? []).map((gltfSkin) => {
446
+ const joints = gltfSkin.joints.map((jointNodeIndex) => gltfNodeToDocNode[jointNodeIndex]);
447
+ const inverseBind = [];
448
+ if (gltfSkin.inverseBindMatrices !== undefined) {
449
+ const ibm = readAccessor(doc, buffers, gltfSkin.inverseBindMatrices, gltfDrops, 'MAT4');
450
+ if (ibm.fault !== null) {
451
+ // The IBM accessor is unreadable. glTF treats absent inverse-bind matrices as identity, so fall
452
+ // back to identity per joint — the skin survives in bind pose rather than collapsing to a zero
453
+ // matrix (which would send the mesh to the origin). Degraded-but-usable = Recover.
454
+ reportGltfAccessorFault(gltfDrops, ImportDiagnosticSeverity.Recover, ibm.fault);
455
+ for (let j = 0; j < joints.length; j++)
456
+ inverseBind.push({ m: identityMatrix16() });
457
+ }
458
+ else if (ibm.count < joints.length) {
459
+ // Present but too few matrices to cover every joint: filling the missing joints with a zero matrix
460
+ // would collapse the mesh, so recover to identity for ALL joints (bind pose) rather than a partial,
461
+ // corrupt palette. Recover — the skin stays usable.
462
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.skin-ibm-count-mismatch', '', {
463
+ firstActual: ibm.count,
464
+ firstExpected: joints.length,
465
+ });
466
+ for (let j = 0; j < joints.length; j++)
467
+ inverseBind.push({ m: identityMatrix16() });
468
+ }
469
+ else {
470
+ const flat = ibm.data;
471
+ for (let j = 0; j < joints.length; j++) {
472
+ inverseBind.push({ m: Float32Array.from({ length: 16 }, (_, k) => flat[j * 16 + k] ?? 0) });
473
+ }
474
+ }
475
+ }
476
+ else {
477
+ for (let j = 0; j < joints.length; j++)
478
+ inverseBind.push({ m: identityMatrix16() });
479
+ }
480
+ return { inverseBind, joints };
481
+ });
482
+ }
483
+ // Builds the document's animation table. Each glTF animation becomes a Scene3DDocumentAnimation whose channels
484
+ // carry a document node index + Scene3DAnimationPath + a sampled AnimationTrack. A `weights` (morph) channel
485
+ // fans out to each morphable mesh node the target produced (the group's per-primitive children, or the leaf
486
+ // mesh node itself), its track width set to that mesh's morph-target count.
487
+ function buildGltfAnimations(doc, buffers, gltfNodeToDocNode, gltfNodePrimitiveNodes, nodes, meshes, gltfDrops) {
488
+ const animations = [];
489
+ const gltfAnimations = doc.animations ?? [];
490
+ for (let a = 0; a < gltfAnimations.length; a++) {
491
+ const animation = gltfAnimations[a];
492
+ const channels = [];
493
+ let duration = 0;
494
+ for (const channel of animation.channels) {
495
+ const targetNodeIndex = channel.target.node;
496
+ if (targetNodeIndex === undefined || gltfNodeToDocNode[targetNodeIndex] === undefined) {
497
+ // The channel targets no node, or a node index outside the table — it cannot be bound, so the
498
+ // channel is omitted (Drop). If every channel of an animation drops this way the animation vanishes
499
+ // (see the channels.length > 0 guard below), which the crumb makes visible.
500
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.animation-target-unresolved', '', {
501
+ firstTarget: targetNodeIndex ?? -1,
502
+ });
503
+ continue;
504
+ }
505
+ const sampler = animation.samplers[channel.sampler];
506
+ if (sampler === undefined) {
507
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.animation-missing-sampler', '', {
508
+ firstSampler: channel.sampler,
509
+ });
510
+ continue;
511
+ }
512
+ // Time keys are SCALAR; the output element type is fixed by the path (rotation VEC4, translation/scale
513
+ // VEC3, weights SCALAR). readAccessor faults on a type mismatch, so a VEC3 "rotation" output is caught
514
+ // here as a fault rather than silently sampled as a 4-component quaternion.
515
+ const inputResult = readAccessor(doc, buffers, sampler.input, gltfDrops, 'SCALAR');
516
+ const outputResult = readAccessor(doc, buffers, sampler.output, gltfDrops, GLTF_ANIMATION_OUTPUT_TYPES[channel.target.path]);
517
+ if (inputResult.fault !== null || outputResult.fault !== null) {
518
+ // A sampler whose time or value accessor is unreadable or the wrong type cannot produce a track — drop
519
+ // this channel (Drop), consistent with the unresolved-target and missing-sampler channel drops above.
520
+ // No partial track survives, so this is not a Recover.
521
+ reportGltfAccessorFault(gltfDrops, ImportDiagnosticSeverity.Drop, inputResult.fault ?? outputResult.fault);
522
+ continue;
523
+ }
524
+ const times = inputResult.data;
525
+ const values = outputResult.data;
526
+ if (inputResult.count === 0 || outputResult.count === 0) {
527
+ // A usable track needs at least one keyframe — an empty sampler yields no usable track, so drop the
528
+ // channel (Drop) rather than create an animation with an empty channel.
529
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.animation-sampler-empty', '', {
530
+ firstSampler: channel.sampler,
531
+ });
532
+ continue;
533
+ }
534
+ // Validate output cardinality against the keyframe count by INTERPOLATION (element counts, not flattened
535
+ // lengths — a VEC4 output with 1 element against 2 keys has length 4, which a `% keys` check wrongly
536
+ // admits). LINEAR/STEP: one output element per key; CUBICSPLINE: three (in-tangent, value, out-tangent).
537
+ // A mismatch is a malformed track → drop the channel. Weights are SCALAR but target-width-scaled, so
538
+ // their cardinality is validated per-mesh in appendGltfWeightsChannels where the target count is known.
539
+ const cubic = sampler.interpolation === 'CUBICSPLINE';
540
+ if (channel.target.path !== 'weights' && outputResult.count !== (cubic ? 3 : 1) * inputResult.count) {
541
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.animation-sampler-cardinality', '', {
542
+ firstSampler: channel.sampler,
543
+ });
544
+ continue;
545
+ }
546
+ duration = Math.max(duration, times.length > 0 ? times[times.length - 1] : 0);
547
+ if (channel.target.path === 'weights') {
548
+ // A multi-primitive mesh's morphable mesh nodes are its per-primitive children; a single-primitive
549
+ // mesh is the target's own document node. Fan the per-mesh glTF weights channel to each.
550
+ const meshNodeIndices = gltfNodePrimitiveNodes[targetNodeIndex].length > 0
551
+ ? gltfNodePrimitiveNodes[targetNodeIndex]
552
+ : [gltfNodeToDocNode[targetNodeIndex]];
553
+ appendGltfWeightsChannels(channels, meshNodeIndices, nodes, meshes, times, values, sampler.interpolation, gltfDrops);
554
+ continue;
555
+ }
556
+ const path = GLTF_ANIMATION_PATHS[channel.target.path];
557
+ if (path === undefined) {
558
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Skip, 'gltf.animation-unsupported-path', '', {
559
+ firstPath: channel.target.path,
560
+ });
561
+ continue;
562
+ }
563
+ const quaternion = path === Scene3DAnimationPathRotation;
564
+ const track = createAnimationTrack({
565
+ components: quaternion ? 4 : 3,
566
+ interpolation: GLTF_SAMPLER_INTERPOLATIONS[sampler.interpolation ?? 'LINEAR'],
567
+ quaternion,
568
+ times,
569
+ values,
570
+ });
571
+ channels.push({ node: gltfNodeToDocNode[targetNodeIndex], path, track });
572
+ }
573
+ if (channels.length > 0)
574
+ animations.push({ channels, duration, name: animation.name ?? `animation${a}` });
575
+ }
576
+ return animations;
577
+ }
578
+ // Appends a Weights (morph) animation channel for each morphable mesh node the target produced (already
579
+ // resolved to document node indices: a single-primitive mesh's own node, or a multi-primitive mesh's
580
+ // per-primitive child mesh nodes — glTF weights are per-mesh and applied to every primitive). Each channel's
581
+ // track width is that mesh's morph-target count so the per-keyframe value block samples straight into the
582
+ // mesh's weight array. A target with no morphable mesh yields no channel (silently dropped).
583
+ function appendGltfWeightsChannels(channels, meshNodeIndices, nodes, meshes, times, values, interpolation, gltfDrops) {
584
+ // SCALAR weight keys, so `times.length` is the keyframe count and `values.length` packs the per-key weights.
585
+ // Each key carries one weight per morph target (×3 for CUBICSPLINE tangents), so the output must be exactly
586
+ // (perKey · keys · targetWidth) long; a mismatch cannot drive that mesh's morph and its channel is dropped.
587
+ const perKey = interpolation === 'CUBICSPLINE' ? 3 : 1;
588
+ let bound = 0;
589
+ let cardinalityDropped = false;
590
+ for (let i = 0; i < meshNodeIndices.length; i++) {
591
+ const meshIndex = nodes[meshNodeIndices[i]]?.mesh;
592
+ const morph = meshIndex !== undefined ? meshes[meshIndex]?.morph : null;
593
+ if (morph == null || morph.targets.length === 0)
594
+ continue;
595
+ if (values.length !== perKey * times.length * morph.targets.length) {
596
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.weights-cardinality-mismatch', '', {
597
+ firstExpected: perKey * times.length * morph.targets.length,
598
+ firstActual: values.length,
599
+ });
600
+ cardinalityDropped = true;
601
+ continue;
602
+ }
603
+ const track = createAnimationTrack({
604
+ components: morph.targets.length,
605
+ interpolation: GLTF_SAMPLER_INTERPOLATIONS[interpolation ?? 'LINEAR'],
606
+ times,
607
+ values,
608
+ });
609
+ channels.push({ node: meshNodeIndices[i], path: Scene3DAnimationPathWeights, track });
610
+ bound++;
611
+ }
612
+ if (bound === 0 && !cardinalityDropped) {
613
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.weights-no-morphable-mesh', '', {});
614
+ }
615
+ }
616
+ // The document metadata for a glTF file — currently null, since the imported glTF schema subset carries no
617
+ // provenance fields (asset.generator/copyright are not read). Kept as a named seam so a future asset-block
618
+ // read populates it in one place.
619
+ function buildGltfMetadata(_doc) {
620
+ return null;
621
+ }
622
+ // An identity decomposed transform for a synthesized child mesh node (a multi-primitive mesh's per-primitive
623
+ // children draw at the group's transform, so their own local transform is identity).
624
+ function createIdentityTransform() {
625
+ return createTransform3D();
626
+ }
627
+ // The document transform for a glTF node: its 16-float column-major `matrix` decomposed to TRS (lossy only
628
+ // on shear, which glTF authoring does not produce), or its explicit translation/rotation/scale fields, or
629
+ // the identity when the node authors neither.
630
+ function gltfNodeTransform(gltfNode) {
631
+ const transform = createTransform3D();
632
+ if (gltfNode.matrix !== undefined) {
633
+ decomposeMatrix4ToTransform3D(transform, { m: new Float32Array(gltfNode.matrix) });
634
+ return transform;
635
+ }
636
+ const t = gltfNode.translation;
637
+ const r = gltfNode.rotation;
638
+ const s = gltfNode.scale;
639
+ if (t !== undefined) {
640
+ transform.position.x = t[0] ?? 0;
641
+ transform.position.y = t[1] ?? 0;
642
+ transform.position.z = t[2] ?? 0;
643
+ }
644
+ if (r !== undefined) {
645
+ transform.rotation.x = r[0] ?? 0;
646
+ transform.rotation.y = r[1] ?? 0;
647
+ transform.rotation.z = r[2] ?? 0;
648
+ transform.rotation.w = r[3] ?? 1;
649
+ }
650
+ if (s !== undefined) {
651
+ transform.scale.x = s[0] ?? 1;
652
+ transform.scale.y = s[1] ?? 1;
653
+ transform.scale.z = s[2] ?? 1;
654
+ }
655
+ return transform;
656
+ }
657
+ // A fresh 16-float identity matrix for a skin joint with no inverse-bind accessor (spec default).
658
+ function identityMatrix16() {
659
+ const m = new Float32Array(16);
660
+ m[0] = 1;
661
+ m[5] = 1;
662
+ m[10] = 1;
663
+ m[15] = 1;
664
+ return m;
665
+ }
666
+ // Converts a glTF material to Flight's StandardPbrMaterial — glTF's own metallic-roughness model. The
667
+ // pbrMetallicRoughness factors/textures, the normal/occlusion/emissive channels, and the alpha mode
668
+ // map field-for-field; absent factors take the spec defaults. Textures resolve to Unresolved refs
669
+ // carrying their sampler, color space, and KHR_texture_transform (the parser references, it does not
670
+ // decode). baseColor/emissive maps are sampled in 'srgb'; the data maps (normal/metallic-roughness/
671
+ // occlusion) in 'linear', so a shader does not gamma-decode data channels. glTF's baseColorFactor and
672
+ // emissiveFactor are LINEAR, but StandardPbrMaterial.baseColor/emissive are packed sRGB (scene-gl
673
+ // gamma-decodes them via unpackColorToLinear), so the linear factor is sRGB-encoded with
674
+ // packLinearToColor before packing — the documented inverse of that decode. This is the faithful
675
+ // decode: glTF is natively PBR, so unlike the classic formats it is NOT reinterpreted.
676
+ function gltfMaterialToPbr(doc, imageResources, material, gltfDrops) {
677
+ const pbr = material.pbrMetallicRoughness ?? {};
678
+ const result = createStandardPbrMaterial({
679
+ baseColor: packGltfLinearColor(pbr.baseColorFactor ?? [1, 1, 1, 1], 4),
680
+ baseColorMap: resolveGltfTexture(doc, imageResources, pbr.baseColorTexture, 'srgb', gltfDrops),
681
+ emissive: packGltfLinearColor(material.emissiveFactor ?? [0, 0, 0], 3),
682
+ emissiveMap: resolveGltfTexture(doc, imageResources, material.emissiveTexture, 'srgb', gltfDrops),
683
+ metallic: pbr.metallicFactor ?? 1,
684
+ metallicRoughnessMap: resolveGltfTexture(doc, imageResources, pbr.metallicRoughnessTexture, 'linear', gltfDrops),
685
+ normalMap: resolveGltfTexture(doc, imageResources, material.normalTexture, 'linear', gltfDrops),
686
+ normalScale: material.normalTexture?.scale ?? 1,
687
+ occlusionMap: resolveGltfTexture(doc, imageResources, material.occlusionTexture, 'linear', gltfDrops),
688
+ occlusionStrength: material.occlusionTexture?.strength ?? 1,
689
+ roughness: pbr.roughnessFactor ?? 1,
690
+ });
691
+ result.alphaMode = material.alphaMode === 'MASK' ? 'mask' : material.alphaMode === 'BLEND' ? 'blend' : 'opaque';
692
+ result.alphaCutoff = material.alphaCutoff ?? 0.5;
693
+ result.doubleSided = material.doubleSided ?? false;
694
+ result.name = material.name ?? null;
695
+ return result;
696
+ }
697
+ // Resolves a glTF material texture reference to a Flight Texture carrying an Unresolved resource ref
698
+ // plus its sampled state: a `data:` URI or bufferView-embedded image becomes an Embedded ref (bytes in
699
+ // hand), an external URI becomes an External ref against `options.basePath`. The referenced glTF
700
+ // `sampler` (wrap/filter) maps onto the Texture's Sampler + wrap; `colorSpace` sets whether the shader
701
+ // gamma-decodes it (srgb for color maps, linear for data maps); a KHR_texture_transform on the
702
+ // textureInfo sets the Texture's uvOffset/uvRotation/uvScale. Returns null when the reference or its
703
+ // image cannot be resolved.
704
+ function resolveGltfTexture(doc, imageResources, info, colorSpace, gltfDrops) {
705
+ if (info === undefined)
706
+ return null; // the material simply has no texture in this slot — spec-valid, silent
707
+ const texture = doc.textures?.[info.index];
708
+ if (texture?.source === undefined) {
709
+ // The textureInfo points at a missing texture, or a texture with no image source — the material keeps
710
+ // its factor and renders without the map (degraded but usable) → Recover.
711
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.texture-source-missing', '', {
712
+ firstTexture: info.index,
713
+ });
714
+ return null;
715
+ }
716
+ const resource = imageResources[texture.source];
717
+ if (resource == null) {
718
+ // The referenced image failed to build (already tallied as an image Drop); the material loses this map
719
+ // but survives → Recover.
720
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.texture-image-unresolved', '', {
721
+ firstImage: texture.source,
722
+ });
723
+ return null;
724
+ }
725
+ const result = createTexture({ resource });
726
+ result.colorSpace = colorSpace;
727
+ applyGltfSampler(result, texture.sampler !== undefined ? doc.samplers?.[texture.sampler] : undefined);
728
+ applyGltfTextureTransform(result, info.extensions?.KHR_texture_transform);
729
+ return result;
730
+ }
731
+ // Maps a glTF sampler's GL wrap/filter enums onto the Texture's Sampler and wrap fields. Absent
732
+ // samplers/fields take the Flight sampler defaults (createTexture already supplied them). Mip-aware
733
+ // glTF min filters imply a generated mip chain (Sampler.mipmaps = true); the non-mip nearest/linear
734
+ // filters imply none. Anisotropy is not a glTF concept, so it stays at the default.
735
+ function applyGltfSampler(texture, sampler) {
736
+ if (sampler === undefined)
737
+ return;
738
+ if (sampler.wrapS !== undefined)
739
+ texture.sampler.wrapU = GLTF_TEXTURE_WRAP[sampler.wrapS];
740
+ if (sampler.wrapT !== undefined)
741
+ texture.sampler.wrapV = GLTF_TEXTURE_WRAP[sampler.wrapT];
742
+ if (sampler.magFilter !== undefined)
743
+ texture.sampler.magFilter = GLTF_TEXTURE_FILTER[sampler.magFilter];
744
+ if (sampler.minFilter !== undefined) {
745
+ texture.sampler.minFilter = GLTF_TEXTURE_FILTER[sampler.minFilter];
746
+ texture.sampler.mipmaps = GLTF_MIN_FILTER_MIPMAPS[sampler.minFilter];
747
+ }
748
+ }
749
+ // Applies a KHR_texture_transform block to the Texture's KHR_texture_transform fields (the identity is
750
+ // already in place from createTexture). offset → uvOffset, rotation (radians) → uvRotation, scale →
751
+ // uvScale. Absent sub-fields take the extension's spec defaults ([0,0] / 0 / [1,1]).
752
+ function applyGltfTextureTransform(texture, transform) {
753
+ if (transform === undefined)
754
+ return;
755
+ texture.uvOffset.x = transform.offset?.[0] ?? 0;
756
+ texture.uvOffset.y = transform.offset?.[1] ?? 0;
757
+ texture.uvRotation = transform.rotation ?? 0;
758
+ texture.uvScale.x = transform.scale?.[0] ?? 1;
759
+ texture.uvScale.y = transform.scale?.[1] ?? 1;
760
+ }
761
+ // Builds one shared resource reference from a glTF image: a `data:` URI decodes its base64 payload to an Embedded ref
762
+ // (MIME from the URI header, the declared `mimeType`, or sniffed from the bytes); an external URI
763
+ // becomes an External ref against `options.basePath`; a bufferView slices the encoded bytes out of its
764
+ // buffer as an Embedded ref.
765
+ function buildGltfImageResourceReference(doc, buffers, image, options, imageIndex, gltfDrops) {
766
+ if (image.uri !== undefined) {
767
+ if (image.uri.startsWith('data:')) {
768
+ const comma = image.uri.indexOf(',');
769
+ if (comma < 0) {
770
+ // A data: URI with no comma has no decodable payload — the image is omitted from the resource set.
771
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.image-malformed-uri', '', {
772
+ firstImage: imageIndex,
773
+ });
774
+ return null;
775
+ }
776
+ const semicolon = image.uri.indexOf(';');
777
+ const declared = semicolon > 5 ? image.uri.slice(5, semicolon) : (image.mimeType ?? null);
778
+ const bytes = decodeBase64(image.uri.slice(comma + 1));
779
+ return buildEmbeddedImageResourceReference(bytes, declared ?? detectImageMimeType(bytes));
780
+ }
781
+ return buildExternalImageResourceReference(image.uri, options?.basePath ?? null);
782
+ }
783
+ if (image.bufferView !== undefined) {
784
+ const bufferView = doc.bufferViews?.[image.bufferView];
785
+ const buffer = bufferView !== undefined ? buffers[bufferView.buffer] : undefined;
786
+ if (bufferView === undefined || buffer === undefined) {
787
+ // The image's bufferView (or its backing buffer) is out of range — the image is omitted.
788
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.image-bufferview-out-of-range', '', {
789
+ firstBufferView: image.bufferView,
790
+ firstImage: imageIndex,
791
+ });
792
+ return null;
793
+ }
794
+ const start = bufferView.byteOffset ?? 0;
795
+ const bytes = buffer.slice(start, start + bufferView.byteLength);
796
+ return buildEmbeddedImageResourceReference(bytes, image.mimeType ?? detectImageMimeType(bytes));
797
+ }
798
+ // A glTF image must carry a uri or a bufferView; one with neither cannot be resolved and is omitted.
799
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.image-no-source', '', { firstImage: imageIndex });
800
+ return null;
801
+ }
802
+ // Packs the first `channels` of a glTF LINEAR-space color factor (each in [0,1]) into a 0xRRGGBBAA
803
+ // integer, sRGB-encoding the RGB channels so scene-gl's unpackColorToLinear gamma-decode recovers the
804
+ // authored linear value (packLinearToColor is the documented inverse of that decode). With 3 channels
805
+ // alpha is forced opaque; with 4 the 4th is the (linear coverage) alpha, passed through unencoded.
806
+ function packGltfLinearColor(factor, channels) {
807
+ const a = channels === 4 ? (factor[3] ?? 0) : 1;
808
+ return packLinearToColor([factor[0] ?? 0, factor[1] ?? 0, factor[2] ?? 0, a]);
809
+ }
810
+ // Decodes a buffer into bytes. A `data:` URI base64-decodes; a buffer with no `uri` is backed by the
811
+ // GLB binary chunk when present. An external (`.bin`) URI is served from `options.externalBuffers`
812
+ // (the caller fetched it, since parse is synchronous), keyed by the exact `uri` string. A URI missing
813
+ // from that map, or a uri-less buffer with no binary chunk, decodes to empty with a warning.
814
+ function decodeGltfBuffer(buffer, binary, options, gltfDrops) {
815
+ const uri = buffer.uri;
816
+ if (uri === undefined) {
817
+ if (binary !== null)
818
+ return binary;
819
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.buffer-empty', 'no-uri', {
820
+ reason: 'no-uri-no-binary',
821
+ });
822
+ return new Uint8Array(0);
823
+ }
824
+ const comma = uri.indexOf(',');
825
+ if (uri.startsWith('data:') && comma >= 0) {
826
+ return decodeBase64(uri.slice(comma + 1));
827
+ }
828
+ const supplied = options?.externalBuffers?.[uri];
829
+ if (supplied !== undefined)
830
+ return Uint8Array.from(supplied);
831
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.buffer-empty', 'external-missing', {
832
+ firstUri: uri,
833
+ reason: 'external-not-supplied',
834
+ });
835
+ return new Uint8Array(0);
836
+ }
837
+ // Portable base64 decode that works in Node.js (Vitest) and browsers alike, avoiding the
838
+ // browser-only atob() global.
839
+ function decodeBase64(s) {
840
+ const table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
841
+ const stripped = s.replace(/[^A-Za-z0-9+/]/g, '');
842
+ const out = [];
843
+ for (let i = 0; i < stripped.length; i += 4) {
844
+ // A trailing quantum shorter than 4 chars encodes 1 or 2 bytes; the absent sextets contribute
845
+ // zero to the value and their bytes are not emitted. (`indexOf` must not feed -1 into the bit
846
+ // math, or it poisons the high byte — the reason padding is stripped and length-checked here.)
847
+ const c0 = table.indexOf(stripped[i]);
848
+ const c1 = table.indexOf(stripped[i + 1]);
849
+ const c2 = i + 2 < stripped.length ? table.indexOf(stripped[i + 2]) : -1;
850
+ const c3 = i + 3 < stripped.length ? table.indexOf(stripped[i + 3]) : -1;
851
+ const n = (c0 << 18) | (c1 << 12) | ((c2 < 0 ? 0 : c2) << 6) | (c3 < 0 ? 0 : c3);
852
+ out.push((n >> 16) & 0xff);
853
+ if (c2 >= 0)
854
+ out.push((n >> 8) & 0xff);
855
+ if (c3 >= 0)
856
+ out.push(n & 0xff);
857
+ }
858
+ return new Uint8Array(out);
859
+ }
860
+ function isSupportedGltfVersion(version) {
861
+ return Number.parseInt(version, 10) === 2;
862
+ }
863
+ // Names only the extensions the core parser actually consumes today. This prevents required-extension
864
+ // diagnostics from contradicting visible behavior while the open handler registry remains a separate
865
+ // depth step; adding a schema field alone must not count as support.
866
+ function isSupportedGltfExtension(extension, handlers) {
867
+ return extension === 'KHR_texture_transform' || handlers?.some((handler) => handler.kind === extension) === true;
868
+ }
869
+ // Normalizes a raw integer component to its float range per the glTF spec: unsigned types map onto
870
+ // [0, 1] by dividing by their max; signed types map onto [-1, 1] via max(c / MAX, -1). Float
871
+ // components pass through unchanged.
872
+ function normalizeComponent(componentType, value) {
873
+ switch (componentType) {
874
+ case 5120:
875
+ return Math.max(value / 127, -1);
876
+ case 5121:
877
+ return value / 255;
878
+ case 5122:
879
+ return Math.max(value / 32767, -1);
880
+ case 5123:
881
+ return value / 65535;
882
+ case 5125:
883
+ return value / 4294967295;
884
+ default:
885
+ return value;
886
+ }
887
+ }
888
+ function primitiveToGeometry(doc, buffers, primitive, gltfDrops) {
889
+ // Position is mandatory in glTF; a primitive with no usable position data (the attribute absent, or its
890
+ // accessor unreadable so it yields zero vertices) is an unusable empty shell — DROP it (return null so the
891
+ // mesh is not emitted) rather than push an empty mesh node. Drop matches md5mesh.mesh-empty. The primitive
892
+ // crumb is the honest classification; the subsuming accessor fault is not emitted as a contradictory
893
+ // Recover (a dropped primitive did not recover).
894
+ const positionIndex = primitive.attributes.POSITION;
895
+ if (positionIndex === undefined) {
896
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.primitive-no-position', '', {});
897
+ return null;
898
+ }
899
+ const position = readAccessor(doc, buffers, positionIndex, gltfDrops, 'VEC3');
900
+ const vertexCount = position.count;
901
+ if (vertexCount === 0) {
902
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.primitive-no-position', '', {
903
+ firstAccessor: positionIndex,
904
+ });
905
+ return null;
906
+ }
907
+ // Optional attributes: a failed, type-mismatched, or count-mismatched accessor is treated as absent (its
908
+ // vertex slots zero-fill with finite defaults) and Recover-crumbed — the mesh stays drawable, the usable
909
+ // survivor a Recover requires. Each expected type is fixed by the vertex layout the loop below reads.
910
+ const normal = readOptionalGltfAttribute(doc, buffers, primitive.attributes.NORMAL, vertexCount, 'VEC3', gltfDrops);
911
+ const tangent = readOptionalGltfAttribute(doc, buffers, primitive.attributes.TANGENT, vertexCount, 'VEC4', gltfDrops);
912
+ const uv = readOptionalGltfAttribute(doc, buffers, primitive.attributes.TEXCOORD_0, vertexCount, 'VEC2', gltfDrops);
913
+ // A primitive is skinned when it carries both influence channels; it then emits the skinned layout
914
+ // (joints0/weights0 past uv0). JOINTS_0 is unsigned-integer indices (not normalized); WEIGHTS_0 is
915
+ // float or normalized-integer weights, renormalized per vertex so any quantization drift still sums 1.
916
+ // A failed influence accessor drops just that channel (Recover), so the mesh falls back to unskinned.
917
+ const joints = readOptionalGltfAttribute(doc, buffers, primitive.attributes.JOINTS_0, vertexCount, 'VEC4', gltfDrops);
918
+ const weights = readOptionalGltfAttribute(doc, buffers, primitive.attributes.WEIGHTS_0, vertexCount, 'VEC4', gltfDrops);
919
+ const skinned = joints !== null && weights !== null;
920
+ const floatsPerVertex = skinned ? SKINNED_FLOATS_PER_VERTEX : CANONICAL_FLOATS_PER_VERTEX;
921
+ const vertices = new Float32Array(vertexCount * floatsPerVertex);
922
+ for (let v = 0; v < vertexCount; v++) {
923
+ const o = v * floatsPerVertex;
924
+ vertices[o] = position.data[v * 3];
925
+ vertices[o + 1] = position.data[v * 3 + 1];
926
+ vertices[o + 2] = position.data[v * 3 + 2];
927
+ if (normal !== null) {
928
+ vertices[o + 3] = normal.data[v * 3];
929
+ vertices[o + 4] = normal.data[v * 3 + 1];
930
+ vertices[o + 5] = normal.data[v * 3 + 2];
931
+ }
932
+ if (tangent !== null) {
933
+ vertices[o + 6] = tangent.data[v * 4];
934
+ vertices[o + 7] = tangent.data[v * 4 + 1];
935
+ vertices[o + 8] = tangent.data[v * 4 + 2];
936
+ vertices[o + 9] = tangent.data[v * 4 + 3];
937
+ }
938
+ if (uv !== null) {
939
+ vertices[o + 10] = uv.data[v * 2];
940
+ vertices[o + 11] = uv.data[v * 2 + 1];
941
+ }
942
+ if (skinned) {
943
+ vertices[o + 12] = joints.data[v * 4];
944
+ vertices[o + 13] = joints.data[v * 4 + 1];
945
+ vertices[o + 14] = joints.data[v * 4 + 2];
946
+ vertices[o + 15] = joints.data[v * 4 + 3];
947
+ const w0 = weights.data[v * 4];
948
+ const w1 = weights.data[v * 4 + 1];
949
+ const w2 = weights.data[v * 4 + 2];
950
+ const w3 = weights.data[v * 4 + 3];
951
+ const sum = w0 + w1 + w2 + w3;
952
+ const inv = sum > 0 ? 1 / sum : 0;
953
+ vertices[o + 16] = w0 * inv;
954
+ vertices[o + 17] = w1 * inv;
955
+ vertices[o + 18] = w2 * inv;
956
+ vertices[o + 19] = w3 * inv;
957
+ }
958
+ }
959
+ // glTF index accessors are ubyte/ushort/uint; normalize to Uint32Array (createMeshGeometry promotes/
960
+ // accepts 16- or 32-bit index buffers). The index buffer defines the primitive's topology; an unreadable
961
+ // or empty one leaves the vertex storage order — which is not a sane triangle list — so no usable
962
+ // primitive survives and the primitive is DROPPED (Drop, mandatory role) rather than kept undrawable.
963
+ let sourceIndices;
964
+ if (primitive.indices !== undefined) {
965
+ const indexResult = readAccessor(doc, buffers, primitive.indices, gltfDrops, 'SCALAR');
966
+ if (indexResult.fault !== null) {
967
+ reportGltfAccessorFault(gltfDrops, ImportDiagnosticSeverity.Drop, indexResult.fault);
968
+ return null;
969
+ }
970
+ if (indexResult.count === 0) {
971
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.primitive-empty-indices', '', {
972
+ firstAccessor: primitive.indices,
973
+ });
974
+ return null;
975
+ }
976
+ sourceIndices = Uint32Array.from(indexResult.data);
977
+ }
978
+ const primitiveElements = buildGltfPrimitiveElements(primitive.mode ?? 4, sourceIndices, vertexCount, gltfDrops);
979
+ if (primitiveElements === null)
980
+ return null; // unsupported primitive mode → drop (no drawable topology)
981
+ return createMeshGeometry({
982
+ indices: primitiveElements.indices,
983
+ layout: skinned ? CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT : CANONICAL_LAYOUT,
984
+ topology: primitiveElements.topology,
985
+ vertices,
986
+ });
987
+ }
988
+ // Maps a glTF primitive mode to its index buffer + topology, or null when the mode is unsupported — an
989
+ // unknown mode has no sane drawable interpretation (reinterpreting it as another topology would draw wrong
990
+ // geometry), so the caller drops the primitive rather than keep zero-element geometry labeled as recovered.
991
+ function buildGltfPrimitiveElements(mode, source, vertexCount, gltfDrops) {
992
+ switch (mode) {
993
+ case 0:
994
+ return { indices: source, topology: 'point-list' };
995
+ case 1:
996
+ return { indices: source, topology: 'line-list' };
997
+ case 2:
998
+ return { indices: buildGltfLineLoopIndices(source, vertexCount), topology: 'line-list' };
999
+ case 3:
1000
+ return { indices: source, topology: 'line-strip' };
1001
+ case 4:
1002
+ return { indices: source, topology: 'triangle-list' };
1003
+ case 5:
1004
+ return { indices: source, topology: 'triangle-strip' };
1005
+ case 6:
1006
+ return { indices: buildGltfTriangleFanIndices(source, vertexCount), topology: 'triangle-list' };
1007
+ default:
1008
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.primitive-unsupported-mode', '', {
1009
+ firstMode: mode,
1010
+ });
1011
+ return null;
1012
+ }
1013
+ }
1014
+ function buildGltfLineLoopIndices(source, vertexCount) {
1015
+ const count = source?.length ?? vertexCount;
1016
+ if (count < 2)
1017
+ return new Uint32Array(0);
1018
+ const out = new Uint32Array(count * 2);
1019
+ for (let i = 0; i < count; i++) {
1020
+ out[i * 2] = source?.[i] ?? i;
1021
+ out[i * 2 + 1] = source?.[(i + 1) % count] ?? (i + 1) % count;
1022
+ }
1023
+ return out;
1024
+ }
1025
+ function buildGltfTriangleFanIndices(source, vertexCount) {
1026
+ const count = source?.length ?? vertexCount;
1027
+ if (count < 3)
1028
+ return new Uint32Array(0);
1029
+ const out = new Uint32Array((count - 2) * 3);
1030
+ const first = source?.[0] ?? 0;
1031
+ for (let i = 1; i + 1 < count; i++) {
1032
+ const offset = (i - 1) * 3;
1033
+ out[offset] = first;
1034
+ out[offset + 1] = source?.[i] ?? i;
1035
+ out[offset + 2] = source?.[i + 1] ?? i + 1;
1036
+ }
1037
+ return out;
1038
+ }
1039
+ // Builds a MeshMorph from a primitive's `targets` (blend shapes), or null when the primitive carries
1040
+ // none. Each target's POSITION delta accessor (always present) plus optional NORMAL/TANGENT delta
1041
+ // accessors are read into de-interleaved Float32Array delta buffers aligned with the base vertices —
1042
+ // the SoA shape blendMeshGeometryMorph consumes. glTF morph tangent deltas are VEC3 (the handedness
1043
+ // `w` is not morphed), so the tangent delta is copied as 3 floats per vertex. `weights` seeds the live
1044
+ // weight array from the mesh's default weights (spec: mesh.weights), zero-filled when absent; a
1045
+ // `weights` animation channel overrides it at runtime.
1046
+ //
1047
+ // Target index is identity: the Nth target corresponds to mesh.weights[N] and to weight-animation output
1048
+ // index N. So an invalid target is NOT dropped in isolation (that would renumber the survivors and shift
1049
+ // every weight/animation correspondence); the WHOLE morph set is dropped instead, keeping indexing honest.
1050
+ // A target is valid only when its POSITION delta reads cleanly with exactly `baseVertexCount` elements.
1051
+ function buildGltfMorph(doc, buffers, primitive, meshWeights, baseVertexCount, gltfDrops) {
1052
+ const gltfTargets = primitive.targets;
1053
+ if (gltfTargets === undefined || gltfTargets.length === 0)
1054
+ return null;
1055
+ const targets = [];
1056
+ for (let t = 0; t < gltfTargets.length; t++) {
1057
+ const target = gltfTargets[t];
1058
+ if (target.POSITION === undefined) {
1059
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.morph-target-no-position', '', {
1060
+ firstTarget: t,
1061
+ });
1062
+ return null;
1063
+ }
1064
+ const positionResult = readAccessor(doc, buffers, target.POSITION, gltfDrops, 'VEC3');
1065
+ if (positionResult.fault !== null) {
1066
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.morph-target-no-position', '', {
1067
+ firstTarget: t,
1068
+ });
1069
+ return null;
1070
+ }
1071
+ if (positionResult.count !== baseVertexCount) {
1072
+ // A delta shorter or longer than the base mesh would blend past the base vertices (NaN) or leave
1073
+ // vertices unmorphed — not a usable target, and it invalidates the index correspondence, so drop the
1074
+ // whole set (Drop) rather than keep a mismatched target.
1075
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Drop, 'gltf.morph-target-count-mismatch', '', {
1076
+ firstActual: positionResult.count,
1077
+ firstExpected: baseVertexCount,
1078
+ firstTarget: t,
1079
+ });
1080
+ return null;
1081
+ }
1082
+ const positionDeltas = Float32Array.from(positionResult.data);
1083
+ // glTF morph deltas are VEC3 for all three channels (the tangent handedness w is not morphed).
1084
+ const normal = readOptionalGltfAttribute(doc, buffers, target.NORMAL, baseVertexCount, 'VEC3', gltfDrops);
1085
+ const tangent = readOptionalGltfAttribute(doc, buffers, target.TANGENT, baseVertexCount, 'VEC3', gltfDrops);
1086
+ const normalDeltas = normal !== null ? Float32Array.from(normal.data) : null;
1087
+ const tangentDeltas = tangent !== null ? Float32Array.from(tangent.data) : null;
1088
+ targets.push({ normalDeltas, positionDeltas, tangentDeltas });
1089
+ }
1090
+ // Every target survived, so targets index-aligns 1:1 with gltfTargets and with mesh.weights.
1091
+ const weights = new Float32Array(targets.length);
1092
+ if (meshWeights !== undefined) {
1093
+ for (let i = 0; i < weights.length && i < meshWeights.length; i++)
1094
+ weights[i] = meshWeights[i];
1095
+ }
1096
+ return { targets, weights };
1097
+ }
1098
+ // Emits an accessor fault at the severity its call-site role dictates: Drop where the fault leaves no
1099
+ // usable survivor (mandatory POSITION/indices, an unsamplable animation channel), Recover where a
1100
+ // non-empty, non-NaN, drawable element remains after substituting a sane default (optional attributes,
1101
+ // identity inverse-bind matrices, an omitted morph delta).
1102
+ function reportGltfAccessorFault(gltfDrops, severity, fault) {
1103
+ tallyGltfDrop(gltfDrops, severity, fault.kind, '', fault.detail);
1104
+ }
1105
+ // Reads an optional vertex attribute (normal/tangent/uv/joints/weights). Returns null — the attribute is
1106
+ // treated as absent, so the vertex loop zero-fills its slots with finite defaults — when the index is
1107
+ // undefined, when the accessor faults (Recover-crumbed: the mesh stays drawable without it), or when its
1108
+ // element count does not match the primitive's vertex count (a count mismatch would read past the shorter
1109
+ // array into non-finite territory; also Recover-crumbed with the expected/actual counts). A present,
1110
+ // correctly-sized attribute returns its decoded data.
1111
+ function readOptionalGltfAttribute(doc, buffers, index, vertexCount, expectedType, gltfDrops) {
1112
+ if (index === undefined)
1113
+ return null;
1114
+ const result = readAccessor(doc, buffers, index, gltfDrops, expectedType);
1115
+ if (result.fault !== null) {
1116
+ reportGltfAccessorFault(gltfDrops, ImportDiagnosticSeverity.Recover, result.fault);
1117
+ return null;
1118
+ }
1119
+ if (result.count !== vertexCount) {
1120
+ reportGltfAccessorFault(gltfDrops, ImportDiagnosticSeverity.Recover, {
1121
+ detail: { firstAccessor: index, firstActual: result.count, firstExpected: vertexCount },
1122
+ kind: 'gltf.accessor-count-mismatch',
1123
+ });
1124
+ return null;
1125
+ }
1126
+ return { data: result.data };
1127
+ }
1128
+ // Decodes a glTF accessor into a flat array, de-striding per `bufferView.byteStride` and decoding
1129
+ // `normalized` integer attributes to their float ranges. Reads through a DataView (little-endian, as
1130
+ // the spec mandates) so unaligned accessor/bufferView offsets are safe. Normalized accessors return a
1131
+ // Float32Array; others return an array of the accessor's native component type (so uint32 index values
1132
+ // stay exact).
1133
+ function readAccessor(doc, buffers, accessorIndex, gltfDrops, expectedType) {
1134
+ const accessor = doc.accessors?.[accessorIndex];
1135
+ if (accessor === undefined) {
1136
+ return {
1137
+ count: 0,
1138
+ data: new Float32Array(0),
1139
+ fault: { detail: { firstAccessor: accessorIndex }, kind: 'gltf.accessor-not-found' },
1140
+ };
1141
+ }
1142
+ // Validate the element TYPE against the consumer's expectation before reading. Every consumer reads a
1143
+ // fixed component count (POSITION VEC3, indices SCALAR, rotation VEC4…); a wrong-width accessor (a VEC3
1144
+ // "rotation", say) would otherwise be silently reinterpreted, striding the read across tuple boundaries.
1145
+ // A mismatch is a fault the caller classifies by role (mandatory → Drop, optional → Recover-absent).
1146
+ if (expectedType !== undefined && accessor.type !== expectedType) {
1147
+ return {
1148
+ count: 0,
1149
+ data: new Float32Array(0),
1150
+ fault: { detail: { firstAccessor: accessorIndex }, kind: 'gltf.accessor-type-mismatch' },
1151
+ };
1152
+ }
1153
+ const componentCount = TYPE_COMPONENTS[accessor.type];
1154
+ const componentByteSize = COMPONENT_BYTE_SIZE[accessor.componentType];
1155
+ const normalize = accessor.normalized === true && accessor.componentType !== 5126;
1156
+ const total = accessor.count * componentCount;
1157
+ const out = normalize ? new Float32Array(total) : createComponentArray(accessor.componentType, total);
1158
+ // Base values from the accessor's bufferView. A sparse accessor may omit the bufferView entirely, in
1159
+ // which case the base is a valid zero-fill that `sparse` then overrides at specific indices.
1160
+ const bufferViewIndex = accessor.bufferView ?? -1;
1161
+ const view = bufferViewIndex >= 0 ? doc.bufferViews?.[bufferViewIndex] : undefined;
1162
+ if (view !== undefined) {
1163
+ const bytes = buffers[view.buffer];
1164
+ if (bytes === undefined) {
1165
+ return {
1166
+ count: 0,
1167
+ data: new Float32Array(0),
1168
+ fault: {
1169
+ detail: { firstAccessor: accessorIndex, firstBuffer: view.buffer },
1170
+ kind: 'gltf.accessor-buffer-not-found',
1171
+ },
1172
+ };
1173
+ }
1174
+ const elementByteSize = componentCount * componentByteSize;
1175
+ const stride = view.byteStride !== undefined && view.byteStride > 0 ? view.byteStride : elementByteSize;
1176
+ const baseOffset = bytes.byteOffset + (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0);
1177
+ // The read must stay within BOTH the declared bufferView extent and the real backing buffer. Guarding
1178
+ // only the buffer end lets an accessor overrun a short bufferView into unrelated bytes of a longer buffer;
1179
+ // guard the last component's end against the tighter of the two and bail with empty rather than throwing
1180
+ // or reading past the declared view.
1181
+ const viewEnd = bytes.byteOffset + (view.byteOffset ?? 0) + view.byteLength;
1182
+ const readLimit = Math.min(viewEnd, bytes.byteOffset + bytes.byteLength);
1183
+ const lastByteEnd = accessor.count > 0 ? baseOffset + (accessor.count - 1) * stride + elementByteSize : baseOffset;
1184
+ if (lastByteEnd > readLimit) {
1185
+ return {
1186
+ count: 0,
1187
+ data: new Float32Array(0),
1188
+ fault: { detail: { firstAccessor: accessorIndex }, kind: 'gltf.accessor-past-buffer' },
1189
+ };
1190
+ }
1191
+ const dataView = new DataView(bytes.buffer);
1192
+ for (let i = 0; i < accessor.count; i++) {
1193
+ const elementOffset = baseOffset + i * stride;
1194
+ for (let c = 0; c < componentCount; c++) {
1195
+ const raw = readComponent(dataView, accessor.componentType, elementOffset + c * componentByteSize);
1196
+ out[i * componentCount + c] = normalize ? normalizeComponent(accessor.componentType, raw) : raw;
1197
+ }
1198
+ }
1199
+ }
1200
+ else if (accessor.sparse === undefined) {
1201
+ return {
1202
+ count: 0,
1203
+ data: new Float32Array(0),
1204
+ fault: {
1205
+ detail: { firstAccessor: accessorIndex, firstBufferView: bufferViewIndex },
1206
+ kind: 'gltf.accessor-bufferview-not-found',
1207
+ },
1208
+ };
1209
+ }
1210
+ if (accessor.sparse !== undefined) {
1211
+ applyAccessorSparse(doc, buffers, accessor.sparse, accessor.count, accessor.componentType, componentCount, normalize, out, gltfDrops);
1212
+ }
1213
+ return { count: accessor.count, data: out, fault: null };
1214
+ }
1215
+ // Applies an accessor's sparse override in place: reads `sparse.count` element indices and the matching
1216
+ // replacement elements, writing each element (componentCount values) over the base `out` array. Indices
1217
+ // and values are tightly packed in their own bufferViews (no byteStride, per the spec).
1218
+ function applyAccessorSparse(doc, buffers, sparse, accessorCount, valueComponentType, componentCount, normalize, out, gltfDrops) {
1219
+ const indicesView = doc.bufferViews?.[sparse.indices.bufferView];
1220
+ const valuesView = doc.bufferViews?.[sparse.values.bufferView];
1221
+ if (indicesView === undefined || valuesView === undefined) {
1222
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.sparse-bufferview-not-found', '', {});
1223
+ return;
1224
+ }
1225
+ const indexBytes = buffers[indicesView.buffer];
1226
+ const valueBytes = buffers[valuesView.buffer];
1227
+ if (indexBytes === undefined || valueBytes === undefined) {
1228
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.sparse-buffer-not-found', '', {});
1229
+ return;
1230
+ }
1231
+ const indexView = new DataView(indexBytes.buffer);
1232
+ const valueView = new DataView(valueBytes.buffer);
1233
+ const indexSize = COMPONENT_BYTE_SIZE[sparse.indices.componentType];
1234
+ const indexBase = indexBytes.byteOffset + (indicesView.byteOffset ?? 0) + (sparse.indices.byteOffset ?? 0);
1235
+ const valueSize = COMPONENT_BYTE_SIZE[valueComponentType];
1236
+ const valueBase = valueBytes.byteOffset + (valuesView.byteOffset ?? 0) + (sparse.values.byteOffset ?? 0);
1237
+ // Guard the packed index and value reads against the tighter of each sparse bufferView's declared window
1238
+ // and its real buffer length (an oversized sparse.count or a short window would otherwise read past the
1239
+ // DataView and throw, or pull unrelated bytes). The base accessor data is already valid, so a bad override
1240
+ // is skipped and the accessor survives with its base values — Recover.
1241
+ const indexLimit = Math.min(indexBytes.byteOffset + (indicesView.byteOffset ?? 0) + indicesView.byteLength, indexBytes.byteOffset + indexBytes.byteLength);
1242
+ const valueLimit = Math.min(valueBytes.byteOffset + (valuesView.byteOffset ?? 0) + valuesView.byteLength, valueBytes.byteOffset + valueBytes.byteLength);
1243
+ if (indexBase + sparse.count * indexSize > indexLimit ||
1244
+ valueBase + sparse.count * componentCount * valueSize > valueLimit) {
1245
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.sparse-past-buffer', '', {
1246
+ firstCount: sparse.count,
1247
+ });
1248
+ return;
1249
+ }
1250
+ // Every sparse override replaces a base element, so its destination index must be within [0, accessorCount).
1251
+ // A typed-array write past the base length is SILENTLY ignored — the override would vanish with no signal —
1252
+ // so pre-scan the indices and, if any is out of range, skip the whole override and keep the base (Recover).
1253
+ for (let s = 0; s < sparse.count; s++) {
1254
+ const targetIndex = readComponent(indexView, sparse.indices.componentType, indexBase + s * indexSize);
1255
+ if (targetIndex < 0 || targetIndex >= accessorCount) {
1256
+ tallyGltfDrop(gltfDrops, ImportDiagnosticSeverity.Recover, 'gltf.sparse-index-out-of-range', '', {
1257
+ firstCount: accessorCount,
1258
+ firstIndex: targetIndex,
1259
+ });
1260
+ return;
1261
+ }
1262
+ }
1263
+ for (let s = 0; s < sparse.count; s++) {
1264
+ const targetIndex = readComponent(indexView, sparse.indices.componentType, indexBase + s * indexSize);
1265
+ for (let c = 0; c < componentCount; c++) {
1266
+ const raw = readComponent(valueView, valueComponentType, valueBase + (s * componentCount + c) * valueSize);
1267
+ out[targetIndex * componentCount + c] = normalize ? normalizeComponent(valueComponentType, raw) : raw;
1268
+ }
1269
+ }
1270
+ }
1271
+ // Reads one component at a byte offset, little-endian per the glTF spec.
1272
+ function readComponent(view, componentType, offset) {
1273
+ switch (componentType) {
1274
+ case 5120:
1275
+ return view.getInt8(offset);
1276
+ case 5121:
1277
+ return view.getUint8(offset);
1278
+ case 5122:
1279
+ return view.getInt16(offset, true);
1280
+ case 5123:
1281
+ return view.getUint16(offset, true);
1282
+ case 5125:
1283
+ return view.getUint32(offset, true);
1284
+ default:
1285
+ return view.getFloat32(offset, true);
1286
+ }
1287
+ }
1288
+ // Walks a GLB container: validates the 12-byte header and returns the parsed JSON document plus the
1289
+ // optional BIN chunk. Returns null (with a warning) on any malformed header or chunk.
1290
+ function readGlbContainer(bytes, diagnostics) {
1291
+ if (bytes.byteLength < GLB_HEADER_BYTES) {
1292
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'glb.header-too-small', 'readGlbContainer');
1293
+ return null;
1294
+ }
1295
+ const source = bytes;
1296
+ const view = new DataView(source.buffer, source.byteOffset, source.byteLength);
1297
+ if (view.getUint32(0, true) !== GLB_MAGIC) {
1298
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'glb.wrong-magic', 'readGlbContainer');
1299
+ return null;
1300
+ }
1301
+ const version = view.getUint32(4, true);
1302
+ if (version !== 2) {
1303
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'glb.unsupported-version', 'readGlbContainer', {
1304
+ version,
1305
+ });
1306
+ return null;
1307
+ }
1308
+ const declaredLength = view.getUint32(8, true);
1309
+ const end = Math.min(declaredLength, source.byteLength);
1310
+ let document = null;
1311
+ let binary = null;
1312
+ let offset = GLB_HEADER_BYTES;
1313
+ while (offset + GLB_CHUNK_HEADER_BYTES <= end) {
1314
+ const chunkLength = view.getUint32(offset, true);
1315
+ const chunkType = view.getUint32(offset + 4, true);
1316
+ const dataStart = offset + GLB_CHUNK_HEADER_BYTES;
1317
+ if (dataStart + chunkLength > end) {
1318
+ // Recover, not Reject: this stops the chunk walk and keeps whatever chunks parsed before it. If a valid
1319
+ // JSON chunk was already read, the container is still usable and returned; if none was, the whole-input
1320
+ // refusal is the separate glb.no-json-chunk Reject below (which returns the null sentinel).
1321
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Recover, 'glb.chunk-past-end', 'readGlbContainer');
1322
+ break;
1323
+ }
1324
+ const chunkData = source.subarray(dataStart, dataStart + chunkLength);
1325
+ if (chunkType === GLB_JSON_CHUNK && document === null) {
1326
+ const json = new TextDecoder().decode(chunkData);
1327
+ try {
1328
+ document = JSON.parse(json);
1329
+ }
1330
+ catch {
1331
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'glb.json-chunk-invalid', 'readGlbContainer');
1332
+ return null;
1333
+ }
1334
+ }
1335
+ else if (chunkType === GLB_BIN_CHUNK && binary === null) {
1336
+ binary = chunkData;
1337
+ }
1338
+ offset = dataStart + chunkLength;
1339
+ }
1340
+ if (document === null) {
1341
+ reportImportDiagnostic(diagnostics, ImportDiagnosticSeverity.Reject, 'glb.no-json-chunk', 'readGlbContainer');
1342
+ return null;
1343
+ }
1344
+ return { binary, document };
1345
+ }
1346
+ function topLevelNodeIndices(nodes) {
1347
+ const referenced = new Set();
1348
+ for (const node of nodes) {
1349
+ if (node.children !== undefined)
1350
+ for (const c of node.children)
1351
+ referenced.add(c);
1352
+ }
1353
+ const roots = [];
1354
+ for (let i = 0; i < nodes.length; i++)
1355
+ if (!referenced.has(i))
1356
+ roots.push(i);
1357
+ return roots;
1358
+ }
1359
+ // Allocates a typed array matching the accessor's component type, so integer (e.g. uint32 index)
1360
+ // values survive without a float round-trip.
1361
+ function createComponentArray(componentType, length) {
1362
+ switch (componentType) {
1363
+ case 5120:
1364
+ return new Int8Array(length);
1365
+ case 5121:
1366
+ return new Uint8Array(length);
1367
+ case 5122:
1368
+ return new Int16Array(length);
1369
+ case 5123:
1370
+ return new Uint16Array(length);
1371
+ case 5125:
1372
+ return new Uint32Array(length);
1373
+ default:
1374
+ return new Float32Array(length);
1375
+ }
1376
+ }
1377
+ const COMPONENT_BYTE_SIZE = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
1378
+ const TYPE_COMPONENTS = { MAT2: 4, MAT3: 9, MAT4: 16, SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
1379
+ // glTF TRS animation target paths → Flight Scene3DAnimationPath. The 'weights' (morph) path is handled
1380
+ // separately by the caller (appendGltfWeightsChannels), because it binds to a mesh's weight array with a
1381
+ // mesh-specific track width rather than a fixed-width transform component, so it is not in this map.
1382
+ const GLTF_ANIMATION_PATHS = {
1383
+ rotation: Scene3DAnimationPathRotation,
1384
+ scale: Scene3DAnimationPathScale,
1385
+ translation: Scene3DAnimationPathTranslation,
1386
+ };
1387
+ // The required output-accessor element type per animated path (glTF spec). rotation is a VEC4 quaternion,
1388
+ // translation/scale are VEC3, weights are SCALAR (target-width-scaled by element count). An output whose type
1389
+ // disagrees would be silently reinterpreted at the wrong stride, so a mismatch faults the channel.
1390
+ const GLTF_ANIMATION_OUTPUT_TYPES = {
1391
+ rotation: 'VEC4',
1392
+ scale: 'VEC3',
1393
+ translation: 'VEC3',
1394
+ weights: 'SCALAR',
1395
+ };
1396
+ // glTF sampler interpolation → Flight AnimationInterpolation (same three modes, same CUBICSPLINE
1397
+ // in-tangent/value/out-tangent layout).
1398
+ const GLTF_SAMPLER_INTERPOLATIONS = {
1399
+ CUBICSPLINE: 'Cubic',
1400
+ LINEAR: 'Linear',
1401
+ STEP: 'Step',
1402
+ };
1403
+ // glTF sampler min/mag filter GL enums → Flight TextureFilter. glTF's mip-aware min filters
1404
+ // (LINEAR_MIPMAP_LINEAR etc.) map onto Flight's mip-aware filter names; the mag filter is always a
1405
+ // non-mip mode (NEAREST/LINEAR).
1406
+ const GLTF_TEXTURE_FILTER = {
1407
+ 9728: 'nearest',
1408
+ 9729: 'linear',
1409
+ 9984: 'nearest-mipmap-nearest',
1410
+ 9985: 'linear-mipmap-nearest',
1411
+ 9986: 'nearest-mipmap-linear',
1412
+ 9987: 'linear-mipmap-linear',
1413
+ };
1414
+ // Whether a glTF min-filter GL enum implies a sampled mip chain — the four *_MIPMAP_* modes do, the
1415
+ // plain NEAREST/LINEAR do not. Sets Sampler.mipmaps so a non-mip filter does not force mip generation.
1416
+ const GLTF_MIN_FILTER_MIPMAPS = {
1417
+ 9728: false,
1418
+ 9729: false,
1419
+ 9984: true,
1420
+ 9985: true,
1421
+ 9986: true,
1422
+ 9987: true,
1423
+ };
1424
+ // glTF sampler wrap GL enums → Flight TextureWrap. REPEAT (10497), CLAMP_TO_EDGE (33071),
1425
+ // MIRRORED_REPEAT (33648).
1426
+ const GLTF_TEXTURE_WRAP = {
1427
+ 10497: 'repeat',
1428
+ 33071: 'clamp-to-edge',
1429
+ 33648: 'mirror-repeat',
1430
+ };
1431
+ // GLB container constants: the header magic (`glTF` little-endian), chunk-type tags (`JSON` and
1432
+ // `BIN\0` little-endian), and the fixed header/chunk-header byte sizes.
1433
+ const GLB_MAGIC = 0x46546c67;
1434
+ const GLB_JSON_CHUNK = 0x4e4f534a;
1435
+ const GLB_BIN_CHUNK = 0x004e4942;
1436
+ const GLB_HEADER_BYTES = 12;
1437
+ const GLB_CHUNK_HEADER_BYTES = 8;
1438
+ // The canonical interleaved PBR vertex layout the mesh builders and scene-{gl,wgpu} renderers share,
1439
+ // plus the skinned record's floats-per-vertex — the same constants every scene-formats importer emits.
1440
+ import { buildEmbeddedImageResourceReference, buildExternalImageResourceReference, CANONICAL_FLOATS_PER_VERTEX, CANONICAL_LAYOUT, SKINNED_FLOATS_PER_VERTEX, } from './shared';
1441
+ // Records one offender against its (kind, discriminator) tally — the aggregate-once alternative to a
1442
+ // per-node/per-primitive/per-accessor `reportImportDiagnostic` (readAccessor alone runs once per attribute
1443
+ // per primitive). No-op (never allocates) when no collector is engaged. `firstDetail` is kept from the FIRST
1444
+ // offender; later ones only bump the count. The discriminator is the categorical sub-reason (never an
1445
+ // instance index/uri), so faults of the same kind across many elements collapse to one crumb.
1446
+ function tallyGltfDrop(tallies, severity, kind, discriminator, firstDetail) {
1447
+ if (tallies === null)
1448
+ return;
1449
+ const key = `${kind}|${discriminator}`;
1450
+ const existing = tallies.get(key);
1451
+ if (existing === undefined)
1452
+ tallies.set(key, { count: 1, detail: firstDetail, kind, severity });
1453
+ else
1454
+ existing.count++;
1455
+ }
1456
+ //# sourceMappingURL=gltfParse.js.map