@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
package/src/bridge.ts ADDED
@@ -0,0 +1,928 @@
1
+ // bridge.ts - public gltfDocToSceneAsset SSOT (M3 w9 / feat-20260518).
2
+ //
3
+ // Excises the previously inline 80-line implementation in
4
+ // apps/hello/gltf/src/main.ts. AI users get a single import path:
5
+ //
6
+ // ```ts
7
+ // import { gltfDocToSceneAsset, toMaterialAsset } from '@forgeax/engine-gltf';
8
+ // const scene = gltfDocToSceneAsset(doc, { meshHandles, materialHandles });
9
+ // ```
10
+ //
11
+ // Pure function: no fs, no fetch, no throw. Walks the default scene's
12
+ // node graph depth-first, accumulating world transforms for children (B3).
13
+ // Handles for mesh / material come from caller-supplied Map lookups so the
14
+ // bridge stays runtime-agnostic.
15
+ //
16
+ // feat-20260608-mesh-multi-section-primitive-multi-material-slot M3 / w13+w15:
17
+ // (B1) N prim per glTF mesh merged into 1 entity with MeshRenderer.materials[]
18
+ // (B2) visit recursively walks ir.children
19
+ // (B3) child world pos accumulates parent transform
20
+ // (B6) camera detection via GltfNodeIr.camera field (not legacy nodes[1] heuristic)
21
+
22
+ import { packInterleavedVertexAttributes } from '@forgeax/engine-geometry';
23
+ import type { Mat4 } from '@forgeax/engine-math';
24
+ import { box3, mat4, quat, vec3 } from '@forgeax/engine-math';
25
+ import { AssetGuid as AssetGuidCodec } from '@forgeax/engine-pack/guid';
26
+ import type {
27
+ AssetGuid,
28
+ Handle,
29
+ LocalEntityId,
30
+ MaterialAsset,
31
+ MaterialError,
32
+ MaterialTextureValue,
33
+ MeshAsset,
34
+ MeshMaterialSlot,
35
+ MorphTarget,
36
+ RenderQueue,
37
+ Result,
38
+ SceneAsset,
39
+ SceneEntity,
40
+ Submesh,
41
+ VertexAttributeMap,
42
+ } from '@forgeax/engine-types';
43
+ import { createMaterialError, err, type GltfError, gltfErr, ok } from './errors.js';
44
+ import type {
45
+ GltfDoc,
46
+ GltfMaterialIr,
47
+ GltfMeshIr,
48
+ GltfNodeIr,
49
+ GltfPunctualLightIr,
50
+ GltfTextureInfoIr,
51
+ } from './parse-gltf.js';
52
+
53
+ /**
54
+ * Convert one or more parsed `GltfMeshIr` primitives sharing the same glTF
55
+ * mesh-index into a single runtime `MeshAsset` POD. Each primitive becomes one
56
+ * `Submesh` row; per-primitive vertex data is concatenated into one big
57
+ * interleaved vertex buffer (12 floats / vertex for unskinned meshes; 18 floats
58
+ * / vertex when any primitive carries `joints0` + `weights0`), and per-primitive
59
+ * index buffers are concatenated into one big index buffer. Each submesh's
60
+ * indices are biased by the running vertex offset so they reference vertices
61
+ * within the merged buffer.
62
+ *
63
+ * Per-MeshAsset stride decision (D-2): if any primitive contains skin
64
+ * attributes, the entire MeshAsset is promoted to 18-float stride and
65
+ * unskinned primitives' skin slots are zero-filled (joints {0,0,0,0} +
66
+ * weights {0,0,0,0}). This avoids splitting one glTF mesh into multiple
67
+ * MeshAsset rows when a model mixes skinned and unskinned primitives.
68
+ *
69
+ * AI users get a 1-to-1 mapping with `MeshRenderer.materials[]`: the i-th
70
+ * submesh is the i-th primitive of the i-th material slot.
71
+ *
72
+ * Pure function (no fs / fetch / registry). Missing attributes fall back to
73
+ * identity defaults per primitive: normal -> +Y, uv -> 0, tangent -> +X with
74
+ * w=1.
75
+ *
76
+ * Invalid source data is returned as a typed `GltfError`; callers must branch
77
+ * on `Result.ok` before publishing a MeshAsset. Caller (gltfImporter / smoke
78
+ * driver) is responsible for grouping by `meshIr.meshIndex` before calling.
79
+ */
80
+ export function meshIrToMeshAsset(
81
+ prims: readonly GltfMeshIr[],
82
+ materials: {
83
+ readonly guidByIndex?: ReadonlyMap<number, string>;
84
+ readonly nameByIndex?: ReadonlyMap<number, string>;
85
+ readonly sourceKeyByIndex?: ReadonlyMap<number, string>;
86
+ } = {},
87
+ ): Result<MeshAsset, GltfError> {
88
+ if (prims.length === 0) {
89
+ return err(
90
+ gltfErr('gltf-mesh-bridge-invalid', {
91
+ reason: 'empty-input',
92
+ primitiveCount: 0,
93
+ }),
94
+ );
95
+ }
96
+ let totalVertexCount = 0;
97
+ let totalIndexCount = 0;
98
+ let hasAnySkin = false;
99
+ const hasAnyColor = prims.some((p) => p.colors0 !== undefined);
100
+ let hasAnyIndices = false;
101
+ // feat-20260629-multi-uv-set-support m1-w3: derive the widest canonical UV
102
+ // key list across all primitives so the geometry projection accommodates it.
103
+ let widestUvIndex = 0;
104
+ const morphTargetCount = prims[0]?.morphTargets?.length ?? 0;
105
+ for (const p of prims) {
106
+ const primVc = p.positions.length / 3;
107
+ totalVertexCount += primVc;
108
+ if (p.indices !== undefined) {
109
+ totalIndexCount += p.indices.length;
110
+ hasAnyIndices = true;
111
+ } else {
112
+ totalIndexCount += primVc;
113
+ }
114
+ if (p.joints0 !== undefined && p.weights0 !== undefined) hasAnySkin = true;
115
+ if ((p.morphTargets?.length ?? 0) !== morphTargetCount) {
116
+ return err(
117
+ gltfErr('gltf-mesh-bridge-invalid', {
118
+ meshIndex: p.meshIndex,
119
+ primitiveIndex: prims.indexOf(p),
120
+ reason: 'morph-count-mismatch',
121
+ expectedTargetCount: morphTargetCount,
122
+ actualTargetCount: p.morphTargets?.length ?? 0,
123
+ }),
124
+ );
125
+ }
126
+ // Count present UV sets in this primitive.
127
+ for (let k = 7; k >= 1; k--) {
128
+ const key = `texcoord${k}` as keyof GltfMeshIr;
129
+ if (p[key] !== undefined) {
130
+ widestUvIndex = Math.max(widestUvIndex, k);
131
+ break;
132
+ }
133
+ }
134
+ }
135
+
136
+ const uvKeys = Array.from({ length: widestUvIndex }, (_, index) => `uv${index + 1}`);
137
+
138
+ const positionsCat = new Float32Array(totalVertexCount * 3);
139
+ const normalsCat = new Float32Array(totalVertexCount * 3);
140
+ const uvsCat = new Float32Array(totalVertexCount * 2);
141
+ const tangentsCat = new Float32Array(totalVertexCount * 4);
142
+ const colorsCat = hasAnyColor ? new Float32Array(totalVertexCount * 4) : undefined;
143
+ const morphTargets: MorphTarget[] = Array.from({ length: morphTargetCount }, (_, targetIndex) => {
144
+ const source = prims[0]?.morphTargets?.[targetIndex];
145
+ return {
146
+ ...(source?.position === undefined
147
+ ? {}
148
+ : { position: new Float32Array(totalVertexCount * 3) }),
149
+ ...(source?.normal === undefined ? {} : { normal: new Float32Array(totalVertexCount * 3) }),
150
+ ...(source?.tangent === undefined ? {} : { tangent: new Float32Array(totalVertexCount * 4) }),
151
+ };
152
+ });
153
+ // feat-20260629-multi-uv-set-support m1-w3: per-UV-set standalone typed arrays
154
+ // for MeshAsset.attributes (uv1..uvK). Allocated only for imported sets.
155
+ const uvCats = uvKeys.map(() => new Float32Array(totalVertexCount * 2));
156
+ // D-2 / w8: when promoted to a skinned projection, retain canonical
157
+ // standalone arrays; the geometry packer owns the interleaved GPU bytes.
158
+ const skinIndicesCat = hasAnySkin ? new Uint16Array(totalVertexCount * 4) : undefined;
159
+ const skinWeightsCat = hasAnySkin ? new Float32Array(totalVertexCount * 4) : undefined;
160
+ // Multi-primitive merge biases each primitive's index range by the running
161
+ // vertex offset, so the merged max index is `totalVertexCount - 1`. When a
162
+ // glTF mesh has > 65535 vertices across all primitives (common for
163
+ // moderately complex scenes like Sponza at ~192k verts), Uint16 overflows
164
+ // and we must widen to Uint32. WebGPU's setIndexBuffer takes 'uint16' or
165
+ // 'uint32' (auto-selected by the runtime via TypedArray.constructor); both
166
+ // are supported by every WebGPU device.
167
+ // bug-20260612 hello-skin visual layered gate: when no primitive carries
168
+ // indices (glTF non-indexed geometry per spec — primitive.indices optional),
169
+ // route MeshAsset.indices to undefined so the runtime takes the
170
+ // pass.draw(vertexCount) non-indexed path. Mixed bags (some prims indexed,
171
+ // some not) synthesize identity indices for the non-indexed prims so the
172
+ // merged MeshAsset stays single-index-buffer. hasAnyIndices === false
173
+ // skips the index buffer allocation entirely.
174
+ const useUint32 = totalVertexCount > 0xffff;
175
+ const indices: Uint16Array | Uint32Array | undefined = !hasAnyIndices
176
+ ? undefined
177
+ : useUint32
178
+ ? new Uint32Array(totalIndexCount)
179
+ : new Uint16Array(totalIndexCount);
180
+ const submeshes: Submesh[] = [];
181
+ const materialSlots: MeshMaterialSlot[] = [];
182
+ const slotByMaterial = new Map<number | null, number>();
183
+ const usedNames = new Set<string>();
184
+ const uniqueSlotName = (base: string): string => {
185
+ let candidate = base.trim() || 'Material';
186
+ let suffix = 2;
187
+ while (usedNames.has(candidate)) candidate = `${base}_${suffix++}`;
188
+ usedNames.add(candidate);
189
+ return candidate;
190
+ };
191
+ const slotFor = (materialIndex: number | null): number => {
192
+ const existing = slotByMaterial.get(materialIndex);
193
+ if (existing !== undefined) return existing;
194
+ const slotIndex = materialSlots.length;
195
+ const guid = materialIndex === null ? undefined : materials.guidByIndex?.get(materialIndex);
196
+ const parsed = guid === undefined ? undefined : AssetGuidCodec.parse(guid);
197
+ materialSlots.push({
198
+ slotName: uniqueSlotName(
199
+ materialIndex === null
200
+ ? 'Default'
201
+ : (materials.nameByIndex?.get(materialIndex) ?? `Material_${materialIndex}`),
202
+ ),
203
+ sourceKey:
204
+ materialIndex === null
205
+ ? 'gltf:default'
206
+ : (materials.sourceKeyByIndex?.get(materialIndex) ?? `gltf:material:${materialIndex}`),
207
+ ...(parsed?.ok ? { defaultMaterial: parsed.value } : {}),
208
+ });
209
+ slotByMaterial.set(materialIndex, slotIndex);
210
+ return slotIndex;
211
+ };
212
+
213
+ let vertexCursor = 0;
214
+ let indexCursor = 0;
215
+ for (const mesh of prims) {
216
+ const materialSlot = slotFor(mesh.materialIndex);
217
+ const primVertexCount = mesh.positions.length / 3;
218
+ const primIndexCount = mesh.indices === undefined ? 0 : mesh.indices.length;
219
+ if (mesh.colors0 !== undefined && mesh.colors0.length !== primVertexCount * 4) {
220
+ return err(
221
+ gltfErr('gltf-mesh-bridge-invalid', {
222
+ meshIndex: mesh.meshIndex,
223
+ primitiveIndex: prims.indexOf(mesh),
224
+ reason: 'color-cardinality',
225
+ semantic: 'COLOR_0',
226
+ vertexCount: primVertexCount,
227
+ expectedLength: primVertexCount * 4,
228
+ actualLength: mesh.colors0.length,
229
+ }),
230
+ );
231
+ }
232
+ for (let i = 0; i < primVertexCount; i++) {
233
+ const p = i * 3;
234
+ positionsCat[(vertexCursor + i) * 3 + 0] = mesh.positions[p + 0] as number;
235
+ positionsCat[(vertexCursor + i) * 3 + 1] = mesh.positions[p + 1] as number;
236
+ positionsCat[(vertexCursor + i) * 3 + 2] = mesh.positions[p + 2] as number;
237
+ if (mesh.normals !== undefined) {
238
+ const n = i * 3;
239
+ normalsCat[(vertexCursor + i) * 3 + 0] = mesh.normals[n + 0] as number;
240
+ normalsCat[(vertexCursor + i) * 3 + 1] = mesh.normals[n + 1] as number;
241
+ normalsCat[(vertexCursor + i) * 3 + 2] = mesh.normals[n + 2] as number;
242
+ } else {
243
+ normalsCat[(vertexCursor + i) * 3 + 1] = 1;
244
+ }
245
+ if (mesh.texcoord0 !== undefined) {
246
+ const t = i * 2;
247
+ uvsCat[(vertexCursor + i) * 2 + 0] = mesh.texcoord0[t + 0] as number;
248
+ uvsCat[(vertexCursor + i) * 2 + 1] = mesh.texcoord0[t + 1] as number;
249
+ }
250
+ if (mesh.tangents !== undefined) {
251
+ const g = i * 4;
252
+ tangentsCat[(vertexCursor + i) * 4 + 0] = mesh.tangents[g + 0] as number;
253
+ tangentsCat[(vertexCursor + i) * 4 + 1] = mesh.tangents[g + 1] as number;
254
+ tangentsCat[(vertexCursor + i) * 4 + 2] = mesh.tangents[g + 2] as number;
255
+ tangentsCat[(vertexCursor + i) * 4 + 3] = mesh.tangents[g + 3] as number;
256
+ } else {
257
+ tangentsCat[(vertexCursor + i) * 4 + 0] = 1;
258
+ tangentsCat[(vertexCursor + i) * 4 + 3] = 1;
259
+ }
260
+ if (colorsCat !== undefined) {
261
+ const colorDst = (vertexCursor + i) * 4;
262
+ const colorSrc = i * 4;
263
+ if (mesh.colors0 === undefined) {
264
+ colorsCat[colorDst + 0] = 1;
265
+ colorsCat[colorDst + 1] = 1;
266
+ colorsCat[colorDst + 2] = 1;
267
+ colorsCat[colorDst + 3] = 1;
268
+ } else {
269
+ colorsCat[colorDst + 0] = mesh.colors0[colorSrc + 0] as number;
270
+ colorsCat[colorDst + 1] = mesh.colors0[colorSrc + 1] as number;
271
+ colorsCat[colorDst + 2] = mesh.colors0[colorSrc + 2] as number;
272
+ colorsCat[colorDst + 3] = mesh.colors0[colorSrc + 3] as number;
273
+ }
274
+ }
275
+ for (let targetIndex = 0; targetIndex < morphTargetCount; targetIndex++) {
276
+ const source = mesh.morphTargets?.[targetIndex];
277
+ const target = morphTargets[targetIndex] as MorphTarget;
278
+ const vertex = vertexCursor + i;
279
+ if (source?.position !== undefined && target.position !== undefined) {
280
+ target.position.set(source.position.subarray(i * 3, i * 3 + 3), vertex * 3);
281
+ }
282
+ if (source?.normal !== undefined && target.normal !== undefined) {
283
+ target.normal.set(source.normal.subarray(i * 3, i * 3 + 3), vertex * 3);
284
+ }
285
+ if (source?.tangent !== undefined && target.tangent !== undefined) {
286
+ target.tangent.set(source.tangent.subarray(i * 4, i * 4 + 4), vertex * 4);
287
+ }
288
+ }
289
+ // D-2 / w8: when the MeshAsset is promoted to a skinned projection,
290
+ // retain both canonical standalone arrays. The geometry packer writes
291
+ // their typed values into the canonical interleaved byte layout once.
292
+ if (hasAnySkin && skinIndicesCat !== undefined && skinWeightsCat !== undefined) {
293
+ const skinDst = (vertexCursor + i) * 4;
294
+ if (mesh.joints0 !== undefined && mesh.weights0 !== undefined) {
295
+ const j = i * 4;
296
+ const j0 = mesh.joints0[j + 0] as number;
297
+ const j1 = mesh.joints0[j + 1] as number;
298
+ const j2 = mesh.joints0[j + 2] as number;
299
+ const j3 = mesh.joints0[j + 3] as number;
300
+ skinIndicesCat[skinDst + 0] = j0;
301
+ skinIndicesCat[skinDst + 1] = j1;
302
+ skinIndicesCat[skinDst + 2] = j2;
303
+ skinIndicesCat[skinDst + 3] = j3;
304
+ const w0 = mesh.weights0[j + 0] as number;
305
+ const w1 = mesh.weights0[j + 1] as number;
306
+ const w2 = mesh.weights0[j + 2] as number;
307
+ const w3 = mesh.weights0[j + 3] as number;
308
+ skinWeightsCat[skinDst + 0] = w0;
309
+ skinWeightsCat[skinDst + 1] = w1;
310
+ skinWeightsCat[skinDst + 2] = w2;
311
+ skinWeightsCat[skinDst + 3] = w3;
312
+ }
313
+ // else: unskinned primitive in a mixed MeshAsset; typed arrays remain
314
+ // zero-filled, which is the canonical fallback.
315
+ }
316
+ // feat-20260629-multi-uv-set-support m1-w3: write uv1..uvK after skin data.
317
+ // Canonical interleaved order: position/normal/uv/tangent/skinIndex/skinWeight/uv1..uv7.
318
+ // UV1 starts at offset UV1_OFFSET (12 for unskinned, 18 for skinned) in float slots.
319
+ // Each additional UV set 2F. Missing texcoordK → zero-fill (plan-strategy M1).
320
+ for (let k = 1; k <= uvKeys.length; k++) {
321
+ const uvKey = `texcoord${k}` as keyof GltfMeshIr;
322
+ const catIdx = k - 1;
323
+ const cat = uvCats[catIdx] as Float32Array;
324
+ const catDst = (vertexCursor + i) * 2;
325
+ const srcArr = mesh[uvKey] as Float32Array | undefined;
326
+ if (srcArr !== undefined) {
327
+ const t = i * 2;
328
+ cat[catDst + 0] = srcArr[t + 0] as number;
329
+ cat[catDst + 1] = srcArr[t + 1] as number;
330
+ }
331
+ // else: zero-fill (implicit — Float32Array defaults to 0)
332
+ }
333
+ }
334
+ // Bias each submesh's indices by the running vertex offset so they
335
+ // reference into the merged vertex buffer rather than the per-primitive
336
+ // local 0-base. When the merged MeshAsset is non-indexed (hasAnyIndices
337
+ // === false → `indices` is undefined here), skip the bias loop and
338
+ // emit a vertex-only submesh (indexCount=0; runtime dispatches via
339
+ // `pass.draw(vertexCount)`). When mixed (some prims indexed, this one
340
+ // not), synthesize identity indices [vertexCursor..+primVertexCount-1]
341
+ // so the merged single-index-buffer contract holds.
342
+ if (indices !== undefined) {
343
+ if (mesh.indices !== undefined) {
344
+ for (let i = 0; i < primIndexCount; i++) {
345
+ const src = mesh.indices[i] as number;
346
+ indices[indexCursor + i] = src + vertexCursor;
347
+ }
348
+ submeshes.push({
349
+ indexOffset: indexCursor,
350
+ indexCount: primIndexCount,
351
+ vertexCount: primVertexCount,
352
+ topology: 'triangle-list',
353
+ materialSlot,
354
+ });
355
+ indexCursor += primIndexCount;
356
+ } else {
357
+ // mixed bag: synthesize identity indices for this non-indexed prim
358
+ for (let i = 0; i < primVertexCount; i++) {
359
+ indices[indexCursor + i] = vertexCursor + i;
360
+ }
361
+ submeshes.push({
362
+ indexOffset: indexCursor,
363
+ indexCount: primVertexCount,
364
+ vertexCount: primVertexCount,
365
+ topology: 'triangle-list',
366
+ materialSlot,
367
+ });
368
+ indexCursor += primVertexCount;
369
+ }
370
+ } else {
371
+ // pure non-indexed mesh: indexCount=0, vertexCount carries draw count.
372
+ submeshes.push({
373
+ indexOffset: 0,
374
+ indexCount: 0,
375
+ vertexCount: primVertexCount,
376
+ topology: 'triangle-list',
377
+ materialSlot,
378
+ });
379
+ }
380
+ vertexCursor += primVertexCount;
381
+ }
382
+
383
+ const attributes: VertexAttributeMap = {
384
+ position: positionsCat,
385
+ normal: normalsCat,
386
+ uv: uvsCat,
387
+ tangent: tangentsCat,
388
+ ...(colorsCat === undefined ? {} : { color: colorsCat }),
389
+ ...(skinIndicesCat === undefined ? {} : { skinIndex: skinIndicesCat }),
390
+ ...(skinWeightsCat === undefined ? {} : { skinWeight: skinWeightsCat }),
391
+ ...Object.fromEntries(uvCats.map((cat, idx) => [`uv${idx + 1}`, cat])),
392
+ };
393
+ const packed = packInterleavedVertexAttributes(attributes, totalVertexCount);
394
+ if (!packed.ok) {
395
+ return err(
396
+ gltfErr('gltf-mesh-bridge-invalid', {
397
+ meshIndex: prims[0]?.meshIndex ?? -1,
398
+ reason: 'layout-invalid',
399
+ cause: packed.error.detail,
400
+ }),
401
+ );
402
+ }
403
+
404
+ return ok({
405
+ kind: 'mesh',
406
+ vertices: packed.value.vertices,
407
+ ...(indices === undefined ? {} : { indices }),
408
+ submeshes,
409
+ materialSlots,
410
+ aabb: box3.fromPositions(box3.create(), positionsCat),
411
+ attributes,
412
+ ...(morphTargetCount === 0 ? {} : { morphTargets }),
413
+ ...(prims[0]?.morphWeights === undefined
414
+ ? {}
415
+ : { morphWeights: new Float32Array(prims[0].morphWeights) }),
416
+ });
417
+ }
418
+
419
+ export interface GltfBridgeContext {
420
+ /** glTF mesh index -> registry MeshAsset handle (multi-submesh). */
421
+ readonly meshHandles: ReadonlyMap<number, Handle<'MeshAsset', 'shared'>>;
422
+ /** glTF material index -> registry MaterialAsset handle. */
423
+ readonly materialHandles: ReadonlyMap<number, Handle<'MaterialAsset', 'shared'>>;
424
+ /**
425
+ * glTF skin index -> SkeletonAsset GUID (string form). When a GltfNodeIr carries
426
+ * a skin reference, the bridge emits `Skin: { skeleton: <guid-string> }` on
427
+ * that node's entity; AssetRegistry._resolveSceneGuids resolves the GUID to
428
+ * a runtime Handle at instantiate time (same protocol as MeshFilter and
429
+ * MeshRenderer.materials[]). Optional — skinless glTFs pass an empty Map
430
+ * (or omit the field) and the bridge does not emit Skin.
431
+ *
432
+ * tweak-20260611 M6 / D-7: emitting Skin from the bridge means the standard
433
+ * loadByGuid<SceneAsset> + instantiate path Just Works for skinned glTF;
434
+ * demos no longer need to runtime-parseGlb + post-load patch the SceneAsset.
435
+ * postSpawnResolveJoints (called from AssetRegistry.instantiate) walks the
436
+ * matching SkinAsset.jointPaths against the spawn subtree to fill Skin.joints[].
437
+ */
438
+ readonly skeletonGuidBySkinIndex?: ReadonlyMap<number, string>;
439
+ }
440
+
441
+ interface MutableSceneEntity {
442
+ localId: LocalEntityId;
443
+ components: Record<string, Record<string, unknown>>;
444
+ /** Index into the external `nodes` array, set after push for ChildOf wiring. */
445
+ localIdx: number;
446
+ }
447
+
448
+ function lightDirection(currentWorld: Mat4): readonly [number, number, number] {
449
+ const direction = mat4.getForward(vec3.create(), currentWorld);
450
+ return [direction[0] ?? 0, direction[1] ?? 0, direction[2] ?? -1];
451
+ }
452
+
453
+ function lightComponent(
454
+ light: GltfPunctualLightIr,
455
+ direction: readonly [number, number, number],
456
+ ): Record<string, unknown> {
457
+ const range = light.range ?? Number.POSITIVE_INFINITY;
458
+ if (light.type === 'directional') {
459
+ return { direction, color: light.color, intensity: light.intensity };
460
+ }
461
+ if (light.type === 'point') {
462
+ return { color: light.color, intensity: light.intensity, range };
463
+ }
464
+ return {
465
+ direction,
466
+ color: light.color,
467
+ intensity: light.intensity,
468
+ range,
469
+ innerConeDeg: ((light.spot?.innerConeAngle ?? 0) * 180) / Math.PI,
470
+ outerConeDeg: ((light.spot?.outerConeAngle ?? Math.PI / 4) * 180) / Math.PI,
471
+ castShadow: false,
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Convert a local TRS (translation, rotation quat, scale) into a Mat4.
477
+ * Uses @forgeax/engine-math out-param style. `out` is mutated in place.
478
+ */
479
+ function composeMat4(
480
+ out: Mat4,
481
+ tx: number,
482
+ ty: number,
483
+ tz: number,
484
+ qx: number,
485
+ qy: number,
486
+ qz: number,
487
+ qw: number,
488
+ sx: number,
489
+ sy: number,
490
+ sz: number,
491
+ ): void {
492
+ const t = vec3.create(tx, ty, tz);
493
+ const r = quat.create();
494
+ r[0] = qx;
495
+ r[1] = qy;
496
+ r[2] = qz;
497
+ r[3] = qw;
498
+ const s = vec3.create(sx, sy, sz);
499
+ mat4.compose(out, t, r, s);
500
+ }
501
+
502
+ /**
503
+ * Convert a parsed GltfDoc into a SceneAsset POD. Caller supplies registry
504
+ * handles via `ctx`; the bridge does no registration of its own.
505
+ *
506
+ * Visits the default scene's nodes depth-first, accumulating world-space
507
+ * transforms from parent to child (B3). Assigns sequential LocalEntityId in
508
+ * document order for reimport-stable identity. Camera nodes are detected by
509
+ * GltfNodeIr.camera (B6 fix).
510
+ *
511
+ * B1: each glTF mesh's N primitives are merged into a single entity.
512
+ * The caller provides `ctx.meshHandles` keyed by glTF mesh index, where each
513
+ * handle points to a MeshAsset whose `submeshes` already span all primitives.
514
+ * `ctx.materialHandles` is keyed by glTF material index; the bridge assembles a
515
+ * `MeshRenderer.materials[]` array from the primitives' materialIndex values
516
+ * in the order they appear in `doc.meshes`.
517
+ *
518
+ * B2: visit recursively walks `ir.children`.
519
+ *
520
+ * D-2 (empty container): transform-only nodes (no mesh, no camera) produce
521
+ * an entity with just Transform + optionally a Name component, preserving the
522
+ * hierarchy for animation/picking hooks.
523
+ */
524
+ export function gltfDocToSceneAsset(doc: GltfDoc, ctx: GltfBridgeContext): SceneAsset {
525
+ const sceneIr = doc.scenes[doc.defaultSceneIndex];
526
+ const resultNodes: MutableSceneEntity[] = [];
527
+ if (sceneIr === undefined) return { kind: 'scene', entities: [] };
528
+ const importedLights = doc.lights ?? doc.extensions?.KHR_lights_punctual?.lights ?? [];
529
+ const animationTargetIds = new Map<number, string>();
530
+ for (const clip of doc.animationClips) {
531
+ for (const channel of clip.channels) {
532
+ animationTargetIds.set(channel.targetNodeIndex, channel.targetId);
533
+ }
534
+ }
535
+
536
+ // Per-node world matrix accumulator (B3 fix).
537
+ const parentWorld = mat4.create();
538
+ mat4.identity(parentWorld);
539
+ const currentWorld = mat4.create();
540
+ const localMat = mat4.create();
541
+
542
+ // bug-20260613: SceneAsset entities also emit ChildOf when a glTF node has a
543
+ // parent (line 547 below). Runtime propagateTransforms then derives
544
+ // Transform.world via `parent.world * compose(child.local TRS)`. If we
545
+ // wrote the *world* TRS into Transform here, every child node would get
546
+ // baked twice -- once at importer time and again at propagate time --
547
+ // collapsing the skin so vertices fly to (parent.world)^2 space and the
548
+ // mesh appears as a scrambled silhouette. Mirror GltfNodeIr.transform's local
549
+ // TRS verbatim; propagateTransforms is the single accumulation path.
550
+ const pushLocalTransform = (
551
+ transform: import('./transform.js').DecomposedTransform,
552
+ ): Record<string, unknown> => ({
553
+ pos: [
554
+ transform.translation[0] ?? 0,
555
+ transform.translation[1] ?? 0,
556
+ transform.translation[2] ?? 0,
557
+ ],
558
+ // Quaternion component order [x, y, z, w] (glTF-aligned; E6).
559
+ quat: [
560
+ transform.rotation[0] ?? 0,
561
+ transform.rotation[1] ?? 0,
562
+ transform.rotation[2] ?? 0,
563
+ transform.rotation[3] ?? 1,
564
+ ],
565
+ scale: [transform.scale[0] ?? 1, transform.scale[1] ?? 1, transform.scale[2] ?? 1],
566
+ });
567
+
568
+ const visit = (gltfNodeIdx: number, parentLocalIdx: number | null): void => {
569
+ const ir = doc.nodes[gltfNodeIdx];
570
+ if (ir === undefined) return;
571
+
572
+ // Compute local transform matrix
573
+ composeMat4(
574
+ localMat,
575
+ ir.transform.translation[0] ?? 0,
576
+ ir.transform.translation[1] ?? 0,
577
+ ir.transform.translation[2] ?? 0,
578
+ ir.transform.rotation[0] ?? 0,
579
+ ir.transform.rotation[1] ?? 0,
580
+ ir.transform.rotation[2] ?? 0,
581
+ ir.transform.rotation[3] ?? 1,
582
+ ir.transform.scale[0] ?? 1,
583
+ ir.transform.scale[1] ?? 1,
584
+ ir.transform.scale[2] ?? 1,
585
+ );
586
+
587
+ // Accumulate world transform (B3: parent * local)
588
+ if (parentLocalIdx === null) {
589
+ // Root node: world = local
590
+ for (let i = 0; i < 16; i++) {
591
+ currentWorld[i] = localMat[i] ?? 0;
592
+ }
593
+ } else {
594
+ mat4.multiply(currentWorld, parentWorld, localMat);
595
+ }
596
+
597
+ // B6: detect camera via GltfNodeIr.camera field (not legacy heuristic)
598
+ const isCamera = ir.camera !== null;
599
+
600
+ // D-2: empty container rule
601
+ // - mesh node: MeshFilter + MeshRenderer
602
+ // - camera node: Camera (standalone entity)
603
+ // - transform-only node (no mesh, no camera): Transform-only entity to preserve hierarchy
604
+ const hasMesh = ir.meshIndex !== null;
605
+
606
+ // Always produce a node for every glTF node (preserves hierarchy).
607
+ // Transform-only nodes carry just Transform + optionally Name.
608
+ const components: Record<string, Record<string, unknown>> = {
609
+ Transform: pushLocalTransform(ir.transform),
610
+ };
611
+
612
+ const importedLight = importedLights[ir.lightIndex ?? -1];
613
+ if (importedLight !== undefined) {
614
+ const componentName =
615
+ importedLight.type === 'directional'
616
+ ? 'DirectionalLight'
617
+ : importedLight.type === 'point'
618
+ ? 'PointLight'
619
+ : 'SpotLight';
620
+ components[componentName] = lightComponent(
621
+ importedLight,
622
+ lightDirection(currentWorld),
623
+ ) as Record<string, unknown>;
624
+ }
625
+
626
+ if (ir.name !== undefined && ir.name !== '') {
627
+ components.Name = { value: ir.name };
628
+ }
629
+ const animationTargetId = animationTargetIds.get(gltfNodeIdx);
630
+ if (animationTargetId !== undefined) {
631
+ components.AnimationTargetId = { value: animationTargetId };
632
+ }
633
+
634
+ if (hasMesh) {
635
+ const meshHandle = ctx.meshHandles.get(ir.meshIndex as number);
636
+ if (meshHandle !== undefined) {
637
+ components.MeshFilter = { assetHandle: meshHandle };
638
+ }
639
+ const meshMorph = doc.meshes.find((mesh) => mesh.meshIndex === ir.meshIndex);
640
+ const morphCount = meshMorph?.morphTargets?.length ?? 0;
641
+ if (morphCount > 0) {
642
+ const weights = ir.morphWeights ?? meshMorph?.morphWeights ?? new Float32Array(morphCount);
643
+ if (weights.length !== morphCount) {
644
+ throw new Error('gltfDocToSceneAsset: MorphWeights length does not match morph targets');
645
+ }
646
+ components.MorphWeights = { weights: new Float32Array(weights) };
647
+ }
648
+ // tweak-20260611 M6: when this node references a glTF skin, stamp a
649
+ // Skin component carrying the SkeletonAsset GUID as a string. The
650
+ // runtime AssetRegistry._resolveSceneGuids resolves the string to a
651
+ // Handle at instantiate time (same protocol as MeshFilter/MeshRenderer).
652
+ // postSpawnResolveJoints (called from AssetRegistry.instantiate) then
653
+ // fills Skin.joints[] by walking the matching SkinAsset.jointPaths
654
+ // against the spawn subtree's Name index.
655
+ if (ir.skinIndex !== null && ctx.skeletonGuidBySkinIndex !== undefined) {
656
+ const skeletonGuid = ctx.skeletonGuidBySkinIndex.get(ir.skinIndex);
657
+ if (skeletonGuid !== undefined) {
658
+ components.Skin = { skeleton: skeletonGuid };
659
+ }
660
+ }
661
+ // B1: collect one material handle per primitive belonging to THIS
662
+ // node's glTF mesh (filter by meshIr.meshIndex === ir.meshIndex), in
663
+ // the same order primitives are emitted in `doc.meshes`. The merged
664
+ // MeshAsset built by `meshIrToMeshAsset` walks the same filter in the
665
+ // same order to produce one Submesh per primitive, so submeshes[i]
666
+ // pairs with materials[i] positionally (AGENTS.md §Component naming
667
+ // "positional materials[i] <-> submeshes[i]" + #317 multi-material
668
+ // contract).
669
+ //
670
+ // A primitive with materialIndex===null (or one whose ctx.materialHandles
671
+ // lookup misses) gets a synthetic missing-handle entry: dropping it
672
+ // would desynchronise materials[].length vs submeshes[].length and
673
+ // trigger the fail-fast `mesh-renderer-material-count-mismatch` at
674
+ // register time. Falling back to the first available material handle
675
+ // for the same mesh keeps the count alignment without inventing a
676
+ // shared default-material handle (Tier-B scope; Sponza / BoxTextured
677
+ // every primitive carries an explicit material so this fallback path
678
+ // is exercised only by under-specified glTFs).
679
+ // Mesh-owned slots carry imported defaults. Scene nodes persist only
680
+ // true instance differences, so the canonical imported node starts with
681
+ // an empty per-slot override vector.
682
+ components.MeshRenderer = { materials: [] };
683
+ }
684
+
685
+ // Instances on the same entity as MeshFilter/MeshRenderer.
686
+ if (ir.instancing !== undefined) {
687
+ components.Instances = { transforms: ir.instancing.transforms };
688
+ }
689
+
690
+ if (isCamera) {
691
+ // B6: camera node detected via GltfNodeIr.camera (not heuristic).
692
+ // Camera component sits alongside Transform (and Name if present).
693
+ components.Camera = {
694
+ fov: 0.7853981633974483,
695
+ aspect: 1.7777777777777777,
696
+ near: 0.1,
697
+ far: 100,
698
+ };
699
+ }
700
+
701
+ const localIdx = resultNodes.length;
702
+ const node: MutableSceneEntity = {
703
+ localId: localIdx as LocalEntityId,
704
+ components,
705
+ localIdx,
706
+ };
707
+
708
+ // ChildOf wiring: if this node has a parent, add ChildOf component
709
+ if (parentLocalIdx !== null) {
710
+ node.components.ChildOf = { parent: parentLocalIdx as LocalEntityId };
711
+ }
712
+
713
+ resultNodes.push(node);
714
+
715
+ // B2: recursively visit children with accumulated world matrix.
716
+ // B3: set parentWorld to this node's world matrix so children multiply
717
+ // correctly (parent * childLocal = childWorld).
718
+ const savedParent = mat4.clone(parentWorld);
719
+ for (let i = 0; i < 16; i++) parentWorld[i] = currentWorld[i] ?? 0;
720
+
721
+ for (const childIdx of ir.children) {
722
+ visit(childIdx, localIdx);
723
+ }
724
+
725
+ // Restore parent world after visiting all children
726
+ for (let i = 0; i < 16; i++) parentWorld[i] = savedParent[i] ?? 0;
727
+ };
728
+
729
+ for (const rootIdx of sceneIr.nodes) visit(rootIdx, null);
730
+
731
+ const frozen: SceneEntity[] = resultNodes.map((n) => ({
732
+ localId: n.localId,
733
+ components: n.components,
734
+ }));
735
+ const lightFacts = importedLights.map((light) => ({
736
+ kind: light.type,
737
+ intensity: light.intensity,
738
+ ...(light.range === undefined ? {} : { range: light.range }),
739
+ ...(light.spot === undefined ? {} : { spot: light.spot }),
740
+ }));
741
+ return {
742
+ kind: 'scene',
743
+ entities: frozen,
744
+ ...(lightFacts.length === 0 ? {} : { lights: lightFacts }),
745
+ } as SceneAsset;
746
+ }
747
+
748
+ /** Internal helper: mark GltfNodeIr usable so future surface evolutions stay typed. */
749
+ export type _NodeIrAlias = GltfNodeIr;
750
+
751
+ export interface MaterialBridgeContext {
752
+ /** glTF texture index -> registry TextureAsset handle. */
753
+ readonly textureHandles?: ReadonlyMap<number, Handle<'TextureAsset', 'shared'>>;
754
+ /** glTF sampler index -> registry SamplerAsset handle. */
755
+ readonly samplerHandles?: ReadonlyMap<number, Handle<'SamplerAsset', 'shared'>>;
756
+ /** Built-in standard MaterialAsset GUID supplied by the importing project. */
757
+ readonly standardRootGuid?: AssetGuid;
758
+ /**
759
+ * feat-20260611 w17-a: when any primitive consuming this material carries
760
+ * JOINTS_0 + WEIGHTS_0, the cooker passes `skinned: true` so the emitted
761
+ * MaterialAsset's pass[0].program.module is `forgeax::pbr-skin` instead of
762
+ * `forgeax::default-standard-pbr`. The cooker (gltf-importer) is the only
763
+ * site with full mesh<->material wiring info; routing here keeps shader
764
+ * choice content-driven (not user-driven, per Q4 — runtime fail-fast in
765
+ * render-system-extract remains the reverse-direction safety net).
766
+ */
767
+ readonly skinned?: boolean;
768
+ }
769
+
770
+ function textureInfo(info: GltfTextureInfoIr | number | undefined): GltfTextureInfoIr | undefined {
771
+ return info === undefined ? undefined : typeof info === 'number' ? { texture: info } : info;
772
+ }
773
+
774
+ type MaterialTextureSlot =
775
+ | 'baseColorTexture'
776
+ | 'metallicRoughnessTexture'
777
+ | 'normalTexture'
778
+ | 'occlusionTexture'
779
+ | 'emissiveTexture';
780
+
781
+ function textureValue(
782
+ info: GltfTextureInfoIr | number | undefined,
783
+ slot: MaterialTextureSlot,
784
+ ctx: MaterialBridgeContext | undefined,
785
+ ): MaterialTextureValue | undefined {
786
+ const binding = textureInfo(info);
787
+ if (binding === undefined || ctx?.textureHandles === undefined) return undefined;
788
+ const textureHandle = ctx.textureHandles.get(binding.texture);
789
+ if (textureHandle === undefined) return undefined;
790
+ const samplerHandle =
791
+ binding.sampler === undefined ? undefined : ctx.samplerHandles?.get(binding.sampler);
792
+ const coordinates =
793
+ binding.texCoord === undefined && binding.transform === undefined
794
+ ? undefined
795
+ : {
796
+ ...(binding.texCoord === undefined ? {} : { set: binding.texCoord }),
797
+ ...(binding.transform === undefined ? {} : { transform: binding.transform }),
798
+ };
799
+ const value = {
800
+ texture: textureHandle as unknown as MaterialTextureValue['texture'],
801
+ ...(samplerHandle === undefined
802
+ ? {}
803
+ : { sampler: samplerHandle as unknown as NonNullable<MaterialTextureValue['sampler']> }),
804
+ ...(coordinates === undefined ? {} : { coordinates }),
805
+ };
806
+ if (slot === 'normalTexture') {
807
+ const normal = info as GltfMaterialIr['normalTexture'];
808
+ if (typeof normal === 'object' && normal?.scale !== undefined) {
809
+ return { ...value, normalScale: normal.scale };
810
+ }
811
+ }
812
+ if (slot === 'occlusionTexture') {
813
+ const occlusion = info as GltfMaterialIr['occlusionTexture'];
814
+ if (typeof occlusion === 'object' && occlusion?.strength !== undefined) {
815
+ return { ...value, occlusionStrength: occlusion.strength };
816
+ }
817
+ }
818
+ return value;
819
+ }
820
+
821
+ export function validateMaterialUvSets(
822
+ mat: GltfMaterialIr,
823
+ primitive: string,
824
+ availableSets: readonly number[],
825
+ ): Result<void, MaterialError> {
826
+ const available = new Set(availableSets);
827
+ const slots: readonly [MaterialTextureSlot, GltfTextureInfoIr | number | undefined][] = [
828
+ ['baseColorTexture', mat.baseColorTexture],
829
+ ['metallicRoughnessTexture', mat.metallicRoughnessTexture],
830
+ ['normalTexture', mat.normalTexture],
831
+ ['occlusionTexture', mat.occlusionTexture],
832
+ ['emissiveTexture', mat.emissiveTexture],
833
+ ];
834
+ for (const [slot, rawBinding] of slots) {
835
+ const binding = textureInfo(rawBinding);
836
+ if (binding === undefined) continue;
837
+ const requestedSet = binding.texCoord ?? 0;
838
+ if (!available.has(requestedSet)) {
839
+ return err(
840
+ createMaterialError('gltf-material-uv-set-missing', {
841
+ material: mat.name ?? '<unnamed>',
842
+ primitive,
843
+ slot,
844
+ requestedSet,
845
+ availableSets,
846
+ }),
847
+ );
848
+ }
849
+ }
850
+ return ok(undefined);
851
+ }
852
+
853
+ /** Convert a parsed GltfMaterialIr into a standard-root derived MaterialAsset. */
854
+ export function toMaterialAsset(mat: GltfMaterialIr, ctx?: MaterialBridgeContext): MaterialAsset {
855
+ const values: Record<string, NonNullable<MaterialAsset['values']>[string]> = {
856
+ baseColor: mat.baseColorFactor,
857
+ metallic: mat.metallicFactor,
858
+ roughness: mat.roughnessFactor,
859
+ };
860
+ if (mat.emissiveFactor !== undefined) {
861
+ values.emissive = mat.emissiveFactor;
862
+ values.emissiveIntensity = 1;
863
+ }
864
+ const textureSlots: readonly [MaterialTextureSlot, GltfTextureInfoIr | number | undefined][] = [
865
+ ['baseColorTexture', mat.baseColorTexture],
866
+ ['metallicRoughnessTexture', mat.metallicRoughnessTexture],
867
+ ['normalTexture', mat.normalTexture],
868
+ ['occlusionTexture', mat.occlusionTexture],
869
+ ['emissiveTexture', mat.emissiveTexture],
870
+ ];
871
+ for (const [slot, info] of textureSlots) {
872
+ const value = textureValue(info, slot, ctx);
873
+ if (value !== undefined) values[slot] = value;
874
+ }
875
+ if (mat.occlusionTexture !== undefined && values.occlusionStrength === undefined) {
876
+ values.occlusionStrength = 1;
877
+ }
878
+
879
+ const module = ctx?.skinned === true ? 'forgeax::pbr-skin' : 'forgeax::default-standard-pbr';
880
+
881
+ const isMask = mat.alphaMode === 'MASK';
882
+ const alphaCutoff = isMask ? (mat.alphaCutoff ?? 0.5) : undefined;
883
+ if (alphaCutoff !== undefined) values.alphaCutoff = alphaCutoff;
884
+
885
+ // glTF BLEND uses straight alpha and does not write depth.
886
+ const isBlend = mat.alphaMode === 'BLEND';
887
+ const straightAlphaBlend = {
888
+ color: {
889
+ srcFactor: 'src-alpha' as const,
890
+ dstFactor: 'one-minus-src-alpha' as const,
891
+ operation: 'add' as const,
892
+ },
893
+ alpha: {
894
+ srcFactor: 'one' as const,
895
+ dstFactor: 'one-minus-src-alpha' as const,
896
+ operation: 'add' as const,
897
+ },
898
+ };
899
+
900
+ // feat-city-glb Bug 5: transparent (BLEND) materials read but do NOT write
901
+ // depth (`depthWriteEnabled: false`), matching the engine's own transparent
902
+ // convention (learn-render 4.3 blending window material). glTF decals are
903
+ // frequently coplanar with the opaque surface they overlay (e.g. a crosswalk
904
+ // decal on the road); writing depth would z-fight / self-occlude. Back-to-
905
+ // front ordering is handled by the Transparent queue + transparent sort.
906
+ const pass = {
907
+ name: 'Forward',
908
+ program: { module },
909
+ renderState: {
910
+ tags: { LightMode: 'Forward' },
911
+ queue: (isBlend ? 3000 : isMask ? 2450 : 2000) as RenderQueue,
912
+ ...(isBlend || isMask || mat.doubleSided === true
913
+ ? {
914
+ ...(isBlend ? { blend: straightAlphaBlend, depthWriteEnabled: false } : {}),
915
+ ...(mat.doubleSided === true ? { cullMode: 'none' as const } : {}),
916
+ }
917
+ : {}),
918
+ },
919
+ };
920
+
921
+ return {
922
+ kind: 'material',
923
+ colorSpace: 'linear',
924
+ ...(ctx?.standardRootGuid === undefined ? {} : { parent: ctx.standardRootGuid }),
925
+ passes: [pass],
926
+ values,
927
+ };
928
+ }