@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.
Files changed (79) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +188 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/blendshape-import.integration.test.d.ts +2 -0
  5. package/dist/__tests__/blendshape-import.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/blendshape-import.unit.test.d.ts +2 -0
  7. package/dist/__tests__/blendshape-import.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/fbx-importer.test.d.ts +2 -0
  9. package/dist/__tests__/fbx-importer.test.d.ts.map +1 -0
  10. package/dist/__tests__/fbx-local-artifacts.test.d.ts +2 -0
  11. package/dist/__tests__/fbx-local-artifacts.test.d.ts.map +1 -0
  12. package/dist/__tests__/index.test.d.ts +2 -0
  13. package/dist/__tests__/index.test.d.ts.map +1 -0
  14. package/dist/__tests__/mesh-material-slots.unit.test.d.ts +2 -0
  15. package/dist/__tests__/mesh-material-slots.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/parse-mesh-multi-uv.test.d.ts +2 -0
  17. package/dist/__tests__/parse-mesh-multi-uv.test.d.ts.map +1 -0
  18. package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
  19. package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
  20. package/dist/__tests__/resolve-texture-path.unit.test.d.ts +2 -0
  21. package/dist/__tests__/resolve-texture-path.unit.test.d.ts.map +1 -0
  22. package/dist/errors.d.ts +50 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/fbx-importer.d.ts +17 -0
  25. package/dist/fbx-importer.d.ts.map +1 -0
  26. package/dist/index.d.ts +62 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.mjs +1499 -0
  29. package/dist/index.mjs.map +1 -0
  30. package/dist/parse-animation-clip.d.ts +35 -0
  31. package/dist/parse-animation-clip.d.ts.map +1 -0
  32. package/dist/parse-material.d.ts +24 -0
  33. package/dist/parse-material.d.ts.map +1 -0
  34. package/dist/parse-mesh.d.ts +21 -0
  35. package/dist/parse-mesh.d.ts.map +1 -0
  36. package/dist/parse-scene.d.ts +28 -0
  37. package/dist/parse-scene.d.ts.map +1 -0
  38. package/dist/parse-skeleton.d.ts +15 -0
  39. package/dist/parse-skeleton.d.ts.map +1 -0
  40. package/dist/parse-skin.d.ts +20 -0
  41. package/dist/parse-skin.d.ts.map +1 -0
  42. package/dist/parse-texture.d.ts +11 -0
  43. package/dist/parse-texture.d.ts.map +1 -0
  44. package/dist/resolve-texture-path.d.ts +25 -0
  45. package/dist/resolve-texture-path.d.ts.map +1 -0
  46. package/dist/to-asset-pack.d.ts +31 -0
  47. package/dist/to-asset-pack.d.ts.map +1 -0
  48. package/package.json +74 -0
  49. package/pkg/fbx-wasm.mjs +2 -0
  50. package/pkg/fbx-wasm.wasm +0 -0
  51. package/scripts/build-wasm.mjs +85 -0
  52. package/scripts/content-key.mjs +88 -0
  53. package/scripts/ensure-wasm.mjs +28 -0
  54. package/scripts/fetch-ufbx.mjs +42 -0
  55. package/scripts/fetch-wasm.mjs +91 -0
  56. package/scripts/parity-diff.mjs +127 -0
  57. package/scripts/test-wasm.mjs +91 -0
  58. package/src/__tests__/blendshape-import.integration.test.ts +90 -0
  59. package/src/__tests__/blendshape-import.unit.test.ts +83 -0
  60. package/src/__tests__/fbx-importer.test.ts +36 -0
  61. package/src/__tests__/fbx-local-artifacts.test.ts +39 -0
  62. package/src/__tests__/index.test.ts +168 -0
  63. package/src/__tests__/mesh-material-slots.unit.test.ts +125 -0
  64. package/src/__tests__/parse-mesh-multi-uv.test.ts +187 -0
  65. package/src/__tests__/pick-e2e.integration.test.ts +247 -0
  66. package/src/__tests__/resolve-texture-path.unit.test.ts +50 -0
  67. package/src/errors.ts +112 -0
  68. package/src/fbx-importer.ts +146 -0
  69. package/src/index.ts +215 -0
  70. package/src/native/bridge.c +942 -0
  71. package/src/parse-animation-clip.ts +271 -0
  72. package/src/parse-material.ts +92 -0
  73. package/src/parse-mesh.ts +223 -0
  74. package/src/parse-scene.ts +75 -0
  75. package/src/parse-skeleton.ts +46 -0
  76. package/src/parse-skin.ts +71 -0
  77. package/src/parse-texture.ts +22 -0
  78. package/src/resolve-texture-path.ts +152 -0
  79. package/src/to-asset-pack.ts +742 -0
