@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,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
|
+
});
|