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