@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,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
|