@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,372 @@
1
+ // fbx-importer.ts — TS wrapper around the ufbx WASM parser.
2
+
3
+ import {
4
+ IMPORT_ERROR_HINTS,
5
+ type ImportContext,
6
+ type ImportDiagnostic,
7
+ ImportError,
8
+ type Importer,
9
+ type ImportResult,
10
+ } from '@forgeax/engine-types';
11
+ import { fbxErr } from './errors.js';
12
+ import { initFbxWasm, parseFbx } from './index.js';
13
+ import {
14
+ type FbxRawAnimDoc,
15
+ parseAnimationClips,
16
+ resolveAnimationTargetIds,
17
+ } from './parse-animation-clip.js';
18
+ import { type FbxRawMaterial, parseMaterial } from './parse-material.js';
19
+ import { type FbxRawDocument, type FbxRawMesh, parseMesh } from './parse-mesh.js';
20
+ import { type FbxRawNodes, parseScene } from './parse-scene.js';
21
+ import { type FbxRawSkeletonDoc, parseSkeleton } from './parse-skeleton.js';
22
+ import { type FbxRawSkinDoc, parseSkin } from './parse-skin.js';
23
+ import { parseTextures } from './parse-texture.js';
24
+ import { resolveFbxTexturePath } from './resolve-texture-path.js';
25
+ import { type FbxDecodedTexture, toAssetPack } from './to-asset-pack.js';
26
+
27
+ export interface FbxSourceKeyOutput {
28
+ readonly kind: string;
29
+ readonly name?: string;
30
+ readonly sourceKey?: string;
31
+ }
32
+
33
+ export type FbxSourceKeyResult =
34
+ | { readonly ok: true; readonly keys: readonly string[] }
35
+ | {
36
+ readonly ok: false;
37
+ readonly code: 'missing-source-key' | 'duplicate-source-key' | 'ambiguous-source-key';
38
+ };
39
+
40
+ /** Derive FBX output identity from producer semantics, never from sourceIndex. */
41
+ export function sourceKeyForFbxOutput(output: FbxSourceKeyOutput): string | undefined {
42
+ const kind = output.kind.trim();
43
+ if (kind.length === 0) return undefined;
44
+ const explicit = output.sourceKey?.trim();
45
+ if (explicit !== undefined && explicit.length > 0) return explicit;
46
+ const name = output.name?.trim();
47
+ return name === undefined || name.length === 0 ? `fbx:${kind}` : `fbx:${kind}:${name}`;
48
+ }
49
+
50
+ export function deriveFbxSourceKeys(outputs: readonly FbxSourceKeyOutput[]): FbxSourceKeyResult {
51
+ const keys = outputs.map(sourceKeyForFbxOutput);
52
+ if (keys.some((key) => key === undefined)) return { ok: false, code: 'missing-source-key' };
53
+ const seen = new Set<string>();
54
+ for (const [index, key] of keys.entries()) {
55
+ if (key === undefined) continue;
56
+ if (seen.has(key)) {
57
+ return {
58
+ ok: false,
59
+ code: outputs[index]?.name === undefined ? 'ambiguous-source-key' : 'duplicate-source-key',
60
+ };
61
+ }
62
+ seen.add(key);
63
+ }
64
+ return { ok: true, keys: keys as string[] };
65
+ }
66
+
67
+ function validationError(
68
+ ctx: ImportContext,
69
+ textureIndex: number,
70
+ code: string,
71
+ actual: string,
72
+ hint: string,
73
+ ): ImportError {
74
+ const diagnostic: ImportDiagnostic = {
75
+ code,
76
+ severity: 'error',
77
+ sourcePath: `${ctx.source}#textures[${textureIndex}]`,
78
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
79
+ rule: 'fbx-external-texture-dependency-closure',
80
+ expected:
81
+ 'every bound FBX texture resolves to one readable supported image in the authorized scope',
82
+ actual,
83
+ hint,
84
+ };
85
+ return new ImportError({
86
+ code: 'source-validation-failed',
87
+ expected: diagnostic.expected,
88
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
89
+ detail: { diagnostics: [diagnostic] },
90
+ });
91
+ }
92
+
93
+ const FBX_PARSE_DIAGNOSTIC_MAX = 256;
94
+ const FBX_PARSE_EXPECTED = 'a readable FBX source that ufbx can parse';
95
+
96
+ function boundedErrorMessage(error: unknown): string {
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ return message.length <= FBX_PARSE_DIAGNOSTIC_MAX
99
+ ? message
100
+ : `${message.slice(0, FBX_PARSE_DIAGNOSTIC_MAX - 3)}...`;
101
+ }
102
+
103
+ function parseValidationError(ctx: ImportContext, error: unknown): ImportError {
104
+ const actual = boundedErrorMessage(error);
105
+ const diagnostic: ImportDiagnostic = {
106
+ code: 'fbx-source-parse-failed',
107
+ severity: 'error',
108
+ sourcePath: ctx.source,
109
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
110
+ rule: 'fbx-source-parse',
111
+ expected: FBX_PARSE_EXPECTED,
112
+ actual,
113
+ hint: 'repair or re-export the FBX source, then retry the same importer and source path',
114
+ };
115
+ return new ImportError({
116
+ code: 'source-validation-failed',
117
+ expected: FBX_PARSE_EXPECTED,
118
+ actual,
119
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
120
+ detail: { diagnostics: [diagnostic] },
121
+ });
122
+ }
123
+
124
+ function importCandidates(ctx: ImportContext): readonly { readonly relativePath: string }[] {
125
+ const raw = ctx.importSettings.fbxCandidatePaths;
126
+ if (!Array.isArray(raw)) return [];
127
+ return raw.flatMap((path): { readonly relativePath: string }[] =>
128
+ typeof path === 'string' ? [{ relativePath: path }] : [],
129
+ );
130
+ }
131
+
132
+ function mimeForTexture(
133
+ path: string,
134
+ bytes: Uint8Array,
135
+ ): 'image/png' | 'image/jpeg' | 'image/x-tga' {
136
+ const lower = path.toLowerCase();
137
+ if (lower.endsWith('.png') || (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e)) {
138
+ return 'image/png';
139
+ }
140
+ if (
141
+ lower.endsWith('.jpg') ||
142
+ lower.endsWith('.jpeg') ||
143
+ (bytes[0] === 0xff && bytes[1] === 0xd8)
144
+ ) {
145
+ return 'image/jpeg';
146
+ }
147
+ return 'image/x-tga';
148
+ }
149
+
150
+ function textureColorSpace(slot: string): 'srgb' | 'linear' {
151
+ return slot === 'normalTexture' ||
152
+ slot === 'metallicRoughnessTexture' ||
153
+ slot === 'occlusionTexture'
154
+ ? 'linear'
155
+ : 'srgb';
156
+ }
157
+
158
+ export const fbxImporter: Importer = {
159
+ key: 'fbx',
160
+
161
+ async import(ctx: ImportContext): Promise<ImportResult> {
162
+ // ufbx WASM path: read raw FBX bytes via the import context (browser +
163
+ // Node both resolve through readSource), then parse in-memory. No native
164
+ // addon / SDK build step (the WASM module self-loads its .wasm).
165
+ const read = await ctx.readSource();
166
+ if (!read.ok) {
167
+ return {
168
+ ok: false,
169
+ error: new ImportError({
170
+ code: 'source-read-failed',
171
+ expected: `readable source file at meta.source "${ctx.source}"`,
172
+ hint: IMPORT_ERROR_HINTS['source-read-failed'],
173
+ detail: {
174
+ source: ctx.source,
175
+ reason: read.error instanceof Error ? read.error.message : String(read.error),
176
+ },
177
+ }),
178
+ };
179
+ }
180
+
181
+ await initFbxWasm();
182
+ let doc: FbxRawDocument & FbxRawSkeletonDoc & FbxRawSkinDoc & FbxRawAnimDoc;
183
+ try {
184
+ const jsonStr = parseFbx(read.value);
185
+ doc = JSON.parse(jsonStr) as FbxRawDocument &
186
+ FbxRawSkeletonDoc &
187
+ FbxRawSkinDoc &
188
+ FbxRawAnimDoc;
189
+ } catch (error) {
190
+ return { ok: false, error: parseValidationError(ctx, error) };
191
+ }
192
+
193
+ // NURBS / patch fail-fast: the bridge emits an error envelope for
194
+ // unsupported surface types (charter P3 explicit failure).
195
+ const maybeError = doc as unknown as {
196
+ error?: { code: string; meshType: string; meshName: string };
197
+ };
198
+ if (maybeError.error?.code === 'fbx-mesh-type-unsupported') {
199
+ const e = fbxErr('fbx-mesh-type-unsupported', {
200
+ meshType: maybeError.error.meshType as 'nurbs' | 'patch',
201
+ meshName: maybeError.error.meshName,
202
+ });
203
+ // The Importer interface returns Promise<ImportedAsset[]> (not Result),
204
+ // so structural FbxError cannot flow through the import-runner's typed
205
+ // error return. Throw a plain Error with the code in the message so
206
+ // AI users can grep import-internal-error.detail.reason for the
207
+ // 'fbx-mesh-type-unsupported' substring; the structural error rides on
208
+ // `.cause`.
209
+ const wrapper = new Error(`${e.code}: ${e.expected}`);
210
+ (wrapper as { cause?: unknown }).cause = e;
211
+ throw wrapper;
212
+ }
213
+
214
+ const rawMeshes: readonly FbxRawMesh[] = doc.meshes ?? [];
215
+ const meshes = rawMeshes.map((raw, i) => parseMesh(raw, i));
216
+
217
+ const scene = parseScene(doc as unknown as FbxRawNodes);
218
+
219
+ const texturesModule = doc as unknown as { textures?: readonly unknown[] };
220
+ const textures = parseTextures({ textures: texturesModule.textures as never });
221
+
222
+ const materialDocs =
223
+ (doc as unknown as { materials?: readonly FbxRawMaterial[] }).materials ?? [];
224
+ const materials =
225
+ materialDocs.length > 0
226
+ ? materialDocs.map((raw, i) => parseMaterial(raw, i))
227
+ : [parseMaterial({ kind: 'fallback' }, 0)];
228
+
229
+ const skeleton = parseSkeleton(doc);
230
+ const skin = parseSkin(doc);
231
+ const animationTargets = resolveAnimationTargetIds(
232
+ (doc as unknown as FbxRawNodes).nodes ?? [],
233
+ doc.clips ?? [],
234
+ );
235
+ if (!animationTargets.ok) {
236
+ const wrapper = new Error(
237
+ `${animationTargets.error.code}: ${animationTargets.error.expected}`,
238
+ );
239
+ (wrapper as { cause?: unknown }).cause = animationTargets.error;
240
+ throw wrapper;
241
+ }
242
+ const animationClips = parseAnimationClips(doc);
243
+
244
+ const textureSlots = new Map<number, string>();
245
+ for (const material of materials) {
246
+ for (const binding of material.textureBindings ?? []) {
247
+ const prior = textureSlots.get(binding.textureIndex);
248
+ const colorSpace = textureColorSpace(binding.slot);
249
+ if (prior !== undefined && prior !== colorSpace) {
250
+ return {
251
+ ok: false,
252
+ error: validationError(
253
+ ctx,
254
+ binding.textureIndex,
255
+ 'fbx-texture-color-space-conflict',
256
+ `texture ${binding.textureIndex} is used as both ${prior} and ${colorSpace}`,
257
+ 'split the source texture or author one color-space semantic per FBX texture',
258
+ ),
259
+ };
260
+ }
261
+ textureSlots.set(binding.textureIndex, colorSpace);
262
+ }
263
+ }
264
+
265
+ const decodedTextures = new Map<number, FbxDecodedTexture>();
266
+ for (const texture of textures) {
267
+ const guidDeclared = ctx.subAssets.some(
268
+ (subAsset) => subAsset.kind === 'texture' && subAsset.sourceIndex === texture.sourceIndex,
269
+ );
270
+ if (!guidDeclared) continue;
271
+
272
+ let bytes: Uint8Array;
273
+ let sourcePath = texture.filePath;
274
+ if (texture.embeddedBytes !== undefined && texture.embeddedBytes.byteLength > 0) {
275
+ bytes = texture.embeddedBytes;
276
+ sourcePath = texture.relativeFilePath ?? texture.filePath;
277
+ } else {
278
+ if (texture.type !== undefined && texture.type !== 'file') {
279
+ return {
280
+ ok: false,
281
+ error: validationError(
282
+ ctx,
283
+ texture.sourceIndex,
284
+ 'fbx-material-texture-slot-unsupported',
285
+ `texture type ${texture.type} is not a single file texture`,
286
+ 'flatten layered/procedural FBX textures to a file texture before importing',
287
+ ),
288
+ };
289
+ }
290
+ const resolution = resolveFbxTexturePath(
291
+ ctx.source,
292
+ {
293
+ ...(texture.relativeFilePath === undefined
294
+ ? {}
295
+ : { declaredRelativePath: texture.relativeFilePath }),
296
+ ...(texture.filePath.length === 0 ? {} : { declaredFilename: texture.filePath }),
297
+ ...(texture.absoluteFilePath === undefined
298
+ ? {}
299
+ : { declaredAbsolutePath: texture.absoluteFilePath }),
300
+ },
301
+ importCandidates(ctx),
302
+ );
303
+ if (!resolution.ok) {
304
+ return {
305
+ ok: false,
306
+ error: validationError(
307
+ ctx,
308
+ texture.sourceIndex,
309
+ resolution.code,
310
+ `${resolution.requestedPath}; candidates=${resolution.candidates.join(', ') || '<none>'}`,
311
+ resolution.code === 'fbx-external-texture-ambiguous'
312
+ ? 'select a folder whose dependency path is unique; first-match guessing is disabled'
313
+ : 'select the containing folder so the FBX and its referenced texture are in the authorized candidate scope',
314
+ ),
315
+ };
316
+ }
317
+ const read = await ctx.readSibling(resolution.readUri);
318
+ if (!read.ok) return read;
319
+ bytes = read.value;
320
+ sourcePath = resolution.relativePath;
321
+ }
322
+
323
+ const slot =
324
+ [...textureSlots.entries()].find(([index]) => index === texture.sourceIndex)?.[1] ?? 'srgb';
325
+ const decoded = await ctx.decodeImage(bytes, mimeForTexture(sourcePath, bytes), {
326
+ ...ctx.importSettings,
327
+ colorSpace: slot,
328
+ });
329
+ if (!decoded.ok) {
330
+ const reason =
331
+ decoded.error.code === 'image-decode-failed'
332
+ ? decoded.error.detail.reason
333
+ : `${decoded.error.code}: ${JSON.stringify(decoded.error.detail)}`;
334
+ return {
335
+ ok: false,
336
+ error: validationError(
337
+ ctx,
338
+ texture.sourceIndex,
339
+ 'fbx-external-texture-decode-failed',
340
+ `${sourcePath}: ${reason}`,
341
+ 'repair or re-export the referenced image, then retry the FBX import',
342
+ ),
343
+ };
344
+ }
345
+ decodedTextures.set(texture.sourceIndex, {
346
+ texture: decoded.value.texture,
347
+ bytes: decoded.value.bytes,
348
+ ...(decoded.value.mediaType === undefined ? {} : { mediaType: decoded.value.mediaType }),
349
+ ...(decoded.value.assetCodec === undefined ? {} : { assetCodec: decoded.value.assetCodec }),
350
+ });
351
+ }
352
+
353
+ return {
354
+ ok: true,
355
+ value: {
356
+ assets: toAssetPack({
357
+ meshes,
358
+ scene,
359
+ materials,
360
+ textures,
361
+ skeleton,
362
+ skin,
363
+ animationClips,
364
+ subAssets: ctx.subAssets,
365
+ decodedTextures,
366
+ ...(ctx.sourceOverrides === undefined ? {} : { sourceOverrides: ctx.sourceOverrides }),
367
+ }),
368
+ sourceDependencies: [],
369
+ },
370
+ };
371
+ },
372
+ };
package/src/index.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * @forgeax/engine-fbx — barrel entry point.
3
+ *
4
+ * FBX importer via ufbx compiled to WebAssembly. This file carries the WASM
5
+ * runtime (initFbxWasm / parseFbx) plus the barrel re-exports of the
6
+ * parse-*.ts bridge layer + `fbxImporter` (single-entry indexability, charter
7
+ * F1). Both browser and Node resolve through the same self-loading WASM glue.
8
+ *
9
+ * Usage:
10
+ * import { fbxImporter } from '@forgeax/engine-fbx'; // build-time importer
11
+ * // or the low-level parse API:
12
+ * import { initFbxWasm, parseFbx } from '@forgeax/engine-fbx';
13
+ * await initFbxWasm(); // load .wasm once
14
+ * const json = parseFbx(fbxBytes); // Uint8Array -> JSON string
15
+ * const pod = JSON.parse(json); // engine FBX POD schema
16
+ *
17
+ * WASM asset resolution (mirrors @forgeax/engine-wgpu-wasm):
18
+ * - Browser / Vite: new URL() resolves to a fetch-able asset URL.
19
+ * - Node runtime: the emcc glue is built with ENVIRONMENT=web,node, so it
20
+ * self-loads the .wasm via fs from the locateFile URL — no manual
21
+ * fs.readFile + wasmBinary hand-off in this layer (Derive, Don't Duplicate).
22
+ * - vitest: runs under Node; same self-load path.
23
+ */
24
+
25
+ // The emcc glue (pkg/fbx-wasm.mjs) is a gitignored build artifact — present only
26
+ // after build:wasm / fetch-wasm. It is imported LAZILY inside _loadWasm (not at
27
+ // module top level) so that merely importing @forgeax/engine-fbx — e.g. the
28
+ // studio editor-core barrel re-exporting cookFbxMeta, or the hermetic
29
+ // barrel-export-contract test (bun test, no wasm build) — does NOT require the
30
+ // built artifact. The glue is only needed once a caller invokes initFbxWasm().
31
+ type CreateFbxModule = (opts?: Record<string, unknown>) => Promise<FbxWasmModule>;
32
+
33
+ /* ── Types ─────────────────────────────────────────────────────────── */
34
+
35
+ interface FbxWasmModule {
36
+ /** @internal emcc-exported C entry: parse FBX bytes at ptr into internal result buffer. */
37
+ _parseFbxWasm(ptr: number, size: number): void;
38
+ /** @internal emcc-exported C entry: pointer to the result JSON string. */
39
+ _getResultPtr(): number;
40
+ /** @internal emcc-exported C entry: byte length of the result JSON string. */
41
+ _getResultLen(): number;
42
+ /** @internal emcc-exported C entry: free the result buffer. */
43
+ _freeResult(): void;
44
+ /** @internal emscripten runtime: allocate `size` bytes in the wasm heap. */
45
+ _malloc(size: number): number;
46
+ /** @internal emscripten runtime: free a wasm-heap pointer. */
47
+ _free(ptr: number): void;
48
+ HEAPU8: Uint8Array;
49
+ UTF8ToString(ptr: number, maxLen?: number): string;
50
+ }
51
+
52
+ let wasmModule: FbxWasmModule | null = null;
53
+ let initPromise: Promise<FbxWasmModule> | null = null;
54
+
55
+ /* ── Internal loader ───────────────────────────────────────────────── */
56
+
57
+ async function _loadWasm(overrideUrl?: string): Promise<FbxWasmModule> {
58
+ // Lazy-load the emcc glue only now that a caller actually wants the wasm.
59
+ // @vite-ignore keeps the bundler from eagerly resolving the (possibly unbuilt)
60
+ // artifact at import-graph time; it resolves at call time in every scenario
61
+ // (browser/Vite, Node, vitest) exactly as the old top-level import did.
62
+ const glueId = '../pkg/fbx-wasm.mjs';
63
+ const createModule = ((await import(/* @vite-ignore */ glueId)) as { default: CreateFbxModule })
64
+ .default;
65
+
66
+ // The emcc glue (ENVIRONMENT=web,node) self-loads the .wasm from this URL:
67
+ // via fetch() in the browser, via fs in Node. We only tell it where to look.
68
+ const wasmAssetUrl = overrideUrl
69
+ ? new URL(overrideUrl, import.meta.url)
70
+ : new URL('../pkg/fbx-wasm.wasm', import.meta.url);
71
+
72
+ const opts: Record<string, unknown> = {
73
+ locateFile: () => wasmAssetUrl.href,
74
+ };
75
+
76
+ try {
77
+ return await createModule(opts);
78
+ } catch (cause) {
79
+ throw new Error(
80
+ `@forgeax/engine-fbx: failed to load WASM from ${wasmAssetUrl.href}. ` +
81
+ 'pkg/fbx-wasm.wasm may be missing. Self-help: ' +
82
+ '(1) fetch a prebuilt artifact via `pnpm -F @forgeax/engine-fbx fetch-wasm`, or ' +
83
+ '(2) compile locally with emcc via `pnpm -F @forgeax/engine-fbx build:wasm`.',
84
+ { cause },
85
+ );
86
+ }
87
+ }
88
+
89
+ /* ── Public API ────────────────────────────────────────────────────── */
90
+
91
+ /**
92
+ * Initialize the WASM module. Must be called once before `parseFbx`.
93
+ * Safe to call multiple times (idempotent). Null-resets on failure so
94
+ * transient errors (e.g. fetch jitter) are retryable.
95
+ *
96
+ * @param wasmUrl — optional override URL for the .wasm file
97
+ */
98
+ export async function initFbxWasm(wasmUrl?: string): Promise<void> {
99
+ if (wasmModule) return;
100
+
101
+ if (!initPromise) {
102
+ initPromise = _loadWasm(wasmUrl).catch((e: unknown) => {
103
+ initPromise = null;
104
+ throw e;
105
+ });
106
+ }
107
+
108
+ wasmModule = await initPromise;
109
+ }
110
+
111
+ /**
112
+ * Parse an FBX file in-memory and return the JSON POD string.
113
+ *
114
+ * The returned JSON follows the engine FBX POD schema, containing:
115
+ * meshes, nodes, materials, skeletons, skins, clips.
116
+ *
117
+ * @param fbxBytes — raw FBX file bytes (binary or ASCII)
118
+ * @returns JSON string matching the engine's FBX POD schema
119
+ * @throws if WASM module is not initialized or parse fails
120
+ */
121
+ export function parseFbx(fbxBytes: Uint8Array): string {
122
+ if (!wasmModule) {
123
+ throw new Error('@forgeax/engine-fbx: WASM not initialized. Call initFbxWasm() first.');
124
+ }
125
+
126
+ const mod = wasmModule;
127
+ const size = fbxBytes.byteLength;
128
+
129
+ const ptr = mod._malloc(size);
130
+ if (!ptr) throw new Error('fbx-wasm: malloc failed for input buffer');
131
+
132
+ try {
133
+ mod.HEAPU8.set(fbxBytes, ptr);
134
+ mod._parseFbxWasm(ptr, size);
135
+ } finally {
136
+ mod._free(ptr);
137
+ }
138
+
139
+ const resultPtr = mod._getResultPtr();
140
+ const resultLen = mod._getResultLen();
141
+
142
+ if (!resultPtr || !resultLen) {
143
+ mod._freeResult();
144
+ throw new Error('fbx-wasm: parseFbxWasm returned empty result');
145
+ }
146
+
147
+ // Decode via a fresh sliced copy, NOT mod.UTF8ToString. Under
148
+ // ALLOW_MEMORY_GROWTH=1 the emcc glue backs HEAPU8 with a *resizable*
149
+ // ArrayBuffer (wasmMemory.toResizableBuffer()); glue's UTF8ArrayToString then
150
+ // calls TextDecoder.decode(HEAPU8.subarray(...)), which modern Chromium
151
+ // rejects ("The provided ArrayBuffer value must not be resizable"). HEAPU8
152
+ // .slice() returns a copy backed by a plain, non-resizable ArrayBuffer, so the
153
+ // decode is accepted in every environment (browser/Vite, Node, vitest).
154
+ const bytes = mod.HEAPU8.slice(resultPtr, resultPtr + resultLen);
155
+ mod._freeResult();
156
+ const json = new TextDecoder().decode(bytes);
157
+
158
+ const firstChars = json.substring(0, 30);
159
+ if (firstChars.includes('"error"')) {
160
+ const parsed = JSON.parse(json);
161
+ if (parsed.error) {
162
+ throw new Error(`fbx-wasm: ${parsed.error.message || 'parse failed'}`);
163
+ }
164
+ }
165
+
166
+ return json;
167
+ }
168
+
169
+ /**
170
+ * Convenience: parse FBX bytes and return the parsed POD object.
171
+ */
172
+ export function parseFbxToObject(fbxBytes: Uint8Array): Record<string, unknown> {
173
+ return JSON.parse(parseFbx(fbxBytes));
174
+ }
175
+
176
+ /**
177
+ * Check if the WASM module is ready.
178
+ */
179
+ export function isFbxWasmReady(): boolean {
180
+ return wasmModule !== null;
181
+ }
182
+
183
+ /* ── Bridge-layer barrel (parse-*.ts + importer + errors) ──────────── */
184
+
185
+ export {
186
+ FBX_ERROR_HINTS,
187
+ type FbxError,
188
+ type FbxErrorCode,
189
+ type FbxErrorDetail,
190
+ fbxErr,
191
+ } from './errors.js';
192
+ export {
193
+ deriveFbxSourceKeys,
194
+ fbxImporter,
195
+ sourceKeyForFbxOutput,
196
+ } from './fbx-importer.js';
197
+ export {
198
+ type FbxRawAnimDoc,
199
+ type FbxRawClip,
200
+ parseAnimationClips,
201
+ } from './parse-animation-clip.js';
202
+ export { type FbxRawMaterial, parseMaterial } from './parse-material.js';
203
+ export { type FbxRawDocument, type FbxRawMesh, parseMesh } from './parse-mesh.js';
204
+ export { type FbxRawNode, type FbxRawNodes, parseScene } from './parse-scene.js';
205
+ export { type FbxRawSkeletonDoc, parseSkeleton } from './parse-skeleton.js';
206
+ export { type FbxRawSkinDoc, parseSkin } from './parse-skin.js';
207
+ export { type FbxRawTexture, type FbxRawTextures, parseTextures } from './parse-texture.js';
208
+ export {
209
+ type FbxTextureCandidate,
210
+ type FbxTexturePathRequest,
211
+ type FbxTextureResolution,
212
+ type FbxTextureResolutionStrategy,
213
+ resolveFbxTexturePath,
214
+ } from './resolve-texture-path.js';
215
+ export { toAssetPack } from './to-asset-pack.js';