@forgeax/engine-gltf 0.1.2

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 (217) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +197 -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__/asset-runtime-fixture.d.ts +14 -0
  7. package/dist/__tests__/asset-runtime-fixture.d.ts.map +1 -0
  8. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts +2 -0
  9. package/dist/__tests__/bridge-material-samplers.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/bridge-material-values.unit.test.d.ts +2 -0
  11. package/dist/__tests__/bridge-material-values.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts +2 -0
  13. package/dist/__tests__/bridge-mixed-color.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/bridge-sampler-required.test-d.d.ts +2 -0
  15. package/dist/__tests__/bridge-sampler-required.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts +2 -0
  17. package/dist/__tests__/bridge-skin-shader-route.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bridge-skin-stride.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/bridge-test-helpers.d.ts +5 -0
  21. package/dist/__tests__/bridge-test-helpers.d.ts.map +1 -0
  22. package/dist/__tests__/bridge.unit.test.d.ts +2 -0
  23. package/dist/__tests__/bridge.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/cli-gltf.integration.test.d.ts +2 -0
  25. package/dist/__tests__/cli-gltf.integration.test.d.ts.map +1 -0
  26. package/dist/__tests__/color-0-accessor.unit.test.d.ts +2 -0
  27. package/dist/__tests__/color-0-accessor.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/data-uri-surface.unit.test.d.ts +2 -0
  29. package/dist/__tests__/data-uri-surface.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-producer.integration.test.d.ts +2 -0
  79. package/dist/__tests__/source-key-producer.integration.test.d.ts.map +1 -0
  80. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  81. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  82. package/dist/accessor/decode-accessor.d.ts +75 -0
  83. package/dist/accessor/decode-accessor.d.ts.map +1 -0
  84. package/dist/accessor/decode-color.d.ts +15 -0
  85. package/dist/accessor/decode-color.d.ts.map +1 -0
  86. package/dist/accessor/index.d.ts +3 -0
  87. package/dist/accessor/index.d.ts.map +1 -0
  88. package/dist/bridge.d.ts +104 -0
  89. package/dist/bridge.d.ts.map +1 -0
  90. package/dist/check-extensions.d.ts +29 -0
  91. package/dist/check-extensions.d.ts.map +1 -0
  92. package/dist/cli-gltf.d.ts +8 -0
  93. package/dist/cli-gltf.d.ts.map +1 -0
  94. package/dist/cli-gltf.mjs +2419 -0
  95. package/dist/cli-gltf.mjs.map +1 -0
  96. package/dist/data-uri.d.ts +6 -0
  97. package/dist/data-uri.d.ts.map +1 -0
  98. package/dist/errors.d.ts +201 -0
  99. package/dist/errors.d.ts.map +1 -0
  100. package/dist/extract-image-bytes.d.ts +42 -0
  101. package/dist/extract-image-bytes.d.ts.map +1 -0
  102. package/dist/gltf-importer.d.ts +21 -0
  103. package/dist/gltf-importer.d.ts.map +1 -0
  104. package/dist/image-color-space.d.ts +35 -0
  105. package/dist/image-color-space.d.ts.map +1 -0
  106. package/dist/importer-entry.d.ts +10 -0
  107. package/dist/importer-entry.d.ts.map +1 -0
  108. package/dist/importer-entry.mjs +3367 -0
  109. package/dist/importer-entry.mjs.map +1 -0
  110. package/dist/index.d.ts +25 -0
  111. package/dist/index.d.ts.map +1 -0
  112. package/dist/index.mjs +3645 -0
  113. package/dist/index.mjs.map +1 -0
  114. package/dist/meshopt-decode.d.ts +39 -0
  115. package/dist/meshopt-decode.d.ts.map +1 -0
  116. package/dist/node-file-entry.d.ts +14 -0
  117. package/dist/node-file-entry.d.ts.map +1 -0
  118. package/dist/node-file-entry.mjs +1942 -0
  119. package/dist/node-file-entry.mjs.map +1 -0
  120. package/dist/node-path.d.ts +14 -0
  121. package/dist/node-path.d.ts.map +1 -0
  122. package/dist/parse-animation.d.ts +70 -0
  123. package/dist/parse-animation.d.ts.map +1 -0
  124. package/dist/parse-glb-chunks.d.ts +25 -0
  125. package/dist/parse-glb-chunks.d.ts.map +1 -0
  126. package/dist/parse-glb.d.ts +3 -0
  127. package/dist/parse-glb.d.ts.map +1 -0
  128. package/dist/parse-gltf-header.d.ts +18 -0
  129. package/dist/parse-gltf-header.d.ts.map +1 -0
  130. package/dist/parse-gltf.d.ts +263 -0
  131. package/dist/parse-gltf.d.ts.map +1 -0
  132. package/dist/parse-skin.d.ts +70 -0
  133. package/dist/parse-skin.d.ts.map +1 -0
  134. package/dist/reimport-reuse-meta.d.ts +48 -0
  135. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  136. package/dist/serialize-meta.d.ts +2 -0
  137. package/dist/serialize-meta.d.ts.map +1 -0
  138. package/dist/source-key.d.ts +22 -0
  139. package/dist/source-key.d.ts.map +1 -0
  140. package/dist/sub-asset-key.d.ts +12 -0
  141. package/dist/sub-asset-key.d.ts.map +1 -0
  142. package/dist/to-asset-pack.d.ts +2 -0
  143. package/dist/to-asset-pack.d.ts.map +1 -0
  144. package/dist/transform.d.ts +37 -0
  145. package/dist/transform.d.ts.map +1 -0
  146. package/package.json +94 -0
  147. package/src/__tests__/animation-target-errors.unit.test.ts +51 -0
  148. package/src/__tests__/asset-runtime-fixture.ts +278 -0
  149. package/src/__tests__/bridge-material-samplers.unit.test.ts +67 -0
  150. package/src/__tests__/bridge-material-values.unit.test.ts +106 -0
  151. package/src/__tests__/bridge-mixed-color.unit.test.ts +100 -0
  152. package/src/__tests__/bridge-sampler-required.test-d.ts +59 -0
  153. package/src/__tests__/bridge-skin-shader-route.unit.test.ts +42 -0
  154. package/src/__tests__/bridge-skin-stride.unit.test.ts +115 -0
  155. package/src/__tests__/bridge-test-helpers.ts +9 -0
  156. package/src/__tests__/bridge.unit.test.ts +1227 -0
  157. package/src/__tests__/cli-gltf.integration.test.ts +122 -0
  158. package/src/__tests__/color-0-accessor.unit.test.ts +174 -0
  159. package/src/__tests__/data-uri-surface.unit.test.ts +26 -0
  160. package/src/__tests__/fixtures/color-0/float-vec3.gltf +13 -0
  161. package/src/__tests__/fixtures/color-0/interleaved-vec3.gltf +6 -0
  162. package/src/__tests__/fixtures/color-0/normalized-ubyte.gltf +6 -0
  163. package/src/__tests__/fixtures/color-0/normalized-ushort.gltf +6 -0
  164. package/src/__tests__/gltf-error-derived-views.test-d.ts +152 -0
  165. package/src/__tests__/gltf-error-migration.test.ts +147 -0
  166. package/src/__tests__/gltf-error-policy-owner.unit.test.ts +185 -0
  167. package/src/__tests__/gltf-importer.test.ts +12 -0
  168. package/src/__tests__/gltf-local-artifacts.test.ts +11 -0
  169. package/src/__tests__/gltf.unit.test.ts +2975 -0
  170. package/src/__tests__/malformed-buffer-data-uri.integration.test.ts +140 -0
  171. package/src/__tests__/material-missing-uv.unit.test.ts +29 -0
  172. package/src/__tests__/material-pack-refs.integration.test.ts +58 -0
  173. package/src/__tests__/material-per-slot-ir.unit.test.ts +90 -0
  174. package/src/__tests__/material-texture-transform.unit.test.ts +75 -0
  175. package/src/__tests__/material-unused-uv.unit.test.ts +44 -0
  176. package/src/__tests__/meshopt-decode.unit.test.ts +87 -0
  177. package/src/__tests__/meshopt-falsifier.unit.test.ts +92 -0
  178. package/src/__tests__/meshopt-mode-filter-owner.test-d.ts +79 -0
  179. package/src/__tests__/meshopt-real-matrix.integration.test.ts +286 -0
  180. package/src/__tests__/morph-import.integration.test.ts +128 -0
  181. package/src/__tests__/morph-import.unit.test.ts +62 -0
  182. package/src/__tests__/parse-decode.unit.test.ts +919 -0
  183. package/src/__tests__/parse-gltf-multi-uv.test.ts +238 -0
  184. package/src/__tests__/parse-gltf-skin-attrs.unit.test.ts +191 -0
  185. package/src/__tests__/parse-gltf.unit.test.ts +133 -0
  186. package/src/__tests__/pick-e2e.integration.test.ts +238 -0
  187. package/src/__tests__/public-surface.unit.test.ts +10 -0
  188. package/src/__tests__/source-key-producer.integration.test.ts +163 -0
  189. package/src/__tests__/source-key.unit.test.ts +159 -0
  190. package/src/accessor/decode-accessor.ts +309 -0
  191. package/src/accessor/decode-color.ts +128 -0
  192. package/src/accessor/index.ts +15 -0
  193. package/src/bridge.ts +929 -0
  194. package/src/check-extensions.ts +81 -0
  195. package/src/cli-gltf.ts +344 -0
  196. package/src/data-uri.ts +29 -0
  197. package/src/errors.ts +386 -0
  198. package/src/extract-image-bytes.ts +343 -0
  199. package/src/gltf-importer.ts +967 -0
  200. package/src/image-color-space.ts +104 -0
  201. package/src/importer-entry.ts +24 -0
  202. package/src/index.ts +147 -0
  203. package/src/meshopt-decode.ts +200 -0
  204. package/src/node-file-entry.ts +98 -0
  205. package/src/node-path.ts +41 -0
  206. package/src/parse-animation.ts +286 -0
  207. package/src/parse-glb-chunks.ts +167 -0
  208. package/src/parse-glb.ts +16 -0
  209. package/src/parse-gltf-header.ts +58 -0
  210. package/src/parse-gltf.ts +1729 -0
  211. package/src/parse-skin.ts +238 -0
  212. package/src/reimport-reuse-meta.ts +134 -0
  213. package/src/serialize-meta.ts +24 -0
  214. package/src/source-key.ts +72 -0
  215. package/src/sub-asset-key.ts +61 -0
  216. package/src/to-asset-pack.ts +8 -0
  217. package/src/transform.ts +117 -0
