@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,2975 @@
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=18):
5
+ // - packages/gltf/src/__tests__/cli-gltf.test.ts
6
+ // - packages/gltf/src/__tests__/errors.test.ts
7
+ // - packages/gltf/src/__tests__/ext-mesh-gpu-instancing.test.ts
8
+ // - packages/gltf/src/__tests__/format-entries.test.ts
9
+ // - packages/gltf/src/__tests__/gltf-importer-byte-equivalence.test.ts
10
+ // - packages/gltf/src/__tests__/gltf-importer-texture-data-uri.test.ts
11
+ // - packages/gltf/src/__tests__/gltf-importer-texture-external.test.ts
12
+ // - packages/gltf/src/__tests__/gltf-importer-texture-glb.test.ts
13
+ // - packages/gltf/src/__tests__/image-color-space.test.ts
14
+ // - packages/gltf/src/__tests__/khr-extensions.test.ts
15
+ // - packages/gltf/src/__tests__/multi-primitive.test.ts
16
+ // - packages/gltf/src/__tests__/pbr-material.test.ts
17
+ // - packages/gltf/src/__tests__/reimport-reuse-meta.test.ts
18
+ // - packages/gltf/src/__tests__/skin-to-asset-pack.test.ts
19
+ // - packages/gltf/src/__tests__/texture-load.test.ts
20
+ // - packages/gltf/src/__tests__/to-asset-pack-texture.test.ts
21
+ //
22
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
23
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
24
+
25
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
26
+ import { tmpdir } from 'node:os';
27
+ import path, { join } from 'node:path';
28
+ import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
29
+
30
+ // Shared helper: encode float array as base64 data URI (used by multi-primitive, pbr-material, texture-load)
31
+ function buildBase64Buffer(floats: number[]): string {
32
+ const bytes = new Uint8Array(floats.length * 4);
33
+ const view = new Float32Array(bytes.buffer);
34
+ floats.forEach((v, i) => {
35
+ view[i] = v;
36
+ });
37
+ return `data:application/octet-stream;base64,${btoa(String.fromCharCode(...bytes))}`;
38
+ }
39
+
40
+ {
41
+ describe('KHR_lights_punctual import contract', () => {
42
+ it('accepts directional, point, and spot author facts with numeric fields', async () => {
43
+ const result = await parseGltf(
44
+ {
45
+ asset: { version: '2.0' },
46
+ extensionsUsed: ['KHR_lights_punctual'],
47
+ extensionsRequired: ['KHR_lights_punctual'],
48
+ extensions: {
49
+ KHR_lights_punctual: {
50
+ lights: [
51
+ { type: 'directional', color: [0.1, 0.2, 0.3], intensity: 4 },
52
+ { type: 'point', color: [0.4, 0.5, 0.6], intensity: 8, range: 3 },
53
+ {
54
+ type: 'spot',
55
+ color: [0.7, 0.8, 0.9],
56
+ intensity: 16,
57
+ range: 5,
58
+ spot: { innerConeAngle: 0.1, outerConeAngle: 0.5 },
59
+ },
60
+ ],
61
+ },
62
+ },
63
+ scenes: [{ nodes: [] }],
64
+ scene: 0,
65
+ nodes: [],
66
+ meshes: [],
67
+ materials: [],
68
+ },
69
+ async () => new ArrayBuffer(0),
70
+ '/direct-light.gltf',
71
+ );
72
+
73
+ expect(result.ok).toBe(true);
74
+ if (!result.ok) return;
75
+ const lights = (result.value as unknown as { lights?: readonly Record<string, unknown>[] })
76
+ .lights;
77
+ expect(lights?.map((light) => light.type)).toEqual(['directional', 'point', 'spot']);
78
+ expect(lights?.[1]?.intensity).toBe(8);
79
+ expect(lights?.[1]?.range).toBe(3);
80
+ expect((lights?.[2]?.spot as { innerConeAngle: number }).innerConeAngle).toBe(0.1);
81
+ });
82
+
83
+ it('rejects unsupported required extensions instead of creating an implicit light', async () => {
84
+ const result = await parseGltf(
85
+ {
86
+ asset: { version: '2.0' },
87
+ extensionsRequired: ['KHR_unknown_light'],
88
+ scenes: [{ nodes: [] }],
89
+ scene: 0,
90
+ nodes: [],
91
+ meshes: [],
92
+ materials: [],
93
+ },
94
+ async () => new ArrayBuffer(0),
95
+ '/invalid-light.gltf',
96
+ );
97
+
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) expect(result.error.code).toBe('gltf-extension-unsupported');
100
+ });
101
+ });
102
+ }
103
+
104
+ // Shared loader stub (used by multi-primitive, pbr-material)
105
+ const noopLoader = async (_: string) => new ArrayBuffer(0);
106
+
107
+ import type { ImportRunnerFs } from '@forgeax/engine-import';
108
+ import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
109
+ import { mat4, quat, vec3 } from '@forgeax/engine-math';
110
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
111
+ import type {
112
+ ImportContext,
113
+ ImportError as ImportErrorType,
114
+ ImportedAsset,
115
+ ImportSubAsset,
116
+ TextureAsset,
117
+ } from '@forgeax/engine-types';
118
+
119
+ function unwrap(result: {
120
+ readonly ok: boolean;
121
+ readonly value?: { readonly assets: readonly ImportedAsset[] };
122
+ }): readonly ImportedAsset[] {
123
+ return result.ok && result.value !== undefined ? result.value.assets : [];
124
+ }
125
+
126
+ import { toMaterialAsset } from '../bridge.js';
127
+ import { checkExtensions, EXTENSION_ALLOWLIST } from '../check-extensions.js';
128
+ import { runCliGltf } from '../cli-gltf.js';
129
+ import {
130
+ GLTF_ERROR_HINTS,
131
+ type GltfError,
132
+ type GltfErrorCode,
133
+ type GltfErrorDetail,
134
+ gltfErr,
135
+ } from '../errors.js';
136
+ import { gltfImporter } from '../gltf-importer.js';
137
+ import { deriveTextureColorSpace } from '../image-color-space.js';
138
+ import type { GltfDoc } from '../parse-gltf.js';
139
+ import { parseGlb, parseGltf, toAssetPack } from '../parse-gltf.js';
140
+ import {
141
+ type GltfDocItem,
142
+ type GltfMetaJson,
143
+ reimportReuseMeta,
144
+ subAssetKey,
145
+ } from '../reimport-reuse-meta.js';
146
+ import { unwrapMeshAsset as meshIrToMeshAsset } from './bridge-test-helpers.js';
147
+
148
+ function unwrapAssetPack(result: ReturnType<typeof toAssetPack>) {
149
+ if (!result.ok) throw new Error(`toAssetPack failed: ${result.error.code}`);
150
+ return result.value;
151
+ }
152
+
153
+ function unwrapReimport(result: ReturnType<typeof reimportReuseMeta>) {
154
+ if (!result.ok) throw new Error(`reimportReuseMeta failed: ${result.error.code}`);
155
+ return result.value;
156
+ }
157
+
158
+ {
159
+ // ─── from cli-gltf.test.ts ───
160
+
161
+ interface CapturedIO {
162
+ stdout: string[];
163
+ stderr: string[];
164
+ }
165
+
166
+ function makeIO(): CapturedIO {
167
+ return { stdout: [], stderr: [] };
168
+ }
169
+
170
+ function ctxFor(io: CapturedIO) {
171
+ return {
172
+ stdoutWrite: (line: string): void => {
173
+ io.stdout.push(line);
174
+ },
175
+ stderrWrite: (line: string): void => {
176
+ io.stderr.push(line);
177
+ },
178
+ };
179
+ }
180
+
181
+ describe('cli-gltf.test.ts', () => {
182
+ let tempDir: string;
183
+
184
+ beforeEach(async () => {
185
+ tempDir = await mkdtemp(join(tmpdir(), 'gltf-cli-'));
186
+ });
187
+
188
+ afterEach(async () => {
189
+ await rm(tempDir, { recursive: true, force: true });
190
+ });
191
+
192
+ describe('cli-gltf (forgeax-engine-remote-gltf plugin bin)', () => {
193
+ describe('subcommand routing (a)', () => {
194
+ it('prints help on --help and exits 0', async () => {
195
+ const io = makeIO();
196
+ const code = await runCliGltf(['--help'], ctxFor(io));
197
+ expect(code).toBe(0);
198
+ const help = io.stdout.join('\n');
199
+ expect(help).toContain('import');
200
+ expect(help).toContain('produces texture');
201
+ });
202
+
203
+ it('emits a JSON Lines envelope with code on unknown subcommand and exits 1', async () => {
204
+ const io = makeIO();
205
+ const code = await runCliGltf(['scan'], ctxFor(io));
206
+ expect(code).toBe(1);
207
+ expect(io.stderr.length).toBe(1);
208
+ const parsed = JSON.parse(io.stderr[0] as string);
209
+ expect(typeof parsed.code).toBe('string');
210
+ expect(typeof parsed.expected).toBe('string');
211
+ expect(typeof parsed.hint).toBe('string');
212
+ });
213
+
214
+ it('routes import without args -> 1 with cli-parse-error JSON envelope', async () => {
215
+ const io = makeIO();
216
+ const code = await runCliGltf(['import'], ctxFor(io));
217
+ expect(code).toBe(1);
218
+ expect(io.stderr.length).toBeGreaterThanOrEqual(1);
219
+ const parsed = JSON.parse(io.stderr[0] as string);
220
+ expect(typeof parsed.code).toBe('string');
221
+ });
222
+ });
223
+
224
+ describe('GltfError stderr JSON Lines (b)', () => {
225
+ it('emits gltf-malformed-header (code/expected/hint) for an invalid .gltf file', async () => {
226
+ const badPath = join(tempDir, 'bad.gltf');
227
+ await writeFile(badPath, '{this is not}valid json', 'utf-8');
228
+ const io = makeIO();
229
+ const code = await runCliGltf(['import', badPath], ctxFor(io));
230
+ expect(code).toBe(1);
231
+ expect(io.stderr.length).toBe(1);
232
+ const line = io.stderr[0] as string;
233
+ expect(line.includes('\n')).toBe(false);
234
+ const parsed = JSON.parse(line);
235
+ expect(parsed.code).toBe('gltf-malformed-header');
236
+ expect(typeof parsed.expected).toBe('string');
237
+ expect(typeof parsed.hint).toBe('string');
238
+ });
239
+
240
+ it('--check on a dir with a .gltf source missing sidecar emits gltf-meta-missing', async () => {
241
+ const sourcePath = join(tempDir, 'sample.gltf');
242
+ await writeFile(sourcePath, '{}', 'utf-8');
243
+ const io = makeIO();
244
+ const code = await runCliGltf(['import', '--check', tempDir], ctxFor(io));
245
+ expect(code).toBe(1);
246
+ expect(io.stderr.length).toBe(1);
247
+ const parsed = JSON.parse(io.stderr[0] as string);
248
+ expect(parsed.code).toBe('gltf-meta-missing');
249
+ expect(typeof parsed.expected).toBe('string');
250
+ expect(typeof parsed.hint).toBe('string');
251
+ });
252
+ });
253
+
254
+ describe('exit codes (c)', () => {
255
+ it('--check on an empty dir exits 0', async () => {
256
+ const io = makeIO();
257
+ const code = await runCliGltf(['import', '--check', tempDir], ctxFor(io));
258
+ expect(code).toBe(0);
259
+ });
260
+
261
+ it('rejects non-gltf/glb extension with exit 1', async () => {
262
+ const txtPath = join(tempDir, 'notgltf.txt');
263
+ await writeFile(txtPath, 'hello', 'utf-8');
264
+ const io = makeIO();
265
+ const code = await runCliGltf(['import', txtPath], ctxFor(io));
266
+ expect(code).toBe(1);
267
+ });
268
+ });
269
+ });
270
+ });
271
+ }
272
+
273
+ {
274
+ // ─── from errors.test.ts ───
275
+
276
+ const ALL_CODES: readonly GltfErrorCode[] = [
277
+ 'gltf-malformed-header',
278
+ 'gltf-version-unsupported',
279
+ 'gltf-buffer-out-of-bounds',
280
+ 'gltf-extension-unsupported',
281
+ 'gltf-accessor-type-mismatch',
282
+ 'gltf-texture-load-failed',
283
+ 'gltf-meta-missing',
284
+ 'gltf-instancing-count-mismatch',
285
+ 'gltf-image-mime-unsupported',
286
+ 'gltf-skin-joint-count-exceeded',
287
+ 'gltf-animation-cubicspline-unsupported',
288
+ 'gltf-morph-unsupported',
289
+ 'gltf-skin-joint-name-missing',
290
+ 'gltf-image-extract-failed',
291
+ 'gltf-skin-attr-asymmetric',
292
+ 'gltf-animation-target-invalid',
293
+ 'gltf-meshopt-decoder-required',
294
+ 'gltf-meshopt-decode-failed',
295
+ 'gltf-morph-invalid',
296
+ 'gltf-color-accessor-unsupported',
297
+ 'gltf-color-accessor-malformed',
298
+ 'gltf-mesh-bridge-invalid',
299
+ ];
300
+
301
+ function classifyByExhaustiveSwitch(err: GltfError): string {
302
+ switch (err.code) {
303
+ case 'gltf-malformed-header':
304
+ return 'malformed';
305
+ case 'gltf-version-unsupported':
306
+ return 'version';
307
+ case 'gltf-buffer-out-of-bounds':
308
+ return 'oob';
309
+ case 'gltf-extension-unsupported':
310
+ return 'ext';
311
+ case 'gltf-accessor-type-mismatch':
312
+ return 'accessor';
313
+ case 'gltf-texture-load-failed':
314
+ return 'tex-load';
315
+ case 'gltf-meta-missing':
316
+ return 'meta';
317
+ case 'gltf-instancing-count-mismatch':
318
+ return 'inst';
319
+ case 'gltf-image-mime-unsupported':
320
+ return 'mime';
321
+ case 'gltf-skin-joint-count-exceeded':
322
+ return 'joint-count';
323
+ case 'gltf-animation-cubicspline-unsupported':
324
+ return 'cubic';
325
+ case 'gltf-morph-unsupported':
326
+ return 'morph';
327
+ case 'gltf-skin-joint-name-missing':
328
+ return 'joint-name';
329
+ case 'gltf-image-extract-failed':
330
+ return 'image-extract';
331
+ case 'gltf-skin-attr-asymmetric':
332
+ return 'skin-attr-asym';
333
+ case 'gltf-animation-target-invalid':
334
+ return 'animation-target';
335
+ case 'gltf-meshopt-decoder-required':
336
+ return 'meshopt-decoder';
337
+ case 'gltf-meshopt-decode-failed':
338
+ return 'meshopt-decode';
339
+ case 'gltf-morph-invalid':
340
+ return 'morph-invalid';
341
+ case 'gltf-color-accessor-unsupported':
342
+ return 'color-unsupported';
343
+ case 'gltf-color-accessor-malformed':
344
+ return 'color-malformed';
345
+ case 'gltf-mesh-bridge-invalid':
346
+ return 'mesh-bridge-invalid';
347
+ }
348
+ }
349
+
350
+ function buildErrSample(code: GltfErrorCode): GltfError {
351
+ switch (code) {
352
+ case 'gltf-malformed-header':
353
+ return gltfErr(code, { filePath: '/x.glb', byteOffset: 0 });
354
+ case 'gltf-version-unsupported':
355
+ return gltfErr(code, { filePath: '/x.gltf', actualVersion: '1.0' });
356
+ case 'gltf-buffer-out-of-bounds':
357
+ return gltfErr(code, { accessor: 0, byteOffset: 0, byteLength: 0, bufferIndex: 0 });
358
+ case 'gltf-extension-unsupported':
359
+ return gltfErr(code, { extension: 'KHR_x', source: 'extensionsRequired' });
360
+ case 'gltf-accessor-type-mismatch':
361
+ return gltfErr(code, { accessorIndex: 0, reason: 'sparse' });
362
+ case 'gltf-texture-load-failed':
363
+ return gltfErr(code, { uri: 'textures/test.png' });
364
+ case 'gltf-meta-missing':
365
+ return gltfErr(code, { filePath: '/x.gltf', expectedMetaPath: '/x.meta.json' });
366
+ case 'gltf-instancing-count-mismatch':
367
+ return gltfErr(code, {
368
+ nodeIndex: 0,
369
+ accessor: 'TRANSLATION',
370
+ expectedCount: 2,
371
+ actualCount: 3,
372
+ });
373
+ case 'gltf-image-mime-unsupported':
374
+ return gltfErr(code, { mimeType: 'image/bmp' });
375
+ case 'gltf-skin-joint-count-exceeded':
376
+ return gltfErr(code, { skinIndex: 0, jointCount: 300, maxJoints: 256 });
377
+ case 'gltf-animation-cubicspline-unsupported':
378
+ return gltfErr(code, { animationIndex: 0, samplerIndex: 1 });
379
+ case 'gltf-morph-unsupported':
380
+ return gltfErr(code, { animationIndex: 0, channelIndex: 2, nodeIndex: 3 });
381
+ case 'gltf-skin-joint-name-missing':
382
+ return gltfErr(code, {
383
+ reason: 'name-missing',
384
+ skinIndex: 0,
385
+ jointPathIndex: 4,
386
+ nodeIndex: 5,
387
+ });
388
+ case 'gltf-image-extract-failed':
389
+ return gltfErr(code, { imageIndex: 0, source: 'bufferView', reason: 'sample' });
390
+ case 'gltf-skin-attr-asymmetric':
391
+ return gltfErr(code, {
392
+ meshIndex: 0,
393
+ primitiveIndex: 0,
394
+ hasJoints: true,
395
+ hasWeights: false,
396
+ });
397
+ case 'gltf-animation-target-invalid':
398
+ return gltfErr(code, {
399
+ reason: 'name-missing',
400
+ animationIndex: 0,
401
+ channelIndex: 0,
402
+ nodeIndex: 0,
403
+ });
404
+ case 'gltf-meshopt-decoder-required':
405
+ return gltfErr(code, {
406
+ bufferView: 0,
407
+ actual: 'required',
408
+ hasCoreFallback: false,
409
+ });
410
+ case 'gltf-meshopt-decode-failed':
411
+ return gltfErr(code, {
412
+ bufferView: 0,
413
+ actual: 'invalid declaration',
414
+ mode: 'ATTRIBUTES',
415
+ filter: 'NONE',
416
+ });
417
+ case 'gltf-morph-invalid':
418
+ return gltfErr(code, {
419
+ meshIndex: 0,
420
+ primitiveIndex: 0,
421
+ reason: 'target-count-exceeded',
422
+ targetCount: 9,
423
+ attributeCount: 9,
424
+ vertexCount: 3,
425
+ });
426
+ case 'gltf-color-accessor-unsupported':
427
+ return gltfErr(code, {
428
+ semantic: 'COLOR_0',
429
+ accessorIndex: 0,
430
+ reason: 'component',
431
+ });
432
+ case 'gltf-color-accessor-malformed':
433
+ return gltfErr(code, {
434
+ semantic: 'COLOR_0',
435
+ accessorIndex: 0,
436
+ reason: 'bounds',
437
+ });
438
+ case 'gltf-mesh-bridge-invalid':
439
+ return gltfErr(code, { reason: 'empty-input', primitiveCount: 0 });
440
+ }
441
+ }
442
+
443
+ describe('errors.test.ts', () => {
444
+ it('GltfErrorDetail projects the complete error detail surface', () => {
445
+ expectTypeOf<GltfErrorDetail>().toEqualTypeOf<GltfError['detail']>();
446
+ });
447
+
448
+ describe('GltfErrorCode roster', () => {
449
+ it('GLTF_ERROR_HINTS exposes exactly 22 keys', () => {
450
+ expect(Object.keys(GLTF_ERROR_HINTS).length).toBe(22);
451
+ });
452
+
453
+ it.each(ALL_CODES)('hint for %s is a non-empty string', (code) => {
454
+ const hint = GLTF_ERROR_HINTS[code];
455
+ expect(typeof hint).toBe('string');
456
+ expect(hint.length).toBeGreaterThan(0);
457
+ });
458
+
459
+ it.each(ALL_CODES)('gltfErr(%s) carries non-empty .expected literal', (code) => {
460
+ const err: GltfError = buildErrSample(code);
461
+ expect(err.expected).toBeTypeOf('string');
462
+ expect(err.expected.length).toBeGreaterThan(0);
463
+ expect(err.code).toBe(code);
464
+ });
465
+
466
+ it('gltf-instancing-count-mismatch carries the 4-field detail shape', () => {
467
+ const err = gltfErr('gltf-instancing-count-mismatch', {
468
+ nodeIndex: 3,
469
+ accessor: 'ROTATION',
470
+ expectedCount: 4,
471
+ actualCount: 3,
472
+ });
473
+ expect(err.code).toBe('gltf-instancing-count-mismatch');
474
+ if (err.code === 'gltf-instancing-count-mismatch') {
475
+ expect(err.detail.nodeIndex).toBe(3);
476
+ expect(err.detail.accessor).toBe('ROTATION');
477
+ expect(err.detail.expectedCount).toBe(4);
478
+ expect(err.detail.actualCount).toBe(3);
479
+ }
480
+ expect(err.hint.length).toBeGreaterThan(0);
481
+ expect(err.expected.length).toBeGreaterThan(0);
482
+ });
483
+
484
+ it('gltf-texture-load-failed carries .detail.uri', () => {
485
+ const err = gltfErr('gltf-texture-load-failed', { uri: 'textures/brick_wall.jpg' });
486
+ expect(err.code).toBe('gltf-texture-load-failed');
487
+ if (err.code === 'gltf-texture-load-failed') {
488
+ expect(err.detail.uri).toBe('textures/brick_wall.jpg');
489
+ }
490
+ expect(err.hint.length).toBeGreaterThan(0);
491
+ expect(err.expected.length).toBeGreaterThan(0);
492
+ });
493
+
494
+ it('gltf-image-mime-unsupported carries .detail.mimeType', () => {
495
+ const err = gltfErr('gltf-image-mime-unsupported', { mimeType: 'image/webp' });
496
+ expect(err.code).toBe('gltf-image-mime-unsupported');
497
+ if (err.code === 'gltf-image-mime-unsupported') {
498
+ expect(err.detail.mimeType).toBe('image/webp');
499
+ }
500
+ expect(err.hint.length).toBeGreaterThan(0);
501
+ expect(err.expected.length).toBeGreaterThan(0);
502
+ });
503
+
504
+ it('gltf-image-extract-failed carries the 3-field detail shape', () => {
505
+ const err = gltfErr('gltf-image-extract-failed', {
506
+ imageIndex: 2,
507
+ source: 'bufferView',
508
+ reason: 'bufferView 3 byte range out of range of buffer',
509
+ });
510
+ expect(err.code).toBe('gltf-image-extract-failed');
511
+ if (err.code === 'gltf-image-extract-failed') {
512
+ expect(err.detail.imageIndex).toBe(2);
513
+ expect(err.detail.source).toBe('bufferView');
514
+ expect(err.detail.reason).toContain('out of range');
515
+ }
516
+ expect(err.hint.length).toBeGreaterThan(0);
517
+ expect(err.expected.length).toBeGreaterThan(0);
518
+ });
519
+
520
+ it('exhaustive switch (no default) covers all 14 codes', () => {
521
+ const codes = ALL_CODES;
522
+ for (const code of codes) {
523
+ const err = buildErrSample(code);
524
+ const familyLabel = classifyByExhaustiveSwitch(err);
525
+ expect(familyLabel).not.toBe('UNREACHED');
526
+ }
527
+ });
528
+ });
529
+ });
530
+ }
531
+
532
+ {
533
+ // ─── from ext-mesh-gpu-instancing.test.ts ───
534
+
535
+ type GltfJson = Record<string, unknown>;
536
+
537
+ function makeQuat(x: number, y: number, z: number, w: number): ReturnType<typeof quat.create> {
538
+ const q = quat.create();
539
+ q[0] = x;
540
+ q[1] = y;
541
+ q[2] = z;
542
+ q[3] = w;
543
+ return q;
544
+ }
545
+
546
+ const NEVER_LOADER = async (uri: string): Promise<ArrayBuffer> => {
547
+ throw new Error(`unexpected externalLoader call for ${uri}`);
548
+ };
549
+
550
+ function f32ToBase64(values: readonly number[]): string {
551
+ const f = new Float32Array(values);
552
+ const u = new Uint8Array(f.buffer);
553
+ let s = '';
554
+ for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i] ?? 0);
555
+ return `data:application/octet-stream;base64,${btoa(s)}`;
556
+ }
557
+
558
+ function u16ToBase64(values: readonly number[]): string {
559
+ const f = new Uint16Array(values);
560
+ const u = new Uint8Array(f.buffer);
561
+ let s = '';
562
+ for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i] ?? 0);
563
+ return `data:application/octet-stream;base64,${btoa(s)}`;
564
+ }
565
+
566
+ interface InstancingFixtureOptions {
567
+ readonly translations?: readonly number[];
568
+ readonly rotations?: readonly number[];
569
+ readonly scales?: readonly number[];
570
+ readonly translationsCount?: number;
571
+ readonly rotationsCount?: number;
572
+ readonly scalesCount?: number;
573
+ }
574
+
575
+ function buildInstancingFixture(opts: InstancingFixtureOptions): GltfJson {
576
+ const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0];
577
+ const indices = [0, 1, 2];
578
+
579
+ const buffers: Array<{ byteLength: number; uri: string }> = [
580
+ { byteLength: positions.length * 4, uri: f32ToBase64(positions) },
581
+ { byteLength: indices.length * 2, uri: u16ToBase64(indices) },
582
+ ];
583
+ const bufferViews: Array<{ buffer: number; byteOffset: number; byteLength: number }> = [
584
+ { buffer: 0, byteOffset: 0, byteLength: positions.length * 4 },
585
+ { buffer: 1, byteOffset: 0, byteLength: indices.length * 2 },
586
+ ];
587
+ const accessors: Array<Record<string, unknown>> = [
588
+ { bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' },
589
+ { bufferView: 1, componentType: 5123, count: 3, type: 'SCALAR' },
590
+ ];
591
+
592
+ const ext: Record<string, number> = {};
593
+ if (opts.translations !== undefined) {
594
+ const idx = buffers.length;
595
+ buffers.push({
596
+ byteLength: opts.translations.length * 4,
597
+ uri: f32ToBase64(opts.translations),
598
+ });
599
+ bufferViews.push({ buffer: idx, byteOffset: 0, byteLength: opts.translations.length * 4 });
600
+ accessors.push({
601
+ bufferView: bufferViews.length - 1,
602
+ componentType: 5126,
603
+ count: opts.translationsCount ?? opts.translations.length / 3,
604
+ type: 'VEC3',
605
+ });
606
+ ext.TRANSLATION = accessors.length - 1;
607
+ }
608
+ if (opts.rotations !== undefined) {
609
+ const idx = buffers.length;
610
+ buffers.push({ byteLength: opts.rotations.length * 4, uri: f32ToBase64(opts.rotations) });
611
+ bufferViews.push({ buffer: idx, byteOffset: 0, byteLength: opts.rotations.length * 4 });
612
+ accessors.push({
613
+ bufferView: bufferViews.length - 1,
614
+ componentType: 5126,
615
+ count: opts.rotationsCount ?? opts.rotations.length / 4,
616
+ type: 'VEC4',
617
+ });
618
+ ext.ROTATION = accessors.length - 1;
619
+ }
620
+ if (opts.scales !== undefined) {
621
+ const idx = buffers.length;
622
+ buffers.push({ byteLength: opts.scales.length * 4, uri: f32ToBase64(opts.scales) });
623
+ bufferViews.push({ buffer: idx, byteOffset: 0, byteLength: opts.scales.length * 4 });
624
+ accessors.push({
625
+ bufferView: bufferViews.length - 1,
626
+ componentType: 5126,
627
+ count: opts.scalesCount ?? opts.scales.length / 3,
628
+ type: 'VEC3',
629
+ });
630
+ ext.SCALE = accessors.length - 1;
631
+ }
632
+
633
+ return {
634
+ asset: { version: '2.0' },
635
+ extensionsUsed: ['EXT_mesh_gpu_instancing'],
636
+ scene: 0,
637
+ scenes: [{ name: 'S', nodes: [0] }],
638
+ nodes: [
639
+ {
640
+ name: 'InstancedBox',
641
+ mesh: 0,
642
+ extensions: { EXT_mesh_gpu_instancing: { attributes: ext } },
643
+ },
644
+ ],
645
+ meshes: [
646
+ { name: 'M', primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] },
647
+ ],
648
+ materials: [{ name: 'Mat' }],
649
+ accessors,
650
+ bufferViews,
651
+ buffers,
652
+ };
653
+ }
654
+
655
+ function expectMat4Close(actual: Float32Array, offset: number, expected: Float32Array): void {
656
+ for (let i = 0; i < 16; i++) {
657
+ const a = actual[offset + i] ?? Number.NaN;
658
+ const e = expected[i] ?? Number.NaN;
659
+ expect(Math.abs(a - e), `index ${offset + i}: actual ${a} expected ${e}`).toBeLessThanOrEqual(
660
+ 1e-6,
661
+ );
662
+ }
663
+ }
664
+
665
+ describe('ext-mesh-gpu-instancing.test.ts', () => {
666
+ describe('parseGltf - EXT_mesh_gpu_instancing extension routing (AC-01)', () => {
667
+ it('extensionsRequired contains EXT_mesh_gpu_instancing + node carries extension -> ok', async () => {
668
+ const json = buildInstancingFixture({ translations: [0, 0, 0] });
669
+ json.extensionsRequired = ['EXT_mesh_gpu_instancing'];
670
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
671
+ expect(result.ok).toBe(true);
672
+ if (!result.ok) return;
673
+ expect(result.value.nodes[0]?.instancing).toBeDefined();
674
+ });
675
+
676
+ it('extensionsRequired contains EXT_mesh_gpu_instancing + node lacks extension -> ok with no instancing', async () => {
677
+ const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0];
678
+ const indices = [0, 1, 2];
679
+ const json: GltfJson = {
680
+ asset: { version: '2.0' },
681
+ extensionsRequired: ['EXT_mesh_gpu_instancing'],
682
+ scene: 0,
683
+ scenes: [{ name: 'S', nodes: [0] }],
684
+ nodes: [{ name: 'N', mesh: 0 }],
685
+ meshes: [{ name: 'M', primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
686
+ accessors: [
687
+ { bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' },
688
+ { bufferView: 1, componentType: 5123, count: 3, type: 'SCALAR' },
689
+ ],
690
+ bufferViews: [
691
+ { buffer: 0, byteOffset: 0, byteLength: positions.length * 4 },
692
+ { buffer: 1, byteOffset: 0, byteLength: indices.length * 2 },
693
+ ],
694
+ buffers: [
695
+ { byteLength: positions.length * 4, uri: f32ToBase64(positions) },
696
+ { byteLength: indices.length * 2, uri: u16ToBase64(indices) },
697
+ ],
698
+ };
699
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
700
+ expect(result.ok).toBe(true);
701
+ if (!result.ok) return;
702
+ expect(result.value.nodes[0]?.instancing).toBeUndefined();
703
+ });
704
+
705
+ it('extensionsUsed only + node carries extension -> ok', async () => {
706
+ const json = buildInstancingFixture({ translations: [0, 0, 0] });
707
+ json.extensionsUsed = ['EXT_mesh_gpu_instancing'];
708
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
709
+ expect(result.ok).toBe(true);
710
+ if (!result.ok) return;
711
+ expect(result.value.nodes[0]?.instancing).toBeDefined();
712
+ });
713
+
714
+ it('extensionsUsed only + no node extension -> ok with no instancing', async () => {
715
+ const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0];
716
+ const indices = [0, 1, 2];
717
+ const json: GltfJson = {
718
+ asset: { version: '2.0' },
719
+ extensionsUsed: ['EXT_mesh_gpu_instancing'],
720
+ scene: 0,
721
+ scenes: [{ name: 'S', nodes: [0] }],
722
+ nodes: [{ name: 'N', mesh: 0 }],
723
+ meshes: [{ name: 'M', primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
724
+ accessors: [
725
+ { bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' },
726
+ { bufferView: 1, componentType: 5123, count: 3, type: 'SCALAR' },
727
+ ],
728
+ bufferViews: [
729
+ { buffer: 0, byteOffset: 0, byteLength: positions.length * 4 },
730
+ { buffer: 1, byteOffset: 0, byteLength: indices.length * 2 },
731
+ ],
732
+ buffers: [
733
+ { byteLength: positions.length * 4, uri: f32ToBase64(positions) },
734
+ { byteLength: indices.length * 2, uri: u16ToBase64(indices) },
735
+ ],
736
+ };
737
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
738
+ expect(result.ok).toBe(true);
739
+ if (!result.ok) return;
740
+ expect(result.value.nodes[0]?.instancing).toBeUndefined();
741
+ });
742
+ });
743
+
744
+ describe('parseGltf - EXT_mesh_gpu_instancing decode (AC-02/03/04)', () => {
745
+ it('happy-path N=2 with TRANSLATION + ROTATION + SCALE all present', async () => {
746
+ const json = buildInstancingFixture({
747
+ translations: [1, 2, 3, 4, 5, 6],
748
+ rotations: [0, 0, 0, 1, 0, 0, 0, 1],
749
+ scales: [1, 1, 1, 2, 2, 2],
750
+ });
751
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
752
+ expect(result.ok).toBe(true);
753
+ if (!result.ok) return;
754
+ const node = result.value.nodes[0];
755
+ expect(node?.instancing).toBeDefined();
756
+ expect(node?.instancing?.count).toBe(2);
757
+ expect(node?.instancing?.transforms.length).toBe(2 * 16);
758
+ const ref = mat4.create();
759
+ mat4.compose(ref, vec3.create(1, 2, 3), makeQuat(0, 0, 0, 1), vec3.create(1, 1, 1));
760
+ expectMat4Close(node?.instancing?.transforms ?? new Float32Array(), 0, ref);
761
+ mat4.compose(ref, vec3.create(4, 5, 6), makeQuat(0, 0, 0, 1), vec3.create(2, 2, 2));
762
+ expectMat4Close(node?.instancing?.transforms ?? new Float32Array(), 16, ref);
763
+ });
764
+
765
+ it('fixture A: only TRANSLATION (rotation -> identity quat, scale -> 1,1,1)', async () => {
766
+ const json = buildInstancingFixture({ translations: [1, 0, 0, 0, 1, 0, 0, 0, 1] });
767
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
768
+ expect(result.ok).toBe(true);
769
+ if (!result.ok) return;
770
+ const node = result.value.nodes[0];
771
+ expect(node?.instancing?.count).toBe(3);
772
+ const ref = mat4.create();
773
+ mat4.compose(ref, vec3.create(0, 1, 0), makeQuat(0, 0, 0, 1), vec3.create(1, 1, 1));
774
+ expectMat4Close(node?.instancing?.transforms ?? new Float32Array(), 16, ref);
775
+ });
776
+
777
+ it('fixture B: single TRANSLATION key (alpha path, N=1)', async () => {
778
+ const json = buildInstancingFixture({ translations: [9, 9, 9] });
779
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
780
+ expect(result.ok).toBe(true);
781
+ if (!result.ok) return;
782
+ const node = result.value.nodes[0];
783
+ expect(node?.instancing?.count).toBe(1);
784
+ expect(node?.instancing?.transforms.length).toBe(16);
785
+ const ref = mat4.create();
786
+ mat4.compose(ref, vec3.create(9, 9, 9), makeQuat(0, 0, 0, 1), vec3.create(1, 1, 1));
787
+ expectMat4Close(node?.instancing?.transforms ?? new Float32Array(), 0, ref);
788
+ });
789
+
790
+ it('fixture C: TRANSLATION + SCALE present, ROTATION default to identity', async () => {
791
+ const json = buildInstancingFixture({
792
+ translations: [0, 0, 0, 1, 1, 1],
793
+ scales: [1, 1, 1, 2, 3, 4],
794
+ });
795
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
796
+ expect(result.ok).toBe(true);
797
+ if (!result.ok) return;
798
+ const node = result.value.nodes[0];
799
+ expect(node?.instancing?.count).toBe(2);
800
+ const ref = mat4.create();
801
+ mat4.compose(ref, vec3.create(1, 1, 1), makeQuat(0, 0, 0, 1), vec3.create(2, 3, 4));
802
+ expectMat4Close(node?.instancing?.transforms ?? new Float32Array(), 16, ref);
803
+ });
804
+
805
+ it('count-mismatch T=4/R=3 returns gltf-instancing-count-mismatch', async () => {
806
+ const json = buildInstancingFixture({
807
+ translations: [0, 0, 0, 1, 0, 0, 2, 0, 0, 3, 0, 0],
808
+ rotations: [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1],
809
+ translationsCount: 4,
810
+ rotationsCount: 3,
811
+ });
812
+ json.buffers = (json.buffers as Array<{ byteLength: number; uri: string }>).map((b, i) =>
813
+ i === 3
814
+ ? { byteLength: 3 * 4 * 4, uri: f32ToBase64([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]) }
815
+ : b,
816
+ );
817
+ json.bufferViews = (
818
+ json.bufferViews as Array<{ buffer: number; byteOffset: number; byteLength: number }>
819
+ ).map((b, i) => (i === 3 ? { ...b, byteLength: 3 * 4 * 4 } : b));
820
+ const result = await parseGltf(json, NEVER_LOADER, '/x.gltf');
821
+ expect(result.ok).toBe(false);
822
+ if (result.ok) return;
823
+ expect(result.error.code).toBe('gltf-instancing-count-mismatch');
824
+ if (result.error.code !== 'gltf-instancing-count-mismatch') return;
825
+ expect(result.error.detail.nodeIndex).toBe(0);
826
+ expect(result.error.detail.accessor).toBe('ROTATION');
827
+ expect(result.error.detail.expectedCount).toBe(4);
828
+ expect(result.error.detail.actualCount).toBe(3);
829
+ });
830
+ });
831
+ });
832
+ }
833
+
834
+ {
835
+ // ─── from format-entries.test.ts ───
836
+
837
+ interface MinimalGltfJsonInput {
838
+ readonly bin?: Uint8Array;
839
+ readonly bufferUri?: string;
840
+ readonly bufferLength: number;
841
+ readonly bufferIndex: number;
842
+ }
843
+
844
+ const BOX_POSITIONS = new Float32Array([
845
+ 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1,
846
+ ]);
847
+ const BOX_INDICES = new Uint16Array([0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7]);
848
+
849
+ function makeBoxBin(): Uint8Array {
850
+ const positionBytes = new Uint8Array(BOX_POSITIONS.buffer);
851
+ const indexBytes = new Uint8Array(BOX_INDICES.buffer);
852
+ const merged = new Uint8Array(positionBytes.byteLength + indexBytes.byteLength);
853
+ merged.set(positionBytes, 0);
854
+ merged.set(indexBytes, positionBytes.byteLength);
855
+ return merged;
856
+ }
857
+
858
+ function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
859
+ const ab = new ArrayBuffer(bytes.byteLength);
860
+ new Uint8Array(ab).set(bytes);
861
+ return ab;
862
+ }
863
+
864
+ function makeBoxJson(input: MinimalGltfJsonInput): Record<string, unknown> {
865
+ const bufferEntry: { byteLength: number; uri?: string } = { byteLength: input.bufferLength };
866
+ if (input.bufferUri !== undefined) bufferEntry.uri = input.bufferUri;
867
+ const buffers = [bufferEntry];
868
+ return {
869
+ asset: { version: '2.0' },
870
+ scene: 0,
871
+ scenes: [{ name: 'BoxScene', nodes: [0] }],
872
+ nodes: [{ name: 'BoxNode', mesh: 0, translation: [1, 2, 3] }],
873
+ meshes: [
874
+ { name: 'BoxMesh', primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] },
875
+ ],
876
+ materials: [{ name: 'BoxMat', pbrMetallicRoughness: { baseColorFactor: [1, 0, 0, 1] } }],
877
+ accessors: [
878
+ { bufferView: 0, componentType: 5126, count: 8, type: 'VEC3' },
879
+ { bufferView: 1, componentType: 5123, count: 12, type: 'SCALAR' },
880
+ ],
881
+ bufferViews: [
882
+ { buffer: 0, byteOffset: 0, byteLength: BOX_POSITIONS.byteLength },
883
+ { buffer: 0, byteOffset: BOX_POSITIONS.byteLength, byteLength: BOX_INDICES.byteLength },
884
+ ],
885
+ buffers,
886
+ };
887
+ }
888
+
889
+ const GLB_MAGIC = 0x46546c67;
890
+ const CHUNK_TYPE_JSON = 0x4e4f534a;
891
+ const CHUNK_TYPE_BIN = 0x004e4942;
892
+
893
+ function packGlb(jsonText: string, binBytes: Uint8Array): ArrayBuffer {
894
+ const jsonPad = jsonText.length % 4 === 0 ? 0 : 4 - (jsonText.length % 4);
895
+ const binPad = binBytes.byteLength % 4 === 0 ? 0 : 4 - (binBytes.byteLength % 4);
896
+ const jsonChunkLen = jsonText.length + jsonPad;
897
+ const binChunkLen = binBytes.byteLength + binPad;
898
+ const totalLen = 12 + 8 + jsonChunkLen + 8 + binChunkLen;
899
+ const buffer = new ArrayBuffer(totalLen);
900
+ const view = new DataView(buffer);
901
+ view.setUint32(0, GLB_MAGIC, true);
902
+ view.setUint32(4, 2, true);
903
+ view.setUint32(8, totalLen, true);
904
+ view.setUint32(12, jsonChunkLen, true);
905
+ view.setUint32(16, CHUNK_TYPE_JSON, true);
906
+ const u8 = new Uint8Array(buffer);
907
+ u8.set(new TextEncoder().encode(jsonText), 20);
908
+ for (let i = 0; i < jsonPad; i++) u8[20 + jsonText.length + i] = 0x20;
909
+ view.setUint32(20 + jsonChunkLen, binChunkLen, true);
910
+ view.setUint32(20 + jsonChunkLen + 4, CHUNK_TYPE_BIN, true);
911
+ u8.set(binBytes, 20 + jsonChunkLen + 8);
912
+ return buffer;
913
+ }
914
+
915
+ function bytesToBase64(bytes: Uint8Array): string {
916
+ let binary = '';
917
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i] ?? 0);
918
+ return btoa(binary);
919
+ }
920
+
921
+ describe('format-entries.test.ts', () => {
922
+ describe('parseGltf / parseGlb / toAssetPack (w16 fixture set)', () => {
923
+ const bin = makeBoxBin();
924
+
925
+ it('(a) parses .gltf with external .bin URI through the externalLoader', async () => {
926
+ const json = makeBoxJson({
927
+ bufferLength: bin.byteLength,
928
+ bufferUri: 'box.bin',
929
+ bufferIndex: 0,
930
+ });
931
+ const result = await parseGltf(
932
+ json,
933
+ async (uri: string) => {
934
+ expect(uri).toBe('box.bin');
935
+ return bytesToArrayBuffer(bin);
936
+ },
937
+ '/fixture/box.gltf',
938
+ );
939
+ expect(result.ok).toBe(true);
940
+ if (!result.ok) return;
941
+ expect(result.value.meshes.length).toBe(1);
942
+ expect(result.value.scenes.length).toBe(1);
943
+ expect(result.value.materials.length).toBe(1);
944
+ expect(result.value.diagnostics.nodeNames).toEqual(['BoxNode']);
945
+ });
946
+
947
+ it('(b) parses .glb through parseGlb (binary chunk path)', async () => {
948
+ const jsonText = JSON.stringify(
949
+ makeBoxJson({ bufferLength: bin.byteLength, bufferIndex: 0 }),
950
+ );
951
+ const glb = packGlb(jsonText, bin);
952
+ const result = await parseGlb(glb, '/fixture/box.glb');
953
+ expect(result.ok).toBe(true);
954
+ if (!result.ok) return;
955
+ expect(result.value.meshes.length).toBe(1);
956
+ expect(result.value.diagnostics.nodeNames).toEqual(['BoxNode']);
957
+ });
958
+
959
+ it('(c) parses .gltf with embedded base64 buffer (no externalLoader call)', async () => {
960
+ const dataUri = `data:application/octet-stream;base64,${bytesToBase64(bin)}`;
961
+ const json = makeBoxJson({
962
+ bufferLength: bin.byteLength,
963
+ bufferUri: dataUri,
964
+ bufferIndex: 0,
965
+ });
966
+ const externalLoader = async (uri: string): Promise<ArrayBuffer> => {
967
+ throw new Error(`externalLoader should not be called for data: URIs (got ${uri})`);
968
+ };
969
+ const result = await parseGltf(json, externalLoader, '/fixture/box.gltf');
970
+ expect(result.ok).toBe(true);
971
+ if (!result.ok) return;
972
+ expect(result.value.meshes.length).toBe(1);
973
+ expect(result.value.scenes.length).toBe(1);
974
+ });
975
+
976
+ it('toAssetPack writes 3 subAssets (mesh / material / scene) for a Tier-B box', async () => {
977
+ const json = makeBoxJson({
978
+ bufferLength: bin.byteLength,
979
+ bufferUri: 'box.bin',
980
+ bufferIndex: 0,
981
+ });
982
+ const docResult = await parseGltf(
983
+ json,
984
+ async () => bytesToArrayBuffer(bin),
985
+ '/fixture/box.gltf',
986
+ );
987
+ expect(docResult.ok).toBe(true);
988
+ if (!docResult.ok) return;
989
+ const pack = unwrapAssetPack(toAssetPack(docResult.value, undefined, 'box.gltf'));
990
+ expect(pack.meta.kind).toBe('external-asset-package');
991
+ expect(pack.meta.subAssets.length).toBe(3);
992
+ const kinds = pack.meta.subAssets.map((s) => s.kind).sort();
993
+ expect(kinds).toEqual(['material', 'mesh', 'scene']);
994
+ expect(pack.meta.subAssets).toEqual(
995
+ expect.arrayContaining([
996
+ expect.objectContaining({ kind: 'mesh', name: 'BoxMesh' }),
997
+ expect.objectContaining({ kind: 'material', name: 'BoxMat' }),
998
+ expect.objectContaining({ kind: 'scene', name: 'BoxScene' }),
999
+ ]),
1000
+ );
1001
+ });
1002
+
1003
+ it('toAssetPack reuses GUIDs byte-identically on second pass (AC-13)', async () => {
1004
+ const json = makeBoxJson({
1005
+ bufferLength: bin.byteLength,
1006
+ bufferUri: 'box.bin',
1007
+ bufferIndex: 0,
1008
+ });
1009
+ const doc1 = await parseGltf(
1010
+ json,
1011
+ async () => bytesToArrayBuffer(bin),
1012
+ '/fixture/box.gltf',
1013
+ );
1014
+ if (!doc1.ok) throw new Error('doc1 failed');
1015
+ const pack1 = unwrapAssetPack(toAssetPack(doc1.value, undefined, 'box.gltf'));
1016
+ const doc2 = await parseGltf(
1017
+ json,
1018
+ async () => bytesToArrayBuffer(bin),
1019
+ '/fixture/box.gltf',
1020
+ );
1021
+ if (!doc2.ok) throw new Error('doc2 failed');
1022
+ const pack2 = unwrapAssetPack(toAssetPack(doc2.value, pack1.meta, 'box.gltf'));
1023
+ expect(pack2.meta.subAssets.map((s) => s.guid)).toEqual(
1024
+ pack1.meta.subAssets.map((s) => s.guid),
1025
+ );
1026
+ });
1027
+ });
1028
+ });
1029
+ }
1030
+
1031
+ {
1032
+ // ─── from gltf-importer-byte-equivalence.test.ts ───
1033
+
1034
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d450';
1035
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d451';
1036
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d452';
1037
+
1038
+ function importerBuildSelfContainedGltfBytes(): Uint8Array {
1039
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
1040
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
1041
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
1042
+ const indices = new Uint16Array([0, 1, 2]);
1043
+
1044
+ const posBytes = new Uint8Array(positions.buffer);
1045
+ const normBytes = new Uint8Array(normals.buffer);
1046
+ const uvBytes = new Uint8Array(uvs.buffer);
1047
+ const idxBytes = new Uint8Array(indices.buffer);
1048
+
1049
+ const segments = [posBytes, normBytes, uvBytes, idxBytes];
1050
+ const total = segments.reduce((n, s) => n + s.byteLength, 0);
1051
+ const blob = new Uint8Array(total);
1052
+ let off = 0;
1053
+ const offsets: number[] = [];
1054
+ for (const s of segments) {
1055
+ offsets.push(off);
1056
+ blob.set(s, off);
1057
+ off += s.byteLength;
1058
+ }
1059
+ const b64 = Buffer.from(blob).toString('base64');
1060
+
1061
+ const gltf = {
1062
+ asset: { version: '2.0' },
1063
+ scene: 0,
1064
+ scenes: [{ nodes: [0] }],
1065
+ nodes: [{ mesh: 0 }],
1066
+ meshes: [
1067
+ {
1068
+ primitives: [
1069
+ { attributes: { POSITION: 0, NORMAL: 1, TEXCOORD_0: 2 }, indices: 3, material: 0 },
1070
+ ],
1071
+ },
1072
+ ],
1073
+ materials: [
1074
+ {
1075
+ pbrMetallicRoughness: {
1076
+ baseColorFactor: [0.8, 0.4, 0.2, 1],
1077
+ metallicFactor: 0.25,
1078
+ roughnessFactor: 0.75,
1079
+ },
1080
+ },
1081
+ ],
1082
+ accessors: [
1083
+ { bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' },
1084
+ { bufferView: 1, componentType: 5126, count: 3, type: 'VEC3' },
1085
+ { bufferView: 2, componentType: 5126, count: 3, type: 'VEC2' },
1086
+ { bufferView: 3, componentType: 5123, count: 3, type: 'SCALAR' },
1087
+ ],
1088
+ bufferViews: [
1089
+ { buffer: 0, byteOffset: offsets[0], byteLength: posBytes.byteLength },
1090
+ { buffer: 0, byteOffset: offsets[1], byteLength: normBytes.byteLength },
1091
+ { buffer: 0, byteOffset: offsets[2], byteLength: uvBytes.byteLength },
1092
+ { buffer: 0, byteOffset: offsets[3], byteLength: idxBytes.byteLength },
1093
+ ],
1094
+ buffers: [{ uri: `data:application/octet-stream;base64,${b64}`, byteLength: total }],
1095
+ };
1096
+ return new TextEncoder().encode(JSON.stringify(gltf));
1097
+ }
1098
+
1099
+ function normalize(value: unknown): unknown {
1100
+ if (
1101
+ value instanceof Float32Array ||
1102
+ value instanceof Uint8Array ||
1103
+ value instanceof Uint16Array ||
1104
+ value instanceof Uint32Array
1105
+ ) {
1106
+ return Array.from(value);
1107
+ }
1108
+ if (Array.isArray(value)) return value.map(normalize);
1109
+ if (value !== null && typeof value === 'object') {
1110
+ const out: Record<string, unknown> = {};
1111
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
1112
+ out[k] = normalize(v);
1113
+ }
1114
+ return out;
1115
+ }
1116
+ return value;
1117
+ }
1118
+
1119
+ function serialize(value: unknown): string {
1120
+ return JSON.stringify(normalize(value));
1121
+ }
1122
+
1123
+ async function parseFixture(bytes: Uint8Array): Promise<GltfDoc> {
1124
+ const json = JSON.parse(new TextDecoder().decode(bytes));
1125
+ const externalLoader = async (uri: string): Promise<ArrayBuffer> => {
1126
+ throw new Error(`unexpected external buffer ${uri}`);
1127
+ };
1128
+ const res = await parseGltf(json, externalLoader, 'fixture.gltf');
1129
+ if (!res.ok) throw new Error(`fixture parse failed: ${res.error.code}`);
1130
+ return res.value;
1131
+ }
1132
+
1133
+ function fixtureMeta(): RunImportMeta {
1134
+ return {
1135
+ importer: 'gltf',
1136
+ source: 'fixture.gltf',
1137
+ subAssets: [
1138
+ { guid: MESH_GUID, sourceIndex: 0, sourceKey: 'mesh', kind: 'mesh' },
1139
+ { guid: MAT_GUID, sourceIndex: 0, sourceKey: 'material', kind: 'material' },
1140
+ { guid: SCENE_GUID, sourceIndex: 0, sourceKey: 'scene', kind: 'scene' },
1141
+ ],
1142
+ };
1143
+ }
1144
+
1145
+ describe('gltf-importer-byte-equivalence.test.ts', () => {
1146
+ describe('gltfImporter byte-equivalence (AC-12, DDC basis / D-5)', () => {
1147
+ it('importer DDC payloads are byte-identical to the bridge-function baseline', async () => {
1148
+ const bytes = importerBuildSelfContainedGltfBytes();
1149
+ const fs: ImportRunnerFs = {
1150
+ readSource: async () => ({ ok: true as const, value: bytes }),
1151
+ };
1152
+
1153
+ const reg = new ImporterRegistry();
1154
+ reg.register(gltfImporter);
1155
+ const res = await runImport(fixtureMeta(), reg, fs);
1156
+ expect(res.ok).toBe(true);
1157
+ if (!res.ok || 'skipped' in res.value) throw new Error('expected a DDC pack');
1158
+ const importerPack = res.value.pack;
1159
+
1160
+ const doc = await parseFixture(bytes);
1161
+ const meshIr = doc.meshes[0];
1162
+ const matIr = doc.materials[0];
1163
+ if (meshIr === undefined || matIr === undefined)
1164
+ throw new Error('fixture doc must carry one mesh + one material');
1165
+ const baselineMesh = meshIrToMeshAsset([meshIr], {
1166
+ guidByIndex: new Map([[0, MAT_GUID]]),
1167
+ sourceKeyByIndex: new Map([[0, 'material']]),
1168
+ ...(matIr.name === undefined ? {} : { nameByIndex: new Map([[0, matIr.name]]) }),
1169
+ });
1170
+ const baselineMat = toMaterialAsset(matIr);
1171
+
1172
+ const importerMesh = importerPack.assets.find((a) => a.guid === MESH_GUID);
1173
+ const importerMat = importerPack.assets.find((a) => a.guid === MAT_GUID);
1174
+
1175
+ expect(serialize(importerMesh?.payload)).toBe(serialize(baselineMesh));
1176
+ const meshBody = importerMesh?.artifacts.body;
1177
+ expect(meshBody?.mediaType).toBe('application/x-forgeax-mesh');
1178
+ expect(meshBody?.assetCodec).toEqual({ name: 'mesh-binary', version: '4' });
1179
+ expect(meshBody?.bytes).toBeInstanceOf(Uint8Array);
1180
+ const meshHeader = new DataView(
1181
+ (meshBody?.bytes as Uint8Array).buffer,
1182
+ (meshBody?.bytes as Uint8Array).byteOffset,
1183
+ (meshBody?.bytes as Uint8Array).byteLength,
1184
+ );
1185
+ expect(meshHeader.getUint32(0, true)).toBe(4);
1186
+ expect(meshHeader.getUint32(4, true)).toBe(1);
1187
+ expect(meshHeader.getUint32(12, true)).toBe(
1188
+ baselineMesh.vertices.byteLength / (baselineMesh.vertices.length / 12),
1189
+ );
1190
+ expect(meshHeader.getUint32(16, true)).toBe(baselineMesh.vertices.length / 12);
1191
+ expect(meshHeader.getUint32(24, true)).toBe(
1192
+ (baselineMesh.indices ?? new Uint16Array()).length,
1193
+ );
1194
+ expect(meshHeader.getUint32(28, true)).toBe(
1195
+ (baselineMesh.indices ?? new Uint16Array()).BYTES_PER_ELEMENT,
1196
+ );
1197
+
1198
+ expect(serialize(importerMat?.payload)).toBe(serialize(baselineMat));
1199
+ expect(importerPack.assets.map((a) => a.guid)).toEqual([MESH_GUID, MAT_GUID, SCENE_GUID]);
1200
+ });
1201
+
1202
+ it('the fixture meta GUIDs round-trip through toAssetPack stably (reimport-stable)', async () => {
1203
+ const doc = await parseFixture(importerBuildSelfContainedGltfBytes());
1204
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'fixture.gltf'));
1205
+ const kinds = subAssets.map((s) => s.kind);
1206
+ expect(kinds).toContain('mesh');
1207
+ expect(kinds).toContain('material');
1208
+ expect(kinds).toContain('scene');
1209
+ });
1210
+ });
1211
+ });
1212
+ }
1213
+
1214
+ {
1215
+ // ─── from gltf-importer-texture-data-uri.test.ts ───
1216
+
1217
+ const TINY_PNG_BASE64 =
1218
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC';
1219
+
1220
+ const MOCK_TEXTURE: TextureAsset = {
1221
+ kind: 'texture',
1222
+ width: 1,
1223
+ height: 1,
1224
+ format: 'rgba8unorm-srgb',
1225
+ data: new Uint8Array([0, 0, 0, 0]),
1226
+ colorSpace: 'srgb',
1227
+ mipmap: true,
1228
+ };
1229
+
1230
+ function buildSelfContainedGltfWithDataUriImage(): Uint8Array {
1231
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
1232
+ const normals = new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]);
1233
+ const uvs = new Float32Array([0, 0, 1, 0, 0, 1]);
1234
+ const indices = new Uint16Array([0, 1, 2]);
1235
+ const segments = [
1236
+ new Uint8Array(positions.buffer),
1237
+ new Uint8Array(normals.buffer),
1238
+ new Uint8Array(uvs.buffer),
1239
+ new Uint8Array(indices.buffer),
1240
+ ];
1241
+ const total = segments.reduce((n, s) => n + s.byteLength, 0);
1242
+ const blob = new Uint8Array(total);
1243
+ let off = 0;
1244
+ const offsets: number[] = [];
1245
+ for (const s of segments) {
1246
+ offsets.push(off);
1247
+ blob.set(s, off);
1248
+ off += s.byteLength;
1249
+ }
1250
+ const b64 = Buffer.from(blob).toString('base64');
1251
+
1252
+ const gltf = {
1253
+ asset: { version: '2.0' },
1254
+ scene: 0,
1255
+ scenes: [{ nodes: [0] }],
1256
+ nodes: [{ mesh: 0 }],
1257
+ meshes: [
1258
+ {
1259
+ primitives: [
1260
+ { attributes: { POSITION: 0, NORMAL: 1, TEXCOORD_0: 2 }, indices: 3, material: 0 },
1261
+ ],
1262
+ },
1263
+ ],
1264
+ materials: [
1265
+ {
1266
+ emissiveFactor: [0.8, 0.4, 0.1],
1267
+ emissiveTexture: { index: 0 },
1268
+ pbrMetallicRoughness: { baseColorTexture: { index: 0 } },
1269
+ },
1270
+ ],
1271
+ textures: [{ source: 0, sampler: 0 }],
1272
+ samplers: [{}],
1273
+ images: [{ uri: `data:image/png;base64,${TINY_PNG_BASE64}` }],
1274
+ accessors: [
1275
+ { bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' },
1276
+ { bufferView: 1, componentType: 5126, count: 3, type: 'VEC3' },
1277
+ { bufferView: 2, componentType: 5126, count: 3, type: 'VEC2' },
1278
+ { bufferView: 3, componentType: 5123, count: 3, type: 'SCALAR' },
1279
+ ],
1280
+ bufferViews: [
1281
+ { buffer: 0, byteOffset: offsets[0], byteLength: segments[0]?.byteLength },
1282
+ { buffer: 0, byteOffset: offsets[1], byteLength: segments[1]?.byteLength },
1283
+ { buffer: 0, byteOffset: offsets[2], byteLength: segments[2]?.byteLength },
1284
+ { buffer: 0, byteOffset: offsets[3], byteLength: segments[3]?.byteLength },
1285
+ ],
1286
+ buffers: [{ uri: `data:application/octet-stream;base64,${b64}`, byteLength: total }],
1287
+ };
1288
+ return new TextEncoder().encode(JSON.stringify(gltf));
1289
+ }
1290
+
1291
+ interface DecodeCall {
1292
+ readonly mimeType: string;
1293
+ }
1294
+
1295
+ function makeDataUriCtx(opts: {
1296
+ bytes: Uint8Array;
1297
+ subAssets: readonly ImportSubAsset[];
1298
+ decodeCalls: DecodeCall[];
1299
+ }): ImportContext {
1300
+ return {
1301
+ source: 'inline.gltf',
1302
+ readSource: async () => ({ ok: true, value: opts.bytes }),
1303
+ readSibling: async (uri: string) => ({
1304
+ ok: false,
1305
+ error: {
1306
+ code: 'source-read-failed',
1307
+ expected: '',
1308
+ hint: '',
1309
+ detail: { source: uri, reason: 'no sibling reads expected on data: URI path' },
1310
+ message: 'unused',
1311
+ name: 'ImportError',
1312
+ } as never,
1313
+ }),
1314
+ decodeImage: async (_bytes, mimeType) => {
1315
+ opts.decodeCalls.push({ mimeType });
1316
+ return {
1317
+ ok: true,
1318
+ value: { texture: { ...MOCK_TEXTURE, colorSpace: 'srgb' }, bytes: new Uint8Array(8) },
1319
+ };
1320
+ },
1321
+ subAssets: opts.subAssets,
1322
+ importSettings: {},
1323
+ };
1324
+ }
1325
+
1326
+ describe('gltf-importer-texture-data-uri.test.ts', () => {
1327
+ describe('gltfImporter texture extraction (c) data URI / .gltf / AC-10', () => {
1328
+ it('decodes the data: URI inline (no readSibling) and funnels through ctx.decodeImage', async () => {
1329
+ const bytes = buildSelfContainedGltfWithDataUriImage();
1330
+ const calls: DecodeCall[] = [];
1331
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d480';
1332
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d481';
1333
+ const ctx = makeDataUriCtx({
1334
+ bytes,
1335
+ subAssets: [
1336
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1337
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1338
+ { guid: '019e2cc6-0c86-79da-aa76-b0984c86d482', sourceIndex: 0, kind: 'mesh' },
1339
+ { guid: '019e2cc6-0c86-79da-aa76-b0984c86d483', sourceIndex: 0, kind: 'scene' },
1340
+ ],
1341
+ decodeCalls: calls,
1342
+ });
1343
+
1344
+ const produced = unwrap(await gltfImporter.import(ctx));
1345
+ expect(calls.length).toBe(1);
1346
+ expect(calls[0]?.mimeType).toBe('image/png');
1347
+
1348
+ const tex = produced.find((p) => p.guid === TEX_GUID);
1349
+ expect(tex?.kind).toBe('texture');
1350
+ const mat = produced.find((p) => p.guid === MAT_GUID);
1351
+ expect(mat?.refs.map((r) => r.guid)).toContain(TEX_GUID);
1352
+ expect(mat?.refs.at(-1)?.sourceField?.fieldName).toBe('emissiveTexture');
1353
+ const materialPayload = mat?.payload as {
1354
+ readonly values?: Readonly<Record<string, unknown>>;
1355
+ };
1356
+ expect(materialPayload.values?.emissive).toEqual([0.8, 0.4, 0.1]);
1357
+ expect(materialPayload.values?.emissiveIntensity).toBe(1);
1358
+ expect(materialPayload.values?.emissiveTexture).toMatchObject({ texture: 1 });
1359
+ });
1360
+ });
1361
+ });
1362
+ }
1363
+
1364
+ {
1365
+ // ─── from gltf-importer-texture-external.test.ts ───
1366
+
1367
+ const FIXTURE_DIR = path.resolve(
1368
+ __dirname,
1369
+ '../../../../forgeax-engine-assets/khronos-gltf-samples/BoxTextured/glTF',
1370
+ );
1371
+
1372
+ const EXT_MOCK_TEXTURE: TextureAsset = {
1373
+ kind: 'texture',
1374
+ width: 1,
1375
+ height: 1,
1376
+ format: 'rgba8unorm-srgb',
1377
+ data: new Uint8Array([200, 100, 50, 255]),
1378
+ colorSpace: 'srgb',
1379
+ mipmap: true,
1380
+ };
1381
+
1382
+ interface SiblingCall {
1383
+ readonly uri: string;
1384
+ }
1385
+ interface ExtDecodeCall {
1386
+ readonly mimeType: string;
1387
+ readonly settings: Readonly<Record<string, unknown>>;
1388
+ }
1389
+
1390
+ function makeExtCtx(opts: {
1391
+ source: string;
1392
+ bytes: Uint8Array;
1393
+ subAssets: readonly ImportSubAsset[];
1394
+ siblingCalls: SiblingCall[];
1395
+ decodeCalls: ExtDecodeCall[];
1396
+ }): ImportContext {
1397
+ return {
1398
+ source: opts.source,
1399
+ readSource: async () => ({ ok: true, value: opts.bytes }),
1400
+ readSibling: async (uri: string) => {
1401
+ opts.siblingCalls.push({ uri });
1402
+ try {
1403
+ const buf = await readFile(path.join(FIXTURE_DIR, uri));
1404
+ return { ok: true, value: new Uint8Array(buf) };
1405
+ } catch (e) {
1406
+ const reason = e instanceof Error ? e.message : String(e);
1407
+ const err: ImportErrorType = {
1408
+ code: 'source-read-failed',
1409
+ expected: '',
1410
+ hint: '',
1411
+ detail: { source: uri, reason },
1412
+ message: '[ImportError source-read-failed]',
1413
+ name: 'ImportError',
1414
+ } as never;
1415
+ return { ok: false, error: err };
1416
+ }
1417
+ },
1418
+ decodeImage: async (_bytes, mimeType, settings) => {
1419
+ opts.decodeCalls.push({ mimeType, settings });
1420
+ return {
1421
+ ok: true,
1422
+ value: {
1423
+ texture: {
1424
+ ...EXT_MOCK_TEXTURE,
1425
+ colorSpace: settings.colorSpace === 'linear' ? 'linear' : 'srgb',
1426
+ format: settings.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb',
1427
+ },
1428
+ bytes: new Uint8Array(8),
1429
+ },
1430
+ };
1431
+ },
1432
+ subAssets: opts.subAssets,
1433
+ importSettings: {},
1434
+ };
1435
+ }
1436
+
1437
+ describe('gltf-importer-texture-external.test.ts', () => {
1438
+ describe('gltfImporter texture extraction (b) external URI / .gltf / AC-09', () => {
1439
+ it('reads the external image via ctx.readSibling and decodes through ctx.decodeImage', async () => {
1440
+ const bytes = new Uint8Array(await readFile(path.join(FIXTURE_DIR, 'BoxTextured.gltf')));
1441
+ const sibCalls: SiblingCall[] = [];
1442
+ const decCalls: ExtDecodeCall[] = [];
1443
+
1444
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d470';
1445
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d471';
1446
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d472';
1447
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d473';
1448
+ const ctx = makeExtCtx({
1449
+ source: 'BoxTextured.gltf',
1450
+ bytes,
1451
+ subAssets: [
1452
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1453
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1454
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1455
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1456
+ ],
1457
+ siblingCalls: sibCalls,
1458
+ decodeCalls: decCalls,
1459
+ });
1460
+
1461
+ const produced = unwrap(await gltfImporter.import(ctx));
1462
+ const sibUris = sibCalls.map((c) => c.uri);
1463
+ expect(sibUris).toContain('CesiumLogoFlat.png');
1464
+ expect(sibUris).toContain('BoxTextured0.bin');
1465
+ expect(decCalls.length).toBe(1);
1466
+ expect(decCalls[0]?.mimeType).toBe('image/png');
1467
+
1468
+ const tex = produced.find((p) => p.guid === TEX_GUID);
1469
+ expect(tex?.kind).toBe('texture');
1470
+ expect((tex?.payload as TextureAsset).colorSpace).toBe('srgb');
1471
+
1472
+ const mat = produced.find((p) => p.guid === MAT_GUID);
1473
+ expect(mat?.refs.map((r) => r.guid)).toContain(TEX_GUID);
1474
+ });
1475
+ });
1476
+ });
1477
+ }
1478
+
1479
+ {
1480
+ // ─── from gltf-importer-texture-glb.test.ts ───
1481
+
1482
+ const GLB_MOCK_TEXTURE: TextureAsset = {
1483
+ kind: 'texture',
1484
+ width: 1,
1485
+ height: 1,
1486
+ format: 'rgba8unorm-srgb',
1487
+ data: new Uint8Array([255, 128, 64, 255]),
1488
+ colorSpace: 'srgb',
1489
+ mipmap: true,
1490
+ };
1491
+
1492
+ interface GlbDecodeCall {
1493
+ readonly mimeType: string;
1494
+ readonly settings: Readonly<Record<string, unknown>>;
1495
+ readonly byteCount: number;
1496
+ }
1497
+
1498
+ function makeGlbCtx(opts: {
1499
+ source: string;
1500
+ bytes: Uint8Array;
1501
+ subAssets: readonly ImportSubAsset[];
1502
+ decodeCalls: GlbDecodeCall[];
1503
+ importSettings?: Readonly<Record<string, unknown>>;
1504
+ sourceOverrides?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
1505
+ compressedArtifact?: boolean;
1506
+ }): ImportContext {
1507
+ return {
1508
+ source: opts.source,
1509
+ readSource: async () => ({ ok: true, value: opts.bytes }),
1510
+ readSibling: async (uri: string) => ({
1511
+ ok: false,
1512
+ error: {
1513
+ code: 'source-read-failed',
1514
+ expected: '',
1515
+ hint: '',
1516
+ detail: { source: uri, reason: 'no sibling reads expected on GLB path' },
1517
+ message: 'unused',
1518
+ name: 'ImportError',
1519
+ } as never,
1520
+ }),
1521
+ decodeImage: async (bytes, mimeType, settings) => {
1522
+ opts.decodeCalls.push({ mimeType, settings, byteCount: bytes.byteLength });
1523
+ const texture: TextureAsset = {
1524
+ ...GLB_MOCK_TEXTURE,
1525
+ colorSpace: settings.colorSpace === 'linear' ? 'linear' : 'srgb',
1526
+ format: settings.colorSpace === 'linear' ? 'rgba8unorm' : 'rgba8unorm-srgb',
1527
+ };
1528
+ const decodeValue = {
1529
+ texture,
1530
+ bytes: new Uint8Array(bytes),
1531
+ ...(opts.compressedArtifact === true
1532
+ ? {
1533
+ mediaType: 'image/ktx2',
1534
+ assetCodec: { name: 'basis', profile: 'etc1s' },
1535
+ }
1536
+ : {}),
1537
+ };
1538
+ return {
1539
+ ok: true,
1540
+ value: decodeValue,
1541
+ };
1542
+ },
1543
+ subAssets: opts.subAssets,
1544
+ importSettings: opts.importSettings ?? {},
1545
+ ...(opts.sourceOverrides === undefined ? {} : { sourceOverrides: opts.sourceOverrides }),
1546
+ };
1547
+ }
1548
+
1549
+ const FIXTURE_GLB = path.resolve(
1550
+ __dirname,
1551
+ '../../../../forgeax-engine-assets/khronos-gltf-samples/BoxTextured/BoxTextured.glb',
1552
+ );
1553
+
1554
+ describe('gltf-importer-texture-glb.test.ts', () => {
1555
+ it('cooks an authored Mesh slot default from the source override', async () => {
1556
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1557
+ const textureGuid = '019e2cc6-0c86-79da-aa76-b0984c86d470';
1558
+ const sourceMaterialGuid = '019e2cc6-0c86-79da-aa76-b0984c86d471';
1559
+ const authoredMaterialGuid = '019e2cc6-0c86-79da-aa76-b0984c86d472';
1560
+ const meshGuid = '019e2cc6-0c86-79da-aa76-b0984c86d473';
1561
+ const sceneGuid = '019e2cc6-0c86-79da-aa76-b0984c86d474';
1562
+ const produced = unwrap(
1563
+ await gltfImporter.import(
1564
+ makeGlbCtx({
1565
+ source: 'BoxTextured.glb',
1566
+ bytes,
1567
+ subAssets: [
1568
+ { guid: textureGuid, sourceIndex: 0, sourceKey: 'texture/0', kind: 'texture' },
1569
+ {
1570
+ guid: sourceMaterialGuid,
1571
+ sourceIndex: 0,
1572
+ sourceKey: 'gltf:material:0',
1573
+ kind: 'material',
1574
+ },
1575
+ { guid: meshGuid, sourceIndex: 0, sourceKey: 'mesh/0', kind: 'mesh' },
1576
+ { guid: sceneGuid, sourceIndex: 0, sourceKey: 'scene/0', kind: 'scene' },
1577
+ ],
1578
+ sourceOverrides: {
1579
+ 'mesh/0': {
1580
+ materialSlots: [
1581
+ {
1582
+ slotName: 'Material',
1583
+ sourceKey: 'gltf:material:0',
1584
+ defaultMaterialGuid: sourceMaterialGuid,
1585
+ },
1586
+ ],
1587
+ materialSlotDefaultOverrides: { 'gltf:material:0': authoredMaterialGuid },
1588
+ },
1589
+ },
1590
+ decodeCalls: [],
1591
+ }),
1592
+ ),
1593
+ );
1594
+ const mesh = produced.find((asset) => asset.guid === meshGuid)?.payload;
1595
+
1596
+ expect(mesh?.kind).toBe('mesh');
1597
+ if (mesh?.kind !== 'mesh') throw new Error('expected imported MeshAsset');
1598
+ expect(AssetGuid.format(mesh.materialSlots[0]?.defaultMaterial as never)).toBe(
1599
+ authoredMaterialGuid,
1600
+ );
1601
+ });
1602
+
1603
+ it('keeps a removed authored slot as a defaultless tombstone without a dependency ref', async () => {
1604
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1605
+ const removedGuid = '019e2cc6-0c86-79da-aa76-b0984c86d499';
1606
+ const meshGuid = '019e2cc6-0c86-79da-aa76-b0984c86d473';
1607
+ const produced = unwrap(
1608
+ await gltfImporter.import(
1609
+ makeGlbCtx({
1610
+ source: 'BoxTextured.glb',
1611
+ bytes,
1612
+ subAssets: [
1613
+ {
1614
+ guid: '019e2cc6-0c86-79da-aa76-b0984c86d470',
1615
+ sourceIndex: 0,
1616
+ sourceKey: 'texture/0',
1617
+ kind: 'texture',
1618
+ },
1619
+ {
1620
+ guid: '019e2cc6-0c86-79da-aa76-b0984c86d471',
1621
+ sourceIndex: 0,
1622
+ sourceKey: 'gltf:material:0',
1623
+ kind: 'material',
1624
+ },
1625
+ { guid: meshGuid, sourceIndex: 0, sourceKey: 'mesh/0', kind: 'mesh' },
1626
+ {
1627
+ guid: '019e2cc6-0c86-79da-aa76-b0984c86d474',
1628
+ sourceIndex: 0,
1629
+ sourceKey: 'scene/0',
1630
+ kind: 'scene',
1631
+ },
1632
+ ],
1633
+ sourceOverrides: {
1634
+ 'mesh/0': {
1635
+ materialSlots: [
1636
+ { slotName: 'Material', sourceKey: 'gltf:material:0' },
1637
+ { slotName: 'Removed', sourceKey: 'gltf:material:removed' },
1638
+ ],
1639
+ materialSlotDefaultOverrides: { 'gltf:material:removed': removedGuid },
1640
+ },
1641
+ },
1642
+ decodeCalls: [],
1643
+ }),
1644
+ ),
1645
+ );
1646
+ const meshAsset = produced.find((asset) => asset.guid === meshGuid);
1647
+ if (meshAsset?.payload.kind !== 'mesh') throw new Error('expected imported MeshAsset');
1648
+ expect(meshAsset.payload.materialSlots[1]?.defaultMaterial).toBeUndefined();
1649
+ expect(meshAsset.refs.map((ref) => ref.guid)).not.toContain(removedGuid);
1650
+ });
1651
+
1652
+ describe('gltfImporter texture extraction (a) bufferView path / GLB / AC-08', () => {
1653
+ it('extracts the bufferView image, decodes through ctx.decodeImage, and emits a kind:"texture" ImportedAsset', async () => {
1654
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1655
+ const calls: GlbDecodeCall[] = [];
1656
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1657
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1658
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1659
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1660
+ const ctx = makeGlbCtx({
1661
+ source: 'BoxTextured.glb',
1662
+ bytes,
1663
+ subAssets: [
1664
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1665
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1666
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1667
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1668
+ ],
1669
+ decodeCalls: calls,
1670
+ });
1671
+
1672
+ const produced = unwrap(await gltfImporter.import(ctx));
1673
+ const tex = produced.find((p) => p.guid === TEX_GUID);
1674
+ expect(tex).toBeDefined();
1675
+ expect(tex?.kind).toBe('texture');
1676
+ expect((tex?.payload as TextureAsset).colorSpace).toBe('srgb');
1677
+ expect(calls.length).toBe(1);
1678
+ expect(calls[0]?.mimeType).toBe('image/png');
1679
+
1680
+ const mat = produced.find((p) => p.guid === MAT_GUID);
1681
+ expect(mat?.refs.map((r) => r.guid)).toContain(TEX_GUID);
1682
+ });
1683
+
1684
+ it('preserves Basis artifact facts from a compressed embedded GLB texture', async () => {
1685
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1686
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d470';
1687
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d471';
1688
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d472';
1689
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d473';
1690
+ const produced = unwrap(
1691
+ await gltfImporter.import(
1692
+ makeGlbCtx({
1693
+ source: 'BoxTextured.glb',
1694
+ bytes,
1695
+ subAssets: [
1696
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1697
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1698
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1699
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1700
+ ],
1701
+ decodeCalls: [],
1702
+ importSettings: { compressionMode: 'etc1s' },
1703
+ compressedArtifact: true,
1704
+ }),
1705
+ ),
1706
+ );
1707
+
1708
+ const texture = produced.find((asset) => asset.guid === TEX_GUID);
1709
+ expect(texture?.artifacts.body?.mediaType).toBe('image/ktx2');
1710
+ expect(texture?.artifacts.body?.assetCodec).toEqual({
1711
+ name: 'basis',
1712
+ profile: 'etc1s',
1713
+ });
1714
+ });
1715
+ });
1716
+ });
1717
+
1718
+ describe('asset-ref-edge-metadata-contract (AC-02 / w6)', () => {
1719
+ it('(a) scene refs mesh edge: sourceField filled with componentName/fieldName', async () => {
1720
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1721
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1722
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1723
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1724
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1725
+ const ctx = makeGlbCtx({
1726
+ source: 'BoxTextured.glb',
1727
+ bytes,
1728
+ subAssets: [
1729
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1730
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1731
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1732
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1733
+ ],
1734
+ decodeCalls: [],
1735
+ });
1736
+ const produced = unwrap(await gltfImporter.import(ctx));
1737
+ const scene = produced.find((p) => p.guid === SCENE_GUID);
1738
+ expect(scene).toBeDefined();
1739
+ const meshRefs = scene?.refs.filter((r) => r.guid === MESH_GUID);
1740
+ expect(meshRefs?.length).toBeGreaterThanOrEqual(1);
1741
+ const meshRef = meshRefs?.[0];
1742
+ expect(meshRef?.sourceField?.componentName).toBe('MeshFilter');
1743
+ expect(meshRef?.sourceField?.fieldName).toBe('assetHandle');
1744
+ expect(meshRef?.sceneEntityId).toBeGreaterThanOrEqual(0);
1745
+ expect(scene?.refs.some((ref) => ref.guid === MAT_GUID)).toBe(false);
1746
+ });
1747
+
1748
+ it('(b) sceneEntityId matches entity localId for handle-field refs', async () => {
1749
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1750
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1751
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1752
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1753
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1754
+ const ctx = makeGlbCtx({
1755
+ source: 'BoxTextured.glb',
1756
+ bytes,
1757
+ subAssets: [
1758
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1759
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1760
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1761
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1762
+ ],
1763
+ decodeCalls: [],
1764
+ });
1765
+ const produced = unwrap(await gltfImporter.import(ctx));
1766
+ const scene = produced.find((p) => p.guid === SCENE_GUID);
1767
+ expect(scene).toBeDefined();
1768
+ const meshRef = scene?.refs.find((r) => r.guid === MESH_GUID);
1769
+ expect(meshRef?.sceneEntityId).toBeGreaterThanOrEqual(0);
1770
+ });
1771
+
1772
+ it('(c) transitive texture edges live on MaterialAsset, not SceneAsset', async () => {
1773
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1774
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1775
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1776
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1777
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1778
+ const ctx = makeGlbCtx({
1779
+ source: 'BoxTextured.glb',
1780
+ bytes,
1781
+ subAssets: [
1782
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1783
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1784
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1785
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1786
+ ],
1787
+ decodeCalls: [],
1788
+ });
1789
+ const produced = unwrap(await gltfImporter.import(ctx));
1790
+ const scene = produced.find((p) => p.guid === SCENE_GUID);
1791
+ expect(scene).toBeDefined();
1792
+ expect(scene?.refs.some((ref) => ref.guid === TEX_GUID)).toBe(false);
1793
+ });
1794
+
1795
+ it('(d) material parent edge points at the injected standard root', async () => {
1796
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1797
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1798
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1799
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1800
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1801
+ const ctx = makeGlbCtx({
1802
+ source: 'BoxTextured.glb',
1803
+ bytes,
1804
+ subAssets: [
1805
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1806
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1807
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1808
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1809
+ ],
1810
+ decodeCalls: [],
1811
+ importSettings: { standardMaterialGuid: '019e2cc6-0c86-79da-aa76-b0984c86d4ff' },
1812
+ });
1813
+ const produced = unwrap(await gltfImporter.import(ctx));
1814
+ const mat = produced.find((p) => p.guid === MAT_GUID);
1815
+ expect(mat).toBeDefined();
1816
+ const parentRefs = mat?.refs.filter((r) => r.sourceField?.fieldName === 'parent');
1817
+ expect(parentRefs?.map((ref) => ref.guid)).toEqual(['019e2cc6-0c86-79da-aa76-b0984c86d4ff']);
1818
+ });
1819
+
1820
+ it('(e) material refs texture edges: sourceField.componentName="<material>" and fieldName is texture slot', async () => {
1821
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1822
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1823
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1824
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1825
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1826
+ const ctx = makeGlbCtx({
1827
+ source: 'BoxTextured.glb',
1828
+ bytes,
1829
+ subAssets: [
1830
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1831
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1832
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1833
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1834
+ ],
1835
+ decodeCalls: [],
1836
+ });
1837
+ const produced = unwrap(await gltfImporter.import(ctx));
1838
+ const mat = produced.find((p) => p.guid === MAT_GUID);
1839
+ expect(mat).toBeDefined();
1840
+ const texRef = mat?.refs.find((r) => r.guid === TEX_GUID);
1841
+ expect(texRef).toBeDefined();
1842
+ expect(texRef?.sourceField?.componentName).toBe('<material>');
1843
+ expect(texRef?.sourceField?.fieldName).toBe('baseColorTexture');
1844
+ expect(texRef?.sceneEntityId).toBeUndefined();
1845
+ });
1846
+
1847
+ it('(f) all scene refs AssetRef entries have valid shape', async () => {
1848
+ const bytes = new Uint8Array(await readFile(FIXTURE_GLB));
1849
+ const TEX_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
1850
+ const MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
1851
+ const MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
1852
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
1853
+ const ctx = makeGlbCtx({
1854
+ source: 'BoxTextured.glb',
1855
+ bytes,
1856
+ subAssets: [
1857
+ { guid: TEX_GUID, sourceIndex: 0, kind: 'texture' },
1858
+ { guid: MAT_GUID, sourceIndex: 0, kind: 'material' },
1859
+ { guid: MESH_GUID, sourceIndex: 0, kind: 'mesh' },
1860
+ { guid: SCENE_GUID, sourceIndex: 0, kind: 'scene' },
1861
+ ],
1862
+ decodeCalls: [],
1863
+ });
1864
+ const produced = unwrap(await gltfImporter.import(ctx));
1865
+ const scene = produced.find((p) => p.guid === SCENE_GUID);
1866
+ expect(scene).toBeDefined();
1867
+ for (const ref of scene?.refs ?? []) {
1868
+ expect(typeof ref.guid).toBe('string');
1869
+ expect(ref.guid.length).toBeGreaterThan(0);
1870
+ if (ref.sourceField !== undefined) {
1871
+ expect(typeof ref.sourceField.fieldName).toBe('string');
1872
+ expect(ref.sourceField.fieldName.length).toBeGreaterThan(0);
1873
+ if (ref.sourceField.componentName !== undefined) {
1874
+ expect(typeof ref.sourceField.componentName).toBe('string');
1875
+ }
1876
+ if (ref.sourceField.arrayIndex !== undefined) {
1877
+ expect(typeof ref.sourceField.arrayIndex).toBe('number');
1878
+ }
1879
+ }
1880
+ }
1881
+ });
1882
+ });
1883
+ }
1884
+
1885
+ {
1886
+ // ─── from image-color-space.test.ts ───
1887
+
1888
+ describe('image-color-space.test.ts', () => {
1889
+ describe('deriveTextureColorSpace (D-3 / AC-08)', () => {
1890
+ it('baseColorTexture image classifies as srgb', () => {
1891
+ const map = deriveTextureColorSpace({
1892
+ imageCount: 1,
1893
+ textures: [{ source: 0 }],
1894
+ materials: [{ baseColorTexture: 0 }],
1895
+ });
1896
+ expect(map.get(0)).toBe('srgb');
1897
+ });
1898
+
1899
+ it('normalTexture image classifies as linear', () => {
1900
+ const map = deriveTextureColorSpace({
1901
+ imageCount: 1,
1902
+ textures: [{ source: 0 }],
1903
+ materials: [{ normalTexture: 0 }],
1904
+ });
1905
+ expect(map.get(0)).toBe('linear');
1906
+ });
1907
+
1908
+ it('metallicRoughnessTexture image classifies as linear', () => {
1909
+ const map = deriveTextureColorSpace({
1910
+ imageCount: 1,
1911
+ textures: [{ source: 0 }],
1912
+ materials: [{ metallicRoughnessTexture: 0 }],
1913
+ });
1914
+ expect(map.get(0)).toBe('linear');
1915
+ });
1916
+
1917
+ it('emissiveTexture image classifies as srgb', () => {
1918
+ const map = deriveTextureColorSpace({
1919
+ imageCount: 1,
1920
+ textures: [{ source: 0 }],
1921
+ materials: [{ emissiveTexture: 0 }],
1922
+ });
1923
+ expect(map.get(0)).toBe('srgb');
1924
+ });
1925
+
1926
+ it('occlusionTexture image classifies as linear', () => {
1927
+ const map = deriveTextureColorSpace({
1928
+ imageCount: 1,
1929
+ textures: [{ source: 0 }],
1930
+ materials: [{ occlusionTexture: 0 }],
1931
+ });
1932
+ expect(map.get(0)).toBe('linear');
1933
+ });
1934
+
1935
+ it('orphan image (no texture references it) defaults to linear (AC-13)', () => {
1936
+ const map = deriveTextureColorSpace({
1937
+ imageCount: 3,
1938
+ textures: [{ source: 0 }],
1939
+ materials: [{ baseColorTexture: 0 }],
1940
+ });
1941
+ expect(map.get(0)).toBe('srgb');
1942
+ expect(map.get(1)).toBe('linear');
1943
+ expect(map.get(2)).toBe('linear');
1944
+ });
1945
+
1946
+ it('conflict (same image bound to baseColor + normal) resolves to srgb (requirements section 8)', () => {
1947
+ const map = deriveTextureColorSpace({
1948
+ imageCount: 1,
1949
+ textures: [{ source: 0 }, { source: 0 }],
1950
+ materials: [{ baseColorTexture: 0, normalTexture: 1 }],
1951
+ });
1952
+ expect(map.get(0)).toBe('srgb');
1953
+ });
1954
+
1955
+ it('conflict resolves to srgb regardless of slot ordering (linear seen first then srgb)', () => {
1956
+ const map = deriveTextureColorSpace({
1957
+ imageCount: 1,
1958
+ textures: [{ source: 0 }, { source: 0 }],
1959
+ materials: [{ normalTexture: 0, baseColorTexture: 1 }],
1960
+ });
1961
+ expect(map.get(0)).toBe('srgb');
1962
+ });
1963
+
1964
+ it('multi-material multi-image scene resolves each image independently', () => {
1965
+ const map = deriveTextureColorSpace({
1966
+ imageCount: 4,
1967
+ textures: [{ source: 0 }, { source: 1 }, { source: 2 }, { source: 3 }],
1968
+ materials: [
1969
+ { baseColorTexture: 0, metallicRoughnessTexture: 1, normalTexture: 2 },
1970
+ { emissiveTexture: 3 },
1971
+ ],
1972
+ });
1973
+ expect(map.get(0)).toBe('srgb');
1974
+ expect(map.get(1)).toBe('linear');
1975
+ expect(map.get(2)).toBe('linear');
1976
+ expect(map.get(3)).toBe('srgb');
1977
+ });
1978
+
1979
+ it('empty doc (no images / no materials) returns empty map', () => {
1980
+ const map = deriveTextureColorSpace({ imageCount: 0, textures: [], materials: [] });
1981
+ expect(map.size).toBe(0);
1982
+ });
1983
+
1984
+ it('undefined textures array (glTF without top-level textures[]) treats nothing as referenced', () => {
1985
+ const map = deriveTextureColorSpace({ imageCount: 2, textures: undefined, materials: [] });
1986
+ expect(map.get(0)).toBe('linear');
1987
+ expect(map.get(1)).toBe('linear');
1988
+ });
1989
+
1990
+ it('texture index out of range is silently ignored (no throw, no map entry forced)', () => {
1991
+ const map = deriveTextureColorSpace({
1992
+ imageCount: 1,
1993
+ textures: [{ source: 0 }],
1994
+ materials: [{ baseColorTexture: 99 }],
1995
+ });
1996
+ expect(map.get(0)).toBe('linear');
1997
+ });
1998
+ });
1999
+ });
2000
+ }
2001
+
2002
+ {
2003
+ // ─── from khr-extensions.test.ts ───
2004
+
2005
+ describe('khr-extensions.test.ts', () => {
2006
+ describe('checkExtensions (w14 fixture set)', () => {
2007
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
2008
+ beforeEach(() => {
2009
+ stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
2010
+ });
2011
+ afterEach(() => {
2012
+ stderrSpy.mockRestore();
2013
+ });
2014
+
2015
+ it('v1 allowlist contains EXT_mesh_gpu_instancing (literal)', () => {
2016
+ expect(EXTENSION_ALLOWLIST).toEqual(['EXT_mesh_gpu_instancing', 'EXT_meshopt_compression']);
2017
+ });
2018
+
2019
+ it('(a) rejects extensionsRequired entries outside the allowlist', () => {
2020
+ const result = checkExtensions({
2021
+ extensionsRequired: ['KHR_materials_pbrSpecularGlossiness'],
2022
+ });
2023
+ expect(result.ok).toBe(false);
2024
+ if (result.ok) return;
2025
+ expect(result.error.code).toBe('gltf-extension-unsupported');
2026
+ if (result.error.code !== 'gltf-extension-unsupported') return;
2027
+ expect(result.error.detail.extension).toBe('KHR_materials_pbrSpecularGlossiness');
2028
+ expect(result.error.detail.source).toBe('extensionsRequired');
2029
+ });
2030
+
2031
+ it('(b) accepts extensionsUsed (not required) into diagnostics list with no stderr', () => {
2032
+ const result = checkExtensions({ extensionsUsed: ['KHR_materials_pbrSpecularGlossiness'] });
2033
+ expect(result.ok).toBe(true);
2034
+ if (!result.ok) return;
2035
+ expect(result.value.unsupportedUsed).toEqual(['KHR_materials_pbrSpecularGlossiness']);
2036
+ expect(stderrSpy).not.toHaveBeenCalled();
2037
+ });
2038
+
2039
+ it('(b2) extensionsUsed over-declared (no material references it) stays silent', () => {
2040
+ // Exporters routinely list an extension in extensionsUsed that no
2041
+ // material/node/texture actually references (e.g. KHR_materials_unlit).
2042
+ // It is informational per the glTF spec -> diagnostics-only, never stderr.
2043
+ const result = checkExtensions({ extensionsUsed: ['KHR_materials_unlit'] });
2044
+ expect(result.ok).toBe(true);
2045
+ if (!result.ok) return;
2046
+ expect(result.value.unsupportedUsed).toEqual(['KHR_materials_unlit']);
2047
+ expect(stderrSpy).not.toHaveBeenCalled();
2048
+ });
2049
+
2050
+ it('extensionsRequired wins fail-fast over a benign extensionsUsed entry', () => {
2051
+ const result = checkExtensions({
2052
+ extensionsRequired: ['KHR_materials_unlit'],
2053
+ extensionsUsed: ['KHR_materials_unlit'],
2054
+ });
2055
+ expect(result.ok).toBe(false);
2056
+ if (result.ok) return;
2057
+ expect(result.error.code).toBe('gltf-extension-unsupported');
2058
+ });
2059
+
2060
+ it('passes empty extensions JSON without warning', () => {
2061
+ const result = checkExtensions({});
2062
+ expect(result.ok).toBe(true);
2063
+ if (!result.ok) return;
2064
+ expect(result.value.unsupportedUsed).toEqual([]);
2065
+ expect(stderrSpy).not.toHaveBeenCalled();
2066
+ });
2067
+
2068
+ it('EXT_mesh_gpu_instancing in extensionsRequired routes ok (allowlisted)', () => {
2069
+ const result = checkExtensions({ extensionsRequired: ['EXT_mesh_gpu_instancing'] });
2070
+ expect(result.ok).toBe(true);
2071
+ if (!result.ok) return;
2072
+ expect(result.value.unsupportedUsed).toEqual([]);
2073
+ expect(stderrSpy).not.toHaveBeenCalled();
2074
+ });
2075
+
2076
+ it('EXT_mesh_gpu_instancing in extensionsUsed (not required) routes ok with no warn', () => {
2077
+ const result = checkExtensions({ extensionsUsed: ['EXT_mesh_gpu_instancing'] });
2078
+ expect(result.ok).toBe(true);
2079
+ if (!result.ok) return;
2080
+ expect(result.value.unsupportedUsed).toEqual([]);
2081
+ expect(stderrSpy).not.toHaveBeenCalled();
2082
+ });
2083
+ });
2084
+ });
2085
+ }
2086
+
2087
+ {
2088
+ // ─── from multi-primitive.test.ts ───
2089
+
2090
+ describe('multi-primitive.test.ts', () => {
2091
+ describe('parseGltf multi-primitive (T-M2-01)', () => {
2092
+ it('produces one GltfMeshIr per primitive for a multi-primitive mesh', async () => {
2093
+ const bufferUri = buildBase64Buffer([0.5, 0.5, 0.5, 1.5, 1.5, 1.5, 2.5, 2.5, 2.5]);
2094
+ const json = {
2095
+ asset: { version: '2.0' },
2096
+ buffers: [{ uri: bufferUri }],
2097
+ bufferViews: [
2098
+ { buffer: 0, byteOffset: 0, byteLength: 12 },
2099
+ { buffer: 0, byteOffset: 12, byteLength: 12 },
2100
+ { buffer: 0, byteOffset: 24, byteLength: 12 },
2101
+ { buffer: 0, byteOffset: 36, byteLength: 0 },
2102
+ ],
2103
+ accessors: [
2104
+ {
2105
+ bufferView: 0,
2106
+ componentType: 5126,
2107
+ type: 'VEC3',
2108
+ count: 1,
2109
+ min: [0.5, 0.5, 0.5],
2110
+ max: [0.5, 0.5, 0.5],
2111
+ },
2112
+ {
2113
+ bufferView: 1,
2114
+ componentType: 5126,
2115
+ type: 'VEC3',
2116
+ count: 1,
2117
+ min: [1.5, 1.5, 1.5],
2118
+ max: [1.5, 1.5, 1.5],
2119
+ },
2120
+ {
2121
+ bufferView: 2,
2122
+ componentType: 5126,
2123
+ type: 'VEC3',
2124
+ count: 1,
2125
+ min: [2.5, 2.5, 2.5],
2126
+ max: [2.5, 2.5, 2.5],
2127
+ },
2128
+ { bufferView: 3, componentType: 5123, type: 'SCALAR', count: 0 },
2129
+ ],
2130
+ meshes: [
2131
+ {
2132
+ name: 'Foo',
2133
+ primitives: [
2134
+ { attributes: { POSITION: 0 }, material: 0 },
2135
+ { attributes: { POSITION: 1 }, material: 1 },
2136
+ { attributes: { POSITION: 2 }, material: 0 },
2137
+ ],
2138
+ },
2139
+ ],
2140
+ materials: [{ name: 'matA' }, { name: 'matB' }],
2141
+ nodes: [{ name: 'Root', mesh: 0 }],
2142
+ scenes: [{ nodes: [0] }],
2143
+ };
2144
+ const result = await parseGltf(json, noopLoader, '/test.gltf');
2145
+ expect(result.ok).toBe(true);
2146
+ if (!result.ok) return;
2147
+ const doc: GltfDoc = result.value;
2148
+ expect(doc.meshes.length).toBe(3);
2149
+ expect(doc.meshes[0]?.materialIndex).toBe(0);
2150
+ expect(doc.meshes[1]?.materialIndex).toBe(1);
2151
+ expect(doc.meshes[2]?.materialIndex).toBe(0);
2152
+ expect(doc.meshes[0]?.positions[0]).toBeCloseTo(0.5);
2153
+ expect(doc.meshes[1]?.positions[0]).toBeCloseTo(1.5);
2154
+ expect(doc.meshes[2]?.positions[0]).toBeCloseTo(2.5);
2155
+ });
2156
+
2157
+ it('mesh name is carried to each primitive GltfMeshIr', async () => {
2158
+ const bufferUri = buildBase64Buffer([0, 0, 0, 0, 0, 0]);
2159
+ const json = {
2160
+ asset: { version: '2.0' },
2161
+ buffers: [{ uri: bufferUri }],
2162
+ bufferViews: [
2163
+ { buffer: 0, byteOffset: 0, byteLength: 12 },
2164
+ { buffer: 0, byteOffset: 12, byteLength: 12 },
2165
+ ],
2166
+ accessors: [
2167
+ {
2168
+ bufferView: 0,
2169
+ componentType: 5126,
2170
+ type: 'VEC3',
2171
+ count: 1,
2172
+ min: [0, 0, 0],
2173
+ max: [0, 0, 0],
2174
+ },
2175
+ {
2176
+ bufferView: 1,
2177
+ componentType: 5126,
2178
+ type: 'VEC3',
2179
+ count: 1,
2180
+ min: [0, 0, 0],
2181
+ max: [0, 0, 0],
2182
+ },
2183
+ ],
2184
+ meshes: [
2185
+ {
2186
+ name: 'NamedMesh',
2187
+ primitives: [{ attributes: { POSITION: 0 } }, { attributes: { POSITION: 1 } }],
2188
+ },
2189
+ ],
2190
+ materials: [],
2191
+ nodes: [],
2192
+ scenes: [],
2193
+ };
2194
+ const result = await parseGltf(json, noopLoader, '/test.gltf');
2195
+ expect(result.ok).toBe(true);
2196
+ if (!result.ok) return;
2197
+ const doc: GltfDoc = result.value;
2198
+ expect(doc.meshes.length).toBe(2);
2199
+ expect(doc.meshes[0]?.name).toBe('NamedMesh');
2200
+ expect(doc.meshes[1]?.name).toBe('NamedMesh');
2201
+ });
2202
+
2203
+ it('single-primitive mesh still produces one GltfMeshIr (no regression)', async () => {
2204
+ const bufferUri = buildBase64Buffer([1, 1, 1]);
2205
+ const json = {
2206
+ asset: { version: '2.0' },
2207
+ buffers: [{ uri: bufferUri }],
2208
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }],
2209
+ accessors: [
2210
+ {
2211
+ bufferView: 0,
2212
+ componentType: 5126,
2213
+ type: 'VEC3',
2214
+ count: 1,
2215
+ min: [1, 1, 1],
2216
+ max: [1, 1, 1],
2217
+ },
2218
+ ],
2219
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
2220
+ materials: [],
2221
+ nodes: [],
2222
+ scenes: [],
2223
+ };
2224
+ const result = await parseGltf(json, noopLoader, '/test.gltf');
2225
+ expect(result.ok).toBe(true);
2226
+ if (!result.ok) return;
2227
+ expect(result.value.meshes.length).toBe(1);
2228
+ });
2229
+ });
2230
+ });
2231
+ }
2232
+
2233
+ {
2234
+ // ─── from pbr-material.test.ts ───
2235
+
2236
+ describe('pbr-material.test.ts', () => {
2237
+ describe('parseGltf PBR material 6 fields (T-M3-01)', () => {
2238
+ it('decodes all 6 pbrMetallicRoughness fields into GltfMaterialIr', async () => {
2239
+ const bufferUri = buildBase64Buffer([0, 0, 0]);
2240
+ const json = {
2241
+ asset: { version: '2.0' },
2242
+ buffers: [{ uri: bufferUri }],
2243
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }],
2244
+ accessors: [{ bufferView: 0, componentType: 5126, type: 'VEC3', count: 1 }],
2245
+ meshes: [
2246
+ { name: 'test-mesh', primitives: [{ attributes: { POSITION: 0 }, material: 0 }] },
2247
+ ],
2248
+ materials: [
2249
+ {
2250
+ name: 'test-material',
2251
+ pbrMetallicRoughness: {
2252
+ baseColorFactor: [0.8, 0.6, 0.4, 1.0],
2253
+ baseColorTexture: { index: 0 },
2254
+ metallicFactor: 0.25,
2255
+ roughnessFactor: 0.75,
2256
+ metallicRoughnessTexture: { index: 1 },
2257
+ },
2258
+ normalTexture: { index: 2 },
2259
+ doubleSided: true,
2260
+ },
2261
+ ],
2262
+ textures: [{ source: 0 }, { source: 1 }, { source: 2 }],
2263
+ images: [
2264
+ { uri: 'baseColor.png', mimeType: 'image/png' },
2265
+ { uri: 'metallicRoughness.png', mimeType: 'image/png' },
2266
+ { uri: 'normal.png', mimeType: 'image/png' },
2267
+ ],
2268
+ };
2269
+ const result = await parseGltf(json, noopLoader, '/test.gltf');
2270
+ expect(result.ok).toBe(true);
2271
+ if (!result.ok) throw new Error('expected ok');
2272
+ const doc: GltfDoc = result.value;
2273
+ expect(doc.materials.length).toBe(1);
2274
+ const mat = doc.materials[0];
2275
+ expect(mat).toBeDefined();
2276
+ if (!mat) throw new Error('expected material[0]');
2277
+ expect(mat.baseColorFactor).toEqual([0.8, 0.6, 0.4, 1.0]);
2278
+ expect(mat.baseColorTexture).toMatchObject({ texture: 0 });
2279
+ expect(mat.metallicFactor).toBe(0.25);
2280
+ expect(mat.roughnessFactor).toBe(0.75);
2281
+ expect(mat.metallicRoughnessTexture).toMatchObject({ texture: 1 });
2282
+ expect(mat.normalTexture).toMatchObject({ texture: 2 });
2283
+ expect(mat.doubleSided).toBe(true);
2284
+ expect(doc.textures).toBeDefined();
2285
+ const texs = doc.textures;
2286
+ if (!texs) throw new Error('expected textures');
2287
+ expect(texs.length).toBe(3);
2288
+ expect(texs[0]?.source ?? -1).toBe(0);
2289
+ expect(doc.images).toBeDefined();
2290
+ const imgs = doc.images;
2291
+ if (!imgs) throw new Error('expected images');
2292
+ expect(imgs.length).toBe(3);
2293
+ expect(imgs[0]?.uri ?? '').toBe('baseColor.png');
2294
+ });
2295
+
2296
+ it('fills glTF spec defaults for missing pbrMetallicRoughness fields', async () => {
2297
+ const bufferUri = buildBase64Buffer([0, 0, 0]);
2298
+ const json = {
2299
+ asset: { version: '2.0' },
2300
+ buffers: [{ uri: bufferUri }],
2301
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }],
2302
+ accessors: [{ bufferView: 0, componentType: 5126, type: 'VEC3', count: 1 }],
2303
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
2304
+ materials: [{ pbrMetallicRoughness: {} }],
2305
+ };
2306
+ const result = await parseGltf(json, noopLoader, '/test.gltf');
2307
+ expect(result.ok).toBe(true);
2308
+ if (!result.ok) throw new Error('expected ok');
2309
+ const mat = result.value.materials[0];
2310
+ expect(mat).toBeDefined();
2311
+ if (!mat) throw new Error('expected material[0]');
2312
+ expect(mat.baseColorFactor).toEqual([1, 1, 1, 1]);
2313
+ expect(mat.metallicFactor).toBe(1.0);
2314
+ expect(mat.roughnessFactor).toBe(1.0);
2315
+ expect(mat.baseColorTexture).toBeUndefined();
2316
+ expect(mat.metallicRoughnessTexture).toBeUndefined();
2317
+ expect(mat.normalTexture).toBeUndefined();
2318
+ });
2319
+
2320
+ it('decodes emissiveFactor and emissiveTexture into GltfMaterialIr', async () => {
2321
+ const bufferUri = buildBase64Buffer([0, 0, 0]);
2322
+ const json = {
2323
+ asset: { version: '2.0' },
2324
+ buffers: [{ uri: bufferUri }],
2325
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }],
2326
+ accessors: [{ bufferView: 0, componentType: 5126, type: 'VEC3', count: 1 }],
2327
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
2328
+ materials: [
2329
+ {
2330
+ emissiveFactor: [0.8, 0.4, 0.1],
2331
+ emissiveTexture: { index: 0 },
2332
+ },
2333
+ ],
2334
+ textures: [{ source: 0 }],
2335
+ images: [{ uri: 'emissive.png', mimeType: 'image/png' }],
2336
+ };
2337
+ const result = await parseGltf(json, noopLoader, '/emissive.gltf');
2338
+ expect(result.ok).toBe(true);
2339
+ if (!result.ok) throw new Error('expected ok');
2340
+ const mat = result.value.materials[0];
2341
+ expect(mat?.emissiveFactor).toEqual([0.8, 0.4, 0.1]);
2342
+ expect(mat?.emissiveTexture).toMatchObject({ texture: 0 });
2343
+ });
2344
+
2345
+ it('resolves texture.index -> textures[ti] -> source two-level hop', async () => {
2346
+ const bufferUri = buildBase64Buffer([0, 0, 0]);
2347
+ const json = {
2348
+ asset: { version: '2.0' },
2349
+ buffers: [{ uri: bufferUri }],
2350
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }],
2351
+ accessors: [{ bufferView: 0, componentType: 5126, type: 'VEC3', count: 1 }],
2352
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
2353
+ materials: [
2354
+ {
2355
+ pbrMetallicRoughness: {
2356
+ baseColorFactor: [0.5, 0.5, 0.5, 1.0],
2357
+ baseColorTexture: { index: 1 },
2358
+ },
2359
+ },
2360
+ ],
2361
+ textures: [
2362
+ { source: 3, sampler: 0 },
2363
+ { source: 5, sampler: 0 },
2364
+ ],
2365
+ images: [
2366
+ {},
2367
+ {},
2368
+ {},
2369
+ { uri: 'tex3.png', mimeType: 'image/png' },
2370
+ {},
2371
+ { uri: 'tex5.png', mimeType: 'image/png' },
2372
+ ],
2373
+ };
2374
+ const result = await parseGltf(json, noopLoader, '/test.gltf');
2375
+ expect(result.ok).toBe(true);
2376
+ if (!result.ok) throw new Error('expected ok');
2377
+ const doc = result.value;
2378
+ const mat = doc.materials[0];
2379
+ expect(mat).toBeDefined();
2380
+ if (!mat) throw new Error('expected material[0]');
2381
+ expect(mat.baseColorTexture).toMatchObject({ texture: 1 });
2382
+ const texs2 = doc.textures;
2383
+ if (!texs2) throw new Error('expected textures');
2384
+ expect(texs2[1]?.source ?? -1).toBe(5);
2385
+ const imgs2 = doc.images;
2386
+ if (!imgs2) throw new Error('expected images');
2387
+ expect(imgs2[5]?.uri ?? '').toBe('tex5.png');
2388
+ });
2389
+ });
2390
+ });
2391
+ }
2392
+
2393
+ {
2394
+ // ─── from reimport-reuse-meta.test.ts ───
2395
+
2396
+ function existingMeta(
2397
+ entries: ReadonlyArray<{ kind: string; sourceIndex: number; guid: string }>,
2398
+ ): GltfMetaJson {
2399
+ return {
2400
+ schemaVersion: 1,
2401
+ kind: 'external-asset-package',
2402
+ importer: 'gltf',
2403
+ source: 'box.gltf',
2404
+ subAssets: entries.map((e) => ({ guid: e.guid, sourceIndex: e.sourceIndex, kind: e.kind })),
2405
+ importSettings: {
2406
+ defaultSceneIndex: 0,
2407
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
2408
+ },
2409
+ };
2410
+ }
2411
+
2412
+ describe('reimport-reuse-meta.test.ts', () => {
2413
+ describe('subAssetKey', () => {
2414
+ it('builds indexFallback combining pluralised kind and sourceIndex', () => {
2415
+ expect(subAssetKey({ kind: 'mesh', sourceIndex: 2, name: 'foo' })).toEqual({
2416
+ kind: 'mesh',
2417
+ name: 'foo',
2418
+ indexFallback: 'meshes/2',
2419
+ });
2420
+ });
2421
+
2422
+ it('preserves name=null when source is unnamed', () => {
2423
+ expect(subAssetKey({ kind: 'material', sourceIndex: 0 })).toEqual({
2424
+ kind: 'material',
2425
+ name: null,
2426
+ indexFallback: 'materials/0',
2427
+ });
2428
+ });
2429
+ });
2430
+
2431
+ describe('reimportReuseMeta (w12 fixture set)', () => {
2432
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
2433
+ beforeEach(() => {
2434
+ stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
2435
+ });
2436
+ afterEach(() => {
2437
+ stderrSpy.mockRestore();
2438
+ });
2439
+
2440
+ it('(a) reuses every GUID byte-identically when nothing changes', () => {
2441
+ const items: GltfDocItem[] = [
2442
+ { kind: 'mesh', sourceIndex: 0, name: 'Box' },
2443
+ { kind: 'material', sourceIndex: 0, name: 'Mat' },
2444
+ { kind: 'scene', sourceIndex: 0, name: 'Scene0' },
2445
+ ];
2446
+ const meta = existingMeta([
2447
+ { kind: 'mesh', sourceIndex: 0, guid: '01928000-7c00-7000-8000-000000000001' },
2448
+ { kind: 'material', sourceIndex: 0, guid: '01928000-7c00-7000-8000-000000000002' },
2449
+ { kind: 'scene', sourceIndex: 0, guid: '01928000-7c00-7000-8000-000000000003' },
2450
+ ]);
2451
+ const result = unwrapReimport(reimportReuseMeta(items, meta));
2452
+ expect(result.subAssets.map((s) => s.guid)).toEqual([
2453
+ '01928000-7c00-7000-8000-000000000001',
2454
+ '01928000-7c00-7000-8000-000000000002',
2455
+ '01928000-7c00-7000-8000-000000000003',
2456
+ ]);
2457
+ });
2458
+
2459
+ it('(b) double-name conflict routes every mesh through indexFallback + warns once per conflict', () => {
2460
+ const items: GltfDocItem[] = [
2461
+ { kind: 'mesh', sourceIndex: 0, name: 'foo' },
2462
+ { kind: 'mesh', sourceIndex: 1, name: 'foo' },
2463
+ ];
2464
+ const meta = existingMeta([
2465
+ { kind: 'mesh', sourceIndex: 0, guid: '01928000-7c00-7000-8000-00000000000a' },
2466
+ { kind: 'mesh', sourceIndex: 1, guid: '01928000-7c00-7000-8000-00000000000b' },
2467
+ ]);
2468
+ const result = reimportReuseMeta(items, meta);
2469
+ expect(result.ok).toBe(false);
2470
+ if (result.ok) return;
2471
+ expect(result.error.code).toBe('duplicate-source-key');
2472
+ expect(result.error.detail.sourceIndices).toEqual([0, 1]);
2473
+ expect(stderrSpy).not.toHaveBeenCalled();
2474
+ });
2475
+
2476
+ it('(c) deleted mesh is dropped, surviving mesh reused, new mesh gets fresh v7', () => {
2477
+ const items: GltfDocItem[] = [
2478
+ { kind: 'mesh', sourceIndex: 1, name: 'B' },
2479
+ { kind: 'mesh', sourceIndex: 2, name: 'NewMesh' },
2480
+ ];
2481
+ const meta = existingMeta([
2482
+ { kind: 'mesh', sourceIndex: 0, guid: '01928000-7c00-7000-8000-00000000aaaa' },
2483
+ { kind: 'mesh', sourceIndex: 1, guid: '01928000-7c00-7000-8000-00000000bbbb' },
2484
+ ]);
2485
+ const result = unwrapReimport(reimportReuseMeta(items, meta));
2486
+ expect(result.subAssets[0]?.guid).toBe('01928000-7c00-7000-8000-00000000bbbb');
2487
+ const fresh = result.subAssets[1]?.guid ?? '';
2488
+ expect(fresh).not.toBe('01928000-7c00-7000-8000-00000000aaaa');
2489
+ expect(AssetGuid.parse(fresh).ok).toBe(true);
2490
+ expect(
2491
+ result.subAssets.some((s) => s.guid === '01928000-7c00-7000-8000-00000000aaaa'),
2492
+ ).toBe(false);
2493
+ });
2494
+
2495
+ it('(d) stage 2 reuses GUID when name differs but (kind,indexFallback) matches', () => {
2496
+ const items: GltfDocItem[] = [{ kind: 'mesh', sourceIndex: 0, name: 'Renamed' }];
2497
+ const meta = existingMeta([
2498
+ { kind: 'mesh', sourceIndex: 0, guid: '01928000-7c00-7000-8000-0000000000aa' },
2499
+ ]);
2500
+ const result = unwrapReimport(reimportReuseMeta(items, meta));
2501
+ expect(result.subAssets[0]?.guid).toBe('01928000-7c00-7000-8000-0000000000aa');
2502
+ });
2503
+
2504
+ it('first-import (no existing meta) mints all-fresh v7 GUIDs', () => {
2505
+ const items: GltfDocItem[] = [
2506
+ { kind: 'mesh', sourceIndex: 0, name: 'Box' },
2507
+ { kind: 'scene', sourceIndex: 0, name: 'Scene0' },
2508
+ ];
2509
+ const result = unwrapReimport(reimportReuseMeta(items, undefined));
2510
+ expect(result.subAssets.length).toBe(2);
2511
+ for (const sa of result.subAssets) {
2512
+ expect(AssetGuid.parse(sa.guid).ok).toBe(true);
2513
+ }
2514
+ expect(result.subAssets[0]?.guid).not.toBe(result.subAssets[1]?.guid);
2515
+ });
2516
+ });
2517
+ });
2518
+ }
2519
+
2520
+ {
2521
+ // ─── from skin-to-asset-pack.test.ts ───
2522
+
2523
+ function makeFixtureDoc(): GltfDoc {
2524
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
2525
+ const indices = new Uint16Array([0, 1, 2]);
2526
+ const ibm = new Float32Array(16);
2527
+ ibm[0] = 1;
2528
+ ibm[5] = 1;
2529
+ ibm[10] = 1;
2530
+ ibm[15] = 1;
2531
+
2532
+ return {
2533
+ meshes: [{ name: 'SkinnedMesh', positions, indices, materialIndex: 0, meshIndex: 0 }],
2534
+ materials: [{ baseColorFactor: [1, 1, 1, 1], metallicFactor: 0, roughnessFactor: 1 }],
2535
+ nodes: [
2536
+ {
2537
+ name: 'root',
2538
+ transform: { translation: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] },
2539
+ meshIndex: 0,
2540
+ skinIndex: 0,
2541
+ camera: null,
2542
+ children: [1],
2543
+ },
2544
+ {
2545
+ name: 'jointA',
2546
+ transform: { translation: [0, 1, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] },
2547
+ meshIndex: null,
2548
+ skinIndex: null,
2549
+ camera: null,
2550
+ children: [],
2551
+ },
2552
+ ],
2553
+ scenes: [{ nodes: [0] }],
2554
+ textures: undefined,
2555
+ images: undefined,
2556
+ samplers: undefined,
2557
+ skeletons: [{ jointCount: 1, inverseBindMatrices: ibm, jointPaths: ['root/jointA'] }],
2558
+ animationClips: [
2559
+ {
2560
+ duration: 1.0,
2561
+ channels: [
2562
+ {
2563
+ targetId: 'ab9b78fd51898a86ab273a47863a7601' as never,
2564
+ targetNodeIndex: 1,
2565
+ property: 'rotation',
2566
+ sampler: {
2567
+ input: new Float32Array([0, 1]),
2568
+ output: new Float32Array([0, 0, 0, 1, 0, 1, 0, 0]),
2569
+ interpolation: 'LINEAR',
2570
+ },
2571
+ },
2572
+ ],
2573
+ },
2574
+ ],
2575
+ defaultSceneIndex: 0,
2576
+ diagnostics: {
2577
+ nodeNames: ['root', 'jointA'],
2578
+ unsupportedExtensions: [],
2579
+ matrixTrsCoexistNodes: [],
2580
+ },
2581
+ };
2582
+ }
2583
+
2584
+ describe('skin-to-asset-pack.test.ts', () => {
2585
+ describe('toAssetPack with skin + animation (M0 integration)', () => {
2586
+ it('produces 6 sub-asset kind types (mesh, material, scene, skeleton, skin, animation-clip)', () => {
2587
+ const doc = makeFixtureDoc();
2588
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'test.gltf'));
2589
+ const kinds = new Set(subAssets.map((s) => s.kind));
2590
+ expect(kinds.has('mesh')).toBe(true);
2591
+ expect(kinds.has('material')).toBe(true);
2592
+ expect(kinds.has('scene')).toBe(true);
2593
+ expect(kinds.has('skeleton')).toBe(true);
2594
+ expect(kinds.has('skin')).toBe(true);
2595
+ expect(kinds.has('animation-clip')).toBe(true);
2596
+ expect(subAssets.length).toBe(6);
2597
+ });
2598
+
2599
+ it('sub-asset GUIDs are non-empty strings', () => {
2600
+ const doc = makeFixtureDoc();
2601
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'test.gltf'));
2602
+ for (const sa of subAssets) {
2603
+ expect(typeof sa.guid).toBe('string');
2604
+ expect(sa.guid.length).toBe(36);
2605
+ }
2606
+ });
2607
+
2608
+ it('skeleton sub-asset keeps sourceIndex aligned with GltfDoc.skeletons', () => {
2609
+ const doc = makeFixtureDoc();
2610
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'test.gltf'));
2611
+ const skeletonEntries = subAssets.filter((s) => s.kind === 'skeleton');
2612
+ expect(skeletonEntries.length).toBe(1);
2613
+ expect(skeletonEntries[0]?.sourceIndex).toBe(0);
2614
+ });
2615
+
2616
+ it('animation-clip sub-asset keeps sourceIndex aligned', () => {
2617
+ const doc = makeFixtureDoc();
2618
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'test.gltf'));
2619
+ const animEntries = subAssets.filter((s) => s.kind === 'animation-clip');
2620
+ expect(animEntries.length).toBe(1);
2621
+ expect(animEntries[0]?.sourceIndex).toBe(0);
2622
+ });
2623
+
2624
+ it('uses animation names for stable source keys', () => {
2625
+ const doc = makeFixtureDoc();
2626
+ const clip = doc.animationClips[0];
2627
+ expect(clip).toBeDefined();
2628
+ if (clip === undefined) return;
2629
+ const named = {
2630
+ ...doc,
2631
+ animationClips: [
2632
+ { ...clip, name: 'Walk' },
2633
+ { ...clip, name: 'Run' },
2634
+ ],
2635
+ };
2636
+ const { subAssets } = unwrapAssetPack(toAssetPack(named, undefined, 'test.gltf'));
2637
+ expect(
2638
+ subAssets.filter((s) => s.kind === 'animation-clip').map((s) => s.sourceKey),
2639
+ ).toEqual(['animation-clip:Walk', 'animation-clip:Run']);
2640
+ });
2641
+
2642
+ it('reimport with same content produces identical GUIDs', () => {
2643
+ const doc = makeFixtureDoc();
2644
+ const first = unwrapAssetPack(toAssetPack(doc, undefined, 'test.gltf'));
2645
+ const second = unwrapAssetPack(
2646
+ toAssetPack(
2647
+ doc,
2648
+ {
2649
+ schemaVersion: 1,
2650
+ kind: 'external-asset-package',
2651
+ importer: 'gltf',
2652
+ source: 'test.gltf',
2653
+ subAssets: first.subAssets,
2654
+ importSettings: {
2655
+ defaultSceneIndex: 0,
2656
+ diagnostics: {
2657
+ nodeNames: [],
2658
+ unsupportedExtensions: [],
2659
+ matrixTrsCoexistNodes: [],
2660
+ },
2661
+ },
2662
+ },
2663
+ 'test.gltf',
2664
+ ),
2665
+ );
2666
+ expect(second.subAssets.length).toBe(first.subAssets.length);
2667
+ for (let i = 0; i < first.subAssets.length; i++) {
2668
+ expect(second.subAssets[i]?.guid).toBe(first.subAssets[i]?.guid);
2669
+ }
2670
+ });
2671
+
2672
+ it('BindPose AABB exported from parse-skin computes correctly', async () => {
2673
+ const { computeBindPoseAABB } = await import('../parse-skin.js');
2674
+ const pos = new Float32Array([-1, -2, -3, 1, 2, 3, 0, 0, 0]);
2675
+ const aabb = computeBindPoseAABB(pos);
2676
+ expect(aabb).toBeDefined();
2677
+ if (!aabb) return;
2678
+ expect(aabb.min).toEqual([-1, -2, -3]);
2679
+ expect(aabb.max).toEqual([1, 2, 3]);
2680
+ });
2681
+ });
2682
+ });
2683
+ }
2684
+
2685
+ {
2686
+ // ─── from texture-load.test.ts ───
2687
+
2688
+ function buildMinimalJson(materialOverrides: object, images: object[]) {
2689
+ const bufferUri = buildBase64Buffer([0, 0, 0]);
2690
+ return {
2691
+ asset: { version: '2.0' },
2692
+ buffers: [{ uri: bufferUri }],
2693
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 12 }],
2694
+ accessors: [{ bufferView: 0, componentType: 5126, type: 'VEC3', count: 1 }],
2695
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
2696
+ materials: [
2697
+ {
2698
+ pbrMetallicRoughness: { baseColorFactor: [1, 1, 1, 1], baseColorTexture: { index: 0 } },
2699
+ ...materialOverrides,
2700
+ },
2701
+ ],
2702
+ textures: [{ source: 0 }],
2703
+ images,
2704
+ };
2705
+ }
2706
+
2707
+ const pngLoader = async (_: string) => new ArrayBuffer(0);
2708
+ const jpegLoader = async (_: string) => new ArrayBuffer(0);
2709
+
2710
+ describe('texture-load.test.ts', () => {
2711
+ describe('parseGltf texture load (T-M3-02)', () => {
2712
+ it('resolves textures via externalLoader (URI success path)', async () => {
2713
+ const json = buildMinimalJson({}, [{ uri: 'textures/checker.png', mimeType: 'image/png' }]);
2714
+ const result = await parseGltf(json, pngLoader, '/test.gltf');
2715
+ expect(result.ok).toBe(true);
2716
+ if (!result.ok) throw new Error('expected ok');
2717
+ const doc = result.value;
2718
+ expect(doc.textures).toBeDefined();
2719
+ const texs3 = doc.textures;
2720
+ if (!texs3) throw new Error('expected textures');
2721
+ expect(texs3.length).toBe(1);
2722
+ expect(texs3[0]?.source ?? -1).toBe(0);
2723
+ expect(doc.images).toBeDefined();
2724
+ const imgs3 = doc.images;
2725
+ if (!imgs3) throw new Error('expected images');
2726
+ expect(imgs3.length).toBe(1);
2727
+ expect(imgs3[0]?.uri ?? '').toBe('textures/checker.png');
2728
+ });
2729
+
2730
+ it('fails with gltf-texture-load-failed when externalLoader rejects', async () => {
2731
+ const failingLoader = async (_: string) => {
2732
+ throw new Error('network error');
2733
+ };
2734
+ const json = buildMinimalJson({}, [{ uri: 'textures/missing.png', mimeType: 'image/png' }]);
2735
+ const result = await parseGltf(json, failingLoader, '/test.gltf');
2736
+ expect(result.ok).toBe(false);
2737
+ if (result.ok) throw new Error('expected error');
2738
+ const err = result.error;
2739
+ expect(err.code).toBe('gltf-texture-load-failed');
2740
+ if (err.code === 'gltf-texture-load-failed') {
2741
+ expect(err.detail.uri).toBe('textures/missing.png');
2742
+ }
2743
+ });
2744
+
2745
+ it('fails with gltf-image-mime-unsupported for non-JPEG/PNG mimeType', async () => {
2746
+ const failingLoader = async (_: string) => {
2747
+ throw new Error('should not be called - mime check comes first');
2748
+ };
2749
+ const json = buildMinimalJson({}, [{ uri: 'textures/photo.webp', mimeType: 'image/webp' }]);
2750
+ const result = await parseGltf(json, failingLoader, '/test.gltf');
2751
+ expect(result.ok).toBe(false);
2752
+ if (result.ok) throw new Error('expected error');
2753
+ const err = result.error;
2754
+ expect(err.code).toBe('gltf-image-mime-unsupported');
2755
+ if (err.code === 'gltf-image-mime-unsupported') {
2756
+ expect(err.detail.mimeType).toBe('image/webp');
2757
+ }
2758
+ });
2759
+
2760
+ it('accepts image/jpeg mimeType successfully', async () => {
2761
+ const json = buildMinimalJson({}, [{ uri: 'textures/photo.jpg', mimeType: 'image/jpeg' }]);
2762
+ const result = await parseGltf(json, jpegLoader, '/test.gltf');
2763
+ expect(result.ok).toBe(true);
2764
+ if (!result.ok) throw new Error('expected ok');
2765
+ const imgs4 = result.value.images;
2766
+ if (!imgs4) throw new Error('expected images');
2767
+ expect(imgs4[0]?.uri ?? '').toBe('textures/photo.jpg');
2768
+ });
2769
+
2770
+ it('accepts image/png mimeType successfully', async () => {
2771
+ const json = buildMinimalJson({}, [{ uri: 'textures/photo.png', mimeType: 'image/png' }]);
2772
+ const result = await parseGltf(json, pngLoader, '/test.gltf');
2773
+ expect(result.ok).toBe(true);
2774
+ if (!result.ok) throw new Error('expected ok');
2775
+ const imgs5 = result.value.images;
2776
+ if (!imgs5) throw new Error('expected images');
2777
+ expect(imgs5[0]?.uri ?? '').toBe('textures/photo.png');
2778
+ });
2779
+ });
2780
+ });
2781
+ }
2782
+
2783
+ {
2784
+ // ─── from to-asset-pack-texture.test.ts ───
2785
+
2786
+ function makeDoc(images: Array<{ readonly name?: string }>): GltfDoc {
2787
+ return {
2788
+ meshes: [],
2789
+ materials: [],
2790
+ nodes: [],
2791
+ scenes: [],
2792
+ textures: undefined,
2793
+ images: images.length > 0 ? images : undefined,
2794
+ samplers: undefined,
2795
+ skeletons: [],
2796
+ animationClips: [],
2797
+ defaultSceneIndex: 0,
2798
+ diagnostics: { nodeNames: [], unsupportedExtensions: [], matrixTrsCoexistNodes: [] },
2799
+ };
2800
+ }
2801
+
2802
+ describe('to-asset-pack-texture.test.ts', () => {
2803
+ describe('toAssetPack emits texture sub-assets (G-2 fix / AC-13)', () => {
2804
+ it('emits one kind:"texture" sub-asset per glTF images[] row', () => {
2805
+ const doc = makeDoc([{ name: 'logo' }, { name: 'normal' }]);
2806
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'box.gltf'));
2807
+ const textureSubs = subAssets.filter((s) => s.kind === 'texture');
2808
+ expect(textureSubs.length).toBe(2);
2809
+ expect(textureSubs.map((s) => s.sourceIndex).sort()).toEqual([0, 1]);
2810
+ });
2811
+
2812
+ it('orphan images (no textures[] reference) still produce texture sub-assets (AC-13)', () => {
2813
+ const doc = makeDoc([{ name: 'orphan-a' }, { name: 'orphan-b' }]);
2814
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'orphans.gltf'));
2815
+ const textureSubs = subAssets.filter((s) => s.kind === 'texture');
2816
+ expect(textureSubs.length).toBe(2);
2817
+ });
2818
+
2819
+ it('no images[] -> no texture sub-assets emitted', () => {
2820
+ const doc = makeDoc([]);
2821
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'no-tex.gltf'));
2822
+ expect(subAssets.filter((s) => s.kind === 'texture')).toEqual([]);
2823
+ });
2824
+
2825
+ it('image without a name produces a texture sub-asset (indexFallback path)', () => {
2826
+ const doc = makeDoc([{}]);
2827
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'unnamed.gltf'));
2828
+ const tex = subAssets.find((s) => s.kind === 'texture');
2829
+ expect(tex).toBeDefined();
2830
+ expect(tex?.sourceIndex).toBe(0);
2831
+ });
2832
+
2833
+ it('texture sub-assets get freshly-minted UUIDv7 GUIDs on first import', () => {
2834
+ const doc = makeDoc([{ name: 'a' }]);
2835
+ const { subAssets } = unwrapAssetPack(toAssetPack(doc, undefined, 'fresh.gltf'));
2836
+ const tex = subAssets.find((s) => s.kind === 'texture');
2837
+ expect(tex?.guid).toMatch(
2838
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/,
2839
+ );
2840
+ });
2841
+ });
2842
+ });
2843
+ }
2844
+
2845
+ {
2846
+ // ─── from gltf-importer-name.test.ts (AC-09) ───
2847
+
2848
+ const NAME_MESH_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d460';
2849
+ const NAME_MAT_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d461';
2850
+
2851
+ function buildNamedGltfBytes(): Uint8Array {
2852
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
2853
+ const posBytes = new Uint8Array(positions.buffer);
2854
+ const b64 = Buffer.from(posBytes).toString('base64');
2855
+
2856
+ const gltf = {
2857
+ asset: { version: '2.0' },
2858
+ scene: 0,
2859
+ scenes: [{ nodes: [0], name: 'MainScene' }],
2860
+ nodes: [{ mesh: 0 }],
2861
+ meshes: [{ name: 'Triangle', primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
2862
+ materials: [
2863
+ {
2864
+ name: 'RedMat',
2865
+ pbrMetallicRoughness: {
2866
+ baseColorFactor: [0.8, 0.2, 0.2, 1],
2867
+ metallicFactor: 0,
2868
+ roughnessFactor: 1,
2869
+ },
2870
+ },
2871
+ ],
2872
+ accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
2873
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: posBytes.byteLength }],
2874
+ buffers: [
2875
+ { uri: `data:application/octet-stream;base64,${b64}`, byteLength: posBytes.byteLength },
2876
+ ],
2877
+ };
2878
+ return new TextEncoder().encode(JSON.stringify(gltf));
2879
+ }
2880
+
2881
+ function buildSingleAssetGltfBytes(): Uint8Array {
2882
+ const positions = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]);
2883
+ const posBytes = new Uint8Array(positions.buffer);
2884
+ const b64 = Buffer.from(posBytes).toString('base64');
2885
+
2886
+ const gltf = {
2887
+ asset: { version: '2.0' },
2888
+ scene: 0,
2889
+ scenes: [{ nodes: [0] }],
2890
+ nodes: [{ mesh: 0 }],
2891
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }],
2892
+ accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
2893
+ bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: posBytes.byteLength }],
2894
+ buffers: [
2895
+ { uri: `data:application/octet-stream;base64,${b64}`, byteLength: posBytes.byteLength },
2896
+ ],
2897
+ };
2898
+ return new TextEncoder().encode(JSON.stringify(gltf));
2899
+ }
2900
+
2901
+ describe('gltf-importer-name.test.ts', () => {
2902
+ describe('gltfImporter asset name plumbing (AC-09)', () => {
2903
+ it('multi-asset glTF: mesh and material name from glTF names', async () => {
2904
+ const bytes = buildNamedGltfBytes();
2905
+ const reg = new ImporterRegistry();
2906
+ reg.register(gltfImporter);
2907
+ const meta: RunImportMeta = {
2908
+ importer: 'gltf',
2909
+ source: 'named.gltf',
2910
+ subAssets: [
2911
+ { guid: NAME_MESH_GUID, sourceIndex: 0, sourceKey: 'mesh:Triangle', kind: 'mesh' },
2912
+ { guid: NAME_MAT_GUID, sourceIndex: 0, sourceKey: 'material:RedMat', kind: 'material' },
2913
+ ],
2914
+ };
2915
+ const fs: ImportRunnerFs = {
2916
+ readSource: async () => ({ ok: true as const, value: bytes }),
2917
+ };
2918
+ const res = await runImport(meta, reg, fs);
2919
+ expect(res.ok).toBe(true);
2920
+ if (!res.ok || 'skipped' in res.value) throw new Error('expected a DDC pack');
2921
+ const pack = res.value.pack;
2922
+ const mesh = pack.assets.find((a) => a.guid === NAME_MESH_GUID);
2923
+ const mat = pack.assets.find((a) => a.guid === NAME_MAT_GUID);
2924
+ expect(mesh?.name).toBe('Triangle');
2925
+ expect(mat?.name).toBe('RedMat');
2926
+ });
2927
+
2928
+ it('multi-asset glTF: scene name from scene name', async () => {
2929
+ const bytes = buildNamedGltfBytes();
2930
+ const reg = new ImporterRegistry();
2931
+ reg.register(gltfImporter);
2932
+ const SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d462';
2933
+ const meta: RunImportMeta = {
2934
+ importer: 'gltf',
2935
+ source: 'named.gltf',
2936
+ subAssets: [
2937
+ { guid: NAME_MESH_GUID, sourceIndex: 0, sourceKey: 'mesh:Triangle', kind: 'mesh' },
2938
+ { guid: NAME_MAT_GUID, sourceIndex: 0, sourceKey: 'material:RedMat', kind: 'material' },
2939
+ { guid: SCENE_GUID, sourceIndex: 0, sourceKey: 'scene:MainScene', kind: 'scene' },
2940
+ ],
2941
+ };
2942
+ const fs: ImportRunnerFs = {
2943
+ readSource: async () => ({ ok: true as const, value: bytes }),
2944
+ };
2945
+ const res = await runImport(meta, reg, fs);
2946
+ expect(res.ok).toBe(true);
2947
+ if (!res.ok || 'skipped' in res.value) throw new Error('expected a DDC pack');
2948
+ const pack = res.value.pack;
2949
+ const scene = pack.assets.find((a) => a.guid === SCENE_GUID);
2950
+ expect(scene?.name).toBe('MainScene');
2951
+ });
2952
+
2953
+ it('single-asset glTF: name is undefined (derived from packagePath)', async () => {
2954
+ const bytes = buildSingleAssetGltfBytes();
2955
+ const reg = new ImporterRegistry();
2956
+ reg.register(gltfImporter);
2957
+ const SINGLE_SCENE_GUID = '019e2cc6-0c86-79da-aa76-b0984c86d463';
2958
+ const meta: RunImportMeta = {
2959
+ importer: 'gltf',
2960
+ source: 'unnamed.gltf',
2961
+ subAssets: [{ guid: SINGLE_SCENE_GUID, sourceIndex: 0, kind: 'scene' }],
2962
+ };
2963
+ const fs: ImportRunnerFs = {
2964
+ readSource: async () => ({ ok: true as const, value: bytes }),
2965
+ };
2966
+ const res = await runImport(meta, reg, fs);
2967
+ expect(res.ok).toBe(true);
2968
+ if (!res.ok || 'skipped' in res.value) throw new Error('expected a DDC pack');
2969
+ const pack = res.value.pack;
2970
+ const scene = pack.assets.find((a) => a.guid === SINGLE_SCENE_GUID);
2971
+ expect(scene?.name).toBeUndefined();
2972
+ });
2973
+ });
2974
+ });
2975
+ }