@forgeax/engine-fbx 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +196 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-runtime-fixture.d.ts +9 -0
  5. package/dist/__tests__/asset-runtime-fixture.d.ts.map +1 -0
  6. package/dist/__tests__/blendshape-import.integration.test.d.ts +2 -0
  7. package/dist/__tests__/blendshape-import.integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/blendshape-import.unit.test.d.ts +2 -0
  9. package/dist/__tests__/blendshape-import.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/fbx-importer.test.d.ts +2 -0
  11. package/dist/__tests__/fbx-importer.test.d.ts.map +1 -0
  12. package/dist/__tests__/fbx-local-artifacts.test.d.ts +2 -0
  13. package/dist/__tests__/fbx-local-artifacts.test.d.ts.map +1 -0
  14. package/dist/__tests__/index.test.d.ts +2 -0
  15. package/dist/__tests__/index.test.d.ts.map +1 -0
  16. package/dist/__tests__/mesh-material-slots.unit.test.d.ts +2 -0
  17. package/dist/__tests__/mesh-material-slots.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/parse-mesh-multi-uv.test.d.ts +2 -0
  19. package/dist/__tests__/parse-mesh-multi-uv.test.d.ts.map +1 -0
  20. package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
  21. package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/resolve-texture-path.unit.test.d.ts +2 -0
  23. package/dist/__tests__/resolve-texture-path.unit.test.d.ts.map +1 -0
  24. package/dist/errors.d.ts +50 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/fbx-importer.d.ts +18 -0
  27. package/dist/fbx-importer.d.ts.map +1 -0
  28. package/dist/index.d.ts +62 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.mjs +9546 -0
  31. package/dist/index.mjs.map +1 -0
  32. package/dist/parse-animation-clip.d.ts +35 -0
  33. package/dist/parse-animation-clip.d.ts.map +1 -0
  34. package/dist/parse-material.d.ts +30 -0
  35. package/dist/parse-material.d.ts.map +1 -0
  36. package/dist/parse-mesh.d.ts +21 -0
  37. package/dist/parse-mesh.d.ts.map +1 -0
  38. package/dist/parse-scene.d.ts +28 -0
  39. package/dist/parse-scene.d.ts.map +1 -0
  40. package/dist/parse-skeleton.d.ts +15 -0
  41. package/dist/parse-skeleton.d.ts.map +1 -0
  42. package/dist/parse-skin.d.ts +20 -0
  43. package/dist/parse-skin.d.ts.map +1 -0
  44. package/dist/parse-texture.d.ts +15 -0
  45. package/dist/parse-texture.d.ts.map +1 -0
  46. package/dist/resolve-texture-path.d.ts +25 -0
  47. package/dist/resolve-texture-path.d.ts.map +1 -0
  48. package/dist/to-asset-pack.d.ts +38 -0
  49. package/dist/to-asset-pack.d.ts.map +1 -0
  50. package/package.json +75 -0
  51. package/pkg/fbx-wasm.mjs +2 -0
  52. package/pkg/fbx-wasm.wasm +0 -0
  53. package/scripts/build-wasm.mjs +85 -0
  54. package/scripts/content-key.mjs +88 -0
  55. package/scripts/ensure-wasm.mjs +28 -0
  56. package/scripts/fetch-ufbx.mjs +42 -0
  57. package/scripts/fetch-wasm.mjs +91 -0
  58. package/scripts/parity-diff.mjs +127 -0
  59. package/scripts/test-wasm.mjs +91 -0
  60. package/src/__tests__/asset-runtime-fixture.ts +251 -0
  61. package/src/__tests__/blendshape-import.integration.test.ts +96 -0
  62. package/src/__tests__/blendshape-import.unit.test.ts +83 -0
  63. package/src/__tests__/fbx-importer.test.ts +200 -0
  64. package/src/__tests__/fbx-local-artifacts.test.ts +39 -0
  65. package/src/__tests__/index.test.ts +167 -0
  66. package/src/__tests__/mesh-material-slots.unit.test.ts +125 -0
  67. package/src/__tests__/parse-mesh-multi-uv.test.ts +187 -0
  68. package/src/__tests__/pick-e2e.integration.test.ts +247 -0
  69. package/src/__tests__/resolve-texture-path.unit.test.ts +50 -0
  70. package/src/errors.ts +112 -0
  71. package/src/fbx-importer.ts +372 -0
  72. package/src/index.ts +215 -0
  73. package/src/native/bridge.c +1060 -0
  74. package/src/parse-animation-clip.ts +271 -0
  75. package/src/parse-material.ts +140 -0
  76. package/src/parse-mesh.ts +223 -0
  77. package/src/parse-scene.ts +75 -0
  78. package/src/parse-skeleton.ts +46 -0
  79. package/src/parse-skin.ts +71 -0
  80. package/src/parse-texture.ts +36 -0
  81. package/src/resolve-texture-path.ts +152 -0
  82. package/src/to-asset-pack.ts +813 -0
