@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.
Files changed (220) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +204 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/animation-target-errors.unit.test.d.ts +2 -0
  5. package/dist/__tests__/animation-target-errors.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts +2 -0
  7. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/bridge-material-values.unit.test.d.ts +2 -0
  9. package/dist/__tests__/bridge-material-values.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts +2 -0
  11. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/bridge-sampler-required.test-d.d.ts +2 -0
  13. package/dist/__tests__/bridge-sampler-required.test-d.d.ts.map +1 -0
  14. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts +2 -0
  15. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts +2 -0
  17. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/bridge-test-helpers.d.ts +5 -0
  19. package/dist/__tests__/bridge-test-helpers.d.ts.map +1 -0
  20. package/dist/__tests__/bridge.unit.test.d.ts +2 -0
  21. package/dist/__tests__/bridge.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/cli-gltf.integration.test.d.ts +2 -0
  23. package/dist/__tests__/cli-gltf.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/color-0-accessor.unit.test.d.ts +2 -0
  25. package/dist/__tests__/color-0-accessor.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/data-uri-surface.unit.test.d.ts +2 -0
  27. package/dist/__tests__/data-uri-surface.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/extension-admission.unit.test.d.ts +2 -0
  29. package/dist/__tests__/extension-admission.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/gltf-error-derived-views.test-d.d.ts +2 -0
  31. package/dist/__tests__/gltf-error-derived-views.test-d.d.ts.map +1 -0
  32. package/dist/__tests__/gltf-error-migration.test.d.ts +2 -0
  33. package/dist/__tests__/gltf-error-migration.test.d.ts.map +1 -0
  34. package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts +2 -0
  35. package/dist/__tests__/gltf-error-policy-owner.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/gltf-importer.test.d.ts +2 -0
  37. package/dist/__tests__/gltf-importer.test.d.ts.map +1 -0
  38. package/dist/__tests__/gltf-local-artifacts.test.d.ts +2 -0
  39. package/dist/__tests__/gltf-local-artifacts.test.d.ts.map +1 -0
  40. package/dist/__tests__/gltf.unit.test.d.ts +2 -0
  41. package/dist/__tests__/gltf.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts +2 -0
  43. package/dist/__tests__/malformed-buffer-data-uri.integration.test.d.ts.map +1 -0
  44. package/dist/__tests__/material-missing-uv.unit.test.d.ts +2 -0
  45. package/dist/__tests__/material-missing-uv.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/material-pack-refs.integration.test.d.ts +2 -0
  47. package/dist/__tests__/material-pack-refs.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/material-per-slot-ir.unit.test.d.ts +2 -0
  49. package/dist/__tests__/material-per-slot-ir.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/material-texture-transform.unit.test.d.ts +2 -0
  51. package/dist/__tests__/material-texture-transform.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/material-unused-uv.unit.test.d.ts +2 -0
  53. package/dist/__tests__/material-unused-uv.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/meshopt-decode.unit.test.d.ts +2 -0
  55. package/dist/__tests__/meshopt-decode.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/meshopt-falsifier.unit.test.d.ts +2 -0
  57. package/dist/__tests__/meshopt-falsifier.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts +2 -0
  59. package/dist/__tests__/meshopt-mode-filter-owner.test-d.d.ts.map +1 -0
  60. package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts +2 -0
  61. package/dist/__tests__/meshopt-real-matrix.integration.test.d.ts.map +1 -0
  62. package/dist/__tests__/morph-import.integration.test.d.ts +2 -0
  63. package/dist/__tests__/morph-import.integration.test.d.ts.map +1 -0
  64. package/dist/__tests__/morph-import.unit.test.d.ts +2 -0
  65. package/dist/__tests__/morph-import.unit.test.d.ts.map +1 -0
  66. package/dist/__tests__/parse-decode.unit.test.d.ts +2 -0
  67. package/dist/__tests__/parse-decode.unit.test.d.ts.map +1 -0
  68. package/dist/__tests__/parse-gltf-multi-uv.test.d.ts +2 -0
  69. package/dist/__tests__/parse-gltf-multi-uv.test.d.ts.map +1 -0
  70. package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts +2 -0
  71. package/dist/__tests__/parse-gltf-skin-attrs.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/parse-gltf.unit.test.d.ts +2 -0
  73. package/dist/__tests__/parse-gltf.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
  75. package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
  76. package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
  77. package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
  78. package/dist/__tests__/source-key-diagnostics.integration.test.d.ts +2 -0
  79. package/dist/__tests__/source-key-diagnostics.integration.test.d.ts.map +1 -0
  80. package/dist/__tests__/source-key-producer.integration.test.d.ts +2 -0
  81. package/dist/__tests__/source-key-producer.integration.test.d.ts.map +1 -0
  82. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  83. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  84. package/dist/accessor/decode-accessor.d.ts +75 -0
  85. package/dist/accessor/decode-accessor.d.ts.map +1 -0
  86. package/dist/accessor/decode-color.d.ts +15 -0
  87. package/dist/accessor/decode-color.d.ts.map +1 -0
  88. package/dist/accessor/index.d.ts +3 -0
  89. package/dist/accessor/index.d.ts.map +1 -0
  90. package/dist/bridge.d.ts +103 -0
  91. package/dist/bridge.d.ts.map +1 -0
  92. package/dist/check-extensions.d.ts +29 -0
  93. package/dist/check-extensions.d.ts.map +1 -0
  94. package/dist/cli-gltf.d.ts +8 -0
  95. package/dist/cli-gltf.d.ts.map +1 -0
  96. package/dist/cli-gltf.mjs +2435 -0
  97. package/dist/cli-gltf.mjs.map +1 -0
  98. package/dist/data-uri.d.ts +6 -0
  99. package/dist/data-uri.d.ts.map +1 -0
  100. package/dist/errors.d.ts +201 -0
  101. package/dist/errors.d.ts.map +1 -0
  102. package/dist/extract-image-bytes.d.ts +42 -0
  103. package/dist/extract-image-bytes.d.ts.map +1 -0
  104. package/dist/gltf-importer.d.ts +21 -0
  105. package/dist/gltf-importer.d.ts.map +1 -0
  106. package/dist/image-color-space.d.ts +35 -0
  107. package/dist/image-color-space.d.ts.map +1 -0
  108. package/dist/importer-entry.d.ts +10 -0
  109. package/dist/importer-entry.d.ts.map +1 -0
  110. package/dist/importer-entry.mjs +3378 -0
  111. package/dist/importer-entry.mjs.map +1 -0
  112. package/dist/index.d.ts +25 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.mjs +3673 -0
  115. package/dist/index.mjs.map +1 -0
  116. package/dist/meshopt-decode.d.ts +39 -0
  117. package/dist/meshopt-decode.d.ts.map +1 -0
  118. package/dist/node-file-entry.d.ts +14 -0
  119. package/dist/node-file-entry.d.ts.map +1 -0
  120. package/dist/node-file-entry.mjs +1946 -0
  121. package/dist/node-file-entry.mjs.map +1 -0
  122. package/dist/node-path.d.ts +14 -0
  123. package/dist/node-path.d.ts.map +1 -0
  124. package/dist/parse-animation.d.ts +70 -0
  125. package/dist/parse-animation.d.ts.map +1 -0
  126. package/dist/parse-glb-chunks.d.ts +25 -0
  127. package/dist/parse-glb-chunks.d.ts.map +1 -0
  128. package/dist/parse-glb.d.ts +3 -0
  129. package/dist/parse-glb.d.ts.map +1 -0
  130. package/dist/parse-gltf-header.d.ts +18 -0
  131. package/dist/parse-gltf-header.d.ts.map +1 -0
  132. package/dist/parse-gltf.d.ts +263 -0
  133. package/dist/parse-gltf.d.ts.map +1 -0
  134. package/dist/parse-skin.d.ts +70 -0
  135. package/dist/parse-skin.d.ts.map +1 -0
  136. package/dist/reimport-reuse-meta.d.ts +48 -0
  137. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  138. package/dist/serialize-meta.d.ts +2 -0
  139. package/dist/serialize-meta.d.ts.map +1 -0
  140. package/dist/source-key.d.ts +44 -0
  141. package/dist/source-key.d.ts.map +1 -0
  142. package/dist/sub-asset-key.d.ts +12 -0
  143. package/dist/sub-asset-key.d.ts.map +1 -0
  144. package/dist/to-asset-pack.d.ts +2 -0
  145. package/dist/to-asset-pack.d.ts.map +1 -0
  146. package/dist/transform.d.ts +37 -0
  147. package/dist/transform.d.ts.map +1 -0
  148. package/package.json +92 -0
  149. package/src/__tests__/animation-target-errors.unit.test.ts +51 -0
  150. package/src/__tests__/bridge-material-samplers.unit.test.ts +67 -0
  151. package/src/__tests__/bridge-material-values.unit.test.ts +106 -0
  152. package/src/__tests__/bridge-mixed-color.unit.test.ts +100 -0
  153. package/src/__tests__/bridge-sampler-required.test-d.ts +59 -0
  154. package/src/__tests__/bridge-skin-shader-route.unit.test.ts +42 -0
  155. package/src/__tests__/bridge-skin-stride.unit.test.ts +115 -0
  156. package/src/__tests__/bridge-test-helpers.ts +9 -0
  157. package/src/__tests__/bridge.unit.test.ts +1227 -0
  158. package/src/__tests__/cli-gltf.integration.test.ts +122 -0
  159. package/src/__tests__/color-0-accessor.unit.test.ts +174 -0
  160. package/src/__tests__/data-uri-surface.unit.test.ts +26 -0
  161. package/src/__tests__/extension-admission.unit.test.ts +19 -0
  162. package/src/__tests__/fixtures/color-0/float-vec3.gltf +13 -0
  163. package/src/__tests__/fixtures/color-0/interleaved-vec3.gltf +6 -0
  164. package/src/__tests__/fixtures/color-0/normalized-ubyte.gltf +6 -0
  165. package/src/__tests__/fixtures/color-0/normalized-ushort.gltf +6 -0
  166. package/src/__tests__/gltf-error-derived-views.test-d.ts +152 -0
  167. package/src/__tests__/gltf-error-migration.test.ts +147 -0
  168. package/src/__tests__/gltf-error-policy-owner.unit.test.ts +185 -0
  169. package/src/__tests__/gltf-importer.test.ts +12 -0
  170. package/src/__tests__/gltf-local-artifacts.test.ts +11 -0
  171. package/src/__tests__/gltf.unit.test.ts +2975 -0
  172. package/src/__tests__/malformed-buffer-data-uri.integration.test.ts +140 -0
  173. package/src/__tests__/material-missing-uv.unit.test.ts +29 -0
  174. package/src/__tests__/material-pack-refs.integration.test.ts +58 -0
  175. package/src/__tests__/material-per-slot-ir.unit.test.ts +90 -0
  176. package/src/__tests__/material-texture-transform.unit.test.ts +105 -0
  177. package/src/__tests__/material-unused-uv.unit.test.ts +44 -0
  178. package/src/__tests__/meshopt-decode.unit.test.ts +87 -0
  179. package/src/__tests__/meshopt-falsifier.unit.test.ts +92 -0
  180. package/src/__tests__/meshopt-mode-filter-owner.test-d.ts +79 -0
  181. package/src/__tests__/meshopt-real-matrix.integration.test.ts +370 -0
  182. package/src/__tests__/morph-import.integration.test.ts +115 -0
  183. package/src/__tests__/morph-import.unit.test.ts +62 -0
  184. package/src/__tests__/parse-decode.unit.test.ts +919 -0
  185. package/src/__tests__/parse-gltf-multi-uv.test.ts +238 -0
  186. package/src/__tests__/parse-gltf-skin-attrs.unit.test.ts +191 -0
  187. package/src/__tests__/parse-gltf.unit.test.ts +133 -0
  188. package/src/__tests__/pick-e2e.integration.test.ts +238 -0
  189. package/src/__tests__/public-surface.unit.test.ts +10 -0
  190. package/src/__tests__/source-key-diagnostics.integration.test.ts +140 -0
  191. package/src/__tests__/source-key-producer.integration.test.ts +171 -0
  192. package/src/__tests__/source-key.unit.test.ts +159 -0
  193. package/src/accessor/decode-accessor.ts +309 -0
  194. package/src/accessor/decode-color.ts +128 -0
  195. package/src/accessor/index.ts +15 -0
  196. package/src/bridge.ts +928 -0
  197. package/src/check-extensions.ts +85 -0
  198. package/src/cli-gltf.ts +339 -0
  199. package/src/data-uri.ts +29 -0
  200. package/src/errors.ts +386 -0
  201. package/src/extract-image-bytes.ts +343 -0
  202. package/src/gltf-importer.ts +976 -0
  203. package/src/image-color-space.ts +104 -0
  204. package/src/importer-entry.ts +24 -0
  205. package/src/index.ts +148 -0
  206. package/src/meshopt-decode.ts +200 -0
  207. package/src/node-file-entry.ts +98 -0
  208. package/src/node-path.ts +41 -0
  209. package/src/parse-animation.ts +286 -0
  210. package/src/parse-glb-chunks.ts +167 -0
  211. package/src/parse-glb.ts +16 -0
  212. package/src/parse-gltf-header.ts +58 -0
  213. package/src/parse-gltf.ts +1731 -0
  214. package/src/parse-skin.ts +238 -0
  215. package/src/reimport-reuse-meta.ts +134 -0
  216. package/src/serialize-meta.ts +24 -0
  217. package/src/source-key.ts +106 -0
  218. package/src/sub-asset-key.ts +61 -0
  219. package/src/to-asset-pack.ts +8 -0
  220. package/src/transform.ts +117 -0
