@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,271 @@
1
+ // parse-animation-clip.ts — M5 t50: TS bridge for animation clip POD data.
2
+ //
3
+ // Consumes the C++ JSON POD clips section emitted by binding.cc
4
+ // (WriteAnimationData), linear-resamples each channel to a fixed fps,
5
+ // produces AnimationClipPod (types SSOT per requirements AC-07).
6
+ //
7
+ // Input schema (from JSON.parse of binding output) — flat per-channel timeline:
8
+ // clips?: [{
9
+ // name?: string,
10
+ // duration: number,
11
+ // channels: [{
12
+ // targetNode: string,
13
+ // property: 'translation'|'rotation'|'scale'|'weights',
14
+ // keyTimes: number[], // ascending, seconds
15
+ // keyValues: number[], // interleaved, stride 3/4 or weightCount
16
+ // weightCount?: number, // required for 'weights'
17
+ // }],
18
+ // }]
19
+ //
20
+ // The binding emits rotation as a real unit quaternion sampled from
21
+ // node->EvaluateLocalTransform(t).GetQ() (NOT raw euler-degree curves), so the
22
+ // bridge only resamples + re-normalizes; it never euler-converts.
23
+ //
24
+ // Processing:
25
+ // linear resample: evaluate the strided keyValues at each framestep (1/fps)
26
+ // rotation: per-component lerp then normalize (nlerp); runtime slerps further
27
+ // output: AnimationClipPod.channels[].sampler.{input,output,interpolation:LINEAR}
28
+
29
+ import { deriveAnimationTargetId } from '@forgeax/engine-animation/target-id';
30
+ import type {
31
+ AnimationChannelPod,
32
+ AnimationClipPod,
33
+ AnimationSamplerPod,
34
+ } from '@forgeax/engine-types';
35
+ import { err, type FbxError, fbxErr, ok, type Result } from './errors.js';
36
+ import { buildFbxNodePaths } from './parse-scene.js';
37
+
38
+ export interface FbxRawAnimChannel {
39
+ readonly targetNode: string;
40
+ readonly property: 'translation' | 'rotation' | 'scale' | 'weights';
41
+ /** Ascending key timestamps in seconds. */
42
+ readonly keyTimes?: number[];
43
+ /** Interleaved key values, stride 3/4 or weightCount for morph weights. */
44
+ readonly keyValues?: number[];
45
+ /** Number of morph weight elements in each sampled key. */
46
+ readonly weightCount?: number;
47
+ }
48
+
49
+ export interface FbxRawClip {
50
+ readonly name?: string;
51
+ readonly duration: number;
52
+ readonly channels: readonly FbxRawAnimChannel[];
53
+ }
54
+
55
+ export interface FbxRawAnimDoc {
56
+ readonly clips?: readonly FbxRawClip[];
57
+ }
58
+
59
+ const DEFAULT_FPS = 30;
60
+
61
+ /**
62
+ * Linear-interpolate a strided keyValues stream at time t into `out`.
63
+ *
64
+ * keyTimes is ascending; keyValues holds `stride` floats per key. Clamps to
65
+ * the first/last key outside the range. For rotation (stride 4) the result is
66
+ * a per-component lerp; the caller normalizes it (nlerp) — runtime playback
67
+ * slerps further, so import-time nlerp is sufficient to keep unit length.
68
+ */
69
+ function sampleStrided(
70
+ keyTimes: readonly number[],
71
+ keyValues: readonly number[],
72
+ stride: number,
73
+ t: number,
74
+ out: number[],
75
+ rotation: boolean,
76
+ ): void {
77
+ const n = keyTimes.length;
78
+ if (n === 0) {
79
+ for (let c = 0; c < stride; c++) out[c] = rotation && c === 3 ? 1 : 0;
80
+ return;
81
+ }
82
+ const first = keyTimes[0] as number;
83
+ const lastIdx = n - 1;
84
+ const last = keyTimes[lastIdx] as number;
85
+ if (t <= first) {
86
+ for (let c = 0; c < stride; c++) out[c] = keyValues[c] ?? 0;
87
+ return;
88
+ }
89
+ if (t >= last) {
90
+ for (let c = 0; c < stride; c++) out[c] = keyValues[lastIdx * stride + c] ?? 0;
91
+ return;
92
+ }
93
+ // Find bracket [i-1, i].
94
+ let i = 1;
95
+ while (i < n && (keyTimes[i] as number) < t) i++;
96
+ const t0 = keyTimes[i - 1] as number;
97
+ const t1 = keyTimes[i] as number;
98
+ const frac = t1 > t0 ? (t - t0) / (t1 - t0) : 0;
99
+ const b0 = (i - 1) * stride;
100
+ const b1 = i * stride;
101
+ for (let c = 0; c < stride; c++) {
102
+ const v0 = keyValues[b0 + c] ?? 0;
103
+ const v1 = keyValues[b1 + c] ?? 0;
104
+ out[c] = v0 + (v1 - v0) * frac;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Build a per-frame sampler for one channel: resample the flat strided
110
+ * keyValues onto a uniform timeline at the given fps. Rotation channels are
111
+ * re-normalized per frame so the lerp output stays a unit quaternion.
112
+ */
113
+ function buildSampler(ch: FbxRawAnimChannel, duration: number, fps: number): AnimationSamplerPod {
114
+ const frameInterval = 1 / fps;
115
+ const frameCount = Math.floor(duration * fps) + 1;
116
+
117
+ const input = new Float32Array(frameCount);
118
+ for (let f = 0; f < frameCount; f++) {
119
+ input[f] = f * frameInterval;
120
+ }
121
+
122
+ const stride =
123
+ ch.property === 'rotation' ? 4 : ch.property === 'weights' ? (ch.weightCount ?? 0) : 3;
124
+ if (ch.property === 'weights' && (!Number.isInteger(stride) || stride < 1 || stride > 8)) {
125
+ throw new Error('fbx-morph-invalid: weight channel count must be an integer in [1, 8]');
126
+ }
127
+ const output = new Float32Array(frameCount * stride);
128
+
129
+ const keyTimes = ch.keyTimes ?? [];
130
+ const keyValues = ch.keyValues ?? [];
131
+ if (keyTimes.length > 0 && keyValues.length !== keyTimes.length * stride) {
132
+ throw new Error('fbx-morph-invalid: animation key value width does not match key times');
133
+ }
134
+ const tmp = new Array<number>(stride);
135
+
136
+ for (let f = 0; f < frameCount; f++) {
137
+ const t = input[f] as number;
138
+ sampleStrided(keyTimes, keyValues, stride, t, tmp, ch.property === 'rotation');
139
+ const base = f * stride;
140
+ if (ch.property === 'rotation') {
141
+ const len = Math.hypot(tmp[0] ?? 0, tmp[1] ?? 0, tmp[2] ?? 0, tmp[3] ?? 0);
142
+ const inv = len > 0 ? 1 / len : 0;
143
+ output[base + 0] = (tmp[0] ?? 0) * inv;
144
+ output[base + 1] = (tmp[1] ?? 0) * inv;
145
+ output[base + 2] = (tmp[2] ?? 0) * inv;
146
+ output[base + 3] = len > 0 ? (tmp[3] ?? 0) * inv : 1;
147
+ } else {
148
+ for (let c = 0; c < stride; c++) output[base + c] = tmp[c] ?? 0;
149
+ }
150
+ }
151
+
152
+ return { input, output, interpolation: 'LINEAR' };
153
+ }
154
+
155
+ function buildChannel(ch: FbxRawAnimChannel, duration: number, fps: number): AnimationChannelPod {
156
+ return {
157
+ targetId: deriveAnimationTargetId(ch.targetNode.split('/')),
158
+ property: ch.property,
159
+ sampler: buildSampler(ch, duration, fps),
160
+ };
161
+ }
162
+
163
+ interface FbxAnimationNode {
164
+ readonly name: string;
165
+ readonly children: readonly number[];
166
+ }
167
+
168
+ export function resolveAnimationTargetIds(
169
+ nodes: readonly FbxAnimationNode[],
170
+ clips: readonly FbxRawClip[],
171
+ ): Result<void, FbxError> {
172
+ const nodesByPath = new Map<string, number[]>();
173
+ const nodePaths = buildFbxNodePaths(nodes);
174
+ const cycle = nodePaths.find((path) => !path.ok && path.reason === 'hierarchy-cycle');
175
+ if (cycle && !cycle.ok) {
176
+ return err(
177
+ fbxErr('fbx-animation-target-invalid', {
178
+ reason: 'hierarchy-cycle',
179
+ nodeIndex: cycle.nodeIndex,
180
+ }),
181
+ );
182
+ }
183
+ const hasAmbiguousName = nodePaths.some((path) => !path.ok);
184
+ for (let index = 0; index < nodePaths.length; index++) {
185
+ const result = nodePaths[index];
186
+ if (result === undefined || !result.ok) continue;
187
+ const path = result.value.join('/');
188
+ const matches = nodesByPath.get(path) ?? [];
189
+ matches.push(index);
190
+ nodesByPath.set(path, matches);
191
+ }
192
+
193
+ const pathById = new Map<string, string>();
194
+ for (let clipIndex = 0; clipIndex < clips.length; clipIndex++) {
195
+ const clip = clips[clipIndex];
196
+ if (clip === undefined) continue;
197
+ for (let channelIndex = 0; channelIndex < clip.channels.length; channelIndex++) {
198
+ const channel = clip.channels[channelIndex];
199
+ if (channel === undefined) continue;
200
+ const segments = channel.targetNode.split('/');
201
+ if (segments.some((segment) => segment.length === 0)) {
202
+ return err(
203
+ fbxErr('fbx-animation-target-invalid', {
204
+ reason: 'path-invalid',
205
+ clipIndex,
206
+ channelIndex,
207
+ targetNode: channel.targetNode,
208
+ }),
209
+ );
210
+ }
211
+ const matches = nodesByPath.get(channel.targetNode);
212
+ if (matches === undefined) {
213
+ return err(
214
+ fbxErr('fbx-animation-target-invalid', {
215
+ reason: hasAmbiguousName ? 'name-missing' : 'path-not-found',
216
+ clipIndex,
217
+ channelIndex,
218
+ targetNode: channel.targetNode,
219
+ }),
220
+ );
221
+ }
222
+ if (matches.length !== 1) {
223
+ return err(
224
+ fbxErr('fbx-animation-target-invalid', {
225
+ reason: 'path-duplicate',
226
+ clipIndex,
227
+ channelIndex,
228
+ targetNode: channel.targetNode,
229
+ }),
230
+ );
231
+ }
232
+ const id = deriveAnimationTargetId(segments);
233
+ const previous = pathById.get(id);
234
+ if (previous !== undefined && previous !== channel.targetNode) {
235
+ return err(
236
+ fbxErr('fbx-animation-target-invalid', {
237
+ reason: 'id-collision',
238
+ clipIndex,
239
+ channelIndex,
240
+ targetNode: channel.targetNode,
241
+ }),
242
+ );
243
+ }
244
+ pathById.set(id, channel.targetNode);
245
+ }
246
+ }
247
+ return ok(undefined);
248
+ }
249
+
250
+ /**
251
+ * Parse animation clips from a C++ JSON POD document.
252
+ * Performs merge-keys + linear resample at the requested fps
253
+ * (default 30). OOS-9: Hermite tangent data is discarded.
254
+ *
255
+ * Returns an empty array when the document has no clip data.
256
+ */
257
+ export function parseAnimationClips(
258
+ doc: FbxRawAnimDoc,
259
+ fps: number = DEFAULT_FPS,
260
+ ): AnimationClipPod[] {
261
+ const clips = doc.clips;
262
+ if (!clips || clips.length === 0) return [];
263
+
264
+ return clips.map((clip): AnimationClipPod => {
265
+ return {
266
+ ...(clip.name !== undefined && { name: clip.name }),
267
+ duration: clip.duration,
268
+ channels: clip.channels.map((ch) => buildChannel(ch, clip.duration, fps)),
269
+ };
270
+ });
271
+ }
@@ -0,0 +1,140 @@
1
+ // parse-material.ts — FBX JSON POD to MaterialPod bridge (t40).
2
+ //
3
+ // Material mapping priority (plan-strategy §D-3):
4
+ // 1. StingrayPBS → channels mapped directly (baseColor/metallic/roughness)
5
+ // 2. Phong → Family A formula: roughness = 1 - sqrt(shininess/100)
6
+ // baseColor = diffuse, metallic = 0
7
+ // 3. Lambert → same formula as Phong (max_gloss=100 default)
8
+ // 4. Fallback → default grey PBR: baseColor=[0.5,0.5,0.5], metal=0, rough=0.5
9
+
10
+ import type { MaterialPod, MaterialTextureBindingPod } from '@forgeax/engine-types';
11
+
12
+ export interface FbxRawTextureBinding {
13
+ readonly slot: MaterialTextureBindingPod['slot'];
14
+ readonly textureIndex: number;
15
+ readonly texCoord?: number;
16
+ }
17
+
18
+ /** JSON shape emitted by binding.cc WriteMaterials for a single material. */
19
+ export interface FbxRawMaterial {
20
+ readonly name?: string;
21
+ readonly kind: 'stingray-pbs' | 'phong' | 'lambert' | 'fallback';
22
+ readonly baseColor?: readonly [number, number, number];
23
+ readonly diffuse?: readonly [number, number, number];
24
+ readonly shininess?: number;
25
+ readonly specular?: readonly [number, number, number];
26
+ readonly textureBindings?: readonly FbxRawTextureBinding[];
27
+ readonly stingrayProps?: {
28
+ readonly baseColor?: readonly [number, number, number];
29
+ readonly metallic?: number;
30
+ readonly roughness?: number;
31
+ readonly emissive?: readonly [number, number, number];
32
+ };
33
+ }
34
+
35
+ function clamp(v: number, lo: number, hi: number): number {
36
+ return v < lo ? lo : v > hi ? hi : v;
37
+ }
38
+
39
+ /** Family A formula: roughness = 1 - sqrt(shininess / maxGloss), clamped [0, 1]. */
40
+ function phongRoughness(shininess: number, maxGloss = 100): number {
41
+ return clamp(1 - Math.sqrt(shininess / maxGloss), 0, 1);
42
+ }
43
+
44
+ /**
45
+ * Parse a single FBX raw material JSON node into a MaterialPod.
46
+ *
47
+ * The caller (fbx-importer.ts) iterates the top-level `materials` array
48
+ * from the C++ binding JSON output.
49
+ */
50
+ export function parseMaterial(raw: FbxRawMaterial, sourceIndex: number): MaterialPod {
51
+ void sourceIndex; // reserved for future cross-referencing
52
+
53
+ const textureBindings = raw.textureBindings?.map((binding) => ({
54
+ slot: binding.slot,
55
+ textureIndex: binding.textureIndex,
56
+ ...(binding.texCoord === undefined ? {} : { texCoord: binding.texCoord }),
57
+ }));
58
+ const textureFields: Pick<
59
+ MaterialPod,
60
+ | 'textureBindings'
61
+ | 'baseColorTextureIndex'
62
+ | 'metallicRoughnessTextureIndex'
63
+ | 'normalTextureIndex'
64
+ | 'occlusionTextureIndex'
65
+ | 'emissiveTextureIndex'
66
+ | 'specularTintTextureIndex'
67
+ > =
68
+ textureBindings === undefined || textureBindings.length === 0
69
+ ? {}
70
+ : {
71
+ textureBindings,
72
+ ...Object.fromEntries(
73
+ textureBindings.map((binding) => [
74
+ binding.slot === 'baseColorTexture'
75
+ ? 'baseColorTextureIndex'
76
+ : binding.slot === 'metallicRoughnessTexture'
77
+ ? 'metallicRoughnessTextureIndex'
78
+ : binding.slot === 'normalTexture'
79
+ ? 'normalTextureIndex'
80
+ : binding.slot === 'occlusionTexture'
81
+ ? 'occlusionTextureIndex'
82
+ : binding.slot === 'emissiveTexture'
83
+ ? 'emissiveTextureIndex'
84
+ : 'specularTintTextureIndex',
85
+ binding.textureIndex,
86
+ ]),
87
+ ),
88
+ };
89
+
90
+ switch (raw.kind) {
91
+ case 'stingray-pbs': {
92
+ const sp = raw.stingrayProps ?? {};
93
+ return {
94
+ ...(raw.name !== undefined && { name: raw.name }),
95
+ baseColorFactor: [
96
+ sp.baseColor?.[0] ?? 0.5,
97
+ sp.baseColor?.[1] ?? 0.5,
98
+ sp.baseColor?.[2] ?? 0.5,
99
+ 1,
100
+ ],
101
+ metallicFactor: sp.metallic ?? 0,
102
+ roughnessFactor: sp.roughness ?? 0.5,
103
+ ...textureFields,
104
+ };
105
+ }
106
+
107
+ case 'phong': {
108
+ const d = raw.diffuse ?? raw.baseColor ?? [0.5, 0.5, 0.5];
109
+ const gloss = raw.shininess ?? 100;
110
+ return {
111
+ ...(raw.name !== undefined && { name: raw.name }),
112
+ baseColorFactor: [d[0] ?? 0.5, d[1] ?? 0.5, d[2] ?? 0.5, 1],
113
+ metallicFactor: 0,
114
+ roughnessFactor: phongRoughness(gloss),
115
+ ...textureFields,
116
+ };
117
+ }
118
+
119
+ case 'lambert': {
120
+ const d = raw.diffuse ?? raw.baseColor ?? [0.5, 0.5, 0.5];
121
+ return {
122
+ ...(raw.name !== undefined && { name: raw.name }),
123
+ baseColorFactor: [d[0] ?? 0.5, d[1] ?? 0.5, d[2] ?? 0.5, 1],
124
+ metallicFactor: 0,
125
+ roughnessFactor: 0.5, // lambert has no specular → default roughness
126
+ ...textureFields,
127
+ };
128
+ }
129
+
130
+ default: {
131
+ // fallback: no recognized material type
132
+ return {
133
+ baseColorFactor: [0.5, 0.5, 0.5, 1],
134
+ metallicFactor: 0,
135
+ roughnessFactor: 0.5,
136
+ ...textureFields,
137
+ };
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,223 @@
1
+ // parse-mesh.ts — FBX JSON POD to MeshPod bridge (t28).
2
+
3
+ import type { MeshPod, MorphTarget } from '@forgeax/engine-types';
4
+
5
+ const MORPH_MAX_TARGETS = 8;
6
+ const MORPH_MAX_ATTRIBUTES = 8;
7
+
8
+ export interface FbxRawMesh {
9
+ readonly name?: string;
10
+ readonly vertices: number[];
11
+ readonly indices?: number[];
12
+ readonly attributes: Record<string, number[]>;
13
+ readonly polygonCount: number;
14
+ readonly sourceIndex: number;
15
+ readonly materialIndex: number;
16
+ readonly morphTargets?: readonly {
17
+ readonly position?: number[];
18
+ readonly normal?: number[];
19
+ readonly tangent?: number[];
20
+ }[];
21
+ readonly morphWeights?: number[];
22
+ }
23
+
24
+ export interface FbxRawDocument {
25
+ readonly meshes?: readonly FbxRawMesh[];
26
+ }
27
+
28
+ // feat-20260629-multi-uv-set-support m1-w6: TEXCOORD key prefix for attribute
29
+ // naming. Used by both parse-mesh (de-index path) and to-asset-pack (consumer).
30
+ const TEXCOORD_PREFIX = 'TEXCOORD_';
31
+
32
+ // FBX maps positions per control-point but normals / UVs per polygon-vertex
33
+ // (per index corner). When an attribute's element count equals the index count
34
+ // (and not the position count), it is corner-mapped and must be de-indexed:
35
+ // expand to one vertex per corner so positions / normals / uvs stay parallel
36
+ // (the layout buildMeshAsset and the engine vertex buffer assume). A glTF-style
37
+ // mesh (attributes already per-vertex) skips this path untouched.
38
+ function isCornerMapped(
39
+ attrLen: number,
40
+ components: number,
41
+ posCount: number,
42
+ idxCount: number,
43
+ ): boolean {
44
+ const elems = attrLen / components;
45
+ return idxCount > 0 && elems === idxCount && elems !== posCount;
46
+ }
47
+
48
+ export function parseMesh(raw: FbxRawMesh, sourceIndex: number): MeshPod {
49
+ const rawIndices = raw.indices && raw.indices.length > 0 ? raw.indices : undefined;
50
+ const posCount = raw.vertices.length / 3;
51
+ const idxCount = rawIndices?.length ?? 0;
52
+ const normal = raw.attributes.NORMAL;
53
+ const rawMorphTargets = raw.morphTargets ?? [];
54
+ const morphAttributeCount = rawMorphTargets.reduce(
55
+ (count, target) =>
56
+ count + (target.position ? 1 : 0) + (target.normal ? 1 : 0) + (target.tangent ? 1 : 0),
57
+ 0,
58
+ );
59
+ if (rawMorphTargets.length > MORPH_MAX_TARGETS || morphAttributeCount > MORPH_MAX_ATTRIBUTES) {
60
+ throw new Error('fbx-morph-invalid: morph target or attribute limit exceeded');
61
+ }
62
+ if (
63
+ rawMorphTargets.some(
64
+ (target) =>
65
+ target.position === undefined &&
66
+ target.normal === undefined &&
67
+ target.tangent === undefined,
68
+ )
69
+ ) {
70
+ throw new Error('fbx-morph-invalid: every target must contain POSITION, NORMAL, or TANGENT');
71
+ }
72
+ const morphTargets = rawMorphTargets.map((target): MorphTarget => {
73
+ const out: {
74
+ position?: Float32Array;
75
+ normal?: Float32Array;
76
+ tangent?: Float32Array;
77
+ } = {};
78
+ for (const [key, values, components] of [
79
+ ['position', target.position, 3],
80
+ ['normal', target.normal, 3],
81
+ ['tangent', target.tangent, 4],
82
+ ] as const) {
83
+ if (values === undefined) continue;
84
+ if (values.length !== posCount * components) {
85
+ throw new Error(`fbx-morph-invalid: ${key} length does not match vertex count`);
86
+ }
87
+ out[key] = new Float32Array(values);
88
+ }
89
+ return out;
90
+ });
91
+ if (raw.morphWeights !== undefined && raw.morphWeights.length !== morphTargets.length) {
92
+ throw new Error('fbx-morph-invalid: default weight count does not match target count');
93
+ }
94
+
95
+ // Determine if any attribute is corner-mapped (NORMAL or any TEXCOORD_n).
96
+ const normalCorner = normal !== undefined && isCornerMapped(normal.length, 3, posCount, idxCount);
97
+ const uvCornerMap = new Map<string, boolean>();
98
+ for (const key of Object.keys(raw.attributes)) {
99
+ if (key.startsWith(TEXCOORD_PREFIX)) {
100
+ const arr = raw.attributes[key];
101
+ if (arr !== undefined && isCornerMapped(arr.length, 2, posCount, idxCount)) {
102
+ uvCornerMap.set(key, true);
103
+ }
104
+ }
105
+ }
106
+ const hasAnyCorner = normalCorner || uvCornerMap.size > 0;
107
+
108
+ if (rawIndices !== undefined && hasAnyCorner) {
109
+ // De-index: one expanded vertex per corner. Position pulled through the
110
+ // index; corner-mapped attributes copied straight; per-vertex attributes
111
+ // gathered through the index. New index buffer is the identity sequence.
112
+ const expandedPos = new Float32Array(idxCount * 3);
113
+ const expandedNormal = normal !== undefined ? new Float32Array(idxCount * 3) : undefined;
114
+ // feat-20260629-multi-uv-set-support m1-w6: expand all TEXCOORD_n sets.
115
+ const expandedUvs = new Map<string, Float32Array>();
116
+ for (const key of Object.keys(raw.attributes)) {
117
+ if (key.startsWith(TEXCOORD_PREFIX)) {
118
+ expandedUvs.set(key, new Float32Array(idxCount * 2));
119
+ }
120
+ }
121
+ const expandedMorphTargets = morphTargets.map((target) => {
122
+ const expanded: {
123
+ position?: Float32Array;
124
+ normal?: Float32Array;
125
+ tangent?: Float32Array;
126
+ } = {};
127
+ for (const [key, values, components] of [
128
+ ['position', target.position, 3],
129
+ ['normal', target.normal, 3],
130
+ ['tangent', target.tangent, 4],
131
+ ] as const) {
132
+ if (values === undefined) continue;
133
+ const output = new Float32Array(idxCount * components);
134
+ for (let corner = 0; corner < idxCount; corner++) {
135
+ const vi = rawIndices[corner] ?? 0;
136
+ for (let component = 0; component < components; component++) {
137
+ output[corner * components + component] = values[vi * components + component] ?? 0;
138
+ }
139
+ }
140
+ expanded[key] = output;
141
+ }
142
+ return expanded;
143
+ });
144
+ for (let corner = 0; corner < idxCount; corner++) {
145
+ const vi = rawIndices[corner] ?? 0;
146
+ expandedPos[corner * 3 + 0] = raw.vertices[vi * 3 + 0] ?? 0;
147
+ expandedPos[corner * 3 + 1] = raw.vertices[vi * 3 + 1] ?? 0;
148
+ expandedPos[corner * 3 + 2] = raw.vertices[vi * 3 + 2] ?? 0;
149
+ if (normal !== undefined && expandedNormal !== undefined) {
150
+ const src = normalCorner ? corner : vi;
151
+ expandedNormal[corner * 3 + 0] = normal[src * 3 + 0] ?? 0;
152
+ expandedNormal[corner * 3 + 1] = normal[src * 3 + 1] ?? 0;
153
+ expandedNormal[corner * 3 + 2] = normal[src * 3 + 2] ?? 0;
154
+ }
155
+ for (const [key, expanded] of expandedUvs) {
156
+ const srcArr = raw.attributes[key];
157
+ if (srcArr !== undefined) {
158
+ const src = uvCornerMap.get(key) ? corner : vi;
159
+ expanded[corner * 2 + 0] = srcArr[src * 2 + 0] ?? 0;
160
+ expanded[corner * 2 + 1] = srcArr[src * 2 + 1] ?? 0;
161
+ }
162
+ }
163
+ }
164
+ const identity = new Uint32Array(idxCount);
165
+ for (let i = 0; i < idxCount; i++) identity[i] = i;
166
+ const expandedAttrs: Record<string, Float32Array> = {};
167
+ if (expandedNormal !== undefined) expandedAttrs.NORMAL = expandedNormal;
168
+ for (const [key, val] of expandedUvs) {
169
+ expandedAttrs[key] = val;
170
+ }
171
+ return {
172
+ ...(raw.name !== undefined ? { name: raw.name } : {}),
173
+ vertices: expandedPos,
174
+ indices: identity,
175
+ attributes: expandedAttrs,
176
+ submeshes: [
177
+ {
178
+ topology: 'triangle-list' as const,
179
+ indexOffset: 0,
180
+ indexCount: idxCount,
181
+ vertexCount: idxCount,
182
+ materialIndex: raw.materialIndex >= 0 ? raw.materialIndex : null,
183
+ },
184
+ ],
185
+ sourceIndex,
186
+ ...(expandedMorphTargets.length === 0 ? {} : { morphTargets: expandedMorphTargets }),
187
+ ...(raw.morphWeights === undefined
188
+ ? {}
189
+ : { morphWeights: new Float32Array(raw.morphWeights) }),
190
+ };
191
+ }
192
+
193
+ const vertices = new Float32Array(raw.vertices);
194
+ const indices = rawIndices ? new Uint16Array(rawIndices) : undefined;
195
+
196
+ const attributes: Record<string, Float32Array | Uint16Array | Uint32Array> = {};
197
+ for (const [key, arr] of Object.entries(raw.attributes)) {
198
+ attributes[key] = new Float32Array(arr);
199
+ }
200
+
201
+ const indexCount = indices?.length ?? 0;
202
+
203
+ const submeshes = [
204
+ {
205
+ topology: 'triangle-list' as const,
206
+ indexOffset: 0,
207
+ indexCount,
208
+ vertexCount: vertices.length / 3,
209
+ materialIndex: raw.materialIndex >= 0 ? raw.materialIndex : null,
210
+ },
211
+ ];
212
+
213
+ return {
214
+ ...(raw.name !== undefined ? { name: raw.name } : {}),
215
+ vertices,
216
+ ...(indices ? { indices } : {}),
217
+ attributes,
218
+ submeshes,
219
+ sourceIndex,
220
+ ...(morphTargets.length === 0 ? {} : { morphTargets }),
221
+ ...(raw.morphWeights === undefined ? {} : { morphWeights: new Float32Array(raw.morphWeights) }),
222
+ };
223
+ }