@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,91 @@
1
+ #!/usr/bin/env node
2
+ // test-wasm.mjs — quick smoke test for the compiled fbx-wasm module.
3
+ // Runs in Node.js (not the browser) using the compiled .mjs + .wasm.
4
+ //
5
+ // Usage: node scripts/test-wasm.mjs <path-to-fbx>
6
+ // e.g.: node scripts/test-wasm.mjs ../../forgeax-engine-assets/vendor/fbx-test/cube.fbx
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const pkgDir = resolve(__dirname, '..');
14
+
15
+ // Emscripten builds with ENVIRONMENT=web, but we can make it work in Node
16
+ // by providing a locateFile that resolves the .wasm correctly.
17
+ const wasmPath = resolve(pkgDir, 'pkg', 'fbx-wasm.wasm');
18
+ const wasmBinary = readFileSync(wasmPath);
19
+
20
+ // Dynamic import of the Emscripten module (Windows needs file:// URLs)
21
+ import { pathToFileURL } from 'node:url';
22
+ const modulePath = pathToFileURL(resolve(pkgDir, 'pkg', 'fbx-wasm.mjs')).href;
23
+ const createModule = (await import(modulePath)).default;
24
+
25
+ const mod = await createModule({
26
+ wasmBinary,
27
+ });
28
+
29
+ // Read the FBX file
30
+ const fbxPath = process.argv[2];
31
+ if (!fbxPath) {
32
+ console.error('Usage: node scripts/test-wasm.mjs <path-to-fbx>');
33
+ process.exit(1);
34
+ }
35
+
36
+ const fbxBytes = readFileSync(resolve(fbxPath));
37
+ console.log(`Parsing: ${fbxPath} (${fbxBytes.length} bytes)`);
38
+
39
+ const ptr = mod._malloc(fbxBytes.length);
40
+ mod.HEAPU8.set(fbxBytes, ptr);
41
+ mod._parseFbxWasm(ptr, fbxBytes.length);
42
+ mod._free(ptr);
43
+
44
+ const resultPtr = mod._getResultPtr();
45
+ const resultLen = mod._getResultLen();
46
+
47
+ if (!resultPtr || !resultLen) {
48
+ console.error('WASM returned empty result');
49
+ mod._freeResult();
50
+ process.exit(1);
51
+ }
52
+
53
+ const json = mod.UTF8ToString(resultPtr, resultLen);
54
+ mod._freeResult();
55
+
56
+ const parsed = JSON.parse(json);
57
+
58
+ if (parsed.error) {
59
+ console.error('Parse error:', parsed.error);
60
+ process.exit(1);
61
+ }
62
+
63
+ console.log('\n=== FBX Parse Result ===');
64
+ console.log(`Meshes: ${parsed.meshes?.length ?? 0}`);
65
+ console.log(`Nodes: ${parsed.nodes?.length ?? 0}`);
66
+ console.log(`Materials: ${parsed.materials?.length ?? 0}`);
67
+ console.log(`Skeletons: ${parsed.skeletons?.length ?? 0}`);
68
+ console.log(`Skins: ${parsed.skins?.length ?? 0}`);
69
+ console.log(`Clips: ${parsed.clips?.length ?? 0}`);
70
+
71
+ if (parsed.meshes?.length > 0) {
72
+ const m = parsed.meshes[0];
73
+ console.log(`\nFirst mesh: "${m.name ?? '(unnamed)'}"`);
74
+ console.log(` vertices: ${m.vertices.length / 3} positions`);
75
+ console.log(` indices: ${m.indices?.length ?? 0}`);
76
+ console.log(` polygons: ${m.polygonCount}`);
77
+ console.log(` attrs: ${Object.keys(m.attributes).join(', ')}`);
78
+ }
79
+
80
+ if (parsed.skeletons?.length > 0) {
81
+ const s = parsed.skeletons[0];
82
+ console.log(`\nSkeleton: ${s.jointCount} joints`);
83
+ console.log(` jointPaths: ${s.jointPaths.slice(0, 5).join(', ')}${s.jointCount > 5 ? '...' : ''}`);
84
+ }
85
+
86
+ if (parsed.clips?.length > 0) {
87
+ const c = parsed.clips[0];
88
+ console.log(`\nFirst clip: "${c.name ?? '(unnamed)'}" (${c.duration.toFixed(3)}s, ${c.channels.length} channels)`);
89
+ }
90
+
91
+ console.log('\n✅ WASM FBX parsing test passed!');
@@ -0,0 +1,90 @@
1
+ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
+ import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
3
+ import type { MeshAsset } from '@forgeax/engine-types';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { fbxImporter } from '../fbx-importer.js';
6
+ import { initFbxWasm, parseFbx } from '../index.js';
7
+
8
+ const MESH_GUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
9
+ const sourceUrl = new URL(
10
+ '../../../../apps/hello/format-tier1/fixtures/multi-target-morph.fbx',
11
+ import.meta.url,
12
+ );
13
+
14
+ async function readFixture(url: URL): Promise<Uint8Array> {
15
+ const nodeFs = (await import('node:' + 'fs/promises')) as unknown as {
16
+ readonly readFile: (path: URL) => Promise<Uint8Array>;
17
+ };
18
+ return new Uint8Array(await nodeFs.readFile(url));
19
+ }
20
+
21
+ function meta(): RunImportMeta {
22
+ return {
23
+ importer: 'fbx',
24
+ source: 'apps/hello/format-tier1/fixtures/multi-target-morph.fbx',
25
+ subAssets: [
26
+ { guid: MESH_GUID, sourceIndex: 0, sourceKey: 'fbx:mesh:MorphTriangle', kind: 'mesh' },
27
+ ],
28
+ };
29
+ }
30
+
31
+ describe('real FBX BlendShape import through ufbx WASM', () => {
32
+ it('keeps source target order, authored weights, and target lengths', async () => {
33
+ const bytes = await readFixture(sourceUrl);
34
+ await initFbxWasm();
35
+ const raw = JSON.parse(parseFbx(bytes)) as {
36
+ readonly meshes: readonly {
37
+ readonly morphTargets?: readonly { readonly position?: readonly number[] }[];
38
+ readonly morphWeights?: readonly number[];
39
+ }[];
40
+ };
41
+ expect(raw.meshes).toHaveLength(1);
42
+ expect(raw.meshes[0]?.morphTargets).toHaveLength(2);
43
+ expect(raw.meshes[0]?.morphTargets?.[0]?.position).toEqual([0, 0, 0, 0.25, 0, 0, 0, 0.5, 0]);
44
+ expect(raw.meshes[0]?.morphTargets?.[1]?.position).toEqual([0, 0, -0.25, 0, 0, 0, 0, 0, 0]);
45
+ expect(raw.meshes[0]?.morphWeights).toEqual([0.25, 0.5]);
46
+
47
+ const registry = new ImporterRegistry();
48
+ registry.register(fbxImporter);
49
+ const fs = { readSource: async () => ({ ok: true as const, value: bytes }) };
50
+ const result = await runImport(meta(), registry, fs);
51
+ expect(result.ok).toBe(true);
52
+ if (!result.ok || 'skipped' in result.value) throw new Error('expected FBX DDC pack');
53
+ const entry = result.value.pack.assets.find((asset) => asset.guid === MESH_GUID);
54
+ expect(entry?.kind).toBe('mesh');
55
+ const payload = entry?.payload as {
56
+ readonly vertices: readonly number[];
57
+ readonly morphTargets?: readonly { readonly position?: readonly number[] }[];
58
+ readonly morphWeights?: readonly number[];
59
+ };
60
+ expect(payload.morphTargets).toHaveLength(2);
61
+ expect(payload.morphTargets?.map((target) => target.position?.length)).toEqual([9, 9]);
62
+ expect(payload.morphWeights).toEqual([0.25, 0.5]);
63
+ expect(payload.vertices.length).toBe(3 * 12);
64
+
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);
75
+ });
76
+
77
+ it('records that this real fixture has no imported animation clip', async () => {
78
+ const bytes = await readFixture(sourceUrl);
79
+ await initFbxWasm();
80
+ const registry = new ImporterRegistry();
81
+ registry.register(fbxImporter);
82
+ const fs = { readSource: async () => ({ ok: true as const, value: bytes }) };
83
+ const result = await runImport(meta(), registry, fs);
84
+ expect(result.ok).toBe(true);
85
+ if (!result.ok || 'skipped' in result.value) return;
86
+ expect(
87
+ result.value.pack.assets.filter((asset) => asset.kind === 'animation-clip'),
88
+ ).toHaveLength(0);
89
+ });
90
+ });
@@ -0,0 +1,83 @@
1
+ import type { MeshPod } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { parseAnimationClips } from '../parse-animation-clip.js';
4
+ import { type FbxRawMesh, parseMesh } from '../parse-mesh.js';
5
+ import { buildMeshAsset } from '../to-asset-pack.js';
6
+
7
+ type MorphRawMesh = FbxRawMesh & {
8
+ readonly morphTargets?: readonly {
9
+ readonly position?: number[];
10
+ readonly normal?: number[];
11
+ readonly tangent?: number[];
12
+ }[];
13
+ readonly morphWeights?: number[];
14
+ };
15
+
16
+ function rawMesh(overrides: Partial<MorphRawMesh> = {}): MorphRawMesh {
17
+ return {
18
+ vertices: [0, 0, 0, 1, 0, 0],
19
+ indices: [0, 1],
20
+ attributes: {},
21
+ polygonCount: 1,
22
+ sourceIndex: 0,
23
+ materialIndex: -1,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe('FBX BlendShape morph projection', () => {
29
+ it('keeps target-major deltas and authored weights through MeshAsset', () => {
30
+ const pod = parseMesh(
31
+ rawMesh({
32
+ morphTargets: [{ position: [0, 0, 0, 0.25, 0, 0] }],
33
+ morphWeights: [0.5],
34
+ }),
35
+ 0,
36
+ ) as MeshPod & {
37
+ readonly morphTargets?: readonly { readonly position?: Float32Array }[];
38
+ readonly morphWeights?: Float32Array;
39
+ };
40
+ const asset = buildMeshAsset(pod, 'fbx-morph-guid');
41
+ const mesh = asset.payload as {
42
+ morphTargets?: readonly { readonly position?: Float32Array }[];
43
+ morphWeights?: Float32Array;
44
+ };
45
+ expect(mesh.morphTargets?.[0]?.position?.[3]).toBeCloseTo(0.25);
46
+ expect(Array.from(mesh.morphWeights ?? [])).toEqual([0.5]);
47
+ });
48
+
49
+ it('fails before asset promotion when a source exceeds the eight-target limit', () => {
50
+ expect(() =>
51
+ parseMesh(
52
+ rawMesh({
53
+ morphTargets: Array.from({ length: 9 }, () => ({ position: [0, 0, 0, 0, 0, 0] })),
54
+ }),
55
+ 0,
56
+ ),
57
+ ).toThrow('fbx-morph-invalid');
58
+ });
59
+
60
+ it('preserves per-element FBX morph weight channels during resampling', () => {
61
+ const [clip] = parseAnimationClips(
62
+ {
63
+ clips: [
64
+ {
65
+ duration: 1,
66
+ channels: [
67
+ {
68
+ targetNode: 'Root',
69
+ property: 'weights',
70
+ weightCount: 4,
71
+ keyTimes: [0, 1],
72
+ keyValues: [0, 0, 0, 0, 1, 2, 3, 4],
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ },
78
+ 1,
79
+ );
80
+ expect(clip?.channels[0]?.property).toBe('weights');
81
+ expect(Array.from(clip?.channels[0]?.sampler.output ?? [])).toEqual([0, 0, 0, 0, 1, 2, 3, 4]);
82
+ });
83
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deriveFbxSourceKeys, sourceKeyForFbxOutput } from '../fbx-importer.js';
3
+
4
+ describe('fbx importer contract migration fixture', () => {
5
+ it('requires the generic ImportProduct shape', () => {
6
+ expect('ImportProduct').toBe('ImportProduct');
7
+ });
8
+
9
+ it('requires multi-asset output to keep local artifact ownership', () => {
10
+ expect('asset-local artifacts').toContain('artifacts');
11
+ expect('package-global artifacts').not.toContain('asset-local artifacts');
12
+ });
13
+ });
14
+
15
+ describe('FBX producer sourceKey', () => {
16
+ it('is stable across source relocation and output reorder', () => {
17
+ const first = deriveFbxSourceKeys([
18
+ { kind: 'mesh', name: 'Body' },
19
+ { kind: 'scene', name: 'Root' },
20
+ ]);
21
+ const reordered = deriveFbxSourceKeys([
22
+ { kind: 'scene', name: 'Root' },
23
+ { kind: 'mesh', name: 'Body' },
24
+ ]);
25
+ expect(first).toEqual({ ok: true, keys: ['fbx:mesh:Body', 'fbx:scene:Root'] });
26
+ expect(reordered).toEqual({ ok: true, keys: ['fbx:scene:Root', 'fbx:mesh:Body'] });
27
+ expect(sourceKeyForFbxOutput({ kind: 'mesh', name: 'Body' })).toBe('fbx:mesh:Body');
28
+ });
29
+
30
+ it('rejects duplicate anonymous output kinds instead of using array position', () => {
31
+ expect(deriveFbxSourceKeys([{ kind: 'mesh' }, { kind: 'mesh' }])).toEqual({
32
+ ok: false,
33
+ code: 'ambiguous-source-key',
34
+ });
35
+ });
36
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildMeshAsset } from '../to-asset-pack.js';
3
+
4
+ const GUID_A = '019e2cc6-0c86-79da-aa76-b0984c86d45c';
5
+ const GUID_B = '019e2cc6-0c86-79da-aa76-b0984c86d45d';
6
+
7
+ function mesh(guid: string) {
8
+ return buildMeshAsset(
9
+ {
10
+ sourceIndex: guid === GUID_A ? 0 : 1,
11
+ vertices: new Float32Array([0, 0, 0]),
12
+ indices: new Uint16Array([0]),
13
+ attributes: {},
14
+ submeshes: [],
15
+ },
16
+ guid,
17
+ );
18
+ }
19
+
20
+ describe('FBX asset-local artifacts', () => {
21
+ it('allows two assets to own the same local key without sharing refs or bytes', () => {
22
+ const first = mesh(GUID_A) as unknown as Record<string, unknown>;
23
+ const second = mesh(GUID_B) as unknown as Record<string, unknown>;
24
+ const firstBody = (first.artifacts as Record<string, Record<string, unknown>>).body;
25
+ const secondBody = (second.artifacts as Record<string, Record<string, unknown>>).body;
26
+ expect(firstBody).toBeDefined();
27
+ expect(secondBody).toBeDefined();
28
+ if (firstBody === undefined || secondBody === undefined) return;
29
+
30
+ expect(first.refs).toEqual([]);
31
+ expect(second.refs).toEqual([]);
32
+ expect(firstBody.mediaType).toBe('application/x-forgeax-mesh');
33
+ expect(secondBody.mediaType).toBe('application/x-forgeax-mesh');
34
+ expect(firstBody.bytes).toBeInstanceOf(Uint8Array);
35
+ expect(secondBody.bytes).toBeInstanceOf(Uint8Array);
36
+ expect(firstBody).not.toHaveProperty('path');
37
+ expect(firstBody).not.toHaveProperty('integrity');
38
+ });
39
+ });
@@ -0,0 +1,168 @@
1
+ import type { ImportedAsset, MeshAsset } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { FbxRawMesh } from '../parse-mesh.js';
4
+ import { parseMesh } from '../parse-mesh.js';
5
+ import { buildMeshAsset } from '../to-asset-pack.js';
6
+
7
+ describe('@forgeax/engine-fbx', () => {
8
+ it('exports initFbxWasm', async () => {
9
+ const mod = await import('../index.js');
10
+ expect(typeof mod.initFbxWasm).toBe('function');
11
+ });
12
+
13
+ it('exports parseFbx', async () => {
14
+ const mod = await import('../index.js');
15
+ expect(typeof mod.parseFbx).toBe('function');
16
+ });
17
+
18
+ it('exports parseFbxToObject', async () => {
19
+ const mod = await import('../index.js');
20
+ expect(typeof mod.parseFbxToObject).toBe('function');
21
+ });
22
+
23
+ it('exports isFbxWasmReady', async () => {
24
+ const mod = await import('../index.js');
25
+ expect(typeof mod.isFbxWasmReady).toBe('function');
26
+ });
27
+
28
+ it('isFbxWasmReady returns false before init', async () => {
29
+ const mod = await import('../index.js');
30
+ expect(mod.isFbxWasmReady()).toBe(false);
31
+ });
32
+
33
+ it('parseFbx throws before init', async () => {
34
+ const mod = await import('../index.js');
35
+ expect(() => mod.parseFbx(new Uint8Array(0))).toThrow('WASM not initialized');
36
+ });
37
+ });
38
+
39
+ describe('@forgeax/engine-fbx barrel', () => {
40
+ it('exports fbxImporter with key "fbx"', async () => {
41
+ const mod = await import('../index.js');
42
+ expect(mod.fbxImporter.key).toBe('fbx');
43
+ expect(typeof mod.fbxImporter.import).toBe('function');
44
+ });
45
+
46
+ it('exports the parse-*.ts bridge layer + toAssetPack', async () => {
47
+ const mod = await import('../index.js');
48
+ expect(typeof mod.parseMesh).toBe('function');
49
+ expect(typeof mod.parseScene).toBe('function');
50
+ expect(typeof mod.parseMaterial).toBe('function');
51
+ expect(typeof mod.parseSkeleton).toBe('function');
52
+ expect(typeof mod.parseSkin).toBe('function');
53
+ expect(typeof mod.parseAnimationClips).toBe('function');
54
+ expect(typeof mod.parseTextures).toBe('function');
55
+ expect(typeof mod.resolveFbxTexturePath).toBe('function');
56
+ expect(typeof mod.toAssetPack).toBe('function');
57
+ expect(typeof mod.fbxErr).toBe('function');
58
+ });
59
+ });
60
+
61
+ // ── M6: buildMeshAsset aabb — GREEN (M7 make-green) ─────────────────
62
+
63
+ /** Extract MeshAsset payload from an ImportedAsset (guards kind='mesh'). */
64
+ function meshFromAsset(asset: ImportedAsset): MeshAsset {
65
+ if (asset.kind !== 'mesh') throw new TypeError(`expected mesh, got ${asset.kind}`);
66
+ return asset.payload as MeshAsset;
67
+ }
68
+
69
+ /** Build a mock FbxRawMesh with no indices (per-vertex attributes only). */
70
+ function mockRawMesh(vertices: number[], attributes?: Record<string, number[]>): FbxRawMesh {
71
+ return {
72
+ name: 'AabbTestMesh',
73
+ vertices,
74
+ attributes: attributes ?? {},
75
+ polygonCount: Math.max(0, Math.floor(vertices.length / 9)),
76
+ sourceIndex: 0,
77
+ materialIndex: -1,
78
+ };
79
+ }
80
+
81
+ describe('buildMeshAsset aabb (GREEN)', () => {
82
+ it('m6-1a: normal quad — aabb matches position min/max', () => {
83
+ const vertices = [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0];
84
+ const raw = mockRawMesh(vertices);
85
+ const pod = parseMesh(raw, 0);
86
+ const asset = buildMeshAsset(pod, 'guid-quad');
87
+ const mesh = meshFromAsset(asset);
88
+
89
+ // aabb should be Float32Array(6) = [minX, minY, minZ, maxX, maxY, maxZ]
90
+ // from vertices pod: minX=0, minY=0, minZ=0, maxX=1, maxY=1, maxZ=0
91
+ const aabb = mesh.aabb;
92
+ expect(aabb, 'aabb must be defined').toBeDefined();
93
+ expect(aabb).toBeInstanceOf(Float32Array);
94
+ expect(aabb?.length).toBe(6);
95
+
96
+ // Normal quad: vertices [(0,0,0),(1,0,0),(0,1,0),(1,1,0)]
97
+ // biome and tsc strict guards: use ?. and as number for Float32Array index access
98
+ expect(aabb?.[0] as number, 'minX').toBe(0);
99
+ expect(aabb?.[1] as number, 'minY').toBe(0);
100
+ expect(aabb?.[2] as number, 'minZ').toBe(0);
101
+ expect(aabb?.[3] as number, 'maxX').toBe(1);
102
+ expect(aabb?.[4] as number, 'maxY').toBe(1);
103
+ expect(aabb?.[5] as number, 'maxZ').toBe(0);
104
+ });
105
+
106
+ it('m6-1b: single point — min == max for all axes', () => {
107
+ const vertices = [5, 5, 5];
108
+ const raw = mockRawMesh(vertices);
109
+ const pod = parseMesh(raw, 0);
110
+ const asset = buildMeshAsset(pod, 'guid-point');
111
+ const mesh = meshFromAsset(asset);
112
+
113
+ const aabb = mesh.aabb;
114
+ expect(aabb, 'aabb must be defined').toBeDefined();
115
+ expect(aabb?.length).toBe(6);
116
+
117
+ expect(aabb?.[0] as number, 'minX').toBe(5);
118
+ expect(aabb?.[1] as number, 'minY').toBe(5);
119
+ expect(aabb?.[2] as number, 'minZ').toBe(5);
120
+ expect(aabb?.[3] as number, 'maxX').toBe(5);
121
+ expect(aabb?.[4] as number, 'maxY').toBe(5);
122
+ expect(aabb?.[5] as number, 'maxZ').toBe(5);
123
+ });
124
+
125
+ it('m6-1c: empty vertices — inverted-empty box, no NaN', () => {
126
+ const vertices: number[] = [];
127
+ const raw = mockRawMesh(vertices);
128
+ const pod = parseMesh(raw, 0);
129
+ const asset = buildMeshAsset(pod, 'guid-empty');
130
+ const mesh = meshFromAsset(asset);
131
+
132
+ const aabb = mesh.aabb;
133
+ expect(aabb, 'aabb must be defined for empty mesh').toBeDefined();
134
+ expect(aabb?.length).toBe(6);
135
+
136
+ // Inverted-empty box convention: min.x > max.x (from box3.fromPositions
137
+ // when positions.length < 3). This signals "no volume" to pick.ts which
138
+ // skips the entity (AC-07).
139
+ expect(aabb?.[0] as number, 'minX').toBeGreaterThan(aabb?.[3] as number);
140
+ // No NaN anywhere
141
+ for (let i = 0; i < 6; i++) {
142
+ expect(Number.isNaN(aabb?.[i] ?? 0), `aabb[${i}] must not be NaN`).toBe(false);
143
+ }
144
+ });
145
+
146
+ it('m6-1d: degenerate plane (Y=0) — one axis min==max, still pickable', () => {
147
+ const vertices = [0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1];
148
+ const raw = mockRawMesh(vertices);
149
+ const pod = parseMesh(raw, 0);
150
+ const asset = buildMeshAsset(pod, 'guid-plane');
151
+ const mesh = meshFromAsset(asset);
152
+
153
+ const aabb = mesh.aabb;
154
+ expect(aabb, 'aabb must be defined').toBeDefined();
155
+ expect(aabb?.length).toBe(6);
156
+
157
+ // Degenerate in Y (all Y=0) => minY == maxY == 0
158
+ expect(aabb?.[0] as number, 'minX').toBe(0);
159
+ expect(aabb?.[1] as number, 'minY').toBe(0);
160
+ expect(aabb?.[2] as number, 'minZ').toBe(0);
161
+ expect(aabb?.[3] as number, 'maxX').toBe(1);
162
+ expect(aabb?.[4] as number, 'maxY').toBe(0);
163
+ expect(aabb?.[5] as number, 'maxZ').toBe(1);
164
+
165
+ // Verify degenerate condition: Y axis has zero thickness
166
+ expect(aabb?.[1] as number).toBe(aabb?.[4] as number);
167
+ });
168
+ });
@@ -0,0 +1,125 @@
1
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
2
+ import type { MeshAsset, MeshPod } from '@forgeax/engine-types';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { buildMeshAsset } from '../to-asset-pack';
5
+
6
+ describe('FBX mesh material slots', () => {
7
+ it('projects repeated material indices through one stable slot table', () => {
8
+ const pod: MeshPod = {
9
+ name: 'MultiMaterial',
10
+ vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
11
+ indices: new Uint16Array([0, 1, 2]),
12
+ attributes: {},
13
+ sourceIndex: 0,
14
+ submeshes: [0, 1, 0].map((materialIndex) => ({
15
+ indexOffset: 0,
16
+ indexCount: 3,
17
+ vertexCount: 3,
18
+ topology: 'triangle-list' as const,
19
+ materialIndex,
20
+ })),
21
+ };
22
+ const asset = buildMeshAsset(pod, '019d0000-0000-7000-8000-000000000010', undefined, {
23
+ guidByIndex: new Map([
24
+ [0, '019d0000-0000-7000-8000-000000000011'],
25
+ [1, '019d0000-0000-7000-8000-000000000012'],
26
+ ]),
27
+ nameByIndex: new Map([
28
+ [0, 'Body'],
29
+ [1, 'Trim'],
30
+ ]),
31
+ });
32
+ if (asset.kind !== 'mesh') throw new Error('expected mesh');
33
+ const mesh = asset.payload as MeshAsset;
34
+
35
+ expect(mesh.submeshes.map((submesh) => submesh.materialSlot)).toEqual([0, 1, 0]);
36
+ expect(mesh.materialSlots.map((slot) => slot.slotName)).toEqual(['Body', 'Trim']);
37
+ expect(asset.refs.map((ref) => ref.guid)).toEqual([
38
+ '019d0000-0000-7000-8000-000000000011',
39
+ '019d0000-0000-7000-8000-000000000012',
40
+ ]);
41
+ });
42
+
43
+ it('keeps old indices across source reorder and appends a newly discovered slot', () => {
44
+ const pod: MeshPod = {
45
+ name: 'Reimported',
46
+ vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
47
+ indices: new Uint16Array([0, 1, 2]),
48
+ attributes: {},
49
+ sourceIndex: 0,
50
+ submeshes: [0, 1].map((materialIndex) => ({
51
+ indexOffset: 0,
52
+ indexCount: 3,
53
+ vertexCount: 3,
54
+ topology: 'triangle-list' as const,
55
+ materialIndex,
56
+ })),
57
+ };
58
+ const asset = buildMeshAsset(pod, '019d0000-0000-7000-8000-000000000020', undefined, {
59
+ guidByIndex: new Map([
60
+ [0, '019d0000-0000-7000-8000-000000000021'],
61
+ [1, '019d0000-0000-7000-8000-000000000022'],
62
+ ]),
63
+ nameByIndex: new Map([
64
+ [0, 'Accent'],
65
+ [1, 'Body'],
66
+ ]),
67
+ sourceKeyByIndex: new Map([
68
+ [0, 'fbx:material:Accent'],
69
+ [1, 'fbx:material:Body'],
70
+ ]),
71
+ previousMaterialSlots: [
72
+ { slotName: 'Body', sourceKey: 'fbx:material:Body' },
73
+ { slotName: 'Trim', sourceKey: 'fbx:material:Trim', defaultMaterialGuid: 'old-trim' },
74
+ ],
75
+ materialSlotDefaultOverrides: {
76
+ 'fbx:material:Trim': '019d0000-0000-7000-8000-000000000099',
77
+ },
78
+ });
79
+ if (asset.kind !== 'mesh') throw new Error('expected mesh');
80
+ const mesh = asset.payload as MeshAsset;
81
+
82
+ expect(mesh.submeshes.map((submesh) => submesh.materialSlot)).toEqual([2, 0]);
83
+ expect(mesh.materialSlots.map((slot) => slot.slotName)).toEqual(['Body', 'Trim', 'Accent']);
84
+ expect(mesh.materialSlots[1]?.defaultMaterial).toBeUndefined();
85
+ expect(asset.refs.map((ref) => ref.guid)).not.toContain('019d0000-0000-7000-8000-000000000099');
86
+ });
87
+
88
+ it('cooks the authored default while retaining stable producer topology', () => {
89
+ const sourceGuid = '019d0000-0000-7000-8000-000000000031';
90
+ const authoredGuid = '019d0000-0000-7000-8000-000000000032';
91
+ const pod: MeshPod = {
92
+ name: 'Editable',
93
+ vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
94
+ indices: new Uint16Array([0, 1, 2]),
95
+ attributes: {},
96
+ sourceIndex: 0,
97
+ submeshes: [
98
+ {
99
+ indexOffset: 0,
100
+ indexCount: 3,
101
+ vertexCount: 3,
102
+ topology: 'triangle-list',
103
+ materialIndex: 0,
104
+ },
105
+ ],
106
+ };
107
+ const asset = buildMeshAsset(pod, '019d0000-0000-7000-8000-000000000030', undefined, {
108
+ guidByIndex: new Map([[0, sourceGuid]]),
109
+ nameByIndex: new Map([[0, 'Body']]),
110
+ sourceKeyByIndex: new Map([[0, 'fbx:material:Body']]),
111
+ previousMaterialSlots: [
112
+ {
113
+ slotName: 'Body',
114
+ sourceKey: 'fbx:material:Body',
115
+ defaultMaterialGuid: sourceGuid,
116
+ },
117
+ ],
118
+ materialSlotDefaultOverrides: { 'fbx:material:Body': authoredGuid },
119
+ });
120
+ if (asset.kind !== 'mesh') throw new Error('expected mesh');
121
+ const mesh = asset.payload as MeshAsset;
122
+
123
+ expect(AssetGuid.format(mesh.materialSlots[0]?.defaultMaterial as never)).toBe(authoredGuid);
124
+ });
125
+ });