@@ -0,0 +1,238 @@
1
+ // parse-gltf-multi-uv.test.ts (feat-20260629-multi-uv-set-support m1-w1).
2
+ //
3
+ // TDD red-green-red: byte-level tests for TEXCOORD_0..7 parsing. Uses
4
+ // hand-crafted glTF JSON with data: URIs to exercise the primitive-decode
5
+ // loop. Initial state is RED because GltfMeshIr currently only carries
6
+ // texcoord0 — texcoord1..texcoord7 fields do not yet exist.
7
+ //
8
+ // Covers: 2/3/8 sets byte-level fidelity, sparse (skip-index) boundary,
9
+ // and missing TEXCOORD_0 → texcoord0 undefined.
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+ import type { GltfMeshIr } from '../parse-gltf.js';
13
+ import { parseGltf } from '../parse-gltf.js';
14
+
15
+ const noopLoader = async (_: string) => new ArrayBuffer(0);
16
+
17
+ interface BufferEntry {
18
+ readonly uri: string;
19
+ }
20
+ interface BufferViewEntry {
21
+ readonly buffer: number;
22
+ readonly byteOffset: number;
23
+ readonly byteLength: number;
24
+ }
25
+ interface AccessorEntry {
26
+ readonly bufferView: number;
27
+ readonly byteOffset: number;
28
+ readonly componentType: number;
29
+ readonly count: number;
30
+ readonly type: string;
31
+ }
32
+
33
+ const FLOAT = 5126;
34
+
35
+ function bytesToB64(bytes: Uint8Array): string {
36
+ return btoa(String.fromCharCode(...bytes));
37
+ }
38
+
39
+ function addBuf(
40
+ buffers: BufferEntry[],
41
+ bufferViews: BufferViewEntry[],
42
+ accessors: AccessorEntry[],
43
+ bytes: Uint8Array,
44
+ type: string,
45
+ count: number,
46
+ componentType: number,
47
+ ): number {
48
+ const bufIdx = buffers.length;
49
+ buffers.push({ uri: `data:application/octet-stream;base64,${bytesToB64(bytes)}` });
50
+ const bvIdx = bufferViews.length;
51
+ bufferViews.push({ buffer: bufIdx, byteOffset: 0, byteLength: bytes.byteLength });
52
+ const acIdx = accessors.length;
53
+ accessors.push({ bufferView: bvIdx, byteOffset: 0, componentType, count, type });
54
+ return acIdx;
55
+ }
56
+
57
+ /**
58
+ * Build a minimal glTF 2.0 JSON document with configurable TEXCOORD sets.
59
+ *
60
+ * @param texcoordSets Array of UV data arrays (Float32Array per set).
61
+ * undefined entries represent missing sets (skip).
62
+ */
63
+ function buildMultiUvJson(texcoordSets: ReadonlyArray<Float32Array | undefined>): unknown {
64
+ const buffers: BufferEntry[] = [];
65
+ const bufferViews: BufferViewEntry[] = [];
66
+ const accessors: AccessorEntry[] = [];
67
+
68
+ // Positions: 4 vertices * VEC3 * F32 = 48 bytes.
69
+ const posF32 = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]);
70
+ const posIdx = addBuf(
71
+ buffers,
72
+ bufferViews,
73
+ accessors,
74
+ new Uint8Array(posF32.buffer),
75
+ 'VEC3',
76
+ 4,
77
+ FLOAT,
78
+ );
79
+
80
+ const attributes: Record<string, number> = { POSITION: posIdx };
81
+
82
+ for (let k = 0; k < texcoordSets.length; k++) {
83
+ const set = texcoordSets[k];
84
+ if (set === undefined) continue;
85
+ const bytes = new Uint8Array(set.buffer);
86
+ const count = set.byteLength / 4 / 2;
87
+ const idx = addBuf(buffers, bufferViews, accessors, bytes, 'VEC2', count, FLOAT);
88
+ attributes[`TEXCOORD_${k}`] = idx;
89
+ }
90
+
91
+ return {
92
+ asset: { version: '2.0' },
93
+ buffers,
94
+ bufferViews,
95
+ accessors,
96
+ meshes: [{ name: 'MultiUvMesh', primitives: [{ attributes }] }],
97
+ materials: [],
98
+ nodes: [],
99
+ scenes: [],
100
+ };
101
+ }
102
+
103
+ /** Cast a GltfMeshIr to a dynamic record for forward-looking field access (texcoord1..7). */
104
+ function asDict(mesh: GltfMeshIr): Record<string, unknown> {
105
+ return mesh as unknown as Record<string, unknown>;
106
+ }
107
+
108
+ describe('parse-gltf-multi-uv.test.ts', () => {
109
+ describe('parseGltf TEXCOORD_0..7 multi-UV parsing', () => {
110
+ it('parses 2 sets of UV (TEXCOORD_0 + TEXCOORD_1) byte-identical', async () => {
111
+ const uv0 = new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]);
112
+ const uv1 = new Float32Array([1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8]);
113
+ const json = buildMultiUvJson([uv0, uv1]);
114
+ const result = await parseGltf(json, noopLoader, '/multi-uv.gltf');
115
+ expect(result.ok).toBe(true);
116
+ if (!result.ok) return;
117
+
118
+ const mesh: GltfMeshIr | undefined = result.value.meshes[0];
119
+ expect(mesh).toBeDefined();
120
+ if (!mesh) return;
121
+
122
+ // texcoord0 must exist and match byte-for-byte
123
+ expect(mesh.texcoord0).toBeInstanceOf(Float32Array);
124
+ expect(mesh.texcoord0?.length).toBe(8);
125
+ for (let i = 0; i < 8; i++) {
126
+ expect(mesh.texcoord0?.[i]).toBeCloseTo(uv0[i] ?? NaN, 6);
127
+ }
128
+
129
+ // texcoord1 should exist once GltfMeshIr is extended (m1-w2).
130
+ // Until then, this assertion will fail (RED — expected TDD red phase).
131
+ // After m1-w2 adds texcoord1 to GltfMeshIr and parse-gltf.ts decodes
132
+ // TEXCOORD_1, this assertion becomes green.
133
+ const meshRecord = asDict(mesh);
134
+ const uv1Actual = meshRecord.texcoord1 as Float32Array | undefined;
135
+ expect(uv1Actual, 'texcoord1 should exist on GltfMeshIr after m1-w2').toBeDefined();
136
+ if (!uv1Actual) return;
137
+ expect(uv1Actual.length).toBe(8);
138
+ for (let i = 0; i < 8; i++) {
139
+ expect(uv1Actual[i]).toBeCloseTo(uv1[i] ?? NaN, 6);
140
+ }
141
+ });
142
+
143
+ it('parses 3 sets of UV (TEXCOORD_0..2) byte-identical', async () => {
144
+ const uv0 = new Float32Array([0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3]);
145
+ const uv1 = new Float32Array([1.0, 1.0, 1.1, 1.1, 1.2, 1.2, 1.3, 1.3]);
146
+ const uv2 = new Float32Array([2.0, 2.0, 2.1, 2.1, 2.2, 2.2, 2.3, 2.3]);
147
+ const json = buildMultiUvJson([uv0, uv1, uv2]);
148
+ const result = await parseGltf(json, noopLoader, '/multi-uv.gltf');
149
+ expect(result.ok).toBe(true);
150
+ if (!result.ok) return;
151
+
152
+ const mesh = result.value.meshes[0];
153
+ expect(mesh).toBeDefined();
154
+ if (!mesh) return;
155
+
156
+ expect(mesh.texcoord0?.length).toBe(8);
157
+ const meshRecord = asDict(mesh);
158
+ const uv1Actual = meshRecord.texcoord1 as Float32Array | undefined;
159
+ const uv2Actual = meshRecord.texcoord2 as Float32Array | undefined;
160
+ expect(uv1Actual, 'texcoord1').toBeDefined();
161
+ expect(uv2Actual, 'texcoord2').toBeDefined();
162
+ if (!uv1Actual || !uv2Actual) return;
163
+ expect(uv1Actual.length).toBe(8);
164
+ expect(uv2Actual.length).toBe(8);
165
+ for (let i = 0; i < 8; i++) {
166
+ expect(uv1Actual[i]).toBeCloseTo(uv1[i] ?? NaN, 6);
167
+ expect(uv2Actual[i]).toBeCloseTo(uv2[i] ?? NaN, 6);
168
+ }
169
+ });
170
+
171
+ it('parses 8 sets of UV (TEXCOORD_0..7) byte-identical', async () => {
172
+ const sets = Array.from({ length: 8 }, (_, k) => {
173
+ const arr = new Float32Array(8); // 4 verts * 2
174
+ for (let i = 0; i < 8; i++) arr[i] = k + i * 0.01;
175
+ return arr;
176
+ });
177
+ const json = buildMultiUvJson(sets);
178
+ const result = await parseGltf(json, noopLoader, '/multi-uv.gltf');
179
+ expect(result.ok).toBe(true);
180
+ if (!result.ok) return;
181
+
182
+ const mesh = result.value.meshes[0];
183
+ expect(mesh).toBeDefined();
184
+ if (!mesh) return;
185
+ expect(mesh.texcoord0?.length).toBe(8);
186
+
187
+ const meshRecord = asDict(mesh);
188
+ for (let k = 1; k <= 7; k++) {
189
+ const actual = meshRecord[`texcoord${k}`] as Float32Array | undefined;
190
+ expect(actual, `texcoord${k} should exist`).toBeDefined();
191
+ if (!actual) continue;
192
+ expect(actual.length).toBe(8);
193
+ for (let i = 0; i < 8; i++) {
194
+ expect(actual[i]).toBeCloseTo(sets[k]?.[i] ?? NaN, 6);
195
+ }
196
+ }
197
+ });
198
+
199
+ it('handles sparse UV sets: TEXCOORD_0 + TEXCOORD_2 (no TEXCOORD_1) → texcoord1 undefined', async () => {
200
+ const uv0 = new Float32Array([0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3]);
201
+ const uv2 = new Float32Array([2.0, 2.0, 2.1, 2.1, 2.2, 2.2, 2.3, 2.3]);
202
+ const json = buildMultiUvJson([uv0, undefined, uv2]);
203
+ const result = await parseGltf(json, noopLoader, '/sparse-uv.gltf');
204
+ expect(result.ok).toBe(true);
205
+ if (!result.ok) return;
206
+
207
+ const mesh = result.value.meshes[0];
208
+ expect(mesh).toBeDefined();
209
+ if (!mesh) return;
210
+ expect(mesh.texcoord0?.length).toBe(8);
211
+
212
+ const meshRecord = asDict(mesh);
213
+ // texcoord1 should be undefined (missing set)
214
+ expect(meshRecord.texcoord1, 'texcoord1 should be undefined for sparse set').toBeUndefined();
215
+ // texcoord2 should exist
216
+ const uv2Actual = meshRecord.texcoord2 as Float32Array | undefined;
217
+ expect(uv2Actual, 'texcoord2 should exist even when texcoord1 is skipped').toBeDefined();
218
+ if (!uv2Actual) return;
219
+ expect(uv2Actual.length).toBe(8);
220
+ for (let i = 0; i < 8; i++) {
221
+ expect(uv2Actual[i]).toBeCloseTo(uv2[i] ?? NaN, 6);
222
+ }
223
+ });
224
+
225
+ it('handles mesh with no TEXCOORD_0 → texcoord0 undefined', async () => {
226
+ const json = buildMultiUvJson([]); // zero UV sets
227
+ const result = await parseGltf(json, noopLoader, '/no-uv.gltf');
228
+ expect(result.ok).toBe(true);
229
+ if (!result.ok) return;
230
+
231
+ const mesh = result.value.meshes[0];
232
+ expect(mesh).toBeDefined();
233
+ if (!mesh) return;
234
+ expect(mesh.texcoord0).toBeUndefined();
235
+ expect(mesh.positions.length).toBe(12); // positions still decoded
236
+ });
237
+ });
238
+ });
@@ -0,0 +1,191 @@
1
+ // parse-gltf-skin-attrs.unit.test.ts (feat-20260611 M1).
2
+ //
3
+ // Covers AC-06 (JOINTS_0/WEIGHTS_0 paired-presence fail-fast) and
4
+ // AC-10 (UBYTE JOINTS_0 width-convert to Uint16Array at parse stage).
5
+ // Owns parseGltf primitive-decode skin-attr surface; parse-decode.unit.test.ts
6
+ // continues to cover positions/normals/texcoord/tangent.
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import type { GltfMeshIr } from '../parse-gltf.js';
10
+ import { parseGltf } from '../parse-gltf.js';
11
+
12
+ const noopLoader = async (_: string) => new ArrayBuffer(0);
13
+
14
+ interface BufferEntry {
15
+ readonly uri: string;
16
+ }
17
+ interface BufferViewEntry {
18
+ readonly buffer: number;
19
+ readonly byteOffset: number;
20
+ readonly byteLength: number;
21
+ }
22
+ interface AccessorEntry {
23
+ readonly bufferView: number;
24
+ readonly byteOffset: number;
25
+ readonly componentType: number;
26
+ readonly count: number;
27
+ readonly type: string;
28
+ }
29
+
30
+ function bytesToB64(bytes: Uint8Array): string {
31
+ return btoa(String.fromCharCode(...bytes));
32
+ }
33
+
34
+ interface SkinPrimitiveOptions {
35
+ readonly includeJoints: boolean;
36
+ readonly includeWeights: boolean;
37
+ readonly jointsComponentType: number;
38
+ }
39
+
40
+ // glTF spec component-type IDs.
41
+ const UBYTE = 5121;
42
+ const FLOAT = 5126;
43
+
44
+ function buildSkinPrimitiveJson(opts: SkinPrimitiveOptions): unknown {
45
+ // 4 vertices for VEC4 attributes; positions are VEC3.
46
+ const buffers: BufferEntry[] = [];
47
+ const bufferViews: BufferViewEntry[] = [];
48
+ const accessors: AccessorEntry[] = [];
49
+
50
+ function addBuf(bytes: Uint8Array, type: string, count: number, componentType: number): number {
51
+ const bufIdx = buffers.length;
52
+ buffers.push({ uri: `data:application/octet-stream;base64,${bytesToB64(bytes)}` });
53
+ const bvIdx = bufferViews.length;
54
+ bufferViews.push({ buffer: bufIdx, byteOffset: 0, byteLength: bytes.byteLength });
55
+ const acIdx = accessors.length;
56
+ accessors.push({ bufferView: bvIdx, byteOffset: 0, componentType, count, type });
57
+ return acIdx;
58
+ }
59
+
60
+ // Positions: 4 vertices * VEC3 * F32 = 48 bytes.
61
+ const posBytes = new Uint8Array(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]).buffer);
62
+ const posIdx = addBuf(posBytes, 'VEC3', 4, FLOAT);
63
+
64
+ let jointsIdx: number | undefined;
65
+ if (opts.includeJoints) {
66
+ if (opts.jointsComponentType === UBYTE) {
67
+ // 4 vertices * VEC4 * U8 = 16 bytes; include 0 and 255 boundaries.
68
+ const j = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 255]);
69
+ jointsIdx = addBuf(j, 'VEC4', 4, UBYTE);
70
+ } else {
71
+ // U16 path: 4 * VEC4 * 2 = 32 bytes.
72
+ const j16 = new Uint16Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 65535]);
73
+ const jBytes = new Uint8Array(j16.buffer);
74
+ jointsIdx = addBuf(jBytes, 'VEC4', 4, 5123);
75
+ }
76
+ }
77
+ let weightsIdx: number | undefined;
78
+ if (opts.includeWeights) {
79
+ const w = new Float32Array([
80
+ 1, 0, 0, 0, 0.5, 0.5, 0, 0, 0.25, 0.25, 0.25, 0.25, 0.7, 0.1, 0.1, 0.1,
81
+ ]);
82
+ const wBytes = new Uint8Array(w.buffer);
83
+ weightsIdx = addBuf(wBytes, 'VEC4', 4, FLOAT);
84
+ }
85
+
86
+ const attributes: Record<string, number> = { POSITION: posIdx };
87
+ if (jointsIdx !== undefined) attributes.JOINTS_0 = jointsIdx;
88
+ if (weightsIdx !== undefined) attributes.WEIGHTS_0 = weightsIdx;
89
+
90
+ return {
91
+ asset: { version: '2.0' },
92
+ buffers,
93
+ bufferViews,
94
+ accessors,
95
+ meshes: [{ name: 'SkinMesh', primitives: [{ attributes }] }],
96
+ materials: [],
97
+ nodes: [],
98
+ scenes: [],
99
+ };
100
+ }
101
+
102
+ describe('parse-gltf-skin-attrs.unit.test.ts', () => {
103
+ describe('parseGltf JOINTS_0 / WEIGHTS_0 paired skinning attributes', () => {
104
+ it('AC-10: UBYTE JOINTS_0 width-converts to Uint16Array (length 16, 0/255 boundaries preserved)', async () => {
105
+ const json = buildSkinPrimitiveJson({
106
+ includeJoints: true,
107
+ includeWeights: true,
108
+ jointsComponentType: UBYTE,
109
+ });
110
+ const result = await parseGltf(json, noopLoader, '/skin.gltf');
111
+ expect(result.ok).toBe(true);
112
+ if (!result.ok) return;
113
+
114
+ const mesh: GltfMeshIr | undefined = result.value.meshes[0];
115
+ expect(mesh).toBeDefined();
116
+ if (!mesh) return;
117
+
118
+ expect(mesh.joints0).toBeDefined();
119
+ const joints = mesh.joints0;
120
+ if (!joints) return;
121
+ expect(joints).toBeInstanceOf(Uint16Array);
122
+ expect(joints.length).toBe(16);
123
+ // Source UBYTE values: 0..14, 255 (boundary).
124
+ const expected = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 255];
125
+ for (let i = 0; i < 16; i++) {
126
+ expect(joints[i]).toBe(expected[i]);
127
+ }
128
+ });
129
+
130
+ it('AC-06: JOINTS_0 present without WEIGHTS_0 -> gltf-skin-attr-asymmetric (hasJoints=true)', async () => {
131
+ const json = buildSkinPrimitiveJson({
132
+ includeJoints: true,
133
+ includeWeights: false,
134
+ jointsComponentType: UBYTE,
135
+ });
136
+ const result = await parseGltf(json, noopLoader, '/skin.gltf');
137
+ expect(result.ok).toBe(false);
138
+ if (result.ok) return;
139
+ expect(result.error.code).toBe('gltf-skin-attr-asymmetric');
140
+ if (result.error.code !== 'gltf-skin-attr-asymmetric') return;
141
+ expect(result.error.detail.hasJoints).toBe(true);
142
+ expect(result.error.detail.hasWeights).toBe(false);
143
+ expect(result.error.detail.meshIndex).toBe(0);
144
+ expect(result.error.detail.primitiveIndex).toBe(0);
145
+ expect(typeof result.error.hint).toBe('string');
146
+ expect(result.error.hint.length).toBeGreaterThan(0);
147
+ });
148
+
149
+ it('AC-06: WEIGHTS_0 present without JOINTS_0 -> gltf-skin-attr-asymmetric (hasWeights=true)', async () => {
150
+ const json = buildSkinPrimitiveJson({
151
+ includeJoints: false,
152
+ includeWeights: true,
153
+ jointsComponentType: UBYTE,
154
+ });
155
+ const result = await parseGltf(json, noopLoader, '/skin.gltf');
156
+ expect(result.ok).toBe(false);
157
+ if (result.ok) return;
158
+ expect(result.error.code).toBe('gltf-skin-attr-asymmetric');
159
+ if (result.error.code !== 'gltf-skin-attr-asymmetric') return;
160
+ expect(result.error.detail.hasJoints).toBe(false);
161
+ expect(result.error.detail.hasWeights).toBe(true);
162
+ });
163
+
164
+ it('AC-06+AC-10: complete skin pair parses cleanly + non-skin attrs unaffected', async () => {
165
+ const json = buildSkinPrimitiveJson({
166
+ includeJoints: true,
167
+ includeWeights: true,
168
+ jointsComponentType: UBYTE,
169
+ });
170
+ const result = await parseGltf(json, noopLoader, '/skin.gltf');
171
+ expect(result.ok).toBe(true);
172
+ if (!result.ok) return;
173
+
174
+ const mesh: GltfMeshIr | undefined = result.value.meshes[0];
175
+ expect(mesh).toBeDefined();
176
+ if (!mesh) return;
177
+
178
+ // (b) joints0 typed + length === vertex count * 4.
179
+ expect(mesh.joints0).toBeInstanceOf(Uint16Array);
180
+ expect(mesh.joints0?.length).toBe(16);
181
+ // (c) weights0 typed + length === vertex count * 4.
182
+ expect(mesh.weights0).toBeInstanceOf(Float32Array);
183
+ expect(mesh.weights0?.length).toBe(16);
184
+ // (d) positions still decoded; no regression on the non-skin path.
185
+ expect(mesh.positions).toBeInstanceOf(Float32Array);
186
+ expect(mesh.positions.length).toBe(12);
187
+ expect(mesh.positions[0]).toBeCloseTo(0);
188
+ expect(mesh.positions[3]).toBeCloseTo(1);
189
+ });
190
+ });
191
+ });
@@ -0,0 +1,133 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { dataUriBase64Payload, decodeBase64 } from '../data-uri.js';
4
+ import { parseGltf } from '../parse-gltf.js';
5
+
6
+ const noopLoader = async (_uri: string) => new ArrayBuffer(0);
7
+
8
+ async function parseMaterial(material: Record<string, unknown>) {
9
+ const result = await parseGltf(
10
+ {
11
+ asset: { version: '2.0' },
12
+ materials: [material],
13
+ },
14
+ noopLoader,
15
+ '/material-alpha.gltf',
16
+ );
17
+ expect(result.ok).toBe(true);
18
+ if (!result.ok) throw new Error('expected material parse to succeed');
19
+ return result.value.materials[0];
20
+ }
21
+
22
+ describe('glTF MASK alpha cutoff parsing', () => {
23
+ it('applies the glTF default cutoff of 0.5', async () => {
24
+ const material = await parseMaterial({
25
+ alphaMode: 'MASK',
26
+ pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 0.5] },
27
+ });
28
+
29
+ expect(material?.alphaMode).toBe('MASK');
30
+ expect(material?.alphaCutoff).toBe(0.5);
31
+ });
32
+
33
+ it.each([0, 0.5, 1])('preserves the explicit cutoff boundary %s', async (cutoff) => {
34
+ const material = await parseMaterial({
35
+ alphaMode: 'MASK',
36
+ alphaCutoff: cutoff,
37
+ pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 0.5] },
38
+ });
39
+
40
+ expect(material?.alphaCutoff).toBe(cutoff);
41
+ });
42
+ });
43
+
44
+ describe('glTF COLOR_0 importer carrier', () => {
45
+ it('publishes normalized VEC3 FLOAT colors as importer-owned RGBA', async () => {
46
+ const fixture = JSON.parse(
47
+ readFileSync(new URL('./fixtures/color-0/float-vec3.gltf', import.meta.url), 'utf8'),
48
+ ) as {
49
+ readonly buffers: readonly { readonly uri: string }[];
50
+ readonly meshes: readonly {
51
+ readonly primitives: readonly { readonly attributes: Record<string, number> }[];
52
+ }[];
53
+ readonly accessors: readonly unknown[];
54
+ readonly bufferViews: readonly unknown[];
55
+ };
56
+ const uri = fixture.buffers[0]?.uri;
57
+ if (uri === undefined) throw new Error('fixture buffer is missing');
58
+ const payload = dataUriBase64Payload(uri);
59
+ if (payload === undefined) throw new Error('fixture buffer must be a data URI');
60
+ const result = await parseGltf(
61
+ fixture,
62
+ async () => {
63
+ const bytes = decodeBase64(payload);
64
+ const buffer = new ArrayBuffer(bytes.byteLength);
65
+ new Uint8Array(buffer).set(bytes);
66
+ return buffer;
67
+ },
68
+ '/fixtures/color-0/float-vec3.gltf',
69
+ );
70
+ expect(result.ok).toBe(true);
71
+ if (!result.ok) return;
72
+ const mesh = result.value.meshes[0] as unknown as {
73
+ readonly colors0?: Float32Array;
74
+ };
75
+ expect(Array.from(mesh.colors0 ?? [])).toEqual([0, 0.5, 1, 1, 1, 0.25, 0, 1]);
76
+ });
77
+
78
+ it('keeps an absent COLOR_0 distinct from a present-invalid accessor', async () => {
79
+ const source = JSON.parse(
80
+ readFileSync(new URL('./fixtures/color-0/float-vec3.gltf', import.meta.url), 'utf8'),
81
+ ) as {
82
+ readonly asset: { readonly version: string };
83
+ readonly buffers: readonly unknown[];
84
+ readonly bufferViews: readonly unknown[];
85
+ readonly accessors: readonly unknown[];
86
+ readonly meshes: readonly {
87
+ readonly primitives: readonly { readonly attributes: Record<string, number> }[];
88
+ }[];
89
+ };
90
+ const sourcePrimitive = source.meshes[0]?.primitives[0];
91
+ if (sourcePrimitive === undefined) throw new Error('fixture primitive is missing');
92
+ const plain = await parseGltf(
93
+ {
94
+ ...source,
95
+ meshes: [
96
+ {
97
+ primitives: [{ attributes: { POSITION: sourcePrimitive.attributes.POSITION ?? 0 } }],
98
+ },
99
+ ],
100
+ },
101
+ noopLoader,
102
+ '/fixtures/color-0/absent.gltf',
103
+ );
104
+ expect(plain.ok).toBe(true);
105
+ if (!plain.ok) return;
106
+ expect(
107
+ (plain.value.meshes[0] as unknown as { readonly colors0?: unknown }).colors0,
108
+ ).toBeUndefined();
109
+
110
+ const invalid = await parseGltf(
111
+ {
112
+ ...source,
113
+ meshes: [
114
+ {
115
+ primitives: [
116
+ {
117
+ attributes: {
118
+ POSITION: sourcePrimitive.attributes.POSITION ?? 0,
119
+ COLOR_0: 9,
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ ],
125
+ },
126
+ noopLoader,
127
+ '/fixtures/color-0/present-invalid.gltf',
128
+ );
129
+ expect(invalid.ok).toBe(false);
130
+ if (invalid.ok) return;
131
+ expect(invalid.error.detail).toMatchObject({ semantic: 'COLOR_0', accessorIndex: 9 });
132
+ });
133
+ });