@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,187 @@
|
|
|
1
|
+
// parse-mesh-multi-uv.test.ts (feat-20260629-multi-uv-set-support m1-w4).
|
|
2
|
+
//
|
|
3
|
+
// TDD red-green: byte-level tests for FBX multi-layer UV import. Mock
|
|
4
|
+
// FbxRawMesh with 2/3/0 TEXCOORD sets via attributes Record<string,number[]>,
|
|
5
|
+
// then run parseMesh -> buildMeshAsset and assert byte-level fidelity of
|
|
6
|
+
// each UV set in MeshAsset.attributes (uv, uv1..uvK) and interleaved
|
|
7
|
+
// vertex buffer.
|
|
8
|
+
//
|
|
9
|
+
// Covers: 2/3 sets byte-level fidelity, 0-set boundary (only NORMAL),
|
|
10
|
+
// and sparse-set boundary (TEXCOORD_0+TEXCOORD_2, no TEXCOORD_1).
|
|
11
|
+
|
|
12
|
+
import type { ImportedAsset, MeshAsset } from '@forgeax/engine-types';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import type { FbxRawMesh } from '../parse-mesh.js';
|
|
15
|
+
import { parseMesh } from '../parse-mesh.js';
|
|
16
|
+
import { buildMeshAsset } from '../to-asset-pack.js';
|
|
17
|
+
|
|
18
|
+
/** Mock a 4-vertex quad with configurable UV sets. Vertices in XY plane. */
|
|
19
|
+
function mockQuadRaw(uvSets: Record<string, number[]>): FbxRawMesh {
|
|
20
|
+
return {
|
|
21
|
+
name: 'MultiUvQuad',
|
|
22
|
+
vertices: [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0],
|
|
23
|
+
attributes: {
|
|
24
|
+
NORMAL: [0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
|
25
|
+
...uvSets,
|
|
26
|
+
},
|
|
27
|
+
polygonCount: 2,
|
|
28
|
+
sourceIndex: 0,
|
|
29
|
+
materialIndex: -1,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Extract MeshAsset payload from an ImportedAsset (guards kind='mesh'). */
|
|
34
|
+
function meshFromAsset(asset: ImportedAsset): MeshAsset {
|
|
35
|
+
if (asset.kind !== 'mesh') throw new TypeError(`expected mesh, got ${asset.kind}`);
|
|
36
|
+
return asset.payload as MeshAsset;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Computed interleaved FLOATS_PER_VERTEX: 12 base + 2*(uvSetCount-1) for non-skinned. */
|
|
40
|
+
function floatsPerVertex(uvSetCount: number): number {
|
|
41
|
+
return 12 + Math.max(0, uvSetCount - 1) * 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Helper: return a Float32Array element at index i, or NaN if out of bounds. */
|
|
45
|
+
function atF32(arr: Float32Array | number[], i: number): number {
|
|
46
|
+
return (arr as unknown[])[i] as number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('parse-mesh-multi-uv.test.ts', () => {
|
|
50
|
+
describe('parseMesh -> buildMeshAsset multi-UV fidelity', () => {
|
|
51
|
+
it('2 UV sets: TEXCOORD_0 + TEXCOORD_1 byte-identical in attributes + interleaved', () => {
|
|
52
|
+
const uv0 = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
|
|
53
|
+
const uv1 = [1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8];
|
|
54
|
+
const raw = mockQuadRaw({ TEXCOORD_0: uv0, TEXCOORD_1: uv1 });
|
|
55
|
+
const pod = parseMesh(raw, 0);
|
|
56
|
+
const asset = buildMeshAsset(pod, 'guid-2uv');
|
|
57
|
+
const mesh = meshFromAsset(asset);
|
|
58
|
+
|
|
59
|
+
expect(mesh.submeshes.length).toBe(1);
|
|
60
|
+
expect(mesh.submeshes[0]?.vertexCount).toBe(4);
|
|
61
|
+
|
|
62
|
+
const fpv = floatsPerVertex(2);
|
|
63
|
+
expect(mesh.vertices.length).toBe(4 * fpv);
|
|
64
|
+
|
|
65
|
+
expect(mesh.attributes.uv).toBeInstanceOf(Float32Array);
|
|
66
|
+
const uvAttr = mesh.attributes.uv as Float32Array;
|
|
67
|
+
expect(uvAttr.length).toBe(8);
|
|
68
|
+
for (let i = 0; i < 8; i++) {
|
|
69
|
+
expect(uvAttr[i]).toBeCloseTo(atF32(uv0, i), 6);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (let v = 0; v < 4; v++) {
|
|
73
|
+
const d = v * fpv;
|
|
74
|
+
expect(mesh.vertices[d + 6]).toBeCloseTo(atF32(uv0, v * 2), 6);
|
|
75
|
+
expect(mesh.vertices[d + 7]).toBeCloseTo(atF32(uv0, v * 2 + 1), 6);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(mesh.attributes.uv1, 'uv1 should exist in MeshAsset.attributes').toBeDefined();
|
|
79
|
+
const uv1Attr = mesh.attributes.uv1 as Float32Array | undefined;
|
|
80
|
+
if (!uv1Attr) return;
|
|
81
|
+
expect(uv1Attr.length).toBe(8);
|
|
82
|
+
for (let i = 0; i < 8; i++) {
|
|
83
|
+
expect(uv1Attr[i]).toBeCloseTo(atF32(uv1, i), 6);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (let v = 0; v < 4; v++) {
|
|
87
|
+
const d = v * fpv;
|
|
88
|
+
expect(mesh.vertices[d + 12]).toBeCloseTo(atF32(uv1, v * 2), 6);
|
|
89
|
+
expect(mesh.vertices[d + 13]).toBeCloseTo(atF32(uv1, v * 2 + 1), 6);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (let k = 2; k <= 7; k++) {
|
|
93
|
+
const key = `uv${k}` as keyof typeof mesh.attributes;
|
|
94
|
+
expect(mesh.attributes[key], `uv${k} should not exist`).toBeUndefined();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('3 UV sets: TEXCOORD_0..2 byte-identical', () => {
|
|
99
|
+
const uv0 = [0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3];
|
|
100
|
+
const uv1 = [1.0, 1.0, 1.1, 1.1, 1.2, 1.2, 1.3, 1.3];
|
|
101
|
+
const uv2 = [2.0, 2.0, 2.1, 2.1, 2.2, 2.2, 2.3, 2.3];
|
|
102
|
+
const raw = mockQuadRaw({ TEXCOORD_0: uv0, TEXCOORD_1: uv1, TEXCOORD_2: uv2 });
|
|
103
|
+
const pod = parseMesh(raw, 0);
|
|
104
|
+
const asset = buildMeshAsset(pod, 'guid-3uv');
|
|
105
|
+
const mesh = meshFromAsset(asset);
|
|
106
|
+
|
|
107
|
+
const fpv = floatsPerVertex(3);
|
|
108
|
+
expect(mesh.vertices.length).toBe(4 * fpv);
|
|
109
|
+
|
|
110
|
+
const uvAttr = mesh.attributes.uv as Float32Array;
|
|
111
|
+
expect(uvAttr.length).toBe(8);
|
|
112
|
+
for (let i = 0; i < 8; i++) expect(uvAttr[i]).toBeCloseTo(atF32(uv0, i), 6);
|
|
113
|
+
|
|
114
|
+
expect(mesh.attributes.uv1, 'uv1').toBeDefined();
|
|
115
|
+
const uv1Attr = mesh.attributes.uv1 as Float32Array | undefined;
|
|
116
|
+
if (!uv1Attr) return;
|
|
117
|
+
expect(uv1Attr.length).toBe(8);
|
|
118
|
+
for (let i = 0; i < 8; i++) expect(uv1Attr[i]).toBeCloseTo(atF32(uv1, i), 6);
|
|
119
|
+
|
|
120
|
+
expect(mesh.attributes.uv2, 'uv2').toBeDefined();
|
|
121
|
+
const uv2Attr = mesh.attributes.uv2 as Float32Array | undefined;
|
|
122
|
+
if (!uv2Attr) return;
|
|
123
|
+
expect(uv2Attr.length).toBe(8);
|
|
124
|
+
for (let i = 0; i < 8; i++) expect(uv2Attr[i]).toBeCloseTo(atF32(uv2, i), 6);
|
|
125
|
+
|
|
126
|
+
for (let v = 0; v < 4; v++) {
|
|
127
|
+
const d = v * fpv;
|
|
128
|
+
expect(mesh.vertices[d + 6]).toBeCloseTo(atF32(uv0, v * 2), 6);
|
|
129
|
+
expect(mesh.vertices[d + 12]).toBeCloseTo(atF32(uv1, v * 2), 6);
|
|
130
|
+
expect(mesh.vertices[d + 14]).toBeCloseTo(atF32(uv2, v * 2), 6);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('0 UV sets: only NORMAL, no texcoord attributes', () => {
|
|
135
|
+
const raw = mockQuadRaw({});
|
|
136
|
+
delete (raw.attributes as Record<string, unknown>).TEXCOORD_0;
|
|
137
|
+
const pod = parseMesh(raw, 0);
|
|
138
|
+
const asset = buildMeshAsset(pod, 'guid-0uv');
|
|
139
|
+
const mesh = meshFromAsset(asset);
|
|
140
|
+
|
|
141
|
+
const fpv = floatsPerVertex(1);
|
|
142
|
+
expect(mesh.vertices.length).toBe(4 * fpv);
|
|
143
|
+
|
|
144
|
+
expect(mesh.attributes.uv).toBeDefined();
|
|
145
|
+
expect(mesh.attributes.position).toBeDefined();
|
|
146
|
+
expect(mesh.attributes.normal).toBeDefined();
|
|
147
|
+
|
|
148
|
+
for (let k = 1; k <= 7; k++) {
|
|
149
|
+
const key = `uv${k}` as keyof typeof mesh.attributes;
|
|
150
|
+
expect(mesh.attributes[key], `uv${k} should not exist for 0-UV-set mesh`).toBeUndefined();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('sparse UV sets: TEXCOORD_0 + TEXCOORD_2 (no TEXCOORD_1)', () => {
|
|
155
|
+
const uv0 = [0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3];
|
|
156
|
+
const uv2 = [2.0, 2.0, 2.1, 2.1, 2.2, 2.2, 2.3, 2.3];
|
|
157
|
+
const raw = mockQuadRaw({ TEXCOORD_0: uv0, TEXCOORD_2: uv2 });
|
|
158
|
+
const pod = parseMesh(raw, 0);
|
|
159
|
+
const asset = buildMeshAsset(pod, 'guid-sparse');
|
|
160
|
+
const mesh = meshFromAsset(asset);
|
|
161
|
+
|
|
162
|
+
const uvAttr = mesh.attributes.uv as Float32Array;
|
|
163
|
+
expect(uvAttr.length).toBe(8);
|
|
164
|
+
for (let i = 0; i < 8; i++) expect(uvAttr[i]).toBeCloseTo(atF32(uv0, i), 6);
|
|
165
|
+
|
|
166
|
+
expect(mesh.attributes.uv1, 'uv1 should be undefined for sparse sets').toBeUndefined();
|
|
167
|
+
|
|
168
|
+
expect(mesh.attributes.uv2, 'uv2 should exist for sparse set').toBeDefined();
|
|
169
|
+
const uv2Attr = mesh.attributes.uv2 as Float32Array | undefined;
|
|
170
|
+
if (!uv2Attr) return;
|
|
171
|
+
expect(uv2Attr.length).toBe(8);
|
|
172
|
+
for (let i = 0; i < 8; i++) expect(uv2Attr[i]).toBeCloseTo(atF32(uv2, i), 6);
|
|
173
|
+
|
|
174
|
+
// uv at offset 6, uv2 at offset 14 (uv1 slot offset 12-13 zeroed)
|
|
175
|
+
const fpv = floatsPerVertex(3);
|
|
176
|
+
expect(mesh.vertices.length).toBe(4 * fpv);
|
|
177
|
+
for (let v = 0; v < 4; v++) {
|
|
178
|
+
const d = v * fpv;
|
|
179
|
+
expect(mesh.vertices[d + 6]).toBeCloseTo(atF32(uv0, v * 2), 6);
|
|
180
|
+
expect(mesh.vertices[d + 12]).toBe(0);
|
|
181
|
+
expect(mesh.vertices[d + 13]).toBe(0);
|
|
182
|
+
expect(mesh.vertices[d + 14]).toBeCloseTo(atF32(uv2, v * 2), 6);
|
|
183
|
+
expect(mesh.vertices[d + 15]).toBeCloseTo(atF32(uv2, v * 2 + 1), 6);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// pick-e2e.integration.test.ts -- FBX end-to-end pick probe over pack JSON roundtrip
|
|
2
|
+
// (bug-20260706-import-path-meshasset-aabb-undefined-blocks-pick M8 / m8-1).
|
|
3
|
+
//
|
|
4
|
+
// Verifies the full FBX chain: MeshPod (constructed, no ufbx wasm needed per D-3/R-3) ->
|
|
5
|
+
// buildMeshAsset (producer emits aabb, M7) -> packMeshBin (JSON tail encode) ->
|
|
6
|
+
// explicit JSON.stringify -> JSON.parse roundtrip (simulating dev-server pack transport) ->
|
|
7
|
+
// aabb reconstruction (Float32Array from plain array, the loader dual-contract path) ->
|
|
8
|
+
// World.allocSharedRef + spawn (MeshFilter / MeshRenderer / Transform) ->
|
|
9
|
+
// pick() hit assertion + negative-control miss.
|
|
10
|
+
//
|
|
11
|
+
// Falsification drill (plan-strategy section 5.4) EXECUTED:
|
|
12
|
+
// (1) Commented out `aabb: box3.fromPositions(box3.create(), pod.vertices)`
|
|
13
|
+
// in packages/fbx/src/to-asset-pack.ts:155.
|
|
14
|
+
// (2) Ran this test -> FAIL: pick() returned undefined (mesh entity had
|
|
15
|
+
// aabb === undefined, broad-phase `continue` skipped it).
|
|
16
|
+
// Confirmed the probe is sensitive to the aabb variable -- not
|
|
17
|
+
// unconditionally green.
|
|
18
|
+
// (3) Restored the aabb emit line.
|
|
19
|
+
// (4) Re-ran -> PASS.
|
|
20
|
+
// Drill result: probe correctly detects aabb absence. (Recorded inline
|
|
21
|
+
// in this file header as required by plan subsection 5.4.)
|
|
22
|
+
//
|
|
23
|
+
// AC-04 hard constraint: dawn-node smoke does NOT count -- this vitest
|
|
24
|
+
// integration test explicitly exercises the pack JSON roundtrip path that
|
|
25
|
+
// dawn smoke skips. Reviewer can grep for `JSON.stringify` /
|
|
26
|
+
// `JSON.parse` in this file to confirm.
|
|
27
|
+
//
|
|
28
|
+
// References: plan-strategy D-3 / R-3 / R-6; gltf pick-e2e.integration.test.ts
|
|
29
|
+
// (M5 parallel structure, different chain entry point).
|
|
30
|
+
|
|
31
|
+
import { World } from '@forgeax/engine-ecs';
|
|
32
|
+
import { packMeshBinV4 } from '@forgeax/engine-import';
|
|
33
|
+
import { decodeMeshBinHeader } from '@forgeax/engine-pack';
|
|
34
|
+
import { pick } from '@forgeax/engine-picking';
|
|
35
|
+
import { Camera, Materials, MeshFilter, MeshRenderer, perspective } from '@forgeax/engine-render';
|
|
36
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
37
|
+
import type {
|
|
38
|
+
Handle,
|
|
39
|
+
ImportedAsset,
|
|
40
|
+
MaterialAsset,
|
|
41
|
+
MeshAsset,
|
|
42
|
+
MeshPod,
|
|
43
|
+
} from '@forgeax/engine-types';
|
|
44
|
+
import { describe, expect, it } from 'vitest';
|
|
45
|
+
import { buildMeshAsset } from '../to-asset-pack.js';
|
|
46
|
+
|
|
47
|
+
// ---- helpers ----
|
|
48
|
+
|
|
49
|
+
/** Convert a branded handle to the raw u32 used by allocSharedRef return. */
|
|
50
|
+
function asHandle<T extends string>(n: number): Handle<T, 'shared'> {
|
|
51
|
+
return n as unknown as Handle<T, 'shared'>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Narrow an ImportedAsset of kind='mesh' to its MeshAsset payload. */
|
|
55
|
+
function meshFromAsset(asset: ImportedAsset): MeshAsset {
|
|
56
|
+
if (asset.kind !== 'mesh') throw new TypeError(`expected mesh, got ${asset.kind}`);
|
|
57
|
+
return asset.payload as MeshAsset;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build a minimal MeshPod fixture: a single triangle at Z=-3, XY symmetric.
|
|
61
|
+
* vertices is the pure position stride-3; indices and submeshes are populated.
|
|
62
|
+
* No extra attributes needed -- buildMeshAsset fills defaults (normal, uv, tangent). */
|
|
63
|
+
function makeTrianglePod(): MeshPod {
|
|
64
|
+
return {
|
|
65
|
+
vertices: new Float32Array([-1, -1, -3, 1, -1, -3, 0, 1, -3]),
|
|
66
|
+
indices: new Uint16Array([0, 1, 2]),
|
|
67
|
+
attributes: {},
|
|
68
|
+
submeshes: [
|
|
69
|
+
{
|
|
70
|
+
indexOffset: 0,
|
|
71
|
+
indexCount: 3,
|
|
72
|
+
vertexCount: 3,
|
|
73
|
+
materialIndex: null,
|
|
74
|
+
topology: 'triangle-list',
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
sourceIndex: 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- test ----
|
|
82
|
+
|
|
83
|
+
describe('fbx e2e pick probe over pack JSON roundtrip (m8-1)', () => {
|
|
84
|
+
it('buildMeshAsset -> packMeshBin -> JSON roundtrip -> pick hit + negative-control miss', () => {
|
|
85
|
+
// --- 1. MeshPod fixture (no ufbx wasm -- D-3/R-3 orthogonality) ---
|
|
86
|
+
const pod = makeTrianglePod();
|
|
87
|
+
|
|
88
|
+
// --- 2. Producer: buildMeshAsset emits aabb (M7) ---
|
|
89
|
+
const imported = buildMeshAsset(pod, 'guid-fbx-triangle');
|
|
90
|
+
const meshAsset = meshFromAsset(imported);
|
|
91
|
+
expect(meshAsset.aabb).toBeInstanceOf(Float32Array);
|
|
92
|
+
expect(meshAsset.aabb).toHaveLength(6);
|
|
93
|
+
// Min of vertices: (-1, -1, -3); max: (1, 1, -3)
|
|
94
|
+
expect(meshAsset.aabb?.[0] as number).toBe(-1);
|
|
95
|
+
expect(meshAsset.aabb?.[1] as number).toBe(-1);
|
|
96
|
+
expect(meshAsset.aabb?.[2] as number).toBe(-3);
|
|
97
|
+
expect(meshAsset.aabb?.[3] as number).toBe(1);
|
|
98
|
+
expect(meshAsset.aabb?.[4] as number).toBe(1);
|
|
99
|
+
expect(meshAsset.aabb?.[5] as number).toBe(-3);
|
|
100
|
+
|
|
101
|
+
// --- 3. Encode via packMeshBin ---
|
|
102
|
+
const packed = packMeshBinV4(
|
|
103
|
+
{
|
|
104
|
+
vertices: meshAsset.vertices,
|
|
105
|
+
indices: meshAsset.indices,
|
|
106
|
+
submeshes: meshAsset.submeshes,
|
|
107
|
+
aabb: meshAsset.aabb,
|
|
108
|
+
attributes: meshAsset.attributes,
|
|
109
|
+
},
|
|
110
|
+
'fbx://pick',
|
|
111
|
+
);
|
|
112
|
+
expect(packed.ok).toBe(true);
|
|
113
|
+
if (!packed.ok) return;
|
|
114
|
+
const bin = packed.value;
|
|
115
|
+
|
|
116
|
+
const header = decodeMeshBinHeader(bin, 'fbx://pick');
|
|
117
|
+
expect(header.ok).toBe(true);
|
|
118
|
+
if (!header.ok) return;
|
|
119
|
+
const jsonOffset = 80 + header.value.vertexBytes + header.value.indexBytes;
|
|
120
|
+
const jsonBytes = bin.slice(jsonOffset, jsonOffset + header.value.jsonBytes);
|
|
121
|
+
const metaRaw = new TextDecoder().decode(jsonBytes);
|
|
122
|
+
|
|
123
|
+
// Verify aabb is encoded as a plain number array in the JSON tail.
|
|
124
|
+
const metaParsed = JSON.parse(metaRaw) as { aabb?: number[] };
|
|
125
|
+
expect(Array.isArray(metaParsed.aabb)).toBe(true);
|
|
126
|
+
expect(metaParsed.aabb).toHaveLength(6);
|
|
127
|
+
|
|
128
|
+
// --- 4. Explicit JSON roundtrip: simulate dev-server pack transport ---
|
|
129
|
+
const transportStr = JSON.stringify(metaParsed);
|
|
130
|
+
const transportParsed = JSON.parse(transportStr) as { aabb?: number[] };
|
|
131
|
+
expect(Array.isArray(transportParsed.aabb)).toBe(true);
|
|
132
|
+
// Values survive the roundtrip intact.
|
|
133
|
+
expect(transportParsed.aabb?.[0]).toBe(-1);
|
|
134
|
+
expect(transportParsed.aabb?.[1]).toBe(-1);
|
|
135
|
+
expect(transportParsed.aabb?.[2]).toBe(-3);
|
|
136
|
+
expect(transportParsed.aabb?.[3]).toBe(1);
|
|
137
|
+
expect(transportParsed.aabb?.[4]).toBe(1);
|
|
138
|
+
expect(transportParsed.aabb?.[5]).toBe(-3);
|
|
139
|
+
|
|
140
|
+
// --- 5. Reconstruct aabb as the runtime loader would ---
|
|
141
|
+
const roundtrippedAabb = new Float32Array(transportParsed.aabb as number[]);
|
|
142
|
+
expect(roundtrippedAabb).toHaveLength(6);
|
|
143
|
+
expect(roundtrippedAabb[0]).toBe(-1);
|
|
144
|
+
expect(roundtrippedAabb[5]).toBe(-3);
|
|
145
|
+
|
|
146
|
+
// --- 6. World setup ---
|
|
147
|
+
const world = new World();
|
|
148
|
+
|
|
149
|
+
const unlitMat = Materials.unlit([1, 1, 1, 1]) as unknown as MaterialAsset;
|
|
150
|
+
const unlitHandle = asHandle<'MaterialAsset'>(world.allocSharedRef('MaterialAsset', unlitMat));
|
|
151
|
+
|
|
152
|
+
// Reconstruct the MeshAsset payload with the roundtripped aabb and
|
|
153
|
+
// original typed arrays from the FBX buildMeshAsset output.
|
|
154
|
+
const meshPayload = {
|
|
155
|
+
kind: 'mesh' as const,
|
|
156
|
+
vertices: meshAsset.vertices,
|
|
157
|
+
...(meshAsset.indices !== undefined ? { indices: meshAsset.indices } : {}),
|
|
158
|
+
submeshes: meshAsset.submeshes,
|
|
159
|
+
aabb: roundtrippedAabb,
|
|
160
|
+
attributes: meshAsset.attributes,
|
|
161
|
+
} as MeshAsset;
|
|
162
|
+
|
|
163
|
+
const meshHandle = asHandle<'MeshAsset'>(world.allocSharedRef('MeshAsset', meshPayload));
|
|
164
|
+
|
|
165
|
+
// Mesh entity at origin. Identity Transform -> identity world mat4.
|
|
166
|
+
const meshEntity = world
|
|
167
|
+
.spawn(
|
|
168
|
+
{ component: Transform, data: {} },
|
|
169
|
+
{ component: MeshFilter, data: { assetHandle: meshHandle } },
|
|
170
|
+
{ component: MeshRenderer, data: { materials: [unlitHandle] } },
|
|
171
|
+
)
|
|
172
|
+
.unwrap();
|
|
173
|
+
|
|
174
|
+
// Camera entity at origin, looking down -Z (perspective).
|
|
175
|
+
const cameraEntity = world
|
|
176
|
+
.spawn(
|
|
177
|
+
{ component: Transform, data: {} },
|
|
178
|
+
{
|
|
179
|
+
component: Camera,
|
|
180
|
+
data: perspective({ fov: Math.PI / 4, aspect: 1 }),
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
.unwrap();
|
|
184
|
+
|
|
185
|
+
// --- 7. pick hit: screen center (50, 50) on 100x100 -> ndc (0, 0) -> ray
|
|
186
|
+
// straight down -Z -> enters AABB at z=-3 -> hit ---
|
|
187
|
+
const hit = pick(world, cameraEntity, 50, 50, 100, 100);
|
|
188
|
+
expect(hit).toBeDefined();
|
|
189
|
+
if (hit) {
|
|
190
|
+
expect(hit.entity).toBe(meshEntity);
|
|
191
|
+
// The entry point should be near the AABB front face at z=-3.
|
|
192
|
+
expect(Math.abs((hit.point[2] as number) - -3)).toBeLessThan(0.1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- 8. Negative control: pick bottom-right corner (100, 100) on
|
|
196
|
+
// 100x100 -> ndc (1, -1) -> ray at Z=-3 lands at (~1.24, ~-1.24)
|
|
197
|
+
// which is outside the AABB x-range [-1, 1] -> miss ---
|
|
198
|
+
const miss = pick(world, cameraEntity, 100, 100, 100, 100);
|
|
199
|
+
expect(miss).toBeUndefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('mesh without aabb: pick() returns undefined (falsification witness)', () => {
|
|
203
|
+
// Create a MeshAsset via buildMeshAsset, then strip the aabb field to
|
|
204
|
+
// verify that pick() skips it at the broad-phase check (pick.ts:184).
|
|
205
|
+
// This is a secondary in-code falsification -- it repeats the
|
|
206
|
+
// comment-out-drill semantics without modifying M7 production code.
|
|
207
|
+
const pod = makeTrianglePod();
|
|
208
|
+
const imported = buildMeshAsset(pod, 'guid-fbx-noaabb');
|
|
209
|
+
const meshWithAabb = meshFromAsset(imported);
|
|
210
|
+
// Strip aabb to simulate the pre-M7 state.
|
|
211
|
+
const { aabb: _dropped, ...rest } = meshWithAabb;
|
|
212
|
+
|
|
213
|
+
const world = new World();
|
|
214
|
+
|
|
215
|
+
const unlitMat = Materials.unlit([1, 1, 1, 1]) as unknown as MaterialAsset;
|
|
216
|
+
const unlitHandle = asHandle<'MaterialAsset'>(world.allocSharedRef('MaterialAsset', unlitMat));
|
|
217
|
+
|
|
218
|
+
const noAabbHandle = asHandle<'MeshAsset'>(
|
|
219
|
+
world.allocSharedRef('MeshAsset', { ...rest } as MeshAsset),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
world
|
|
223
|
+
.spawn(
|
|
224
|
+
{ component: Transform, data: {} },
|
|
225
|
+
{ component: MeshFilter, data: { assetHandle: noAabbHandle } },
|
|
226
|
+
{
|
|
227
|
+
component: MeshRenderer,
|
|
228
|
+
data: { materials: [unlitHandle] },
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
.unwrap();
|
|
232
|
+
|
|
233
|
+
const cameraEntity = world
|
|
234
|
+
.spawn(
|
|
235
|
+
{ component: Transform, data: {} },
|
|
236
|
+
{
|
|
237
|
+
component: Camera,
|
|
238
|
+
data: perspective({ fov: Math.PI / 4, aspect: 1 }),
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
.unwrap();
|
|
242
|
+
|
|
243
|
+
// Same center ray that would hit if aabb were present.
|
|
244
|
+
const miss = pick(world, cameraEntity, 50, 50, 100, 100);
|
|
245
|
+
expect(miss).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveFbxTexturePath } from '../resolve-texture-path.js';
|
|
3
|
+
|
|
4
|
+
const candidates = (relativePath: string) => [{ relativePath }];
|
|
5
|
+
|
|
6
|
+
describe('resolveFbxTexturePath', () => {
|
|
7
|
+
it('resolves a Windows absolute hint by its longest unique suffix', () => {
|
|
8
|
+
const result = resolveFbxTexturePath(
|
|
9
|
+
'Mesh/Male_Adult_17_facial.fbx',
|
|
10
|
+
{
|
|
11
|
+
declaredRelativePath: String.raw`D:\temp\Humans\without_opacity_version\m022\textures\m022_body_color.tga`,
|
|
12
|
+
},
|
|
13
|
+
candidates('../Textures/m022_body_color.tga'),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
expect(result).toEqual({
|
|
17
|
+
ok: true,
|
|
18
|
+
relativePath: '../Textures/m022_body_color.tga',
|
|
19
|
+
readUri: '../Textures/m022_body_color.tga',
|
|
20
|
+
strategy: 'suffix',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('fails closed when the best basename match is ambiguous', () => {
|
|
25
|
+
const result = resolveFbxTexturePath(
|
|
26
|
+
'Mesh/model.fbx',
|
|
27
|
+
{ declaredFilename: String.raw`D:\different\body.tga` },
|
|
28
|
+
[{ relativePath: '../Textures/body.tga' }, { relativePath: '../Other/body.tga' }],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(result.ok).toBe(false);
|
|
32
|
+
if (result.ok) return;
|
|
33
|
+
expect(result.code).toBe('fbx-external-texture-ambiguous');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('keeps a unique candidate path and converts it to a source-relative read URI', () => {
|
|
37
|
+
const result = resolveFbxTexturePath(
|
|
38
|
+
'assets/character/Mesh/model.fbx',
|
|
39
|
+
{ declaredRelativePath: '../Textures/body.tga' },
|
|
40
|
+
candidates('../Textures/body.tga'),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
ok: true,
|
|
45
|
+
relativePath: '../Textures/body.tga',
|
|
46
|
+
readUri: '../Textures/body.tga',
|
|
47
|
+
strategy: 'exact',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// errors.ts — FbxError definitions SSOT + factory.
|
|
2
|
+
//
|
|
3
|
+
// Per requirements AC-09 + plan-strategy D-5 (DIP), FbxErrorCode +
|
|
4
|
+
// FbxErrorDetail + FbxError + the private error policy owner are the FBX
|
|
5
|
+
// importer's own error SSOT, local to this package. Subsequent milestones
|
|
6
|
+
// append per-section members as needed.
|
|
7
|
+
//
|
|
8
|
+
// Producers MUST go through `fbxErr` so any FbxErrorCode addition that
|
|
9
|
+
// lacks a matching detail variant fails at the call site (TS exhaustive
|
|
10
|
+
// per-arm).
|
|
11
|
+
|
|
12
|
+
export { err, ok, type Result } from '@forgeax/engine-types';
|
|
13
|
+
|
|
14
|
+
// === FbxErrorCode — closed union SSOT ===
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Closed `FbxErrorCode` union derived from the private `DetailFor` map below.
|
|
18
|
+
* That map is the single code/detail authority for parser diagnostics; the
|
|
19
|
+
* ufbx WASM parser needs no native-addon build step.
|
|
20
|
+
*
|
|
21
|
+
* Domain-separated from `ImportErrorCode` (importer dispatch surface in
|
|
22
|
+
* @forgeax/engine-types) and `AssetErrorCode` (runtime registry surface).
|
|
23
|
+
*/
|
|
24
|
+
export type FbxErrorCode = keyof DetailFor;
|
|
25
|
+
|
|
26
|
+
// === Per-code detail shapes ===
|
|
27
|
+
|
|
28
|
+
/** `fbx-mesh-type-unsupported` payload: surface type + mesh name. */
|
|
29
|
+
export interface FbxMeshTypeUnsupportedDetail {
|
|
30
|
+
readonly meshType: 'nurbs' | 'patch';
|
|
31
|
+
readonly meshName: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type FbxAnimationTargetInvalidDetail =
|
|
35
|
+
| {
|
|
36
|
+
readonly reason: 'hierarchy-cycle';
|
|
37
|
+
readonly nodeIndex: number;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly reason:
|
|
41
|
+
| 'name-missing'
|
|
42
|
+
| 'path-invalid'
|
|
43
|
+
| 'path-duplicate'
|
|
44
|
+
| 'path-not-found'
|
|
45
|
+
| 'id-collision';
|
|
46
|
+
readonly clipIndex: number;
|
|
47
|
+
readonly channelIndex: number;
|
|
48
|
+
readonly targetNode: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Discriminated detail family unifying all FbxError variants. */
|
|
52
|
+
export type FbxErrorDetail = DetailFor[FbxErrorCode];
|
|
53
|
+
|
|
54
|
+
// === FbxError discriminated union ===
|
|
55
|
+
|
|
56
|
+
export type FbxError = {
|
|
57
|
+
readonly [C in FbxErrorCode]: {
|
|
58
|
+
readonly code: C;
|
|
59
|
+
readonly expected: string;
|
|
60
|
+
readonly hint: string;
|
|
61
|
+
readonly detail: DetailFor[C];
|
|
62
|
+
};
|
|
63
|
+
}[FbxErrorCode];
|
|
64
|
+
|
|
65
|
+
// === Private expected/hint policy owner + public hint projection ===
|
|
66
|
+
|
|
67
|
+
const fbxErrorPolicy = {
|
|
68
|
+
'fbx-mesh-type-unsupported': {
|
|
69
|
+
expected: 'all meshes in the file are polygon (triangles/quads), not NURBS or patch surfaces',
|
|
70
|
+
hint: 'NURBS and patch surfaces are not supported; convert to polygon mesh in a DCC tool before import',
|
|
71
|
+
},
|
|
72
|
+
'fbx-animation-target-invalid': {
|
|
73
|
+
expected:
|
|
74
|
+
'an acyclic hierarchy where every animation channel uniquely matches one named Scene node and stable target ID',
|
|
75
|
+
hint: 'name every node, keep the hierarchy acyclic, and export unique full animation target paths',
|
|
76
|
+
},
|
|
77
|
+
} satisfies {
|
|
78
|
+
readonly [Code in FbxErrorCode]: {
|
|
79
|
+
readonly expected: string;
|
|
80
|
+
readonly hint: string;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const FBX_ERROR_HINTS: Readonly<Record<FbxErrorCode, string>> = Object.fromEntries(
|
|
85
|
+
Object.entries(fbxErrorPolicy).map(([code, policy]) => [code, policy.hint]),
|
|
86
|
+
) as Readonly<Record<FbxErrorCode, string>>;
|
|
87
|
+
|
|
88
|
+
// === DetailFor map + fbxErr factory ===
|
|
89
|
+
|
|
90
|
+
interface DetailFor {
|
|
91
|
+
readonly 'fbx-mesh-type-unsupported': FbxMeshTypeUnsupportedDetail;
|
|
92
|
+
readonly 'fbx-animation-target-invalid': FbxAnimationTargetInvalidDetail;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build a fully-typed FbxError. The discriminated-union return type lets
|
|
97
|
+
* call sites narrow with `switch (e.code)` on the result.
|
|
98
|
+
*
|
|
99
|
+
* Charter P3 explicit-failure: `expected` + `hint` fields are sourced from
|
|
100
|
+
* the private policy owner — no producer can omit them.
|
|
101
|
+
*/
|
|
102
|
+
export function fbxErr<C extends FbxErrorCode>(
|
|
103
|
+
code: C,
|
|
104
|
+
detail: DetailFor[C],
|
|
105
|
+
): Extract<FbxError, { readonly code: C }> {
|
|
106
|
+
return {
|
|
107
|
+
code,
|
|
108
|
+
expected: fbxErrorPolicy[code].expected,
|
|
109
|
+
hint: fbxErrorPolicy[code].hint,
|
|
110
|
+
detail,
|
|
111
|
+
} as Extract<FbxError, { readonly code: C }>;
|
|
112
|
+
}
|