@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,1731 @@
1
+ // parse-gltf.ts - JSON-side glTF importer entry (w17).
2
+ //
3
+ // w17 lands the full parseGltf main function plus parseGlb / toAssetPack
4
+ // siblings. The helpers exported here include both the surface API
5
+ // (parseGltf / parseGlb / toAssetPack) and a few mesh / scene utilities
6
+ // the main path uses internally.
7
+
8
+ import { mat4, quat, vec3 } from '@forgeax/engine-math';
9
+ import {
10
+ IMPORT_ERROR_HINTS,
11
+ ImportError,
12
+ MESH_MATERIAL_SLOT_SOURCE_OVERRIDE_PAYLOAD_SCHEMA,
13
+ type MeshMaterialSlotTopologyEntry,
14
+ reconcileMeshMaterialSlotTopology,
15
+ } from '@forgeax/engine-types';
16
+ import {
17
+ type AccessorJson,
18
+ type BufferViewJson,
19
+ COMPONENT_TYPE,
20
+ decodeAccessor,
21
+ } from './accessor/decode-accessor.js';
22
+ import { decodeColorAccessor } from './accessor/decode-color.js';
23
+ import { checkExtensions, type GltfExtensionsJson } from './check-extensions.js';
24
+ import { Base64DecodeError, dataUriBase64Payload, decodeBase64 } from './data-uri.js';
25
+ import { err, type GltfError, gltfErr, ok, type Result } from './errors.js';
26
+ import {
27
+ type GltfBufferViewDecodeCapability,
28
+ type MeshoptBufferViewJson,
29
+ projectMeshoptBufferViews,
30
+ } from './meshopt-decode.js';
31
+ import { type GltfAnimationClipRecord, parseAnimation } from './parse-animation.js';
32
+ import { parseGlbChunks } from './parse-glb-chunks.js';
33
+ import { parseGltfHeader } from './parse-gltf-header.js';
34
+ import { type GltfSkeletonRecord, parseSkin } from './parse-skin.js';
35
+ import {
36
+ type GltfDocItem,
37
+ type GltfMetaJson,
38
+ type GltfSubAssetEntry,
39
+ reimportReuseMeta,
40
+ } from './reimport-reuse-meta.js';
41
+ import type { GltfSourceKeyError } from './source-key.js';
42
+ import { type DecomposedTransform, decomposeNodeTransform } from './transform.js';
43
+
44
+ export interface MeshPrimitiveJson {
45
+ readonly attributes?: Record<string, number>;
46
+ readonly indices?: number;
47
+ readonly material?: number;
48
+ readonly mode?: number;
49
+ readonly targets?: ReadonlyArray<Record<string, number>>;
50
+ }
51
+
52
+ export interface MeshJson {
53
+ readonly name?: string;
54
+ readonly weights?: readonly number[];
55
+ readonly primitives: readonly MeshPrimitiveJson[];
56
+ }
57
+
58
+ // === Tier-B v1 IR (GltfDoc) ===
59
+ //
60
+ // Shape: a denormalised view of the parsed glTF JSON, post-validation,
61
+ // suitable for downstream `toAssetPack` and runtime AssetRegistry
62
+ // hand-off. Math types are POD (number tuples), no Vec3/Quat brand at
63
+ // the boundary (charter proposition 5).
64
+
65
+ interface InstancingAttributes {
66
+ readonly TRANSLATION?: number;
67
+ readonly ROTATION?: number;
68
+ readonly SCALE?: number;
69
+ }
70
+
71
+ /**
72
+ * Decode EXT_mesh_gpu_instancing attributes for a single node.
73
+ *
74
+ * Cross-validates that all present TRS accessor counts agree (spec
75
+ * MUST clause "Extending nodes with instance attributes"). Missing
76
+ * accessors fall back to identity (translation=(0,0,0),
77
+ * rotation=(0,0,0,1), scale=(1,1,1)). Composes N column-major mat4 via
78
+ * @forgeax/engine-math `mat4.compose`, packing them into a single
79
+ * Float32Array of length N*16. Pure function: no fs / fetch / throw.
80
+ */
81
+ function decodeNodeInstancing(
82
+ nodeIndex: number,
83
+ attributes: InstancingAttributes,
84
+ accessors: readonly AccessorJson[],
85
+ bufferViews: readonly BufferViewJson[],
86
+ buffers: readonly Uint8Array[],
87
+ ): Result<NodeInstancingIr, GltfError> {
88
+ const tIdx = attributes.TRANSLATION;
89
+ const rIdx = attributes.ROTATION;
90
+ const sIdx = attributes.SCALE;
91
+
92
+ let count: number | undefined;
93
+ const setOrCheck = (label: 'TRANSLATION' | 'ROTATION' | 'SCALE', n: number): GltfError | null => {
94
+ if (count === undefined) {
95
+ count = n;
96
+ return null;
97
+ }
98
+ if (n !== count) {
99
+ return gltfErr('gltf-instancing-count-mismatch', {
100
+ nodeIndex,
101
+ accessor: label,
102
+ expectedCount: count,
103
+ actualCount: n,
104
+ });
105
+ }
106
+ return null;
107
+ };
108
+
109
+ let tValues: Float32Array | undefined;
110
+ if (tIdx !== undefined) {
111
+ const acc = accessors[tIdx];
112
+ if (acc === undefined) return err(unknownAccessor(tIdx));
113
+ const e = setOrCheck('TRANSLATION', acc.count);
114
+ if (e !== null) return err(e);
115
+ const decoded = decodeAttributeAccessor(tIdx, acc, bufferViews, buffers);
116
+ if (!decoded.ok) return err(decoded.error);
117
+ tValues = decoded.value;
118
+ }
119
+ let rValues: Float32Array | undefined;
120
+ if (rIdx !== undefined) {
121
+ const acc = accessors[rIdx];
122
+ if (acc === undefined) return err(unknownAccessor(rIdx));
123
+ const e = setOrCheck('ROTATION', acc.count);
124
+ if (e !== null) return err(e);
125
+ const decoded = decodeAttributeAccessor(rIdx, acc, bufferViews, buffers);
126
+ if (!decoded.ok) return err(decoded.error);
127
+ rValues = decoded.value;
128
+ }
129
+ let sValues: Float32Array | undefined;
130
+ if (sIdx !== undefined) {
131
+ const acc = accessors[sIdx];
132
+ if (acc === undefined) return err(unknownAccessor(sIdx));
133
+ const e = setOrCheck('SCALE', acc.count);
134
+ if (e !== null) return err(e);
135
+ const decoded = decodeAttributeAccessor(sIdx, acc, bufferViews, buffers);
136
+ if (!decoded.ok) return err(decoded.error);
137
+ sValues = decoded.value;
138
+ }
139
+
140
+ const n = count ?? 0;
141
+ const transforms = new Float32Array(n * 16);
142
+ const tmp = mat4.create();
143
+ const tv = vec3.create();
144
+ const rv = quat.create();
145
+ rv[3] = 1;
146
+ const sv = vec3.create(1, 1, 1);
147
+ for (let i = 0; i < n; i++) {
148
+ if (tValues !== undefined) {
149
+ tv[0] = tValues[i * 3] ?? 0;
150
+ tv[1] = tValues[i * 3 + 1] ?? 0;
151
+ tv[2] = tValues[i * 3 + 2] ?? 0;
152
+ } else {
153
+ tv[0] = 0;
154
+ tv[1] = 0;
155
+ tv[2] = 0;
156
+ }
157
+ if (rValues !== undefined) {
158
+ rv[0] = rValues[i * 4] ?? 0;
159
+ rv[1] = rValues[i * 4 + 1] ?? 0;
160
+ rv[2] = rValues[i * 4 + 2] ?? 0;
161
+ rv[3] = rValues[i * 4 + 3] ?? 1;
162
+ } else {
163
+ rv[0] = 0;
164
+ rv[1] = 0;
165
+ rv[2] = 0;
166
+ rv[3] = 1;
167
+ }
168
+ if (sValues !== undefined) {
169
+ sv[0] = sValues[i * 3] ?? 1;
170
+ sv[1] = sValues[i * 3 + 1] ?? 1;
171
+ sv[2] = sValues[i * 3 + 2] ?? 1;
172
+ } else {
173
+ sv[0] = 1;
174
+ sv[1] = 1;
175
+ sv[2] = 1;
176
+ }
177
+ mat4.compose(tmp, tv, rv, sv);
178
+ for (let k = 0; k < 16; k++) {
179
+ transforms[i * 16 + k] = tmp[k] ?? 0;
180
+ }
181
+ }
182
+
183
+ return ok({ count: n, transforms });
184
+ }
185
+
186
+ function unknownAccessor(accessorIndex: number): GltfError {
187
+ return gltfErr('gltf-accessor-type-mismatch', {
188
+ accessorIndex,
189
+ reason: 'unknownComponentType',
190
+ });
191
+ }
192
+
193
+ function decodeAttributeAccessor(
194
+ accessorIndex: number,
195
+ accessor: AccessorJson,
196
+ bufferViews: readonly BufferViewJson[],
197
+ buffers: readonly Uint8Array[],
198
+ ): Result<Float32Array, GltfError> {
199
+ const view = bufferViews[accessor.bufferView ?? -1];
200
+ if (view === undefined) return err(unknownAccessor(accessorIndex));
201
+ const buf = buffers[view.buffer];
202
+ if (buf === undefined) return err(unknownAccessor(accessorIndex));
203
+ const decoded = decodeAccessor({
204
+ accessorIndex,
205
+ accessor,
206
+ bufferView: view,
207
+ buffer: buf,
208
+ role: 'attribute',
209
+ });
210
+ if (!decoded.ok) return err(decoded.error);
211
+ if (decoded.value.kind !== 'f32') return err(unknownAccessor(accessorIndex));
212
+ return ok(decoded.value.data);
213
+ }
214
+
215
+ export interface GltfMeshIr {
216
+ readonly name?: string;
217
+ readonly positions: Float32Array;
218
+ readonly normals?: Float32Array;
219
+ readonly texcoord0?: Float32Array;
220
+ /** TEXCOORD_1 per-vertex UV set 1 (Float32Array, 2 per vertex). feat-20260629-multi-uv-set-support m1-w2. */
221
+ readonly texcoord1?: Float32Array;
222
+ /** TEXCOORD_2 per-vertex UV set 2 (Float32Array, 2 per vertex). */
223
+ readonly texcoord2?: Float32Array;
224
+ /** TEXCOORD_3 per-vertex UV set 3 (Float32Array, 2 per vertex). */
225
+ readonly texcoord3?: Float32Array;
226
+ /** TEXCOORD_4 per-vertex UV set 4 (Float32Array, 2 per vertex). */
227
+ readonly texcoord4?: Float32Array;
228
+ /** TEXCOORD_5 per-vertex UV set 5 (Float32Array, 2 per vertex). */
229
+ readonly texcoord5?: Float32Array;
230
+ /** TEXCOORD_6 per-vertex UV set 6 (Float32Array, 2 per vertex). */
231
+ readonly texcoord6?: Float32Array;
232
+ /** TEXCOORD_7 per-vertex UV set 7 (Float32Array, 2 per vertex). */
233
+ readonly texcoord7?: Float32Array;
234
+ readonly tangents?: Float32Array;
235
+ /** COLOR_0 importer carrier; bridge projects it to the canonical color attribute. */
236
+ readonly colors0?: Float32Array;
237
+ /** JOINTS_0 per-vertex joint indices (Uint16Array, 4 per vertex). UBYTE source is width-converted to U16 at parse time (D-3). */
238
+ readonly joints0?: Uint16Array;
239
+ /** WEIGHTS_0 per-vertex skin weights (Float32Array, 4 per vertex). */
240
+ readonly weights0?: Float32Array;
241
+ readonly morphTargets?: readonly {
242
+ readonly position?: Float32Array;
243
+ readonly normal?: Float32Array;
244
+ readonly tangent?: Float32Array;
245
+ }[];
246
+ readonly morphWeights?: Float32Array;
247
+ /**
248
+ * Optional per-glTF-spec: when omitted, primitive declares non-indexed
249
+ * geometry (vertex buffer is consumed in vertex order, every 3 verts =
250
+ * 1 triangle for triangle-list). bridge.ts handles undefined by routing
251
+ * MeshAsset.indices to undefined and submesh.indexCount=0 (vertexCount
252
+ * carries the draw count for `pass.draw(vertexCount)`).
253
+ * bug-20260612 hello-skin visual layered gate: prior shape coerced
254
+ * undefined into Uint16Array(0) and tripped MeshAsset.indices !==
255
+ * undefined branches downstream into drawIndexed(0).
256
+ * U16 (incl. widened U8) or U32 — width preserved from the source accessor;
257
+ * bridge.ts narrows U32 to U16 when the merged maxIndex fits.
258
+ */
259
+ readonly indices?: Uint16Array | Uint32Array;
260
+ readonly materialIndex: number | null;
261
+ /**
262
+ * Owning glTF mesh index in the original document (`gltf.meshes[meshIndex]`).
263
+ * After OOS-7 flattening, multiple GltfMeshIr entries may share the same
264
+ * meshIndex (one per primitive). Bridge layer uses this to filter material
265
+ * collection per node, fixing the verify-r1 charter-defect where multi
266
+ * glTF-mesh documents would silently misalign materials across nodes.
267
+ */
268
+ readonly meshIndex: number;
269
+ }
270
+
271
+ export interface GltfTextureIr {
272
+ readonly sampler?: number;
273
+ readonly source: number;
274
+ readonly name?: string;
275
+ }
276
+
277
+ export interface GltfTextureTransformIr {
278
+ readonly offset?: readonly [number, number];
279
+ readonly rotation?: number;
280
+ readonly scale?: readonly [number, number];
281
+ }
282
+
283
+ export interface GltfTextureInfoIr {
284
+ readonly texture: number;
285
+ readonly sampler?: number;
286
+ readonly texCoord?: number;
287
+ readonly transform?: GltfTextureTransformIr;
288
+ }
289
+
290
+ export interface GltfNormalTextureInfoIr extends GltfTextureInfoIr {
291
+ readonly scale?: number;
292
+ }
293
+
294
+ export interface GltfOcclusionTextureInfoIr extends GltfTextureInfoIr {
295
+ readonly strength?: number;
296
+ }
297
+
298
+ export interface GltfImageIr {
299
+ readonly uri?: string;
300
+ readonly mimeType?: string;
301
+ readonly bufferView?: number;
302
+ readonly name?: string;
303
+ }
304
+
305
+ export interface GltfSamplerIr {
306
+ readonly magFilter?: number;
307
+ readonly minFilter?: number;
308
+ readonly wrapS: number;
309
+ readonly wrapT: number;
310
+ readonly name?: string;
311
+ }
312
+
313
+ export interface GltfMaterialIr {
314
+ readonly name?: string;
315
+ readonly baseColorFactor: readonly [number, number, number, number];
316
+ /** glTF emissiveFactor; omitted means the glTF default [0, 0, 0]. */
317
+ readonly emissiveFactor?: readonly [number, number, number];
318
+ readonly baseColorTexture?: GltfTextureInfoIr | number;
319
+ /** Texture binding for the emissive map. */
320
+ readonly emissiveTexture?: GltfTextureInfoIr | number;
321
+ readonly metallicFactor: number;
322
+ readonly roughnessFactor: number;
323
+ readonly metallicRoughnessTexture?: GltfTextureInfoIr | number;
324
+ readonly normalTexture?: GltfNormalTextureInfoIr | number;
325
+ readonly occlusionTexture?: GltfOcclusionTextureInfoIr | number;
326
+ /**
327
+ * glTF `alphaMode` (`'OPAQUE'` | `'MASK'` | `'BLEND'`). Absent -> `'OPAQUE'`
328
+ * (glTF spec default). Drives the bridge's transparent / alpha-test routing.
329
+ */
330
+ readonly alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND';
331
+ /** glTF `alphaCutoff` (MASK-mode threshold; glTF spec default 0.5). */
332
+ readonly alphaCutoff?: number;
333
+ /**
334
+ * glTF `doubleSided`. Absent -> false (the glTF spec default). The bridge
335
+ * maps this to a no-cull material pass so reverse-facing glass and cards
336
+ * remain visible without changing their authored winding.
337
+ */
338
+ readonly doubleSided?: boolean;
339
+ /**
340
+ * baseColorTexture UV-set index (glTF `baseColorTexture.texCoord`). Absent
341
+ * -> 0. Selects which interleaved UV set the base-color sampler reads.
342
+ */
343
+ readonly baseColorTexCoord?: number;
344
+ }
345
+
346
+ export interface NodeInstancingIr {
347
+ /** Number of instances. All TRS attribute accessors share this count. */
348
+ readonly count: number;
349
+ /** N column-major mat4 transforms packed into one Float32Array (length = N*16). */
350
+ readonly transforms: Float32Array;
351
+ }
352
+
353
+ export type GltfPunctualLightType = 'directional' | 'point' | 'spot';
354
+
355
+ export interface GltfPunctualLightIr {
356
+ readonly type: GltfPunctualLightType;
357
+ readonly color: readonly [number, number, number];
358
+ readonly intensity: number;
359
+ readonly range?: number;
360
+ readonly spot?: {
361
+ readonly innerConeAngle: number;
362
+ readonly outerConeAngle: number;
363
+ };
364
+ }
365
+
366
+ export interface GltfNodeIr {
367
+ readonly name?: string;
368
+ readonly transform: DecomposedTransform;
369
+ readonly meshIndex: number | null;
370
+ /** Node-authored morph weights override mesh defaults at instantiation. */
371
+ readonly morphWeights?: Float32Array;
372
+ /** Valid when node carries `skin` reference. Null when no skin. */
373
+ readonly skinIndex: number | null;
374
+ readonly children: readonly number[];
375
+ /** Present when node carries EXT_mesh_gpu_instancing (feat-20260518). */
376
+ readonly instancing?: NodeInstancingIr;
377
+ /** glTF camera index. Null when the node does not reference a camera. */
378
+ readonly camera: number | null;
379
+ /** KHR_lights_punctual light index. Null when the node has no light. */
380
+ readonly lightIndex?: number | null;
381
+ }
382
+
383
+ export interface GltfSceneIr {
384
+ readonly name?: string;
385
+ readonly nodes: readonly number[];
386
+ }
387
+
388
+ export interface GltfDiagnosticsIr {
389
+ readonly nodeNames: readonly string[];
390
+ readonly unsupportedExtensions: readonly string[];
391
+ readonly matrixTrsCoexistNodes: readonly number[];
392
+ }
393
+
394
+ export interface GltfDoc {
395
+ readonly meshes: readonly GltfMeshIr[];
396
+ readonly materials: readonly GltfMaterialIr[];
397
+ readonly nodes: readonly GltfNodeIr[];
398
+ readonly scenes: readonly GltfSceneIr[];
399
+ readonly textures: readonly GltfTextureIr[] | undefined;
400
+ readonly images: readonly GltfImageIr[] | undefined;
401
+ readonly samplers: readonly GltfSamplerIr[] | undefined;
402
+ readonly skeletons: readonly GltfSkeletonRecord[];
403
+ readonly animationClips: readonly GltfAnimationClipRecord[];
404
+ readonly defaultSceneIndex: number;
405
+ readonly diagnostics: GltfDiagnosticsIr;
406
+ /** KHR_lights_punctual author facts, kept in source units for the bridge. */
407
+ readonly lights?: readonly GltfPunctualLightIr[];
408
+ /** Raw extension projection retained for bridge-only hand-constructed docs. */
409
+ readonly extensions?: {
410
+ readonly KHR_lights_punctual?: {
411
+ readonly lights?: readonly GltfPunctualLightIr[];
412
+ };
413
+ };
414
+ /**
415
+ * Original glTF-mesh-index -> number of primitives that mesh expanded into
416
+ * (feat-20260608 round-2). parseGltf flattens N glTF meshes with M_i
417
+ * primitives into sum(M_i) GltfMeshIr entries; this map preserves the M_i so
418
+ * downstream consumers (gltfDocToSceneAsset's `meshPrimitiveCount` param)
419
+ * can map a glTF mesh index back to the flat-GltfMeshIr offset range without
420
+ * re-parsing JSON.
421
+ *
422
+ * Optional for backwards compatibility with hand-constructed test
423
+ * fixtures; producers from parseGltf / parseGlb always populate it.
424
+ */
425
+ readonly meshPrimitiveCount?: ReadonlyMap<number, number>;
426
+ }
427
+
428
+ interface BuffersJson {
429
+ readonly byteLength: number;
430
+ readonly uri?: string;
431
+ }
432
+
433
+ interface TextureTransformJson {
434
+ readonly offset?: readonly number[];
435
+ readonly rotation?: number;
436
+ readonly scale?: readonly number[];
437
+ readonly texCoord?: number;
438
+ }
439
+
440
+ interface TextureInfoJson {
441
+ readonly index: number;
442
+ readonly texCoord?: number;
443
+ readonly extensions?: {
444
+ readonly KHR_texture_transform?: TextureTransformJson;
445
+ };
446
+ }
447
+
448
+ interface NormalTextureInfoJson extends TextureInfoJson {
449
+ readonly scale?: number;
450
+ }
451
+
452
+ interface OcclusionTextureInfoJson extends TextureInfoJson {
453
+ readonly strength?: number;
454
+ }
455
+
456
+ interface RootGltfJson extends GltfExtensionsJson {
457
+ readonly asset?: { readonly version?: string };
458
+ readonly scene?: number;
459
+ readonly scenes?: ReadonlyArray<{ readonly name?: string; readonly nodes?: readonly number[] }>;
460
+ readonly nodes?: ReadonlyArray<{
461
+ readonly name?: string;
462
+ readonly mesh?: number;
463
+ readonly skin?: number;
464
+ readonly camera?: number;
465
+ readonly children?: readonly number[];
466
+ readonly matrix?: readonly number[];
467
+ readonly translation?: readonly number[];
468
+ readonly rotation?: readonly number[];
469
+ readonly scale?: readonly number[];
470
+ readonly weights?: readonly number[];
471
+ readonly extensions?: {
472
+ readonly KHR_lights_punctual?: {
473
+ readonly light?: number;
474
+ };
475
+ readonly EXT_mesh_gpu_instancing?: {
476
+ readonly attributes?: {
477
+ readonly TRANSLATION?: number;
478
+ readonly ROTATION?: number;
479
+ readonly SCALE?: number;
480
+ };
481
+ };
482
+ };
483
+ }>;
484
+ readonly extensions?: {
485
+ readonly KHR_lights_punctual?: {
486
+ readonly lights?: ReadonlyArray<{
487
+ readonly type?: string;
488
+ readonly color?: readonly number[];
489
+ readonly intensity?: number;
490
+ readonly range?: number;
491
+ readonly spot?: {
492
+ readonly innerConeAngle?: number;
493
+ readonly outerConeAngle?: number;
494
+ };
495
+ }>;
496
+ };
497
+ };
498
+ readonly skins?: ReadonlyArray<{
499
+ readonly name?: string;
500
+ readonly joints: readonly number[];
501
+ readonly inverseBindMatrices?: number;
502
+ }>;
503
+ readonly meshes?: readonly MeshJson[];
504
+ readonly materials?: ReadonlyArray<{
505
+ readonly name?: string;
506
+ readonly pbrMetallicRoughness?: {
507
+ readonly baseColorFactor?: readonly number[];
508
+ readonly baseColorTexture?: TextureInfoJson;
509
+ readonly metallicFactor?: number;
510
+ readonly roughnessFactor?: number;
511
+ readonly metallicRoughnessTexture?: TextureInfoJson;
512
+ };
513
+ readonly normalTexture?: NormalTextureInfoJson;
514
+ readonly emissiveFactor?: readonly number[];
515
+ readonly emissiveTexture?: TextureInfoJson;
516
+ readonly occlusionTexture?: OcclusionTextureInfoJson;
517
+ readonly alphaMode?: string;
518
+ readonly alphaCutoff?: number;
519
+ readonly doubleSided?: boolean;
520
+ }>;
521
+ readonly textures?: ReadonlyArray<{
522
+ readonly sampler?: number;
523
+ readonly source?: number;
524
+ readonly name?: string;
525
+ }>;
526
+ readonly images?: ReadonlyArray<{
527
+ readonly uri?: string;
528
+ readonly mimeType?: string;
529
+ readonly bufferView?: number;
530
+ readonly name?: string;
531
+ }>;
532
+ readonly samplers?: ReadonlyArray<{
533
+ readonly magFilter?: number;
534
+ readonly minFilter?: number;
535
+ readonly wrapS?: number;
536
+ readonly wrapT?: number;
537
+ readonly name?: string;
538
+ }>;
539
+ readonly accessors?: readonly AccessorJson[];
540
+ readonly bufferViews?: readonly BufferViewJson[];
541
+ readonly buffers?: readonly BuffersJson[];
542
+ readonly animations?: ReadonlyArray<{
543
+ readonly name?: string;
544
+ readonly channels: ReadonlyArray<{
545
+ readonly sampler: number;
546
+ readonly target: {
547
+ readonly node?: number;
548
+ readonly path: string;
549
+ };
550
+ }>;
551
+ readonly samplers: ReadonlyArray<{
552
+ readonly input: number;
553
+ readonly output: number;
554
+ readonly interpolation?: string;
555
+ }>;
556
+ }>;
557
+ }
558
+
559
+ function tuple2(values: readonly number[] | undefined): readonly [number, number] | undefined {
560
+ if (values === undefined || values.length < 2) return undefined;
561
+ return [values[0] ?? 0, values[1] ?? 0];
562
+ }
563
+
564
+ function parseTextureInfo(
565
+ info: TextureInfoJson,
566
+ textures: readonly GltfTextureIr[],
567
+ ): GltfTextureInfoIr {
568
+ const transformJson = info.extensions?.KHR_texture_transform;
569
+ const offset = tuple2(transformJson?.offset);
570
+ const scale = tuple2(transformJson?.scale);
571
+ const transform =
572
+ offset === undefined && transformJson?.rotation === undefined && scale === undefined
573
+ ? undefined
574
+ : {
575
+ ...(offset === undefined ? {} : { offset }),
576
+ ...(transformJson?.rotation === undefined ? {} : { rotation: transformJson.rotation }),
577
+ ...(scale === undefined ? {} : { scale }),
578
+ };
579
+ const sampler = textures[info.index]?.sampler;
580
+ return {
581
+ texture: info.index,
582
+ ...(sampler === undefined ? {} : { sampler }),
583
+ ...(info.texCoord === undefined && transformJson?.texCoord === undefined
584
+ ? {}
585
+ : { texCoord: transformJson?.texCoord ?? info.texCoord }),
586
+ ...(transform === undefined ? {} : { transform }),
587
+ };
588
+ }
589
+
590
+ export type ExternalLoader = (uri: string) => Promise<ArrayBuffer>;
591
+
592
+ async function resolveBuffer(
593
+ buf: BuffersJson,
594
+ externalLoader: ExternalLoader,
595
+ binChunk: Uint8Array | undefined,
596
+ ): Promise<Uint8Array> {
597
+ if (buf.uri === undefined) {
598
+ // GLB BIN chunk slot.
599
+ if (binChunk === undefined) {
600
+ throw new Error('parseGltf: buffer 0 has no uri and no GLB BIN chunk available');
601
+ }
602
+ return binChunk;
603
+ }
604
+ const dataPayload = dataUriBase64Payload(buf.uri);
605
+ if (dataPayload !== undefined) {
606
+ return decodeBase64(dataPayload);
607
+ }
608
+ const arrayBuffer = await externalLoader(buf.uri);
609
+ return new Uint8Array(arrayBuffer);
610
+ }
611
+
612
+ interface ParseGltfInternalsContext {
613
+ readonly externalLoader: ExternalLoader;
614
+ readonly binChunk?: Uint8Array;
615
+ readonly filePath: string;
616
+ readonly meshopt?: GltfBufferViewDecodeCapability;
617
+ }
618
+
619
+ type GltfParseError = GltfError | ImportError;
620
+
621
+ function invalidBufferDataUriError(
622
+ filePath: string,
623
+ bufferIndex: number,
624
+ cause: Base64DecodeError,
625
+ ): ImportError {
626
+ return new ImportError({
627
+ code: 'source-validation-failed',
628
+ expected: `glTF buffer ${bufferIndex} data URI to contain a valid base64 payload`,
629
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
630
+ detail: {
631
+ diagnostics: [
632
+ {
633
+ code: 'gltf-buffer-data-uri-invalid',
634
+ severity: 'error',
635
+ sourcePath: filePath,
636
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
637
+ rule: 'gltf-buffer-data-uri-base64',
638
+ expected: 'a valid base64 payload after ;base64,',
639
+ actual: `buffer ${bufferIndex}: ${cause.message}`,
640
+ hint: 'repair the buffer data URI or provide a valid external .bin sibling',
641
+ },
642
+ ],
643
+ },
644
+ });
645
+ }
646
+
647
+ export interface GltfParseOptions {
648
+ readonly meshopt?: GltfBufferViewDecodeCapability;
649
+ }
650
+
651
+ function parsePunctualLights(
652
+ json: RootGltfJson,
653
+ filePath: string,
654
+ ): Result<readonly GltfPunctualLightIr[], GltfError> {
655
+ const source = json.extensions?.KHR_lights_punctual?.lights ?? [];
656
+ const lights: GltfPunctualLightIr[] = [];
657
+ for (const light of source) {
658
+ const type = light?.type;
659
+ if (type !== 'directional' && type !== 'point' && type !== 'spot') {
660
+ return err(gltfErr('gltf-malformed-header', { filePath, byteOffset: 0 }));
661
+ }
662
+ const color = light.color ?? [1, 1, 1];
663
+ if (
664
+ color.length < 3 ||
665
+ color
666
+ .slice(0, 3)
667
+ .some(
668
+ (value) => typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1,
669
+ )
670
+ ) {
671
+ return err(gltfErr('gltf-malformed-header', { filePath, byteOffset: 0 }));
672
+ }
673
+ const intensity = light.intensity ?? 1;
674
+ if (typeof intensity !== 'number' || !Number.isFinite(intensity) || intensity < 0) {
675
+ return err(gltfErr('gltf-malformed-header', { filePath, byteOffset: 0 }));
676
+ }
677
+ const range = light.range;
678
+ if (
679
+ range !== undefined &&
680
+ (typeof range !== 'number' || !Number.isFinite(range) || range < 0)
681
+ ) {
682
+ return err(gltfErr('gltf-malformed-header', { filePath, byteOffset: 0 }));
683
+ }
684
+ let spot: GltfPunctualLightIr['spot'];
685
+ if (type === 'spot') {
686
+ const innerConeAngle = light.spot?.innerConeAngle ?? 0;
687
+ const outerConeAngle = light.spot?.outerConeAngle ?? Math.PI / 4;
688
+ if (
689
+ !Number.isFinite(innerConeAngle) ||
690
+ !Number.isFinite(outerConeAngle) ||
691
+ innerConeAngle < 0 ||
692
+ innerConeAngle >= outerConeAngle ||
693
+ outerConeAngle > Math.PI / 2
694
+ ) {
695
+ return err(gltfErr('gltf-malformed-header', { filePath, byteOffset: 0 }));
696
+ }
697
+ spot = { innerConeAngle, outerConeAngle };
698
+ }
699
+ lights.push({
700
+ type,
701
+ color: [color[0] ?? 1, color[1] ?? 1, color[2] ?? 1],
702
+ intensity,
703
+ ...(range === undefined || range === 0 ? {} : { range }),
704
+ ...(spot === undefined ? {} : { spot }),
705
+ });
706
+ }
707
+ return ok(lights);
708
+ }
709
+
710
+ async function parseGltfWithBin(
711
+ json: RootGltfJson,
712
+ ctx: ParseGltfInternalsContext,
713
+ ): Promise<Result<GltfDoc, GltfParseError>> {
714
+ const headerResult = parseGltfHeader(json, ctx.filePath);
715
+ if (!headerResult.ok) return err(headerResult.error);
716
+
717
+ const extResult = checkExtensions(json);
718
+ if (!extResult.ok) return err(extResult.error);
719
+ const unsupportedExtensions = extResult.value.unsupportedUsed;
720
+ const lightsResult = parsePunctualLights(json, ctx.filePath);
721
+ if (!lightsResult.ok) return err(lightsResult.error);
722
+
723
+ const meshesJson = json.meshes ?? [];
724
+
725
+ // Resolve buffers (data: URI / external / GLB BIN chunk).
726
+ const buffersJson = json.buffers ?? [];
727
+ const buffers: Uint8Array[] = [];
728
+ for (let i = 0; i < buffersJson.length; i++) {
729
+ const bufJson = buffersJson[i];
730
+ if (bufJson === undefined) continue;
731
+ try {
732
+ const bytes = await resolveBuffer(bufJson, ctx.externalLoader, ctx.binChunk);
733
+ buffers.push(bytes);
734
+ } catch (e) {
735
+ if (e instanceof Base64DecodeError) {
736
+ return err(invalidBufferDataUriError(ctx.filePath, i, e));
737
+ }
738
+ return err(
739
+ gltfErr('gltf-malformed-header', {
740
+ filePath: ctx.filePath,
741
+ byteOffset: 0,
742
+ }),
743
+ );
744
+ }
745
+ }
746
+
747
+ const accessors = json.accessors ?? [];
748
+ const rawBufferViews = json.bufferViews ?? [];
749
+ const projected = await projectMeshoptBufferViews(
750
+ rawBufferViews as readonly MeshoptBufferViewJson[],
751
+ buffers,
752
+ json.extensionsRequired ?? [],
753
+ ctx.meshopt,
754
+ );
755
+ if (!projected.ok) return err(projected.error);
756
+ const bufferViews = projected.value.bufferViews;
757
+ buffers.splice(0, buffers.length, ...projected.value.buffers);
758
+
759
+ const meshes: GltfMeshIr[] = [];
760
+ // feat-20260608 round-2: surface the original-glTF-mesh primitive counts so
761
+ // build-time consumers (gltfImporter scene arm) can hand them to
762
+ // gltfDocToSceneAsset's `meshPrimitiveCount` parameter without re-parsing
763
+ // the JSON. Without this, importGltf at gltf-importer.ts ~272 walks the
764
+ // already-flattened doc.meshes (one entry per primitive) and cannot
765
+ // reconstruct the gltfMesh -> primitiveCount map; the bridge then uses
766
+ // identity (gltfMeshIdx == flatIdx) and any multi-primitive .gltf imports
767
+ // misindex meshes after the first primitive.
768
+ const meshPrimitiveCount = new Map<number, number>();
769
+ for (let meshIndex = 0; meshIndex < meshesJson.length; meshIndex++) {
770
+ const meshJson = meshesJson[meshIndex];
771
+ if (meshJson === undefined) continue;
772
+ meshPrimitiveCount.set(meshIndex, meshJson.primitives.length);
773
+ for (const prim of meshJson.primitives) {
774
+ if (prim === undefined) continue;
775
+ const positionAccessorIndex = prim.attributes?.POSITION;
776
+ if (positionAccessorIndex === undefined) {
777
+ return err(
778
+ gltfErr('gltf-accessor-type-mismatch', {
779
+ accessorIndex: -1,
780
+ reason: 'unknownComponentType',
781
+ }),
782
+ );
783
+ }
784
+ const positionAccessor = accessors[positionAccessorIndex];
785
+ const positionBufferView = bufferViews[positionAccessor?.bufferView ?? -1];
786
+ if (positionAccessor === undefined || positionBufferView === undefined) {
787
+ return err(
788
+ gltfErr('gltf-buffer-out-of-bounds', {
789
+ accessor: positionAccessorIndex,
790
+ byteOffset: 0,
791
+ byteLength: 0,
792
+ bufferIndex: positionBufferView?.buffer ?? 0,
793
+ }),
794
+ );
795
+ }
796
+ const positionBuffer = buffers[positionBufferView.buffer];
797
+ if (positionBuffer === undefined) {
798
+ return err(
799
+ gltfErr('gltf-buffer-out-of-bounds', {
800
+ accessor: positionAccessorIndex,
801
+ byteOffset: positionBufferView.byteOffset ?? 0,
802
+ byteLength: positionBufferView.byteLength,
803
+ bufferIndex: positionBufferView.buffer,
804
+ }),
805
+ );
806
+ }
807
+ const positionDecoded = decodeAccessor({
808
+ accessorIndex: positionAccessorIndex,
809
+ accessor: positionAccessor,
810
+ bufferView: positionBufferView,
811
+ buffer: positionBuffer,
812
+ role: 'attribute',
813
+ });
814
+ if (!positionDecoded.ok) return err(positionDecoded.error);
815
+ if (positionDecoded.value.kind !== 'f32') {
816
+ return err(
817
+ gltfErr('gltf-accessor-type-mismatch', {
818
+ accessorIndex: positionAccessorIndex,
819
+ reason: 'unknownComponentType',
820
+ }),
821
+ );
822
+ }
823
+ const positionsDecoded = positionDecoded.value.data;
824
+ // Re-allocate over a fresh ArrayBuffer so the GltfMeshIr.positions type
825
+ // (`Float32Array<ArrayBuffer>`) holds without ArrayBufferLike leakage.
826
+ const positions = new Float32Array(positionsDecoded.length);
827
+ positions.set(positionsDecoded);
828
+
829
+ // Decode optional vertex attributes: NORMAL (VEC3), TEXCOORD_0 (VEC2),
830
+ // TANGENT (VEC4). Each uses the existing decodeAttributeAccessor helper.
831
+ // Missing attributes leave the GltfMeshIr field undefined.
832
+ const attrs = prim.attributes ?? {};
833
+
834
+ let colors0: Float32Array | undefined;
835
+ const colorIdx = attrs.COLOR_0;
836
+ if (colorIdx !== undefined) {
837
+ const colorAccessor = accessors[colorIdx];
838
+ const colorBufferView = bufferViews[colorAccessor?.bufferView ?? -1];
839
+ if (colorAccessor === undefined || colorBufferView === undefined) {
840
+ return err(
841
+ gltfErr('gltf-color-accessor-malformed', {
842
+ semantic: 'COLOR_0',
843
+ accessorIndex: colorIdx,
844
+ reason: 'reference',
845
+ }),
846
+ );
847
+ }
848
+ const colorBuffer = buffers[colorBufferView.buffer];
849
+ if (colorBuffer === undefined) {
850
+ return err(
851
+ gltfErr('gltf-color-accessor-malformed', {
852
+ semantic: 'COLOR_0',
853
+ accessorIndex: colorIdx,
854
+ reason: 'reference',
855
+ }),
856
+ );
857
+ }
858
+ const decoded = decodeColorAccessor({
859
+ accessorIndex: colorIdx,
860
+ accessor: colorAccessor,
861
+ bufferView: colorBufferView,
862
+ buffer: colorBuffer,
863
+ bufferIndex: colorBufferView.buffer,
864
+ semantic: 'COLOR_0',
865
+ });
866
+ if (!decoded.ok) return err(decoded.error);
867
+ if (decoded.value.length !== (positions.length / 3) * 4) {
868
+ return err(
869
+ gltfErr('gltf-color-accessor-malformed', {
870
+ semantic: 'COLOR_0',
871
+ accessorIndex: colorIdx,
872
+ reason: 'count',
873
+ }),
874
+ );
875
+ }
876
+ colors0 = decoded.value;
877
+ }
878
+
879
+ let normals: Float32Array | undefined;
880
+ const normalIdx = attrs.NORMAL;
881
+ if (normalIdx !== undefined) {
882
+ const acc = accessors[normalIdx];
883
+ if (acc !== undefined) {
884
+ const decoded = decodeAttributeAccessor(normalIdx, acc, bufferViews, buffers);
885
+ if (decoded.ok) {
886
+ const src = decoded.value;
887
+ const owned = new Float32Array(src.length);
888
+ owned.set(src);
889
+ normals = owned;
890
+ }
891
+ }
892
+ }
893
+
894
+ let texcoord0: Float32Array | undefined;
895
+ const texIdx = attrs.TEXCOORD_0;
896
+ if (texIdx !== undefined) {
897
+ const acc = accessors[texIdx];
898
+ if (acc !== undefined) {
899
+ const decoded = decodeAttributeAccessor(texIdx, acc, bufferViews, buffers);
900
+ if (decoded.ok) {
901
+ const src = decoded.value;
902
+ const owned = new Float32Array(src.length);
903
+ owned.set(src);
904
+ texcoord0 = owned;
905
+ }
906
+ }
907
+ }
908
+
909
+ // feat-20260629-multi-uv-set-support m1-w2: decode TEXCOORD_1..7
910
+ // using the same decodeAttributeAccessor→Float32Array pattern.
911
+ // Missing accessor → field stays undefined (no error, mirrors TEXCOORD_0).
912
+ // componentType non-FLOAT → decodeAttributeAccessor returns error already.
913
+ let texcoord1: Float32Array | undefined;
914
+ let texcoord2: Float32Array | undefined;
915
+ let texcoord3: Float32Array | undefined;
916
+ let texcoord4: Float32Array | undefined;
917
+ let texcoord5: Float32Array | undefined;
918
+ let texcoord6: Float32Array | undefined;
919
+ let texcoord7: Float32Array | undefined;
920
+ for (let k = 1; k <= 7; k++) {
921
+ const tcIdx = attrs[`TEXCOORD_${k}`];
922
+ if (tcIdx === undefined) continue;
923
+ const acc = accessors[tcIdx];
924
+ if (acc !== undefined) {
925
+ const decoded = decodeAttributeAccessor(tcIdx, acc, bufferViews, buffers);
926
+ if (decoded.ok) {
927
+ const src = decoded.value;
928
+ const owned = new Float32Array(src.length);
929
+ owned.set(src);
930
+ if (k === 1) texcoord1 = owned;
931
+ else if (k === 2) texcoord2 = owned;
932
+ else if (k === 3) texcoord3 = owned;
933
+ else if (k === 4) texcoord4 = owned;
934
+ else if (k === 5) texcoord5 = owned;
935
+ else if (k === 6) texcoord6 = owned;
936
+ else texcoord7 = owned;
937
+ }
938
+ }
939
+ }
940
+
941
+ let tangents: Float32Array | undefined;
942
+ const tanIdx = attrs.TANGENT;
943
+ if (tanIdx !== undefined) {
944
+ const acc = accessors[tanIdx];
945
+ if (acc !== undefined) {
946
+ const decoded = decodeAttributeAccessor(tanIdx, acc, bufferViews, buffers);
947
+ if (decoded.ok) {
948
+ const src = decoded.value;
949
+ const owned = new Float32Array(src.length);
950
+ owned.set(src);
951
+ tangents = owned;
952
+ }
953
+ }
954
+ }
955
+
956
+ const rawTargets = prim.targets ?? [];
957
+ for (const target of rawTargets) {
958
+ const colorTarget = target.COLOR_0;
959
+ if (colorTarget !== undefined) {
960
+ return err(
961
+ gltfErr('gltf-color-accessor-unsupported', {
962
+ semantic: 'COLOR_0',
963
+ accessorIndex: colorTarget,
964
+ reason: 'morph',
965
+ }),
966
+ );
967
+ }
968
+ }
969
+ const morphAttributeCount = rawTargets.reduce(
970
+ (count, target) =>
971
+ count +
972
+ (target.POSITION === undefined ? 0 : 1) +
973
+ (target.NORMAL === undefined ? 0 : 1) +
974
+ (target.TANGENT === undefined ? 0 : 1),
975
+ 0,
976
+ );
977
+ const morphError = (
978
+ reason:
979
+ | 'target-count-exceeded'
980
+ | 'attribute-count-exceeded'
981
+ | 'attribute-length-mismatch'
982
+ | 'weights-length-mismatch'
983
+ | 'sparse-or-unsupported-accessor',
984
+ ) =>
985
+ err(
986
+ gltfErr('gltf-morph-invalid', {
987
+ meshIndex,
988
+ primitiveIndex: meshJson.primitives.indexOf(prim),
989
+ reason,
990
+ targetCount: rawTargets.length,
991
+ attributeCount: morphAttributeCount,
992
+ vertexCount: positions.length / 3,
993
+ }),
994
+ );
995
+ if (rawTargets.length > 8) return morphError('target-count-exceeded');
996
+ if (morphAttributeCount > 8) return morphError('attribute-count-exceeded');
997
+
998
+ const morphTargets: {
999
+ readonly position?: Float32Array;
1000
+ readonly normal?: Float32Array;
1001
+ readonly tangent?: Float32Array;
1002
+ }[] = [];
1003
+ for (const target of rawTargets) {
1004
+ const output: {
1005
+ position?: Float32Array;
1006
+ normal?: Float32Array;
1007
+ tangent?: Float32Array;
1008
+ } = {};
1009
+ for (const [key, accessorIndex] of Object.entries(target)) {
1010
+ if (key !== 'POSITION' && key !== 'NORMAL' && key !== 'TANGENT') continue;
1011
+ const targetAccessor = accessors[accessorIndex];
1012
+ if (targetAccessor === undefined) return morphError('attribute-length-mismatch');
1013
+ const decoded = decodeAttributeAccessor(
1014
+ accessorIndex,
1015
+ targetAccessor,
1016
+ bufferViews,
1017
+ buffers,
1018
+ );
1019
+ if (!decoded.ok) {
1020
+ return morphError(
1021
+ decoded.error.code === 'gltf-accessor-type-mismatch'
1022
+ ? 'sparse-or-unsupported-accessor'
1023
+ : 'attribute-length-mismatch',
1024
+ );
1025
+ }
1026
+ const expected = (positions.length / 3) * (key === 'TANGENT' ? 4 : 3);
1027
+ if (decoded.value.length !== expected) return morphError('attribute-length-mismatch');
1028
+ const owned = new Float32Array(decoded.value.length);
1029
+ owned.set(decoded.value);
1030
+ if (key === 'POSITION') output.position = owned;
1031
+ else if (key === 'NORMAL') output.normal = owned;
1032
+ else output.tangent = owned;
1033
+ }
1034
+ morphTargets.push(output);
1035
+ }
1036
+ const meshWeights = meshJson.weights;
1037
+ if (meshWeights !== undefined && meshWeights.length !== rawTargets.length) {
1038
+ return morphError('weights-length-mismatch');
1039
+ }
1040
+
1041
+ // JOINTS_0 / WEIGHTS_0 (feat-20260611 M1 w1): paired skinning attributes.
1042
+ // glTF 2.0 spec section 3.7.2.1 requires the pair to appear together for
1043
+ // any skinned primitive; lone presence -> gltf-skin-attr-asymmetric.
1044
+ // JOINTS_0 componentType: UBYTE(5121) widen->U16 via decodeAccessor
1045
+ // role='joints' (D-3 width-convert at parse), or USHORT(5123) standard
1046
+ // U16 path. WEIGHTS_0 componentType: FLOAT(5126) standard F32 path.
1047
+ const jointsIdx = attrs.JOINTS_0;
1048
+ const weightsIdx = attrs.WEIGHTS_0;
1049
+ const hasJoints = jointsIdx !== undefined;
1050
+ const hasWeights = weightsIdx !== undefined;
1051
+ if (hasJoints !== hasWeights) {
1052
+ const primitiveIndex = meshJson.primitives.indexOf(prim);
1053
+ return err(
1054
+ gltfErr('gltf-skin-attr-asymmetric', {
1055
+ meshIndex,
1056
+ primitiveIndex,
1057
+ hasJoints,
1058
+ hasWeights,
1059
+ }),
1060
+ );
1061
+ }
1062
+ let joints0: Uint16Array | undefined;
1063
+ let weights0: Float32Array | undefined;
1064
+ if (hasJoints && hasWeights) {
1065
+ const jointsAccessor = accessors[jointsIdx as number];
1066
+ const jointsBufferView = bufferViews[jointsAccessor?.bufferView ?? -1];
1067
+ if (jointsAccessor === undefined || jointsBufferView === undefined) {
1068
+ return err(unknownAccessor(jointsIdx as number));
1069
+ }
1070
+ const jointsBuffer = buffers[jointsBufferView.buffer];
1071
+ if (jointsBuffer === undefined) return err(unknownAccessor(jointsIdx as number));
1072
+ const jointsDecoded = decodeAccessor({
1073
+ accessorIndex: jointsIdx as number,
1074
+ accessor: jointsAccessor,
1075
+ bufferView: jointsBufferView,
1076
+ buffer: jointsBuffer,
1077
+ role: 'joints',
1078
+ });
1079
+ if (!jointsDecoded.ok) return err(jointsDecoded.error);
1080
+ if (jointsDecoded.value.kind !== 'u16') {
1081
+ return err(
1082
+ gltfErr('gltf-accessor-type-mismatch', {
1083
+ accessorIndex: jointsIdx as number,
1084
+ reason: 'unknownComponentType',
1085
+ }),
1086
+ );
1087
+ }
1088
+ const src = jointsDecoded.value.data;
1089
+ const owned = new Uint16Array(src.length);
1090
+ owned.set(src);
1091
+ joints0 = owned;
1092
+
1093
+ const weightsAccessor = accessors[weightsIdx as number];
1094
+ const weightsBufferView = bufferViews[weightsAccessor?.bufferView ?? -1];
1095
+ if (weightsAccessor === undefined || weightsBufferView === undefined) {
1096
+ return err(unknownAccessor(weightsIdx as number));
1097
+ }
1098
+ const weightsBuffer = buffers[weightsBufferView.buffer];
1099
+ if (weightsBuffer === undefined) return err(unknownAccessor(weightsIdx as number));
1100
+ const weightsDecoded = decodeAccessor({
1101
+ accessorIndex: weightsIdx as number,
1102
+ accessor: weightsAccessor,
1103
+ bufferView: weightsBufferView,
1104
+ buffer: weightsBuffer,
1105
+ role: 'attribute',
1106
+ });
1107
+ if (!weightsDecoded.ok) return err(weightsDecoded.error);
1108
+ if (weightsDecoded.value.kind !== 'f32') {
1109
+ return err(
1110
+ gltfErr('gltf-accessor-type-mismatch', {
1111
+ accessorIndex: weightsIdx as number,
1112
+ reason: 'unknownComponentType',
1113
+ }),
1114
+ );
1115
+ }
1116
+ const wsrc = weightsDecoded.value.data;
1117
+ const wowned = new Float32Array(wsrc.length);
1118
+ wowned.set(wsrc);
1119
+ weights0 = wowned;
1120
+ }
1121
+
1122
+ // bug-20260612 hello-skin visual layered gate: glTF spec marks
1123
+ // primitive.indices optional; undefined means non-indexed geometry.
1124
+ // Leave indices undefined here; bridge.ts handles the non-indexed
1125
+ // path by routing MeshAsset.indices to undefined.
1126
+ let indices: Uint16Array | Uint32Array | undefined;
1127
+ if (prim.indices !== undefined) {
1128
+ const indexAccessor = accessors[prim.indices];
1129
+ const indexBufferView = bufferViews[indexAccessor?.bufferView ?? -1];
1130
+ if (indexAccessor === undefined || indexBufferView === undefined) {
1131
+ return err(
1132
+ gltfErr('gltf-buffer-out-of-bounds', {
1133
+ accessor: prim.indices,
1134
+ byteOffset: 0,
1135
+ byteLength: 0,
1136
+ bufferIndex: indexBufferView?.buffer ?? 0,
1137
+ }),
1138
+ );
1139
+ }
1140
+ const indexBuffer = buffers[indexBufferView.buffer];
1141
+ if (indexBuffer === undefined) {
1142
+ return err(
1143
+ gltfErr('gltf-buffer-out-of-bounds', {
1144
+ accessor: prim.indices,
1145
+ byteOffset: indexBufferView.byteOffset ?? 0,
1146
+ byteLength: indexBufferView.byteLength,
1147
+ bufferIndex: indexBufferView.buffer,
1148
+ }),
1149
+ );
1150
+ }
1151
+ const indexDecoded = decodeAccessor({
1152
+ accessorIndex: prim.indices,
1153
+ accessor: indexAccessor,
1154
+ bufferView: indexBufferView,
1155
+ buffer: indexBuffer,
1156
+ role: 'indices',
1157
+ });
1158
+ if (!indexDecoded.ok) return err(indexDecoded.error);
1159
+ if (indexDecoded.value.kind === 'u16') {
1160
+ // Re-allocate over a fresh ArrayBuffer so the GltfMeshIr.indices type
1161
+ // (`Uint16Array<ArrayBuffer>`) holds without the underlying
1162
+ // SharedArrayBuffer-friendly ArrayBufferLike slot leaking through.
1163
+ const src = indexDecoded.value.data;
1164
+ const owned = new Uint16Array(src.length);
1165
+ owned.set(src);
1166
+ indices = owned;
1167
+ } else if (indexDecoded.value.kind === 'u32') {
1168
+ // U32 indices ride through end-to-end: MeshAsset.indices is
1169
+ // `Uint16Array | Uint32Array`, mesh-bin serializes iwidth=4, and
1170
+ // the GPU runtime auto-selects 'uint32' via `instanceof Uint32Array`
1171
+ // (createRenderer.ts). bridge.ts merges by value, so small meshes
1172
+ // whose maxIndex < 65536 are losslessly narrowed to Uint16 there.
1173
+ // Re-allocate over a fresh ArrayBuffer for the same reason as u16.
1174
+ const src = indexDecoded.value.data;
1175
+ const owned = new Uint32Array(src.length);
1176
+ owned.set(src);
1177
+ indices = owned;
1178
+ } else {
1179
+ return err(
1180
+ gltfErr('gltf-accessor-type-mismatch', {
1181
+ accessorIndex: prim.indices,
1182
+ reason: 'unknownComponentType',
1183
+ }),
1184
+ );
1185
+ }
1186
+ }
1187
+
1188
+ const meshIr: GltfMeshIr = {
1189
+ ...(meshJson.name === undefined ? {} : { name: meshJson.name }),
1190
+ positions,
1191
+ ...(normals === undefined ? {} : { normals }),
1192
+ ...(texcoord0 === undefined ? {} : { texcoord0 }),
1193
+ ...(texcoord1 === undefined ? {} : { texcoord1 }),
1194
+ ...(texcoord2 === undefined ? {} : { texcoord2 }),
1195
+ ...(texcoord3 === undefined ? {} : { texcoord3 }),
1196
+ ...(texcoord4 === undefined ? {} : { texcoord4 }),
1197
+ ...(texcoord5 === undefined ? {} : { texcoord5 }),
1198
+ ...(texcoord6 === undefined ? {} : { texcoord6 }),
1199
+ ...(texcoord7 === undefined ? {} : { texcoord7 }),
1200
+ ...(tangents === undefined ? {} : { tangents }),
1201
+ ...(colors0 === undefined ? {} : { colors0 }),
1202
+ ...(joints0 === undefined ? {} : { joints0 }),
1203
+ ...(weights0 === undefined ? {} : { weights0 }),
1204
+ ...(morphTargets.length === 0 ? {} : { morphTargets }),
1205
+ ...(meshWeights === undefined ? {} : { morphWeights: new Float32Array(meshWeights) }),
1206
+ ...(indices === undefined ? {} : { indices }),
1207
+ materialIndex: prim.material ?? null,
1208
+ meshIndex,
1209
+ };
1210
+ meshes.push(meshIr);
1211
+ }
1212
+ }
1213
+
1214
+ // Textures, images, samplers (Tier-C: parse top-level arrays).
1215
+ const texturesJson = json.textures ?? [];
1216
+ const textures: GltfTextureIr[] = [];
1217
+ for (const texJson of texturesJson) {
1218
+ const texIr: GltfTextureIr = {
1219
+ source: texJson.source ?? -1,
1220
+ ...(texJson.sampler === undefined ? {} : { sampler: texJson.sampler }),
1221
+ ...(texJson.name === undefined ? {} : { name: texJson.name }),
1222
+ };
1223
+ textures.push(texIr);
1224
+ }
1225
+
1226
+ const imagesJson = json.images ?? [];
1227
+ const images: GltfImageIr[] = [];
1228
+ for (const imgJson of imagesJson) {
1229
+ const mimeType = imgJson.mimeType;
1230
+ if (mimeType !== undefined && mimeType !== 'image/jpeg' && mimeType !== 'image/png') {
1231
+ return err(gltfErr('gltf-image-mime-unsupported', { mimeType }));
1232
+ }
1233
+ const imgIr: GltfImageIr = {
1234
+ ...(imgJson.uri === undefined ? {} : { uri: imgJson.uri }),
1235
+ ...(imgJson.mimeType === undefined ? {} : { mimeType: imgJson.mimeType }),
1236
+ ...(imgJson.bufferView === undefined ? {} : { bufferView: imgJson.bufferView }),
1237
+ ...(imgJson.name === undefined ? {} : { name: imgJson.name }),
1238
+ };
1239
+ images.push(imgIr);
1240
+ }
1241
+
1242
+ // Load external image URIs via externalLoader.
1243
+ for (const img of images) {
1244
+ if (img.uri !== undefined) {
1245
+ if (dataUriBase64Payload(img.uri) !== undefined) continue; // data: URI, skip external load
1246
+ try {
1247
+ await ctx.externalLoader(img.uri);
1248
+ } catch (_e) {
1249
+ return err(gltfErr('gltf-texture-load-failed', { uri: img.uri }));
1250
+ }
1251
+ }
1252
+ }
1253
+
1254
+ const samplersJson = json.samplers ?? [];
1255
+ const samplers: GltfSamplerIr[] = [];
1256
+ for (const sampJson of samplersJson) {
1257
+ samplers.push({
1258
+ ...(sampJson.magFilter === undefined ? {} : { magFilter: sampJson.magFilter }),
1259
+ ...(sampJson.minFilter === undefined ? {} : { minFilter: sampJson.minFilter }),
1260
+ wrapS: sampJson.wrapS ?? 10497, // REPEAT (glTF default)
1261
+ wrapT: sampJson.wrapT ?? 10497,
1262
+ ...(sampJson.name === undefined ? {} : { name: sampJson.name }),
1263
+ });
1264
+ }
1265
+
1266
+ // Materials (Tier-C subset: metallic-roughness PBR + normal/emissive maps).
1267
+ const materialsJson = json.materials ?? [];
1268
+ const materials: GltfMaterialIr[] = [];
1269
+ for (const matJson of materialsJson) {
1270
+ const pbr = matJson.pbrMetallicRoughness;
1271
+ const baseColor = pbr?.baseColorFactor ?? [1, 1, 1, 1];
1272
+ const baseColor4: readonly [number, number, number, number] = [
1273
+ baseColor[0] ?? 1,
1274
+ baseColor[1] ?? 1,
1275
+ baseColor[2] ?? 1,
1276
+ baseColor[3] ?? 1,
1277
+ ];
1278
+
1279
+ const alphaMode =
1280
+ matJson.alphaMode === 'MASK' || matJson.alphaMode === 'BLEND' ? matJson.alphaMode : undefined;
1281
+ const alphaCutoff = alphaMode === 'MASK' ? (matJson.alphaCutoff ?? 0.5) : undefined;
1282
+ const emissiveFactor = matJson.emissiveFactor;
1283
+ const emissiveFactor3: readonly [number, number, number] = [
1284
+ emissiveFactor?.[0] ?? 0,
1285
+ emissiveFactor?.[1] ?? 0,
1286
+ emissiveFactor?.[2] ?? 0,
1287
+ ];
1288
+ materials.push({
1289
+ ...(matJson.name === undefined ? {} : { name: matJson.name }),
1290
+ baseColorFactor: baseColor4,
1291
+ ...(emissiveFactor === undefined ? {} : { emissiveFactor: emissiveFactor3 }),
1292
+ metallicFactor: pbr?.metallicFactor ?? 1.0,
1293
+ roughnessFactor: pbr?.roughnessFactor ?? 1.0,
1294
+ ...(pbr?.baseColorTexture === undefined
1295
+ ? {}
1296
+ : { baseColorTexture: parseTextureInfo(pbr.baseColorTexture, textures) }),
1297
+ ...(pbr?.metallicRoughnessTexture === undefined
1298
+ ? {}
1299
+ : { metallicRoughnessTexture: parseTextureInfo(pbr.metallicRoughnessTexture, textures) }),
1300
+ ...(matJson.normalTexture === undefined
1301
+ ? {}
1302
+ : {
1303
+ normalTexture: {
1304
+ ...parseTextureInfo(matJson.normalTexture, textures),
1305
+ ...(matJson.normalTexture.scale === undefined
1306
+ ? {}
1307
+ : { scale: matJson.normalTexture.scale }),
1308
+ },
1309
+ }),
1310
+ ...(matJson.occlusionTexture === undefined
1311
+ ? {}
1312
+ : {
1313
+ occlusionTexture: {
1314
+ ...parseTextureInfo(matJson.occlusionTexture, textures),
1315
+ ...(matJson.occlusionTexture.strength === undefined
1316
+ ? {}
1317
+ : { strength: matJson.occlusionTexture.strength }),
1318
+ },
1319
+ }),
1320
+ ...(matJson.emissiveTexture === undefined
1321
+ ? {}
1322
+ : { emissiveTexture: parseTextureInfo(matJson.emissiveTexture, textures) }),
1323
+ ...(alphaMode === undefined ? {} : { alphaMode }),
1324
+ ...(alphaCutoff === undefined ? {} : { alphaCutoff }),
1325
+ ...(matJson.doubleSided === true ? { doubleSided: true } : {}),
1326
+ ...(pbr?.baseColorTexture?.texCoord === undefined || pbr.baseColorTexture.texCoord === 0
1327
+ ? {}
1328
+ : { baseColorTexCoord: pbr.baseColorTexture.texCoord }),
1329
+ });
1330
+ }
1331
+
1332
+ // Nodes + diagnostics.
1333
+ const diagnostics = {
1334
+ nodeNames: [] as string[],
1335
+ unsupportedExtensions: [...unsupportedExtensions] as string[],
1336
+ matrixTrsCoexistNodes: [] as number[],
1337
+ };
1338
+ const nodes: GltfNodeIr[] = [];
1339
+ const nodesJson = json.nodes ?? [];
1340
+ for (let nodeIndex = 0; nodeIndex < nodesJson.length; nodeIndex++) {
1341
+ const nodeJson = nodesJson[nodeIndex];
1342
+ if (nodeJson === undefined) continue;
1343
+ const transform = decomposeNodeTransform(nodeJson, nodeIndex, diagnostics);
1344
+ if (nodeJson.name !== undefined) diagnostics.nodeNames.push(nodeJson.name);
1345
+ const instancingExt = nodeJson.extensions?.EXT_mesh_gpu_instancing;
1346
+ let instancing: NodeInstancingIr | undefined;
1347
+ if (instancingExt !== undefined) {
1348
+ const instancingResult = decodeNodeInstancing(
1349
+ nodeIndex,
1350
+ instancingExt.attributes ?? {},
1351
+ accessors,
1352
+ bufferViews,
1353
+ buffers,
1354
+ );
1355
+ if (!instancingResult.ok) return err(instancingResult.error);
1356
+ instancing = instancingResult.value;
1357
+ }
1358
+ const nodeMorphWeights =
1359
+ nodeJson.weights === undefined ? undefined : new Float32Array(nodeJson.weights);
1360
+ nodes.push({
1361
+ ...(nodeJson.name === undefined ? {} : { name: nodeJson.name }),
1362
+ transform,
1363
+ meshIndex: nodeJson.mesh ?? null,
1364
+ skinIndex: nodeJson.skin ?? null,
1365
+ children: nodeJson.children ?? [],
1366
+ camera: nodeJson.camera ?? null,
1367
+ lightIndex: nodeJson.extensions?.KHR_lights_punctual?.light ?? null,
1368
+ ...(instancing === undefined ? {} : { instancing }),
1369
+ ...(nodeMorphWeights === undefined ? {} : { morphWeights: nodeMorphWeights }),
1370
+ });
1371
+ }
1372
+
1373
+ // Scenes.
1374
+ const scenesJson = json.scenes ?? [];
1375
+ const scenes: GltfSceneIr[] = scenesJson.map((s) => ({
1376
+ ...(s.name === undefined ? {} : { name: s.name }),
1377
+ nodes: s.nodes ?? [],
1378
+ }));
1379
+ const defaultSceneIndex = json.scene ?? 0;
1380
+
1381
+ // Skins (feat-20260523-skin-skeleton-animation M0).
1382
+ const skinsJson = json.skins;
1383
+ const skinResult = parseSkin(skinsJson, nodesJson, accessors, bufferViews, buffers);
1384
+ if (!skinResult.ok) return err(skinResult.error);
1385
+ const skeletons = skinResult.value;
1386
+
1387
+ // Animations (feat-20260523-skin-skeleton-animation M0).
1388
+ const animationsJson = json.animations;
1389
+ const animResult = parseAnimation(animationsJson, nodesJson, accessors, bufferViews, buffers);
1390
+ if (!animResult.ok) return err(animResult.error);
1391
+ const animationClips = animResult.value;
1392
+
1393
+ return ok({
1394
+ meshes,
1395
+ materials,
1396
+ nodes,
1397
+ scenes,
1398
+ skeletons,
1399
+ animationClips,
1400
+ textures: textures.length > 0 ? textures : undefined,
1401
+ images: images.length > 0 ? images : undefined,
1402
+ samplers: samplers.length > 0 ? samplers : undefined,
1403
+ defaultSceneIndex,
1404
+ diagnostics: {
1405
+ nodeNames: diagnostics.nodeNames,
1406
+ unsupportedExtensions: diagnostics.unsupportedExtensions,
1407
+ matrixTrsCoexistNodes: diagnostics.matrixTrsCoexistNodes,
1408
+ },
1409
+ meshPrimitiveCount,
1410
+ lights: lightsResult.value,
1411
+ });
1412
+ }
1413
+
1414
+ /**
1415
+ * Parse a glTF 2.0 JSON document. The `externalLoader` resolves
1416
+ * `buffers[].uri` references that are NOT data: URIs (callers in Node
1417
+ * land typically wrap fs.readFile; browser callers wrap fetch).
1418
+ *
1419
+ * Pure function modulo `externalLoader` (caller-provided I/O is the
1420
+ * only side effect). No global state, no fs / network direct call.
1421
+ */
1422
+ export async function parseGltfForImporter(
1423
+ json: unknown,
1424
+ externalLoader: ExternalLoader,
1425
+ filePath: string,
1426
+ options: GltfParseOptions = {},
1427
+ ): Promise<Result<GltfDoc, GltfParseError>> {
1428
+ if (json === null || typeof json !== 'object') {
1429
+ return err(
1430
+ gltfErr('gltf-malformed-header', {
1431
+ filePath,
1432
+ byteOffset: 0,
1433
+ }),
1434
+ );
1435
+ }
1436
+ return parseGltfWithBin(json as RootGltfJson, {
1437
+ externalLoader,
1438
+ filePath,
1439
+ ...(options.meshopt === undefined ? {} : { meshopt: options.meshopt }),
1440
+ });
1441
+ }
1442
+
1443
+ function legacyParseResult(
1444
+ result: Result<GltfDoc, GltfParseError>,
1445
+ filePath: string,
1446
+ byteOffset: number,
1447
+ ): Result<GltfDoc, GltfError> {
1448
+ if (result.ok) return ok(result.value);
1449
+ if (result.error instanceof ImportError) {
1450
+ return err(gltfErr('gltf-malformed-header', { filePath, byteOffset }));
1451
+ }
1452
+ return err(result.error);
1453
+ }
1454
+
1455
+ export async function parseGltf(
1456
+ json: unknown,
1457
+ externalLoader: ExternalLoader,
1458
+ filePath: string,
1459
+ options: GltfParseOptions = {},
1460
+ ): Promise<Result<GltfDoc, GltfError>> {
1461
+ const result = await parseGltfForImporter(json, externalLoader, filePath, options);
1462
+ return legacyParseResult(result, filePath, 0);
1463
+ }
1464
+
1465
+ /**
1466
+ * Parse a GLB 2.0 binary container: split into JSON + BIN chunks via
1467
+ * `parseGlbChunks`, then run the JSON document through `parseGltf` with
1468
+ * the BIN chunk wired in as buffer-0 backing storage.
1469
+ */
1470
+ export async function parseGlbForImporter(
1471
+ buffer: ArrayBuffer,
1472
+ filePath: string,
1473
+ options: GltfParseOptions = {},
1474
+ ): Promise<Result<GltfDoc, GltfParseError>> {
1475
+ const chunksResult = parseGlbChunks(buffer, filePath);
1476
+ if (!chunksResult.ok) return err(chunksResult.error);
1477
+ let json: unknown;
1478
+ try {
1479
+ json = JSON.parse(new TextDecoder().decode(chunksResult.value.jsonChunk));
1480
+ } catch (_e) {
1481
+ return err(
1482
+ gltfErr('gltf-malformed-header', {
1483
+ filePath,
1484
+ byteOffset: 12,
1485
+ }),
1486
+ );
1487
+ }
1488
+ if (json === null || typeof json !== 'object') {
1489
+ return err(
1490
+ gltfErr('gltf-malformed-header', {
1491
+ filePath,
1492
+ byteOffset: 12,
1493
+ }),
1494
+ );
1495
+ }
1496
+ const externalLoader: ExternalLoader = async (uri: string) => {
1497
+ throw new Error(`parseGlb: GLB containers must not reference external URIs (got ${uri})`);
1498
+ };
1499
+ return parseGltfWithBin(json as RootGltfJson, {
1500
+ externalLoader,
1501
+ ...(chunksResult.value.binChunk === undefined ? {} : { binChunk: chunksResult.value.binChunk }),
1502
+ filePath,
1503
+ ...(options.meshopt === undefined ? {} : { meshopt: options.meshopt }),
1504
+ });
1505
+ }
1506
+
1507
+ export async function parseGlb(
1508
+ buffer: ArrayBuffer,
1509
+ filePath: string,
1510
+ options: GltfParseOptions = {},
1511
+ ): Promise<Result<GltfDoc, GltfError>> {
1512
+ const result = await parseGlbForImporter(buffer, filePath, options);
1513
+ return legacyParseResult(result, filePath, 12);
1514
+ }
1515
+
1516
+ export interface GltfAssetPack {
1517
+ readonly meta: GltfMetaJson;
1518
+ readonly subAssets: readonly GltfSubAssetEntry[];
1519
+ }
1520
+
1521
+ export type GltfAssetPackResult = Result<GltfAssetPack, GltfSourceKeyError>;
1522
+
1523
+ /**
1524
+ * Project a parsed `GltfDoc` into the disk-shape `<source>.meta.json` plus
1525
+ * the freshly minted `subAssets[]` list (UUIDv7 + reimport-reuse-meta
1526
+ * two-stage match per plan-strategy section 2.4). Source-key conflicts are
1527
+ * returned before GUID minting or serialization.
1528
+ *
1529
+ * `existingMeta` is the previously-written `<source>.meta.json` parsed back
1530
+ * into memory (callers typically read + JSON.parse it before invoking).
1531
+ * `undefined` triggers first-pass full-fresh GUID minting.
1532
+ *
1533
+ * Sub-asset ordering: meshes first, then materials, then scenes - the
1534
+ * order is stable across reimports so AC-13 byte-identical holds when
1535
+ * source content does not change. Stable semantic sourceKey values are
1536
+ * assigned by the producer reuse boundary; sourceIndex remains a locator.
1537
+ */
1538
+ export function toAssetPack(
1539
+ doc: GltfDoc,
1540
+ existingMeta: GltfMetaJson | undefined,
1541
+ source: string,
1542
+ ): GltfAssetPackResult {
1543
+ const items: GltfDocItem[] = [];
1544
+ // Mesh sub-assets are keyed on the *original glTF mesh-index*, not on the
1545
+ // flat GltfMeshIr index. parseGltf flattens N glTF meshes with M_i primitives
1546
+ // into sum(M_i) GltfMeshIr rows (each carries its owning meshIndex). The
1547
+ // gltfImporter merges all primitives sharing a meshIndex into one MeshAsset
1548
+ // with M_i Submesh entries (one per primitive), so the meta sidecar must
1549
+ // emit exactly one `kind: 'mesh'` row per unique meshIndex — not one per
1550
+ // flat GltfMeshIr row, which would over-emit M_i sub-assets per glTF mesh and
1551
+ // desynchronise from runtime MeshRenderer.materials[].length.
1552
+ const seenMeshIndices = new Set<number>();
1553
+ for (const m of doc.meshes) {
1554
+ if (m === undefined) continue;
1555
+ if (seenMeshIndices.has(m.meshIndex)) continue;
1556
+ seenMeshIndices.add(m.meshIndex);
1557
+ items.push({
1558
+ kind: 'mesh',
1559
+ sourceIndex: m.meshIndex,
1560
+ ...(m.name === undefined ? {} : { name: m.name }),
1561
+ });
1562
+ }
1563
+ for (let i = 0; i < doc.materials.length; i++) {
1564
+ const m = doc.materials[i];
1565
+ if (m === undefined) continue;
1566
+ items.push({
1567
+ kind: 'material',
1568
+ sourceIndex: i,
1569
+ ...(m.name === undefined ? {} : { name: m.name }),
1570
+ });
1571
+ }
1572
+ for (let i = 0; i < doc.scenes.length; i++) {
1573
+ const s = doc.scenes[i];
1574
+ if (s === undefined) continue;
1575
+ items.push({
1576
+ kind: 'scene',
1577
+ sourceIndex: i,
1578
+ ...(s.name === undefined ? {} : { name: s.name }),
1579
+ });
1580
+ }
1581
+ // Texture sub-assets — one entry per `images[]` row (feat-20260608 M3 D-3,
1582
+ // requirements G-2 / AC-13). Orphan images (declared but unreferenced by
1583
+ // any `textures[]` row) still produce a sub-asset; the importer assigns
1584
+ // them colorSpace 'linear' (no colour-encoded purpose inferable). Without
1585
+ // this loop the meta carries no `kind: 'texture'` row, AssetRegistry
1586
+ // never imports the bytes, and the runtime renders a white box (G-2).
1587
+ const images = doc.images ?? [];
1588
+ for (let i = 0; i < images.length; i++) {
1589
+ const img = images[i];
1590
+ if (img === undefined) continue;
1591
+ items.push({
1592
+ kind: 'texture',
1593
+ sourceIndex: i,
1594
+ ...(img.name === undefined ? {} : { name: img.name }),
1595
+ });
1596
+ }
1597
+ const samplers = doc.samplers ?? [];
1598
+ for (let i = 0; i < samplers.length; i++) {
1599
+ const sampler = samplers[i];
1600
+ if (sampler === undefined) continue;
1601
+ items.push({
1602
+ kind: 'sampler',
1603
+ sourceIndex: i,
1604
+ ...(sampler.name === undefined ? {} : { name: sampler.name }),
1605
+ });
1606
+ }
1607
+ // Skeletons (feat-20260523-skin-skeleton-animation M0).
1608
+ for (let i = 0; i < doc.skeletons.length; i++) {
1609
+ items.push({ kind: 'skeleton', sourceIndex: i });
1610
+ }
1611
+ // Skin bindings (feat-20260523-skin-skeleton-animation M0).
1612
+ // Emit one skin sub-asset per GltfSkeletonRecord (1:1 mapping).
1613
+ for (let i = 0; i < doc.skeletons.length; i++) {
1614
+ items.push({ kind: 'skin', sourceIndex: i });
1615
+ }
1616
+ // Animation clips (feat-20260523-skin-skeleton-animation M0).
1617
+ for (let i = 0; i < doc.animationClips.length; i++) {
1618
+ const clip = doc.animationClips[i];
1619
+ if (clip === undefined) continue;
1620
+ items.push({
1621
+ kind: 'animation-clip',
1622
+ sourceIndex: i,
1623
+ ...(clip.name === undefined ? {} : { name: clip.name }),
1624
+ });
1625
+ }
1626
+
1627
+ const reuse = reimportReuseMeta(items, existingMeta);
1628
+ if (!reuse.ok) return reuse;
1629
+ const subAssetByKindIndex = new Map(
1630
+ reuse.value.subAssets.map((entry) => [`${entry.kind}:${entry.sourceIndex}`, entry]),
1631
+ );
1632
+ const sourceOverrides: Record<string, Readonly<Record<string, unknown>>> = {
1633
+ ...(existingMeta?.sourceOverrides ?? {}),
1634
+ };
1635
+ for (const meshIndex of seenMeshIndices) {
1636
+ const meshOutput = subAssetByKindIndex.get(`mesh:${meshIndex}`);
1637
+ if (meshOutput?.sourceKey === undefined) continue;
1638
+ const materialSlots: MeshMaterialSlotTopologyEntry[] = [];
1639
+ const slotByMaterial = new Map<number | null, number>();
1640
+ const usedNames = new Set<string>();
1641
+ const uniqueName = (raw: string): string => {
1642
+ const base = raw.trim() || 'Material';
1643
+ let candidate = base;
1644
+ let suffix = 2;
1645
+ while (usedNames.has(candidate)) candidate = `${base}_${suffix++}`;
1646
+ usedNames.add(candidate);
1647
+ return candidate;
1648
+ };
1649
+ for (const primitive of doc.meshes.filter((mesh) => mesh.meshIndex === meshIndex)) {
1650
+ const materialIndex = primitive.materialIndex;
1651
+ if (slotByMaterial.has(materialIndex)) continue;
1652
+ const materialOutput =
1653
+ materialIndex === null ? undefined : subAssetByKindIndex.get(`material:${materialIndex}`);
1654
+ slotByMaterial.set(materialIndex, materialSlots.length);
1655
+ materialSlots.push({
1656
+ slotName: uniqueName(
1657
+ materialIndex === null
1658
+ ? 'Default'
1659
+ : (doc.materials[materialIndex]?.name ?? `Material_${materialIndex}`),
1660
+ ),
1661
+ sourceKey:
1662
+ materialIndex === null
1663
+ ? 'gltf:default'
1664
+ : (materialOutput?.sourceKey ?? `gltf:material:${materialIndex}`),
1665
+ ...(materialOutput === undefined ? {} : { defaultMaterialGuid: materialOutput.guid }),
1666
+ });
1667
+ }
1668
+ const previousPayload = existingMeta?.sourceOverrides?.[meshOutput.sourceKey];
1669
+ const previousRaw = previousPayload?.materialSlots;
1670
+ const previous = Array.isArray(previousRaw)
1671
+ ? previousRaw.filter(
1672
+ (slot): slot is MeshMaterialSlotTopologyEntry =>
1673
+ slot !== null &&
1674
+ typeof slot === 'object' &&
1675
+ !Array.isArray(slot) &&
1676
+ typeof (slot as { slotName?: unknown }).slotName === 'string',
1677
+ )
1678
+ : [];
1679
+ const reconciled = reconcileMeshMaterialSlotTopology(materialSlots, previous);
1680
+ if (!reconciled.ok) {
1681
+ return err({
1682
+ code: 'mesh-material-slot-topology-change',
1683
+ expected: `unambiguous material slot identity for mesh ${meshOutput.guid}`,
1684
+ hint: reconciled.error.hint,
1685
+ detail: {
1686
+ sourceIndices: reconciled.error.nextIndices,
1687
+ previousIndices: reconciled.error.previousIndices,
1688
+ },
1689
+ });
1690
+ }
1691
+ sourceOverrides[meshOutput.sourceKey] = {
1692
+ ...(previousPayload ?? {}),
1693
+ materialSlots: reconciled.slots,
1694
+ };
1695
+ }
1696
+ const meta: GltfMetaJson = {
1697
+ schemaVersion: 1,
1698
+ kind: 'external-asset-package',
1699
+ importer: 'gltf',
1700
+ source,
1701
+ subAssets: reuse.value.subAssets,
1702
+ ...(Object.keys(sourceOverrides).length === 0 ? {} : { sourceOverrides }),
1703
+ sourceOverrideDescriptors: reuse.value.subAssets
1704
+ .filter((entry) => entry.kind === 'mesh' && entry.sourceKey !== undefined)
1705
+ .map((entry) => ({
1706
+ sourceKey: entry.sourceKey as string,
1707
+ semantic: 'mesh-material-slot-defaults' as const,
1708
+ payloadSchema: MESH_MATERIAL_SLOT_SOURCE_OVERRIDE_PAYLOAD_SCHEMA,
1709
+ })),
1710
+ importSettings: {
1711
+ defaultSceneIndex: doc.defaultSceneIndex,
1712
+ ...(typeof existingMeta?.importSettings.standardMaterialGuid === 'string'
1713
+ ? { standardMaterialGuid: existingMeta.importSettings.standardMaterialGuid }
1714
+ : {}),
1715
+ ...(existingMeta?.importSettings.downscaleMaxDimension !== undefined
1716
+ ? { downscaleMaxDimension: existingMeta.importSettings.downscaleMaxDimension }
1717
+ : {}),
1718
+ diagnostics: {
1719
+ nodeNames: doc.diagnostics.nodeNames,
1720
+ unsupportedExtensions: doc.diagnostics.unsupportedExtensions,
1721
+ matrixTrsCoexistNodes: doc.diagnostics.matrixTrsCoexistNodes,
1722
+ },
1723
+ },
1724
+ };
1725
+ return ok({ meta, subAssets: reuse.value.subAssets });
1726
+ }
1727
+
1728
+ // Suppress unused-import warning; COMPONENT_TYPE is re-exported so
1729
+ // downstream consumers can grep the constants table from a single
1730
+ // import. (TS strict mode would otherwise drop the binding.)
1731
+ export { COMPONENT_TYPE };