@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.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +196 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-runtime-fixture.d.ts +9 -0
  5. package/dist/__tests__/asset-runtime-fixture.d.ts.map +1 -0
  6. package/dist/__tests__/blendshape-import.integration.test.d.ts +2 -0
  7. package/dist/__tests__/blendshape-import.integration.test.d.ts.map +1 -0
  8. package/dist/__tests__/blendshape-import.unit.test.d.ts +2 -0
  9. package/dist/__tests__/blendshape-import.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/fbx-importer.test.d.ts +2 -0
  11. package/dist/__tests__/fbx-importer.test.d.ts.map +1 -0
  12. package/dist/__tests__/fbx-local-artifacts.test.d.ts +2 -0
  13. package/dist/__tests__/fbx-local-artifacts.test.d.ts.map +1 -0
  14. package/dist/__tests__/index.test.d.ts +2 -0
  15. package/dist/__tests__/index.test.d.ts.map +1 -0
  16. package/dist/__tests__/mesh-material-slots.unit.test.d.ts +2 -0
  17. package/dist/__tests__/mesh-material-slots.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/parse-mesh-multi-uv.test.d.ts +2 -0
  19. package/dist/__tests__/parse-mesh-multi-uv.test.d.ts.map +1 -0
  20. package/dist/__tests__/pick-e2e.integration.test.d.ts +2 -0
  21. package/dist/__tests__/pick-e2e.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/resolve-texture-path.unit.test.d.ts +2 -0
  23. package/dist/__tests__/resolve-texture-path.unit.test.d.ts.map +1 -0
  24. package/dist/errors.d.ts +50 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/fbx-importer.d.ts +18 -0
  27. package/dist/fbx-importer.d.ts.map +1 -0
  28. package/dist/index.d.ts +62 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.mjs +9546 -0
  31. package/dist/index.mjs.map +1 -0
  32. package/dist/parse-animation-clip.d.ts +35 -0
  33. package/dist/parse-animation-clip.d.ts.map +1 -0
  34. package/dist/parse-material.d.ts +30 -0
  35. package/dist/parse-material.d.ts.map +1 -0
  36. package/dist/parse-mesh.d.ts +21 -0
  37. package/dist/parse-mesh.d.ts.map +1 -0
  38. package/dist/parse-scene.d.ts +28 -0
  39. package/dist/parse-scene.d.ts.map +1 -0
  40. package/dist/parse-skeleton.d.ts +15 -0
  41. package/dist/parse-skeleton.d.ts.map +1 -0
  42. package/dist/parse-skin.d.ts +20 -0
  43. package/dist/parse-skin.d.ts.map +1 -0
  44. package/dist/parse-texture.d.ts +15 -0
  45. package/dist/parse-texture.d.ts.map +1 -0
  46. package/dist/resolve-texture-path.d.ts +25 -0
  47. package/dist/resolve-texture-path.d.ts.map +1 -0
  48. package/dist/to-asset-pack.d.ts +38 -0
  49. package/dist/to-asset-pack.d.ts.map +1 -0
  50. package/package.json +75 -0
  51. package/pkg/fbx-wasm.mjs +2 -0
  52. package/pkg/fbx-wasm.wasm +0 -0
  53. package/scripts/build-wasm.mjs +85 -0
  54. package/scripts/content-key.mjs +88 -0
  55. package/scripts/ensure-wasm.mjs +28 -0
  56. package/scripts/fetch-ufbx.mjs +42 -0
  57. package/scripts/fetch-wasm.mjs +91 -0
  58. package/scripts/parity-diff.mjs +127 -0
  59. package/scripts/test-wasm.mjs +91 -0
  60. package/src/__tests__/asset-runtime-fixture.ts +251 -0
  61. package/src/__tests__/blendshape-import.integration.test.ts +96 -0
  62. package/src/__tests__/blendshape-import.unit.test.ts +83 -0
  63. package/src/__tests__/fbx-importer.test.ts +200 -0
  64. package/src/__tests__/fbx-local-artifacts.test.ts +39 -0
  65. package/src/__tests__/index.test.ts +167 -0
  66. package/src/__tests__/mesh-material-slots.unit.test.ts +125 -0
  67. package/src/__tests__/parse-mesh-multi-uv.test.ts +187 -0
  68. package/src/__tests__/pick-e2e.integration.test.ts +247 -0
  69. package/src/__tests__/resolve-texture-path.unit.test.ts +50 -0
  70. package/src/errors.ts +112 -0
  71. package/src/fbx-importer.ts +372 -0
  72. package/src/index.ts +215 -0
  73. package/src/native/bridge.c +1060 -0
  74. package/src/parse-animation-clip.ts +271 -0
  75. package/src/parse-material.ts +140 -0
  76. package/src/parse-mesh.ts +223 -0
  77. package/src/parse-scene.ts +75 -0
  78. package/src/parse-skeleton.ts +46 -0
  79. package/src/parse-skin.ts +71 -0
  80. package/src/parse-texture.ts +36 -0
  81. package/src/resolve-texture-path.ts +152 -0
  82. package/src/to-asset-pack.ts +813 -0
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ // fetch-wasm.mjs — Download the pre-built fbx-wasm pkg/ bundle from GitHub
3
+ // Releases so no-Emscripten consumers (Studio setup, editor standalone, CI
4
+ // typecheck, fresh clones) can resolve @forgeax/engine-fbx without an emcc
5
+ // toolchain.
6
+ //
7
+ // Like @forgeax/engine-wgpu-wasm and @forgeax/engine-codec, the fbx pkg/ carries
8
+ // TWO files emcc emits together — fbx-wasm.wasm + its self-loading fbx-wasm.mjs
9
+ // glue (MODULARIZE + EXPORT_ES6). src/index.ts imports the .mjs glue at runtime,
10
+ // so fetching only the .wasm would leave consumers without a loader. The release
11
+ // asset is therefore the WHOLE pkg/ packed as a content-keyed .tar.gz; this
12
+ // script downloads and extracts it into packages/fbx/pkg/.
13
+ //
14
+ // The content key (see content-key.mjs) is the SSOT shared with the CI publish
15
+ // job, so producer and consumer always agree on the asset name.
16
+ //
17
+ // Usage:
18
+ // pnpm -F @forgeax/engine-fbx fetch-wasm
19
+ // node scripts/fetch-wasm.mjs
20
+ //
21
+ // Environment:
22
+ // GITHUB_TOKEN — optional Bearer token for private-repo auth. Falls back to
23
+ // `gh auth token`. Anonymous requests work for public repos.
24
+ //
25
+ // Exit codes: 0 on success; 1 on any FetchError. Callers that want a build
26
+ // fallback treat a non-zero exit as "release unavailable, compile via
27
+ // build:wasm" (graceful degradation: architecture-principles #9).
28
+
29
+ import { rm, readdir } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+
32
+ import { FetchError, getGitOrigin, getReleaseAsset, downloadAsset, extractTarball } from '../../../scripts/lib/fetch-wasm-lib.mjs';
33
+ import { PKG_ROOT, RELEASE_TAG, resolveAsset } from './content-key.mjs';
34
+
35
+ const PKG_DIR = join(PKG_ROOT, 'pkg');
36
+ const TMP_TARBALL = join(PKG_ROOT, '.pkg-fetch.tar.gz');
37
+
38
+ const PKG_LABEL = 'fbx-wasm';
39
+ const BUILD_HINT =
40
+ 'compile locally with `pnpm -F @forgeax/engine-fbx build:wasm` (needs the Emscripten emcc toolchain)';
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Main
44
+ // ---------------------------------------------------------------------------
45
+
46
+ async function main() {
47
+ console.log('Resolving git origin...');
48
+ const { owner, repo } = getGitOrigin();
49
+ console.log(` origin: ${owner}/${repo}`);
50
+
51
+ console.log('Computing content key (bridge.c + fetch-ufbx.mjs + build-wasm.mjs)...');
52
+ const { sha256, assetName } = await resolveAsset();
53
+ console.log(` content SHA256: ${sha256}`);
54
+ console.log(` asset name: ${assetName}`);
55
+
56
+ console.log(`Fetching release tag "${RELEASE_TAG}" from ${owner}/${repo}...`);
57
+ const asset = await getReleaseAsset(owner, repo, RELEASE_TAG, assetName, {
58
+ pkgLabel: PKG_LABEL,
59
+ buildHint: BUILD_HINT,
60
+ missingAssetHint:
61
+ 'bridge.c or the fbx build scripts have uncommitted changes, or the release for this content is not yet published.',
62
+ });
63
+
64
+ console.log(`Downloading ${assetName} (${(asset.size / 1024).toFixed(0)} KB)...`);
65
+ await downloadAsset(asset, TMP_TARBALL, {
66
+ owner,
67
+ repo,
68
+ tag: RELEASE_TAG,
69
+ pkgLabel: PKG_LABEL,
70
+ buildHint: BUILD_HINT,
71
+ });
72
+
73
+ console.log(`Extracting into ${PKG_DIR} ...`);
74
+ await extractTarball(TMP_TARBALL, PKG_DIR);
75
+ await rm(TMP_TARBALL, { force: true });
76
+
77
+ const members = await readdir(PKG_DIR);
78
+ console.log(` pkg/ now holds: ${members.join(', ')}`);
79
+ console.log('Done.');
80
+ }
81
+
82
+ main().catch(async (e) => {
83
+ await rm(TMP_TARBALL, { force: true }).catch(() => {});
84
+ if (e instanceof FetchError) {
85
+ console.error(`\n[${e.code}] ${e.message}`);
86
+ console.error(`Hint: ${e.hint}`);
87
+ } else {
88
+ console.error('\nUnexpected error:', e.message || e);
89
+ }
90
+ process.exit(1);
91
+ });
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // freeze-snapshot.mjs (historically parity-diff.mjs) — re-freeze the parity
3
+ // regression snapshots from live ufbx output.
4
+ //
5
+ // History: during M1 this script double-ran ufbx (WASM) vs the in-repo FBX SDK
6
+ // binding to prove field-for-field parity before the SDK was deleted (M2). Once
7
+ // the SDK left the repo that diff half became unrunnable, so it was removed; the
8
+ // human-signed parity baseline it produced now lives frozen under
9
+ // __tests__/__snapshots__/ and parity-snapshot.test.ts asserts live ufbx output
10
+ // against it (see that file's header). What remains here is the re-freeze tool:
11
+ // it recomputes the compact structural digest from live ufbx and overwrites the
12
+ // snapshots. Re-freezing changes the human-signed baseline, so run it only with
13
+ // a deliberate parity re-sign (charter: Human as Final Authority).
14
+ //
15
+ // Usage:
16
+ // pnpm -F @forgeax/engine-fbx build # dist/ must be current
17
+ // node scripts/parity-diff.mjs # print each sample's digest
18
+ // node scripts/parity-diff.mjs --freeze # overwrite __tests__/__snapshots__/*.json
19
+ //
20
+ // The digest() below MUST stay identical to parity-snapshot.test.ts's copy so
21
+ // the freeze and the assertion agree (the .mjs script and the .ts test cannot
22
+ // cleanly import each other).
23
+
24
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { initFbxWasm, parseFbxToObject } from '../dist/index.mjs';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ const PKG = join(HERE, '..');
31
+ const VENDOR = join(PKG, '../../forgeax-engine-assets/vendor/fbx-test');
32
+ const SNAP_DIR = join(PKG, '__tests__/__snapshots__');
33
+
34
+ const SAMPLES = ['cube', 'humanoid'];
35
+
36
+ const ufbxRaw = (name) =>
37
+ parseFbxToObject(new Uint8Array(readFileSync(join(VENDOR, `${name}.fbx`))));
38
+
39
+ // A compact, git-friendly structural digest of the raw ufbx output. The full
40
+ // raw dump is ~13 MB (dense 9s animation keyframes) — too large to commit — so
41
+ // the frozen snapshot captures the parity-critical structure plus representative
42
+ // value spot-checks. It stays falsifiable: any bridge regression on counts,
43
+ // material, geometry bounds, node roster, or sampled key values trips it.
44
+ function bboxOf(vertices) {
45
+ const min = [Infinity, Infinity, Infinity];
46
+ const max = [-Infinity, -Infinity, -Infinity];
47
+ for (let i = 0; i < vertices.length; i += 3) {
48
+ for (let k = 0; k < 3; k++) {
49
+ const v = vertices[i + k];
50
+ if (v < min[k]) min[k] = v;
51
+ if (v > max[k]) max[k] = v;
52
+ }
53
+ }
54
+ return { min: min.map((x) => +x.toFixed(4)), max: max.map((x) => +x.toFixed(4)) };
55
+ }
56
+ function digest(raw) {
57
+ const meshes = (raw.meshes ?? []).map((m) => ({
58
+ name: m.name,
59
+ vertexFloats: m.vertices.length,
60
+ indexCount: (m.indices ?? []).length,
61
+ bbox: bboxOf(m.vertices),
62
+ attributes: Object.keys(m.attributes ?? {}).sort(),
63
+ }));
64
+ const nodes = (raw.nodes ?? []).map((n) => n.name);
65
+ const materials = (raw.materials ?? []).map((m) => ({
66
+ name: m.name,
67
+ kind: m.kind,
68
+ ...(m.diffuse && { diffuse: m.diffuse.map((x) => +x.toFixed(6)) }),
69
+ ...(m.shininess !== undefined && { shininess: +m.shininess.toFixed(4) }),
70
+ ...(m.stingrayProps && { stingrayProps: m.stingrayProps }),
71
+ }));
72
+ const clips = (raw.clips ?? []).map((c) => ({
73
+ name: c.name,
74
+ duration: +c.duration.toFixed(6),
75
+ channelCount: c.channels.length,
76
+ channelsByProperty: {
77
+ translation: c.channels.filter((ch) => ch.property === 'translation').length,
78
+ rotation: c.channels.filter((ch) => ch.property === 'rotation').length,
79
+ scale: c.channels.filter((ch) => ch.property === 'scale').length,
80
+ },
81
+ // Spot-check the first channel's first/last key so value regressions trip.
82
+ firstChannel: c.channels[0] && {
83
+ targetNode: c.channels[0].targetNode,
84
+ property: c.channels[0].property,
85
+ keyCount: c.channels[0].keyTimes.length,
86
+ firstKeyValues: c.channels[0].keyValues.slice(0, 4).map((x) => +x.toFixed(4)),
87
+ },
88
+ }));
89
+ const skeletons = (raw.skeletons ?? []).map((s) => ({
90
+ jointCount: s.jointCount,
91
+ jointPaths: s.jointPaths,
92
+ }));
93
+ const skins = (raw.skins ?? []).map((s) => ({
94
+ meshSourceIndex: s.meshSourceIndex,
95
+ vertexCount: s.vertexCount,
96
+ jointPathCount: s.jointPaths.length,
97
+ }));
98
+ return { meshes, nodes, materials, clips, skeletons, skins };
99
+ }
100
+
101
+ async function main() {
102
+ const freeze = process.argv.includes('--freeze');
103
+ await initFbxWasm();
104
+
105
+ const frozen = {};
106
+ for (const name of SAMPLES) {
107
+ const raw = ufbxRaw(name);
108
+ frozen[name] = digest(raw);
109
+ console.log(`[${name}] ${JSON.stringify(frozen[name]).length} bytes digest`);
110
+ }
111
+
112
+ if (freeze) {
113
+ mkdirSync(SNAP_DIR, { recursive: true });
114
+ for (const name of SAMPLES) {
115
+ const dst = join(SNAP_DIR, `${name}-snapshot.json`);
116
+ writeFileSync(dst, `${JSON.stringify(frozen[name], null, 2)}\n`);
117
+ console.log(`froze ${dst}`);
118
+ }
119
+ }
120
+
121
+ process.exit(0);
122
+ }
123
+
124
+ main().catch((e) => {
125
+ console.error(e);
126
+ process.exit(1);
127
+ });
@@ -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,251 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ type AssetRegistry,
4
+ createAssetRegistry,
5
+ createCatalogSource,
6
+ } from '@forgeax/engine-assets-runtime';
7
+ import { meshAssetDecoder, meshAssetKind } from '@forgeax/engine-geometry';
8
+ import { type DdcPack, normaliseForPack } from '@forgeax/engine-import';
9
+ import type {
10
+ AssetDecoder,
11
+ AssetLoadError,
12
+ MeshAsset,
13
+ MorphTarget,
14
+ Result,
15
+ VertexAttributeMap,
16
+ } from '@forgeax/engine-types';
17
+ import { err } from '@forgeax/engine-types';
18
+
19
+ const SCOPE_ID = 'fbx-test-scope';
20
+ const GENERATION = 1;
21
+ const PACK_DIGEST = `sha256:${'4'.repeat(64)}`;
22
+ const OUTPUT_SET_DIGEST = `sha256:${'5'.repeat(64)}`;
23
+ const OUTPUT_DIGEST = `sha256:${'6'.repeat(64)}`;
24
+ const ATTRIBUTE_KEYS = [
25
+ 'position',
26
+ 'normal',
27
+ 'uv',
28
+ 'tangent',
29
+ 'skinIndex',
30
+ 'skinWeight',
31
+ 'uv1',
32
+ 'uv2',
33
+ 'uv3',
34
+ 'uv4',
35
+ 'uv5',
36
+ 'uv6',
37
+ 'uv7',
38
+ ] as const;
39
+
40
+ type FixtureAsset = DdcPack['assets'][number];
41
+
42
+ function sha256(bytes: Uint8Array): string {
43
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
44
+ }
45
+
46
+ function floatArray(value: unknown): Float32Array | undefined {
47
+ if (value instanceof Float32Array) return value;
48
+ if (value instanceof ArrayBuffer) return new Float32Array(value);
49
+ if (ArrayBuffer.isView(value))
50
+ return new Float32Array(Array.from(value as unknown as ArrayLike<number>));
51
+ if (Array.isArray(value)) return new Float32Array(value as number[]);
52
+ return undefined;
53
+ }
54
+
55
+ function indexArray(value: unknown): Uint16Array | Uint32Array | undefined {
56
+ if (value instanceof Uint16Array || value instanceof Uint32Array) return value;
57
+ if (ArrayBuffer.isView(value) || Array.isArray(value)) {
58
+ const values = Array.from(value as unknown as ArrayLike<number>);
59
+ return values.some((entry) => entry > 0xffff)
60
+ ? new Uint32Array(values)
61
+ : new Uint16Array(values);
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function integerAttribute(value: unknown): Uint16Array | undefined {
67
+ if (value instanceof Uint16Array) return value;
68
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
69
+ return new Uint16Array(Array.from(value as unknown as ArrayLike<number>));
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ function morphTarget(value: unknown): MorphTarget | undefined {
75
+ if (value === null || typeof value !== 'object') return undefined;
76
+ const source = value as Record<string, unknown>;
77
+ const position = floatArray(source.position);
78
+ const normal = floatArray(source.normal);
79
+ const tangent = floatArray(source.tangent);
80
+ return {
81
+ ...(position === undefined ? {} : { position }),
82
+ ...(normal === undefined ? {} : { normal }),
83
+ ...(tangent === undefined ? {} : { tangent }),
84
+ };
85
+ }
86
+
87
+ function meshPayload(value: unknown): MeshAsset | undefined {
88
+ if (value === null || typeof value !== 'object') return undefined;
89
+ const source = value as Record<string, unknown>;
90
+ const rawAttributes = source.attributes;
91
+ if (
92
+ source.kind !== 'mesh' ||
93
+ floatArray(source.vertices) === undefined ||
94
+ rawAttributes === null ||
95
+ typeof rawAttributes !== 'object' ||
96
+ !Array.isArray(source.submeshes) ||
97
+ !Array.isArray(source.materialSlots)
98
+ ) {
99
+ return undefined;
100
+ }
101
+ const attributes: VertexAttributeMap = {};
102
+ const sourceAttributes = rawAttributes as Record<string, unknown>;
103
+ for (const key of ATTRIBUTE_KEYS) {
104
+ const raw = sourceAttributes[key];
105
+ if (raw === undefined) continue;
106
+ const converted = key === 'skinIndex' ? integerAttribute(raw) : floatArray(raw);
107
+ if (converted !== undefined) attributes[key] = converted;
108
+ }
109
+ const morphWeights = floatArray(source.morphWeights);
110
+ const morphTargets = Array.isArray(source.morphTargets)
111
+ ? source.morphTargets.flatMap((target) => {
112
+ const converted = morphTarget(target);
113
+ return converted === undefined ? [] : [converted];
114
+ })
115
+ : undefined;
116
+ const vertices = floatArray(source.vertices) as Float32Array;
117
+ const indices = indexArray(source.indices);
118
+ const aabb = floatArray(source.aabb);
119
+ return {
120
+ kind: 'mesh',
121
+ vertices,
122
+ ...(indices === undefined ? {} : { indices }),
123
+ attributes,
124
+ ...(aabb === undefined ? {} : { aabb }),
125
+ submeshes: source.submeshes as MeshAsset['submeshes'],
126
+ materialSlots: source.materialSlots as MeshAsset['materialSlots'],
127
+ ...(morphTargets === undefined ? {} : { morphTargets }),
128
+ ...(morphWeights === undefined ? {} : { morphWeights }),
129
+ };
130
+ }
131
+
132
+ export const normalizedMeshAssetDecoder: AssetDecoder<MeshAsset> = {
133
+ async decode(input): Promise<Result<MeshAsset, AssetLoadError>> {
134
+ const body = input.envelope.artifacts.body;
135
+ if (body !== undefined) {
136
+ const bytes = await input.artifacts.read(body);
137
+ if (!bytes.ok) return bytes;
138
+ }
139
+ const payload = meshPayload(input.envelope.payload);
140
+ if (payload === undefined) {
141
+ return err({
142
+ code: 'asset-package-invalid',
143
+ expected: 'a JSON-safe mesh payload that can be owned by Geometry',
144
+ hint: 'recook the mesh payload with typed vertex and attribute fields',
145
+ detail: { guid: input.envelope.guid, reason: 'mesh payload normalization failed' },
146
+ });
147
+ }
148
+ return meshAssetDecoder.decode({
149
+ ...input,
150
+ envelope: { ...input.envelope, payload },
151
+ });
152
+ },
153
+ };
154
+
155
+ export function installMeshDecoder(assets: AssetRegistry) {
156
+ return assets.installDecoder(meshAssetKind, normalizedMeshAssetDecoder);
157
+ }
158
+
159
+ function row(asset: FixtureAsset, packageUrl: string) {
160
+ const sourcePath = `fixtures/${asset.guid}`;
161
+ return {
162
+ guid: asset.guid,
163
+ packageUrl,
164
+ kind: asset.kind,
165
+ sourcePath,
166
+ revision: { rootId: 'fbx-fixture', digest: 'sha256:catalog', observedAt: 1 },
167
+ publication: {
168
+ schemaVersion: 'asset-publication/1' as const,
169
+ sourcePath,
170
+ sourceRevision: 'fbx-fixture-source',
171
+ generation: GENERATION,
172
+ digest: PACK_DIGEST,
173
+ outputSetDigest: OUTPUT_SET_DIGEST,
174
+ outputs: [
175
+ {
176
+ guid: asset.guid,
177
+ sourceKey: asset.sourceKey ?? asset.guid,
178
+ kind: asset.kind,
179
+ digest: OUTPUT_DIGEST,
180
+ refs: asset.refs,
181
+ },
182
+ ],
183
+ receipt: {
184
+ schemaVersion: 'asset-publication-receipt/1' as const,
185
+ sourcePath,
186
+ sourceRevision: 'fbx-fixture-source',
187
+ inputFingerprint: 'fbx-fixture-input',
188
+ outputDigest: OUTPUT_DIGEST,
189
+ outputSetDigest: OUTPUT_SET_DIGEST,
190
+ externalEvidence: [],
191
+ },
192
+ externalEvidence: [],
193
+ },
194
+ };
195
+ }
196
+
197
+ export function createAssetRuntimeFixture(sourceAssets: readonly FixtureAsset[]): AssetRegistry {
198
+ const packageUrl = 'https://assets.test/fbx-fixture.pack.json';
199
+ const artifactBytes = new Map<string, Uint8Array>();
200
+ const runtimeAssets = sourceAssets.map((asset) => {
201
+ const artifacts = Object.fromEntries(
202
+ Object.entries(asset.artifacts).map(([key, body]) => {
203
+ const path = `${asset.guid}/${key}.bin`;
204
+ const url = new URL(path, packageUrl).toString();
205
+ artifactBytes.set(url, body.bytes);
206
+ return [
207
+ key,
208
+ {
209
+ path,
210
+ mediaType: body.mediaType,
211
+ contentEncoding: 'identity' as const,
212
+ byteLength: body.bytes.byteLength,
213
+ integrity: { algorithm: 'sha256' as const, digest: sha256(body.bytes) },
214
+ ...(body.assetCodec === undefined ? {} : { assetCodec: body.assetCodec }),
215
+ },
216
+ ];
217
+ }),
218
+ );
219
+ return {
220
+ guid: asset.guid,
221
+ kind: asset.kind,
222
+ ...(asset.name === undefined ? {} : { name: asset.name }),
223
+ payload: normaliseForPack(asset.payload) as Record<string, unknown>,
224
+ refs: asset.refs,
225
+ artifacts,
226
+ };
227
+ });
228
+ const pack = {
229
+ schemaVersion: '2.0.0' as const,
230
+ kind: 'internal-text-package' as const,
231
+ scopeId: SCOPE_ID,
232
+ generation: GENERATION,
233
+ digest: PACK_DIGEST,
234
+ outputSetDigest: OUTPUT_SET_DIGEST,
235
+ assets: runtimeAssets,
236
+ };
237
+ const fetcher: typeof globalThis.fetch = async (input) => {
238
+ const url = String(input);
239
+ if (url === packageUrl) return new Response(JSON.stringify(pack));
240
+ const bytes = artifactBytes.get(url);
241
+ return bytes === undefined
242
+ ? new Response('not found', { status: 404 })
243
+ : new Response(bytes as unknown as BodyInit);
244
+ };
245
+ return createAssetRegistry({
246
+ catalog: createCatalogSource({ entries: sourceAssets.map((asset) => row(asset, packageUrl)) }),
247
+ fetcher,
248
+ scopeId: SCOPE_ID,
249
+ generation: GENERATION,
250
+ });
251
+ }
@@ -0,0 +1,96 @@
1
+ import { World } from '@forgeax/engine-ecs';
2
+ import { meshAssetKind } from '@forgeax/engine-geometry';
3
+ import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { fbxImporter } from '../fbx-importer.js';
6
+ import { initFbxWasm, parseFbx } from '../index.js';
7
+ import { createAssetRuntimeFixture, installMeshDecoder } from './asset-runtime-fixture';
8
+
9
+ const MESH_GUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
10
+ const sourceUrl = new URL(
11
+ '../../../../apps/hello/format-tier1/fixtures/multi-target-morph.fbx',
12
+ import.meta.url,
13
+ );
14
+
15
+ async function readFixture(url: URL): Promise<Uint8Array> {
16
+ const nodeFs = (await import('node:' + 'fs/promises')) as unknown as {
17
+ readonly readFile: (path: URL) => Promise<Uint8Array>;
18
+ };
19
+ return new Uint8Array(await nodeFs.readFile(url));
20
+ }
21
+
22
+ function meta(): RunImportMeta {
23
+ return {
24
+ importer: 'fbx',
25
+ source: 'apps/hello/format-tier1/fixtures/multi-target-morph.fbx',
26
+ subAssets: [
27
+ { guid: MESH_GUID, sourceIndex: 0, sourceKey: 'fbx:mesh:MorphTriangle', kind: 'mesh' },
28
+ ],
29
+ };
30
+ }
31
+
32
+ describe('real FBX BlendShape import through ufbx WASM', () => {
33
+ it('keeps source target order, authored weights, and target lengths', async () => {
34
+ const bytes = await readFixture(sourceUrl);
35
+ await initFbxWasm();
36
+ const raw = JSON.parse(parseFbx(bytes)) as {
37
+ readonly meshes: readonly {
38
+ readonly morphTargets?: readonly { readonly position?: readonly number[] }[];
39
+ readonly morphWeights?: readonly number[];
40
+ }[];
41
+ };
42
+ expect(raw.meshes).toHaveLength(1);
43
+ expect(raw.meshes[0]?.morphTargets).toHaveLength(2);
44
+ expect(raw.meshes[0]?.morphTargets?.[0]?.position).toEqual([0, 0, 0, 0.25, 0, 0, 0, 0.5, 0]);
45
+ expect(raw.meshes[0]?.morphTargets?.[1]?.position).toEqual([0, 0, -0.25, 0, 0, 0, 0, 0, 0]);
46
+ expect(raw.meshes[0]?.morphWeights).toEqual([0.25, 0.5]);
47
+
48
+ const registry = new ImporterRegistry();
49
+ registry.register(fbxImporter);
50
+ const fs = { readSource: async () => ({ ok: true as const, value: bytes }) };
51
+ const result = await runImport(meta(), registry, fs);
52
+ expect(result.ok).toBe(true);
53
+ if (!result.ok || 'skipped' in result.value) throw new Error('expected FBX DDC pack');
54
+ const entry = result.value.pack.assets.find((asset) => asset.guid === MESH_GUID);
55
+ expect(entry?.kind).toBe('mesh');
56
+ if (entry === undefined) return;
57
+ const payload = entry?.payload as {
58
+ readonly vertices: readonly number[];
59
+ readonly morphTargets?: readonly { readonly position?: readonly number[] }[];
60
+ readonly morphWeights?: readonly number[];
61
+ };
62
+ expect(payload.morphTargets).toHaveLength(2);
63
+ expect(payload.morphTargets?.map((target) => target.position?.length)).toEqual([9, 9]);
64
+ expect(payload.morphWeights).toEqual([0.25, 0.5]);
65
+ expect(payload.vertices.length).toBe(3 * 12);
66
+
67
+ const assets = createAssetRuntimeFixture([entry]);
68
+ installMeshDecoder(assets);
69
+ try {
70
+ const loaded = await assets.load(MESH_GUID, meshAssetKind);
71
+ expect(loaded.ok).toBe(true);
72
+ if (!loaded.ok) return;
73
+ const world = new World();
74
+ world.internSharedRef('MeshAsset', loaded.value);
75
+ expect(loaded.value.kind).toBe('mesh');
76
+ expect(Array.from(loaded.value.morphWeights ?? [])).toEqual([0.25, 0.5]);
77
+ expect(loaded.value.morphTargets).toHaveLength(2);
78
+ } finally {
79
+ assets.dispose();
80
+ }
81
+ });
82
+
83
+ it('records that this real fixture has no imported animation clip', async () => {
84
+ const bytes = await readFixture(sourceUrl);
85
+ await initFbxWasm();
86
+ const registry = new ImporterRegistry();
87
+ registry.register(fbxImporter);
88
+ const fs = { readSource: async () => ({ ok: true as const, value: bytes }) };
89
+ const result = await runImport(meta(), registry, fs);
90
+ expect(result.ok).toBe(true);
91
+ if (!result.ok || 'skipped' in result.value) return;
92
+ expect(
93
+ result.value.pack.assets.filter((asset) => asset.kind === 'animation-clip'),
94
+ ).toHaveLength(0);
95
+ });
96
+ });