@@ -0,0 +1,813 @@
1
+ // to-asset-pack.ts — aggregate parsed sub-assets into ImportedAsset[] (t31).
2
+ //
3
+ // GUID import-stable iron law: GUIDs come from `ctx.subAssets[]` (the external
4
+ // meta), never minted here. Each parsed POD is matched to its declared GUID by
5
+ // (kind, sourceIndex); a parsed asset that no sub-asset declares is dropped so
6
+ // the produced set stays a subset of the declared set (mirrors gltfImporter).
7
+ // The import-runner then validates produced == declared and rejects mismatches.
8
+
9
+ import { deriveAnimationTargetId } from '@forgeax/engine-animation/target-id';
10
+ import { packMeshBinV4 } from '@forgeax/engine-import';
11
+ import { box3 } from '@forgeax/engine-math';
12
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
13
+ import type {
14
+ AnimationClipPod,
15
+ AssetCodec,
16
+ AssetRef,
17
+ ImportedAsset,
18
+ MaterialAsset,
19
+ MaterialPod,
20
+ MaterialTextureBindingPod,
21
+ MaterialTextureValue,
22
+ MeshAsset,
23
+ MeshMaterialSlot,
24
+ MeshMaterialSlotTopologyEntry,
25
+ MeshPod,
26
+ SceneAsset,
27
+ ScenePod,
28
+ SkeletonPod,
29
+ SkinPod,
30
+ SourceOverrideMap,
31
+ TextureAsset,
32
+ TexturePod,
33
+ } from '@forgeax/engine-types';
34
+ import {
35
+ IMPORT_ERROR_HINTS,
36
+ ImportError,
37
+ reconcileMeshMaterialSlotTopology,
38
+ resolveMeshMaterialSlotDefaultGuid,
39
+ } from '@forgeax/engine-types';
40
+ import { buildFbxNodePaths } from './parse-scene.js';
41
+
42
+ type SubAsset = {
43
+ readonly guid: string;
44
+ readonly sourceIndex: number;
45
+ readonly kind: string;
46
+ readonly sourceKey?: string;
47
+ };
48
+
49
+ /** Resolve the meta-declared GUID for a parsed (kind, sourceIndex) pair. */
50
+ function makeGuidResolver(
51
+ subAssets: readonly SubAsset[],
52
+ ): (kind: string, sourceIndex: number) => string | undefined {
53
+ const byKey = new Map<string, string>();
54
+ for (const sub of subAssets) byKey.set(`${sub.kind}:${sub.sourceIndex}`, sub.guid);
55
+ return (kind, sourceIndex) => byKey.get(`${kind}:${sourceIndex}`);
56
+ }
57
+
58
+ export function buildMeshAsset(
59
+ pod: MeshPod,
60
+ guid: string,
61
+ influences?: readonly { jointIndices: Uint16Array; jointWeights: Float32Array }[],
62
+ materialContext: {
63
+ readonly guidByIndex?: ReadonlyMap<number, string>;
64
+ readonly nameByIndex?: ReadonlyMap<number, string>;
65
+ readonly sourceKeyByIndex?: ReadonlyMap<number, string>;
66
+ readonly previousMaterialSlots?: readonly MeshMaterialSlotTopologyEntry[];
67
+ readonly materialSlotDefaultOverrides?: Readonly<Record<string, string | null>>;
68
+ readonly meshSourceKey?: string;
69
+ } = {},
70
+ ): ImportedAsset {
71
+ const vc = pod.vertices.length / 3;
72
+ const n = pod.attributes.NORMAL as Float32Array | undefined;
73
+ // feat-20260629-multi-uv-set-support m1-w6: scan all TEXCOORD_n sets
74
+ // (n in [0,7]) from MeshPod.attributes. TEXCOORD_0 -> uv (set 0),
75
+ // TEXCOORD_n for n>=1 -> uvN attribute. >8 sets truncated per D-6.
76
+ // uvSetCount = max(n) + 1 (not count of keys) so sparse sets (TEXCOORD_0+TEXCOORD_2
77
+ // without TEXCOORD_1) still get correct interleaved stride with zero-filled gap.
78
+ let uvSetCount = 1; // always at least 1 (uv slot in interleaved)
79
+ for (const key of Object.keys(pod.attributes)) {
80
+ if (key.startsWith('TEXCOORD_')) {
81
+ const n = Number(key.slice('TEXCOORD_'.length));
82
+ if (Number.isFinite(n) && n >= 0 && n <= 7) {
83
+ uvSetCount = Math.max(uvSetCount, n + 1);
84
+ }
85
+ }
86
+ }
87
+
88
+ const u = pod.attributes.TEXCOORD_0 as Float32Array | undefined;
89
+
90
+ // Skinned meshes use the 18-float interleaved stride (mirror of gltfImporter):
91
+ // 12 floats (position/normal/uv/tangent) + uint16x4 joints (2 float slots, via
92
+ // an aliased Uint16 view) + float32x4 weights = 18 floats / 72 bytes. The
93
+ // runtime deriveVertexBufferLayout expects skinIndex at byte 48, skinWeight at
94
+ // byte 56. Unskinned meshes keep the 12-float layout.
95
+ const skinned = influences !== undefined && influences.length === vc && vc > 0;
96
+ // feat-20260629-multi-uv-set-support m1-w6: dynamic stride.
97
+ // Canonical interleaved order = position/normal/uv/tangent/skinIndex/skinWeight/uv1..uv7.
98
+ // Base stride: 12 (unskinned) / 18 (skinned). Extra UV sets add 2F each.
99
+ // UV1 offset: 12 (unskinned) / 18 (skinned) -- same as glTF bridge m1-w3.
100
+ const BASE_FLOATS = skinned ? 18 : 12;
101
+ const UV1_OFFSET = skinned ? 18 : 12;
102
+ const FLOATS_PER_VERT = BASE_FLOATS + (uvSetCount - 1) * 2;
103
+ const ib = new Float32Array(vc * FLOATS_PER_VERT);
104
+ const ibU16 = skinned ? new Uint16Array(ib.buffer) : undefined;
105
+ const skinIndexAttr = skinned ? new Uint16Array(vc * 4) : undefined;
106
+ const skinWeightAttr = skinned ? new Float32Array(vc * 4) : undefined;
107
+
108
+ for (let i = 0; i < vc; i++) {
109
+ const d = i * FLOATS_PER_VERT;
110
+ const p = i * 3;
111
+ const t = i * 2;
112
+ ib[d + 0] = pod.vertices[p + 0] ?? 0;
113
+ ib[d + 1] = pod.vertices[p + 1] ?? 0;
114
+ ib[d + 2] = pod.vertices[p + 2] ?? 0;
115
+ ib[d + 3] = n?.[p + 0] ?? 0;
116
+ ib[d + 4] = n?.[p + 1] ?? 0;
117
+ ib[d + 5] = n?.[p + 2] ?? 0;
118
+ ib[d + 6] = u?.[t + 0] ?? 0;
119
+ ib[d + 7] = u?.[t + 1] ?? 0;
120
+ ib[d + 8] = 1;
121
+ ib[d + 9] = 0;
122
+ ib[d + 10] = 0;
123
+ ib[d + 11] = 1;
124
+ if (skinned && ibU16 && skinIndexAttr && skinWeightAttr) {
125
+ const inf = influences[i];
126
+ const u16Base = (d + 12) * 2; // float slot 12 -> uint16 index (d+12)*2
127
+ const sd = i * 4;
128
+ for (let k = 0; k < 4; k++) {
129
+ const ji = inf?.jointIndices[k] ?? 0;
130
+ const jw = inf?.jointWeights[k] ?? 0;
131
+ ibU16[u16Base + k] = ji;
132
+ ib[d + 14 + k] = jw;
133
+ skinIndexAttr[sd + k] = ji;
134
+ skinWeightAttr[sd + k] = jw;
135
+ }
136
+ }
137
+ // feat-20260629-multi-uv-set-support m1-w6: write uv1..uvK after skin data.
138
+ // Canonical interleaved order matches glTF bridge m1-w3:
139
+ // position/normal/uv/tangent/skinIndex/skinWeight/uv1..uv7.
140
+ // UV1 starts at UV1_OFFSET (12 for unskinned, 18 for skinned) in float slots.
141
+ // Each additional UV set 2F. Missing texcoordK -> zero-fill (implicit).
142
+ for (let k = 1; k < uvSetCount; k++) {
143
+ const srcKey = `TEXCOORD_${k}`;
144
+ const srcArr = pod.attributes[srcKey] as Float32Array | undefined;
145
+ const interleavedOffset = UV1_OFFSET + (k - 1) * 2;
146
+ if (srcArr !== undefined) {
147
+ ib[d + interleavedOffset + 0] = srcArr[t + 0] ?? 0;
148
+ ib[d + interleavedOffset + 1] = srcArr[t + 1] ?? 0;
149
+ }
150
+ // else: zero-fill (implicit -- Float32Array defaults to 0)
151
+ }
152
+ }
153
+
154
+ // feat-20260629-multi-uv-set-support m1-w6: per-UV-set standalone typed arrays
155
+ // for MeshAsset.attributes (uv1..uvK). TEXCOORD_n -> attributes.uvN.
156
+ // Preserve sparse source-set semantics in the importer-facing mesh.
157
+ const extraUvAttrs: Record<string, Float32Array> = {};
158
+ for (let k = 1; k < uvSetCount; k++) {
159
+ const srcKey = `TEXCOORD_${k}`;
160
+ const srcArr = pod.attributes[srcKey] as Float32Array | undefined;
161
+ if (srcArr !== undefined) {
162
+ const cat = new Float32Array(vc * 2);
163
+ for (let i = 0; i < vc; i++) {
164
+ const t2 = i * 2;
165
+ cat[t2 + 0] = srcArr[t2 + 0] ?? 0;
166
+ cat[t2 + 1] = srcArr[t2 + 1] ?? 0;
167
+ }
168
+ extraUvAttrs[`uv${k}`] = cat;
169
+ }
170
+ }
171
+
172
+ const attributes: MeshAsset['attributes'] = {
173
+ position: pod.vertices,
174
+ normal: n ?? new Float32Array(vc * 3).fill(0),
175
+ uv: u ?? new Float32Array(vc * 2).fill(0),
176
+ tangent: new Float32Array(vc * 4).fill(0).map((_, i) => (i % 4 === 0 || i % 4 === 3 ? 1 : 0)),
177
+ ...(skinIndexAttr ? { skinIndex: skinIndexAttr } : {}),
178
+ ...(skinWeightAttr ? { skinWeight: skinWeightAttr } : {}),
179
+ ...extraUvAttrs,
180
+ };
181
+
182
+ const materialSlots: MeshMaterialSlot[] = [];
183
+ const slotByMaterial = new Map<number | null, number>();
184
+ const usedNames = new Set<string>();
185
+ const uniqueName = (raw: string): string => {
186
+ const base = raw.trim() || 'Material';
187
+ let candidate = base;
188
+ let suffix = 2;
189
+ while (usedNames.has(candidate)) candidate = `${base}_${suffix++}`;
190
+ usedNames.add(candidate);
191
+ return candidate;
192
+ };
193
+ const materialSlotFor = (materialIndex: number | null): number => {
194
+ const existing = slotByMaterial.get(materialIndex);
195
+ if (existing !== undefined) return existing;
196
+ const guidString =
197
+ materialIndex === null ? undefined : materialContext.guidByIndex?.get(materialIndex);
198
+ const parsed = guidString === undefined ? undefined : AssetGuid.parse(guidString);
199
+ const slotIndex = materialSlots.length;
200
+ materialSlots.push({
201
+ slotName: uniqueName(
202
+ materialIndex === null
203
+ ? 'Default'
204
+ : (materialContext.nameByIndex?.get(materialIndex) ?? `Material_${materialIndex}`),
205
+ ),
206
+ sourceKey:
207
+ materialIndex === null
208
+ ? 'fbx:default'
209
+ : (materialContext.sourceKeyByIndex?.get(materialIndex) ??
210
+ `fbx:material:${materialIndex}`),
211
+ ...(parsed?.ok ? { defaultMaterial: parsed.value } : {}),
212
+ });
213
+ slotByMaterial.set(materialIndex, slotIndex);
214
+ return slotIndex;
215
+ };
216
+ const currentMesh: MeshAsset = {
217
+ kind: 'mesh',
218
+ vertices: ib,
219
+ ...(pod.indices ? { indices: pod.indices } : {}),
220
+ aabb: box3.fromPositions(box3.create(), pod.vertices),
221
+ attributes,
222
+ ...(pod.morphTargets === undefined
223
+ ? {}
224
+ : {
225
+ morphTargets: pod.morphTargets.map((target) => ({
226
+ ...(target.position === undefined
227
+ ? {}
228
+ : { position: new Float32Array(target.position) }),
229
+ ...(target.normal === undefined ? {} : { normal: new Float32Array(target.normal) }),
230
+ ...(target.tangent === undefined ? {} : { tangent: new Float32Array(target.tangent) }),
231
+ })),
232
+ }),
233
+ ...(pod.morphWeights === undefined ? {} : { morphWeights: new Float32Array(pod.morphWeights) }),
234
+ submeshes: pod.submeshes.map((sm) => ({
235
+ indexOffset: sm.indexOffset,
236
+ indexCount: sm.indexCount,
237
+ vertexCount: vc,
238
+ topology: sm.topology,
239
+ materialSlot: materialSlotFor(sm.materialIndex),
240
+ })),
241
+ materialSlots,
242
+ };
243
+
244
+ const reconciled = reconcileMeshMaterialSlotTopology(
245
+ currentMesh.materialSlots.map((slot) => ({
246
+ slotName: slot.slotName,
247
+ ...(slot.sourceKey === undefined ? {} : { sourceKey: slot.sourceKey }),
248
+ ...(slot.defaultMaterial === undefined
249
+ ? {}
250
+ : { defaultMaterialGuid: AssetGuid.format(slot.defaultMaterial) }),
251
+ })),
252
+ materialContext.previousMaterialSlots,
253
+ );
254
+ if (!reconciled.ok) {
255
+ throw new ImportError({
256
+ code: 'mesh-material-slot-topology-change',
257
+ expected: `unambiguous material slot identity for mesh ${guid}`,
258
+ hint: IMPORT_ERROR_HINTS['mesh-material-slot-topology-change'],
259
+ detail: {
260
+ meshGuid: guid,
261
+ ...(materialContext.meshSourceKey === undefined
262
+ ? {}
263
+ : { meshSourceKey: materialContext.meshSourceKey }),
264
+ previousIndices: reconciled.error.previousIndices,
265
+ nextIndices: reconciled.error.nextIndices,
266
+ },
267
+ });
268
+ }
269
+ const mesh: MeshAsset = {
270
+ ...currentMesh,
271
+ submeshes: currentMesh.submeshes.map((submesh) => ({
272
+ ...submesh,
273
+ materialSlot: reconciled.currentToStableSlot[submesh.materialSlot] as number,
274
+ })),
275
+ materialSlots: reconciled.slots.map((slot, stableIndex) => {
276
+ const active = reconciled.currentToStableSlot.includes(stableIndex);
277
+ const effectiveDefault = resolveMeshMaterialSlotDefaultGuid(
278
+ slot,
279
+ active
280
+ ? materialContext.materialSlotDefaultOverrides?.[slot.sourceKey ?? slot.slotName]
281
+ : undefined,
282
+ );
283
+ const parsed = effectiveDefault === undefined ? undefined : AssetGuid.parse(effectiveDefault);
284
+ return {
285
+ slotName: slot.slotName,
286
+ ...(slot.sourceKey === undefined ? {} : { sourceKey: slot.sourceKey }),
287
+ ...(active && parsed?.ok ? { defaultMaterial: parsed.value } : {}),
288
+ };
289
+ }),
290
+ };
291
+
292
+ // The wire projection is dense even when the source mesh intentionally
293
+ // preserves sparse UV-set presence. Missing intermediate slots are zeroed
294
+ // only for the canonical v4 payload.
295
+ const wireAttributes = { ...mesh.attributes } as Record<string, Float32Array | Uint16Array>;
296
+ for (let k = 1; k < uvSetCount; k++) {
297
+ if (wireAttributes[`uv${k}`] === undefined) {
298
+ wireAttributes[`uv${k}`] = new Float32Array(vc * 2);
299
+ }
300
+ }
301
+ const wireMesh: MeshAsset = { ...mesh, attributes: wireAttributes };
302
+
303
+ const refs: AssetRef[] = [];
304
+ const seenRefs = new Set<string>();
305
+ for (let slotIndex = 0; slotIndex < mesh.materialSlots.length; slotIndex++) {
306
+ const defaultMaterial = mesh.materialSlots[slotIndex]?.defaultMaterial;
307
+ const materialGuid =
308
+ defaultMaterial === undefined ? undefined : AssetGuid.format(defaultMaterial);
309
+ if (materialGuid !== undefined && !seenRefs.has(materialGuid.toLowerCase())) {
310
+ seenRefs.add(materialGuid.toLowerCase());
311
+ refs.push({
312
+ guid: materialGuid,
313
+ sourceField: { fieldName: 'materialSlots', arrayIndex: slotIndex },
314
+ });
315
+ }
316
+ }
317
+
318
+ return {
319
+ guid,
320
+ kind: 'mesh',
321
+ ...(pod.name !== undefined ? { name: pod.name } : {}),
322
+ payload: mesh,
323
+ refs,
324
+ artifacts: {
325
+ body: {
326
+ mediaType: 'application/x-forgeax-mesh',
327
+ assetCodec: { name: 'mesh-binary', version: '4' },
328
+ bytes: (() => {
329
+ const packed = packMeshBinV4(
330
+ wireMesh as never,
331
+ materialContext.meshSourceKey ?? 'fbx://mesh',
332
+ refs.map((ref) => ref.guid),
333
+ );
334
+ if (!packed.ok) {
335
+ throw new ImportError({
336
+ code: 'import-internal-error',
337
+ expected: 'mesh-bin v4 producer to accept the canonical FBX mesh projection',
338
+ hint: 're-cook the FBX source with its Meta sidecar after fixing the mesh payload',
339
+ detail: { reason: `${packed.error.code}: ${packed.error.actual}` },
340
+ });
341
+ }
342
+ return packed.value;
343
+ })(),
344
+ },
345
+ },
346
+ };
347
+ }
348
+
349
+ export interface FbxDecodedTexture {
350
+ readonly texture: TextureAsset;
351
+ readonly bytes: Uint8Array;
352
+ readonly mediaType?: string;
353
+ readonly assetCodec?: AssetCodec;
354
+ }
355
+
356
+ function materialBindings(pod: MaterialPod): readonly MaterialTextureBindingPod[] {
357
+ if (pod.textureBindings !== undefined) return pod.textureBindings;
358
+ const bindings: MaterialTextureBindingPod[] = [];
359
+ const legacy: readonly [MaterialTextureBindingPod['slot'], number | undefined][] = [
360
+ ['baseColorTexture', pod.baseColorTextureIndex],
361
+ ['metallicRoughnessTexture', pod.metallicRoughnessTextureIndex],
362
+ ['normalTexture', pod.normalTextureIndex],
363
+ ['specularTintTexture', pod.specularTintTextureIndex],
364
+ ['emissiveTexture', pod.emissiveTextureIndex],
365
+ ['occlusionTexture', pod.occlusionTextureIndex],
366
+ ];
367
+ for (const [slot, textureIndex] of legacy) {
368
+ if (textureIndex !== undefined) bindings.push({ slot, textureIndex });
369
+ }
370
+ return bindings;
371
+ }
372
+
373
+ function buildMaterialAsset(
374
+ pod: MaterialPod,
375
+ guid: string,
376
+ skinned = false,
377
+ textureGuidByIndex: ReadonlyMap<number, string> = new Map(),
378
+ ): ImportedAsset {
379
+ // A material consumed by a skinned mesh must select the pbr-skin shader so the
380
+ // runtime PSO chain (LayoutKind 'pbr-skin' + 18-float vertex layout + joint
381
+ // palette) is exercised; the render-system fail-fasts otherwise (mirror of
382
+ // gltfImporter's `skinned` routing).
383
+ const values: Record<string, NonNullable<MaterialAsset['values']>[string]> = {
384
+ baseColor: pod.baseColorFactor as readonly [number, number, number, number],
385
+ metallic: pod.metallicFactor,
386
+ roughness: pod.roughnessFactor,
387
+ };
388
+ const refs: AssetRef[] = [];
389
+ for (const binding of materialBindings(pod)) {
390
+ const textureGuid = textureGuidByIndex.get(binding.textureIndex);
391
+ if (textureGuid === undefined) continue;
392
+ const textureValue: MaterialTextureValue = {
393
+ // Runtime material loading interprets texture handles as indexes into
394
+ // this asset's refs[]; the GUID is carried by the corresponding edge.
395
+ texture: refs.length as unknown as MaterialTextureValue['texture'],
396
+ ...(binding.texCoord === undefined ? {} : { coordinates: { set: binding.texCoord } }),
397
+ };
398
+ values[binding.slot] = textureValue;
399
+ refs.push({
400
+ guid: textureGuid,
401
+ sourceField: { componentName: '<material>', fieldName: binding.slot },
402
+ });
403
+ }
404
+ const mat: MaterialAsset = {
405
+ kind: 'material',
406
+ colorSpace: 'linear',
407
+ passes: [
408
+ {
409
+ name: 'Forward',
410
+ program: { module: skinned ? 'forgeax::pbr-skin' : 'forgeax::default-standard-pbr' },
411
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
412
+ },
413
+ ],
414
+ values,
415
+ };
416
+ return {
417
+ guid,
418
+ kind: 'material',
419
+ ...(pod.name !== undefined ? { name: pod.name } : {}),
420
+ payload: mat,
421
+ refs,
422
+ artifacts: {},
423
+ };
424
+ }
425
+
426
+ interface SceneBuildContext {
427
+ /** mesh sourceIndex -> MeshFilter.assetHandle (a scene refs[] index). */
428
+ readonly meshHandleByIndex: ReadonlyMap<number, number>;
429
+ /** mesh sourceIndex carrying the skin deformer; null when the scene has no skin. */
430
+ readonly skinnedMeshIndex: number | null;
431
+ /** Skin.skeleton handle (a scene refs[] index); undefined when no skeleton. */
432
+ readonly skeletonHandle: number | undefined;
433
+ /** Scene refs[]: [mesh..., skeleton..., skin...] GUIDs. */
434
+ readonly refs: readonly string[];
435
+ /**
436
+ * Skin GUIDs (inline strings) injected into the SceneAsset payload and the
437
+ * scene envelope's refs[] so the typed scene load pulls each
438
+ * SkinAsset on the browser-async pack-fetch path. Mirrors gltf-importer:
439
+ * stored as GUID strings (the on-disk round-trip + parseScenePayload's
440
+ * resolveSkinGuids accept both shapes).
441
+ */
442
+ readonly skinGuids: readonly string[];
443
+ readonly animationTargetIds: ReadonlySet<string>;
444
+ readonly morphWeightsByMeshIndex: ReadonlyMap<
445
+ number,
446
+ { readonly targetCount: number; readonly weights?: Float32Array }
447
+ >;
448
+ }
449
+
450
+ function buildSceneAsset(pod: ScenePod, guid: string, ctx: SceneBuildContext): ImportedAsset {
451
+ // ChildOf wiring: ScenePod.children[] holds flattened-array indices; invert to
452
+ // a parent map so each entity emits ChildOf { parent } (localId === array idx).
453
+ const parentOf = new Map<number, number>();
454
+ for (let i = 0; i < pod.entities.length; i++) {
455
+ const e = pod.entities[i];
456
+ if (!e) continue;
457
+ for (const childIdx of e.children ?? []) parentOf.set(childIdx, i);
458
+ }
459
+ const targetIdByEntity = new Map<number, string>();
460
+ const nodePaths = buildFbxNodePaths(pod.entities);
461
+ for (let index = 0; index < nodePaths.length; index++) {
462
+ const path = nodePaths[index];
463
+ if (path === undefined || !path.ok) continue;
464
+ const targetId = deriveAnimationTargetId(path.value);
465
+ if (ctx.animationTargetIds.has(targetId)) targetIdByEntity.set(index, targetId);
466
+ }
467
+
468
+ const entities = pod.entities.map((e, idx) => {
469
+ const components: Record<string, Record<string, unknown>> = {
470
+ Transform: {
471
+ pos: [e.transform.translation[0], e.transform.translation[1], e.transform.translation[2]],
472
+ // Quaternion component order [x, y, z, w] (E6).
473
+ quat: [
474
+ e.transform.rotation[0],
475
+ e.transform.rotation[1],
476
+ e.transform.rotation[2],
477
+ e.transform.rotation[3],
478
+ ],
479
+ scale: [e.transform.scale[0], e.transform.scale[1], e.transform.scale[2]],
480
+ },
481
+ };
482
+
483
+ // Name is required for postSpawnResolveJoints to match SkinAsset.jointPaths
484
+ // against the spawned subtree (the skeleton joint resolution path).
485
+ if (e.name) components.Name = { value: e.name };
486
+ const targetId = targetIdByEntity.get(idx);
487
+ if (targetId !== undefined) components.AnimationTargetId = { value: targetId };
488
+
489
+ const parent = parentOf.get(idx);
490
+ if (parent !== undefined) components.ChildOf = { parent };
491
+
492
+ if (e.meshIndex !== null) {
493
+ const meshHandle = ctx.meshHandleByIndex.get(e.meshIndex);
494
+ if (meshHandle !== undefined) components.MeshFilter = { assetHandle: meshHandle };
495
+
496
+ components.MeshRenderer = { materials: [] };
497
+ const morph = ctx.morphWeightsByMeshIndex.get(e.meshIndex);
498
+ if (morph !== undefined && morph.targetCount > 0) {
499
+ const weights = morph.weights ?? new Float32Array(morph.targetCount);
500
+ if (weights.length !== morph.targetCount) {
501
+ throw new Error('fbxScene: MorphWeights length does not match morph targets');
502
+ }
503
+ components.MorphWeights = { weights: Array.from(weights) };
504
+ }
505
+ // Skinned mesh node carries Skin { skeleton: <handle> }; instantiate
506
+ // resolves the handle and postSpawnResolveJoints fills Skin.joints[].
507
+ if (ctx.skinnedMeshIndex === e.meshIndex && ctx.skeletonHandle !== undefined) {
508
+ components.Skin = { skeleton: ctx.skeletonHandle };
509
+ }
510
+ }
511
+
512
+ return { localId: idx as never, components };
513
+ });
514
+
515
+ const scene: SceneAsset = {
516
+ kind: 'scene',
517
+ entities,
518
+ ...(ctx.skinGuids.length > 0 ? { skinGuids: ctx.skinGuids } : {}),
519
+ } as SceneAsset;
520
+
521
+ return {
522
+ guid,
523
+ kind: 'scene',
524
+ ...(pod.name !== undefined ? { name: pod.name } : {}),
525
+ payload: scene,
526
+ refs: ctx.refs.map((guid) => ({ guid })),
527
+ artifacts: {},
528
+ };
529
+ }
530
+
531
+ function buildTextureAsset(
532
+ pod: TexturePod,
533
+ guid: string,
534
+ decoded: FbxDecodedTexture,
535
+ ): ImportedAsset {
536
+ return {
537
+ guid,
538
+ kind: 'texture',
539
+ ...(pod.name !== undefined ? { name: pod.name } : {}),
540
+ payload: decoded.texture,
541
+ refs: [],
542
+ artifacts: {
543
+ body: {
544
+ mediaType: decoded.mediaType?.startsWith('image/ktx2')
545
+ ? decoded.mediaType
546
+ : 'application/x-forgeax-rgba8',
547
+ ...(decoded.assetCodec === undefined ? {} : { assetCodec: decoded.assetCodec }),
548
+ bytes: decoded.bytes,
549
+ },
550
+ },
551
+ };
552
+ }
553
+
554
+ export function toAssetPack(params: {
555
+ readonly meshes: readonly MeshPod[];
556
+ readonly scene: ScenePod;
557
+ readonly materials: readonly MaterialPod[];
558
+ readonly textures: readonly TexturePod[];
559
+ readonly skeleton: SkeletonPod;
560
+ readonly skin: SkinPod;
561
+ readonly animationClips: readonly AnimationClipPod[];
562
+ readonly subAssets: readonly SubAsset[];
563
+ readonly sourceOverrides?: SourceOverrideMap;
564
+ readonly decodedTextures?: ReadonlyMap<number, FbxDecodedTexture>;
565
+ }): readonly ImportedAsset[] {
566
+ const assets: ImportedAsset[] = [];
567
+ const guidOf = makeGuidResolver(params.subAssets);
568
+ const textureGuidByIndex = new Map<number, string>();
569
+ for (const texture of params.subAssets) {
570
+ if (texture.kind === 'texture') textureGuidByIndex.set(texture.sourceIndex, texture.guid);
571
+ }
572
+ const materialGuidByIndex = new Map<number, string>();
573
+ const materialNameByIndex = new Map<number, string>();
574
+ const materialSourceKeyByIndex = new Map<number, string>();
575
+ for (let materialIndex = 0; materialIndex < params.materials.length; materialIndex++) {
576
+ const materialGuid = guidOf('material', materialIndex);
577
+ if (materialGuid !== undefined) materialGuidByIndex.set(materialIndex, materialGuid);
578
+ const materialSourceKey = params.subAssets.find(
579
+ (entry) => entry.kind === 'material' && entry.sourceIndex === materialIndex,
580
+ )?.sourceKey;
581
+ if (materialSourceKey !== undefined) {
582
+ materialSourceKeyByIndex.set(materialIndex, materialSourceKey);
583
+ }
584
+ const materialName = params.materials[materialIndex]?.name;
585
+ if (materialName !== undefined) materialNameByIndex.set(materialIndex, materialName);
586
+ }
587
+
588
+ // The skin deforms the (single) first mesh; its per-vertex influences promote
589
+ // both the mesh (18-float skinned layout) and its material (pbr-skin shader).
590
+ const hasSkin = params.skin.vertexCount > 0;
591
+ const skinnedMeshSourceIndex = hasSkin ? (params.meshes[0]?.sourceIndex ?? null) : null;
592
+
593
+ for (const mesh of params.meshes) {
594
+ const meshDeclaration = params.subAssets.find(
595
+ (entry) => entry.kind === 'mesh' && entry.sourceIndex === mesh.sourceIndex,
596
+ );
597
+ const guid = meshDeclaration?.guid;
598
+ if (guid === undefined) continue;
599
+ const previousRaw =
600
+ meshDeclaration?.sourceKey === undefined
601
+ ? undefined
602
+ : params.sourceOverrides?.[meshDeclaration.sourceKey]?.materialSlots;
603
+ const previousMaterialSlots = Array.isArray(previousRaw)
604
+ ? previousRaw.filter(
605
+ (slot): slot is MeshMaterialSlotTopologyEntry =>
606
+ slot !== null &&
607
+ typeof slot === 'object' &&
608
+ !Array.isArray(slot) &&
609
+ typeof (slot as { slotName?: unknown }).slotName === 'string',
610
+ )
611
+ : undefined;
612
+ const authoredRaw =
613
+ meshDeclaration?.sourceKey === undefined
614
+ ? undefined
615
+ : params.sourceOverrides?.[meshDeclaration.sourceKey]?.materialSlotDefaultOverrides;
616
+ const materialSlotDefaultOverrides =
617
+ authoredRaw !== null && typeof authoredRaw === 'object' && !Array.isArray(authoredRaw)
618
+ ? Object.fromEntries(
619
+ Object.entries(authoredRaw).filter(
620
+ (entry): entry is [string, string | null] =>
621
+ typeof entry[1] === 'string' || entry[1] === null,
622
+ ),
623
+ )
624
+ : undefined;
625
+ const inf = mesh.sourceIndex === skinnedMeshSourceIndex ? params.skin.influences : undefined;
626
+ assets.push(
627
+ buildMeshAsset(mesh, guid, inf, {
628
+ guidByIndex: materialGuidByIndex,
629
+ nameByIndex: materialNameByIndex,
630
+ sourceKeyByIndex: materialSourceKeyByIndex,
631
+ ...(previousMaterialSlots === undefined ? {} : { previousMaterialSlots }),
632
+ ...(materialSlotDefaultOverrides === undefined ? {} : { materialSlotDefaultOverrides }),
633
+ ...(meshDeclaration?.sourceKey === undefined
634
+ ? {}
635
+ : { meshSourceKey: meshDeclaration.sourceKey }),
636
+ }),
637
+ );
638
+ }
639
+
640
+ for (let i = 0; i < params.materials.length; i++) {
641
+ const mat = params.materials[i];
642
+ if (!mat) continue;
643
+ const guid = guidOf('material', i);
644
+ // Single-mesh fixtures: any material is consumed by the skinned mesh.
645
+ if (guid !== undefined) {
646
+ assets.push(buildMaterialAsset(mat, guid, hasSkin, textureGuidByIndex));
647
+ }
648
+ }
649
+
650
+ for (const tex of params.textures) {
651
+ const guid = guidOf('texture', tex.sourceIndex);
652
+ if (guid !== undefined) {
653
+ const decoded = params.decodedTextures?.get(tex.sourceIndex);
654
+ if (decoded === undefined) {
655
+ throw new Error(`fbx texture ${tex.sourceIndex} was declared but not decoded`);
656
+ }
657
+ assets.push(buildTextureAsset(tex, guid, decoded));
658
+ }
659
+ }
660
+
661
+ // Scene refs[] ordering (mirror of gltfImporter): only direct scene
662
+ // dependencies live here. Mesh defaults belong to MeshAsset refs and
663
+ // textures belong to MaterialAsset refs, so the scene concatenates
664
+ // [mesh GUIDs..., skeleton GUIDs..., skin GUIDs...]
665
+ // each section in declared sourceIndex order. Build the section -> refs-index
666
+ // maps here so buildSceneAsset can stamp the right indices.
667
+ const declaredByKind = (kind: string): string[] =>
668
+ params.subAssets
669
+ .filter((s) => s.kind === kind)
670
+ .slice()
671
+ .sort((a, b) => a.sourceIndex - b.sourceIndex)
672
+ .map((s) => s.guid);
673
+
674
+ const meshGuids = declaredByKind('mesh');
675
+ const skeletonGuids = declaredByKind('skeleton');
676
+ const skinGuids = declaredByKind('skin');
677
+ const sceneRefs = [...meshGuids, ...skeletonGuids, ...skinGuids];
678
+
679
+ // mesh sourceIndex -> scene refs[] index (mesh section starts at 0).
680
+ const meshHandleByIndex = new Map<number, number>();
681
+ params.subAssets
682
+ .filter((s) => s.kind === 'mesh')
683
+ .forEach((s) => {
684
+ const idx = meshGuids.indexOf(s.guid);
685
+ if (idx >= 0) meshHandleByIndex.set(s.sourceIndex, idx);
686
+ });
687
+ // skeleton handle = first skeleton's scene refs[] index.
688
+ const skeletonRefBase = meshGuids.length;
689
+ const skeletonHandle = skeletonGuids.length > 0 ? skeletonRefBase : undefined;
690
+ // Single-mesh fixtures: the skin deforms the (only) mesh node.
691
+ const skinnedMeshIndex =
692
+ params.skin.vertexCount > 0 ? (params.meshes[0]?.sourceIndex ?? null) : null;
693
+
694
+ // Skeleton asset (t48). refs[] empty (skeleton is leaf data). The runtime
695
+ // SkeletonAsset POD is { inverseBindMatrices, jointCount } — joint paths live
696
+ // on the parallel SkinAsset (mirror of gltfImporter's emit policy). The native
697
+ // binding derives the skeleton from the skin's clusters, so jointCount /
698
+ // inverseBindMatrices already align 1:1 with the skin's jointPaths and the
699
+ // per-vertex skinIndex influences (runtime requires
700
+ // SkeletonAsset.jointCount === Skin.joints.length).
701
+ const skeletonGuid = guidOf('skeleton', 0);
702
+ if (params.skeleton.jointCount > 0 && skeletonGuid !== undefined) {
703
+ assets.push({
704
+ guid: skeletonGuid,
705
+ kind: 'skeleton',
706
+ payload: {
707
+ kind: 'skeleton',
708
+ inverseBindMatrices: params.skeleton.inverseBindMatrices,
709
+ jointCount: params.skeleton.jointCount,
710
+ } as never,
711
+ refs: [],
712
+ artifacts: {},
713
+ });
714
+ }
715
+
716
+ // Skin asset (t49). The runtime SkinAsset POD is { skeletonGuid, jointPaths };
717
+ // per-vertex influences live in the mesh vertex buffer, not the SkinAsset.
718
+ // refs[] carries the skeleton GUID cross-edge (mirror of gltfImporter) so the
719
+ // runner / runtime asset graph sees the skin->skeleton link.
720
+ const skinGuid = guidOf('skin', 0);
721
+ if (params.skin.vertexCount > 0 && skinGuid !== undefined) {
722
+ assets.push({
723
+ guid: skinGuid,
724
+ kind: 'skin',
725
+ payload: {
726
+ kind: 'skin',
727
+ skeletonGuid: skeletonGuid ?? '',
728
+ jointPaths: params.skin.jointPaths,
729
+ } as never,
730
+ refs:
731
+ skeletonGuid !== undefined
732
+ ? [{ guid: skeletonGuid, sourceField: { fieldName: 'skeleton' } }]
733
+ : [],
734
+ artifacts: {},
735
+ });
736
+ }
737
+
738
+ // Animation clip assets (t50)
739
+ for (let i = 0; i < params.animationClips.length; i++) {
740
+ const clip = params.animationClips[i];
741
+ if (!clip) continue;
742
+ const guid = guidOf('animation-clip', i);
743
+ if (guid === undefined) continue;
744
+ assets.push({
745
+ guid,
746
+ kind: 'animation-clip',
747
+ payload: {
748
+ kind: 'animation-clip',
749
+ name: clip.name ?? `Clip${i}`,
750
+ duration: clip.duration,
751
+ channels: clip.channels.map((ch) => ({
752
+ targetId: ch.targetId,
753
+ property: ch.property,
754
+ sampler: {
755
+ input: Array.from(ch.sampler.input),
756
+ output: Array.from(ch.sampler.output),
757
+ interpolation: ch.sampler.interpolation,
758
+ },
759
+ })),
760
+ } as never,
761
+ refs: [],
762
+ artifacts: {},
763
+ });
764
+ }
765
+
766
+ const sceneGuid = guidOf('scene', 0);
767
+ const animationTargetIds = new Set(
768
+ params.animationClips.flatMap((clip) => clip.channels.map((channel) => channel.targetId)),
769
+ );
770
+ const morphWeightsByMeshIndex = new Map<
771
+ number,
772
+ { readonly targetCount: number; readonly weights?: Float32Array }
773
+ >();
774
+ for (const mesh of params.meshes) {
775
+ const targetCount = mesh.morphTargets?.length ?? 0;
776
+ if (targetCount > 0) {
777
+ morphWeightsByMeshIndex.set(mesh.sourceIndex, {
778
+ targetCount,
779
+ ...(mesh.morphWeights === undefined
780
+ ? {}
781
+ : { weights: new Float32Array(mesh.morphWeights) }),
782
+ });
783
+ }
784
+ }
785
+ if (sceneGuid !== undefined) {
786
+ assets.push(
787
+ buildSceneAsset(params.scene, sceneGuid, {
788
+ meshHandleByIndex,
789
+ skinnedMeshIndex,
790
+ skeletonHandle,
791
+ refs: sceneRefs,
792
+ skinGuids,
793
+ animationTargetIds,
794
+ morphWeightsByMeshIndex,
795
+ }),
796
+ );
797
+ }
798
+
799
+ // XOR identity rule (aligned with the glTF importer): a single-asset package
800
+ // derives its name from the package path, so the stored entry name is dropped.
801
+ // Only multi-asset packages keep per-entry stored names. FBX is almost always
802
+ // multi-asset (a scene asset is always emitted), so this strips name only in
803
+ // the degenerate single-asset case.
804
+ if (assets.length === 1) {
805
+ const only = assets[0];
806
+ if (only && 'name' in only) {
807
+ const { name: _dropped, ...rest } = only;
808
+ assets[0] = rest;
809
+ }
810
+ }
811
+
812
+ return assets;
813
+ }