@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.
- package/LICENSE +202 -0
- package/README.md +196 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-runtime-fixture.d.ts +9 -0
- package/dist/__tests__/asset-runtime-fixture.d.ts.map +1 -0
- package/dist/__tests__/blendshape-import.integration.test.d.ts +2 -0
- package/dist/__tests__/blendshape-import.integration.test.d.ts.map +1 -0
- package/dist/__tests__/blendshape-import.unit.test.d.ts +2 -0
- package/dist/__tests__/blendshape-import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fbx-importer.test.d.ts +2 -0
- package/dist/__tests__/fbx-importer.test.d.ts.map +1 -0
- package/dist/__tests__/fbx-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/fbx-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-material-slots.unit.test.d.ts +2 -0
- package/dist/__tests__/mesh-material-slots.unit.test.d.ts.map +1 -0
- package/dist/__tests__/parse-mesh-multi-uv.test.d.ts +2 -0
- package/dist/__tests__/parse-mesh-multi-uv.test.d.ts.map +1 -0
- package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
- package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-texture-path.unit.test.d.ts +2 -0
- package/dist/__tests__/resolve-texture-path.unit.test.d.ts.map +1 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/fbx-importer.d.ts +18 -0
- package/dist/fbx-importer.d.ts.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +9546 -0
- package/dist/index.mjs.map +1 -0
- package/dist/parse-animation-clip.d.ts +35 -0
- package/dist/parse-animation-clip.d.ts.map +1 -0
- package/dist/parse-material.d.ts +30 -0
- package/dist/parse-material.d.ts.map +1 -0
- package/dist/parse-mesh.d.ts +21 -0
- package/dist/parse-mesh.d.ts.map +1 -0
- package/dist/parse-scene.d.ts +28 -0
- package/dist/parse-scene.d.ts.map +1 -0
- package/dist/parse-skeleton.d.ts +15 -0
- package/dist/parse-skeleton.d.ts.map +1 -0
- package/dist/parse-skin.d.ts +20 -0
- package/dist/parse-skin.d.ts.map +1 -0
- package/dist/parse-texture.d.ts +15 -0
- package/dist/parse-texture.d.ts.map +1 -0
- package/dist/resolve-texture-path.d.ts +25 -0
- package/dist/resolve-texture-path.d.ts.map +1 -0
- package/dist/to-asset-pack.d.ts +38 -0
- package/dist/to-asset-pack.d.ts.map +1 -0
- package/package.json +75 -0
- package/pkg/fbx-wasm.mjs +2 -0
- package/pkg/fbx-wasm.wasm +0 -0
- package/scripts/build-wasm.mjs +85 -0
- package/scripts/content-key.mjs +88 -0
- package/scripts/ensure-wasm.mjs +28 -0
- package/scripts/fetch-ufbx.mjs +42 -0
- package/scripts/fetch-wasm.mjs +91 -0
- package/scripts/parity-diff.mjs +127 -0
- package/scripts/test-wasm.mjs +91 -0
- package/src/__tests__/asset-runtime-fixture.ts +251 -0
- package/src/__tests__/blendshape-import.integration.test.ts +96 -0
- package/src/__tests__/blendshape-import.unit.test.ts +83 -0
- package/src/__tests__/fbx-importer.test.ts +200 -0
- package/src/__tests__/fbx-local-artifacts.test.ts +39 -0
- package/src/__tests__/index.test.ts +167 -0
- package/src/__tests__/mesh-material-slots.unit.test.ts +125 -0
- package/src/__tests__/parse-mesh-multi-uv.test.ts +187 -0
- package/src/__tests__/pick-e2e.integration.test.ts +247 -0
- package/src/__tests__/resolve-texture-path.unit.test.ts +50 -0
- package/src/errors.ts +112 -0
- package/src/fbx-importer.ts +372 -0
- package/src/index.ts +215 -0
- package/src/native/bridge.c +1060 -0
- package/src/parse-animation-clip.ts +271 -0
- package/src/parse-material.ts +140 -0
- package/src/parse-mesh.ts +223 -0
- package/src/parse-scene.ts +75 -0
- package/src/parse-skeleton.ts +46 -0
- package/src/parse-skin.ts +71 -0
- package/src/parse-texture.ts +36 -0
- package/src/resolve-texture-path.ts +152 -0
- package/src/to-asset-pack.ts +813 -0
|
@@ -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,200 @@
|
|
|
1
|
+
import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
|
|
2
|
+
import type { ImportErrorDetail } from '@forgeax/engine-types';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { deriveFbxSourceKeys, fbxImporter, sourceKeyForFbxOutput } from '../fbx-importer.js';
|
|
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
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('fbx importer contract migration fixture', () => {
|
|
169
|
+
it('requires the generic ImportProduct shape', () => {
|
|
170
|
+
expect('ImportProduct').toBe('ImportProduct');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('requires multi-asset output to keep local artifact ownership', () => {
|
|
174
|
+
expect('asset-local artifacts').toContain('artifacts');
|
|
175
|
+
expect('package-global artifacts').not.toContain('asset-local artifacts');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('FBX producer sourceKey', () => {
|
|
180
|
+
it('is stable across source relocation and output reorder', () => {
|
|
181
|
+
const first = deriveFbxSourceKeys([
|
|
182
|
+
{ kind: 'mesh', name: 'Body' },
|
|
183
|
+
{ kind: 'scene', name: 'Root' },
|
|
184
|
+
]);
|
|
185
|
+
const reordered = deriveFbxSourceKeys([
|
|
186
|
+
{ kind: 'scene', name: 'Root' },
|
|
187
|
+
{ kind: 'mesh', name: 'Body' },
|
|
188
|
+
]);
|
|
189
|
+
expect(first).toEqual({ ok: true, keys: ['fbx:mesh:Body', 'fbx:scene:Root'] });
|
|
190
|
+
expect(reordered).toEqual({ ok: true, keys: ['fbx:scene:Root', 'fbx:mesh:Body'] });
|
|
191
|
+
expect(sourceKeyForFbxOutput({ kind: 'mesh', name: 'Body' })).toBe('fbx:mesh:Body');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('rejects duplicate anonymous output kinds instead of using array position', () => {
|
|
195
|
+
expect(deriveFbxSourceKeys([{ kind: 'mesh' }, { kind: 'mesh' }])).toEqual({
|
|
196
|
+
ok: false,
|
|
197
|
+
code: 'ambiguous-source-key',
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -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,167 @@
|
|
|
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.toAssetPack).toBe('function');
|
|
56
|
+
expect(typeof mod.fbxErr).toBe('function');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── M6: buildMeshAsset aabb — GREEN (M7 make-green) ─────────────────
|
|
61
|
+
|
|
62
|
+
/** Extract MeshAsset payload from an ImportedAsset (guards kind='mesh'). */
|
|
63
|
+
function meshFromAsset(asset: ImportedAsset): MeshAsset {
|
|
64
|
+
if (asset.kind !== 'mesh') throw new TypeError(`expected mesh, got ${asset.kind}`);
|
|
65
|
+
return asset.payload as MeshAsset;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a mock FbxRawMesh with no indices (per-vertex attributes only). */
|
|
69
|
+
function mockRawMesh(vertices: number[], attributes?: Record<string, number[]>): FbxRawMesh {
|
|
70
|
+
return {
|
|
71
|
+
name: 'AabbTestMesh',
|
|
72
|
+
vertices,
|
|
73
|
+
attributes: attributes ?? {},
|
|
74
|
+
polygonCount: Math.max(0, Math.floor(vertices.length / 9)),
|
|
75
|
+
sourceIndex: 0,
|
|
76
|
+
materialIndex: -1,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('buildMeshAsset aabb (GREEN)', () => {
|
|
81
|
+
it('m6-1a: normal quad — aabb matches position min/max', () => {
|
|
82
|
+
const vertices = [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0];
|
|
83
|
+
const raw = mockRawMesh(vertices);
|
|
84
|
+
const pod = parseMesh(raw, 0);
|
|
85
|
+
const asset = buildMeshAsset(pod, 'guid-quad');
|
|
86
|
+
const mesh = meshFromAsset(asset);
|
|
87
|
+
|
|
88
|
+
// aabb should be Float32Array(6) = [minX, minY, minZ, maxX, maxY, maxZ]
|
|
89
|
+
// from vertices pod: minX=0, minY=0, minZ=0, maxX=1, maxY=1, maxZ=0
|
|
90
|
+
const aabb = mesh.aabb;
|
|
91
|
+
expect(aabb, 'aabb must be defined').toBeDefined();
|
|
92
|
+
expect(aabb).toBeInstanceOf(Float32Array);
|
|
93
|
+
expect(aabb?.length).toBe(6);
|
|
94
|
+
|
|
95
|
+
// Normal quad: vertices [(0,0,0),(1,0,0),(0,1,0),(1,1,0)]
|
|
96
|
+
// biome and tsc strict guards: use ?. and as number for Float32Array index access
|
|
97
|
+
expect(aabb?.[0] as number, 'minX').toBe(0);
|
|
98
|
+
expect(aabb?.[1] as number, 'minY').toBe(0);
|
|
99
|
+
expect(aabb?.[2] as number, 'minZ').toBe(0);
|
|
100
|
+
expect(aabb?.[3] as number, 'maxX').toBe(1);
|
|
101
|
+
expect(aabb?.[4] as number, 'maxY').toBe(1);
|
|
102
|
+
expect(aabb?.[5] as number, 'maxZ').toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('m6-1b: single point — min == max for all axes', () => {
|
|
106
|
+
const vertices = [5, 5, 5];
|
|
107
|
+
const raw = mockRawMesh(vertices);
|
|
108
|
+
const pod = parseMesh(raw, 0);
|
|
109
|
+
const asset = buildMeshAsset(pod, 'guid-point');
|
|
110
|
+
const mesh = meshFromAsset(asset);
|
|
111
|
+
|
|
112
|
+
const aabb = mesh.aabb;
|
|
113
|
+
expect(aabb, 'aabb must be defined').toBeDefined();
|
|
114
|
+
expect(aabb?.length).toBe(6);
|
|
115
|
+
|
|
116
|
+
expect(aabb?.[0] as number, 'minX').toBe(5);
|
|
117
|
+
expect(aabb?.[1] as number, 'minY').toBe(5);
|
|
118
|
+
expect(aabb?.[2] as number, 'minZ').toBe(5);
|
|
119
|
+
expect(aabb?.[3] as number, 'maxX').toBe(5);
|
|
120
|
+
expect(aabb?.[4] as number, 'maxY').toBe(5);
|
|
121
|
+
expect(aabb?.[5] as number, 'maxZ').toBe(5);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('m6-1c: empty vertices — inverted-empty box, no NaN', () => {
|
|
125
|
+
const vertices: number[] = [];
|
|
126
|
+
const raw = mockRawMesh(vertices);
|
|
127
|
+
const pod = parseMesh(raw, 0);
|
|
128
|
+
const asset = buildMeshAsset(pod, 'guid-empty');
|
|
129
|
+
const mesh = meshFromAsset(asset);
|
|
130
|
+
|
|
131
|
+
const aabb = mesh.aabb;
|
|
132
|
+
expect(aabb, 'aabb must be defined for empty mesh').toBeDefined();
|
|
133
|
+
expect(aabb?.length).toBe(6);
|
|
134
|
+
|
|
135
|
+
// Inverted-empty box convention: min.x > max.x (from box3.fromPositions
|
|
136
|
+
// when positions.length < 3). This signals "no volume" to pick.ts which
|
|
137
|
+
// skips the entity (AC-07).
|
|
138
|
+
expect(aabb?.[0] as number, 'minX').toBeGreaterThan(aabb?.[3] as number);
|
|
139
|
+
// No NaN anywhere
|
|
140
|
+
for (let i = 0; i < 6; i++) {
|
|
141
|
+
expect(Number.isNaN(aabb?.[i] ?? 0), `aabb[${i}] must not be NaN`).toBe(false);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('m6-1d: degenerate plane (Y=0) — one axis min==max, still pickable', () => {
|
|
146
|
+
const vertices = [0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1];
|
|
147
|
+
const raw = mockRawMesh(vertices);
|
|
148
|
+
const pod = parseMesh(raw, 0);
|
|
149
|
+
const asset = buildMeshAsset(pod, 'guid-plane');
|
|
150
|
+
const mesh = meshFromAsset(asset);
|
|
151
|
+
|
|
152
|
+
const aabb = mesh.aabb;
|
|
153
|
+
expect(aabb, 'aabb must be defined').toBeDefined();
|
|
154
|
+
expect(aabb?.length).toBe(6);
|
|
155
|
+
|
|
156
|
+
// Degenerate in Y (all Y=0) => minY == maxY == 0
|
|
157
|
+
expect(aabb?.[0] as number, 'minX').toBe(0);
|
|
158
|
+
expect(aabb?.[1] as number, 'minY').toBe(0);
|
|
159
|
+
expect(aabb?.[2] as number, 'minZ').toBe(0);
|
|
160
|
+
expect(aabb?.[3] as number, 'maxX').toBe(1);
|
|
161
|
+
expect(aabb?.[4] as number, 'maxY').toBe(0);
|
|
162
|
+
expect(aabb?.[5] as number, 'maxZ').toBe(1);
|
|
163
|
+
|
|
164
|
+
// Verify degenerate condition: Y axis has zero thickness
|
|
165
|
+
expect(aabb?.[1] as number).toBe(aabb?.[4] as number);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -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
|
+
});
|