@forgeax/engine-gltf 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +204 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/animation-target-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/animation-target-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-material-samplers.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-material-samplers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-material-values.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-material-values.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-mixed-color.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-mixed-color.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-sampler-required.test-d.d.ts +2 -0
- package/dist/__tests__/bridge-sampler-required.test-d.d.ts.map +1 -0
- package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-skin-stride.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge-skin-stride.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bridge-test-helpers.d.ts +5 -0
- package/dist/__tests__/bridge-test-helpers.d.ts.map +1 -0
- package/dist/__tests__/bridge.unit.test.d.ts +2 -0
- package/dist/__tests__/bridge.unit.test.d.ts.map +1 -0
- package/dist/__tests__/cli-gltf.integration.test.d.ts +2 -0
- package/dist/__tests__/cli-gltf.integration.test.d.ts.map +1 -0
- package/dist/__tests__/color-0-accessor.unit.test.d.ts +2 -0
- package/dist/__tests__/color-0-accessor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/data-uri-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/data-uri-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/extension-admission.unit.test.d.ts +2 -0
- package/dist/__tests__/extension-admission.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-error-derived-views.test-d.d.ts +2 -0
- package/dist/__tests__/gltf-error-derived-views.test-d.d.ts.map +1 -0
- package/dist/__tests__/gltf-error-migration.test.d.ts +2 -0
- package/dist/__tests__/gltf-error-migration.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts +2 -0
- package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-importer.test.d.ts +2 -0
- package/dist/__tests__/gltf-importer.test.d.ts.map +1 -0
- package/dist/__tests__/gltf-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/gltf-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/gltf.unit.test.d.ts +2 -0
- package/dist/__tests__/gltf.unit.test.d.ts.map +1 -0
- package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts +2 -0
- package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts.map +1 -0
- package/dist/__tests__/material-missing-uv.unit.test.d.ts +2 -0
- package/dist/__tests__/material-missing-uv.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-pack-refs.integration.test.d.ts +2 -0
- package/dist/__tests__/material-pack-refs.integration.test.d.ts.map +1 -0
- package/dist/__tests__/material-per-slot-ir.unit.test.d.ts +2 -0
- package/dist/__tests__/material-per-slot-ir.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-texture-transform.unit.test.d.ts +2 -0
- package/dist/__tests__/material-texture-transform.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-unused-uv.unit.test.d.ts +2 -0
- package/dist/__tests__/material-unused-uv.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meshopt-decode.unit.test.d.ts +2 -0
- package/dist/__tests__/meshopt-decode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meshopt-falsifier.unit.test.d.ts +2 -0
- package/dist/__tests__/meshopt-falsifier.unit.test.d.ts.map +1 -0
- package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts +2 -0
- package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts +2 -0
- package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-import.integration.test.d.ts +2 -0
- package/dist/__tests__/morph-import.integration.test.d.ts.map +1 -0
- package/dist/__tests__/morph-import.unit.test.d.ts +2 -0
- package/dist/__tests__/morph-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-decode.unit.test.d.ts +2 -0
- package/dist/__tests__/parse-decode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-gltf-multi-uv.test.d.ts +2 -0
- package/dist/__tests__/parse-gltf-multi-uv.test.d.ts.map +1 -0
- package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts +2 -0
- package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-gltf.unit.test.d.ts +2 -0
- package/dist/__tests__/parse-gltf.unit.test.d.ts.map +1 -0
- package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
- package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
- package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/source-key-diagnostics.integration.test.d.ts +2 -0
- package/dist/__tests__/source-key-diagnostics.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-key-producer.integration.test.d.ts +2 -0
- package/dist/__tests__/source-key-producer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-key.unit.test.d.ts +2 -0
- package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
- package/dist/accessor/decode-accessor.d.ts +75 -0
- package/dist/accessor/decode-accessor.d.ts.map +1 -0
- package/dist/accessor/decode-color.d.ts +15 -0
- package/dist/accessor/decode-color.d.ts.map +1 -0
- package/dist/accessor/index.d.ts +3 -0
- package/dist/accessor/index.d.ts.map +1 -0
- package/dist/bridge.d.ts +103 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/check-extensions.d.ts +29 -0
- package/dist/check-extensions.d.ts.map +1 -0
- package/dist/cli-gltf.d.ts +8 -0
- package/dist/cli-gltf.d.ts.map +1 -0
- package/dist/cli-gltf.mjs +2435 -0
- package/dist/cli-gltf.mjs.map +1 -0
- package/dist/data-uri.d.ts +6 -0
- package/dist/data-uri.d.ts.map +1 -0
- package/dist/errors.d.ts +201 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/extract-image-bytes.d.ts +42 -0
- package/dist/extract-image-bytes.d.ts.map +1 -0
- package/dist/gltf-importer.d.ts +21 -0
- package/dist/gltf-importer.d.ts.map +1 -0
- package/dist/image-color-space.d.ts +35 -0
- package/dist/image-color-space.d.ts.map +1 -0
- package/dist/importer-entry.d.ts +10 -0
- package/dist/importer-entry.d.ts.map +1 -0
- package/dist/importer-entry.mjs +3378 -0
- package/dist/importer-entry.mjs.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3673 -0
- package/dist/index.mjs.map +1 -0
- package/dist/meshopt-decode.d.ts +39 -0
- package/dist/meshopt-decode.d.ts.map +1 -0
- package/dist/node-file-entry.d.ts +14 -0
- package/dist/node-file-entry.d.ts.map +1 -0
- package/dist/node-file-entry.mjs +1946 -0
- package/dist/node-file-entry.mjs.map +1 -0
- package/dist/node-path.d.ts +14 -0
- package/dist/node-path.d.ts.map +1 -0
- package/dist/parse-animation.d.ts +70 -0
- package/dist/parse-animation.d.ts.map +1 -0
- package/dist/parse-glb-chunks.d.ts +25 -0
- package/dist/parse-glb-chunks.d.ts.map +1 -0
- package/dist/parse-glb.d.ts +3 -0
- package/dist/parse-glb.d.ts.map +1 -0
- package/dist/parse-gltf-header.d.ts +18 -0
- package/dist/parse-gltf-header.d.ts.map +1 -0
- package/dist/parse-gltf.d.ts +263 -0
- package/dist/parse-gltf.d.ts.map +1 -0
- package/dist/parse-skin.d.ts +70 -0
- package/dist/parse-skin.d.ts.map +1 -0
- package/dist/reimport-reuse-meta.d.ts +48 -0
- package/dist/reimport-reuse-meta.d.ts.map +1 -0
- package/dist/serialize-meta.d.ts +2 -0
- package/dist/serialize-meta.d.ts.map +1 -0
- package/dist/source-key.d.ts +44 -0
- package/dist/source-key.d.ts.map +1 -0
- package/dist/sub-asset-key.d.ts +12 -0
- package/dist/sub-asset-key.d.ts.map +1 -0
- package/dist/to-asset-pack.d.ts +2 -0
- package/dist/to-asset-pack.d.ts.map +1 -0
- package/dist/transform.d.ts +37 -0
- package/dist/transform.d.ts.map +1 -0
- package/package.json +92 -0
- package/src/__tests__/animation-target-errors.unit.test.ts +51 -0
- package/src/__tests__/bridge-material-samplers.unit.test.ts +67 -0
- package/src/__tests__/bridge-material-values.unit.test.ts +106 -0
- package/src/__tests__/bridge-mixed-color.unit.test.ts +100 -0
- package/src/__tests__/bridge-sampler-required.test-d.ts +59 -0
- package/src/__tests__/bridge-skin-shader-route.unit.test.ts +42 -0
- package/src/__tests__/bridge-skin-stride.unit.test.ts +115 -0
- package/src/__tests__/bridge-test-helpers.ts +9 -0
- package/src/__tests__/bridge.unit.test.ts +1227 -0
- package/src/__tests__/cli-gltf.integration.test.ts +122 -0
- package/src/__tests__/color-0-accessor.unit.test.ts +174 -0
- package/src/__tests__/data-uri-surface.unit.test.ts +26 -0
- package/src/__tests__/extension-admission.unit.test.ts +19 -0
- package/src/__tests__/fixtures/color-0/float-vec3.gltf +13 -0
- package/src/__tests__/fixtures/color-0/interleaved-vec3.gltf +6 -0
- package/src/__tests__/fixtures/color-0/normalized-ubyte.gltf +6 -0
- package/src/__tests__/fixtures/color-0/normalized-ushort.gltf +6 -0
- package/src/__tests__/gltf-error-derived-views.test-d.ts +152 -0
- package/src/__tests__/gltf-error-migration.test.ts +147 -0
- package/src/__tests__/gltf-error-policy-owner.unit.test.ts +185 -0
- package/src/__tests__/gltf-importer.test.ts +12 -0
- package/src/__tests__/gltf-local-artifacts.test.ts +11 -0
- package/src/__tests__/gltf.unit.test.ts +2975 -0
- package/src/__tests__/malformed-buffer-data-uri.integration.test.ts +140 -0
- package/src/__tests__/material-missing-uv.unit.test.ts +29 -0
- package/src/__tests__/material-pack-refs.integration.test.ts +58 -0
- package/src/__tests__/material-per-slot-ir.unit.test.ts +90 -0
- package/src/__tests__/material-texture-transform.unit.test.ts +105 -0
- package/src/__tests__/material-unused-uv.unit.test.ts +44 -0
- package/src/__tests__/meshopt-decode.unit.test.ts +87 -0
- package/src/__tests__/meshopt-falsifier.unit.test.ts +92 -0
- package/src/__tests__/meshopt-mode-filter-owner.test-d.ts +79 -0
- package/src/__tests__/meshopt-real-matrix.integration.test.ts +370 -0
- package/src/__tests__/morph-import.integration.test.ts +115 -0
- package/src/__tests__/morph-import.unit.test.ts +62 -0
- package/src/__tests__/parse-decode.unit.test.ts +919 -0
- package/src/__tests__/parse-gltf-multi-uv.test.ts +238 -0
- package/src/__tests__/parse-gltf-skin-attrs.unit.test.ts +191 -0
- package/src/__tests__/parse-gltf.unit.test.ts +133 -0
- package/src/__tests__/pick-e2e.integration.test.ts +238 -0
- package/src/__tests__/public-surface.unit.test.ts +10 -0
- package/src/__tests__/source-key-diagnostics.integration.test.ts +140 -0
- package/src/__tests__/source-key-producer.integration.test.ts +171 -0
- package/src/__tests__/source-key.unit.test.ts +159 -0
- package/src/accessor/decode-accessor.ts +309 -0
- package/src/accessor/decode-color.ts +128 -0
- package/src/accessor/index.ts +15 -0
- package/src/bridge.ts +928 -0
- package/src/check-extensions.ts +85 -0
- package/src/cli-gltf.ts +339 -0
- package/src/data-uri.ts +29 -0
- package/src/errors.ts +386 -0
- package/src/extract-image-bytes.ts +343 -0
- package/src/gltf-importer.ts +976 -0
- package/src/image-color-space.ts +104 -0
- package/src/importer-entry.ts +24 -0
- package/src/index.ts +148 -0
- package/src/meshopt-decode.ts +200 -0
- package/src/node-file-entry.ts +98 -0
- package/src/node-path.ts +41 -0
- package/src/parse-animation.ts +286 -0
- package/src/parse-glb-chunks.ts +167 -0
- package/src/parse-glb.ts +16 -0
- package/src/parse-gltf-header.ts +58 -0
- package/src/parse-gltf.ts +1731 -0
- package/src/parse-skin.ts +238 -0
- package/src/reimport-reuse-meta.ts +134 -0
- package/src/serialize-meta.ts +24 -0
- package/src/source-key.ts +106 -0
- package/src/sub-asset-key.ts +61 -0
- package/src/to-asset-pack.ts +8 -0
- package/src/transform.ts +117 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
// Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
|
|
2
|
+
// biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
|
|
3
|
+
//
|
|
4
|
+
// Source files (N=6):
|
|
5
|
+
// - packages/gltf/src/__tests__/attribute-decode.test.ts
|
|
6
|
+
// - packages/gltf/src/__tests__/decode-accessor.test.ts
|
|
7
|
+
// - packages/gltf/src/__tests__/decompose-node-transform.test.ts
|
|
8
|
+
// - packages/gltf/src/__tests__/parse-animation.test.ts
|
|
9
|
+
// - packages/gltf/src/__tests__/parse-header.test.ts
|
|
10
|
+
// - packages/gltf/src/__tests__/parse-skin.test.ts
|
|
11
|
+
//
|
|
12
|
+
// Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
|
|
13
|
+
// source as ancestorTitles[0]. Top-level imports merged + deduped.
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
16
|
+
import { COMPONENT_TYPE, decodeAccessor } from '../accessor/decode-accessor.js';
|
|
17
|
+
import { parseAnimation } from '../parse-animation.js';
|
|
18
|
+
import { parseGlbChunks, parseGltfHeader } from '../parse-glb-chunks.js';
|
|
19
|
+
import type { GltfMeshIr } from '../parse-gltf.js';
|
|
20
|
+
import { parseGltf } from '../parse-gltf.js';
|
|
21
|
+
import { parseSkin } from '../parse-skin.js';
|
|
22
|
+
import { decomposeNodeTransform } from '../transform.js';
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
// ─── from attribute-decode.test.ts ───
|
|
26
|
+
|
|
27
|
+
const noopLoader = async (_: string) => new ArrayBuffer(0);
|
|
28
|
+
|
|
29
|
+
describe('attribute-decode.test.ts', () => {
|
|
30
|
+
describe('parseGltf attribute decode (T-M2-02)', () => {
|
|
31
|
+
function buildPositionNormalTexcoordTangentJson(includeTangent: boolean): unknown {
|
|
32
|
+
const posBytes = new Uint8Array(12);
|
|
33
|
+
const posF32 = new Float32Array(posBytes.buffer);
|
|
34
|
+
posF32[0] = 1.0;
|
|
35
|
+
posF32[1] = 2.0;
|
|
36
|
+
posF32[2] = 3.0;
|
|
37
|
+
|
|
38
|
+
const normBytes = new Uint8Array(36);
|
|
39
|
+
const normF32 = new Float32Array(normBytes.buffer);
|
|
40
|
+
normF32[0] = 0.0;
|
|
41
|
+
normF32[1] = 1.0;
|
|
42
|
+
normF32[2] = 0.0;
|
|
43
|
+
normF32[3] = 0.0;
|
|
44
|
+
normF32[4] = 1.0;
|
|
45
|
+
normF32[5] = 0.0;
|
|
46
|
+
normF32[6] = 1.0;
|
|
47
|
+
normF32[7] = 0.0;
|
|
48
|
+
normF32[8] = 0.0;
|
|
49
|
+
|
|
50
|
+
const texBytes = new Uint8Array(24);
|
|
51
|
+
const texF32 = new Float32Array(texBytes.buffer);
|
|
52
|
+
texF32[0] = 0.0;
|
|
53
|
+
texF32[1] = 0.0;
|
|
54
|
+
texF32[2] = 0.5;
|
|
55
|
+
texF32[3] = 0.5;
|
|
56
|
+
texF32[4] = 1.0;
|
|
57
|
+
texF32[5] = 0.0;
|
|
58
|
+
|
|
59
|
+
const tanBytes = new Uint8Array(48);
|
|
60
|
+
const tanF32 = new Float32Array(tanBytes.buffer);
|
|
61
|
+
tanF32[0] = 1.0;
|
|
62
|
+
tanF32[1] = 0.0;
|
|
63
|
+
tanF32[2] = 0.0;
|
|
64
|
+
tanF32[3] = 1.0;
|
|
65
|
+
tanF32[4] = 1.0;
|
|
66
|
+
tanF32[5] = 0.0;
|
|
67
|
+
tanF32[6] = 0.0;
|
|
68
|
+
tanF32[7] = 1.0;
|
|
69
|
+
tanF32[8] = 1.0;
|
|
70
|
+
tanF32[9] = 0.0;
|
|
71
|
+
tanF32[10] = 0.0;
|
|
72
|
+
tanF32[11] = 1.0;
|
|
73
|
+
|
|
74
|
+
const buffers: Array<{ uri: string }> = [];
|
|
75
|
+
const bufferViews: Array<Record<string, unknown>> = [];
|
|
76
|
+
const accessors: Array<Record<string, unknown>> = [];
|
|
77
|
+
|
|
78
|
+
function addBuf(
|
|
79
|
+
bytes: Uint8Array,
|
|
80
|
+
viewByteLength: number,
|
|
81
|
+
type: string,
|
|
82
|
+
count: number,
|
|
83
|
+
): number {
|
|
84
|
+
const b64 = btoa(String.fromCharCode(...bytes));
|
|
85
|
+
const bufIdx = buffers.length;
|
|
86
|
+
buffers.push({ uri: `data:application/octet-stream;base64,${b64}` });
|
|
87
|
+
const bvIdx = bufferViews.length;
|
|
88
|
+
bufferViews.push({ buffer: bufIdx, byteOffset: 0, byteLength: viewByteLength });
|
|
89
|
+
const acIdx = accessors.length;
|
|
90
|
+
accessors.push({ bufferView: bvIdx, componentType: 5126, type, count });
|
|
91
|
+
return acIdx;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const posIdx = addBuf(posBytes, 12, 'VEC3', 1);
|
|
95
|
+
const normIdx = addBuf(normBytes, 36, 'VEC3', 3);
|
|
96
|
+
const texIdx = addBuf(texBytes, 24, 'VEC2', 3);
|
|
97
|
+
const tanIdx = includeTangent ? addBuf(tanBytes, 48, 'VEC4', 3) : -1;
|
|
98
|
+
|
|
99
|
+
const primAttributes: Record<string, number> = {
|
|
100
|
+
POSITION: posIdx,
|
|
101
|
+
NORMAL: normIdx,
|
|
102
|
+
TEXCOORD_0: texIdx,
|
|
103
|
+
};
|
|
104
|
+
if (includeTangent) {
|
|
105
|
+
primAttributes.TANGENT = tanIdx;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
asset: { version: '2.0' },
|
|
110
|
+
buffers,
|
|
111
|
+
bufferViews,
|
|
112
|
+
accessors,
|
|
113
|
+
meshes: [{ name: 'AttrMesh', primitives: [{ attributes: primAttributes }] }],
|
|
114
|
+
materials: [],
|
|
115
|
+
nodes: [],
|
|
116
|
+
scenes: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
it('decodes NORMAL as Float32Array with 3-component normals', async () => {
|
|
121
|
+
const json = buildPositionNormalTexcoordTangentJson(false);
|
|
122
|
+
const result = await parseGltf(json, noopLoader, '/test.gltf');
|
|
123
|
+
expect(result.ok).toBe(true);
|
|
124
|
+
if (!result.ok) return;
|
|
125
|
+
|
|
126
|
+
const mesh: GltfMeshIr | undefined = result.value.meshes[0];
|
|
127
|
+
expect(mesh).toBeDefined();
|
|
128
|
+
if (!mesh) return;
|
|
129
|
+
|
|
130
|
+
expect(mesh.normals).toBeDefined();
|
|
131
|
+
const normals = mesh.normals;
|
|
132
|
+
if (!normals) return;
|
|
133
|
+
expect(normals.length).toBe(9);
|
|
134
|
+
expect(normals[0]).toBeCloseTo(0.0);
|
|
135
|
+
expect(normals[1]).toBeCloseTo(1.0);
|
|
136
|
+
expect(normals[2]).toBeCloseTo(0.0);
|
|
137
|
+
expect(normals[6]).toBeCloseTo(1.0);
|
|
138
|
+
expect(normals[7]).toBeCloseTo(0.0);
|
|
139
|
+
expect(normals[8]).toBeCloseTo(0.0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('decodes TEXCOORD_0 as Float32Array with 2-component UVs', async () => {
|
|
143
|
+
const json = buildPositionNormalTexcoordTangentJson(false);
|
|
144
|
+
const result = await parseGltf(json, noopLoader, '/test.gltf');
|
|
145
|
+
expect(result.ok).toBe(true);
|
|
146
|
+
if (!result.ok) return;
|
|
147
|
+
|
|
148
|
+
const mesh: GltfMeshIr | undefined = result.value.meshes[0];
|
|
149
|
+
expect(mesh).toBeDefined();
|
|
150
|
+
if (!mesh) return;
|
|
151
|
+
|
|
152
|
+
expect(mesh.texcoord0).toBeDefined();
|
|
153
|
+
const uvs = mesh.texcoord0;
|
|
154
|
+
if (!uvs) return;
|
|
155
|
+
expect(uvs.length).toBe(6);
|
|
156
|
+
expect(uvs[0]).toBeCloseTo(0.0);
|
|
157
|
+
expect(uvs[1]).toBeCloseTo(0.0);
|
|
158
|
+
expect(uvs[2]).toBeCloseTo(0.5);
|
|
159
|
+
expect(uvs[3]).toBeCloseTo(0.5);
|
|
160
|
+
expect(uvs[4]).toBeCloseTo(1.0);
|
|
161
|
+
expect(uvs[5]).toBeCloseTo(0.0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('decodes TANGENT as Float32Array with 4-component tangents when present', async () => {
|
|
165
|
+
const json = buildPositionNormalTexcoordTangentJson(true);
|
|
166
|
+
const result = await parseGltf(json, noopLoader, '/test.gltf');
|
|
167
|
+
expect(result.ok).toBe(true);
|
|
168
|
+
if (!result.ok) return;
|
|
169
|
+
|
|
170
|
+
const mesh: GltfMeshIr | undefined = result.value.meshes[0];
|
|
171
|
+
expect(mesh).toBeDefined();
|
|
172
|
+
if (!mesh) return;
|
|
173
|
+
|
|
174
|
+
expect(mesh.tangents).toBeDefined();
|
|
175
|
+
const tangents = mesh.tangents;
|
|
176
|
+
if (!tangents) return;
|
|
177
|
+
expect(tangents.length).toBe(12);
|
|
178
|
+
expect(tangents[0]).toBeCloseTo(1.0);
|
|
179
|
+
expect(tangents[1]).toBeCloseTo(0.0);
|
|
180
|
+
expect(tangents[2]).toBeCloseTo(0.0);
|
|
181
|
+
expect(tangents[3]).toBeCloseTo(1.0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('TANGENT is undefined when attribute is absent', async () => {
|
|
185
|
+
const json = buildPositionNormalTexcoordTangentJson(false);
|
|
186
|
+
const result = await parseGltf(json, noopLoader, '/test.gltf');
|
|
187
|
+
expect(result.ok).toBe(true);
|
|
188
|
+
if (!result.ok) return;
|
|
189
|
+
|
|
190
|
+
const mesh: GltfMeshIr | undefined = result.value.meshes[0];
|
|
191
|
+
expect(mesh).toBeDefined();
|
|
192
|
+
if (!mesh) return;
|
|
193
|
+
|
|
194
|
+
expect(mesh.tangents).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('positions still decoded correctly alongside new attributes', async () => {
|
|
198
|
+
const json = buildPositionNormalTexcoordTangentJson(false);
|
|
199
|
+
const result = await parseGltf(json, noopLoader, '/test.gltf');
|
|
200
|
+
expect(result.ok).toBe(true);
|
|
201
|
+
if (!result.ok) return;
|
|
202
|
+
|
|
203
|
+
const mesh: GltfMeshIr | undefined = result.value.meshes[0];
|
|
204
|
+
expect(mesh).toBeDefined();
|
|
205
|
+
if (!mesh) return;
|
|
206
|
+
|
|
207
|
+
expect(mesh.positions.length).toBe(3);
|
|
208
|
+
expect(mesh.positions[0]).toBeCloseTo(1.0);
|
|
209
|
+
expect(mesh.positions[1]).toBeCloseTo(2.0);
|
|
210
|
+
expect(mesh.positions[2]).toBeCloseTo(3.0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// U32 indices (componentType 5125) are preserved end-to-end rather than
|
|
214
|
+
// rejected. Blender / UniGLTF commonly export U32 even for tiny meshes;
|
|
215
|
+
// the decoder keeps the source width (bridge.ts narrows to U16 when the
|
|
216
|
+
// merged maxIndex fits). Regression guard for the prior over-rejection
|
|
217
|
+
// that surfaced as gltf-accessor-type-mismatch / unknownComponentType.
|
|
218
|
+
it('preserves U32 SCALAR INDICES as Uint32Array (no rejection)', async () => {
|
|
219
|
+
const posBytes = new Uint8Array(36);
|
|
220
|
+
const posF32 = new Float32Array(posBytes.buffer);
|
|
221
|
+
// 3 vertices forming one triangle.
|
|
222
|
+
posF32.set([0, 0, 0, 1, 0, 0, 0, 1, 0]);
|
|
223
|
+
const idxBytes = new Uint8Array(12);
|
|
224
|
+
new Uint32Array(idxBytes.buffer).set([0, 1, 2]);
|
|
225
|
+
|
|
226
|
+
const toDataUri = (bytes: Uint8Array) =>
|
|
227
|
+
`data:application/octet-stream;base64,${btoa(String.fromCharCode(...bytes))}`;
|
|
228
|
+
|
|
229
|
+
const json = {
|
|
230
|
+
asset: { version: '2.0' },
|
|
231
|
+
buffers: [{ uri: toDataUri(posBytes) }, { uri: toDataUri(idxBytes) }],
|
|
232
|
+
bufferViews: [
|
|
233
|
+
{ buffer: 0, byteOffset: 0, byteLength: 36 },
|
|
234
|
+
{ buffer: 1, byteOffset: 0, byteLength: 12 },
|
|
235
|
+
],
|
|
236
|
+
accessors: [
|
|
237
|
+
{ bufferView: 0, componentType: 5126, type: 'VEC3', count: 3 },
|
|
238
|
+
{ bufferView: 1, componentType: 5125, type: 'SCALAR', count: 3 },
|
|
239
|
+
],
|
|
240
|
+
meshes: [{ name: 'U32Mesh', primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
|
|
241
|
+
materials: [],
|
|
242
|
+
nodes: [],
|
|
243
|
+
scenes: [],
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const result = await parseGltf(json, noopLoader, '/u32.gltf');
|
|
247
|
+
expect(result.ok).toBe(true);
|
|
248
|
+
if (!result.ok) return;
|
|
249
|
+
|
|
250
|
+
const mesh: GltfMeshIr | undefined = result.value.meshes[0];
|
|
251
|
+
expect(mesh).toBeDefined();
|
|
252
|
+
if (!mesh) return;
|
|
253
|
+
expect(mesh.indices).toBeInstanceOf(Uint32Array);
|
|
254
|
+
expect(Array.from(mesh.indices ?? [])).toEqual([0, 1, 2]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
{
|
|
261
|
+
// ─── from decode-accessor.test.ts ───
|
|
262
|
+
|
|
263
|
+
describe('decode-accessor.test.ts', () => {
|
|
264
|
+
describe('decodeAccessor (w9 fixture set)', () => {
|
|
265
|
+
it('(a) decodes sequential POSITION VEC3 F32 accessor', () => {
|
|
266
|
+
const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]);
|
|
267
|
+
const buffer = new Uint8Array(positions.buffer);
|
|
268
|
+
const result = decodeAccessor({
|
|
269
|
+
accessorIndex: 0,
|
|
270
|
+
accessor: {
|
|
271
|
+
bufferView: 0,
|
|
272
|
+
componentType: COMPONENT_TYPE.F32,
|
|
273
|
+
count: 4,
|
|
274
|
+
type: 'VEC3',
|
|
275
|
+
},
|
|
276
|
+
bufferView: { buffer: 0, byteLength: positions.byteLength },
|
|
277
|
+
buffer,
|
|
278
|
+
role: 'attribute',
|
|
279
|
+
});
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
if (!result.ok) return;
|
|
282
|
+
expect(result.value.kind).toBe('f32');
|
|
283
|
+
expect(Array.from(result.value.data)).toEqual(Array.from(positions));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('(b) rejects sparse accessor', () => {
|
|
287
|
+
const result = decodeAccessor({
|
|
288
|
+
accessorIndex: 3,
|
|
289
|
+
accessor: {
|
|
290
|
+
bufferView: 0,
|
|
291
|
+
componentType: COMPONENT_TYPE.F32,
|
|
292
|
+
count: 4,
|
|
293
|
+
type: 'VEC3',
|
|
294
|
+
sparse: { count: 1, indices: {}, values: {} },
|
|
295
|
+
},
|
|
296
|
+
bufferView: { buffer: 0, byteLength: 48 },
|
|
297
|
+
buffer: new Uint8Array(48),
|
|
298
|
+
role: 'attribute',
|
|
299
|
+
});
|
|
300
|
+
expect(result.ok).toBe(false);
|
|
301
|
+
if (result.ok) return;
|
|
302
|
+
expect(result.error.code).toBe('gltf-accessor-type-mismatch');
|
|
303
|
+
if (result.error.code !== 'gltf-accessor-type-mismatch') return;
|
|
304
|
+
expect(result.error.detail.accessorIndex).toBe(3);
|
|
305
|
+
expect(result.error.detail.reason).toBe('sparse');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('(c) rejects morph-target accessor (caller flag)', () => {
|
|
309
|
+
const result = decodeAccessor(
|
|
310
|
+
{
|
|
311
|
+
accessorIndex: 7,
|
|
312
|
+
accessor: {
|
|
313
|
+
bufferView: 0,
|
|
314
|
+
componentType: COMPONENT_TYPE.F32,
|
|
315
|
+
count: 4,
|
|
316
|
+
type: 'VEC3',
|
|
317
|
+
},
|
|
318
|
+
bufferView: { buffer: 0, byteLength: 48 },
|
|
319
|
+
buffer: new Uint8Array(48),
|
|
320
|
+
role: 'attribute',
|
|
321
|
+
},
|
|
322
|
+
{ morph: true },
|
|
323
|
+
);
|
|
324
|
+
expect(result.ok).toBe(false);
|
|
325
|
+
if (result.ok) return;
|
|
326
|
+
expect(result.error.code).toBe('gltf-accessor-type-mismatch');
|
|
327
|
+
if (result.error.code !== 'gltf-accessor-type-mismatch') return;
|
|
328
|
+
expect(result.error.detail.accessorIndex).toBe(7);
|
|
329
|
+
expect(result.error.detail.reason).toBe('morph');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('(d) rejects interleaved layout (byteStride != elementSize)', () => {
|
|
333
|
+
const result = decodeAccessor({
|
|
334
|
+
accessorIndex: 1,
|
|
335
|
+
accessor: {
|
|
336
|
+
bufferView: 0,
|
|
337
|
+
componentType: COMPONENT_TYPE.F32,
|
|
338
|
+
count: 4,
|
|
339
|
+
type: 'VEC3',
|
|
340
|
+
},
|
|
341
|
+
bufferView: { buffer: 0, byteLength: 32, byteStride: 32 },
|
|
342
|
+
buffer: new Uint8Array(64),
|
|
343
|
+
role: 'attribute',
|
|
344
|
+
});
|
|
345
|
+
expect(result.ok).toBe(false);
|
|
346
|
+
if (result.ok) return;
|
|
347
|
+
expect(result.error.code).toBe('gltf-accessor-type-mismatch');
|
|
348
|
+
if (result.error.code !== 'gltf-accessor-type-mismatch') return;
|
|
349
|
+
expect(result.error.detail.reason).toBe('interleaved');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('(e) widens U8 INDICES to U16', () => {
|
|
353
|
+
const indices = new Uint8Array([0, 1, 2, 0, 2, 3]);
|
|
354
|
+
const result = decodeAccessor({
|
|
355
|
+
accessorIndex: 2,
|
|
356
|
+
accessor: {
|
|
357
|
+
bufferView: 0,
|
|
358
|
+
componentType: COMPONENT_TYPE.U8,
|
|
359
|
+
count: 6,
|
|
360
|
+
type: 'SCALAR',
|
|
361
|
+
},
|
|
362
|
+
bufferView: { buffer: 0, byteLength: 6 },
|
|
363
|
+
buffer: indices,
|
|
364
|
+
role: 'indices',
|
|
365
|
+
});
|
|
366
|
+
expect(result.ok).toBe(true);
|
|
367
|
+
if (!result.ok) return;
|
|
368
|
+
expect(result.value.kind).toBe('u16');
|
|
369
|
+
expect(Array.from(result.value.data as Uint16Array)).toEqual([0, 1, 2, 0, 2, 3]);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('(f) rejects accessor whose range overruns bufferView.byteLength', () => {
|
|
373
|
+
const result = decodeAccessor({
|
|
374
|
+
accessorIndex: 5,
|
|
375
|
+
accessor: {
|
|
376
|
+
bufferView: 0,
|
|
377
|
+
byteOffset: 32,
|
|
378
|
+
componentType: COMPONENT_TYPE.F32,
|
|
379
|
+
count: 4,
|
|
380
|
+
type: 'VEC3',
|
|
381
|
+
},
|
|
382
|
+
bufferView: { buffer: 0, byteLength: 48 },
|
|
383
|
+
buffer: new Uint8Array(48),
|
|
384
|
+
role: 'attribute',
|
|
385
|
+
});
|
|
386
|
+
expect(result.ok).toBe(false);
|
|
387
|
+
if (result.ok) return;
|
|
388
|
+
expect(result.error.code).toBe('gltf-buffer-out-of-bounds');
|
|
389
|
+
if (result.error.code !== 'gltf-buffer-out-of-bounds') return;
|
|
390
|
+
expect(result.error.detail.accessor).toBe(5);
|
|
391
|
+
expect(result.error.detail.bufferIndex).toBe(0);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('accepts U16 SCALAR INDICES without widening', () => {
|
|
395
|
+
const indices = new Uint16Array([0, 1, 2, 3, 4, 5]);
|
|
396
|
+
const result = decodeAccessor({
|
|
397
|
+
accessorIndex: 9,
|
|
398
|
+
accessor: {
|
|
399
|
+
bufferView: 0,
|
|
400
|
+
componentType: COMPONENT_TYPE.U16,
|
|
401
|
+
count: 6,
|
|
402
|
+
type: 'SCALAR',
|
|
403
|
+
},
|
|
404
|
+
bufferView: { buffer: 0, byteLength: indices.byteLength },
|
|
405
|
+
buffer: new Uint8Array(indices.buffer),
|
|
406
|
+
role: 'indices',
|
|
407
|
+
});
|
|
408
|
+
expect(result.ok).toBe(true);
|
|
409
|
+
if (!result.ok) return;
|
|
410
|
+
expect(result.value.kind).toBe('u16');
|
|
411
|
+
expect(Array.from(result.value.data)).toEqual([0, 1, 2, 3, 4, 5]);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
{
|
|
418
|
+
// ─── from decompose-node-transform.test.ts ───
|
|
419
|
+
|
|
420
|
+
describe('decompose-node-transform.test.ts', () => {
|
|
421
|
+
describe('decomposeNodeTransform (w10 fixture set)', () => {
|
|
422
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
423
|
+
beforeEach(() => {
|
|
424
|
+
stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
425
|
+
});
|
|
426
|
+
afterEach(() => {
|
|
427
|
+
stderrSpy.mockRestore();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('(a) decomposes matrix-only node (translation 1/2/3, identity rotation/scale)', () => {
|
|
431
|
+
const matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1];
|
|
432
|
+
const diagnostics = { matrixTrsCoexistNodes: [] as number[] };
|
|
433
|
+
const trs = decomposeNodeTransform({ matrix }, 0, diagnostics);
|
|
434
|
+
expect(trs.translation).toEqual([1, 2, 3]);
|
|
435
|
+
expect(Array.from(trs.rotation)).toEqual([0, 0, 0, 1]);
|
|
436
|
+
expect(trs.scale).toEqual([1, 1, 1]);
|
|
437
|
+
expect(diagnostics.matrixTrsCoexistNodes).toEqual([]);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('(b) returns explicit TRS-only fields verbatim', () => {
|
|
441
|
+
const diagnostics = { matrixTrsCoexistNodes: [] as number[] };
|
|
442
|
+
const trs = decomposeNodeTransform(
|
|
443
|
+
{
|
|
444
|
+
translation: [4, 5, 6],
|
|
445
|
+
rotation: [0, 0, 0, 1],
|
|
446
|
+
scale: [2, 2, 2],
|
|
447
|
+
},
|
|
448
|
+
1,
|
|
449
|
+
diagnostics,
|
|
450
|
+
);
|
|
451
|
+
expect(trs.translation).toEqual([4, 5, 6]);
|
|
452
|
+
expect(trs.rotation).toEqual([0, 0, 0, 1]);
|
|
453
|
+
expect(trs.scale).toEqual([2, 2, 2]);
|
|
454
|
+
expect(diagnostics.matrixTrsCoexistNodes).toEqual([]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('(c) returns identity TRS when all fields are absent', () => {
|
|
458
|
+
const diagnostics = { matrixTrsCoexistNodes: [] as number[] };
|
|
459
|
+
const trs = decomposeNodeTransform({}, 2, diagnostics);
|
|
460
|
+
expect(trs.translation).toEqual([0, 0, 0]);
|
|
461
|
+
expect(trs.rotation).toEqual([0, 0, 0, 1]);
|
|
462
|
+
expect(trs.scale).toEqual([1, 1, 1]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('(d) prefers matrix when matrix + TRS coexist; warns + records node index', () => {
|
|
466
|
+
const matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 7, 8, 9, 1];
|
|
467
|
+
const diagnostics = { matrixTrsCoexistNodes: [] as number[] };
|
|
468
|
+
const trs = decomposeNodeTransform(
|
|
469
|
+
{
|
|
470
|
+
matrix,
|
|
471
|
+
translation: [99, 99, 99],
|
|
472
|
+
rotation: [0.1, 0.2, 0.3, 0.9],
|
|
473
|
+
scale: [3, 3, 3],
|
|
474
|
+
},
|
|
475
|
+
5,
|
|
476
|
+
diagnostics,
|
|
477
|
+
);
|
|
478
|
+
expect(trs.translation).toEqual([7, 8, 9]);
|
|
479
|
+
expect(diagnostics.matrixTrsCoexistNodes).toEqual([5]);
|
|
480
|
+
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
|
481
|
+
const message = stderrSpy.mock.calls[0]?.[0];
|
|
482
|
+
expect(typeof message).toBe('string');
|
|
483
|
+
expect(message as string).toContain('node[5]');
|
|
484
|
+
expect(message as string).toContain('matrix takes precedence');
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
{
|
|
491
|
+
// ─── from parse-animation.test.ts ───
|
|
492
|
+
|
|
493
|
+
describe('parse-animation.test.ts', () => {
|
|
494
|
+
describe('parseAnimation', () => {
|
|
495
|
+
it('returns empty array when animationsJson is undefined', () => {
|
|
496
|
+
const r = parseAnimation(undefined, [], [], [], []);
|
|
497
|
+
expect(r.ok && r.value.length).toBe(0);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('parses a LINEAR animation with one channel', () => {
|
|
501
|
+
const input = new Float32Array([0, 0.5, 1]);
|
|
502
|
+
const output = new Float32Array([0, 0, 0, 1, 2, 3, 2, 4, 6]);
|
|
503
|
+
const inputBuf = new Uint8Array(input.buffer);
|
|
504
|
+
const outputBuf = new Uint8Array(output.buffer);
|
|
505
|
+
|
|
506
|
+
const r = parseAnimation(
|
|
507
|
+
[
|
|
508
|
+
{
|
|
509
|
+
channels: [{ sampler: 0, target: { node: 0, path: 'translation' } }],
|
|
510
|
+
samplers: [{ input: 0, output: 1, interpolation: 'LINEAR' }],
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
[{ name: 'root' }],
|
|
514
|
+
[
|
|
515
|
+
{ componentType: 5126, type: 'SCALAR', count: 3, bufferView: 0 },
|
|
516
|
+
{ componentType: 5126, type: 'VEC3', count: 3, bufferView: 1 },
|
|
517
|
+
],
|
|
518
|
+
[
|
|
519
|
+
{ buffer: 0, byteLength: inputBuf.byteLength },
|
|
520
|
+
{ buffer: 1, byteLength: outputBuf.byteLength },
|
|
521
|
+
],
|
|
522
|
+
[inputBuf, outputBuf],
|
|
523
|
+
);
|
|
524
|
+
expect(r.ok).toBe(true);
|
|
525
|
+
if (!r.ok) return;
|
|
526
|
+
const clip = r.value[0];
|
|
527
|
+
expect(clip).toBeDefined();
|
|
528
|
+
if (!clip) return;
|
|
529
|
+
expect(clip.duration).toBe(1);
|
|
530
|
+
expect(clip.channels.length).toBe(1);
|
|
531
|
+
const ch = clip.channels[0];
|
|
532
|
+
if (!ch) return;
|
|
533
|
+
expect(ch.property).toBe('translation');
|
|
534
|
+
expect(ch.sampler.interpolation).toBe('LINEAR');
|
|
535
|
+
expect(ch.sampler.input.length).toBe(3);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('parses a STEP animation', () => {
|
|
539
|
+
const input = new Float32Array([0, 1, 2]);
|
|
540
|
+
const output = new Float32Array([0, 0, 0, 1, 2, 3, 2, 4, 6]);
|
|
541
|
+
const inputBuf = new Uint8Array(input.buffer);
|
|
542
|
+
const outputBuf = new Uint8Array(output.buffer);
|
|
543
|
+
|
|
544
|
+
const r = parseAnimation(
|
|
545
|
+
[
|
|
546
|
+
{
|
|
547
|
+
channels: [{ sampler: 0, target: { node: 0, path: 'translation' } }],
|
|
548
|
+
samplers: [{ input: 0, output: 1, interpolation: 'STEP' }],
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
[{ name: 'joint' }],
|
|
552
|
+
[
|
|
553
|
+
{ componentType: 5126, type: 'SCALAR', count: 3, bufferView: 0 },
|
|
554
|
+
{ componentType: 5126, type: 'VEC3', count: 3, bufferView: 1 },
|
|
555
|
+
],
|
|
556
|
+
[
|
|
557
|
+
{ buffer: 0, byteLength: inputBuf.byteLength },
|
|
558
|
+
{ buffer: 1, byteLength: outputBuf.byteLength },
|
|
559
|
+
],
|
|
560
|
+
[inputBuf, outputBuf],
|
|
561
|
+
);
|
|
562
|
+
expect(r.ok).toBe(true);
|
|
563
|
+
if (!r.ok) return;
|
|
564
|
+
expect(r.value[0]?.channels[0]?.sampler.interpolation).toBe('STEP');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('fail-fast on CUBICSPLINE interpolation', () => {
|
|
568
|
+
const r = parseAnimation(
|
|
569
|
+
[
|
|
570
|
+
{
|
|
571
|
+
channels: [{ sampler: 0, target: { node: 0, path: 'translation' } }],
|
|
572
|
+
samplers: [{ input: 0, output: 1, interpolation: 'CUBICSPLINE' }],
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
[{ name: 'j' }],
|
|
576
|
+
[],
|
|
577
|
+
[],
|
|
578
|
+
[],
|
|
579
|
+
);
|
|
580
|
+
expect(r.ok).toBe(false);
|
|
581
|
+
if (!r.ok) expect(r.error.code).toBe('gltf-animation-cubicspline-unsupported');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('preserves morph weights channel output for playback', () => {
|
|
585
|
+
const input = new Float32Array([0, 1]);
|
|
586
|
+
const output = new Float32Array([0, 0, 0, 1, 1, 1]);
|
|
587
|
+
const inputBuf = new Uint8Array(input.buffer);
|
|
588
|
+
const outputBuf = new Uint8Array(output.buffer);
|
|
589
|
+
|
|
590
|
+
const r = parseAnimation(
|
|
591
|
+
[
|
|
592
|
+
{
|
|
593
|
+
channels: [{ sampler: 0, target: { node: 0, path: 'weights' } }],
|
|
594
|
+
samplers: [{ input: 0, output: 1, interpolation: 'LINEAR' }],
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
[{ name: 'j' }],
|
|
598
|
+
[
|
|
599
|
+
{ componentType: 5126, type: 'SCALAR', count: 2, bufferView: 0 },
|
|
600
|
+
{ componentType: 5126, type: 'VEC3', count: 2, bufferView: 1 },
|
|
601
|
+
],
|
|
602
|
+
[
|
|
603
|
+
{ buffer: 0, byteLength: inputBuf.byteLength },
|
|
604
|
+
{ buffer: 1, byteLength: outputBuf.byteLength },
|
|
605
|
+
],
|
|
606
|
+
[inputBuf, outputBuf],
|
|
607
|
+
);
|
|
608
|
+
expect(r.ok).toBe(true);
|
|
609
|
+
if (r.ok) {
|
|
610
|
+
expect(r.value[0]?.channels[0]?.property).toBe('weights');
|
|
611
|
+
expect(r.value[0]?.channels[0]?.sampler.output).toHaveLength(6);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('computes duration as max(input[last]) across channels', () => {
|
|
616
|
+
const inputA = new Float32Array([0, 1, 2]);
|
|
617
|
+
const inputB = new Float32Array([0, 1, 3]);
|
|
618
|
+
const output = new Float32Array([0, 0, 0, 1, 1, 1, 2, 2, 2, 0, 0, 0, 1, 1, 1, 3, 3, 3]);
|
|
619
|
+
|
|
620
|
+
const buf = new Uint8Array(new Float32Array([...inputA, ...inputB, ...output]).buffer);
|
|
621
|
+
const r = parseAnimation(
|
|
622
|
+
[
|
|
623
|
+
{
|
|
624
|
+
channels: [
|
|
625
|
+
{ sampler: 0, target: { node: 0, path: 'translation' } },
|
|
626
|
+
{ sampler: 1, target: { node: 1, path: 'scale' } },
|
|
627
|
+
],
|
|
628
|
+
samplers: [
|
|
629
|
+
{ input: 0, output: 2, interpolation: 'LINEAR' },
|
|
630
|
+
{ input: 1, output: 3, interpolation: 'LINEAR' },
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
[{ name: 'A' }, { name: 'B' }],
|
|
635
|
+
[
|
|
636
|
+
{ componentType: 5126, type: 'SCALAR', count: 3, bufferView: 0 },
|
|
637
|
+
{ componentType: 5126, type: 'SCALAR', count: 3, bufferView: 1 },
|
|
638
|
+
{ componentType: 5126, type: 'VEC3', count: 3, bufferView: 2 },
|
|
639
|
+
{ componentType: 5126, type: 'VEC3', count: 3, bufferView: 3 },
|
|
640
|
+
],
|
|
641
|
+
[
|
|
642
|
+
{ buffer: 0, byteLength: 12 },
|
|
643
|
+
{ buffer: 1, byteLength: 12 },
|
|
644
|
+
{ buffer: 2, byteLength: 36 },
|
|
645
|
+
{ buffer: 3, byteLength: 36 },
|
|
646
|
+
],
|
|
647
|
+
[buf.slice(0, 12), buf.slice(12, 24), buf.slice(24, 60), buf.slice(60, 96)],
|
|
648
|
+
);
|
|
649
|
+
expect(r.ok).toBe(true);
|
|
650
|
+
if (!r.ok) return;
|
|
651
|
+
expect(r.value[0]?.duration).toBe(3);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
{
|
|
658
|
+
// ─── from parse-header.test.ts ───
|
|
659
|
+
|
|
660
|
+
const GLB_MAGIC = 0x46546c67;
|
|
661
|
+
const GLB_VERSION = 2;
|
|
662
|
+
const CHUNK_TYPE_JSON = 0x4e4f534a;
|
|
663
|
+
const CHUNK_TYPE_BIN = 0x004e4942;
|
|
664
|
+
|
|
665
|
+
function utf8Bytes(text: string): Uint8Array {
|
|
666
|
+
return new TextEncoder().encode(text);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function padTo4(bytes: Uint8Array, padByte: number): Uint8Array {
|
|
670
|
+
const remainder = bytes.byteLength % 4;
|
|
671
|
+
if (remainder === 0) return bytes;
|
|
672
|
+
const padded = new Uint8Array(bytes.byteLength + (4 - remainder));
|
|
673
|
+
padded.set(bytes, 0);
|
|
674
|
+
for (let i = bytes.byteLength; i < padded.byteLength; i++) {
|
|
675
|
+
padded[i] = padByte;
|
|
676
|
+
}
|
|
677
|
+
return padded;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
interface BuildOptions {
|
|
681
|
+
readonly magic?: number;
|
|
682
|
+
readonly version?: number;
|
|
683
|
+
readonly overrideLength?: number;
|
|
684
|
+
readonly omitJsonChunk?: boolean;
|
|
685
|
+
readonly jsonChunkLengthOverride?: number;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function buildGlb(
|
|
689
|
+
jsonText: string,
|
|
690
|
+
binBytes: Uint8Array | null,
|
|
691
|
+
opts: BuildOptions = {},
|
|
692
|
+
): ArrayBuffer {
|
|
693
|
+
const jsonPadded = padTo4(utf8Bytes(jsonText), 0x20);
|
|
694
|
+
const binPadded = binBytes === null ? null : padTo4(binBytes, 0x00);
|
|
695
|
+
const headerSize = 12;
|
|
696
|
+
const jsonChunkHeaderSize = 8;
|
|
697
|
+
const binChunkHeaderSize = binPadded === null ? 0 : 8;
|
|
698
|
+
const jsonChunkSize = opts.omitJsonChunk ? 0 : jsonChunkHeaderSize + jsonPadded.byteLength;
|
|
699
|
+
const binChunkSize = binPadded === null ? 0 : binChunkHeaderSize + binPadded.byteLength;
|
|
700
|
+
const totalLength = headerSize + jsonChunkSize + binChunkSize;
|
|
701
|
+
const declaredLength = opts.overrideLength ?? totalLength;
|
|
702
|
+
const buffer = new ArrayBuffer(totalLength);
|
|
703
|
+
const view = new DataView(buffer);
|
|
704
|
+
view.setUint32(0, opts.magic ?? GLB_MAGIC, true);
|
|
705
|
+
view.setUint32(4, opts.version ?? GLB_VERSION, true);
|
|
706
|
+
view.setUint32(8, declaredLength, true);
|
|
707
|
+
let offset = headerSize;
|
|
708
|
+
if (!opts.omitJsonChunk) {
|
|
709
|
+
view.setUint32(offset, opts.jsonChunkLengthOverride ?? jsonPadded.byteLength, true);
|
|
710
|
+
view.setUint32(offset + 4, CHUNK_TYPE_JSON, true);
|
|
711
|
+
new Uint8Array(buffer, offset + 8, jsonPadded.byteLength).set(jsonPadded);
|
|
712
|
+
offset += 8 + jsonPadded.byteLength;
|
|
713
|
+
}
|
|
714
|
+
if (binPadded !== null) {
|
|
715
|
+
view.setUint32(offset, binPadded.byteLength, true);
|
|
716
|
+
view.setUint32(offset + 4, CHUNK_TYPE_BIN, true);
|
|
717
|
+
new Uint8Array(buffer, offset + 8, binPadded.byteLength).set(binPadded);
|
|
718
|
+
}
|
|
719
|
+
return buffer;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const MINIMAL_GLTF_JSON = JSON.stringify({ asset: { version: '2.0' } });
|
|
723
|
+
|
|
724
|
+
describe('parse-header.test.ts', () => {
|
|
725
|
+
describe('parseGlbChunks (w7 fixture set)', () => {
|
|
726
|
+
it('(a) accepts a valid GLB with JSON + BIN chunk', () => {
|
|
727
|
+
const bin = new Uint8Array([1, 2, 3, 4]);
|
|
728
|
+
const buffer = buildGlb(MINIMAL_GLTF_JSON, bin);
|
|
729
|
+
const result = parseGlbChunks(buffer, '/fixture/box.glb');
|
|
730
|
+
expect(result.ok).toBe(true);
|
|
731
|
+
if (!result.ok) return;
|
|
732
|
+
expect(result.value.version).toBe(2);
|
|
733
|
+
expect(result.value.length).toBe(buffer.byteLength);
|
|
734
|
+
expect(new TextDecoder().decode(result.value.jsonChunk).trimEnd()).toBe(MINIMAL_GLTF_JSON);
|
|
735
|
+
expect(result.value.binChunk).toBeDefined();
|
|
736
|
+
expect(result.value.binChunk?.byteLength).toBe(4);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('(a2) accepts a valid GLB with JSON chunk only (BIN omitted)', () => {
|
|
740
|
+
const buffer = buildGlb(MINIMAL_GLTF_JSON, null);
|
|
741
|
+
const result = parseGlbChunks(buffer, '/fixture/box.glb');
|
|
742
|
+
expect(result.ok).toBe(true);
|
|
743
|
+
if (!result.ok) return;
|
|
744
|
+
expect(result.value.binChunk).toBeUndefined();
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('(b) rejects wrong magic 0xDEADBEEF with gltf-malformed-header', () => {
|
|
748
|
+
const buffer = buildGlb(MINIMAL_GLTF_JSON, null, { magic: 0xdeadbeef });
|
|
749
|
+
const result = parseGlbChunks(buffer, '/fixture/bad-magic.glb');
|
|
750
|
+
expect(result.ok).toBe(false);
|
|
751
|
+
if (result.ok) return;
|
|
752
|
+
expect(result.error.code).toBe('gltf-malformed-header');
|
|
753
|
+
if (result.error.code !== 'gltf-malformed-header') return;
|
|
754
|
+
expect(result.error.detail.filePath).toBe('/fixture/bad-magic.glb');
|
|
755
|
+
expect(result.error.detail.byteOffset).toBe(0);
|
|
756
|
+
expect(result.error.detail.magic).toBe(0xdeadbeef);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('(c) rejects version=1 with gltf-version-unsupported', () => {
|
|
760
|
+
const buffer = buildGlb(MINIMAL_GLTF_JSON, null, { version: 1 });
|
|
761
|
+
const result = parseGlbChunks(buffer, '/fixture/v1.glb');
|
|
762
|
+
expect(result.ok).toBe(false);
|
|
763
|
+
if (result.ok) return;
|
|
764
|
+
expect(result.error.code).toBe('gltf-version-unsupported');
|
|
765
|
+
if (result.error.code !== 'gltf-version-unsupported') return;
|
|
766
|
+
expect(result.error.detail.filePath).toBe('/fixture/v1.glb');
|
|
767
|
+
expect(result.error.detail.actualVersion).toBe('1');
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('(d) rejects chunk length past buffer end with gltf-malformed-header', () => {
|
|
771
|
+
const buffer = buildGlb(MINIMAL_GLTF_JSON, null, { jsonChunkLengthOverride: 0xffff });
|
|
772
|
+
const result = parseGlbChunks(buffer, '/fixture/oversized.glb');
|
|
773
|
+
expect(result.ok).toBe(false);
|
|
774
|
+
if (result.ok) return;
|
|
775
|
+
expect(result.error.code).toBe('gltf-malformed-header');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('(e) rejects missing JSON chunk with gltf-malformed-header', () => {
|
|
779
|
+
const buffer = buildGlb(MINIMAL_GLTF_JSON, null, { omitJsonChunk: true });
|
|
780
|
+
const result = parseGlbChunks(buffer, '/fixture/no-json.glb');
|
|
781
|
+
expect(result.ok).toBe(false);
|
|
782
|
+
if (result.ok) return;
|
|
783
|
+
expect(result.error.code).toBe('gltf-malformed-header');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('(f) rejects buffers shorter than the 12-byte header with gltf-malformed-header', () => {
|
|
787
|
+
const buffer = new ArrayBuffer(8);
|
|
788
|
+
const result = parseGlbChunks(buffer, '/fixture/truncated.glb');
|
|
789
|
+
expect(result.ok).toBe(false);
|
|
790
|
+
if (result.ok) return;
|
|
791
|
+
expect(result.error.code).toBe('gltf-malformed-header');
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe('parseGltfHeader (asset.version check)', () => {
|
|
796
|
+
it('accepts asset.version === "2.0"', () => {
|
|
797
|
+
const result = parseGltfHeader({ asset: { version: '2.0' } }, '/fixture/box.gltf');
|
|
798
|
+
expect(result.ok).toBe(true);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('rejects asset.version === "1.0" with gltf-version-unsupported', () => {
|
|
802
|
+
const result = parseGltfHeader({ asset: { version: '1.0' } }, '/fixture/v1.gltf');
|
|
803
|
+
expect(result.ok).toBe(false);
|
|
804
|
+
if (result.ok) return;
|
|
805
|
+
expect(result.error.code).toBe('gltf-version-unsupported');
|
|
806
|
+
if (result.error.code !== 'gltf-version-unsupported') return;
|
|
807
|
+
expect(result.error.detail.actualVersion).toBe('1.0');
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('rejects asset missing entirely with gltf-malformed-header', () => {
|
|
811
|
+
const result = parseGltfHeader({}, '/fixture/no-asset.gltf');
|
|
812
|
+
expect(result.ok).toBe(false);
|
|
813
|
+
if (result.ok) return;
|
|
814
|
+
expect(result.error.code).toBe('gltf-malformed-header');
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
{
|
|
821
|
+
// ─── from parse-skin.test.ts ───
|
|
822
|
+
|
|
823
|
+
function makeNode(
|
|
824
|
+
name: string,
|
|
825
|
+
children: readonly number[] = [],
|
|
826
|
+
): { name: string; children: readonly number[] } {
|
|
827
|
+
return { name, children };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
describe('parse-skin.test.ts', () => {
|
|
831
|
+
describe('parseSkin', () => {
|
|
832
|
+
it('returns empty array when skinsJson is undefined', () => {
|
|
833
|
+
const r = parseSkin(undefined, [], [], [], []);
|
|
834
|
+
expect(r.ok && r.value.length).toBe(0);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('returns empty array when skinsJson is empty', () => {
|
|
838
|
+
const r = parseSkin([], [], [], [], []);
|
|
839
|
+
expect(r.ok && r.value.length).toBe(0);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('parses a skin with identity IBM (no IBM accessor)', () => {
|
|
843
|
+
const nodes = [makeNode('root', [1, 2]), makeNode('spine'), makeNode('arm')];
|
|
844
|
+
const r = parseSkin([{ joints: [1, 2] }], nodes, [], [], []);
|
|
845
|
+
expect(r.ok).toBe(true);
|
|
846
|
+
if (!r.ok) return;
|
|
847
|
+
const s = r.value[0];
|
|
848
|
+
expect(s).toBeDefined();
|
|
849
|
+
if (!s) return;
|
|
850
|
+
expect(s.jointCount).toBe(2);
|
|
851
|
+
expect(s.inverseBindMatrices.length).toBe(32);
|
|
852
|
+
expect(s.inverseBindMatrices[0]).toBe(1);
|
|
853
|
+
expect(s.inverseBindMatrices[5]).toBe(1);
|
|
854
|
+
expect(s.inverseBindMatrices[10]).toBe(1);
|
|
855
|
+
expect(s.inverseBindMatrices[15]).toBe(1);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('resolves jointPaths from node hierarchy', () => {
|
|
859
|
+
const nodes = [makeNode('root', [1]), makeNode('spine', [2]), makeNode('arm')];
|
|
860
|
+
const r = parseSkin([{ joints: [2] }], nodes, [], [], []);
|
|
861
|
+
expect(r.ok).toBe(true);
|
|
862
|
+
if (!r.ok) return;
|
|
863
|
+
const s = r.value[0];
|
|
864
|
+
expect(s).toBeDefined();
|
|
865
|
+
if (!s) return;
|
|
866
|
+
expect(s.jointPaths[0]).toBe('root/spine/arm');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('fail-fast on missing joint name', () => {
|
|
870
|
+
const nodes = [
|
|
871
|
+
{ name: 'root', children: [1] } as const,
|
|
872
|
+
{ name: undefined as unknown as string, children: [] } as {
|
|
873
|
+
name?: string;
|
|
874
|
+
children: readonly number[];
|
|
875
|
+
},
|
|
876
|
+
];
|
|
877
|
+
const r = parseSkin(
|
|
878
|
+
[{ joints: [1] }],
|
|
879
|
+
nodes as { readonly name?: string; readonly children?: readonly number[] }[],
|
|
880
|
+
[],
|
|
881
|
+
[],
|
|
882
|
+
[],
|
|
883
|
+
);
|
|
884
|
+
expect(r.ok).toBe(false);
|
|
885
|
+
if (!r.ok) expect(r.error.code).toBe('gltf-skin-joint-name-missing');
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('fail-fast when joint count exceeds MAX_JOINTS (256)', () => {
|
|
889
|
+
const joints = Array.from({ length: 257 }, (_, i) => i);
|
|
890
|
+
const nodes = joints.map((i) => makeNode(`joint${i}`));
|
|
891
|
+
const r = parseSkin([{ joints }], nodes, [], [], []);
|
|
892
|
+
expect(r.ok).toBe(false);
|
|
893
|
+
if (!r.ok) expect(r.error.code).toBe('gltf-skin-joint-count-exceeded');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('decodes IBM from MAT4 F32 accessor', () => {
|
|
897
|
+
const ibmData = new Float32Array(16 * 2);
|
|
898
|
+
ibmData[0] = 2;
|
|
899
|
+
ibmData[5] = 3;
|
|
900
|
+
const buffer = new Uint8Array(ibmData.buffer);
|
|
901
|
+
const nodes = [makeNode('root', [1]), makeNode('j1')];
|
|
902
|
+
const r = parseSkin(
|
|
903
|
+
[{ joints: [1], inverseBindMatrices: 0 }],
|
|
904
|
+
nodes,
|
|
905
|
+
[{ componentType: 5126, type: 'MAT4', count: 2, bufferView: 0 }],
|
|
906
|
+
[{ buffer: 0, byteLength: ibmData.byteLength }],
|
|
907
|
+
[buffer],
|
|
908
|
+
);
|
|
909
|
+
expect(r.ok).toBe(true);
|
|
910
|
+
if (!r.ok) return;
|
|
911
|
+
const s = r.value[0];
|
|
912
|
+
expect(s).toBeDefined();
|
|
913
|
+
if (!s) return;
|
|
914
|
+
expect(s.inverseBindMatrices[0]).toBeCloseTo(2);
|
|
915
|
+
expect(s.inverseBindMatrices[5]).toBeCloseTo(3);
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
}
|