@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,75 @@
1
+ // parse-scene.ts — FBX JSON POD to ScenePod bridge (t29).
2
+
3
+ import type { SceneEntityPod, ScenePod } from '@forgeax/engine-types';
4
+
5
+ export interface FbxRawNode {
6
+ readonly name: string;
7
+ readonly transform: {
8
+ readonly translation: [number, number, number];
9
+ readonly rotation: [number, number, number, number];
10
+ readonly scale: [number, number, number];
11
+ };
12
+ readonly meshIndex: number;
13
+ readonly children: readonly number[];
14
+ }
15
+
16
+ export interface FbxRawNodes {
17
+ readonly nodes?: readonly FbxRawNode[];
18
+ }
19
+
20
+ export type FbxNodePathResult =
21
+ | { readonly ok: true; readonly value: readonly string[] }
22
+ | {
23
+ readonly ok: false;
24
+ readonly reason: 'name-missing' | 'path-invalid' | 'hierarchy-cycle';
25
+ readonly nodeIndex: number;
26
+ };
27
+
28
+ export function buildFbxNodePaths(
29
+ nodes: readonly { readonly name?: string; readonly children?: readonly number[] }[],
30
+ ): readonly FbxNodePathResult[] {
31
+ const parents = new Map<number, number>();
32
+ for (let index = 0; index < nodes.length; index++) {
33
+ for (const child of nodes[index]?.children ?? []) parents.set(child, index);
34
+ }
35
+ return nodes.map((_, index) => {
36
+ const reversed: string[] = [];
37
+ const visited = new Set<number>();
38
+ let current: number | undefined = index;
39
+ while (current !== undefined) {
40
+ if (visited.has(current)) {
41
+ return { ok: false, reason: 'hierarchy-cycle', nodeIndex: current };
42
+ }
43
+ visited.add(current);
44
+ const name = nodes[current]?.name;
45
+ if (name === undefined || name.length === 0) {
46
+ return { ok: false, reason: 'name-missing', nodeIndex: current };
47
+ }
48
+ if (name.includes('/')) {
49
+ return { ok: false, reason: 'path-invalid', nodeIndex: current };
50
+ }
51
+ reversed.push(name);
52
+ current = parents.get(current);
53
+ }
54
+ return { ok: true, value: reversed.reverse() };
55
+ });
56
+ }
57
+
58
+ export function parseScene(rawNodes: FbxRawNodes): ScenePod {
59
+ const nodes = rawNodes.nodes ?? [];
60
+ const entities: SceneEntityPod[] = nodes.map((n) => ({
61
+ name: n.name,
62
+ transform: {
63
+ translation: n.transform.translation,
64
+ rotation: n.transform.rotation,
65
+ scale: n.transform.scale,
66
+ },
67
+ meshIndex: n.meshIndex >= 0 ? n.meshIndex : null,
68
+ children: n.children,
69
+ }));
70
+
71
+ return {
72
+ entities,
73
+ rootEntityIndex: 0,
74
+ };
75
+ }
@@ -0,0 +1,46 @@
1
+ // parse-skeleton.ts — M5 t48: TS bridge for skeleton POD data.
2
+ //
3
+ // Consumes the C++ JSON POD skeleton section emitted by t45 binding.cc
4
+ // (WriteSkeletonData), produces SkeletonPod (types SSOT per requirements AC-05).
5
+ //
6
+ // Input schema (from JSON.parse of binding output):
7
+ // skeletons?: [{
8
+ // jointCount: number,
9
+ // inverseBindMatrices: number[], // jointCount * 16 packed doubles
10
+ // jointPaths: string[],
11
+ // }]
12
+ //
13
+ // Output: SkeletonPod { jointCount, inverseBindMatrices: Float32Array, jointPaths }
14
+
15
+ import type { SkeletonPod } from '@forgeax/engine-types';
16
+
17
+ export interface FbxRawSkeleton {
18
+ readonly jointCount: number;
19
+ readonly inverseBindMatrices: number[];
20
+ readonly jointPaths: string[];
21
+ }
22
+
23
+ export interface FbxRawSkeletonDoc {
24
+ readonly skeletons?: readonly FbxRawSkeleton[];
25
+ }
26
+
27
+ /**
28
+ * Parse the first skeleton from a C++ JSON POD document.
29
+ * Returns an empty skeleton when the document has no skeleton data.
30
+ */
31
+ export function parseSkeleton(doc: FbxRawSkeletonDoc): SkeletonPod {
32
+ const skeletons = doc.skeletons;
33
+ if (!skeletons || skeletons.length === 0) {
34
+ return { jointCount: 0, inverseBindMatrices: new Float32Array(0), jointPaths: [] };
35
+ }
36
+
37
+ const skel = skeletons[0];
38
+ if (!skel) {
39
+ return { jointCount: 0, inverseBindMatrices: new Float32Array(0), jointPaths: [] };
40
+ }
41
+ return {
42
+ jointCount: skel.jointCount,
43
+ inverseBindMatrices: new Float32Array(skel.inverseBindMatrices),
44
+ jointPaths: [...skel.jointPaths],
45
+ };
46
+ }
@@ -0,0 +1,71 @@
1
+ // parse-skin.ts — M5 t49: TS bridge for skin POD data.
2
+ //
3
+ // Consumes the C++ JSON POD skin section emitted by t45 binding.cc
4
+ // (WriteSkinData), produces SkinPod (types SSOT per requirements AC-06).
5
+ //
6
+ // Input schema (from JSON.parse of binding output):
7
+ // skins?: [{
8
+ // meshSourceIndex: number,
9
+ // jointPaths: string[],
10
+ // vertexCount: number,
11
+ // influences: [{ jointIndices: number[], jointWeights: number[] }],
12
+ // }]
13
+ //
14
+ // Output: SkinPod { skeletonGuid, jointPaths, vertexCount, influences }
15
+
16
+ import type { SkinPod, SkinVertexInfluencePod } from '@forgeax/engine-types';
17
+
18
+ export interface FbxRawSkinInfluence {
19
+ readonly jointIndices: number[];
20
+ readonly jointWeights: number[];
21
+ }
22
+
23
+ export interface FbxRawSkin {
24
+ readonly meshSourceIndex: number;
25
+ readonly jointPaths: string[];
26
+ readonly vertexCount: number;
27
+ readonly influences: readonly FbxRawSkinInfluence[];
28
+ }
29
+
30
+ export interface FbxRawSkinDoc {
31
+ readonly skins?: readonly FbxRawSkin[];
32
+ }
33
+
34
+ function bridgeSkeletonGuid(_jointPaths: readonly string[]): string {
35
+ // Deterministic GUID from joint paths. Real GUID assignment happens
36
+ // at import-runner time; this is a placeholder bridge identifier.
37
+ return 'fbx-skeleton-000000000000';
38
+ }
39
+
40
+ function toInfluence(raw: FbxRawSkinInfluence): SkinVertexInfluencePod {
41
+ // Pad to exactly 4 entries
42
+ const ji = new Uint16Array(4);
43
+ const jw = new Float32Array(4);
44
+ for (let i = 0; i < 4; i++) {
45
+ ji[i] = raw.jointIndices[i] ?? 0;
46
+ jw[i] = raw.jointWeights[i] ?? 0;
47
+ }
48
+ return { jointIndices: ji, jointWeights: jw };
49
+ }
50
+
51
+ /**
52
+ * Parse the first skin from a C++ JSON POD document.
53
+ * Returns an empty skin when the document has no skin data.
54
+ */
55
+ export function parseSkin(doc: FbxRawSkinDoc): SkinPod {
56
+ const skins = doc.skins;
57
+ if (!skins || skins.length === 0) {
58
+ return { skeletonGuid: '', jointPaths: [], vertexCount: 0, influences: [] };
59
+ }
60
+
61
+ const skin = skins[0];
62
+ if (!skin) {
63
+ return { skeletonGuid: '', jointPaths: [], vertexCount: 0, influences: [] };
64
+ }
65
+ return {
66
+ skeletonGuid: bridgeSkeletonGuid(skin.jointPaths),
67
+ jointPaths: [...skin.jointPaths],
68
+ vertexCount: skin.vertexCount,
69
+ influences: skin.influences.map(toInfluence),
70
+ };
71
+ }
@@ -0,0 +1,36 @@
1
+ // parse-texture.ts — FBX JSON POD to TexturePod bridge (t30).
2
+
3
+ import type { TexturePod } from '@forgeax/engine-types';
4
+
5
+ export interface FbxRawTexture {
6
+ readonly name?: string;
7
+ readonly filePath?: string;
8
+ readonly relativeFilePath?: string;
9
+ readonly absoluteFilePath?: string;
10
+ readonly embeddedBytes?: readonly number[];
11
+ readonly type?: 'file' | 'layered' | 'procedural' | 'shader' | 'unknown';
12
+ readonly sourceIndex: number;
13
+ }
14
+
15
+ export interface FbxRawTextures {
16
+ readonly textures?: readonly FbxRawTexture[];
17
+ }
18
+
19
+ function normalizePath(path: string): string {
20
+ return path.replaceAll('\\', '/');
21
+ }
22
+
23
+ export function parseTextures(raw: FbxRawTextures): TexturePod[] {
24
+ const textures = raw.textures ?? [];
25
+ return textures.map((t) => ({
26
+ name: t.name ?? t.filePath ?? t.relativeFilePath ?? `texture-${t.sourceIndex}`,
27
+ filePath: normalizePath(t.filePath ?? t.relativeFilePath ?? ''),
28
+ ...(t.relativeFilePath === undefined
29
+ ? {}
30
+ : { relativeFilePath: normalizePath(t.relativeFilePath) }),
31
+ ...(t.absoluteFilePath === undefined ? {} : { absoluteFilePath: t.absoluteFilePath }),
32
+ ...(t.embeddedBytes === undefined ? {} : { embeddedBytes: Uint8Array.from(t.embeddedBytes) }),
33
+ ...(t.type === undefined ? {} : { type: t.type }),
34
+ sourceIndex: t.sourceIndex,
35
+ }));
36
+ }
@@ -0,0 +1,152 @@
1
+ export type FbxTextureResolutionStrategy = 'exact' | 'case-folded' | 'suffix' | 'basename';
2
+
3
+ export interface FbxTexturePathRequest {
4
+ readonly declaredRelativePath?: string;
5
+ readonly declaredFilename?: string;
6
+ readonly declaredAbsolutePath?: string;
7
+ }
8
+
9
+ export interface FbxTextureCandidate {
10
+ /** Candidate path relative to the FBX source file's directory. */
11
+ readonly relativePath: string;
12
+ }
13
+
14
+ export type FbxTextureResolution =
15
+ | {
16
+ readonly ok: true;
17
+ readonly relativePath: string;
18
+ /** URI passed to ImportContext.readSibling from the FBX source. */
19
+ readonly readUri: string;
20
+ readonly strategy: FbxTextureResolutionStrategy;
21
+ }
22
+ | {
23
+ readonly ok: false;
24
+ readonly code: 'fbx-external-texture-missing' | 'fbx-external-texture-ambiguous';
25
+ readonly requestedPath: string;
26
+ readonly candidates: readonly string[];
27
+ };
28
+
29
+ function normalizeSourceRelativePath(raw: string): string | undefined {
30
+ const value = raw.replaceAll('\\', '/');
31
+ if (value.startsWith('/') || /^[A-Za-z]:\//.test(value) || value.startsWith('//'))
32
+ return undefined;
33
+ const parts: string[] = [];
34
+ for (const part of value.split('/')) {
35
+ if (part === '' || part === '.') continue;
36
+ if (part === '..') {
37
+ if (parts.at(-1) !== undefined && parts.at(-1) !== '..') parts.pop();
38
+ else parts.push('..');
39
+ } else {
40
+ parts.push(part);
41
+ }
42
+ }
43
+ return parts.join('/');
44
+ }
45
+
46
+ function requestSegments(raw: string): string[] {
47
+ const value = raw
48
+ .replaceAll('\\', '/')
49
+ .replace(/^[A-Za-z]:\//, '')
50
+ .replace(/^\/+/, '');
51
+ return value.split('/').filter((part) => part !== '' && part !== '.');
52
+ }
53
+
54
+ function uniqueMatches(paths: readonly string[], predicate: (path: string) => boolean): string[] {
55
+ return [...new Set(paths.filter(predicate))];
56
+ }
57
+
58
+ function longestSuffixLength(request: readonly string[], candidate: readonly string[]): number {
59
+ let count = 0;
60
+ while (
61
+ count < request.length &&
62
+ count < candidate.length &&
63
+ request[request.length - 1 - count]?.toLowerCase() ===
64
+ candidate[candidate.length - 1 - count]?.toLowerCase()
65
+ ) {
66
+ count++;
67
+ }
68
+ return count;
69
+ }
70
+
71
+ /** Resolve an FBX-declared texture against a caller-authorized candidate tree. */
72
+ export function resolveFbxTexturePath(
73
+ sourcePath: string,
74
+ request: FbxTexturePathRequest,
75
+ candidates: readonly FbxTextureCandidate[],
76
+ ): FbxTextureResolution {
77
+ const candidatePaths = candidates.flatMap((candidate) => {
78
+ const normalized = normalizeSourceRelativePath(candidate.relativePath);
79
+ return normalized === undefined || normalized.length === 0 ? [] : [normalized];
80
+ });
81
+ const requestedPath =
82
+ request.declaredRelativePath ?? request.declaredFilename ?? request.declaredAbsolutePath ?? '';
83
+ void sourcePath;
84
+
85
+ const choose = (
86
+ matches: readonly string[],
87
+ strategy: FbxTextureResolutionStrategy,
88
+ ): FbxTextureResolution | undefined => {
89
+ const unique = [...new Set(matches)];
90
+ if (unique.length === 1) {
91
+ const relativePath = unique[0];
92
+ if (relativePath === undefined) return undefined;
93
+ return {
94
+ ok: true,
95
+ relativePath,
96
+ readUri: relativePath,
97
+ strategy,
98
+ };
99
+ }
100
+ if (unique.length > 1) {
101
+ return {
102
+ ok: false,
103
+ code: 'fbx-external-texture-ambiguous',
104
+ requestedPath,
105
+ candidates: unique,
106
+ };
107
+ }
108
+ return undefined;
109
+ };
110
+
111
+ const relativeRequest = request.declaredRelativePath ?? request.declaredFilename;
112
+ if (relativeRequest !== undefined && !/^(?:[A-Za-z]:[\\/]|[\\/])/.test(relativeRequest)) {
113
+ const normalizedRequest = normalizeSourceRelativePath(relativeRequest);
114
+ if (normalizedRequest !== undefined) {
115
+ const scopeExact = choose(
116
+ uniqueMatches(candidatePaths, (path) => path === normalizedRequest),
117
+ 'exact',
118
+ );
119
+ if (scopeExact !== undefined) return scopeExact;
120
+ const scopeFolded = choose(
121
+ uniqueMatches(
122
+ candidatePaths,
123
+ (path) => path.toLowerCase() === normalizedRequest.toLowerCase(),
124
+ ),
125
+ 'case-folded',
126
+ );
127
+ if (scopeFolded !== undefined) return scopeFolded;
128
+ }
129
+ }
130
+
131
+ const requestedSegments = requestSegments(requestedPath);
132
+ if (requestedSegments.length > 0) {
133
+ const scored = candidatePaths.map((path) => ({
134
+ path,
135
+ score: longestSuffixLength(requestedSegments, path.split('/')),
136
+ }));
137
+ const bestScore = Math.max(0, ...scored.map((entry) => entry.score));
138
+ if (bestScore > 0) {
139
+ const best = scored.filter((entry) => entry.score === bestScore).map((entry) => entry.path);
140
+ const strategy: FbxTextureResolutionStrategy = bestScore === 1 ? 'basename' : 'suffix';
141
+ const suffix = choose(best, strategy);
142
+ if (suffix !== undefined) return suffix;
143
+ }
144
+ }
145
+
146
+ return {
147
+ ok: false,
148
+ code: 'fbx-external-texture-missing',
149
+ requestedPath,
150
+ candidates: candidatePaths,
151
+ };
152
+ }