@@ -0,0 +1,146 @@
1
+ // fbx-importer.ts — TS wrapper around the ufbx WASM parser.
2
+
3
+ import type { ImportContext, Importer, ImportResult } from '@forgeax/engine-types';
4
+ import { fbxErr } from './errors.js';
5
+ import { initFbxWasm, parseFbx } from './index.js';
6
+ import {
7
+ type FbxRawAnimDoc,
8
+ parseAnimationClips,
9
+ resolveAnimationTargetIds,
10
+ } from './parse-animation-clip.js';
11
+ import { type FbxRawMaterial, parseMaterial } from './parse-material.js';
12
+ import { type FbxRawDocument, type FbxRawMesh, parseMesh } from './parse-mesh.js';
13
+ import { type FbxRawNodes, parseScene } from './parse-scene.js';
14
+ import { type FbxRawSkeletonDoc, parseSkeleton } from './parse-skeleton.js';
15
+ import { type FbxRawSkinDoc, parseSkin } from './parse-skin.js';
16
+ import { parseTextures } from './parse-texture.js';
17
+ import { toAssetPack } from './to-asset-pack.js';
18
+
19
+ export interface FbxSourceKeyOutput {
20
+ readonly kind: string;
21
+ readonly name?: string;
22
+ }
23
+
24
+ export type FbxSourceKeyResult =
25
+ | { readonly ok: true; readonly keys: readonly string[] }
26
+ | {
27
+ readonly ok: false;
28
+ readonly code: 'missing-source-key' | 'duplicate-source-key' | 'ambiguous-source-key';
29
+ };
30
+
31
+ /** Derive FBX output identity from producer semantics, never from sourceIndex. */
32
+ export function sourceKeyForFbxOutput(output: FbxSourceKeyOutput): string | undefined {
33
+ const kind = output.kind.trim();
34
+ if (kind.length === 0) return undefined;
35
+ const name = output.name?.trim();
36
+ return name === undefined || name.length === 0 ? `fbx:${kind}` : `fbx:${kind}:${name}`;
37
+ }
38
+
39
+ export function deriveFbxSourceKeys(outputs: readonly FbxSourceKeyOutput[]): FbxSourceKeyResult {
40
+ const keys = outputs.map(sourceKeyForFbxOutput);
41
+ if (keys.some((key) => key === undefined)) return { ok: false, code: 'missing-source-key' };
42
+ const seen = new Set<string>();
43
+ for (const [index, key] of keys.entries()) {
44
+ if (key === undefined) continue;
45
+ if (seen.has(key)) {
46
+ return {
47
+ ok: false,
48
+ code: outputs[index]?.name === undefined ? 'ambiguous-source-key' : 'duplicate-source-key',
49
+ };
50
+ }
51
+ seen.add(key);
52
+ }
53
+ return { ok: true, keys: keys as string[] };
54
+ }
55
+
56
+ export const fbxImporter: Importer = {
57
+ key: 'fbx',
58
+
59
+ async import(ctx: ImportContext): Promise<ImportResult> {
60
+ // ufbx WASM path: read raw FBX bytes via the import context (browser +
61
+ // Node both resolve through readSource), then parse in-memory. No native
62
+ // addon / SDK build step (the WASM module self-loads its .wasm).
63
+ const read = await ctx.readSource();
64
+ if (!read.ok) {
65
+ const wrapper = new Error(`fbx-source-unreadable: ${ctx.source}`);
66
+ (wrapper as { cause?: unknown }).cause = read.error;
67
+ throw wrapper;
68
+ }
69
+
70
+ await initFbxWasm();
71
+ const jsonStr = parseFbx(read.value);
72
+ const doc = JSON.parse(jsonStr) as FbxRawDocument &
73
+ FbxRawSkeletonDoc &
74
+ FbxRawSkinDoc &
75
+ FbxRawAnimDoc;
76
+
77
+ // NURBS / patch fail-fast: the bridge emits an error envelope for
78
+ // unsupported surface types (charter P3 explicit failure).
79
+ const maybeError = doc as unknown as {
80
+ error?: { code: string; meshType: string; meshName: string };
81
+ };
82
+ if (maybeError.error?.code === 'fbx-mesh-type-unsupported') {
83
+ const e = fbxErr('fbx-mesh-type-unsupported', {
84
+ meshType: maybeError.error.meshType as 'nurbs' | 'patch',
85
+ meshName: maybeError.error.meshName,
86
+ });
87
+ // The Importer interface returns Promise<ImportedAsset[]> (not Result),
88
+ // so structural FbxError cannot flow through the import-runner's typed
89
+ // error return. Throw a plain Error with the code in the message so
90
+ // AI users can grep import-internal-error.detail.reason for the
91
+ // 'fbx-mesh-type-unsupported' substring; the structural error rides on
92
+ // `.cause`.
93
+ const wrapper = new Error(`${e.code}: ${e.expected}`);
94
+ (wrapper as { cause?: unknown }).cause = e;
95
+ throw wrapper;
96
+ }
97
+
98
+ const rawMeshes: readonly FbxRawMesh[] = doc.meshes ?? [];
99
+ const meshes = rawMeshes.map((raw, i) => parseMesh(raw, i));
100
+
101
+ const scene = parseScene(doc as unknown as FbxRawNodes);
102
+
103
+ const texturesModule = doc as unknown as { textures?: readonly unknown[] };
104
+ const textures = parseTextures({ textures: texturesModule.textures as never });
105
+
106
+ const materialDocs =
107
+ (doc as unknown as { materials?: readonly FbxRawMaterial[] }).materials ?? [];
108
+ const materials =
109
+ materialDocs.length > 0
110
+ ? materialDocs.map((raw, i) => parseMaterial(raw, i))
111
+ : [parseMaterial({ kind: 'fallback' }, 0)];
112
+
113
+ const skeleton = parseSkeleton(doc);
114
+ const skin = parseSkin(doc);
115
+ const animationTargets = resolveAnimationTargetIds(
116
+ (doc as unknown as FbxRawNodes).nodes ?? [],
117
+ doc.clips ?? [],
118
+ );
119
+ if (!animationTargets.ok) {
120
+ const wrapper = new Error(
121
+ `${animationTargets.error.code}: ${animationTargets.error.expected}`,
122
+ );
123
+ (wrapper as { cause?: unknown }).cause = animationTargets.error;
124
+ throw wrapper;
125
+ }
126
+ const animationClips = parseAnimationClips(doc);
127
+
128
+ return {
129
+ ok: true,
130
+ value: {
131
+ assets: toAssetPack({
132
+ meshes,
133
+ scene,
134
+ materials,
135
+ textures,
136
+ skeleton,
137
+ skin,
138
+ animationClips,
139
+ subAssets: ctx.subAssets,
140
+ ...(ctx.sourceOverrides === undefined ? {} : { sourceOverrides: ctx.sourceOverrides }),
141
+ }),
142
+ sourceDependencies: [],
143
+ },
144
+ };
145
+ },
146
+ };
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';