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