@forgeax/engine-fbx 0.1.3 → 0.1.6
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/README.md +9 -17
- package/dist/.tsbuildinfo +1 -1
- package/dist/fbx-importer.d.ts +1 -2
- package/dist/fbx-importer.d.ts.map +1 -1
- package/dist/index.mjs +206 -8253
- package/dist/index.mjs.map +1 -1
- package/dist/parse-material.d.ts +1 -7
- package/dist/parse-material.d.ts.map +1 -1
- package/dist/parse-texture.d.ts +1 -5
- package/dist/parse-texture.d.ts.map +1 -1
- package/dist/to-asset-pack.d.ts +1 -8
- package/dist/to-asset-pack.d.ts.map +1 -1
- package/package.json +11 -12
- package/pkg/fbx-wasm.wasm +0 -0
- package/src/__tests__/blendshape-import.integration.test.ts +12 -18
- package/src/__tests__/fbx-importer.test.ts +1 -165
- package/src/__tests__/index.test.ts +1 -0
- package/src/fbx-importer.ts +10 -236
- package/src/native/bridge.c +0 -118
- package/src/parse-material.ts +1 -49
- package/src/parse-texture.ts +3 -17
- package/src/to-asset-pack.ts +20 -91
- package/dist/__tests__/asset-runtime-fixture.d.ts +0 -9
- package/dist/__tests__/asset-runtime-fixture.d.ts.map +0 -1
- package/src/__tests__/asset-runtime-fixture.ts +0 -251
package/dist/parse-material.d.ts
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import type { MaterialPod
|
|
2
|
-
export interface FbxRawTextureBinding {
|
|
3
|
-
readonly slot: MaterialTextureBindingPod['slot'];
|
|
4
|
-
readonly textureIndex: number;
|
|
5
|
-
readonly texCoord?: number;
|
|
6
|
-
}
|
|
1
|
+
import type { MaterialPod } from '@forgeax/engine-types';
|
|
7
2
|
/** JSON shape emitted by binding.cc WriteMaterials for a single material. */
|
|
8
3
|
export interface FbxRawMaterial {
|
|
9
4
|
readonly name?: string;
|
|
@@ -12,7 +7,6 @@ export interface FbxRawMaterial {
|
|
|
12
7
|
readonly diffuse?: readonly [number, number, number];
|
|
13
8
|
readonly shininess?: number;
|
|
14
9
|
readonly specular?: readonly [number, number, number];
|
|
15
|
-
readonly textureBindings?: readonly FbxRawTextureBinding[];
|
|
16
10
|
readonly stingrayProps?: {
|
|
17
11
|
readonly baseColor?: readonly [number, number, number];
|
|
18
12
|
readonly metallic?: number;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse-material.d.ts","sourceRoot":"","sources":["../src/parse-material.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,
|
|
1
|
+
{"version":3,"file":"parse-material.d.ts","sourceRoot":"","sources":["../src/parse-material.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,CAAC;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACvD,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IACtD,QAAQ,CAAC,aAAa,CAAC,EAAE;QACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;QACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;KACvD,CAAC;CACH;AAWD;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,GAAG,WAAW,CAiDnF"}
|
package/dist/parse-texture.d.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type { TexturePod } from '@forgeax/engine-types';
|
|
2
2
|
export interface FbxRawTexture {
|
|
3
3
|
readonly name?: string;
|
|
4
|
-
readonly filePath
|
|
5
|
-
readonly relativeFilePath?: string;
|
|
6
|
-
readonly absoluteFilePath?: string;
|
|
7
|
-
readonly embeddedBytes?: readonly number[];
|
|
8
|
-
readonly type?: 'file' | 'layered' | 'procedural' | 'shader' | 'unknown';
|
|
4
|
+
readonly filePath: string;
|
|
9
5
|
readonly sourceIndex: number;
|
|
10
6
|
}
|
|
11
7
|
export interface FbxRawTextures {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parse-texture.d.ts","sourceRoot":"","sources":["../src/parse-texture.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,
|
|
1
|
+
{"version":3,"file":"parse-texture.d.ts","sourceRoot":"","sources":["../src/parse-texture.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;CAC9C;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,cAAc,GAAG,UAAU,EAAE,CAO/D"}
|
package/dist/to-asset-pack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnimationClipPod,
|
|
1
|
+
import type { AnimationClipPod, ImportedAsset, MaterialPod, MeshMaterialSlotTopologyEntry, MeshPod, ScenePod, SkeletonPod, SkinPod, SourceOverrideMap, TexturePod } from '@forgeax/engine-types';
|
|
2
2
|
type SubAsset = {
|
|
3
3
|
readonly guid: string;
|
|
4
4
|
readonly sourceIndex: number;
|
|
@@ -16,12 +16,6 @@ export declare function buildMeshAsset(pod: MeshPod, guid: string, influences?:
|
|
|
16
16
|
readonly materialSlotDefaultOverrides?: Readonly<Record<string, string | null>>;
|
|
17
17
|
readonly meshSourceKey?: string;
|
|
18
18
|
}): ImportedAsset;
|
|
19
|
-
export interface FbxDecodedTexture {
|
|
20
|
-
readonly texture: TextureAsset;
|
|
21
|
-
readonly bytes: Uint8Array;
|
|
22
|
-
readonly mediaType?: string;
|
|
23
|
-
readonly assetCodec?: AssetCodec;
|
|
24
|
-
}
|
|
25
19
|
export declare function toAssetPack(params: {
|
|
26
20
|
readonly meshes: readonly MeshPod[];
|
|
27
21
|
readonly scene: ScenePod;
|
|
@@ -32,7 +26,6 @@ export declare function toAssetPack(params: {
|
|
|
32
26
|
readonly animationClips: readonly AnimationClipPod[];
|
|
33
27
|
readonly subAssets: readonly SubAsset[];
|
|
34
28
|
readonly sourceOverrides?: SourceOverrideMap;
|
|
35
|
-
readonly decodedTextures?: ReadonlyMap<number, FbxDecodedTexture>;
|
|
36
29
|
}): readonly ImportedAsset[];
|
|
37
30
|
export {};
|
|
38
31
|
//# sourceMappingURL=to-asset-pack.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"to-asset-pack.d.ts","sourceRoot":"","sources":["../src/to-asset-pack.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACV,gBAAgB,
|
|
1
|
+
{"version":3,"file":"to-asset-pack.d.ts","sourceRoot":"","sources":["../src/to-asset-pack.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACV,gBAAgB,EAEhB,aAAa,EAEb,WAAW,EAGX,6BAA6B,EAC7B,OAAO,EAEP,QAAQ,EACR,WAAW,EACX,OAAO,EACP,iBAAiB,EACjB,UAAU,EACX,MAAM,uBAAuB,CAAC;AAS/B,KAAK,QAAQ,GAAG;IACd,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B,CAAC;AAWF,wBAAgB,cAAc,CAC5B,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,SAAS;IAAE,YAAY,EAAE,WAAW,CAAC;IAAC,YAAY,EAAE,YAAY,CAAA;CAAE,EAAE,EACjF,eAAe,GAAE;IACf,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnD,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxD,QAAQ,CAAC,qBAAqB,CAAC,EAAE,SAAS,6BAA6B,EAAE,CAAC;IAC1E,QAAQ,CAAC,4BAA4B,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC;IAChF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CAC5B,GACL,aAAa,CAqRf;AAyJD,wBAAgB,WAAW,CAAC,MAAM,EAAE;IAClC,QAAQ,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,SAAS,WAAW,EAAE,CAAC;IAC3C,QAAQ,CAAC,QAAQ,EAAE,SAAS,UAAU,EAAE,CAAC;IACzC,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,cAAc,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACrD,QAAQ,CAAC,SAAS,EAAE,SAAS,QAAQ,EAAE,CAAC;IACxC,QAAQ,CAAC,eAAe,CAAC,EAAE,iBAAiB,CAAC;CAC9C,GAAG,SAAS,aAAa,EAAE,CA4O3B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/engine-fbx",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,19 +23,18 @@
|
|
|
23
23
|
"README.md"
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@forgeax/engine-animation": "0.1.
|
|
27
|
-
"@forgeax/engine-math": "0.1.
|
|
28
|
-
"@forgeax/engine-
|
|
29
|
-
"@forgeax/engine-
|
|
30
|
-
"@forgeax/engine-
|
|
26
|
+
"@forgeax/engine-animation": "0.1.6",
|
|
27
|
+
"@forgeax/engine-math": "0.1.6",
|
|
28
|
+
"@forgeax/engine-pack": "0.1.6",
|
|
29
|
+
"@forgeax/engine-render": "0.1.6",
|
|
30
|
+
"@forgeax/engine-scene": "0.1.6",
|
|
31
|
+
"@forgeax/engine-types": "0.1.6"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
|
-
"@forgeax/engine-assets-runtime": "0.1.
|
|
34
|
-
"@forgeax/engine-
|
|
35
|
-
"@forgeax/engine-
|
|
36
|
-
"@forgeax/engine-
|
|
37
|
-
"@forgeax/engine-picking": "0.1.3",
|
|
38
|
-
"@forgeax/engine-runtime": "0.1.3",
|
|
34
|
+
"@forgeax/engine-assets-runtime": "0.1.6",
|
|
35
|
+
"@forgeax/engine-import": "0.1.6",
|
|
36
|
+
"@forgeax/engine-picking": "0.1.6",
|
|
37
|
+
"@forgeax/engine-runtime": "0.1.6",
|
|
39
38
|
"@types/node": "^20.14.0"
|
|
40
39
|
},
|
|
41
40
|
"forgeax": {
|
package/pkg/fbx-wasm.wasm
CHANGED
|
Binary file
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { meshAssetKind } from '@forgeax/engine-geometry';
|
|
1
|
+
import { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
3
2
|
import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
|
|
3
|
+
import type { MeshAsset } from '@forgeax/engine-types';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
import { fbxImporter } from '../fbx-importer.js';
|
|
6
6
|
import { initFbxWasm, parseFbx } from '../index.js';
|
|
7
|
-
import { createAssetRuntimeFixture, installMeshDecoder } from './asset-runtime-fixture';
|
|
8
7
|
|
|
9
8
|
const MESH_GUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
|
10
9
|
const sourceUrl = new URL(
|
|
@@ -53,7 +52,6 @@ describe('real FBX BlendShape import through ufbx WASM', () => {
|
|
|
53
52
|
if (!result.ok || 'skipped' in result.value) throw new Error('expected FBX DDC pack');
|
|
54
53
|
const entry = result.value.pack.assets.find((asset) => asset.guid === MESH_GUID);
|
|
55
54
|
expect(entry?.kind).toBe('mesh');
|
|
56
|
-
if (entry === undefined) return;
|
|
57
55
|
const payload = entry?.payload as {
|
|
58
56
|
readonly vertices: readonly number[];
|
|
59
57
|
readonly morphTargets?: readonly { readonly position?: readonly number[] }[];
|
|
@@ -64,20 +62,16 @@ describe('real FBX BlendShape import through ufbx WASM', () => {
|
|
|
64
62
|
expect(payload.morphWeights).toEqual([0.25, 0.5]);
|
|
65
63
|
expect(payload.vertices.length).toBe(3 * 12);
|
|
66
64
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(loaded.value.morphTargets).toHaveLength(2);
|
|
78
|
-
} finally {
|
|
79
|
-
assets.dispose();
|
|
80
|
-
}
|
|
65
|
+
const runtime = new AssetRegistry({} as never);
|
|
66
|
+
const parsedMesh = runtime.parseAssetPayload('mesh', entry?.payload ?? {});
|
|
67
|
+
expect(parsedMesh).toMatchObject({ kind: 'mesh' });
|
|
68
|
+
expect(runtime.catalog(MESH_GUID, parsedMesh as never).ok).toBe(true);
|
|
69
|
+
const loaded = await runtime.loadByGuid<MeshAsset>(runtime.parseGuid(MESH_GUID));
|
|
70
|
+
expect(loaded.ok).toBe(true);
|
|
71
|
+
if (!loaded.ok) return;
|
|
72
|
+
expect(loaded.value.kind).toBe('mesh');
|
|
73
|
+
expect(Array.from(loaded.value.morphWeights ?? [])).toEqual([0.25, 0.5]);
|
|
74
|
+
expect(loaded.value.morphTargets).toHaveLength(2);
|
|
81
75
|
});
|
|
82
76
|
|
|
83
77
|
it('records that this real fixture has no imported animation clip', async () => {
|
|
@@ -1,169 +1,5 @@
|
|
|
1
|
-
import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
|
|
2
|
-
import type { ImportErrorDetail } from '@forgeax/engine-types';
|
|
3
1
|
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { deriveFbxSourceKeys,
|
|
5
|
-
|
|
6
|
-
const FBX_SOURCE = 'apps/hello/format-tier1/fixtures/multi-target-morph.fbx';
|
|
7
|
-
const FBX_GUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
|
8
|
-
const FBX_SOURCE_URL = new URL(`../../../../${FBX_SOURCE}`, import.meta.url);
|
|
9
|
-
|
|
10
|
-
async function readFbxFixture(): Promise<Uint8Array> {
|
|
11
|
-
const nodeFs = (await import('node:' + 'fs/promises')) as unknown as {
|
|
12
|
-
readonly readFile: (path: URL) => Promise<Uint8Array>;
|
|
13
|
-
};
|
|
14
|
-
return new Uint8Array(await nodeFs.readFile(FBX_SOURCE_URL));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function fbxMeta(): RunImportMeta {
|
|
18
|
-
return {
|
|
19
|
-
importer: 'fbx',
|
|
20
|
-
source: FBX_SOURCE,
|
|
21
|
-
subAssets: [
|
|
22
|
-
{
|
|
23
|
-
guid: FBX_GUID,
|
|
24
|
-
sourceIndex: 0,
|
|
25
|
-
sourceKey: 'fbx:mesh:MorphTriangle',
|
|
26
|
-
kind: 'mesh',
|
|
27
|
-
},
|
|
28
|
-
],
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('public FBX importer source-read recovery', () => {
|
|
33
|
-
it('preserves refusal and retries on the same registry and Meta/GUID', async () => {
|
|
34
|
-
const sourceBytes = await readFbxFixture();
|
|
35
|
-
const registry = new ImporterRegistry();
|
|
36
|
-
registry.register(fbxImporter);
|
|
37
|
-
const reads = { count: 0 };
|
|
38
|
-
let refuseImporterRead = true;
|
|
39
|
-
const fs = {
|
|
40
|
-
readSource: async () => {
|
|
41
|
-
reads.count += 1;
|
|
42
|
-
if (refuseImporterRead && reads.count === 2) {
|
|
43
|
-
return {
|
|
44
|
-
ok: false as const,
|
|
45
|
-
error: new Error('transient source read refusal: repairable'),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
return { ok: true as const, value: sourceBytes };
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const refused = await runImport(fbxMeta(), registry, fs);
|
|
53
|
-
expect(refused).toMatchObject({
|
|
54
|
-
ok: false,
|
|
55
|
-
error: {
|
|
56
|
-
code: 'source-read-failed',
|
|
57
|
-
detail: {
|
|
58
|
-
source: FBX_SOURCE,
|
|
59
|
-
reason: 'transient source read refusal: repairable',
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
expect(refused).not.toHaveProperty('value.pack');
|
|
64
|
-
expect(reads.count).toBe(2);
|
|
65
|
-
|
|
66
|
-
refuseImporterRead = false;
|
|
67
|
-
const repaired = await runImport(fbxMeta(), registry, fs);
|
|
68
|
-
const repeated = await runImport(fbxMeta(), registry, fs);
|
|
69
|
-
expect(repaired.ok).toBe(true);
|
|
70
|
-
expect(repeated.ok).toBe(true);
|
|
71
|
-
if (
|
|
72
|
-
!repaired.ok ||
|
|
73
|
-
'skipped' in repaired.value ||
|
|
74
|
-
!repeated.ok ||
|
|
75
|
-
'skipped' in repeated.value
|
|
76
|
-
) {
|
|
77
|
-
throw new Error('repaired FBX import must publish a product');
|
|
78
|
-
}
|
|
79
|
-
expect(repaired.value.pack.assets).toHaveLength(1);
|
|
80
|
-
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
81
|
-
guid: FBX_GUID,
|
|
82
|
-
sourceKey: 'fbx:mesh:MorphTriangle',
|
|
83
|
-
kind: 'mesh',
|
|
84
|
-
});
|
|
85
|
-
expect(repeated.value.pack).toEqual(repaired.value.pack);
|
|
86
|
-
expect(reads.count).toBe(6);
|
|
87
|
-
expect(registry.registeredImporters()).toEqual(['fbx']);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe('public FBX importer malformed-source recovery', () => {
|
|
92
|
-
it('returns one bounded source diagnostic, then retries deterministically on repaired bytes', async () => {
|
|
93
|
-
const sourceBytes = await readFbxFixture();
|
|
94
|
-
const meta = fbxMeta();
|
|
95
|
-
const registry = new ImporterRegistry();
|
|
96
|
-
registry.register(fbxImporter);
|
|
97
|
-
const reads = { count: 0 };
|
|
98
|
-
let bytes: Uint8Array<ArrayBufferLike> = new TextEncoder().encode(
|
|
99
|
-
'readable but malformed FBX source',
|
|
100
|
-
);
|
|
101
|
-
const fs = {
|
|
102
|
-
readSource: async (path: string) => {
|
|
103
|
-
reads.count += 1;
|
|
104
|
-
expect(path).toBe(FBX_SOURCE);
|
|
105
|
-
return { ok: true as const, value: bytes };
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
const refused = await runImport(meta, registry, fs);
|
|
110
|
-
expect(refused.ok).toBe(false);
|
|
111
|
-
expect(refused).not.toHaveProperty('value');
|
|
112
|
-
if (refused.ok) throw new Error('malformed FBX must be refused');
|
|
113
|
-
const diagnostics = (
|
|
114
|
-
refused.error.detail as Extract<
|
|
115
|
-
ImportErrorDetail,
|
|
116
|
-
{ readonly diagnostics: readonly unknown[] }
|
|
117
|
-
>
|
|
118
|
-
).diagnostics;
|
|
119
|
-
expect(refused.error.code).toBe('source-validation-failed');
|
|
120
|
-
expect(refused.error).not.toHaveProperty('value');
|
|
121
|
-
expect(refused.error).not.toHaveProperty('pack');
|
|
122
|
-
expect(refused.error).not.toHaveProperty('cookProducts');
|
|
123
|
-
expect(diagnostics).toHaveLength(1);
|
|
124
|
-
expect(refused.error).toMatchObject({
|
|
125
|
-
expected: 'a readable FBX source that ufbx can parse',
|
|
126
|
-
detail: {
|
|
127
|
-
diagnostics: [
|
|
128
|
-
{
|
|
129
|
-
code: 'fbx-source-parse-failed',
|
|
130
|
-
severity: 'error',
|
|
131
|
-
sourcePath: FBX_SOURCE,
|
|
132
|
-
sourceRange: { start: 0, end: 0, line: 1, column: 1 },
|
|
133
|
-
rule: 'fbx-source-parse',
|
|
134
|
-
expected: 'a readable FBX source that ufbx can parse',
|
|
135
|
-
actual: 'fbx-wasm: Unrecognized file format',
|
|
136
|
-
},
|
|
137
|
-
],
|
|
138
|
-
},
|
|
139
|
-
});
|
|
140
|
-
expect(diagnostics[0]?.actual.length).toBeLessThanOrEqual(256);
|
|
141
|
-
expect(reads.count).toBe(2);
|
|
142
|
-
|
|
143
|
-
bytes = sourceBytes;
|
|
144
|
-
const repaired = await runImport(meta, registry, fs);
|
|
145
|
-
const repeated = await runImport(meta, registry, fs);
|
|
146
|
-
expect(repaired.ok).toBe(true);
|
|
147
|
-
expect(repeated.ok).toBe(true);
|
|
148
|
-
if (
|
|
149
|
-
!repaired.ok ||
|
|
150
|
-
'skipped' in repaired.value ||
|
|
151
|
-
!repeated.ok ||
|
|
152
|
-
'skipped' in repeated.value
|
|
153
|
-
) {
|
|
154
|
-
throw new Error('repaired FBX import must publish a product');
|
|
155
|
-
}
|
|
156
|
-
expect(repaired.value.pack).toEqual(repeated.value.pack);
|
|
157
|
-
expect(repaired.value.pack.assets).toHaveLength(1);
|
|
158
|
-
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
159
|
-
guid: FBX_GUID,
|
|
160
|
-
sourceKey: 'fbx:mesh:MorphTriangle',
|
|
161
|
-
kind: 'mesh',
|
|
162
|
-
});
|
|
163
|
-
expect(reads.count).toBe(6);
|
|
164
|
-
expect(registry.registeredImporters()).toEqual(['fbx']);
|
|
165
|
-
});
|
|
166
|
-
});
|
|
2
|
+
import { deriveFbxSourceKeys, sourceKeyForFbxOutput } from '../fbx-importer.js';
|
|
167
3
|
|
|
168
4
|
describe('fbx importer contract migration fixture', () => {
|
|
169
5
|
it('requires the generic ImportProduct shape', () => {
|
|
@@ -52,6 +52,7 @@ describe('@forgeax/engine-fbx barrel', () => {
|
|
|
52
52
|
expect(typeof mod.parseSkin).toBe('function');
|
|
53
53
|
expect(typeof mod.parseAnimationClips).toBe('function');
|
|
54
54
|
expect(typeof mod.parseTextures).toBe('function');
|
|
55
|
+
expect(typeof mod.resolveFbxTexturePath).toBe('function');
|
|
55
56
|
expect(typeof mod.toAssetPack).toBe('function');
|
|
56
57
|
expect(typeof mod.fbxErr).toBe('function');
|
|
57
58
|
});
|
package/src/fbx-importer.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
// fbx-importer.ts — TS wrapper around the ufbx WASM parser.
|
|
2
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';
|
|
3
|
+
import type { ImportContext, Importer, ImportResult } from '@forgeax/engine-types';
|
|
11
4
|
import { fbxErr } from './errors.js';
|
|
12
5
|
import { initFbxWasm, parseFbx } from './index.js';
|
|
13
6
|
import {
|
|
@@ -21,13 +14,11 @@ import { type FbxRawNodes, parseScene } from './parse-scene.js';
|
|
|
21
14
|
import { type FbxRawSkeletonDoc, parseSkeleton } from './parse-skeleton.js';
|
|
22
15
|
import { type FbxRawSkinDoc, parseSkin } from './parse-skin.js';
|
|
23
16
|
import { parseTextures } from './parse-texture.js';
|
|
24
|
-
import {
|
|
25
|
-
import { type FbxDecodedTexture, toAssetPack } from './to-asset-pack.js';
|
|
17
|
+
import { toAssetPack } from './to-asset-pack.js';
|
|
26
18
|
|
|
27
19
|
export interface FbxSourceKeyOutput {
|
|
28
20
|
readonly kind: string;
|
|
29
21
|
readonly name?: string;
|
|
30
|
-
readonly sourceKey?: string;
|
|
31
22
|
}
|
|
32
23
|
|
|
33
24
|
export type FbxSourceKeyResult =
|
|
@@ -41,8 +32,6 @@ export type FbxSourceKeyResult =
|
|
|
41
32
|
export function sourceKeyForFbxOutput(output: FbxSourceKeyOutput): string | undefined {
|
|
42
33
|
const kind = output.kind.trim();
|
|
43
34
|
if (kind.length === 0) return undefined;
|
|
44
|
-
const explicit = output.sourceKey?.trim();
|
|
45
|
-
if (explicit !== undefined && explicit.length > 0) return explicit;
|
|
46
35
|
const name = output.name?.trim();
|
|
47
36
|
return name === undefined || name.length === 0 ? `fbx:${kind}` : `fbx:${kind}:${name}`;
|
|
48
37
|
}
|
|
@@ -64,97 +53,6 @@ export function deriveFbxSourceKeys(outputs: readonly FbxSourceKeyOutput[]): Fbx
|
|
|
64
53
|
return { ok: true, keys: keys as string[] };
|
|
65
54
|
}
|
|
66
55
|
|
|
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
56
|
export const fbxImporter: Importer = {
|
|
159
57
|
key: 'fbx',
|
|
160
58
|
|
|
@@ -164,31 +62,17 @@ export const fbxImporter: Importer = {
|
|
|
164
62
|
// addon / SDK build step (the WASM module self-loads its .wasm).
|
|
165
63
|
const read = await ctx.readSource();
|
|
166
64
|
if (!read.ok) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
};
|
|
65
|
+
const wrapper = new Error(`fbx-source-unreadable: ${ctx.source}`);
|
|
66
|
+
(wrapper as { cause?: unknown }).cause = read.error;
|
|
67
|
+
throw wrapper;
|
|
179
68
|
}
|
|
180
69
|
|
|
181
70
|
await initFbxWasm();
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
FbxRawSkinDoc &
|
|
188
|
-
FbxRawAnimDoc;
|
|
189
|
-
} catch (error) {
|
|
190
|
-
return { ok: false, error: parseValidationError(ctx, error) };
|
|
191
|
-
}
|
|
71
|
+
const jsonStr = parseFbx(read.value);
|
|
72
|
+
const doc = JSON.parse(jsonStr) as FbxRawDocument &
|
|
73
|
+
FbxRawSkeletonDoc &
|
|
74
|
+
FbxRawSkinDoc &
|
|
75
|
+
FbxRawAnimDoc;
|
|
192
76
|
|
|
193
77
|
// NURBS / patch fail-fast: the bridge emits an error envelope for
|
|
194
78
|
// unsupported surface types (charter P3 explicit failure).
|
|
@@ -241,115 +125,6 @@ export const fbxImporter: Importer = {
|
|
|
241
125
|
}
|
|
242
126
|
const animationClips = parseAnimationClips(doc);
|
|
243
127
|
|
|
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
128
|
return {
|
|
354
129
|
ok: true,
|
|
355
130
|
value: {
|
|
@@ -362,7 +137,6 @@ export const fbxImporter: Importer = {
|
|
|
362
137
|
skin,
|
|
363
138
|
animationClips,
|
|
364
139
|
subAssets: ctx.subAssets,
|
|
365
|
-
decodedTextures,
|
|
366
140
|
...(ctx.sourceOverrides === undefined ? {} : { sourceOverrides: ctx.sourceOverrides }),
|
|
367
141
|
}),
|
|
368
142
|
sourceDependencies: [],
|