@@ -0,0 +1,967 @@
1
+ // gltf-importer.ts - the build-time gltfImporter (feat-20260603-asset-import-loader-injection M2 / w19,
2
+ // extended in feat-20260608 M3 w14 with the texture pipeline: three image
3
+ // source paths funnelled through the ImportContext decodeImage seam).
4
+ //
5
+ // The `{ key: 'gltf', import }` Importer the @forgeax/engine-import runner
6
+ // dispatches a `*.meta.json` with `importer: 'gltf'` to. It reads the source
7
+ // bytes via `ctx.readSource()`, parses them to a `GltfDoc` (parseGltf / parseGlb),
8
+ // and converts each declared sub-asset (mesh / material / scene / texture /
9
+ // skin / animation-clip) into an `ImportedAsset` POD stamped with the
10
+ // meta-declared GUID (GUID import-stable iron law: GUIDs come from
11
+ // `ctx.subAssets[]`, never minted here).
12
+ //
13
+ // Texture pipeline (M3 D-1 / D-3 / D-6, requirements AC-08 / 09 / 10 / 11 /
14
+ // 12 / 13): for every glTF `images[]` row the importer extracts the raw
15
+ // PNG / JPEG bytes from one of three sources (bufferView slice in
16
+ // .glb / data: URI in .gltf / external URI sibling read), funnels them
17
+ // through `ctx.decodeImage` (the only seam to @forgeax/engine-image — a
18
+ // grep gate enforces zero static `from '@forgeax/engine-image'` lines in
19
+ // this package), and emits a `kind: 'texture'` ImportedAsset stamped with
20
+ // the meta-declared GUID. The colorSpace is pre-derived per-image by
21
+ // `deriveTextureColorSpace` (D-3 walk of material slot bindings).
22
+ //
23
+ // Material refs[]: each material's emitted ImportedAsset carries the GUIDs
24
+ // of every texture sub-asset its slots reference, so the runner builds the
25
+ // scene/material -> texture cross-edge needed by AC-11 / AC-19.
26
+ //
27
+ // Sub-asset -> GUID mapping: `ctx.subAssets[]` carries one entry per declared
28
+ // sub-asset with `{ guid, sourceIndex, kind }`. The importer indexes into the
29
+ // parsed doc by (kind, sourceIndex) and emits the corresponding POD under the
30
+ // declared GUID. A sub-asset kind with no doc counterpart (or a sourceIndex out
31
+ // of range) is skipped; the runner's GUID iron-law check then surfaces the
32
+ // gap as `import-produced-no-assets`. A texture sub-asset that fails byte
33
+ // extraction surfaces as `gltf-image-extract-failed` (D-6).
34
+
35
+ import { packMeshBinV4 } from '@forgeax/engine-import';
36
+ import { AssetGuid as AssetGuidCodec } from '@forgeax/engine-pack/guid';
37
+ import type {
38
+ AssetGuid,
39
+ AssetRef,
40
+ Handle,
41
+ ImportContext,
42
+ ImportedAsset,
43
+ Importer,
44
+ ImportResult,
45
+ MaterialTextureValue,
46
+ MaterialValue,
47
+ MeshAsset,
48
+ MeshMaterialSlotTopologyEntry,
49
+ } from '@forgeax/engine-types';
50
+ import {
51
+ IMPORT_ERROR_HINTS,
52
+ ImportError,
53
+ reconcileMeshMaterialSlotTopology,
54
+ resolveMeshMaterialSlotDefaultGuid,
55
+ toShared,
56
+ } from '@forgeax/engine-types';
57
+ import {
58
+ gltfDocToSceneAsset,
59
+ meshIrToMeshAsset,
60
+ toMaterialAsset,
61
+ validateMaterialUvSets,
62
+ } from './bridge.js';
63
+ import { gltfErr } from './errors.js';
64
+ import { extractImageBytes } from './extract-image-bytes.js';
65
+ import { deriveTextureColorSpace } from './image-color-space.js';
66
+ import type { GltfBufferViewDecodeCapability } from './meshopt-decode.js';
67
+ import type { GltfDoc, GltfMaterialIr, GltfTextureInfoIr } from './parse-gltf.js';
68
+ import { parseGlbForImporter, parseGltfForImporter } from './parse-gltf.js';
69
+
70
+ type ParseDocResult =
71
+ | { readonly ok: true; readonly value: GltfDoc }
72
+ | { readonly ok: false; readonly error: ImportError };
73
+
74
+ function isGlbBytes(source: string): boolean {
75
+ return source.toLowerCase().endsWith('.glb');
76
+ }
77
+
78
+ function previousMaterialSlotTopology(
79
+ ctx: ImportContext,
80
+ meshSourceKey: string | undefined,
81
+ ): readonly MeshMaterialSlotTopologyEntry[] | undefined {
82
+ if (meshSourceKey === undefined) return undefined;
83
+ const value = ctx.sourceOverrides?.[meshSourceKey]?.materialSlots;
84
+ if (value === undefined) return undefined;
85
+ if (!Array.isArray(value)) {
86
+ throw new ImportError({
87
+ code: 'invalid-source-override-payload',
88
+ expected: `${meshSourceKey}.materialSlots to be an array`,
89
+ hint: IMPORT_ERROR_HINTS['invalid-source-override-payload'],
90
+ detail: {
91
+ sourceKey: meshSourceKey,
92
+ declaredSourceKeys: ctx.subAssets.flatMap((entry) => entry.sourceKey ?? []),
93
+ reason: 'materialSlots is not an array',
94
+ },
95
+ });
96
+ }
97
+ const slots: MeshMaterialSlotTopologyEntry[] = [];
98
+ for (const [index, raw] of value.entries()) {
99
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
100
+ throw new ImportError({
101
+ code: 'invalid-source-override-payload',
102
+ expected: `${meshSourceKey}.materialSlots[${index}] to be an object`,
103
+ hint: IMPORT_ERROR_HINTS['invalid-source-override-payload'],
104
+ detail: {
105
+ sourceKey: meshSourceKey,
106
+ declaredSourceKeys: ctx.subAssets.flatMap((entry) => entry.sourceKey ?? []),
107
+ reason: `materialSlots[${index}] is not an object`,
108
+ },
109
+ });
110
+ }
111
+ const slot = raw as Record<string, unknown>;
112
+ if (typeof slot.slotName !== 'string' || slot.slotName.trim().length === 0) {
113
+ throw new ImportError({
114
+ code: 'invalid-source-override-payload',
115
+ expected: `${meshSourceKey}.materialSlots[${index}].slotName to be non-empty`,
116
+ hint: IMPORT_ERROR_HINTS['invalid-source-override-payload'],
117
+ detail: {
118
+ sourceKey: meshSourceKey,
119
+ declaredSourceKeys: ctx.subAssets.flatMap((entry) => entry.sourceKey ?? []),
120
+ reason: `materialSlots[${index}].slotName is invalid`,
121
+ },
122
+ });
123
+ }
124
+ slots.push({
125
+ slotName: slot.slotName,
126
+ ...(typeof slot.sourceKey === 'string' ? { sourceKey: slot.sourceKey } : {}),
127
+ ...(typeof slot.defaultMaterialGuid === 'string'
128
+ ? { defaultMaterialGuid: slot.defaultMaterialGuid }
129
+ : {}),
130
+ });
131
+ }
132
+ return slots;
133
+ }
134
+
135
+ function stabilizeMeshMaterialSlots(
136
+ mesh: MeshAsset,
137
+ ctx: ImportContext,
138
+ meshGuid: string,
139
+ meshSourceKey: string | undefined,
140
+ ): MeshAsset {
141
+ const current = mesh.materialSlots.map(
142
+ (slot): MeshMaterialSlotTopologyEntry => ({
143
+ slotName: slot.slotName,
144
+ ...(slot.sourceKey === undefined ? {} : { sourceKey: slot.sourceKey }),
145
+ ...(slot.defaultMaterial === undefined
146
+ ? {}
147
+ : { defaultMaterialGuid: AssetGuidCodec.format(slot.defaultMaterial) }),
148
+ }),
149
+ );
150
+ const reconciled = reconcileMeshMaterialSlotTopology(
151
+ current,
152
+ previousMaterialSlotTopology(ctx, meshSourceKey),
153
+ );
154
+ if (!reconciled.ok) {
155
+ throw new ImportError({
156
+ code: 'mesh-material-slot-topology-change',
157
+ expected: `unambiguous material slot identity for mesh ${meshGuid}`,
158
+ hint: IMPORT_ERROR_HINTS['mesh-material-slot-topology-change'],
159
+ detail: {
160
+ meshGuid,
161
+ ...(meshSourceKey === undefined ? {} : { meshSourceKey }),
162
+ previousIndices: reconciled.error.previousIndices,
163
+ nextIndices: reconciled.error.nextIndices,
164
+ },
165
+ });
166
+ }
167
+ const authoredDefaults =
168
+ meshSourceKey === undefined
169
+ ? undefined
170
+ : ctx.sourceOverrides?.[meshSourceKey]?.materialSlotDefaultOverrides;
171
+ if (
172
+ authoredDefaults !== undefined &&
173
+ (authoredDefaults === null ||
174
+ typeof authoredDefaults !== 'object' ||
175
+ Array.isArray(authoredDefaults))
176
+ ) {
177
+ throw new ImportError({
178
+ code: 'invalid-source-override-payload',
179
+ expected: `${meshSourceKey}.materialSlotDefaultOverrides to be an object`,
180
+ hint: IMPORT_ERROR_HINTS['invalid-source-override-payload'],
181
+ detail: {
182
+ sourceKey: meshSourceKey,
183
+ declaredSourceKeys: [],
184
+ reason: 'materialSlotDefaultOverrides is invalid',
185
+ },
186
+ });
187
+ }
188
+ const authoredBySlot = authoredDefaults as Readonly<Record<string, unknown>> | undefined;
189
+ const activeStableSlots = new Set(reconciled.currentToStableSlot);
190
+ return {
191
+ ...mesh,
192
+ submeshes: mesh.submeshes.map((submesh) => ({
193
+ ...submesh,
194
+ materialSlot: reconciled.currentToStableSlot[submesh.materialSlot] as number,
195
+ })),
196
+ materialSlots: reconciled.slots.map((slot, stableIndex) => {
197
+ const active = activeStableSlots.has(stableIndex);
198
+ const authored = authoredBySlot?.[slot.sourceKey ?? slot.slotName];
199
+ const effectiveDefault = resolveMeshMaterialSlotDefaultGuid(
200
+ slot,
201
+ active && (typeof authored === 'string' || authored === null) ? authored : undefined,
202
+ );
203
+ const parsed =
204
+ effectiveDefault === undefined ? undefined : AssetGuidCodec.parse(effectiveDefault);
205
+ return {
206
+ slotName: slot.slotName,
207
+ ...(slot.sourceKey === undefined ? {} : { sourceKey: slot.sourceKey }),
208
+ ...(active && parsed?.ok ? { defaultMaterial: parsed.value } : {}),
209
+ };
210
+ }),
211
+ };
212
+ }
213
+
214
+ async function parseDoc(
215
+ source: string,
216
+ bytes: Uint8Array,
217
+ ctx: ImportContext,
218
+ meshopt?: GltfBufferViewDecodeCapability,
219
+ ): Promise<ParseDocResult> {
220
+ const ab = bytes.buffer.slice(
221
+ bytes.byteOffset,
222
+ bytes.byteOffset + bytes.byteLength,
223
+ ) as ArrayBuffer;
224
+ if (isGlbBytes(source)) {
225
+ const res = await parseGlbForImporter(ab, source, meshopt === undefined ? {} : { meshopt });
226
+ if (!res.ok) {
227
+ if (res.error instanceof ImportError) return { ok: false, error: res.error };
228
+ throw new Error(`parseGlb failed: ${res.error.code} ${res.error.expected}`);
229
+ }
230
+ return { ok: true, value: res.value };
231
+ }
232
+ let json: unknown;
233
+ try {
234
+ json = JSON.parse(new TextDecoder().decode(bytes));
235
+ } catch (e) {
236
+ throw new Error(`gltf JSON parse failed: ${e instanceof Error ? e.message : String(e)}`);
237
+ }
238
+ // External buffers: read sibling files relative to meta.source. data: URIs
239
+ // are decoded inline by parseGltf; only external URIs reach the loader.
240
+ const externalLoader = async (uri: string): Promise<ArrayBuffer> => {
241
+ const sib = await ctx.readSibling(uri);
242
+ if (!sib.ok) {
243
+ throw new Error(`gltfImporter: external buffer "${uri}" read failed: ${sib.error.code}`);
244
+ }
245
+ return sib.value.buffer.slice(
246
+ sib.value.byteOffset,
247
+ sib.value.byteOffset + sib.value.byteLength,
248
+ ) as ArrayBuffer;
249
+ };
250
+ const res = await parseGltfForImporter(
251
+ json,
252
+ externalLoader,
253
+ source,
254
+ meshopt === undefined ? {} : { meshopt },
255
+ );
256
+ if (!res.ok) {
257
+ if (res.error instanceof ImportError) return { ok: false, error: res.error };
258
+ throw new Error(`parseGltf failed: ${res.error.code} ${res.error.expected}`);
259
+ }
260
+ return { ok: true, value: res.value };
261
+ }
262
+
263
+ interface HandleMaps {
264
+ readonly meshHandles: Map<number, Handle<'MeshAsset', 'shared'>>;
265
+ readonly materialHandles: Map<number, Handle<'MaterialAsset', 'shared'>>;
266
+ readonly textureHandles: Map<number, Handle<'TextureAsset', 'shared'>>;
267
+ readonly samplerHandles: Map<number, Handle<'SamplerAsset', 'shared'>>;
268
+ readonly meshGuidByIndex: Map<number, string>;
269
+ readonly materialGuidByIndex: Map<number, string>;
270
+ readonly textureGuidByIndex: Map<number, string>;
271
+ readonly samplerGuidByIndex: Map<number, string>;
272
+ }
273
+
274
+ /**
275
+ * Build the gltf-index -> declared-GUID maps so scene refs resolve to the
276
+ * sub-asset GUIDs (not runtime handles, which do not exist at import time).
277
+ * The scene POD therefore carries deterministic synthetic handle slots; the
278
+ * GUID cross-references are recorded on the ImportedAsset `refs[]`.
279
+ *
280
+ * Texture handles are seeded from `subAssets[]` of kind 'texture'; their
281
+ * sourceIndex is the glTF `images[]` row (toAssetPack emits one texture
282
+ * sub-asset per image row, so the mapping is image-index keyed even though
283
+ * the field is named textureHandles for material binding readability).
284
+ * GltfMaterialIr's `*Texture` fields hold glTF `textures[]` indices; the
285
+ * importer dereferences `textures[i].source` to convert that into an image
286
+ * index when stamping material handles.
287
+ */
288
+ function buildHandleMaps(
289
+ subAssets: readonly { guid: string; sourceIndex: number; kind: string }[],
290
+ doc: GltfDoc,
291
+ ): HandleMaps {
292
+ const meshHandles = new Map<number, Handle<'MeshAsset', 'shared'>>();
293
+ const materialHandles = new Map<number, Handle<'MaterialAsset', 'shared'>>();
294
+ const textureHandles = new Map<number, Handle<'TextureAsset', 'shared'>>();
295
+ const samplerHandles = new Map<number, Handle<'SamplerAsset', 'shared'>>();
296
+ const meshGuidByIndex = new Map<number, string>();
297
+ const materialGuidByIndex = new Map<number, string>();
298
+ const textureGuidByIndex = new Map<number, string>();
299
+ const samplerGuidByIndex = new Map<number, string>();
300
+ // bug-20260610 layer 7c-2: scene's `refs[]` is concatenated in the order
301
+ // [mesh sub-assets..., material sub-assets..., texture sub-assets...] (see
302
+ // gltf-importer scene branch). The synthetic handle values stamped here
303
+ // travel through `MeshFilter.assetHandle` / `MeshRenderer.materials[*]` and
304
+ // are decoded at runtime as **refs[] indices** by parseScenePayload. So the
305
+ // handle value MUST equal the slot offset in the eventual concat, NOT the
306
+ // gltf source-index. The previous keying-by-sourceIndex worked accidentally
307
+ // when there was exactly one mesh + N <= meshCount materials; it broke as
308
+ // soon as materialIndex landed past the mesh-section boundary (Sponza:
309
+ // materials[0]=0 -> refs[0] = mesh GUID, every submesh sampled the mesh
310
+ // asset as its material -> single fallback unlit dispatch entry, no per-
311
+ // primitive draws).
312
+ let meshCursor = 0;
313
+ let materialCursor = 0;
314
+ for (const sub of subAssets) {
315
+ if (sub.kind === 'mesh') {
316
+ meshHandles.set(sub.sourceIndex, toShared<'MeshAsset'>(meshCursor));
317
+ meshGuidByIndex.set(sub.sourceIndex, sub.guid);
318
+ meshCursor += 1;
319
+ } else if (sub.kind === 'material') {
320
+ // material handles come AFTER all mesh refs in the concat.
321
+ // The actual offset (= meshCount + materialCursor) is back-patched
322
+ // below once we know meshCount.
323
+ materialHandles.set(sub.sourceIndex, toShared<'MaterialAsset'>(materialCursor));
324
+ materialGuidByIndex.set(sub.sourceIndex, sub.guid);
325
+ materialCursor += 1;
326
+ } else if (sub.kind === 'texture') {
327
+ textureGuidByIndex.set(sub.sourceIndex, sub.guid);
328
+ } else if (sub.kind === 'sampler') {
329
+ samplerGuidByIndex.set(sub.sourceIndex, sub.guid);
330
+ }
331
+ }
332
+ const meshCount = meshCursor;
333
+ if (meshCount > 0) {
334
+ for (const [k, v] of materialHandles) {
335
+ const local = v as unknown as number;
336
+ materialHandles.set(k, toShared<'MaterialAsset'>(local + meshCount));
337
+ }
338
+ }
339
+ // For material binding (toMaterialAsset / values.<X>Texture) the handle
340
+ // value is the texture's slot offset within the SAME asset's `refs[]` (which
341
+ // is `materialTextureRefs` order, not the scene-level refs concat).
342
+ // toMaterialAsset only consumes textureHandles to copy a number into the
343
+ // values; the gltf-importer's later 7a fix-up rewrites those values
344
+ // into refs[] indices for the runtime materialLoader. Keying by texIndex
345
+ // and storing `tex.source` here matches the existing 7a path.
346
+ const textures = doc.textures ?? [];
347
+ for (let texIndex = 0; texIndex < textures.length; texIndex++) {
348
+ const tex = textures[texIndex];
349
+ if (tex === undefined) continue;
350
+ if (textureGuidByIndex.has(tex.source)) {
351
+ textureHandles.set(texIndex, toShared<'TextureAsset'>(tex.source));
352
+ }
353
+ }
354
+ for (const [samplerIndex] of samplerGuidByIndex) {
355
+ samplerHandles.set(samplerIndex, toShared<'SamplerAsset'>(samplerIndex));
356
+ }
357
+ return {
358
+ meshHandles,
359
+ materialHandles,
360
+ textureHandles,
361
+ samplerHandles,
362
+ meshGuidByIndex,
363
+ materialGuidByIndex,
364
+ textureGuidByIndex,
365
+ samplerGuidByIndex,
366
+ };
367
+ }
368
+
369
+ function textureInfo(info: GltfTextureInfoIr | number | undefined): GltfTextureInfoIr | undefined {
370
+ return info === undefined ? undefined : typeof info === 'number' ? { texture: info } : info;
371
+ }
372
+
373
+ /** Collect texture and sampler GUID refs for one material (AC-11 cross-edge). */
374
+ export function materialRefsForPack(
375
+ mat: GltfMaterialIr,
376
+ doc: GltfDoc,
377
+ textureGuidByIndex: ReadonlyMap<number, string>,
378
+ samplerGuidByIndex: ReadonlyMap<number, string> = new Map(),
379
+ ): readonly AssetRef[] {
380
+ const refs: AssetRef[] = [];
381
+ const textures = doc.textures ?? [];
382
+ function pushRefsForSlot(info: GltfTextureInfoIr | number | undefined, fieldName: string): void {
383
+ const binding = textureInfo(info);
384
+ if (binding === undefined) return;
385
+ const tex = textures[binding.texture];
386
+ if (tex === undefined) return;
387
+ const guid = textureGuidByIndex.get(tex.source);
388
+ if (guid !== undefined) {
389
+ refs.push({
390
+ guid,
391
+ sourceField: { componentName: '<material>', fieldName },
392
+ });
393
+ }
394
+ if (binding.sampler !== undefined) {
395
+ const samplerGuid = samplerGuidByIndex.get(binding.sampler);
396
+ if (samplerGuid !== undefined) {
397
+ refs.push({
398
+ guid: samplerGuid,
399
+ sourceField: { componentName: '<material>', fieldName: `${fieldName}.sampler` },
400
+ });
401
+ }
402
+ }
403
+ }
404
+ pushRefsForSlot(mat.baseColorTexture, 'baseColorTexture');
405
+ pushRefsForSlot(mat.metallicRoughnessTexture, 'metallicRoughnessTexture');
406
+ pushRefsForSlot(mat.normalTexture, 'normalTexture');
407
+ pushRefsForSlot(mat.occlusionTexture, 'occlusionTexture');
408
+ pushRefsForSlot(mat.emissiveTexture, 'emissiveTexture');
409
+ return refs;
410
+ }
411
+
412
+ function availableUvSets(mesh: GltfDoc['meshes'][number]): readonly number[] {
413
+ const sets: number[] = [];
414
+ for (let set = 0; set <= 7; set++) {
415
+ const field = `texcoord${set}` as keyof typeof mesh;
416
+ if (mesh[field] !== undefined) sets.push(set);
417
+ }
418
+ return sets;
419
+ }
420
+
421
+ function rewriteMaterialAssetRefs(
422
+ matAsset: ReturnType<typeof toMaterialAsset>,
423
+ mat: GltfMaterialIr,
424
+ doc: GltfDoc,
425
+ maps: HandleMaps,
426
+ ): ReturnType<typeof toMaterialAsset> {
427
+ const values = { ...(matAsset.values ?? {}) } as Record<string, MaterialValue | null>;
428
+ const textures = doc.textures ?? [];
429
+ const slots: readonly [
430
+ (
431
+ | 'baseColorTexture'
432
+ | 'metallicRoughnessTexture'
433
+ | 'normalTexture'
434
+ | 'occlusionTexture'
435
+ | 'emissiveTexture'
436
+ ),
437
+ GltfTextureInfoIr | number | undefined,
438
+ ][] = [
439
+ ['baseColorTexture', mat.baseColorTexture],
440
+ ['metallicRoughnessTexture', mat.metallicRoughnessTexture],
441
+ ['normalTexture', mat.normalTexture],
442
+ ['occlusionTexture', mat.occlusionTexture],
443
+ ['emissiveTexture', mat.emissiveTexture],
444
+ ];
445
+ let cursor = 0;
446
+ for (const [slot, rawBinding] of slots) {
447
+ const binding = textureInfo(rawBinding);
448
+ if (binding === undefined) continue;
449
+ const texture = textures[binding.texture];
450
+ const textureGuid =
451
+ texture === undefined ? undefined : maps.textureGuidByIndex.get(texture.source);
452
+ const value = values[slot];
453
+ if (textureGuid === undefined || typeof value !== 'object' || value === null) {
454
+ delete values[slot];
455
+ continue;
456
+ }
457
+ const textureValue = value as MaterialTextureValue;
458
+ const textureRef = cursor as unknown as MaterialTextureValue['texture'];
459
+ cursor++;
460
+ const rewritten =
461
+ binding.sampler !== undefined && maps.samplerGuidByIndex.has(binding.sampler)
462
+ ? {
463
+ ...textureValue,
464
+ texture: textureRef,
465
+ sampler: cursor as unknown as NonNullable<MaterialTextureValue['sampler']>,
466
+ }
467
+ : { ...textureValue, texture: textureRef };
468
+ if (binding.sampler !== undefined && maps.samplerGuidByIndex.has(binding.sampler)) cursor++;
469
+ values[slot] = rewritten;
470
+ }
471
+ return { ...matAsset, values };
472
+ }
473
+
474
+ async function importGltf(
475
+ ctx: ImportContext,
476
+ meshopt?: GltfBufferViewDecodeCapability,
477
+ ): Promise<ImportResult> {
478
+ const read = await ctx.readSource();
479
+ if (!read.ok) {
480
+ throw new Error(
481
+ `gltfImporter: readSource failed: ${read.error instanceof Error ? read.error.message : String(read.error)}`,
482
+ );
483
+ }
484
+ const parsed = await parseDoc(ctx.source, read.value, ctx, meshopt);
485
+ if (!parsed.ok) return parsed;
486
+ const doc = parsed.value;
487
+ const maps = buildHandleMaps(ctx.subAssets, doc);
488
+
489
+ // Pre-derive each images[] row's colorSpace from material slot bindings
490
+ // (D-3 / AC-08). orphan images default to 'linear' (AC-13).
491
+ const imageColorSpaces = deriveTextureColorSpace({
492
+ imageCount: (doc.images ?? []).length,
493
+ textures: doc.textures,
494
+ materials: doc.materials,
495
+ });
496
+
497
+ // Extract image bytes (3 source paths) once. Failures here are surfaced
498
+ // per-image so a single bad row does not abort the whole importer.
499
+ const declaredImageIndices = new Set<number>();
500
+ for (const sub of ctx.subAssets) {
501
+ if (sub.kind === 'texture') declaredImageIndices.add(sub.sourceIndex);
502
+ }
503
+ const extraction =
504
+ declaredImageIndices.size > 0
505
+ ? await extractImageBytes(read.value, ctx.source, ctx)
506
+ : {
507
+ extracted: new Map(),
508
+ failures: [] as readonly {
509
+ imageIndex: number;
510
+ source: 'bufferView' | 'data-uri' | 'external-uri';
511
+ reason: string;
512
+ }[],
513
+ };
514
+
515
+ // M4 (tweak-20260611-skin-fox-3clip-and-kb-sample-assets): SkinAsset.refs[]
516
+ // carries the skeleton GUID (each skin binds 1:1 to a SkeletonAsset; the
517
+ // skeletonGuid field is the on-asset cross-reference; refs[] is the runner-
518
+ // visible cross-edge used for pack-index ordering and integrity checks).
519
+ // Build the index now so the skin emit branch below can stamp it.
520
+ const skeletonGuidBySourceIndex = new Map<number, string>();
521
+ for (const sub of ctx.subAssets) {
522
+ if (sub.kind === 'skeleton') skeletonGuidBySourceIndex.set(sub.sourceIndex, sub.guid);
523
+ }
524
+ // feat-20260612 M2 fixup: parallel skin GUID index. Skins and skeletons
525
+ // share `sourceIndex` (toAssetPack emits 1:1 per GltfSkeletonRecord) but live
526
+ // as distinct `kind` sub-assets, so the SkinAsset GUIDs differ from the
527
+ // SkeletonAsset GUIDs. The scene branch below appends these GUIDs to both
528
+ // its refs[] (the runtime recursion source: assets.load(sceneGuid,
529
+ // sceneAssetKind) walks
530
+ // envelope.refs to recursively pull every SkinAsset before instantiate) and
531
+ // its payload.skinGuids (the reverse-decode hint) -- without the refs[] edge,
532
+ // browser-async-pack-fetch never loads SkinAssets and Skin.joints stays
533
+ // length=0).
534
+ const skinGuidBySourceIndex = new Map<number, string>();
535
+ for (const sub of ctx.subAssets) {
536
+ if (sub.kind === 'skin') skinGuidBySourceIndex.set(sub.sourceIndex, sub.guid);
537
+ }
538
+
539
+ const out: ImportedAsset[] = [];
540
+ const isMultiAsset = ctx.subAssets.length > 1;
541
+ for (const sub of ctx.subAssets) {
542
+ if (sub.kind === 'mesh') {
543
+ // sub.sourceIndex now indexes glTF mesh-index (not flat GltfMeshIr index).
544
+ // parseGltf flattens N glTF meshes with M_i primitives into sum(M_i)
545
+ // GltfMeshIr rows, all sharing meshIndex; gather all rows whose
546
+ // meshIndex === sub.sourceIndex (preserving doc.meshes order so the
547
+ // bridge's positional materials[i] <-> submeshes[i] pairing aligns)
548
+ // and let meshIrToMeshAsset interleave them into one MeshAsset with
549
+ // N Submesh entries.
550
+ const prims = doc.meshes.filter((m) => m.meshIndex === sub.sourceIndex);
551
+ if (prims.length === 0) continue;
552
+ const meshName = isMultiAsset ? prims[0]?.name : undefined;
553
+ const materialNameByIndex = new Map<number, string>();
554
+ const materialSourceKeyByIndex = new Map<number, string>();
555
+ for (let materialIndex = 0; materialIndex < doc.materials.length; materialIndex++) {
556
+ const name = doc.materials[materialIndex]?.name;
557
+ if (typeof name === 'string') materialNameByIndex.set(materialIndex, name);
558
+ }
559
+ for (const material of ctx.subAssets) {
560
+ if (material.kind === 'material' && material.sourceKey !== undefined) {
561
+ materialSourceKeyByIndex.set(material.sourceIndex, material.sourceKey);
562
+ }
563
+ }
564
+ const bridged = meshIrToMeshAsset(prims, {
565
+ guidByIndex: maps.materialGuidByIndex,
566
+ nameByIndex: materialNameByIndex,
567
+ sourceKeyByIndex: materialSourceKeyByIndex,
568
+ });
569
+ if (!bridged.ok) {
570
+ return {
571
+ ok: false,
572
+ error: new ImportError({
573
+ code: 'import-internal-error',
574
+ expected: 'gltf mesh bridge to produce a canonical MeshAsset',
575
+ actual: bridged.error.code,
576
+ hint: 'repair the source primitive and re-run the glTF importer',
577
+ detail: {
578
+ reason: `gltf mesh bridge rejected mesh ${sub.sourceIndex}; inspect the bridge error detail`,
579
+ },
580
+ }),
581
+ };
582
+ }
583
+ const meshPayload = stabilizeMeshMaterialSlots(bridged.value, ctx, sub.guid, sub.sourceKey);
584
+ const materialRefs: AssetRef[] = [];
585
+ const seenMaterialGuids = new Set<string>();
586
+ for (let slotIndex = 0; slotIndex < meshPayload.materialSlots.length; slotIndex++) {
587
+ const defaultMaterial = meshPayload.materialSlots[slotIndex]?.defaultMaterial;
588
+ const guid =
589
+ defaultMaterial === undefined ? undefined : AssetGuidCodec.format(defaultMaterial);
590
+ if (guid !== undefined && !seenMaterialGuids.has(guid.toLowerCase())) {
591
+ seenMaterialGuids.add(guid.toLowerCase());
592
+ materialRefs.push({
593
+ guid,
594
+ sourceField: { fieldName: 'materialSlots', arrayIndex: slotIndex },
595
+ });
596
+ }
597
+ }
598
+ out.push({
599
+ guid: sub.guid,
600
+ kind: 'mesh',
601
+ ...(meshName !== undefined ? { name: meshName } : {}),
602
+ payload: meshPayload,
603
+ refs: materialRefs,
604
+ artifacts: {
605
+ body: {
606
+ mediaType: 'application/x-forgeax-mesh',
607
+ assetCodec: { name: 'mesh-binary', version: '4' },
608
+ bytes: (() => {
609
+ const packed = packMeshBinV4(
610
+ meshPayload as never,
611
+ sub.sourceKey ?? ctx.source,
612
+ materialRefs.map((ref) => ref.guid),
613
+ );
614
+ if (!packed.ok) {
615
+ throw new ImportError({
616
+ code: 'import-internal-error',
617
+ expected: 'mesh-bin v4 producer to accept the canonical mesh projection',
618
+ hint: 're-cook the source with its Meta sidecar after fixing the mesh payload',
619
+ detail: { reason: `${packed.error.code}: ${packed.error.actual}` },
620
+ });
621
+ }
622
+ return packed.value;
623
+ })(),
624
+ },
625
+ },
626
+ });
627
+ } else if (sub.kind === 'material') {
628
+ const mat = doc.materials[sub.sourceIndex];
629
+ if (mat === undefined) continue;
630
+ // feat-20260611 w17-a: scan doc.meshes[] for any primitive that (a)
631
+ // references this material and (b) carries JOINTS_0 + WEIGHTS_0. If
632
+ // found, route the emitted MaterialAsset to `forgeax::pbr-skin`.
633
+ // Mirrors the per-MeshAsset 18F-stride decision in meshIrToMeshAsset
634
+ // (D-2): a material consumed by any skinned primitive must use the
635
+ // skin shader so the runtime PSO chain (LayoutKind='pbr-skin' +
636
+ // 6-attribute deriveVertexBufferLayout) is exercised.
637
+ let skinned = false;
638
+ for (const meshIr of doc.meshes) {
639
+ if (meshIr.materialIndex !== sub.sourceIndex) continue;
640
+ if (meshIr.joints0 !== undefined && meshIr.weights0 !== undefined) {
641
+ skinned = true;
642
+ break;
643
+ }
644
+ }
645
+ for (let primitiveIndex = 0; primitiveIndex < doc.meshes.length; primitiveIndex++) {
646
+ const meshIr = doc.meshes[primitiveIndex];
647
+ if (meshIr?.materialIndex !== sub.sourceIndex) continue;
648
+ const uvResult = validateMaterialUvSets(
649
+ mat,
650
+ `primitive-${primitiveIndex}`,
651
+ availableUvSets(meshIr),
652
+ );
653
+ if (!uvResult.ok) {
654
+ throw Object.assign(new Error(uvResult.error.message), uvResult.error);
655
+ }
656
+ }
657
+ const matAsset = toMaterialAsset(mat, {
658
+ textureHandles: maps.textureHandles,
659
+ samplerHandles: maps.samplerHandles,
660
+ skinned,
661
+ ...(typeof ctx.importSettings.standardMaterialGuid === 'string'
662
+ ? { standardRootGuid: ctx.importSettings.standardMaterialGuid as unknown as AssetGuid }
663
+ : {}),
664
+ });
665
+ const refs = materialRefsForPack(mat, doc, maps.textureGuidByIndex, maps.samplerGuidByIndex);
666
+ // D-8: if material has a parent, add parent edge to refs
667
+ const materialRefs: AssetRef[] = [...refs];
668
+ if (matAsset.parent !== undefined) {
669
+ materialRefs.push({
670
+ guid: matAsset.parent as unknown as string,
671
+ sourceField: { fieldName: 'parent' },
672
+ });
673
+ }
674
+ const rewrittenAsset = rewriteMaterialAssetRefs(matAsset, mat, doc, maps);
675
+ const matName = isMultiAsset ? mat.name : undefined;
676
+ out.push({
677
+ guid: sub.guid,
678
+ kind: 'material',
679
+ ...(matName !== undefined ? { name: matName } : {}),
680
+ payload: rewrittenAsset,
681
+ refs: materialRefs,
682
+ artifacts: {},
683
+ });
684
+ } else if (sub.kind === 'texture') {
685
+ const imageIndex = sub.sourceIndex;
686
+ const extracted = extraction.extracted.get(imageIndex);
687
+ if (extracted === undefined) {
688
+ const failure = extraction.failures.find((f) => f.imageIndex === imageIndex);
689
+ const detail = failure ?? {
690
+ imageIndex,
691
+ source: 'bufferView' as const,
692
+ reason: 'image row missing from extraction map (no images[] entry?)',
693
+ };
694
+ const error = gltfErr('gltf-image-extract-failed', detail);
695
+ throw new Error(
696
+ `gltfImporter: ${error.code} on image ${imageIndex} (${detail.source}): ${detail.reason}`,
697
+ );
698
+ }
699
+ const colorSpace = imageColorSpaces.get(imageIndex) ?? 'linear';
700
+ // Carry colorSpace + mipmap settings into decodeImage via a per-image
701
+ // settings record (mirror of importImageSettings — but here the importer
702
+ // owns the decision because the seam is in-bounds for AC-12 (c)).
703
+ const decodeSettings = {
704
+ ...ctx.importSettings,
705
+ colorSpace,
706
+ mipmap: ctx.importSettings.mipmap ?? true,
707
+ };
708
+ const decoded = await ctx.decodeImage(extracted.bytes, extracted.mimeType, decodeSettings);
709
+ if (!decoded.ok) {
710
+ const reason = `decodeImage failed: ${decoded.error.code}`;
711
+ const error = gltfErr('gltf-image-extract-failed', {
712
+ imageIndex,
713
+ source: extracted.source,
714
+ reason,
715
+ });
716
+ throw new Error(
717
+ `gltfImporter: ${error.code} on image ${imageIndex} (${extracted.source}): ${reason}`,
718
+ );
719
+ }
720
+ const imageItem = (doc.images ?? [])[imageIndex];
721
+ const texName = isMultiAsset ? imageItem?.name : undefined;
722
+ out.push({
723
+ guid: sub.guid,
724
+ kind: 'texture',
725
+ ...(texName !== undefined ? { name: texName } : {}),
726
+ payload: decoded.value.texture,
727
+ refs: [],
728
+ artifacts: {
729
+ body: {
730
+ mediaType: decoded.value.mediaType ?? extracted.mimeType,
731
+ assetCodec: decoded.value.assetCodec ?? { name: 'rgba8', version: '1' },
732
+ bytes: decoded.value.bytes,
733
+ },
734
+ },
735
+ });
736
+ } else if (sub.kind === 'sampler') {
737
+ const sampler = doc.samplers?.[sub.sourceIndex];
738
+ if (sampler === undefined) continue;
739
+ const filter = (value: number | undefined): 'nearest' | 'linear' | undefined => {
740
+ if (value === undefined) return undefined;
741
+ return value === 9728 || value === 9984 || value === 9986 || value === 9988
742
+ ? 'nearest'
743
+ : 'linear';
744
+ };
745
+ const mipmapFilter = (value: number | undefined): 'nearest' | 'linear' | undefined => {
746
+ if (value === undefined) return undefined;
747
+ return value === 9984 || value === 9985 ? 'nearest' : 'linear';
748
+ };
749
+ const addressMode = (value: number): 'repeat' | 'mirror-repeat' | 'clamp-to-edge' => {
750
+ if (value === 33071) return 'clamp-to-edge';
751
+ if (value === 33648) return 'mirror-repeat';
752
+ return 'repeat';
753
+ };
754
+ const magFilter = filter(sampler.magFilter);
755
+ const minFilter = filter(sampler.minFilter);
756
+ const mipmap = mipmapFilter(sampler.minFilter);
757
+ const payload = {
758
+ kind: 'sampler' as const,
759
+ ...(magFilter === undefined ? {} : { magFilter }),
760
+ ...(minFilter === undefined ? {} : { minFilter }),
761
+ ...(mipmap === undefined ? {} : { mipmapFilter: mipmap }),
762
+ addressModeU: addressMode(sampler.wrapS),
763
+ addressModeV: addressMode(sampler.wrapT),
764
+ };
765
+ out.push({ guid: sub.guid, kind: 'sampler', payload, refs: [], artifacts: {} });
766
+ } else if (sub.kind === 'scene') {
767
+ // #317 multi-material design: bridge accepts glTF mesh-index keyed
768
+ // meshHandles + materialHandles only; primitive merge happens via
769
+ // doc.meshes[] filtering on meshIndex inside the bridge.
770
+ // tweak-20260611 M6: also pass skeletonGuidBySkinIndex so the bridge
771
+ // can stamp Skin component (skeleton GUID string) onto skinned mesh
772
+ // entities. Skins are 1:1 with skeletons in toAssetPack so the same
773
+ // sourceIndex map serves both purposes (the field name says
774
+ // "BySkinIndex" because the bridge keys by GltfNodeIr.skinIndex).
775
+ const scene = gltfDocToSceneAsset(doc, {
776
+ meshHandles: maps.meshHandles,
777
+ materialHandles: maps.materialHandles,
778
+ skeletonGuidBySkinIndex: skeletonGuidBySourceIndex,
779
+ });
780
+ // Scene refs contain only dependencies authored directly by the scene:
781
+ // mesh, skeleton, and skin assets. Imported material defaults belong to
782
+ // MeshAsset.materialSlots, and textures belong to MaterialAsset refs;
783
+ // repeating either here would create a second dependency owner.
784
+ // Skeleton GUIDs are appended so the runtime asset graph sees the
785
+ // cross-edge when a skinned mesh node carries Skin { skeleton:
786
+ // <guid-string> }; skin GUIDs (feat-20260612 M2 fixup) carry the
787
+ // SkinAsset cross-edge that has no entity-component representation but
788
+ // is required for postSpawnResolveJoints to resolve Skin.joints[] via
789
+ // SkinAsset.jointPaths.
790
+ //
791
+ // D-2 / D-3: refs carries structured edge metadata (AssetRef[]).
792
+ // Walk scene entities to build a handle-value -> (entityLocalId,
793
+ // componentName, fieldName, arrayIndex?) provenance map, then
794
+ // produce AssetRef[] with sourceField / sceneEntityId filled for mesh
795
+ // handle-field edges. Skeleton edges: sourceField from Skin.skeleton if entity
796
+ // carries that GUID. Skin edges: sourceField=undefined (cross-edge
797
+ // with no entity-component representation).
798
+ const handleValueProvenance = new Map<
799
+ number,
800
+ { sceneEntityId: number; componentName: string; fieldName: string; arrayIndex?: number }
801
+ >();
802
+ const skeletonGuidProvenance = new Map<string, { sceneEntityId: number }>();
803
+ for (const entity of scene.entities) {
804
+ const comps = entity.components as Record<string, Record<string, unknown>>;
805
+ const mf = comps.MeshFilter;
806
+ if (mf !== undefined && typeof mf.assetHandle === 'number') {
807
+ handleValueProvenance.set(mf.assetHandle, {
808
+ sceneEntityId: entity.localId,
809
+ componentName: 'MeshFilter',
810
+ fieldName: 'assetHandle',
811
+ });
812
+ }
813
+ const skin = comps.Skin;
814
+ if (skin !== undefined && typeof skin.skeleton === 'string') {
815
+ skeletonGuidProvenance.set(skin.skeleton, { sceneEntityId: entity.localId });
816
+ }
817
+ }
818
+
819
+ const meshGuidList = [...maps.meshGuidByIndex.values()];
820
+
821
+ function makeRef(guid: string, idx: number): AssetRef {
822
+ const prov = handleValueProvenance.get(idx);
823
+ if (prov !== undefined) {
824
+ return {
825
+ guid,
826
+ sourceField: {
827
+ componentName: prov.componentName,
828
+ fieldName: prov.fieldName,
829
+ ...(prov.arrayIndex !== undefined ? { arrayIndex: prov.arrayIndex } : {}),
830
+ },
831
+ sceneEntityId: prov.sceneEntityId,
832
+ };
833
+ }
834
+ return { guid };
835
+ }
836
+
837
+ const refs: AssetRef[] = [];
838
+ let cursor = 0;
839
+ for (const guid of meshGuidList) {
840
+ refs.push(makeRef(guid, cursor));
841
+ cursor++;
842
+ }
843
+ {
844
+ const skeletonGuidList = [...skeletonGuidBySourceIndex.values()];
845
+ for (const guid of skeletonGuidList) {
846
+ const skProv = skeletonGuidProvenance.get(guid);
847
+ refs.push(
848
+ skProv !== undefined
849
+ ? {
850
+ guid,
851
+ sourceField: { componentName: 'Skin', fieldName: 'skeleton' },
852
+ sceneEntityId: skProv.sceneEntityId,
853
+ }
854
+ : { guid },
855
+ );
856
+ cursor++;
857
+ }
858
+ }
859
+ {
860
+ const skinGuidList = [...skinGuidBySourceIndex.values()];
861
+ for (const guid of skinGuidList) {
862
+ refs.push({ guid });
863
+ cursor++;
864
+ }
865
+ }
866
+ // Inject skinGuids into the scene payload as the reverse-decode hint; the
867
+ // SkinAsset GUIDs are already in the scene envelope's refs[] above, which
868
+ // is the runtime recursion source for the browser-async-pack-fetch path.
869
+ // Stored as inline GUID strings (in-memory dawn smoke path); the on-disk
870
+ // .pack.json round-trip preserves them as strings -- parseScenePayload's
871
+ // resolveSkinGuids accepts both string and refs[]-index shapes.
872
+ const skinGuidList = [...skinGuidBySourceIndex.values()];
873
+ const sceneWithSkinGuids =
874
+ skinGuidList.length > 0 ? { ...scene, skinGuids: skinGuidList } : scene;
875
+ const sceneName = isMultiAsset ? doc.scenes[sub.sourceIndex]?.name : undefined;
876
+ out.push({
877
+ guid: sub.guid,
878
+ kind: 'scene',
879
+ ...(sceneName !== undefined ? { name: sceneName } : {}),
880
+ payload: sceneWithSkinGuids,
881
+ refs,
882
+ artifacts: {},
883
+ });
884
+ } else if (sub.kind === 'skeleton') {
885
+ // tweak-20260611 M4: skeleton sub-asset POD emit. GltfSkeletonRecord (the
886
+ // gltf IR shape from parse-skin) carries inverseBindMatrices + jointCount
887
+ // + jointPaths; SkeletonAsset (the runtime POD) carries IBM + jointCount
888
+ // only — jointPaths live on the parallel SkinAsset (1:1 mapping per
889
+ // toAssetPack's emit policy). refs[] is empty (skeleton is leaf data).
890
+ const rec = doc.skeletons[sub.sourceIndex];
891
+ if (rec === undefined) continue;
892
+ const payload = {
893
+ kind: 'skeleton' as const,
894
+ inverseBindMatrices: rec.inverseBindMatrices,
895
+ jointCount: rec.jointCount,
896
+ };
897
+ out.push({ guid: sub.guid, kind: 'skeleton', payload, refs: [], artifacts: {} });
898
+ } else if (sub.kind === 'skin') {
899
+ // tweak-20260611 M4: skin sub-asset POD emit. The 1:1 mapping with
900
+ // skeleton (toAssetPack emits one skin per GltfSkeletonRecord at the same
901
+ // sourceIndex) lets us pull the skeleton GUID out of the pre-built
902
+ // skeletonGuidBySourceIndex map. SkinAsset is the runtime POD, distinct
903
+ // from GltfSkeletonRecord: skeletonGuid + jointPaths only (zero entity refs
904
+ // at the asset layer per AC-06). refs[] carries the skeletonGuid so the
905
+ // runner sees the cross-edge.
906
+ const rec = doc.skeletons[sub.sourceIndex];
907
+ if (rec === undefined) continue;
908
+ const skeletonGuid = skeletonGuidBySourceIndex.get(sub.sourceIndex);
909
+ if (skeletonGuid === undefined) continue;
910
+ const payload = {
911
+ kind: 'skin' as const,
912
+ skeletonGuid,
913
+ jointPaths: rec.jointPaths,
914
+ };
915
+ out.push({
916
+ guid: sub.guid,
917
+ kind: 'skin',
918
+ payload,
919
+ refs: [{ guid: skeletonGuid, sourceField: { fieldName: 'skeleton' } }],
920
+ artifacts: {},
921
+ });
922
+ } else if (sub.kind === 'animation-clip') {
923
+ // tweak-20260611 M4: animation-clip sub-asset POD emit. GltfAnimationClipRecord
924
+ // (gltf IR) and AnimationClip (runtime POD) are structurally compatible
925
+ // — both carry duration + channels[]; channels' inner shape matches
926
+ // (targetId / property / sampler). property is narrowed to the runtime
927
+ // closed union by the parser. refs[] is empty (animation clips reference joints by name path
928
+ // resolved at post-spawn time, not by sub-asset cross-edge).
929
+ const rec = doc.animationClips[sub.sourceIndex];
930
+ if (rec === undefined) continue;
931
+ const payload = {
932
+ kind: 'animation-clip' as const,
933
+ duration: rec.duration,
934
+ channels: rec.channels.map((ch) => ({
935
+ targetId: ch.targetId,
936
+ property: ch.property,
937
+ sampler: {
938
+ input: ch.sampler.input,
939
+ output: ch.sampler.output,
940
+ interpolation: ch.sampler.interpolation,
941
+ },
942
+ })),
943
+ };
944
+ out.push({ guid: sub.guid, kind: 'animation-clip', payload, refs: [], artifacts: {} });
945
+ }
946
+ }
947
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
948
+ }
949
+
950
+ /**
951
+ * The gltf {@link Importer}. Register it into an `ImporterRegistry` so the
952
+ * import runner dispatches `meta.importer === 'gltf'` sidecars here.
953
+ *
954
+ * @example
955
+ * ```ts
956
+ * import { ImporterRegistry } from '@forgeax/engine-import';
957
+ * import { gltfImporter } from '@forgeax/engine-gltf';
958
+ * const importers = new ImporterRegistry();
959
+ * importers.register(gltfImporter);
960
+ * ```
961
+ */
962
+ export function createGltfImporter(meshopt?: GltfBufferViewDecodeCapability): Importer {
963
+ return { key: 'gltf', import: (ctx) => importGltf(ctx, meshopt) };
964
+ }
965
+
966
+ /** Importer for hosts that provide an optional build-only Meshopt decoder. */
967
+ export const gltfImporter: Importer = createGltfImporter();