From 7fb76f111f711ccd8d2896f2a97e3ca8d6f430e5 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Tue, 19 May 2026 19:44:59 -0700 Subject: [PATCH 1/3] Add TypeScript FBX loader Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../loaders/src/FBX/fbxFileLoader.metadata.ts | 10 + packages/dev/loaders/src/FBX/fbxFileLoader.ts | 3098 +++++++++++++++++ packages/dev/loaders/src/FBX/index.ts | 13 + .../loaders/src/FBX/interpreter/animation.ts | 833 +++++ .../src/FBX/interpreter/blendShapes.ts | 268 ++ .../src/FBX/interpreter/connections.ts | 335 ++ .../src/FBX/interpreter/fbxInterpreter.ts | 767 ++++ .../loaders/src/FBX/interpreter/geometry.ts | 747 ++++ .../loaders/src/FBX/interpreter/materials.ts | 216 ++ .../src/FBX/interpreter/propertyTemplates.ts | 172 + .../dev/loaders/src/FBX/interpreter/rig.ts | 331 ++ .../src/FBX/interpreter/sceneDiagnostics.ts | 118 + .../loaders/src/FBX/interpreter/skeleton.ts | 708 ++++ .../loaders/src/FBX/interpreter/transform.ts | 119 + .../loaders/src/FBX/parsers/fbxAsciiParser.ts | 364 ++ .../src/FBX/parsers/fbxBinaryParser.ts | 236 ++ .../loaders/src/FBX/parsers/zlibInflate.ts | 403 +++ .../dev/loaders/src/FBX/types/fbxTypes.ts | 95 + packages/dev/loaders/src/dynamic.ts | 10 + packages/dev/loaders/src/index.ts | 1 + .../dev/loaders/test/unit/FBX/dynamic.test.ts | 36 + .../unit/FBX/interpreter/blendShapes.test.ts | 110 + .../unit/FBX/interpreter/connections.test.ts | 106 + .../unit/FBX/interpreter/geometry.test.ts | 90 + .../FBX/interpreter/propertyTemplates.test.ts | 80 + .../unit/FBX/interpreter/transform.test.ts | 43 + .../loaders/test/unit/FBX/materials.test.ts | 236 ++ .../test/unit/FBX/parsers/zlibInflate.test.ts | 191 + specs/fbx-loader/architecture.md | 139 + specs/fbx-loader/goals.md | 25 + specs/fbx-loader/requirements.md | 89 + 31 files changed, 9989 insertions(+) create mode 100644 packages/dev/loaders/src/FBX/fbxFileLoader.metadata.ts create mode 100644 packages/dev/loaders/src/FBX/fbxFileLoader.ts create mode 100644 packages/dev/loaders/src/FBX/index.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/animation.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/blendShapes.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/connections.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/geometry.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/materials.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/rig.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/skeleton.ts create mode 100644 packages/dev/loaders/src/FBX/interpreter/transform.ts create mode 100644 packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts create mode 100644 packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts create mode 100644 packages/dev/loaders/src/FBX/parsers/zlibInflate.ts create mode 100644 packages/dev/loaders/src/FBX/types/fbxTypes.ts create mode 100644 packages/dev/loaders/test/unit/FBX/dynamic.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/transform.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/materials.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/parsers/zlibInflate.test.ts create mode 100644 specs/fbx-loader/architecture.md create mode 100644 specs/fbx-loader/goals.md create mode 100644 specs/fbx-loader/requirements.md diff --git a/packages/dev/loaders/src/FBX/fbxFileLoader.metadata.ts b/packages/dev/loaders/src/FBX/fbxFileLoader.metadata.ts new file mode 100644 index 00000000000..e5d72d20083 --- /dev/null +++ b/packages/dev/loaders/src/FBX/fbxFileLoader.metadata.ts @@ -0,0 +1,10 @@ +import { type ISceneLoaderPluginExtensions, type ISceneLoaderPluginMetadata } from "core/index"; + +export const FBXFileLoaderMetadata = { + name: "fbx", + + extensions: { + // eslint-disable-next-line @typescript-eslint/naming-convention + ".fbx": { isBinary: true }, + } as const satisfies ISceneLoaderPluginExtensions, +} as const satisfies ISceneLoaderPluginMetadata; diff --git a/packages/dev/loaders/src/FBX/fbxFileLoader.ts b/packages/dev/loaders/src/FBX/fbxFileLoader.ts new file mode 100644 index 00000000000..559d0123853 --- /dev/null +++ b/packages/dev/loaders/src/FBX/fbxFileLoader.ts @@ -0,0 +1,3098 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { + type ISceneLoaderPluginAsync, + type ISceneLoaderPluginFactory, + type ISceneLoaderAsyncResult, + type ISceneLoaderProgressEvent, + type SceneLoaderPluginOptions, + RegisterSceneLoaderPlugin, +} from "core/Loading/sceneLoader"; +import { type Scene } from "core/scene"; +import { type FloatArray, type Nullable } from "core/types"; +import { Mesh } from "core/Meshes/mesh"; +import { SubMesh } from "core/Meshes/subMesh"; +import { VertexData } from "core/Meshes/mesh.vertexData"; +import { StandardMaterial } from "core/Materials/standardMaterial"; +import { Material } from "core/Materials/material"; +import { MultiMaterial } from "core/Materials/multiMaterial"; +import { type ITextureCreationOptions, Texture } from "core/Materials/Textures/texture"; +import { type BaseTexture } from "core/Materials/Textures/baseTexture"; +import { Color3 } from "core/Maths/math.color"; +import { Vector3, Quaternion, Matrix } from "core/Maths/math.vector"; +import { TransformNode } from "core/Meshes/transformNode"; +import { Skeleton } from "core/Bones/skeleton"; +import { Bone } from "core/Bones/bone"; +import { Animation } from "core/Animations/animation"; +import { AnimationGroup } from "core/Animations/animationGroup"; +import { AnimationKeyInterpolation, type IAnimationKey } from "core/Animations/animationKey"; +import { MorphTarget } from "core/Morph/morphTarget"; +import { MorphTargetManager } from "core/Morph/morphTargetManager"; +import { Camera } from "core/Cameras/camera"; +import { FreeCamera } from "core/Cameras/freeCamera"; +import { PointLight } from "core/Lights/pointLight"; +import { DirectionalLight } from "core/Lights/directionalLight"; +import { SpotLight } from "core/Lights/spotLight"; +import { AssetContainer } from "core/assetContainer"; +import { GetMimeType } from "core/Misc/fileTools"; + +import { parseBinaryFBX } from "./parsers/fbxBinaryParser"; +import { parseAsciiFBX } from "./parsers/fbxAsciiParser"; +import { interpretFBX, type FBXModelData, type FBXSceneData, type FBXCameraData, type FBXLightData } from "./interpreter/fbxInterpreter"; +import { type FBXDocument } from "./types/fbxTypes"; +import { type FBXGeometryData } from "./interpreter/geometry"; +import { type FBXMaterialData, type FBXTextureRef } from "./interpreter/materials"; +import { type FBXSkinData, type FBXBoneData } from "./interpreter/skeleton"; +import { type FBXRigData, type FBXSkinBindingData } from "./interpreter/rig"; +import { type FBXBlendShapeData, type FBXShapeData } from "./interpreter/blendShapes"; +import { sampleFBXCurveAtTime, type FBXAnimationStackData, type FBXCurveData, type FBXCurveNodeData } from "./interpreter/animation"; +import { computeFBXGeometricDeltaMatrix, computeFBXGeometricMatrix, computeFBXGeometricNormalMatrix, computeFBXLocalMatrix } from "./interpreter/transform"; +import { FBXFileLoaderMetadata } from "./fbxFileLoader.metadata"; + +const FBX_ASCII_MAGIC = "; FBX"; +const FBX_BINARY_MAGIC = "Kaydara FBX Binary"; +const BIND_REST_SCALE_RATIO_THRESHOLD = 10; + +/** + * Source convention for tangent-space normal maps loaded from FBX normal-map slots. + */ +export type FBXNormalMapCoordinateSystem = "y-up" | "y-down"; + +/** + * Defines options for the FBX loader. + */ +export interface FBXFileLoaderOptions { + /** + * Source convention for tangent-space normal maps connected through FBX normal-map slots. + * FBX does not standardize this convention, so the loader defaults to the glTF/USD-style Y-up convention. + * Set to "y-down" for assets authored with inverted green/Y normal maps. + */ + normalMapCoordinateSystem?: FBXNormalMapCoordinateSystem; +} + +declare module "core/Loading/sceneLoader" { + // eslint-disable-next-line jsdoc/require-jsdoc, @typescript-eslint/naming-convention + export interface SceneLoaderPluginOptions { + /** + * Defines options for the FBX loader. + */ + [FBXFileLoaderMetadata.name]: FBXFileLoaderOptions; + } +} + +interface IFBXSceneLoaderAsyncResult extends ISceneLoaderAsyncResult { + materials: Material[]; + textures: BaseTexture[]; + cameras: Camera[]; +} + +/** + * FBX file loader plugin for Babylon.js. + * Pure TypeScript implementation — no Autodesk FBX SDK dependency. + */ +export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPluginFactory { + public readonly name = FBXFileLoaderMetadata.name; + + public readonly extensions = FBXFileLoaderMetadata.extensions; + + private readonly _options: Required; + private readonly _bindRestBones = new WeakSet(); + private readonly _sourceBonesBySkeleton = new WeakMap(); + private readonly _scaleCompensationHelpersBySkeleton = new WeakMap>(); + + /** + * Creates a new FBX loader. + * @param options - Options controlling FBX loading behavior + */ + public constructor(options: FBXFileLoaderOptions = {}) { + this._options = { + normalMapCoordinateSystem: options.normalMapCoordinateSystem ?? "y-up", + }; + } + + /** + * Creates an FBX loader plugin instance with options from SceneLoader. + * @param options - Scene loader plugin options + * @returns The configured FBX loader + */ + public createPlugin(options: SceneLoaderPluginOptions): ISceneLoaderPluginAsync { + return new FBXFileLoader(options[FBXFileLoaderMetadata.name]); + } + + public async importMeshAsync( + meshesNames: string | readonly string[] | null | undefined, + scene: Scene, + data: unknown, + rootUrl: string, + _onProgress?: (event: ISceneLoaderProgressEvent) => void, + _fileName?: string + ): Promise { + const doc = this._parse(data); + const fbxScene = interpretFBX(doc); + return this._buildScene(fbxScene, scene, rootUrl, meshesNames); + } + + public async loadAsync(scene: Scene, data: unknown, rootUrl: string, _onProgress?: (event: ISceneLoaderProgressEvent) => void, _fileName?: string): Promise { + const doc = this._parse(data); + const fbxScene = interpretFBX(doc); + this._buildScene(fbxScene, scene, rootUrl, null); + } + + public async loadAssetContainerAsync( + scene: Scene, + data: unknown, + rootUrl: string, + _onProgress?: (event: ISceneLoaderProgressEvent) => void, + _fileName?: string + ): Promise { + const doc = this._parse(data); + const fbxScene = interpretFBX(doc); + + const container = new AssetContainer(scene); + + // Build the scene into a temporary holder, then move results to container + const result = this._buildScene(fbxScene, scene, rootUrl, null); + + for (const mesh of result.meshes) { + container.meshes.push(mesh); + } + for (const skeleton of result.skeletons) { + container.skeletons.push(skeleton); + } + for (const ag of result.animationGroups) { + container.animationGroups.push(ag); + } + for (const tn of result.transformNodes) { + container.transformNodes.push(tn); + } + for (const light of result.lights) { + container.lights.push(light); + } + for (const camera of result.cameras) { + container.cameras.push(camera); + } + for (const material of result.materials) { + this._addMaterialToContainer(material, container); + } + for (const texture of result.textures) { + this._addTextureToContainer(texture, container); + } + for (const mesh of result.meshes) { + this._addMaterialToContainer(mesh.material, container); + } + + // Remove all added objects from the scene (container owns them) + this._setAssetContainer(container); + container.removeAllFromScene(); + + return container; + } + + // ── Parsing ──────────────────────────────────────────────────────────── + + private _parse(data: unknown): FBXDocument { + if (data instanceof ArrayBuffer) { + return this._parseFromArrayBuffer(data); + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + const buffer = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; + return this._parseFromArrayBuffer(buffer); + } + if (typeof data === "string") { + return parseAsciiFBX(data); + } + throw new Error("FBXFileLoader: unsupported data type"); + } + + private _parseFromArrayBuffer(buffer: ArrayBuffer): FBXDocument { + // Check magic bytes to determine binary vs ASCII + const headerBytes = new Uint8Array(buffer, 0, Math.min(21, buffer.byteLength)); + const header = String.fromCharCode(...headerBytes); + + if (header.startsWith(FBX_BINARY_MAGIC)) { + return parseBinaryFBX(buffer); + } + + // Try ASCII + const text = new TextDecoder("utf-8").decode(buffer); + if (text.trimStart().startsWith(FBX_ASCII_MAGIC)) { + return parseAsciiFBX(text); + } + + throw new Error("FBXFileLoader: unrecognized FBX format"); + } + + // ── Scene Building ───────────────────────────────────────────────────── + + private _buildScene(fbxScene: FBXSceneData, scene: Scene, rootUrl: string, meshesNames: string | readonly string[] | null | undefined): IFBXSceneLoaderAsyncResult { + const nameFilter = this._buildNameFilter(meshesNames); + + // Create materials + const materialCache = new Map(); + for (const matData of fbxScene.materials) { + const material = this._createMaterial(matData, scene, rootUrl); + materialCache.set(matData.id, material); + } + + // Create one Babylon skeleton per resolved deformation rig. + const skeletons: Skeleton[] = []; + const skeletonByRigId = new Map(); + const skeletonByGeometryId = new Map(); + const skinByGeometryId = new Map(); + const skinBindingByGeometryId = new Map(); + const skinById = new Map(); + + for (const skin of fbxScene.skins) { + skinById.set(skin.id, skin); + } + + for (const rig of fbxScene.rigs) { + const skeleton = this._createSkeleton(rig.id, rig.bones, scene); + skeletons.push(skeleton); + skeletonByRigId.set(rig.id, skeleton); + + for (const binding of rig.skinBindings) { + const skin = skinById.get(binding.skinId); + if (!skin) { + continue; + } + + skeletonByGeometryId.set(binding.geometryId, skeleton); + skinByGeometryId.set(binding.geometryId, skin); + skinBindingByGeometryId.set(binding.geometryId, binding); + } + } + + // Collect model data for animation sampling. + const modelIdToData = new Map(); + const collectModelData = (models: FBXModelData[]) => { + for (const m of models) { + modelIdToData.set(m.id, m); + collectModelData(m.children); + } + }; + collectModelData(fbxScene.rootModels); + const cullingConflictMaterialIds = FBXFileLoader._collectCullingConflictMaterialIds(fbxScene.rootModels); + const cullingMaterialCloneCache = new Map(); + + // Build the FBX hierarchy under the same handedness conversion root that + // Babylon's glTF loader uses when loading right-handed assets into a + // left-handed scene. If the FBX file declares a non-Y-up scene basis, + // add a child axis-conversion root so model/bind math stays in FBX space. + const rootNode = new TransformNode("__fbx_root__", scene); + if (!scene.useRightHandedSystem) { + rootNode.rotation.y = Math.PI; + rootNode.scaling.z = -1; + } + + const meshes: Mesh[] = []; + const transformNodes: TransformNode[] = [rootNode]; + let assetRoot = rootNode; + const axisConversion = FBXFileLoader._computeFBXAxisConversionMatrix(fbxScene); + if (!axisConversion.equals(Matrix.Identity())) { + assetRoot = new TransformNode("__fbx_axis_conversion__", scene); + assetRoot.parent = rootNode; + FBXFileLoader._applyMatrixToTransform(assetRoot, axisConversion); + transformNodes.push(assetRoot); + } + const modelIdToNode = new Map(); + const fbxWorldIdentity = Matrix.Identity(); + + for (const model of fbxScene.rootModels) { + this._buildModel( + model, + scene, + assetRoot, + assetRoot, + fbxWorldIdentity, + materialCache, + nameFilter, + meshes, + transformNodes, + skeletonByGeometryId, + skinByGeometryId, + skinBindingByGeometryId, + modelIdToNode, + cullingConflictMaterialIds, + cullingMaterialCloneCache + ); + } + + // Link non-skinned child meshes/nodes to their parent bones so they + // follow skeletal animation. Preserve their current world matrix when + // switching from the FBX model hierarchy to Babylon's bone parent. + for (const rig of fbxScene.rigs) { + const skeleton = skeletonByRigId.get(rig.id); + if (!skeleton) { + continue; + } + + const boneModelIds = new Set(rig.bones.map((b) => b.modelId)); + const skinnedMesh = meshes.find((m) => m.skeleton === skeleton) ?? null; + const boneReferenceNode = skinnedMesh ?? rootNode; + + for (const boneData of rig.bones) { + if (!boneData.isCluster) { + continue; + } + + const boneNode = modelIdToNode.get(boneData.modelId); + const bone = this._getSourceBone(skeleton, boneData.index); + if (!boneNode || !bone) { + continue; + } + + // Find direct children of this bone's TransformNode that aren't bones themselves + for (const child of [...boneNode.getChildren()]) { + const childTransform = child as TransformNode; + // Check if this child is itself a bone — if so, skip it + let childIsBone = false; + for (const [modelId, node] of Array.from(modelIdToNode)) { + if (node === childTransform && boneModelIds.has(modelId)) { + childIsBone = true; + break; + } + } + if (!childIsBone) { + const childWorld = childTransform.computeWorldMatrix(true).clone(); + const boneReferenceWorld = FBXFileLoader._getBoneReferenceWorldMatrix(skeleton, bone, boneReferenceNode, skinnedMesh); + const boneReferenceWorldInv = new Matrix(); + boneReferenceWorld.invertToRef(boneReferenceWorldInv); + const childLocalToBone = childWorld.multiply(boneReferenceWorldInv); + + childTransform.parent = null; + childTransform.attachToBone(bone, boneReferenceNode); + FBXFileLoader._applyMatrixToTransform(childTransform, childLocalToBone); + } + } + } + } + + // Apply blend shapes (morph targets) to meshes + if (fbxScene.blendShapes.length > 0) { + this._applyBlendShapes(fbxScene.blendShapes, meshes, scene, fbxScene.unitScaleFactor); + } + + // Create animation groups + const animationGroups: AnimationGroup[] = []; + for (const animStack of fbxScene.animations) { + const group = this._createAnimationGroup(animStack, fbxScene.rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes); + if (group) { + animationGroups.push(group); + } + } + + // Create cameras + const cameras: FreeCamera[] = []; + for (const camData of fbxScene.cameras) { + const cam = this._createCamera(camData, modelIdToNode, scene); + if (cam) { + cameras.push(cam); + } + } + + // Create lights + const sceneLights: (PointLight | DirectionalLight | SpotLight)[] = []; + for (const lightData of fbxScene.lights) { + const light = this._createLight(lightData, modelIdToNode, scene); + if (light) { + sceneLights.push(light); + } + } + + return { + meshes, + particleSystems: [], + skeletons, + animationGroups, + transformNodes, + geometries: [], + lights: sceneLights, + spriteManagers: [], + materials: Array.from(materialCache.values()), + textures: Array.from(new Set(Array.from(materialCache.values()).flatMap((material) => material.getActiveTextures()))), + cameras, + }; + } + + private _addMaterialToContainer(material: Nullable, container: AssetContainer): void { + if (!material) { + return; + } + + if (material instanceof MultiMaterial) { + if (!container.multiMaterials.includes(material)) { + container.multiMaterials.push(material); + } + for (const subMaterial of material.subMaterials) { + this._addMaterialToContainer(subMaterial, container); + } + } else if (!container.materials.includes(material)) { + container.materials.push(material); + } + + for (const texture of material.getActiveTextures()) { + this._addTextureToContainer(texture, container); + } + } + + private _addTextureToContainer(texture: BaseTexture, container: AssetContainer): void { + if (!container.textures.includes(texture)) { + container.textures.push(texture); + } + } + + private _setAssetContainer(container: AssetContainer): void { + for (const asset of container.meshes) { + asset._parentContainer = container; + } + for (const asset of container.transformNodes) { + asset._parentContainer = container; + } + for (const asset of container.skeletons) { + asset._parentContainer = container; + } + for (const asset of container.animationGroups) { + asset._parentContainer = container; + } + for (const asset of container.lights) { + asset._parentContainer = container; + } + for (const asset of container.cameras) { + asset._parentContainer = container; + } + for (const asset of container.materials) { + asset._parentContainer = container; + } + for (const asset of container.multiMaterials) { + asset._parentContainer = container; + } + for (const asset of container.textures) { + asset._parentContainer = container; + } + } + + private static _computeFBXAxisConversionMatrix(fbxScene: FBXSceneData): Matrix { + const basisRows: [number, number, number][] = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ]; + + const assignAxis = (sourceAxis: number, sourceSign: number, targetAxis: number): void => { + if (sourceAxis < 0 || sourceAxis > 2) { + return; + } + const row: [number, number, number] = [0, 0, 0]; + row[targetAxis] = sourceSign >= 0 ? 1 : -1; + basisRows[sourceAxis] = row; + }; + + assignAxis(fbxScene.coordAxis, fbxScene.coordAxisSign, 0); + assignAxis(fbxScene.upAxis, fbxScene.upAxisSign, 1); + assignAxis(fbxScene.frontAxis, fbxScene.frontAxisSign, 2); + + if (basisRows.some((row) => row.every((value) => value === 0))) { + return Matrix.Identity(); + } + + return Matrix.FromValues( + basisRows[0][0], + basisRows[0][1], + basisRows[0][2], + 0, + basisRows[1][0], + basisRows[1][1], + basisRows[1][2], + 0, + basisRows[2][0], + basisRows[2][1], + basisRows[2][2], + 0, + 0, + 0, + 0, + 1 + ); + } + + private _buildModel( + model: FBXModelData, + scene: Scene, + parent: Nullable, + assetRoot: TransformNode, + parentFBXWorldMatrix: Matrix, + materialCache: Map, + nameFilter: ((name: string) => boolean) | null, + meshes: Mesh[], + transformNodes: TransformNode[], + skeletonByGeometryId: Map, + skinByGeometryId: Map, + skinBindingByGeometryId: Map, + modelIdToNode: Map, + cullingConflictMaterialIds: Set, + cullingMaterialCloneCache: Map + ): void { + const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model); + const fbxWorldMatrix = localMatrix.multiply(parentFBXWorldMatrix); + + if (model.geometry && model.subType === "Mesh") { + // Create mesh + if (nameFilter && !nameFilter(model.name)) { + return; + } + + const skeleton = skeletonByGeometryId.get(model.geometry.id); + const skin = skinByGeometryId.get(model.geometry.id); + const skinBinding = skinBindingByGeometryId.get(model.geometry.id); + + if (skeleton && skin) { + skeleton.needInitialSkinMatrix = true; + } + + const mesh = this._createMesh(model, model.geometry, scene, skeleton, skin, skinBinding); + + // For skinned meshes: keep bind/pose math in FBX space, but parent + // the rendered mesh under the same conversion root as non-skinned + // meshes. The pose matrix cancels the real FBX mesh transform only; + // the root handedness conversion remains applied once at render time. + if (skeleton && skin) { + const meshBindMatrix = skin.meshBindPoseMatrix ? Matrix.FromArray(skin.meshBindPoseMatrix) : fbxWorldMatrix; + mesh.parent = assetRoot; + FBXFileLoader._applyMatrixToTransform(mesh, meshBindMatrix); + mesh.computeWorldMatrix(true); + mesh.updatePoseMatrix(Matrix.Invert(meshBindMatrix)); + mesh.alwaysSelectAsActiveMesh = true; + } else { + if (parent) { + mesh.parent = parent; + } + FBXFileLoader._applyFBXTransform(mesh, model); + } + + // Apply material(s) + if (model.materials.length > 1 && model.geometry?.materialIndices) { + // Multi-material: create sub-meshes for each material + this._applyMultiMaterial(mesh, model, materialCache, scene, cullingConflictMaterialIds, cullingMaterialCloneCache); + } else if (model.materials.length > 0) { + const mat = materialCache.get(model.materials[0].id); + if (mat) { + mesh.material = FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(model.materials[0].id)); + } + } + + if (model.geometry?.colors) { + this._useUnmodulatedVertexColorMaterials(mesh, scene); + } + this._applyMaterialUVSetCoordinates(mesh.material, model.geometry); + + meshes.push(mesh); + modelIdToNode.set(model.id, mesh); + + FBXFileLoader._applyModelMetadata(mesh, model); + + // Recurse children + for (const child of model.children) { + this._buildModel( + child, + scene, + mesh, + assetRoot, + fbxWorldMatrix, + materialCache, + nameFilter, + meshes, + transformNodes, + skeletonByGeometryId, + skinByGeometryId, + skinBindingByGeometryId, + modelIdToNode, + cullingConflictMaterialIds, + cullingMaterialCloneCache + ); + } + } else { + // Transform node (Null type or no geometry) + const transformNode = new TransformNode(model.name, scene); + if (parent) { + transformNode.parent = parent; + } + + // Apply full FBX transform chain + FBXFileLoader._applyFBXTransform(transformNode, model); + + transformNodes.push(transformNode); + modelIdToNode.set(model.id, transformNode); + + FBXFileLoader._applyModelMetadata(transformNode, model); + + // Recurse children + for (const child of model.children) { + this._buildModel( + child, + scene, + transformNode, + assetRoot, + fbxWorldMatrix, + materialCache, + nameFilter, + meshes, + transformNodes, + skeletonByGeometryId, + skinByGeometryId, + skinBindingByGeometryId, + modelIdToNode, + cullingConflictMaterialIds, + cullingMaterialCloneCache + ); + } + } + } + + private static _applyModelMetadata(node: TransformNode | Mesh, model: FBXModelData): void { + if (!model.customProperties && model.diagnostics.length === 0) { + return; + } + + node.metadata = { + ...((node.metadata as object) ?? {}), + ...(model.customProperties ? { fbxCustomProperties: model.customProperties } : {}), + ...(model.diagnostics.length > 0 ? { fbxDiagnostics: model.diagnostics } : {}), + }; + } + + private _createMesh(model: FBXModelData, geomData: FBXGeometryData, scene: Scene, skeleton?: Skeleton, skin?: FBXSkinData, skinBinding?: FBXSkinBindingData): Mesh { + const mesh = new Mesh(model.name, scene); + mesh.sideOrientation = scene.useRightHandedSystem ? Material.CounterClockWiseSideOrientation : Material.ClockWiseSideOrientation; + const vertexData = new VertexData(); + + // Convert Float64Array to Float32Array for Babylon + const positions = float64To32(geomData.positions); + + const gt = model.geometricTranslation; + const gr = model.geometricRotation; + const gs = model.geometricScaling; + + // Geometric transforms affect only this mesh's geometry, not children. + // Blender composes them as T * R * S; Babylon's row-vector equivalent is S * R * T. + const geometricPositionMatrix = FBXFileLoader._computeFBXGeometricMatrix(gt, gr, gs); + const geometricDeltaMatrix = FBXFileLoader._computeFBXGeometricDeltaMatrix(gr, gs); + const geometricNormalMatrix = FBXFileLoader._computeFBXGeometricNormalMatrix(gr, gs); + const hasGeometricPositionTransform = !geometricPositionMatrix.equals(Matrix.Identity()); + const hasGeometricDeltaTransform = !geometricDeltaMatrix.equals(Matrix.Identity()); + const hasGeometricNormalTransform = !geometricNormalMatrix.equals(Matrix.Identity()); + + if (hasGeometricPositionTransform) { + for (let i = 0; i < positions.length; i += 3) { + const v = Vector3.TransformCoordinates(new Vector3(positions[i], positions[i + 1], positions[i + 2]), geometricPositionMatrix); + positions[i] = v.x; + positions[i + 1] = v.y; + positions[i + 2] = v.z; + } + } + + // For skinned meshes: do NOT bake mesh local transform into vertices. + // Vertices remain in their original mesh-local space, keeping the mesh data + // clean for retargeting. The mesh node carries its FBX transform as an + // initial pose, while TransformLink bind matrices handle skinning. + + vertexData.positions = positions; + vertexData.indices = Array.from(geomData.indices); + + let normals: Float32Array | undefined; + if (geomData.normals) { + normals = float64To32(geomData.normals); + if (hasGeometricNormalTransform) { + for (let i = 0; i < normals.length; i += 3) { + const n = Vector3.TransformNormal(new Vector3(normals[i], normals[i + 1], normals[i + 2]), geometricNormalMatrix); + if (n.lengthSquared() > 0) { + n.normalize(); + } + normals[i] = n.x; + normals[i + 1] = n.y; + normals[i + 2] = n.z; + } + } + vertexData.normals = normals; + } + + if (geomData.uvs) { + vertexData.uvs = float64To32(geomData.uvs); + } + if (geomData.uvSets.length > 1) { + vertexData.uvs2 = float64To32(geomData.uvSets[1].data); + } + if (geomData.uvSets.length > 2) { + vertexData.uvs3 = float64To32(geomData.uvSets[2].data); + } + if (geomData.uvSets.length > 3) { + vertexData.uvs4 = float64To32(geomData.uvSets[3].data); + } + if (geomData.uvSets.length > 4) { + vertexData.uvs5 = float64To32(geomData.uvSets[4].data); + } + if (geomData.uvSets.length > 5) { + vertexData.uvs6 = float64To32(geomData.uvSets[5].data); + } + + if (geomData.tangents) { + const tangents = float64To32(geomData.tangents); + if (hasGeometricNormalTransform) { + for (let i = 0; i < tangents.length; i += 4) { + const t = Vector3.TransformNormal(new Vector3(tangents[i], tangents[i + 1], tangents[i + 2]), geometricNormalMatrix); + if (t.lengthSquared() > 0) { + t.normalize(); + } + tangents[i] = t.x; + tangents[i + 1] = t.y; + tangents[i + 2] = t.z; + } + } + applyTangentHandednessScale(tangents, this._getNormalMapTangentHandednessScale()); + vertexData.tangents = tangents; + } else if (normals && vertexData.uvs) { + vertexData.tangents = generateTangents( + positions, + normals, + vertexData.uvs, + geomData.indices, + this._getNormalMapTangentHandednessScale(), + geomData.controlPointIndices, + geomData.materialIndices + ); + } + + if (geomData.colors) { + // Force alpha to 1.0 — FBX vertex color alpha is often unreliable + // (e.g. zeroed out by exporters) and would cause transparency sorting issues. + const colors = new Float32Array(geomData.colors.length); + for (let i = 0; i < colors.length; i += 4) { + colors[i] = geomData.colors[i]; + colors[i + 1] = geomData.colors[i + 1]; + colors[i + 2] = geomData.colors[i + 2]; + colors[i + 3] = 1.0; + } + vertexData.colors = colors; + mesh.hasVertexAlpha = false; + } + + // Apply bone weights if we have a skin + if (skeleton && skin) { + const { matricesIndices, matricesWeights, matricesIndicesExtra, matricesWeightsExtra, numBoneInfluencers } = this._buildSkinningData(geomData, skin, skinBinding); + vertexData.matricesIndices = matricesIndices; + vertexData.matricesWeights = matricesWeights; + if (matricesIndicesExtra && matricesWeightsExtra) { + vertexData.matricesIndicesExtra = matricesIndicesExtra; + vertexData.matricesWeightsExtra = matricesWeightsExtra; + } + mesh.numBoneInfluencers = numBoneInfluencers; + } + + vertexData.applyToMesh(mesh); + + // Store geometry metadata for blend shape matching + mesh.metadata = { + ...((mesh.metadata as object) ?? {}), + fbxGeometryId: geomData.id, + fbxControlPointIndices: geomData.controlPointIndices, + fbxGeometryDeltaMatrix: hasGeometricDeltaTransform ? geometricDeltaMatrix : null, + fbxGeometryNormalMatrix: hasGeometricNormalTransform ? geometricNormalMatrix : null, + // Back-compat for existing morph delta handling metadata. + fbxPreRotMatrix: hasGeometricDeltaTransform ? geometricDeltaMatrix : null, + }; + + if (skeleton) { + mesh.skeleton = skeleton; + } + + return mesh; + } + + /** + * Apply multi-material to a mesh by creating sub-meshes grouped by material index. + * Reorders the index buffer so that triangles sharing the same material are contiguous. + */ + private _applyMultiMaterial( + mesh: Mesh, + model: FBXModelData, + materialCache: Map, + scene: Scene, + cullingConflictMaterialIds: Set, + cullingMaterialCloneCache: Map + ): void { + const matIndices = model.geometry!.materialIndices!; + const indices = mesh.getIndices(); + if (!indices) { + return; + } + + const triCount = indices.length / 3; + + // Group triangles by material index + const groups = new Map(); // matIdx -> triangle indices + for (let ti = 0; ti < triCount; ti++) { + const matIdx = ti < matIndices.length ? matIndices[ti] : 0; + let group = groups.get(matIdx); + if (!group) { + group = []; + groups.set(matIdx, group); + } + group.push(ti); + } + + // Sort group keys to ensure consistent ordering + const sortedMatIndices = Array.from(groups.keys()).sort((a, b) => a - b); + + // Reorder index buffer so triangles are grouped by material + const newIndices: number[] = []; + const subMeshRanges: { start: number; count: number; matIdx: number }[] = []; + + for (const matIdx of sortedMatIndices) { + const tris = groups.get(matIdx)!; + const start = newIndices.length; + for (const ti of tris) { + newIndices.push(indices[ti * 3], indices[ti * 3 + 1], indices[ti * 3 + 2]); + } + subMeshRanges.push({ start, count: tris.length * 3, matIdx }); + } + + // Update the mesh's index buffer + mesh.setIndices(newIndices); + + // Create MultiMaterial + const multiMat = new MultiMaterial(model.name + "_multi", scene); + for (const range of subMeshRanges) { + const fbxMat = model.materials[range.matIdx]; + if (fbxMat) { + const mat = materialCache.get(fbxMat.id); + if (mat) { + multiMat.subMaterials.push(FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(fbxMat.id))); + } else { + multiMat.subMaterials.push(null); + } + } else { + multiMat.subMaterials.push(null); + } + } + + mesh.material = multiMat; + + // Clear existing sub-meshes and create new ones + mesh.subMeshes = []; + const vertexCount = mesh.getTotalVertices(); + for (let i = 0; i < subMeshRanges.length; i++) { + const range = subMeshRanges[i]; + new SubMesh(i, 0, vertexCount, range.start, range.count, mesh); + } + } + + private static _collectCullingConflictMaterialIds(models: FBXModelData[]): Set { + // Deliberately scan the full scene, not just name-filtered models. This + // can over-clone for filtered imports, but avoids shared culling state. + const usage = new Map(); + const collect = (model: FBXModelData): void => { + for (const material of model.materials) { + const state = usage.get(material.id) ?? { cullingOff: false, cullingOn: false }; + if (model.cullingOff) { + state.cullingOff = true; + } else { + state.cullingOn = true; + } + usage.set(material.id, state); + } + for (const child of model.children) { + collect(child); + } + }; + for (const model of models) { + collect(model); + } + + const conflicts = new Set(); + for (const [materialId, state] of Array.from(usage)) { + if (state.cullingOff && state.cullingOn) { + conflicts.add(materialId); + } + } + return conflicts; + } + + private static _getModelMaterial( + material: StandardMaterial, + model: FBXModelData, + cullingCloneCache?: Map, + cloneCullingOffMaterial = true + ): StandardMaterial { + if (!model.cullingOff || !material.backFaceCulling) { + return material; + } + if (!cloneCullingOffMaterial) { + material.backFaceCulling = false; + return material; + } + + const cached = cullingCloneCache?.get(material); + if (cached) { + return cached; + } + + const clone = material.clone(`${material.name}_CullingOff`); + clone.backFaceCulling = false; + cullingCloneCache?.set(material, clone); + return clone; + } + + private _applyMaterialUVSetCoordinates(material: unknown, geometry: FBXGeometryData): void { + if (!material) { + return; + } + if (material instanceof MultiMaterial) { + for (const subMaterial of material.subMaterials) { + if (subMaterial instanceof StandardMaterial) { + this._applyStandardMaterialUVSetCoordinates(subMaterial, geometry); + } + } + return; + } + if (material instanceof StandardMaterial) { + this._applyStandardMaterialUVSetCoordinates(material, geometry); + } + } + + private _applyStandardMaterialUVSetCoordinates(material: StandardMaterial, geometry: FBXGeometryData): void { + for (const texture of [ + material.diffuseTexture, + material.bumpTexture, + material.emissiveTexture, + material.ambientTexture, + material.specularTexture, + material.opacityTexture, + material.reflectionTexture, + ]) { + if (!texture) { + continue; + } + + const uvSetName = (texture.metadata as { fbxUVSetName?: string } | null | undefined)?.fbxUVSetName; + if (!uvSetName) { + continue; + } + + const uvSetIndex = geometry.uvSets.findIndex((uvSet) => uvSet.name === uvSetName); + if (uvSetIndex >= 0) { + texture.coordinatesIndex = uvSetIndex; + } + } + } + + /** + * Babylon multiplies vertex colors by material diffuse color. Use per-mesh + * material clones so vertex-colored geometry can render unmodulated without + * changing shared materials used by non-vertex-colored meshes. + */ + private _useUnmodulatedVertexColorMaterials(mesh: Mesh, scene: Scene): void { + const assignedMat = mesh.material; + if (!assignedMat) { + return; + } + + if (assignedMat instanceof StandardMaterial) { + if (!assignedMat.diffuseTexture) { + const clone = assignedMat.clone(`${assignedMat.name}_VertexColor`); + clone.diffuseColor = new Color3(1, 1, 1); + mesh.material = clone; + } + return; + } + + if (assignedMat instanceof MultiMaterial) { + const multiMat = new MultiMaterial(`${assignedMat.name}_VertexColor`, scene); + multiMat.subMaterials = assignedMat.subMaterials.map((sub) => { + if (sub instanceof StandardMaterial && !sub.diffuseTexture) { + const clone = sub.clone(`${sub.name}_VertexColor`); + clone.diffuseColor = new Color3(1, 1, 1); + return clone; + } + return sub; + }); + mesh.material = multiMat; + } + } + + /** + * Build per-polygon-vertex bone indices and weights from the control-point-based skin data. + * The geometry expands control points to per-polygon-vertex, so we need to look up + * each polygon-vertex's control point index. + */ + private _buildSkinningData( + geomData: FBXGeometryData, + skin: FBXSkinData, + skinBinding?: FBXSkinBindingData + ): { + matricesIndices: Float32Array; + matricesWeights: Float32Array; + matricesIndicesExtra: Float32Array | null; + matricesWeightsExtra: Float32Array | null; + numBoneInfluencers: number; + } { + // The positions array is per-polygon-vertex (already expanded). + // We need to figure out the control point index for each polygon vertex. + // The geometry stores positions per polygon-vertex, so geomData.positions.length/3 + // = number of polygon vertices. We stored control point indices during expansion, + // but they aren't exported. Instead, we can use the fact that skin data is indexed + // by control point, and the geometry's _controlPointIndices stores this mapping. + // + // Since we don't have direct access to the control point mapping from FBXGeometryData, + // we'll use the vertex positions to build the skinning buffer. But actually, + // we should extend geometry to export control point indices per polygon-vertex. + // + // For now, use the approach of matching positions to control points. + // Actually, let's look at this differently - the indices/weights in the skin + // are per control point. The geometry already expanded to per polygon-vertex + // with positions copied from control points. We need to know which control point + // each polygon-vertex came from. + // + // We'll use geomData.controlPointIndices if available. + const vertexCount = geomData.positions.length / 3; + const matricesIndices = new Float32Array(vertexCount * 4); + const matricesWeights = new Float32Array(vertexCount * 4); + let matricesIndicesExtra: Float32Array | null = null; + let matricesWeightsExtra: Float32Array | null = null; + let numBoneInfluencers = 0; + + if (geomData.controlPointIndices) { + for (let i = 0; i < vertexCount; i++) { + const cpIdx = geomData.controlPointIndices[i]; + const boneIdx = skin.boneIndices[cpIdx] ?? []; + numBoneInfluencers = Math.max(numBoneInfluencers, Math.min(boneIdx.length, 8)); + } + + if (numBoneInfluencers > 4) { + matricesIndicesExtra = new Float32Array(vertexCount * 4); + matricesWeightsExtra = new Float32Array(vertexCount * 4); + } + + for (let i = 0; i < vertexCount; i++) { + const cpIdx = geomData.controlPointIndices[i]; + const boneIdx = skin.boneIndices[cpIdx] ?? []; + const boneWts = skin.boneWeights[cpIdx] ?? []; + + for (let j = 0; j < 8; j++) { + const indicesBuffer = j < 4 ? matricesIndices : matricesIndicesExtra; + const weightsBuffer = j < 4 ? matricesWeights : matricesWeightsExtra; + if (!indicesBuffer || !weightsBuffer) { + continue; + } + + const bufferIndex = i * 4 + (j % 4); + if (j < boneIdx.length) { + const skinBoneIndex = boneIdx[j]; + const rigBoneIndex = skinBinding ? skinBinding.skinBoneIndexToRigBoneIndex[skinBoneIndex] : skinBoneIndex; + if (rigBoneIndex === undefined || rigBoneIndex < 0) { + throw new Error(`FBXFileLoader: missing rig bone mapping for skin bone index ${skinBoneIndex}`); + } + indicesBuffer[bufferIndex] = rigBoneIndex; + } else { + indicesBuffer[bufferIndex] = 0; + } + weightsBuffer[bufferIndex] = j < boneWts.length ? boneWts[j] : 0; + } + } + } + + return { + matricesIndices, + matricesWeights, + matricesIndicesExtra, + matricesWeightsExtra, + numBoneInfluencers: Math.max(numBoneInfluencers, 1), + }; + } + + private _createMaterial(matData: FBXMaterialData, scene: Scene, rootUrl: string): StandardMaterial { + const material = new StandardMaterial(matData.name, scene); + + const props = matData.properties; + const hasTexture = (...slots: string[]): boolean => matData.textures.some((texture) => slots.includes(texture.propertyName)); + + if (matData.type === "Lambert") { + material.specularColor = Color3.Black(); + } + + if (props.diffuseColor) { + const diffuseFactor = hasTexture("DiffuseColor", "Diffuse") ? 1 : (props.diffuseFactor ?? 1); + material.diffuseColor = new Color3(props.diffuseColor[0] * diffuseFactor, props.diffuseColor[1] * diffuseFactor, props.diffuseColor[2] * diffuseFactor); + } + + if (props.ambientColor) { + const ambientFactor = hasTexture("AmbientColor", "Ambient") ? 1 : (props.ambientFactor ?? 1); + material.ambientColor = new Color3(props.ambientColor[0] * ambientFactor, props.ambientColor[1] * ambientFactor, props.ambientColor[2] * ambientFactor); + } + + if (matData.type === "Phong" && props.specularColor) { + const specularFactor = hasTexture("SpecularColor", "Specular", "Shininess", "ShininessExponent") ? 1 : (props.specularFactor ?? 1); + material.specularColor = new Color3(props.specularColor[0] * specularFactor, props.specularColor[1] * specularFactor, props.specularColor[2] * specularFactor); + } + + if (props.emissiveColor) { + const emissiveFactor = hasTexture("EmissiveColor", "Emissive") ? 1 : (props.emissiveFactor ?? 1); + material.emissiveColor = new Color3(props.emissiveColor[0] * emissiveFactor, props.emissiveColor[1] * emissiveFactor, props.emissiveColor[2] * emissiveFactor); + } + + if (props.opacity !== undefined) { + material.alpha = props.opacity; + } else if (props.transparencyFactor !== undefined) { + material.alpha = 1 - props.transparencyFactor; + } + + if (material.alpha < 1) { + material.transparencyMode = Material.MATERIAL_ALPHABLEND; + } + + if (props.shininess !== undefined) { + material.specularPower = props.shininess; + } + + // Apply textures + for (const tex of matData.textures) { + if (!FBXFileLoader._isSupportedMaterialTextureSlot(tex.propertyName)) { + continue; + } + + const texture = FBXFileLoader._createTexture(tex, scene, rootUrl, FBXFileLoader._isNormalMapTextureSlot(tex.propertyName)); + if (!texture) { + continue; + } + + switch (tex.propertyName) { + case "DiffuseColor": + material.diffuseTexture = texture; + // In FBX, a connected diffuse texture provides the color. + // Set diffuseColor to white so the texture isn't darkened by + // the material's base color (many FBX exports set it near-black). + material.diffuseColor = new Color3(1, 1, 1); + break; + case "NormalMap": + case "NormalMapTexture": + case "normalCamera": + material.bumpTexture = texture; + this._configureNormalTexture(texture, material); + break; + case "Bump": + case "BumpFactor": + material.bumpTexture = texture; + this._configureNormalTexture(texture, material); + break; + case "EmissiveColor": + material.emissiveTexture = texture; + break; + case "AmbientColor": + material.ambientTexture = texture; + break; + case "SpecularColor": + material.specularTexture = texture; + break; + case "TransparencyFactor": + case "TransparentColor": + material.opacityTexture = texture; + material.transparencyMode = Material.MATERIAL_ALPHATESTANDBLEND; + break; + case "ReflectionColor": + case "ReflectionFactor": + material.reflectionTexture = texture; + break; + case "DisplacementColor": + case "Displacement": + case "DisplacementFactor": + // StandardMaterial doesn't have a displacement slot natively; + // store for potential PBR conversion use + break; + case "ShininessExponent": + case "Shininess": + // Shininess map — no direct StandardMaterial slot + break; + } + + // Apply UV transforms + if (tex.uvTranslation) { + texture.uOffset = tex.uvTranslation[0]; + texture.vOffset = tex.uvTranslation[1]; + } + if (tex.uvScaling) { + texture.uScale = tex.uvScaling[0]; + texture.vScale = tex.uvScaling[1]; + } + if (tex.uvRotation !== undefined) { + texture.wAng = tex.uvRotation * (Math.PI / 180); + } + if (tex.uvSetIndex !== undefined) { + texture.coordinatesIndex = tex.uvSetIndex; + } + if (tex.uvSetName) { + texture.metadata = { + ...((texture.metadata as object) ?? {}), + fbxUVSetName: tex.uvSetName, + }; + } + } + + return material; + } + + private _configureNormalTexture(texture: Texture, material: StandardMaterial): void { + texture.gammaSpace = false; + material.invertNormalMapX = false; + material.invertNormalMapY = this._options.normalMapCoordinateSystem === "y-down"; + } + + private _getNormalMapTangentHandednessScale(): 1 | -1 { + return this._options.normalMapCoordinateSystem === "y-down" ? -1 : 1; + } + + private static _isSupportedMaterialTextureSlot(propertyName: string): boolean { + switch (propertyName) { + case "DiffuseColor": + case "NormalMap": + case "NormalMapTexture": + case "normalCamera": + case "Bump": + case "BumpFactor": + case "EmissiveColor": + case "AmbientColor": + case "SpecularColor": + case "TransparencyFactor": + case "TransparentColor": + case "ReflectionColor": + case "ReflectionFactor": + case "DisplacementColor": + case "Displacement": + case "DisplacementFactor": + case "ShininessExponent": + case "Shininess": + return true; + default: + return false; + } + } + + private static _isNormalMapTextureSlot(propertyName: string): boolean { + switch (propertyName) { + case "NormalMap": + case "NormalMapTexture": + case "normalCamera": + case "Bump": + case "BumpFactor": + return true; + default: + return false; + } + } + + private static _createTexture(tex: FBXTextureRef, scene: Scene, rootUrl: string, isDataTexture: boolean): Nullable { + const sourceName = FBXFileLoader._getTextureSourceName(tex); + const creationOptions = FBXFileLoader._getTextureCreationOptions(sourceName, isDataTexture, tex.embeddedData); + + if (tex.embeddedData) { + const texture = new Texture(null, scene, creationOptions); + const embeddedTextureName = sourceName ?? `embeddedTexture_${tex.id.toString()}`; + texture.updateURL(`data:fbx-embedded-texture/${encodeURIComponent(embeddedTextureName)}`, new Uint8Array(tex.embeddedData), undefined, creationOptions.forcedExtension); + texture.name = embeddedTextureName; + return texture; + } + + const textureUrls = FBXFileLoader._getExternalTextureUrls(tex, rootUrl); + const textureUrl = textureUrls.shift(); + if (!textureUrl) { + return null; + } + + return FBXFileLoader._createExternalTexture(textureUrl, textureUrls, scene, creationOptions); + } + + private static _createExternalTexture(texturePath: string, fallbackUrls: string[], scene: Scene, creationOptions: ITextureCreationOptions): Texture { + fallbackUrls.push(...FBXFileLoader._buildTextureFallbackUrls(texturePath)); + let fallbackIndex = 0; + const texture = new Texture(texturePath, scene, { + ...creationOptions, + onError: () => { + const fallbackUrl = fallbackUrls[fallbackIndex++]; + if (fallbackUrl && texture.getScene()) { + texture.updateURL(fallbackUrl, null, undefined, FBXFileLoader._getForcedExtension(fallbackUrl)); + } + }, + }); + return texture; + } + + private static _buildTextureFallbackUrls(texturePath: string): string[] { + const slashIndex = Math.max(texturePath.lastIndexOf("/"), texturePath.lastIndexOf("\\")); + const dotIndex = texturePath.lastIndexOf("."); + if (dotIndex <= slashIndex) { + return []; + } + + const basePath = texturePath.slice(0, dotIndex); + const currentExtension = texturePath.slice(dotIndex + 1).toLowerCase(); + const extensionFallbacks = ["png", "jpg", "jpeg", "webp", "bmp", "tga"]; + return extensionFallbacks.filter((extension) => extension !== currentExtension).map((extension) => `${basePath}.${extension}`); + } + + private static _getTextureCreationOptions(sourceName: Nullable, isDataTexture: boolean, embeddedData: Nullable): ITextureCreationOptions { + const mimeType = embeddedData ? (sourceName ? FBXFileLoader._getMimeType(sourceName) : "image/png") : undefined; + return { + buffer: embeddedData ? new Uint8Array(embeddedData) : undefined, + forcedExtension: sourceName ? FBXFileLoader._getForcedExtension(sourceName, mimeType) : embeddedData ? ".png" : undefined, + gammaSpace: !isDataTexture, + mimeType, + }; + } + + private static _getExternalTextureUrls(tex: FBXTextureRef, rootUrl: string): string[] { + const textureNames = [tex.relativeFileName, tex.fileName].filter((name): name is string => !!name); + const urls: string[] = []; + + for (const textureName of textureNames) { + const normalized = textureName.replace(/\\/g, "/"); + if (FBXFileLoader._isSafeRelativeTexturePath(normalized)) { + urls.push(rootUrl + normalized); + } + + const basename = FBXFileLoader._getTextureSourceNameFromPath(normalized); + if (basename) { + urls.push(rootUrl + basename); + } + } + + return Array.from(new Set(urls)); + } + + private static _getTextureSourceName(tex: FBXTextureRef): Nullable { + const textureName = tex.relativeFileName || tex.fileName; + if (!textureName) { + return null; + } + const normalized = textureName.replace(/\\/g, "/"); + return FBXFileLoader._getTextureSourceNameFromPath(normalized); + } + + private static _getTextureSourceNameFromPath(texturePath: string): Nullable { + return texturePath.split("/").pop() ?? texturePath; + } + + private static _isSafeRelativeTexturePath(texturePath: string): boolean { + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(texturePath) || texturePath.startsWith("/") || texturePath.startsWith("//")) { + return false; + } + + return !texturePath.split("/").some((part) => part === ".."); + } + + private static _getForcedExtension(fileName: string, mimeType?: string): string | undefined { + const slashIndex = Math.max(fileName.lastIndexOf("/"), fileName.lastIndexOf("\\")); + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex > slashIndex) { + return fileName.slice(dotIndex).toLowerCase(); + } + + switch (mimeType) { + case "image/png": + return ".png"; + case "image/jpeg": + return ".jpg"; + case "image/webp": + return ".webp"; + case "image/bmp": + return ".bmp"; + case "image/gif": + return ".gif"; + case "image/x-tga": + return ".tga"; + default: + return undefined; + } + } + + private static _getMimeType(fileName: string): string { + const mimeType = GetMimeType(fileName); + if (mimeType) { + return mimeType; + } + + const extension = FBXFileLoader._getForcedExtension(fileName); + switch (extension) { + case ".tga": + return "image/x-tga"; + case ".bmp": + return "image/bmp"; + case ".gif": + return "image/gif"; + default: + return "image/png"; + } + } + + /** + * Apply blend shape (morph target) deformers to meshes. + * FBX Shape vertices are stored as absolute positions for sparse control points. + * We compute deltas relative to the base mesh positions. + */ + private _applyBlendShapes(blendShapes: FBXBlendShapeData[], meshes: Mesh[], scene: Scene, unitScaleFactor: number): void { + // Build a map from geometry ID to mesh (using the mesh metadata we'll need to store) + // The mesh's geometry ID is tracked through the model hierarchy during _buildModel. + // We need to match blendShape.geometryId to the correct mesh. + // Strategy: match by examining which meshes have positions matching the geometry. + + for (const bs of blendShapes) { + // Find the mesh that uses this geometry + const mesh = meshes.find((m) => { + const geomId = (m.metadata as { fbxGeometryId?: bigint } | undefined)?.fbxGeometryId; + return geomId === bs.geometryId; + }); + if (!mesh) { + continue; + } + + const morphTargetManager = new MorphTargetManager(scene); + morphTargetManager.optimizeInfluencers = false; + // Get preRotation matrix if the mesh had its positions baked + const deltaMatrix = + (mesh.metadata as { fbxGeometryDeltaMatrix?: Matrix | null; fbxPreRotMatrix?: Matrix | null } | undefined)?.fbxGeometryDeltaMatrix ?? + (mesh.metadata as { fbxPreRotMatrix?: Matrix | null } | undefined)?.fbxPreRotMatrix ?? + null; + const normalMatrix = (mesh.metadata as { fbxGeometryNormalMatrix?: Matrix | null } | undefined)?.fbxGeometryNormalMatrix ?? deltaMatrix; + + for (const channel of bs.channels) { + // Get the control point indices for this mesh (stored as metadata) + const cpIndices = (mesh.metadata as { fbxControlPointIndices?: Uint32Array } | undefined)?.fbxControlPointIndices; + if (!cpIndices) { + continue; + } + + const basePositions = mesh.getVerticesData("position"); + const baseNormals = mesh.getVerticesData("normal"); + if (!basePositions) { + continue; + } + + const initialInfluences = calculateBlendShapeInfluences(channel.deformPercent, channel.fullWeights, channel.shapes.length); + const targetIndices: number[] = []; + for (let shapeIndex = 0; shapeIndex < channel.shapes.length; shapeIndex++) { + const shape = channel.shapes[shapeIndex]; + if (!shape) { + continue; + } + const targetData = buildMorphTargetData(shape, cpIndices, basePositions, baseNormals, deltaMatrix, normalMatrix, unitScaleFactor); + if (!targetData) { + continue; + } + + const targetName = channel.fullWeights && channel.shapes.length > 1 ? `${channel.name}_${channel.fullWeights[shapeIndex]}` : channel.name; + const morphTarget = new MorphTarget(targetName, initialInfluences[shapeIndex] ?? 0, scene); + morphTarget.setPositions(targetData.positions); + if (targetData.normals) { + morphTarget.setNormals(targetData.normals); + } + + targetIndices.push(morphTargetManager.numTargets); + morphTargetManager.addTarget(morphTarget); + } + + if (targetIndices.length === 0) { + continue; + } + + // Store channel ID mapping on the mesh for animation targeting. + // Keep the legacy single-target map for existing consumers and add + // richer in-between metadata for FullWeights-aware animation baking. + if (!mesh.metadata) { + mesh.metadata = {}; + } + if (!(mesh.metadata as Record).fbxBlendShapeChannelIds) { + (mesh.metadata as Record).fbxBlendShapeChannelIds = new Map(); + } + ((mesh.metadata as Record).fbxBlendShapeChannelIds as Map).set(channel.id, targetIndices[0]); + if (!(mesh.metadata as Record).fbxBlendShapeChannelTargets) { + (mesh.metadata as Record).fbxBlendShapeChannelTargets = new Map(); + } + ((mesh.metadata as Record).fbxBlendShapeChannelTargets as Map).set(channel.id, { + targetIndices, + fullWeights: channel.fullWeights, + }); + } + + if (morphTargetManager.numTargets > 0) { + morphTargetManager.numMaxInfluencers = morphTargetManager.numTargets; + mesh.morphTargetManager = morphTargetManager; + } + } + } + + private _createCamera(camData: FBXCameraData, modelIdToNode: Map, scene: Scene): FreeCamera | null { + const parentNode = modelIdToNode.get(camData.modelId); + const position = parentNode ? parentNode.position.clone() : Vector3.Zero(); + + const camera = new FreeCamera(camData.name, position, scene); + camera.fov = camData.fieldOfView * (Math.PI / 180); + camera.minZ = camData.nearPlane; + camera.maxZ = camData.farPlane; + camera.metadata = { + ...((camera.metadata as object) ?? {}), + fbxCamera: { + projectionType: camData.projectionType, + focalLength: camData.focalLength, + filmWidth: camData.filmWidth, + filmHeight: camData.filmHeight, + orthoZoom: camData.orthoZoom, + roll: camData.roll, + aspectRatio: camData.aspectRatio, + unknownProperties: camData.unknownProperties, + diagnostics: camData.diagnostics, + }, + }; + + if (camData.projectionType === "orthographic") { + const orthoHeight = camData.orthoZoom && camData.orthoZoom > 0 ? camData.orthoZoom : 1; + const aspect = camData.aspectRatio > 0 ? camData.aspectRatio : 1; + camera.mode = Camera.ORTHOGRAPHIC_CAMERA; + camera.orthoTop = orthoHeight / 2; + camera.orthoBottom = -orthoHeight / 2; + camera.orthoRight = (orthoHeight * aspect) / 2; + camera.orthoLeft = -(orthoHeight * aspect) / 2; + } + + if (parentNode) { + camera.parent = parentNode; + } + + return camera; + } + + private _createLight(lightData: FBXLightData, modelIdToNode: Map, scene: Scene): PointLight | DirectionalLight | SpotLight | null { + const parentNode = modelIdToNode.get(lightData.modelId); + const position = parentNode ? parentNode.position.clone() : Vector3.Zero(); + const color = new Color3(lightData.color[0], lightData.color[1], lightData.color[2]); + + let light: PointLight | DirectionalLight | SpotLight; + + switch (lightData.lightType) { + case 1: // Directional + light = new DirectionalLight(lightData.name, new Vector3(0, -1, 0), scene); + light.diffuse = color; + light.intensity = lightData.intensity; + break; + case 2: { + // Spot + const angle = lightData.coneAngle * (Math.PI / 180); + light = new SpotLight(lightData.name, position, new Vector3(0, -1, 0), angle, 2, scene); + light.diffuse = color; + light.intensity = lightData.intensity; + break; + } + default: // Point (0) + light = new PointLight(lightData.name, position, scene); + light.diffuse = color; + light.intensity = lightData.intensity; + break; + } + + light.metadata = { + ...((light.metadata as object) ?? {}), + fbxLight: { + lightType: lightData.lightType, + decayType: lightData.decayType, + decayStart: lightData.decayStart, + innerAngle: lightData.innerAngle, + outerAngle: lightData.outerAngle, + enableNearAttenuation: lightData.enableNearAttenuation, + enableFarAttenuation: lightData.enableFarAttenuation, + castShadows: lightData.castShadows, + unknownProperties: lightData.unknownProperties, + diagnostics: lightData.diagnostics, + }, + }; + + if (parentNode) { + light.parent = parentNode; + } + + return light; + } + + private _createSkeleton(skeletonId: string, bones: FBXBoneData[], scene: Scene): Skeleton { + const skeleton = new Skeleton("Skeleton", `skeleton_${skeletonId}`, scene); + const sourceBones: Bone[] = []; + const scaleCompensationHelpers = new Map(); + const authoredLocalMatrices: Matrix[] = []; + const authoredAbsoluteMatrices: Matrix[] = []; + const authoredRuntimeLocalMatrices: Matrix[] = []; + + // Compute authored Lcl matrices for bones that do not carry FBX bind data. + for (let i = 0; i < bones.length; i++) { + const boneData = bones[i]; + const authoredLocal = FBXFileLoader._computeFBXLocalMatrix( + boneData.translation, + boneData.rotation, + boneData.scale, + boneData.preRotation, + boneData.postRotation, + boneData.rotationPivot, + boneData.scalingPivot, + boneData.rotationOffset, + boneData.scalingOffset, + boneData.rotationOrder + ); + authoredLocalMatrices[i] = authoredLocal; + authoredRuntimeLocalMatrices[i] = FBXFileLoader._computeFBXRuntimeLocalMatrix(bones, authoredLocal, i); + } + authoredAbsoluteMatrices.push(...FBXFileLoader._computeFBXAbsoluteMatrices(bones, authoredRuntimeLocalMatrices)); + + const absoluteBindMatrices = bones.map((boneData, index) => + boneData.transformLinkMatrix + ? Matrix.FromArray(boneData.transformLinkMatrix) + : boneData.modelBindPoseMatrix + ? Matrix.FromArray(boneData.modelBindPoseMatrix) + : authoredAbsoluteMatrices[index] + ); + + const localBindMatrices = absoluteBindMatrices.map((absoluteBind, index) => { + const parentIndex = bones[index].parentIndex; + if (parentIndex < 0) { + return absoluteBind; + } + + const parentAbsoluteBindInv = new Matrix(); + absoluteBindMatrices[parentIndex].invertToRef(parentAbsoluteBindInv); + return absoluteBind.multiply(parentAbsoluteBindInv); + }); + const useBindAsRest = FBXFileLoader._shouldUseBindMatricesAsRest(bones, authoredLocalMatrices, localBindMatrices); + + // Most animation curves naturally target authored Lcl transforms. Use + // bind matrices as live rest pose only for rigs with severe bind/local + // scale disagreement, which otherwise produce invalid skin matrices. + // Only bones with that scale disagreement need their animation curves + // remapped into bind-rest space; ordinary child curves are already in + // the expected local animation space. + for (let i = 0; i < bones.length; i++) { + let localMatrix = useBindAsRest ? localBindMatrices[i] : authoredRuntimeLocalMatrices[i]; + let parentBone = bones[i].parentIndex >= 0 ? sourceBones[bones[i].parentIndex] : null; + if (!useBindAsRest && bones[i].inheritType === 2 && bones[i].parentIndex >= 0 && parentBone) { + const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(authoredLocalMatrices[i], bones[bones[i].parentIndex].scale); + const helper = new Bone( + `${bones[i].name}__fbx_scaleCompensation`, + skeleton, + parentBone, + split.helperLocalMatrix, + split.helperLocalMatrix.clone(), + Matrix.Identity(), + -1 + ); + helper.metadata = { + ...((helper.metadata as object) ?? {}), + fbxScaleCompensationForBoneIndex: i, + fbxScaleCompensationForBoneName: bones[i].name, + }; + scaleCompensationHelpers.set(i, helper); + parentBone = helper; + localMatrix = split.boneLocalMatrix; + } + const bone = new Bone(bones[i].name, skeleton, parentBone, localMatrix, useBindAsRest ? localMatrix.clone() : null, useBindAsRest ? localMatrix.clone() : null, i); + if (useBindAsRest && bones[i].isCluster && FBXFileLoader._getMaxScaleRatio(authoredLocalMatrices[i], localBindMatrices[i]) >= BIND_REST_SCALE_RATIO_THRESHOLD) { + this._bindRestBones.add(bone); + } + sourceBones.push(bone); + } + this._sourceBonesBySkeleton.set(skeleton, sourceBones); + this._scaleCompensationHelpersBySkeleton.set(skeleton, scaleCompensationHelpers); + + if (!useBindAsRest) { + for (let i = 0; i < bones.length; i++) { + const bone = sourceBones[i]; + bone.updateMatrix(localBindMatrices[i], false, false); + } + for (const helper of Array.from(scaleCompensationHelpers.values())) { + helper.updateMatrix(Matrix.Identity(), false, false); + } + for (const bone of skeleton.bones) { + if (!bone.getParent()) { + bone._updateAbsoluteBindMatrices(undefined, true); + } + } + } + + return skeleton; + } + + private _getSourceBone(skeleton: Skeleton, sourceIndex: number): Bone | undefined { + return this._sourceBonesBySkeleton.get(skeleton)?.[sourceIndex] ?? skeleton.bones[sourceIndex]; + } + + private _getScaleCompensationHelper(skeleton: Skeleton, sourceIndex: number): Bone | undefined { + return this._scaleCompensationHelpersBySkeleton.get(skeleton)?.get(sourceIndex); + } + + private static _computeFBXAbsoluteMatrices(bones: FBXBoneData[], localMatrices: Matrix[]): Matrix[] { + const absoluteMatrices: Matrix[] = []; + for (let i = 0; i < bones.length; i++) { + const parentIndex = bones[i].parentIndex; + if (parentIndex < 0) { + absoluteMatrices[i] = localMatrices[i].clone(); + continue; + } + + absoluteMatrices[i] = localMatrices[i].multiply(absoluteMatrices[parentIndex]); + } + return absoluteMatrices; + } + + private static _computeFBXRuntimeLocalMatrix(bones: FBXBoneData[], localMatrix: Matrix, index: number, parentScaleOverride?: [number, number, number]): Matrix { + const parentIndex = bones[index].parentIndex; + if (bones[index].inheritType !== 2 || parentIndex < 0) { + return localMatrix; + } + + const parentScale = parentScaleOverride ?? bones[parentIndex].scale; + return FBXFileLoader._applyParentScaleCompensation(localMatrix, parentScale); + } + + private static _applyParentScaleCompensation(localMatrix: Matrix, parentScale: [number, number, number]): Matrix { + const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale); + return split.boneLocalMatrix.multiply(split.helperLocalMatrix); + } + + private static _splitParentScaleCompensatedLocalMatrix(localMatrix: Matrix, parentScale: [number, number, number]): { boneLocalMatrix: Matrix; helperLocalMatrix: Matrix } { + const translation = localMatrix.getTranslation(); + const boneLocalMatrix = localMatrix.clone(); + boneLocalMatrix.setTranslation(Vector3.Zero()); + const helperLocalMatrix = Matrix.Compose(FBXFileLoader._getInverseScaleVector(parentScale), Quaternion.Identity(), translation); + return { boneLocalMatrix, helperLocalMatrix }; + } + + private static _safeInverseScale(value: number): number { + return Math.abs(value) > 1e-8 ? 1 / value : 1; + } + + private static _getInverseScaleVector(scale: [number, number, number]): Vector3 { + return new Vector3(FBXFileLoader._safeInverseScale(scale[0]), FBXFileLoader._safeInverseScale(scale[1]), FBXFileLoader._safeInverseScale(scale[2])); + } + + private static _shouldUseBindMatricesAsRest(bones: FBXBoneData[], authoredLocalMatrices: Matrix[], localBindMatrices: Matrix[]): boolean { + return bones.some((bone, index) => { + if (!bone.isCluster) { + return false; + } + return FBXFileLoader._getMaxScaleRatio(authoredLocalMatrices[index], localBindMatrices[index]) >= BIND_REST_SCALE_RATIO_THRESHOLD; + }); + } + + private static _getMaxScaleRatio(a: Matrix, b: Matrix): number { + const scaleA = new Vector3(); + const rotationA = new Quaternion(); + const translationA = new Vector3(); + const scaleB = new Vector3(); + const rotationB = new Quaternion(); + const translationB = new Vector3(); + a.decompose(scaleA, rotationA, translationA); + b.decompose(scaleB, rotationB, translationB); + + return Math.max(FBXFileLoader._getScaleRatio(scaleA.x, scaleB.x), FBXFileLoader._getScaleRatio(scaleA.y, scaleB.y), FBXFileLoader._getScaleRatio(scaleA.z, scaleB.z)); + } + + private static _getScaleRatio(a: number, b: number): number { + const absA = Math.abs(a); + const absB = Math.abs(b); + if (absA < 1e-6 || absB < 1e-6) { + return absA < 1e-6 && absB < 1e-6 ? 1 : Number.POSITIVE_INFINITY; + } + return Math.max(absA / absB, absB / absA); + } + + private static _computeFBXGeometricMatrix(translation: [number, number, number], rotation: [number, number, number], scale: [number, number, number]): Matrix { + return computeFBXGeometricMatrix(translation, rotation, scale); + } + + private static _computeFBXGeometricDeltaMatrix(rotation: [number, number, number], scale: [number, number, number]): Matrix { + return computeFBXGeometricDeltaMatrix(rotation, scale); + } + + private static _computeFBXGeometricNormalMatrix(rotation: [number, number, number], scale: [number, number, number]): Matrix { + return computeFBXGeometricNormalMatrix(rotation, scale); + } + + /** + * Compute the full FBX local transform matrix: + * M = T * Roff * Rp * Rpre * R * Rpost^-1 * Rp^-1 * Soff * Sp * S * Sp^-1 + * + * In row-vector convention: v' = v * M + */ + private static _computeFBXLocalMatrix( + translation: [number, number, number], + rotation: [number, number, number], + scale: [number, number, number], + preRotation: [number, number, number], + postRotation: [number, number, number], + rotationPivot: [number, number, number], + scalingPivot: [number, number, number], + rotationOffset: [number, number, number], + scalingOffset: [number, number, number], + rotationOrder: number = 0 + ): Matrix { + return computeFBXLocalMatrix({ + translation, + rotation, + scale, + preRotation, + postRotation, + rotationPivot, + scalingPivot, + rotationOffset, + scalingOffset, + rotationOrder, + }); + } + + /** + * Apply the FBX transform chain to a Babylon TransformNode or Mesh. + * Decomposes the full local matrix into position/rotation/scale. + */ + private static _applyFBXTransform(node: TransformNode | Mesh, model: FBXModelData): void { + const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model); + + // Decompose into TRS + const s = new Vector3(); + const r = new Quaternion(); + const t = new Vector3(); + localMatrix.decompose(s, r, t); + + node.position = t; + node.rotationQuaternion = r; + node.scaling = s; + } + + private static _computeFBXModelLocalMatrix(model: FBXModelData): Matrix { + return FBXFileLoader._computeFBXLocalMatrix( + model.translation, + model.rotation, + model.scale, + model.preRotation, + model.postRotation, + model.rotationPivot, + model.scalingPivot, + model.rotationOffset, + model.scalingOffset, + model.rotationOrder + ); + } + + private static _getBoneReferenceWorldMatrix(skeleton: Skeleton, bone: Bone, referenceNode: TransformNode, skinnedMesh: Mesh | null): Matrix { + if (skinnedMesh) { + skeleton.getTransformMatrices(skinnedMesh); + } else { + skeleton.prepare(true); + } + referenceNode.computeWorldMatrix(true); + return bone.getFinalMatrix().multiply(referenceNode.getWorldMatrix()); + } + + private static _applyMatrixToTransform(node: TransformNode, matrix: Matrix): void { + const s = new Vector3(); + const r = new Quaternion(); + const t = new Vector3(); + matrix.decompose(s, r, t); + + node.position = t; + node.rotationQuaternion = r; + node.scaling = s; + } + + private _createAnimationGroup( + animStack: FBXAnimationStackData, + rigs: FBXRigData[], + skeletonByRigId: Map, + scene: Scene, + modelIdToNode: Map, + modelIdToData: Map, + meshes: Mesh[] + ): AnimationGroup | null { + if (animStack.curveNodes.length === 0) { + return null; + } + + const animGroup = new AnimationGroup(animStack.name, scene); + + // Build a map from model ID to resolved rig bones. A single FBX model ID + // should only appear once per resolved rig, but keeping an array preserves + // the previous animation fan-out behavior for any future duplicate rigs. + const modelIdToBones = new Map(); + for (const rig of rigs) { + const skeleton = skeletonByRigId.get(rig.id); + if (!skeleton) { + continue; + } + + for (const boneData of rig.bones) { + const bone = this._getSourceBone(skeleton, boneData.index); + if (!bone) { + continue; + } + + const bones = modelIdToBones.get(boneData.modelId); + if (bones) { + bones.push(bone); + } else { + modelIdToBones.set(boneData.modelId, [bone]); + } + } + } + + // Group curve nodes by target + const boneCurves = new Map(); + const nonBoneCurves = new Map(); + const blendShapeCurves: FBXCurveNodeData[] = []; + + for (const curveNode of animStack.curveNodes) { + if (curveNode.type === "DeformPercent") { + blendShapeCurves.push(curveNode); + continue; + } + + if (modelIdToBones.has(curveNode.targetModelId)) { + if (!boneCurves.has(curveNode.targetModelId)) { + boneCurves.set(curveNode.targetModelId, []); + } + boneCurves.get(curveNode.targetModelId)!.push(curveNode); + } else { + if (!nonBoneCurves.has(curveNode.targetModelId)) { + nonBoneCurves.set(curveNode.targetModelId, []); + } + nonBoneCurves.get(curveNode.targetModelId)!.push(curveNode); + } + } + + // Process bone targets: compute full FBX local matrix per frame, decompose to TRS. + // For bind-rest rigs, only the bones recorded in _bindRestBones need their + // authored Lcl curves remapped onto the bind-rest local space. + const inheritedRigModelIds = new Set(); + for (const rig of rigs) { + const inheritType2ModelIds = new Set(rig.bones.filter((bone) => bone.inheritType === 2).map((bone) => bone.modelId)); + if (inheritType2ModelIds.size === 0) { + continue; + } + + const skeleton = skeletonByRigId.get(rig.id); + if (!skeleton) { + continue; + } + + if (skeleton.bones.some((bone) => this._bindRestBones.has(bone))) { + continue; + } + + for (const modelId of Array.from(inheritType2ModelIds)) { + inheritedRigModelIds.add(modelId); + } + for (const { bone, animations } of this._buildInheritedRigBoneAnimations( + rig, + skeleton, + boneCurves, + modelIdToData, + inheritType2ModelIds, + animStack.startTime, + animStack.stopTime + )) { + for (const animation of animations) { + animGroup.addTargetedAnimation(animation, bone); + } + } + } + + for (const [targetId, curveNodes] of Array.from(boneCurves)) { + if (inheritedRigModelIds.has(targetId)) { + continue; + } + + const bones = modelIdToBones.get(targetId); + const modelData = modelIdToData.get(targetId); + if (!bones || bones.length === 0 || !modelData) { + continue; + } + + for (const bone of bones) { + const animations = this._buildBoneAnimations( + curveNodes, + bone.name, + modelData, + animStack.startTime, + animStack.stopTime, + this._bindRestBones.has(bone) ? bone.getBindMatrix() : undefined + ); + for (const animation of animations) { + animGroup.addTargetedAnimation(animation, bone); + } + } + } + + // Process non-bone targets: bake full transform matrix per frame + for (const [targetId, curveNodes] of Array.from(nonBoneCurves)) { + const node = modelIdToNode.get(targetId); + if (!node) { + continue; + } + + const modelData = modelIdToData.get(targetId); + if (!modelData) { + continue; + } + + const animations = this._buildNodeAnimations(curveNodes, node.name, modelData, animStack.startTime, animStack.stopTime); + for (const animation of animations) { + animGroup.addTargetedAnimation(animation, node); + } + } + + // Process blend shape (morph target) animations + for (const curveNode of blendShapeCurves) { + const targetChannelId = curveNode.targetModelId; + + // Find the morph target with matching channel ID across all meshes + let targetFound = false; + for (const mesh of meshes) { + if (!mesh.morphTargetManager || targetFound) { + continue; + } + const metadata = mesh.metadata as Record | undefined; + const channelTargets = metadata?.fbxBlendShapeChannelTargets as Map | undefined; + const targetInfo = channelTargets?.get(targetChannelId); + if (targetInfo && curveNode.curves.length > 0) { + const fps = 30; + for (let shapeIndex = 0; shapeIndex < targetInfo.targetIndices.length; shapeIndex++) { + const target = mesh.morphTargetManager.getTarget(targetInfo.targetIndices[shapeIndex]); + if (!target) { + continue; + } + const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE); + const keys = buildScalarAnimationKeys( + curveNode.curves[0], + fps, + animStack.startTime, + animStack.stopTime, + (value) => calculateBlendShapeInfluences(value, targetInfo.fullWeights, targetInfo.targetIndices.length)[shapeIndex] ?? 0 + ); + anim.setKeys(keys); + animGroup.addTargetedAnimation(anim, target); + } + targetFound = true; + continue; + } + + const channelMap = metadata?.fbxBlendShapeChannelIds as Map | undefined; + if (!channelMap) { + continue; + } + const targetIndex = channelMap.get(targetChannelId); + if (targetIndex === undefined) { + continue; + } + + const target = mesh.morphTargetManager.getTarget(targetIndex); + if (target && curveNode.curves.length > 0) { + const fps = 30; + const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE); + const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => value / 100); + anim.setKeys(keys); + animGroup.addTargetedAnimation(anim, target); + targetFound = true; + } + } + } + + // Normalize the animation group + if (animGroup.targetedAnimations.length > 0) { + animGroup.normalize(animStack.startTime * 30, animStack.stopTime * 30); + return animGroup; + } + + animGroup.dispose(); + return null; + } + + private _buildInheritedRigBoneAnimations( + rig: FBXRigData, + skeleton: Skeleton, + boneCurves: Map, + modelIdToData: Map, + compensatedModelIds: Set, + startTime: number, + stopTime: number + ): { bone: Bone; animations: Animation[] }[] { + const fps = 30; + const sampledModelIds = new Set(); + for (let i = 0; i < rig.bones.length; i++) { + if (!compensatedModelIds.has(rig.bones[i].modelId)) { + continue; + } + + for (let parentIndex = i; parentIndex >= 0; parentIndex = rig.bones[parentIndex].parentIndex) { + sampledModelIds.add(rig.bones[parentIndex].modelId); + } + } + const rigCurveNodes = rig.bones.filter((bone) => sampledModelIds.has(bone.modelId)).flatMap((bone) => boneCurves.get(bone.modelId) ?? []); + const times = collectAnimationSampleTimes(rigCurveNodes, fps, startTime, stopTime); + if (times.length === 0) { + return []; + } + + const keysByBone = rig.bones.map(() => ({ + posKeys: [] as { frame: number; value: Vector3 }[], + rotKeys: [] as { frame: number; value: Quaternion }[], + sclKeys: [] as { frame: number; value: Vector3 }[], + prevQuat: null as Quaternion | null, + })); + const keysByHelper = rig.bones.map(() => ({ + posKeys: [] as { frame: number; value: Vector3 }[], + rotKeys: [] as { frame: number; value: Quaternion }[], + sclKeys: [] as { frame: number; value: Vector3 }[], + prevQuat: null as Quaternion | null, + })); + const restLocalInverses = rig.bones.map((boneData, index) => { + const bone = this._getSourceBone(skeleton, index); + const modelData = modelIdToData.get(boneData.modelId); + if (!bone || !modelData || !this._bindRestBones.has(bone)) { + return null; + } + + const restLocalMatrix = FBXFileLoader._computeFBXModelLocalMatrix(modelData); + const restLocalInverse = new Matrix(); + restLocalMatrix.invertToRef(restLocalInverse); + return restLocalInverse; + }); + + for (const time of times) { + const localMatrices = rig.bones.map((boneData, index) => { + const modelData = modelIdToData.get(boneData.modelId); + const curveNodes = boneCurves.get(boneData.modelId) ?? []; + let localMatrix = modelData ? this._sampleModelLocalMatrix(modelData, curveNodes, time) : Matrix.Identity(); + + const restLocalInverse = restLocalInverses[index]; + if (restLocalInverse) { + const sourceBone = this._getSourceBone(skeleton, index); + localMatrix = (sourceBone?.getBindMatrix() ?? Matrix.Identity()).multiply(restLocalInverse).multiply(localMatrix); + } + return localMatrix; + }); + const sampledScales = rig.bones.map((boneData) => { + const modelData = modelIdToData.get(boneData.modelId); + const curveNodes = boneCurves.get(boneData.modelId) ?? []; + return modelData ? this._sampleModelScale(modelData, curveNodes, time) : boneData.scale; + }); + const frame = time * fps; + + for (let i = 0; i < localMatrices.length; i++) { + if (!compensatedModelIds.has(rig.bones[i].modelId)) { + continue; + } + + const parentIndex = rig.bones[i].parentIndex; + const parentScale = parentIndex >= 0 ? sampledScales[parentIndex] : rig.bones[i].scale; + const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrices[i], parentScale); + FBXFileLoader._pushMatrixKeys(keysByBone[i], frame, split.boneLocalMatrix); + FBXFileLoader._pushMatrixKeys(keysByHelper[i], frame, split.helperLocalMatrix); + } + } + + const result: { bone: Bone; animations: Animation[] }[] = []; + for (let i = 0; i < rig.bones.length; i++) { + if (!compensatedModelIds.has(rig.bones[i].modelId)) { + continue; + } + + const bone = this._getSourceBone(skeleton, i); + if (!bone) { + continue; + } + + const { posKeys, rotKeys, sclKeys } = keysByBone[i]; + const animations: Animation[] = []; + if (!this._isVector3KeysConstant(posKeys)) { + const posAnim = new Animation(`${bone.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + posAnim.setKeys(posKeys); + animations.push(posAnim); + } + if (!areQuaternionKeysConstant(rotKeys)) { + const rotAnim = new Animation(`${bone.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE); + rotAnim.setKeys(rotKeys); + animations.push(rotAnim); + } + if (!this._isVector3KeysConstant(sclKeys)) { + const sclAnim = new Animation(`${bone.name}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + sclAnim.setKeys(sclKeys); + animations.push(sclAnim); + } + if (animations.length > 0) { + result.push({ bone, animations }); + } + + const helper = this._getScaleCompensationHelper(skeleton, i); + if (!helper) { + continue; + } + + const helperAnimations: Animation[] = []; + const { posKeys: helperPosKeys, rotKeys: helperRotKeys, sclKeys: helperSclKeys } = keysByHelper[i]; + if (!this._isVector3KeysConstant(helperPosKeys)) { + const posAnim = new Animation(`${helper.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + posAnim.setKeys(helperPosKeys); + helperAnimations.push(posAnim); + } + if (!areQuaternionKeysConstant(helperRotKeys)) { + const rotAnim = new Animation(`${helper.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE); + rotAnim.setKeys(helperRotKeys); + helperAnimations.push(rotAnim); + } + if (!this._isVector3KeysConstant(helperSclKeys)) { + const sclAnim = new Animation(`${helper.name}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + sclAnim.setKeys(helperSclKeys); + helperAnimations.push(sclAnim); + } + if (helperAnimations.length > 0) { + result.push({ bone: helper, animations: helperAnimations }); + } + } + + return result; + } + + private static _pushMatrixKeys( + keySet: { + posKeys: { frame: number; value: Vector3 }[]; + rotKeys: { frame: number; value: Quaternion }[]; + sclKeys: { frame: number; value: Vector3 }[]; + prevQuat: Quaternion | null; + }, + frame: number, + matrix: Matrix + ): void { + const s = new Vector3(); + const r = new Quaternion(); + const t = new Vector3(); + matrix.decompose(s, r, t); + + if (keySet.prevQuat && Quaternion.Dot(keySet.prevQuat, r) < 0) { + r.scaleInPlace(-1); + } + keySet.prevQuat = r; + + keySet.posKeys.push({ frame, value: t }); + keySet.rotKeys.push({ frame, value: r }); + keySet.sclKeys.push({ frame, value: s }); + } + + /** + * Build animations for a non-bone node, correctly handling pivots. + * Computes the full FBX transform matrix at each keyframe and decomposes into TRS. + */ + private _buildNodeAnimations(curveNodes: FBXCurveNodeData[], nodeName: string, modelData: FBXModelData, startTime: number, stopTime: number): Animation[] { + const fps = 30; + + // Separate curves by type + const tNode = curveNodes.find((cn) => cn.type === "T"); + const rNode = curveNodes.find((cn) => cn.type === "R"); + const sNode = curveNodes.find((cn) => cn.type === "S"); + + const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime); + if (times.length === 0) { + return []; + } + + // Get curve accessors + const txCurve = tNode?.curves.find((c) => c.channel === "d|X"); + const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y"); + const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z"); + const rxCurve = rNode?.curves.find((c) => c.channel === "d|X"); + const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y"); + const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z"); + const sxCurve = sNode?.curves.find((c) => c.channel === "d|X"); + const syCurve = sNode?.curves.find((c) => c.channel === "d|Y"); + const szCurve = sNode?.curves.find((c) => c.channel === "d|Z"); + + // Build keyframes by computing the full matrix at each time + const posKeys: { frame: number; value: Vector3 }[] = []; + const rotKeys: { frame: number; value: Quaternion }[] = []; + const sclKeys: { frame: number; value: Vector3 }[] = []; + let prevQuat: Quaternion | null = null; + + for (const time of times) { + const frame = time * fps; + + // Sample animated values, falling back to model's base values + const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0]; + const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1]; + const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2]; + const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0]; + const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1]; + const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2]; + const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0]; + const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1]; + const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2]; + + // Compute the full FBX local transform matrix with pivots + const localMatrix = FBXFileLoader._computeFBXLocalMatrix( + [tx, ty, tz], + [rx, ry, rz], + [sx, sy, sz], + modelData.preRotation, + modelData.postRotation, + modelData.rotationPivot, + modelData.scalingPivot, + modelData.rotationOffset, + modelData.scalingOffset, + modelData.rotationOrder + ); + + // Decompose into TRS + const s = new Vector3(); + const r = new Quaternion(); + const t = new Vector3(); + localMatrix.decompose(s, r, t); + + // Ensure quaternion continuity + if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) { + r.scaleInPlace(-1); + } + prevQuat = r; + + posKeys.push({ frame, value: t }); + rotKeys.push({ frame, value: r }); + sclKeys.push({ frame, value: s }); + } + + const animations: Animation[] = []; + + // Only create position animation if it's not constant + if (!this._isVector3KeysConstant(posKeys)) { + const posAnim = new Animation(`${nodeName}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + posAnim.setKeys(posKeys); + animations.push(posAnim); + } + + // Always create rotation animation (if there are rotation curves) + if (rNode) { + const rotAnim = new Animation(`${nodeName}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE); + rotAnim.setKeys(rotKeys); + animations.push(rotAnim); + } + + // Only create scale animation if it's not constant + if (!this._isVector3KeysConstant(sclKeys)) { + const sclAnim = new Animation(`${nodeName}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + sclAnim.setKeys(sclKeys); + animations.push(sclAnim); + } + + return animations; + } + + private _isVector3KeysConstant(keys: { frame: number; value: Vector3 }[]): boolean { + if (keys.length < 2) { + return true; + } + const first = keys[0].value; + for (let i = 1; i < keys.length; i++) { + const v = keys[i].value; + if (Math.abs(v.x - first.x) > 0.0001 || Math.abs(v.y - first.y) > 0.0001 || Math.abs(v.z - first.z) > 0.0001) { + return false; + } + } + return true; + } + + private _sampleModelLocalMatrix(modelData: FBXModelData, curveNodes: FBXCurveNodeData[], time: number, scaleOverride?: [number, number, number]): Matrix { + const tNode = curveNodes.find((cn) => cn.type === "T"); + const rNode = curveNodes.find((cn) => cn.type === "R"); + const sNode = curveNodes.find((cn) => cn.type === "S"); + + const txCurve = tNode?.curves.find((c) => c.channel === "d|X"); + const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y"); + const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z"); + const rxCurve = rNode?.curves.find((c) => c.channel === "d|X"); + const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y"); + const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z"); + const sxCurve = sNode?.curves.find((c) => c.channel === "d|X"); + const syCurve = sNode?.curves.find((c) => c.channel === "d|Y"); + const szCurve = sNode?.curves.find((c) => c.channel === "d|Z"); + + return FBXFileLoader._computeFBXLocalMatrix( + [ + sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0], + sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1], + sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2], + ], + [ + sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0], + sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1], + sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2], + ], + scaleOverride ?? [ + sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0], + sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1], + sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2], + ], + modelData.preRotation, + modelData.postRotation, + modelData.rotationPivot, + modelData.scalingPivot, + modelData.rotationOffset, + modelData.scalingOffset, + modelData.rotationOrder + ); + } + + private _sampleModelScale(modelData: FBXModelData, curveNodes: FBXCurveNodeData[], time: number): [number, number, number] { + const sNode = curveNodes.find((cn) => cn.type === "S"); + const sxCurve = sNode?.curves.find((c) => c.channel === "d|X"); + const syCurve = sNode?.curves.find((c) => c.channel === "d|Y"); + const szCurve = sNode?.curves.find((c) => c.channel === "d|Z"); + return [ + sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0], + sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1], + sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2], + ]; + } + + /** + * Build matrix-baked bone animation from full FBX local transforms. + * The bind matrix carries the skinning offset, so animation curves drive + * the same FBX local transform chain as the source skeleton. + */ + private _buildBoneAnimations( + curveNodes: FBXCurveNodeData[], + boneName: string, + modelData: FBXModelData, + startTime: number, + stopTime: number, + bindLocalMatrix?: Matrix + ): Animation[] { + const fps = 30; + + // Separate curves by type + const tNode = curveNodes.find((cn) => cn.type === "T"); + const rNode = curveNodes.find((cn) => cn.type === "R"); + const sNode = curveNodes.find((cn) => cn.type === "S"); + + const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime); + if (times.length === 0) { + return []; + } + + // Get curve accessors + const txCurve = tNode?.curves.find((c) => c.channel === "d|X"); + const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y"); + const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z"); + const rxCurve = rNode?.curves.find((c) => c.channel === "d|X"); + const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y"); + const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z"); + const sxCurve = sNode?.curves.find((c) => c.channel === "d|X"); + const syCurve = sNode?.curves.find((c) => c.channel === "d|Y"); + const szCurve = sNode?.curves.find((c) => c.channel === "d|Z"); + + const posKeys: { frame: number; value: Vector3 }[] = []; + const rotKeys: { frame: number; value: Quaternion }[] = []; + const sclKeys: { frame: number; value: Vector3 }[] = []; + let prevQuat: Quaternion | null = null; + let restLocalInverse: Matrix | null = null; + if (bindLocalMatrix) { + const restLocalMatrix = FBXFileLoader._computeFBXLocalMatrix( + modelData.translation, + modelData.rotation, + modelData.scale, + modelData.preRotation, + modelData.postRotation, + modelData.rotationPivot, + modelData.scalingPivot, + modelData.rotationOffset, + modelData.scalingOffset, + modelData.rotationOrder + ); + restLocalInverse = new Matrix(); + restLocalMatrix.invertToRef(restLocalInverse); + } + + for (const time of times) { + const frame = time * fps; + + // Sample animated values, falling back to model's base values + const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0]; + const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1]; + const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2]; + const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0]; + const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1]; + const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2]; + const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0]; + const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1]; + const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2]; + + // Compute the full FBX local matrix from animated Lcl values + const localMatrix = FBXFileLoader._computeFBXLocalMatrix( + [tx, ty, tz], + [rx, ry, rz], + [sx, sy, sz], + modelData.preRotation, + modelData.postRotation, + modelData.rotationPivot, + modelData.scalingPivot, + modelData.rotationOffset, + modelData.scalingOffset, + modelData.rotationOrder + ); + + const correctedLocalMatrix = restLocalInverse && bindLocalMatrix ? bindLocalMatrix.multiply(restLocalInverse).multiply(localMatrix) : localMatrix; + + const s = new Vector3(); + const r = new Quaternion(); + const t = new Vector3(); + correctedLocalMatrix.decompose(s, r, t); + + if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) { + r.scaleInPlace(-1); + } + prevQuat = r; + + posKeys.push({ frame, value: t }); + rotKeys.push({ frame, value: r }); + sclKeys.push({ frame, value: s }); + } + + const animations: Animation[] = []; + + if (!this._isVector3KeysConstant(posKeys)) { + const posAnim = new Animation(`${boneName}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + posAnim.setKeys(posKeys); + animations.push(posAnim); + } + + if (rNode) { + const rotAnim = new Animation(`${boneName}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE); + rotAnim.setKeys(rotKeys); + animations.push(rotAnim); + } + + if (!this._isVector3KeysConstant(sclKeys)) { + const sclAnim = new Animation(`${boneName}_scaling`, "scaling", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE); + sclAnim.setKeys(sclKeys); + animations.push(sclAnim); + } + + return animations; + } + + private _buildNameFilter(meshesNames: string | readonly string[] | null | undefined): ((name: string) => boolean) | null { + if (!meshesNames) { + return null; + } + if (typeof meshesNames === "string") { + if (meshesNames === "") { + return null; + } + return (name: string) => name === meshesNames; + } + if (meshesNames.length === 0) { + return null; + } + const nameSet = new Set(meshesNames); + return (name: string) => nameSet.has(name); + } +} + +function float64To32(arr: Float64Array): Float32Array { + const result = new Float32Array(arr.length); + for (let i = 0; i < arr.length; i++) { + result[i] = arr[i]; + } + return result; +} + +function applyTangentHandednessScale(tangents: Float32Array, scale: 1 | -1): void { + if (scale === 1) { + return; + } + for (let i = 3; i < tangents.length; i += 4) { + tangents[i] *= scale; + } +} + +function generateTangents( + positions: ArrayLike, + normals: ArrayLike, + uvs: ArrayLike, + indices: ArrayLike, + normalMapTangentHandednessScale: 1 | -1 = 1, + controlPointIndices: ArrayLike | null = null, + materialIndices: ArrayLike | null = null +): Float32Array { + const vertexCount = positions.length / 3; + const groups = new Map(); + const vertexGroupKeys = new Array(vertexCount).fill(null); + + for (let i = 0; i + 2 < indices.length; i += 3) { + const materialIndex = materialIndices ? materialIndices[i / 3] : 0; + const i1 = indices[i]; + const i2 = indices[i + 1]; + const i3 = indices[i + 2]; + + const p1 = i1 * 3; + const p2 = i2 * 3; + const p3 = i3 * 3; + const uv1 = i1 * 2; + const uv2 = i2 * 2; + const uv3 = i3 * 2; + + const x1 = positions[p2] - positions[p1]; + const x2 = positions[p3] - positions[p1]; + const y1 = positions[p2 + 1] - positions[p1 + 1]; + const y2 = positions[p3 + 1] - positions[p1 + 1]; + const z1 = positions[p2 + 2] - positions[p1 + 2]; + const z2 = positions[p3 + 2] - positions[p1 + 2]; + + const s1 = uvs[uv2] - uvs[uv1]; + const s2 = uvs[uv3] - uvs[uv1]; + const t1 = uvs[uv2 + 1] - uvs[uv1 + 1]; + const t2 = uvs[uv3 + 1] - uvs[uv1 + 1]; + const denominator = s1 * t2 - s2 * t1; + if (Math.abs(denominator) < 1e-8) { + continue; + } + + const r = 1 / denominator; + const sx = (t2 * x1 - t1 * x2) * r; + const sy = (t2 * y1 - t1 * y2) * r; + const sz = (t2 * z1 - t1 * z2) * r; + const bx = (s1 * x2 - s2 * x1) * r; + const by = (s1 * y2 - s2 * y1) * r; + const bz = (s1 * z2 - s2 * z1) * r; + + accumulateTangentContribution(i1, i2, i3, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys); + accumulateTangentContribution(i2, i3, i1, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys); + accumulateTangentContribution(i3, i1, i2, sx, sy, sz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex, groups, vertexGroupKeys); + } + + const tangents = new Float32Array(vertexCount * 4); + for (let i = 0; i < vertexCount; i++) { + const no = i * 3; + const to = i * 4; + const [nx, ny, nz] = normalizeVector(normals[no], normals[no + 1], normals[no + 2]); + const group = vertexGroupKeys[i] ? groups.get(vertexGroupKeys[i]!) : undefined; + const tx = group?.tx ?? 0; + const ty = group?.ty ?? 0; + const tz = group?.tz ?? 0; + const normalDotTangent = nx * tx + ny * ty + nz * tz; + + let ox = tx - nx * normalDotTangent; + let oy = ty - ny * normalDotTangent; + let oz = tz - nz * normalDotTangent; + const tangentLength = Math.hypot(ox, oy, oz); + if (tangentLength > 1e-8) { + ox /= tangentLength; + oy /= tangentLength; + oz /= tangentLength; + } else { + [ox, oy, oz] = buildFallbackTangent(nx, ny, nz); + } + + const bx = group?.bx ?? 0; + const by = group?.by ?? 0; + const bz = group?.bz ?? 0; + const cx = ny * oz - nz * oy; + const cy = nz * ox - nx * oz; + const cz = nx * oy - ny * ox; + const bitangentLength = Math.hypot(bx, by, bz); + const handedness = bitangentLength > 1e-8 && cx * bx + cy * by + cz * bz < 0 ? -1 : 1; + + tangents[to] = ox; + tangents[to + 1] = oy; + tangents[to + 2] = oz; + tangents[to + 3] = handedness * normalMapTangentHandednessScale; + } + + return tangents; +} + +interface TangentGroup { + tx: number; + ty: number; + tz: number; + bx: number; + by: number; + bz: number; +} + +function accumulateTangentContribution( + vertexIndex: number, + nextIndex: number, + prevIndex: number, + tx: number, + ty: number, + tz: number, + bx: number, + by: number, + bz: number, + positions: ArrayLike, + normals: ArrayLike, + uvs: ArrayLike, + controlPointIndices: ArrayLike | null, + materialIndex: number, + groups: Map, + vertexGroupKeys: Array +): void { + const weight = computeCornerAngle(positions, vertexIndex, nextIndex, prevIndex); + if (weight <= 1e-8) { + return; + } + + const key = buildTangentGroupKey(vertexIndex, tx, ty, tz, bx, by, bz, positions, normals, uvs, controlPointIndices, materialIndex); + let group = groups.get(key); + if (!group) { + group = { tx: 0, ty: 0, tz: 0, bx: 0, by: 0, bz: 0 }; + groups.set(key, group); + } + + group.tx += tx * weight; + group.ty += ty * weight; + group.tz += tz * weight; + group.bx += bx * weight; + group.by += by * weight; + group.bz += bz * weight; + vertexGroupKeys[vertexIndex] ??= key; +} + +function buildTangentGroupKey( + vertexIndex: number, + tx: number, + ty: number, + tz: number, + bx: number, + by: number, + bz: number, + positions: ArrayLike, + normals: ArrayLike, + uvs: ArrayLike, + controlPointIndices: ArrayLike | null, + materialIndex: number +): string { + const po = vertexIndex * 3; + const no = vertexIndex * 3; + const uo = vertexIndex * 2; + const [nx, ny, nz] = normalizeVector(normals[no], normals[no + 1], normals[no + 2]); + const handedness = computeTangentHandedness(nx, ny, nz, tx, ty, tz, bx, by, bz); + const positionKey = controlPointIndices + ? `cp:${controlPointIndices[vertexIndex]}` + : `p:${quantizeTangentKey(positions[po])},${quantizeTangentKey(positions[po + 1])},${quantizeTangentKey(positions[po + 2])}`; + return [ + positionKey, + quantizeTangentKey(nx), + quantizeTangentKey(ny), + quantizeTangentKey(nz), + quantizeTangentKey(uvs[uo]), + quantizeTangentKey(uvs[uo + 1]), + handedness, + materialIndex, + ].join("|"); +} + +function computeTangentHandedness(nx: number, ny: number, nz: number, tx: number, ty: number, tz: number, bx: number, by: number, bz: number): 1 | -1 { + const cx = ny * tz - nz * ty; + const cy = nz * tx - nx * tz; + const cz = nx * ty - ny * tx; + return cx * bx + cy * by + cz * bz < 0 ? -1 : 1; +} + +function computeCornerAngle(positions: ArrayLike, vertexIndex: number, nextIndex: number, prevIndex: number): number { + const vo = vertexIndex * 3; + const no = nextIndex * 3; + const po = prevIndex * 3; + const ax = positions[no] - positions[vo]; + const ay = positions[no + 1] - positions[vo + 1]; + const az = positions[no + 2] - positions[vo + 2]; + const bx = positions[po] - positions[vo]; + const by = positions[po + 1] - positions[vo + 1]; + const bz = positions[po + 2] - positions[vo + 2]; + const aLength = Math.hypot(ax, ay, az); + const bLength = Math.hypot(bx, by, bz); + if (aLength <= 1e-8 || bLength <= 1e-8) { + return 0; + } + const dot = (ax * bx + ay * by + az * bz) / (aLength * bLength); + return Math.acos(Math.max(-1, Math.min(1, dot))); +} + +function normalizeVector(x: number, y: number, z: number): [number, number, number] { + const length = Math.hypot(x, y, z); + return length > 1e-8 ? [x / length, y / length, z / length] : [0, 0, 1]; +} + +function quantizeTangentKey(value: number): number { + const quantized = Math.round(value * 1e6); + return Object.is(quantized, -0) ? 0 : quantized; +} + +function buildFallbackTangent(nx: number, ny: number, nz: number): [number, number, number] { + const ax = Math.abs(nx) < 0.9 ? 1 : 0; + const ay = ax === 1 ? 0 : 1; + const dot = nx * ax + ny * ay; + let tx = ax - nx * dot; + let ty = ay - ny * dot; + let tz = -nz * dot; + const length = Math.hypot(tx, ty, tz); + if (length <= 1e-8) { + return [1, 0, 0]; + } + tx /= length; + ty /= length; + tz /= length; + return [tx, ty, tz]; +} + +function buildMorphTargetData( + shape: FBXShapeData, + cpIndices: Uint32Array, + basePositions: FloatArray, + baseNormals: FloatArray | null, + deltaMatrix: Matrix | null, + normalMatrix: Matrix | null, + unitScaleFactor: number +): { positions: Float32Array; normals: Float32Array | null } | null { + const vertexCount = basePositions.length / 3; + const targetPositions = new Float32Array(vertexCount * 3); + const hasNormals = shape.normals !== null && baseNormals !== null; + const targetNormals = hasNormals ? new Float32Array(vertexCount * 3) : null; + + for (let i = 0; i < targetPositions.length; i++) { + targetPositions[i] = basePositions[i]; + } + if (targetNormals && baseNormals) { + for (let i = 0; i < targetNormals.length; i++) { + targetNormals[i] = baseNormals[i]; + } + } + + const cpToShapeIdx = new Map(); + for (let i = 0; i < shape.indices.length; i++) { + cpToShapeIdx.set(shape.indices[i], i); + } + + for (let vi = 0; vi < vertexCount; vi++) { + const cpIdx = cpIndices[vi]; + const shapeIdx = cpToShapeIdx.get(cpIdx); + if (shapeIdx === undefined) { + continue; + } + + let dx = shape.vertices[shapeIdx * 3]; + let dy = shape.vertices[shapeIdx * 3 + 1]; + let dz = shape.vertices[shapeIdx * 3 + 2]; + + if (deltaMatrix) { + const rv = Vector3.TransformNormal(new Vector3(dx, dy, dz), deltaMatrix); + dx = rv.x; + dy = rv.y; + dz = rv.z; + } + + if (unitScaleFactor !== 1) { + dx *= unitScaleFactor; + dy *= unitScaleFactor; + dz *= unitScaleFactor; + } + + targetPositions[vi * 3] += dx; + targetPositions[vi * 3 + 1] += dy; + targetPositions[vi * 3 + 2] += dz; + + if (targetNormals && shape.normals) { + let nx = shape.normals[shapeIdx * 3]; + let ny = shape.normals[shapeIdx * 3 + 1]; + let nz = shape.normals[shapeIdx * 3 + 2]; + if (normalMatrix) { + const rn = Vector3.TransformNormal(new Vector3(nx, ny, nz), normalMatrix); + if (rn.lengthSquared() > 0) { + rn.normalize(); + } + nx = rn.x; + ny = rn.y; + nz = rn.z; + } + targetNormals[vi * 3] += nx; + targetNormals[vi * 3 + 1] += ny; + targetNormals[vi * 3 + 2] += nz; + } + } + + return { positions: targetPositions, normals: targetNormals }; +} + +function calculateBlendShapeInfluences(deformPercent: number, fullWeights: number[] | null, shapeCount: number): number[] { + if (shapeCount <= 0) { + return []; + } + if (!fullWeights || fullWeights.length !== shapeCount || shapeCount === 1) { + const denominator = fullWeights?.[0] && fullWeights[0] !== 0 ? fullWeights[0] : 100; + return [clamp01(deformPercent / denominator)]; + } + + const influences = new Array(shapeCount).fill(0); + if (deformPercent <= fullWeights[0]) { + influences[0] = fullWeights[0] === 0 ? (deformPercent <= 0 ? 1 : 0) : clamp01(deformPercent / fullWeights[0]); + return influences; + } + + for (let i = 1; i < fullWeights.length; i++) { + const previousWeight = fullWeights[i - 1]; + const nextWeight = fullWeights[i]; + if (deformPercent > nextWeight) { + continue; + } + + const range = nextWeight - previousWeight; + if (Math.abs(range) < 1e-6) { + influences[i] = 1; + return influences; + } + + const t = clamp01((deformPercent - previousWeight) / range); + influences[i - 1] = 1 - t; + influences[i] = t; + return influences; + } + + influences[shapeCount - 1] = 1; + return influences; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function collectAnimationSampleTimes(curveNodes: FBXCurveNodeData[], fps: number, startTime: number, stopTime: number): number[] { + let minTime = Number.POSITIVE_INFINITY; + let maxTime = Number.NEGATIVE_INFINITY; + const sourceTimes = new Set(); + + for (const curveNode of curveNodes) { + for (const curve of curveNode.curves) { + for (const key of curve.keys) { + minTime = Math.min(minTime, key.time); + maxTime = Math.max(maxTime, key.time); + if (key.time >= startTime && key.time <= stopTime) { + sourceTimes.add(key.time); + } + } + } + } + + if (!Number.isFinite(minTime) || !Number.isFinite(maxTime)) { + return []; + } + + const rangeStart = stopTime > startTime ? startTime : minTime; + const rangeStop = stopTime > startTime ? stopTime : maxTime; + const times = new Set([rangeStart, rangeStop, ...Array.from(sourceTimes)]); + const startFrame = Math.ceil(rangeStart * fps); + const stopFrame = Math.floor(rangeStop * fps); + + for (let frame = startFrame; frame <= stopFrame; frame++) { + times.add(frame / fps); + } + + return Array.from(times).sort((a, b) => a - b); +} + +function areQuaternionKeysConstant(keys: { frame: number; value: Quaternion }[]): boolean { + if (keys.length < 2) { + return true; + } + const first = keys[0].value; + for (let i = 1; i < keys.length; i++) { + const value = keys[i].value; + if (Math.abs(value.x - first.x) > 0.0001 || Math.abs(value.y - first.y) > 0.0001 || Math.abs(value.z - first.z) > 0.0001 || Math.abs(value.w - first.w) > 0.0001) { + return false; + } + } + return true; +} + +RegisterSceneLoaderPlugin(new FBXFileLoader()); + +function buildScalarAnimationKeys(curve: FBXCurveData, fps: number, startTime: number, stopTime: number, mapValue: (value: number) => number): IAnimationKey[] { + const range = getCurveSampleRange(curve, startTime, stopTime); + const keys = curve.keys + .filter((key) => key.time >= range.start && key.time <= range.stop) + .map((key) => ({ + source: key, + frame: key.time * fps, + value: mapValue(key.value), + })); + + if (!keys.some((key) => Math.abs(key.source.time - range.start) < 1e-6)) { + keys.unshift({ + source: { + time: range.start, + value: sampleFBXCurveAtTime(curve, range.start) ?? 0, + interpolation: "linear", + }, + frame: range.start * fps, + value: mapValue(sampleFBXCurveAtTime(curve, range.start) ?? 0), + }); + } + + if (!keys.some((key) => Math.abs(key.source.time - range.stop) < 1e-6)) { + keys.push({ + source: { + time: range.stop, + value: sampleFBXCurveAtTime(curve, range.stop) ?? 0, + interpolation: "linear", + }, + frame: range.stop * fps, + value: mapValue(sampleFBXCurveAtTime(curve, range.stop) ?? 0), + }); + } + + const animationKeys: IAnimationKey[] = keys.map((key) => ({ + frame: key.frame, + value: key.value, + })); + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i].source; + const nextAnimationKey = animationKeys[i + 1]; + + if (key.interpolation === "constant") { + animationKeys[i].interpolation = AnimationKeyInterpolation.STEP; + continue; + } + + if (key.interpolation !== "cubic") { + continue; + } + + const nextKey = keys[i + 1].source; + const duration = Math.max(nextKey.time - key.time, 1e-6); + const linearSlope = (nextKey.value - key.value) / duration; + animationKeys[i].outTangent = mapSlope(key.rightSlope ?? linearSlope, mapValue) / fps; + nextAnimationKey.inTangent = mapSlope(key.nextLeftSlope ?? linearSlope, mapValue) / fps; + } + + return animationKeys; +} + +function mapSlope(slope: number, mapValue: (value: number) => number): number { + return mapValue(slope) - mapValue(0); +} + +function getCurveSampleRange(curve: FBXCurveData, startTime: number, stopTime: number): { start: number; stop: number } { + if (stopTime > startTime) { + return { start: startTime, stop: stopTime }; + } + + return { + start: curve.keys[0]?.time ?? 0, + stop: curve.keys[curve.keys.length - 1]?.time ?? 0, + }; +} diff --git a/packages/dev/loaders/src/FBX/index.ts b/packages/dev/loaders/src/FBX/index.ts new file mode 100644 index 00000000000..690df80f2c1 --- /dev/null +++ b/packages/dev/loaders/src/FBX/index.ts @@ -0,0 +1,13 @@ +export { FBXFileLoader } from "./fbxFileLoader"; +export type { FBXFileLoaderOptions, FBXNormalMapCoordinateSystem } from "./fbxFileLoader"; +export { FBXFileLoaderMetadata } from "./fbxFileLoader.metadata"; +export { parseBinaryFBX } from "./parsers/fbxBinaryParser"; +export { parseAsciiFBX } from "./parsers/fbxAsciiParser"; +export { interpretFBX } from "./interpreter/fbxInterpreter"; +export type { FBXDocument, FBXNode, FBXProperty } from "./types/fbxTypes"; +export type { FBXSceneData, FBXModelData } from "./interpreter/fbxInterpreter"; +export type { FBXGeometryData } from "./interpreter/geometry"; +export type { FBXMaterialData, FBXTextureRef, FBXMaterialProperties } from "./interpreter/materials"; +export type { FBXSkinData, FBXBoneData } from "./interpreter/skeleton"; +export type { FBXRigData, FBXRigBoneData, FBXSkinBindingData } from "./interpreter/rig"; +export type { FBXAnimationStackData, FBXCurveNodeData, FBXCurveData, FBXKeyframe } from "./interpreter/animation"; diff --git a/packages/dev/loaders/src/FBX/interpreter/animation.ts b/packages/dev/loaders/src/FBX/interpreter/animation.ts new file mode 100644 index 00000000000..3bb394bdd2e --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/animation.ts @@ -0,0 +1,833 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXNode, findChildByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes"; + +import { type FBXObjectMap, getChildren } from "./connections"; + +/** FBX time units: 46186158000 ticks per second */ +const FBX_TIME_UNIT = 46186158000; +const KEY_ATTR_DATA_STRIDE = 4; +const SAMPLED_CURVE_MIN_KEY_COUNT = 8; +const SAMPLED_CURVE_MAX_INTERVAL_SECONDS = 1 / 23; +const SAMPLED_CURVE_UNIFORM_TOLERANCE_RATIO = 0.05; +const SAMPLED_CURVE_LINEAR_DEVIATION_RATIO = 0.01; +const SAMPLED_CURVE_LINEAR_DEVIATION_ABSOLUTE = 1e-4; +const SAMPLED_CURVE_DEGENERATE_SLOPE_ABSOLUTE = 1e-5; +const SAMPLED_CURVE_COMMON_FPS = [24, 25, 30, 48, 50, 60, 100, 120]; + +export type FBXInterpolationType = "constant" | "linear" | "cubic"; + +/** A single keyframe */ +export interface FBXKeyframe { + /** Time in seconds */ + time: number; + /** Value at this keyframe */ + value: number; + /** Interpolation used from this key to the next key */ + interpolation: FBXInterpolationType; + /** Constant interpolation variant */ + constantMode?: "standard" | "next"; + /** Cubic outgoing slope in value units per second */ + rightSlope?: number; + /** Cubic incoming slope for the next key, in value units per second */ + nextLeftSlope?: number; +} + +/** An animation curve (one axis of one property) */ +export interface FBXCurveData { + /** Channel: "d|X", "d|Y", "d|Z" */ + channel: string; + /** Keyframes */ + keys: FBXKeyframe[]; + /** True for baked sample curves that should be connected as linear samples */ + isSampled?: boolean; +} + +/** An animation curve node (T/R/S for one bone) */ +export interface FBXCurveNodeData { + /** Property type: "T" (translation), "R" (rotation), "S" (scale) */ + type: string; + /** Target model (bone) ID */ + targetModelId: bigint; + /** Curves for each axis */ + curves: FBXCurveData[]; +} + +export interface FBXUnsupportedCurveNodeData { + /** Raw AnimationCurveNode property type/name */ + type: string; + /** CurveNode object ID */ + id: bigint; + /** Target object ID if the curve node is connected to an object/property */ + targetId: bigint | null; + /** OP connection property name on the target, e.g. Visibility */ + propertyName?: string; + /** Number of connected animation curves that were ignored */ + curveCount: number; + /** Connected curves preserved for diagnostics and future runtime support */ + curves: FBXCurveData[]; + /** Local default values stored on the unsupported curve node */ + defaultValues: Record; +} + +export interface FBXAnimationDiagnostic { + type: "multiple-animation-layers" | "unsupported-layer-blend-mode" | "partial-layer-weight" | "unsupported-curve-node"; + message: string; + layerName?: string; + curveNodeId?: bigint; + curveNodeType?: string; + targetId?: bigint | null; + propertyName?: string; +} + +/** Animation layer with blend mode info */ +export interface FBXAnimationLayerData { + /** Layer name */ + name: string; + /** Layer weight (0-100, default 100) */ + weight: number; + /** Layer weight normalized to 0-1 */ + normalizedWeight: number; + /** Blend mode: 0=Additive, 1=Override, 2=OverridePassthrough */ + blendMode: number; + /** Curve nodes in this layer */ + curveNodes: FBXCurveNodeData[]; + /** Unsupported/non-TRS curve nodes preserved for diagnostics */ + unsupportedCurveNodes: FBXUnsupportedCurveNodeData[]; + /** Recoverable layer diagnostics */ + diagnostics: FBXAnimationDiagnostic[]; +} + +/** One animation clip (AnimationStack) */ +export interface FBXAnimationStackData { + /** Animation name */ + name: string; + /** Clip start in seconds after any keyframe rebasing */ + startTime: number; + /** Clip stop in seconds after any keyframe rebasing */ + stopTime: number; + /** Duration in seconds */ + duration: number; + /** Per-bone curve nodes (flattened from all layers for backward compat) */ + curveNodes: FBXCurveNodeData[]; + /** Animation layers (preserves blend mode info) */ + layers: FBXAnimationLayerData[]; + /** Unsupported/non-TRS curve nodes preserved for diagnostics */ + unsupportedCurveNodes: FBXUnsupportedCurveNodeData[]; + /** Recoverable animation diagnostics */ + diagnostics: FBXAnimationDiagnostic[]; +} + +/** + * Extract all animation stacks from the FBX scene. + */ +export function extractAnimations(objectMap: FBXObjectMap): FBXAnimationStackData[] { + const stacks: FBXAnimationStackData[] = []; + + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name === "AnimationStack") { + const stack = extractAnimStack(id, node, objectMap); + if (stack) { + stacks.push(stack); + } + } + } + + return stacks; +} + +function extractAnimStack(stackId: bigint, stackNode: FBXNode, objectMap: FBXObjectMap): FBXAnimationStackData | null { + const name = cleanFBXName(getPropertyValue(stackNode, 1) ?? "Animation"); + const declaredTimeSpan = extractAnimationStackTimeSpan(stackNode); + + // Find AnimationLayer children of this stack + const layerEntries = getChildren(objectMap, stackId, "AnimationLayer"); + if (layerEntries.length === 0) { + return null; + } + + // Collect all CurveNodes from all layers + const allCurveNodes: FBXCurveNodeData[] = []; + const allUnsupportedCurveNodes: FBXUnsupportedCurveNodeData[] = []; + const layers: FBXAnimationLayerData[] = []; + const diagnostics: FBXAnimationDiagnostic[] = []; + let minTime = Infinity; + let maxTime = 0; + + for (const { id: layerId, node: layerNode } of layerEntries) { + // Extract layer properties + const layerName = cleanFBXName(getPropertyValue(layerNode, 1) ?? "Layer"); + let weight = 100; + let blendMode = 0; + + const props70 = findChildByName(layerNode, "Properties70"); + if (props70) { + for (const p of props70.children) { + if (p.name !== "P") { + continue; + } + const pName = getPropertyValue(p, 0); + if (pName === "Weight") { + const v = p.properties[4]?.value; + if (typeof v === "number") { + weight = v; + } + } else if (pName === "BlendMode") { + const v = p.properties[4]?.value; + if (typeof v === "number") { + blendMode = v; + } else if (typeof v === "bigint") { + blendMode = Number(v); + } + } + } + } + + // AnimationCurveNodes are children of the layer + const curveNodeEntries = getChildren(objectMap, layerId, "AnimationCurveNode"); + const layerCurveNodes: FBXCurveNodeData[] = []; + const layerUnsupportedCurveNodes: FBXUnsupportedCurveNodeData[] = []; + const layerDiagnostics: FBXAnimationDiagnostic[] = []; + + for (const { id: curveNodeId, node: curveNodeNode } of curveNodeEntries) { + const curveNodeData = extractCurveNode(curveNodeId, curveNodeNode, objectMap); + if (!curveNodeData) { + const unsupported = extractUnsupportedCurveNode(curveNodeId, curveNodeNode, objectMap); + if (unsupported) { + scanCurveTimes(unsupported.curves, (time) => { + if (time < minTime) { + minTime = time; + } + if (time > maxTime) { + maxTime = time; + } + }); + layerUnsupportedCurveNodes.push(unsupported); + allUnsupportedCurveNodes.push(unsupported); + const diagnostic: FBXAnimationDiagnostic = { + type: "unsupported-curve-node", + message: `AnimationCurveNode '${unsupported.type}' is preserved as diagnostic data but not evaluated at runtime.`, + layerName, + curveNodeId, + curveNodeType: unsupported.type, + targetId: unsupported.targetId, + propertyName: unsupported.propertyName, + }; + layerDiagnostics.push(diagnostic); + diagnostics.push(diagnostic); + } + continue; + } + + for (const curve of curveNodeData.curves) { + for (const key of curve.keys) { + if (key.time < minTime) { + minTime = key.time; + } + if (key.time > maxTime) { + maxTime = key.time; + } + } + } + + layerCurveNodes.push(curveNodeData); + allCurveNodes.push(curveNodeData); + } + + layers.push({ + name: layerName, + weight, + normalizedWeight: weight / 100, + blendMode, + curveNodes: layerCurveNodes, + unsupportedCurveNodes: layerUnsupportedCurveNodes, + diagnostics: layerDiagnostics, + }); + } + + if (allCurveNodes.length === 0 && allUnsupportedCurveNodes.length === 0) { + return null; + } + + if (layers.length > 1) { + diagnostics.push({ + type: "multiple-animation-layers", + message: "Multiple animation layers are preserved, but runtime blending is not yet evaluated.", + }); + } + for (const layer of layers) { + if (layer.blendMode !== 0) { + const diagnostic: FBXAnimationDiagnostic = { + type: "unsupported-layer-blend-mode", + message: `Animation layer blend mode ${layer.blendMode} is preserved but not yet blended at runtime.`, + layerName: layer.name, + }; + layer.diagnostics.push(diagnostic); + diagnostics.push(diagnostic); + } + if (layer.weight !== 100) { + const diagnostic: FBXAnimationDiagnostic = { + type: "partial-layer-weight", + message: `Animation layer weight ${layer.weight} is preserved but not yet applied at runtime.`, + layerName: layer.name, + }; + layer.diagnostics.push(diagnostic); + diagnostics.push(diagnostic); + } + } + + const timeOffset = minTime > 0 && isFinite(minTime) ? minTime : 0; + + // Rebase all keyframe times so the animation starts at 0 + if (timeOffset > 0) { + for (const cn of allCurveNodes) { + for (const curve of cn.curves) { + for (const key of curve.keys) { + key.time -= timeOffset; + } + } + } + for (const cn of allUnsupportedCurveNodes) { + for (const curve of cn.curves) { + for (const key of curve.keys) { + key.time -= timeOffset; + } + } + } + maxTime -= timeOffset; + } + + const declaredStart = declaredTimeSpan ? Math.max(declaredTimeSpan.start - timeOffset, 0) : 0; + const declaredStop = declaredTimeSpan ? Math.max(declaredTimeSpan.stop - timeOffset, declaredStart) : 0; + const hasDeclaredDuration = declaredStop > declaredStart; + const startTime = hasDeclaredDuration ? declaredStart : 0; + const stopTime = hasDeclaredDuration ? declaredStop : maxTime; + + return { + name, + startTime, + stopTime, + duration: Math.max(stopTime - startTime, 0), + curveNodes: allCurveNodes, + layers, + unsupportedCurveNodes: allUnsupportedCurveNodes, + diagnostics, + }; +} + +function extractAnimationStackTimeSpan(stackNode: FBXNode): { start: number; stop: number } | null { + const props70 = findChildByName(stackNode, "Properties70"); + if (!props70) { + return null; + } + + let start = 0; + let stop: number | null = null; + + for (const p of props70.children) { + if (p.name !== "P") { + continue; + } + const pName = getPropertyValue(p, 0); + if (pName === "LocalStart" || pName === "ReferenceStart") { + start = fbxTimeToSeconds(p.properties[4]?.value) ?? start; + } else if (pName === "LocalStop" || pName === "ReferenceStop") { + stop = fbxTimeToSeconds(p.properties[4]?.value) ?? stop; + } + } + + return stop !== null ? { start, stop } : null; +} + +function extractCurveNode(curveNodeId: bigint, curveNodeNode: FBXNode, objectMap: FBXObjectMap): FBXCurveNodeData | null { + const typeName = cleanFBXName(getPropertyValue(curveNodeNode, 1) ?? ""); + + // Handle T (translation), R (rotation), S (scale) targeting Models + if (typeName === "T" || typeName === "R" || typeName === "S") { + const targetModelId = findCurveNodeTarget(curveNodeId, objectMap); + if (targetModelId === null) { + return null; + } + + const curves = extractCurves(curveNodeId, objectMap); + if (curves.length === 0) { + return null; + } + + return { + type: typeName, + targetModelId, + curves, + }; + } + + // Handle DeformPercent targeting BlendShapeChannels + if (typeName === "DeformPercent") { + const targetId = findCurveNodeBlendShapeTarget(curveNodeId, objectMap); + if (targetId === null) { + return null; + } + + const curves = extractCurves(curveNodeId, objectMap); + if (curves.length === 0) { + return null; + } + + return { + type: "DeformPercent", + targetModelId: targetId, + curves, + }; + } + + return null; +} + +function extractUnsupportedCurveNode(curveNodeId: bigint, curveNodeNode: FBXNode, objectMap: FBXObjectMap): FBXUnsupportedCurveNodeData | null { + const typeName = cleanFBXName(getPropertyValue(curveNodeNode, 1) ?? ""); + const curves = extractCurves(curveNodeId, objectMap); + const defaultValues = extractCurveNodeDefaultValues(curveNodeNode); + if (curves.length === 0 && Object.keys(defaultValues).length === 0) { + return null; + } + + let targetId: bigint | null = null; + let propertyName: string | undefined; + for (const conn of objectMap.connections) { + if (conn.childId === curveNodeId && conn.type === "OP") { + targetId = conn.parentId; + propertyName = conn.propertyName; + break; + } + } + + return { + type: typeName, + id: curveNodeId, + targetId, + propertyName, + curveCount: curves.length, + curves, + defaultValues, + }; +} + +function scanCurveTimes(curves: FBXCurveData[], visit: (time: number) => void): void { + for (const curve of curves) { + for (const key of curve.keys) { + visit(key.time); + } + } +} + +/** + * Find the Model that an AnimationCurveNode targets. + * The CurveNode connects to the Model via OP connection with a property name. + */ +function findCurveNodeTarget(curveNodeId: bigint, objectMap: FBXObjectMap): bigint | null { + // Look for connections where this curveNode is a child (going up to parent) + // The OP connection from curveNode → Model has the property name (e.g. "Lcl Translation") + for (const conn of objectMap.connections) { + if (conn.childId === curveNodeId && conn.type === "OP") { + const parentNode = objectMap.objects.get(conn.parentId); + if (parentNode && parentNode.name === "Model") { + return conn.parentId; + } + } + } + return null; +} + +/** + * Find the BlendShapeChannel that a DeformPercent AnimationCurveNode targets. + */ +function findCurveNodeBlendShapeTarget(curveNodeId: bigint, objectMap: FBXObjectMap): bigint | null { + for (const conn of objectMap.connections) { + if (conn.childId === curveNodeId && conn.type === "OP") { + const parentNode = objectMap.objects.get(conn.parentId); + if (parentNode && parentNode.name === "Deformer") { + const subType = getPropertyValue(parentNode, 2); + if (subType === "BlendShapeChannel") { + return conn.parentId; + } + } + } + } + // Also check OO connections + for (const conn of objectMap.connections) { + if (conn.childId === curveNodeId && conn.type === "OO") { + const parentNode = objectMap.objects.get(conn.parentId); + if (parentNode && parentNode.name === "Deformer") { + const subType = getPropertyValue(parentNode, 2); + if (subType === "BlendShapeChannel") { + return conn.parentId; + } + } + } + } + return null; +} + +/** + * Extract AnimationCurves connected to a CurveNode. + * Each curve connects via OP with channel "d|X", "d|Y", or "d|Z". + */ +function extractCurves(curveNodeId: bigint, objectMap: FBXObjectMap): FBXCurveData[] { + const curves: FBXCurveData[] = []; + + // Find AnimationCurve children of this CurveNode + for (const conn of objectMap.connections) { + if (conn.parentId === curveNodeId && conn.type === "OP") { + const curveNode = objectMap.objects.get(conn.childId); + if (!curveNode || curveNode.name !== "AnimationCurve") { + continue; + } + + const channel = conn.propertyName ?? "d|X"; + const keys = extractKeyframes(curveNode); + if (keys.length > 0) { + const isSampled = isSampledAnimationCurve(curveNode, keys); + curves.push({ channel, keys: isSampled ? makeLinearSampleKeys(keys) : keys, isSampled }); + } + } + } + + // Also check OO connections (some exporters use OO for curve→curveNode) + if (curves.length === 0) { + const ooChildren = getChildren(objectMap, curveNodeId, "AnimationCurve"); + // For OO connections, infer channel from order (X, Y, Z) + const channelNames = ["d|X", "d|Y", "d|Z"]; + for (let i = 0; i < ooChildren.length && i < 3; i++) { + const keys = extractKeyframes(ooChildren[i].node); + if (keys.length > 0) { + const isSampled = isSampledAnimationCurve(ooChildren[i].node, keys); + curves.push({ channel: channelNames[i], keys: isSampled ? makeLinearSampleKeys(keys) : keys, isSampled }); + } + } + } + + return curves; +} + +function extractCurveNodeDefaultValues(curveNodeNode: FBXNode): Record { + const defaults: Record = {}; + const props70 = findChildByName(curveNodeNode, "Properties70"); + for (const p of props70?.children ?? []) { + if (p.name !== "P") { + continue; + } + const propName = getPropertyValue(p, 0); + if (!propName?.startsWith("d|")) { + continue; + } + const value = toNumber(p.properties[4]?.value); + if (value !== null) { + defaults[propName] = value; + } + } + return defaults; +} + +/** + * Extract keyframes from an AnimationCurve node. + */ +function extractKeyframes(curveNode: FBXNode): FBXKeyframe[] { + const keyTimeNode = findChildByName(curveNode, "KeyTime"); + const keyValueNode = findChildByName(curveNode, "KeyValueFloat"); + + if (!keyTimeNode || !keyValueNode) { + return []; + } + + const keyTimes = toInt64Array(keyTimeNode.properties[0]?.value); + const keyValues = toFloat32Array(keyValueNode.properties[0]?.value); + const keyAttrFlags = toInt32Array(findChildByName(curveNode, "KeyAttrFlags")?.properties[0]?.value); + const keyAttrData = toFloat32Array(findChildByName(curveNode, "KeyAttrDataFloat")?.properties[0]?.value); + const keyAttrRefCount = toInt32Array(findChildByName(curveNode, "KeyAttrRefCount")?.properties[0]?.value); + + if (!keyTimes || !keyValues) { + return []; + } + if (keyTimes.length !== keyValues.length) { + return []; + } + + const keyAttributeIndices = buildKeyAttributeIndices(keyTimes.length, keyAttrFlags, keyAttrRefCount); + + const keys: FBXKeyframe[] = []; + for (let i = 0; i < keyTimes.length; i++) { + const attrIndex = keyAttributeIndices[i]; + const flag = attrIndex >= 0 ? (keyAttrFlags?.[attrIndex] ?? 0) : 0; + const dataOffset = attrIndex * KEY_ATTR_DATA_STRIDE; + + keys.push({ + time: Number(keyTimes[i]) / FBX_TIME_UNIT, + value: keyValues[i], + interpolation: getInterpolationType(flag), + constantMode: (flag & 0x00000100) !== 0 ? "next" : "standard", + rightSlope: getFiniteKeyAttrData(keyAttrData, dataOffset), + nextLeftSlope: getFiniteKeyAttrData(keyAttrData, dataOffset + 1), + }); + } + + return keys; +} + +function isSampledAnimationCurve(curveNode: FBXNode, keys: readonly FBXKeyframe[]): boolean { + const rawName = getPropertyValue(curveNode, 1) ?? ""; + return cleanFBXName(rawName) === "FbxMayaSample Curve" || isFrameBakedSampledCurve(keys); +} + +export function isFrameBakedSampledCurve(keys: readonly FBXKeyframe[]): boolean { + if (keys.length < SAMPLED_CURVE_MIN_KEY_COUNT) { + return false; + } + + const deltas: number[] = []; + for (let i = 1; i < keys.length; i++) { + const delta = keys[i].time - keys[i - 1].time; + if (!(delta > 0)) { + return false; + } + deltas.push(delta); + } + + const averageDelta = deltas.reduce((sum, delta) => sum + delta, 0) / deltas.length; + if (averageDelta > SAMPLED_CURVE_MAX_INTERVAL_SECONDS) { + return false; + } + + const uniformTolerance = Math.max(1e-6, averageDelta * SAMPLED_CURVE_UNIFORM_TOLERANCE_RATIO); + if (deltas.some((delta) => Math.abs(delta - averageDelta) > uniformTolerance)) { + return false; + } + + const sampledFps = 1 / averageDelta; + const matchesCommonFps = SAMPLED_CURVE_COMMON_FPS.some((fps) => Math.abs(sampledFps - fps) <= Math.max(0.25, fps * 0.02)); + if (!matchesCommonFps) { + return false; + } + + return !hasMeaningfulCubicTangents(keys); +} + +function makeLinearSampleKeys(keys: FBXKeyframe[]): FBXKeyframe[] { + return keys.map((key) => ({ + time: key.time, + value: key.value, + interpolation: "linear", + })); +} + +function hasMeaningfulCubicTangents(keys: readonly FBXKeyframe[]): boolean { + let hasCubicSegment = false; + let hasCompleteTangents = true; + let allSlopesDegenerate = true; + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + let maxLinearDeviation = 0; + + for (const key of keys) { + minValue = Math.min(minValue, key.value); + maxValue = Math.max(maxValue, key.value); + } + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const nextKey = keys[i + 1]; + if (key.interpolation !== "cubic") { + continue; + } + + hasCubicSegment = true; + const segmentDuration = nextKey.time - key.time; + if (!(segmentDuration > 0)) { + continue; + } + + const linearSlope = (nextKey.value - key.value) / segmentDuration; + const rightSlope = key.rightSlope; + const nextLeftSlope = key.nextLeftSlope; + if (rightSlope === undefined || nextLeftSlope === undefined) { + hasCompleteTangents = false; + continue; + } + + if (Math.abs(rightSlope) > SAMPLED_CURVE_DEGENERATE_SLOPE_ABSOLUTE || Math.abs(nextLeftSlope) > SAMPLED_CURVE_DEGENERATE_SLOPE_ABSOLUTE) { + allSlopesDegenerate = false; + } + + for (const t of [0.25, 0.5, 0.75]) { + const cubic = cubicHermite(key.value, nextKey.value, rightSlope, nextLeftSlope, segmentDuration, t); + const linear = key.value + t * segmentDuration * linearSlope; + maxLinearDeviation = Math.max(maxLinearDeviation, Math.abs(cubic - linear)); + } + } + + if (!hasCubicSegment || !hasCompleteTangents || allSlopesDegenerate) { + return false; + } + + const range = maxValue - minValue; + const deviationTolerance = Math.max(SAMPLED_CURVE_LINEAR_DEVIATION_ABSOLUTE, range * SAMPLED_CURVE_LINEAR_DEVIATION_RATIO); + return maxLinearDeviation > deviationTolerance; +} + +export function sampleFBXCurveAtTime(curveData: FBXCurveData | undefined, time: number): number | null { + if (!curveData || curveData.keys.length === 0) { + return null; + } + + const keys = curveData.keys; + + if (time <= keys[0].time) { + return keys[0].value; + } + if (time >= keys[keys.length - 1].time) { + return keys[keys.length - 1].value; + } + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const nextKey = keys[i + 1]; + if (time < key.time || time > nextKey.time) { + continue; + } + + if (nextKey.time === key.time) { + return key.value; + } + if (key.interpolation === "constant") { + return key.constantMode === "next" ? nextKey.value : key.value; + } + + const segmentDuration = nextKey.time - key.time; + const t = (time - key.time) / segmentDuration; + + if (key.interpolation === "cubic" && !curveData.isSampled) { + const linearSlope = (nextKey.value - key.value) / segmentDuration; + const rightSlope = key.rightSlope ?? linearSlope; + const nextLeftSlope = key.nextLeftSlope ?? linearSlope; + return cubicHermite(key.value, nextKey.value, rightSlope, nextLeftSlope, segmentDuration, t); + } + + return key.value + t * (nextKey.value - key.value); + } + + return keys[keys.length - 1].value; +} + +// ── Utilities ────────────────────────────────────────────────────────────────── + +function toInt64Array(value: unknown): BigInt64Array | null { + if (value instanceof BigInt64Array) { + return value; + } + return null; +} + +function toInt32Array(value: unknown): Int32Array | null { + if (value instanceof Int32Array) { + return value; + } + return null; +} + +function fbxTimeToSeconds(value: unknown): number | null { + if (typeof value === "bigint") { + return Number(value) / FBX_TIME_UNIT; + } + if (typeof value === "number") { + return value / FBX_TIME_UNIT; + } + return null; +} + +function toNumber(value: unknown): number | null { + if (typeof value === "number") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + return null; +} + +function toFloat32Array(value: unknown): Float32Array | null { + if (value instanceof Float32Array) { + return value; + } + if (value instanceof Float64Array) { + const result = new Float32Array(value.length); + for (let i = 0; i < value.length; i++) { + result[i] = value[i]; + } + return result; + } + return null; +} + +function buildKeyAttributeIndices(keyCount: number, keyAttrFlags: Int32Array | null, keyAttrRefCount: Int32Array | null): number[] { + if (!keyAttrFlags || keyAttrFlags.length === 0) { + return new Array(keyCount).fill(-1); + } + + if (keyAttrRefCount && keyAttrRefCount.length > 0) { + let total = 0; + for (const count of keyAttrRefCount) { + total += count; + } + + if (total === keyCount) { + const indices: number[] = []; + for (let attrIndex = 0; attrIndex < keyAttrRefCount.length; attrIndex++) { + const count = keyAttrRefCount[attrIndex]; + for (let i = 0; i < count; i++) { + indices.push(attrIndex); + } + } + return indices; + } + } + + if (keyAttrFlags.length === keyCount) { + return Array.from({ length: keyCount }, (_, i) => i); + } + + if (keyAttrFlags.length === 1) { + return new Array(keyCount).fill(0); + } + + return Array.from({ length: keyCount }, (_, i) => Math.min(i, keyAttrFlags.length - 1)); +} + +function getInterpolationType(flag: number): FBXInterpolationType { + if ((flag & 0x00000008) !== 0) { + return "cubic"; + } + if ((flag & 0x00000004) !== 0) { + return "linear"; + } + if ((flag & 0x00000002) !== 0) { + return "constant"; + } + return "linear"; +} + +function getFiniteKeyAttrData(keyAttrData: Float32Array | null, index: number): number | undefined { + if (!keyAttrData || index < 0 || index >= keyAttrData.length) { + return undefined; + } + const value = keyAttrData[index]; + return Number.isFinite(value) ? value : undefined; +} + +function cubicHermite(value0: number, value1: number, slope0: number, slope1: number, segmentDuration: number, t: number): number { + const t2 = t * t; + const t3 = t2 * t; + const h00 = 2 * t3 - 3 * t2 + 1; + const h10 = t3 - 2 * t2 + t; + const h01 = -2 * t3 + 3 * t2; + const h11 = t3 - t2; + + return h00 * value0 + h10 * segmentDuration * slope0 + h01 * value1 + h11 * segmentDuration * slope1; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts b/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts new file mode 100644 index 00000000000..a0ae97dce9b --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts @@ -0,0 +1,268 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXNode, findChildByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes"; + +import { type FBXObjectMap, getChildren } from "./connections"; + +/** A single morph target (shape) within a blend shape channel */ +export interface FBXShapeData { + /** Sparse vertex indices affected by this shape */ + indices: Uint32Array; + /** Absolute vertex positions for affected vertices [x,y,z,...] */ + vertices: Float64Array; + /** Normals for affected vertices [x,y,z,...] (optional) */ + normals: Float64Array | null; +} + +export interface FBXBlendShapeDiagnostic { + type: "full-weights-mismatch" | "missing-full-weights"; + message: string; + channelId: bigint; + channelName: string; +} + +/** A blend shape channel (one animatable morph target) */ +export interface FBXBlendShapeChannelData { + /** Channel name */ + name: string; + /** Channel node ID */ + id: bigint; + /** Default weight (0-100 in FBX) */ + deformPercent: number; + /** Shape geometry (typically one per channel, but FBX supports in-between shapes) */ + shapes: FBXShapeData[]; + /** In-between full weights in FBX DeformPercent units (0-100), one per shape when present */ + fullWeights: number[] | null; + /** Recoverable blend-shape diagnostics */ + diagnostics: FBXBlendShapeDiagnostic[]; +} + +/** A blend shape deformer attached to a geometry */ +export interface FBXBlendShapeData { + /** Deformer ID */ + id: bigint; + /** Geometry ID this blend shape is attached to */ + geometryId: bigint; + /** Channels (each is an animatable morph target) */ + channels: FBXBlendShapeChannelData[]; +} + +/** + * Extract all blend shape deformers from the FBX scene. + */ +export function extractBlendShapes(objectMap: FBXObjectMap): FBXBlendShapeData[] { + const blendShapes: FBXBlendShapeData[] = []; + + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name === "Deformer" && getPropertyValue(node, 2) === "BlendShape") { + const bs = extractBlendShape(id, node, objectMap); + if (bs) { + blendShapes.push(bs); + } + } + } + + return blendShapes; +} + +function extractBlendShape(deformerId: bigint, _deformerNode: FBXNode, objectMap: FBXObjectMap): FBXBlendShapeData | null { + // Find the geometry this blend shape is attached to + const parent = objectMap.parentOf.get(deformerId); + if (!parent) { + return null; + } + + const parentNode = objectMap.objects.get(parent.id); + if (!parentNode || parentNode.name !== "Geometry") { + return null; + } + + const geometryId = parent.id; + + // Find BlendShapeChannel children + const channels: FBXBlendShapeChannelData[] = []; + const channelChildren = getChildren(objectMap, deformerId, "Deformer"); + + for (const { id: channelId, node: channelNode } of channelChildren) { + const subType = getPropertyValue(channelNode, 2); + if (subType !== "BlendShapeChannel") { + continue; + } + + const channelName = cleanFBXName(getPropertyValue(channelNode, 1) ?? "MorphTarget"); + + // Read DeformPercent from Properties70 + let deformPercent = 0; + const props70 = findChildByName(channelNode, "Properties70"); + if (props70) { + for (const p of props70.children) { + if (p.name !== "P") { + continue; + } + const pName = getPropertyValue(p, 0); + if (pName === "DeformPercent") { + const val = p.properties[4]?.value; + if (typeof val === "number") { + deformPercent = val; + } else if (typeof val === "bigint") { + deformPercent = Number(val); + } + } + } + } + + const rawFullWeights = extractFullWeights(channelNode); + + // Find connected Shape geometries + const shapes: FBXShapeData[] = []; + const shapeChildren = getChildren(objectMap, channelId, "Geometry"); + + for (const { node: shapeNode } of shapeChildren) { + const shapeSubType = getPropertyValue(shapeNode, 2); + if (shapeSubType !== "Shape") { + continue; + } + + const shape = extractShape(shapeNode); + if (shape) { + shapes.push(shape); + } + } + + if (shapes.length > 0) { + const diagnostics: FBXBlendShapeDiagnostic[] = []; + const fullWeights = normalizeFullWeights(rawFullWeights, shapes, channelId, channelName, diagnostics); + channels.push({ + name: channelName, + id: channelId, + deformPercent, + shapes: sortShapesByFullWeight(shapes, fullWeights), + fullWeights: fullWeights ? [...fullWeights].sort((a, b) => a - b) : null, + diagnostics, + }); + } + } + + if (channels.length === 0) { + return null; + } + + return { + id: deformerId, + geometryId, + channels, + }; +} + +function extractFullWeights(channelNode: FBXNode): number[] | null { + const fullWeightsNode = findChildByName(channelNode, "FullWeights"); + const rawFullWeights = fullWeightsNode?.properties[0]?.value; + if (!rawFullWeights) { + return null; + } + + if (rawFullWeights instanceof Float64Array || rawFullWeights instanceof Float32Array || rawFullWeights instanceof Int32Array) { + return Array.from(rawFullWeights, (value) => Number(value)); + } + return null; +} + +function normalizeFullWeights( + fullWeights: number[] | null, + shapes: FBXShapeData[], + channelId: bigint, + channelName: string, + diagnostics: FBXBlendShapeDiagnostic[] +): number[] | null { + if (!fullWeights) { + if (shapes.length > 1) { + diagnostics.push({ + type: "missing-full-weights", + message: "Blend shape channel has multiple shapes but no FullWeights; using the first shape for compatibility.", + channelId, + channelName, + }); + } + return null; + } + + if (fullWeights.length !== shapes.length) { + if (shapes.length === 1) { + return null; + } + + diagnostics.push({ + type: "full-weights-mismatch", + message: `FullWeights length ${fullWeights.length} does not match shape count ${shapes.length}; using the first shape for compatibility.`, + channelId, + channelName, + }); + return null; + } + + return fullWeights; +} + +function sortShapesByFullWeight(shapes: FBXShapeData[], fullWeights: number[] | null): FBXShapeData[] { + if (!fullWeights || fullWeights.length !== shapes.length) { + return shapes.length > 1 ? [shapes[0]] : shapes; + } + + return shapes + .map((shape, index) => ({ shape, weight: fullWeights[index] })) + .sort((a, b) => a.weight - b.weight) + .map((entry) => entry.shape); +} + +function extractShape(shapeNode: FBXNode): FBXShapeData | null { + // Shape has: Indexes (sparse vertex indices), Vertices (delta offsets from base), Normals (optional delta) + const indexesNode = findChildByName(shapeNode, "Indexes"); + const verticesNode = findChildByName(shapeNode, "Vertices"); + + if (!indexesNode || !verticesNode) { + return null; + } + + const rawIndices = indexesNode.properties[0]?.value; + const rawVertices = verticesNode.properties[0]?.value; + + if (!rawIndices || !rawVertices) { + return null; + } + + // Convert indices + let indices: Uint32Array; + if (rawIndices instanceof Int32Array) { + indices = new Uint32Array(rawIndices.length); + for (let i = 0; i < rawIndices.length; i++) { + indices[i] = rawIndices[i]; + } + } else if (rawIndices instanceof Uint32Array) { + indices = rawIndices; + } else { + return null; + } + + // Convert vertices + let vertices: Float64Array; + if (rawVertices instanceof Float64Array) { + vertices = rawVertices; + } else if (rawVertices instanceof Float32Array) { + vertices = new Float64Array(rawVertices); + } else { + return null; + } + + // Optional normals + let normals: Float64Array | null = null; + const normalsNode = findChildByName(shapeNode, "Normals"); + if (normalsNode) { + const rawNormals = normalsNode.properties[0]?.value; + if (rawNormals instanceof Float64Array) { + normals = rawNormals; + } else if (rawNormals instanceof Float32Array) { + normals = new Float64Array(rawNormals); + } + } + + return { indices, vertices, normals }; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/connections.ts b/packages/dev/loaders/src/FBX/interpreter/connections.ts new file mode 100644 index 00000000000..13c0947bbc8 --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/connections.ts @@ -0,0 +1,335 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXDocument, type FBXNode, cleanFBXName, findDocumentNode, getPropertyValue } from "../types/fbxTypes"; + +/** Connection type: OO = object-to-object, OP = object-to-property */ +export type ConnectionType = "OO" | "OP"; + +export interface FBXConnection { + type: ConnectionType; + childId: bigint; + parentId: bigint; + /** For OP connections, the property name on the parent (e.g. "DiffuseColor") */ + propertyName?: string; +} + +export interface FBXObjectEntry { + id: bigint; + node: FBXNode; + source: "Objects" | "legacySyntheticGeometry"; + legacyName?: string; + synthetic: boolean; +} + +export interface FBXConnectionEntry { + source: "C" | "Connect"; + rawType?: string; + childId?: bigint; + parentId?: bigint; + propertyName?: string; + accepted: boolean; +} + +export type FBXConnectionDiagnosticReason = + | "unsupported-connection-type" + | "missing-connection-endpoint" + | "unresolved-legacy-endpoint" + | "unresolved-object-reference" + | "duplicate-parent" + | "self-loop"; + +export interface FBXConnectionDiagnostic { + reason: FBXConnectionDiagnosticReason; + message: string; + connectionIndex?: number; + type?: string; + childId?: bigint; + parentId?: bigint; + propertyName?: string; +} + +export interface FBXObjectMap { + /** All objects by their unique ID */ + objects: Map; + /** Object table entries, including synthetic compatibility objects */ + objectEntries: FBXObjectEntry[]; + /** Children of each object ID */ + childrenOf: Map; + /** Parent of each object ID */ + parentOf: Map; + /** Raw connection list */ + connections: FBXConnection[]; + /** Raw connection-table entries and whether they were accepted into the graph */ + connectionEntries: FBXConnectionEntry[]; + /** Unsupported or suspicious connection shapes encountered while preserving graph behavior */ + diagnostics: FBXConnectionDiagnostic[]; +} + +/** + * Build a connection graph from a parsed FBX document. + * Maps object IDs to their FBXNode and resolves parent-child relationships. + */ +export function resolveConnections(doc: FBXDocument): FBXObjectMap { + const objects = new Map(); + const objectEntries: FBXObjectEntry[] = []; + const childrenOf = new Map(); + const parentOf = new Map(); + const connections: FBXConnection[] = []; + const connectionEntries: FBXConnectionEntry[] = []; + const diagnostics: FBXConnectionDiagnostic[] = []; + const legacyIds = new Map(); + const syntheticLegacyIds = new Map>(); + let nextLegacyId = -1n; + + const getLegacyId = (name: string): bigint => { + let id = legacyIds.get(name); + if (id === undefined) { + id = nextLegacyId--; + legacyIds.set(name, id); + } + return id; + }; + + const getSyntheticLegacyId = (role: string, name: string): bigint => { + let idsByName = syntheticLegacyIds.get(role); + if (!idsByName) { + idsByName = new Map(); + syntheticLegacyIds.set(role, idsByName); + } + + let id = idsByName.get(name); + if (id === undefined) { + id = nextLegacyId--; + idsByName.set(name, id); + } + return id; + }; + + // Build object map from Objects section + const objectsNode = findDocumentNode(doc, "Objects"); + if (objectsNode) { + for (const obj of objectsNode.children) { + const idProp = obj.properties[0]; + if (idProp) { + const id = toBigInt(idProp.value); + if (id !== undefined) { + objects.set(id, obj); + objectEntries.push({ id, node: obj, source: "Objects", synthetic: false }); + } else if (typeof idProp.value === "string") { + const legacyName = cleanFBXName(idProp.value); + const id = getLegacyId(legacyName); + const normalized = normalizeLegacyObject(obj, id); + objects.set(id, normalized); + objectEntries.push({ id, node: normalized, source: "Objects", legacyName, synthetic: false }); + + if (obj.name === "Model" && getPropertyValue(obj, 1) === "Mesh") { + const geometryId = getSyntheticLegacyId("Geometry", legacyName); + const geometry = createLegacyGeometry(obj, geometryId); + objects.set(geometryId, geometry); + objectEntries.push({ id: geometryId, node: geometry, source: "legacySyntheticGeometry", legacyName, synthetic: true }); + addConnection(connections, childrenOf, parentOf, diagnostics, "OO", geometryId, id); + } + } + } + } + } + + // Parse connections + const connectionsNode = findDocumentNode(doc, "Connections"); + if (connectionsNode) { + for (const c of connectionsNode.children) { + if (c.name !== "C" && c.name !== "Connect") { + continue; + } + + const connectionIndex = connectionEntries.length; + const type = getPropertyValue(c, 0); + const childIdRaw = c.properties[1]?.value; + const parentIdRaw = c.properties[2]?.value; + const entry: FBXConnectionEntry = { + source: c.name, + rawType: type, + accepted: false, + }; + connectionEntries.push(entry); + + if (type !== "OO" && type !== "OP") { + const childId = childIdRaw === undefined ? undefined : toObjectId(childIdRaw, legacyIds); + const parentId = parentIdRaw === undefined ? undefined : toObjectId(parentIdRaw, legacyIds); + diagnostics.push({ + reason: "unsupported-connection-type", + message: `Unsupported FBX connection type '${type ?? ""}' was not added to the graph.`, + connectionIndex, + type, + childId, + parentId, + }); + continue; + } + + if (childIdRaw === undefined || parentIdRaw === undefined) { + diagnostics.push({ + reason: "missing-connection-endpoint", + message: "FBX connection is missing a child or parent endpoint.", + connectionIndex, + type, + }); + continue; + } + + const childId = toObjectId(childIdRaw, legacyIds); + const parentId = toObjectId(parentIdRaw, legacyIds); + if (childId === undefined || parentId === undefined) { + diagnostics.push({ + reason: "unresolved-legacy-endpoint", + message: "FBX connection references a legacy string endpoint that is not present in the object table.", + connectionIndex, + type, + }); + continue; + } + + const propertyName = type === "OP" && c.properties.length > 3 ? getPropertyValue(c, 3) : undefined; + + entry.childId = childId; + entry.parentId = parentId; + entry.propertyName = propertyName; + + if (childId === parentId) { + diagnostics.push({ + reason: "self-loop", + message: "FBX connection references the same object as child and parent.", + connectionIndex, + type, + childId, + parentId, + propertyName, + }); + } + if (!objects.has(childId)) { + diagnostics.push({ + reason: "unresolved-object-reference", + message: "FBX connection child ID is not present in the object table.", + connectionIndex, + type, + childId, + parentId, + propertyName, + }); + } + if (parentId !== 0n && !objects.has(parentId)) { + diagnostics.push({ + reason: "unresolved-object-reference", + message: "FBX connection parent ID is not present in the object table.", + connectionIndex, + type, + childId, + parentId, + propertyName, + }); + } + + addConnection(connections, childrenOf, parentOf, diagnostics, type, childId, parentId, propertyName, connectionIndex); + entry.accepted = true; + } + } + + return { objects, objectEntries, childrenOf, parentOf, connections, connectionEntries, diagnostics }; +} + +/** Get all child objects of a given parent ID, optionally filtered by node name */ +export function getChildren(map: FBXObjectMap, parentId: bigint, nodeName?: string): { id: bigint; node: FBXNode; propertyName?: string }[] { + const children = map.childrenOf.get(parentId) ?? []; + const result: { id: bigint; node: FBXNode; propertyName?: string }[] = []; + + for (const child of children) { + const node = map.objects.get(child.id); + if (node && (!nodeName || node.name === nodeName)) { + result.push({ id: child.id, node, propertyName: child.propertyName }); + } + } + + return result; +} + +function toBigInt(value: unknown): bigint | undefined { + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number") { + return BigInt(Math.round(value)); + } + return undefined; +} + +function toObjectId(value: unknown, legacyIds: Map): bigint | undefined { + const numericId = toBigInt(value); + if (numericId !== undefined) { + return numericId; + } + if (typeof value !== "string") { + return undefined; + } + const legacyName = cleanFBXName(value); + if (legacyName === "Scene") { + return 0n; + } + return legacyIds.get(legacyName); +} + +function addConnection( + connections: FBXConnection[], + childrenOf: Map, + parentOf: Map, + diagnostics: FBXConnectionDiagnostic[], + type: ConnectionType, + childId: bigint, + parentId: bigint, + propertyName?: string, + connectionIndex?: number +): void { + connections.push({ type, childId, parentId, propertyName }); + + if (!childrenOf.has(parentId)) { + childrenOf.set(parentId, []); + } + childrenOf.get(parentId)!.push({ id: childId, propertyName }); + const existingParent = parentOf.get(childId); + if (existingParent) { + diagnostics.push({ + reason: "duplicate-parent", + message: "FBX object has multiple parents; preserving the existing last-parent behavior.", + connectionIndex, + type, + childId, + parentId, + propertyName, + }); + } + parentOf.set(childId, { id: parentId, propertyName }); +} + +function normalizeLegacyObject(node: FBXNode, id: bigint): FBXNode { + const name = cleanFBXName(getPropertyValue(node, 0) ?? node.name); + const subType = getPropertyValue(node, 1) ?? ""; + return { + ...node, + properties: [ + { type: "int64", value: id }, + { type: "string", value: name }, + { type: "string", value: subType }, + ], + }; +} + +function createLegacyGeometry(modelNode: FBXNode, geometryId: bigint): FBXNode { + const name = cleanFBXName(getPropertyValue(modelNode, 0) ?? "Geometry"); + return { + name: "Geometry", + properties: [ + { type: "int64", value: geometryId }, + { type: "string", value: name }, + { type: "string", value: "Mesh" }, + ], + children: modelNode.children, + }; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts b/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts new file mode 100644 index 00000000000..9bdc443f5dc --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts @@ -0,0 +1,767 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXDocument, type FBXNode, findDocumentNode, findChildByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes"; + +import { resolveConnections, getChildren, type FBXObjectMap } from "./connections"; +import { extractGeometry, type FBXGeometryData } from "./geometry"; +import { extractMaterial, type FBXMaterialData } from "./materials"; +import { extractSkins, type FBXSkinData } from "./skeleton"; +import { resolveRigs, type FBXRigData } from "./rig"; +import { extractAnimations, type FBXAnimationStackData } from "./animation"; +import { extractBlendShapes, type FBXBlendShapeData } from "./blendShapes"; +import { extractSceneDiagnostics, type FBXSceneDiagnostic } from "./sceneDiagnostics"; +import { + extractPropertyTemplates, + getPropertyTemplate, + resolveNumberProperty, + resolvePropertyValue, + resolveVector3Property, + type FBXPropertyTemplate, + type FBXPropertyTemplateMap, +} from "./propertyTemplates"; + +/** Represents a model (transform node) in the FBX scene */ +export interface FBXModelData { + id: bigint; + name: string; + subType: string; + /** Geometry attached to this model (if it's a Mesh type) */ + geometry?: FBXGeometryData; + /** Materials assigned to this model */ + materials: FBXMaterialData[]; + /** Child models */ + children: FBXModelData[]; + /** Transform properties */ + translation: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; + /** PreRotation (applied before Lcl Rotation, in degrees) */ + preRotation: [number, number, number]; + /** PostRotation (applied after Lcl Rotation, inverted, in degrees) */ + postRotation: [number, number, number]; + /** RotationPivot — point around which rotation occurs */ + rotationPivot: [number, number, number]; + /** ScalingPivot — point around which scaling occurs */ + scalingPivot: [number, number, number]; + /** RotationOffset — translation after rotation pivot */ + rotationOffset: [number, number, number]; + /** ScalingOffset — translation after scaling pivot */ + scalingOffset: [number, number, number]; + /** Geometric transforms — applied to geometry only, do not affect children */ + geometricTranslation: [number, number, number]; + geometricRotation: [number, number, number]; + geometricScaling: [number, number, number]; + /** Rotation order: 0=XYZ, 1=XZY, 2=YZX, 3=YXZ, 4=ZXY, 5=ZYX */ + rotationOrder: number; + /** FBX transform inheritance mode. 0=RrSs, 1=RSrs, 2=Rrs */ + inheritType: number; + /** Whether backface culling is disabled ("CullingOff") */ + cullingOff: boolean; + /** User-defined custom properties from Properties70 */ + customProperties?: Record; + /** Recoverable model import diagnostics */ + diagnostics: string[]; +} + +/** Camera data extracted from FBX */ +export interface FBXCameraData { + /** Model ID this camera is attached to */ + modelId: bigint; + /** Camera name */ + name: string; + /** Field of view in degrees */ + fieldOfView: number; + /** Near clip plane */ + nearPlane: number; + /** Far clip plane */ + farPlane: number; + /** Aspect ratio (width/height), 0 = use viewport */ + aspectRatio: number; + /** Projection type */ + projectionType: "perspective" | "orthographic"; + /** Focal length in millimeters when present */ + focalLength?: number; + /** Filmback width in inches when present */ + filmWidth?: number; + /** Filmback height in inches when present */ + filmHeight?: number; + /** Orthographic zoom/height when present */ + orthoZoom?: number; + /** Camera roll in degrees when present */ + roll?: number; + /** Known unsupported or unrecognized camera properties */ + unknownProperties: string[]; + /** Recoverable camera import diagnostics */ + diagnostics: string[]; +} + +/** Light data extracted from FBX */ +export interface FBXLightData { + /** Model ID this light is attached to */ + modelId: bigint; + /** Light name */ + name: string; + /** Light type: 0=Point, 1=Directional, 2=Spot */ + lightType: number; + /** Color [r,g,b] 0-1 */ + color: [number, number, number]; + /** Intensity multiplier */ + intensity: number; + /** Cone angle in degrees (for spot lights) */ + coneAngle: number; + /** Decay type: 0=None, 1=Linear, 2=Quadratic */ + decayType: number; + /** Inner cone angle in degrees for spot lights */ + innerAngle?: number; + /** Outer cone angle in degrees for spot lights */ + outerAngle?: number; + /** Distance at which FBX attenuation starts; preserved as metadata */ + decayStart?: number; + /** Whether FBX near attenuation is enabled */ + enableNearAttenuation?: boolean; + /** Whether FBX far attenuation is enabled */ + enableFarAttenuation?: boolean; + /** Whether the source light requested shadow casting */ + castShadows?: boolean; + /** Known unsupported or unrecognized light properties */ + unknownProperties: string[]; + /** Recoverable light import diagnostics */ + diagnostics: string[]; +} + +/** Result of interpreting an FBX document */ +export interface FBXSceneData { + /** All root-level models */ + rootModels: FBXModelData[]; + /** All geometries in the scene */ + geometries: FBXGeometryData[]; + /** All materials in the scene */ + materials: FBXMaterialData[]; + /** Skin deformers (skeletons + vertex weights) */ + skins: FBXSkinData[]; + /** Resolved deformation rigs shared by one or more skins */ + rigs: FBXRigData[]; + /** Blend shape deformers (morph targets) */ + blendShapes: FBXBlendShapeData[]; + /** Animation stacks (clips) */ + animations: FBXAnimationStackData[]; + /** Cameras */ + cameras: FBXCameraData[]; + /** Lights */ + lights: FBXLightData[]; + /** Scene-level unsupported feature diagnostics */ + diagnostics: FBXSceneDiagnostic[]; + /** Global settings */ + upAxis: number; + upAxisSign: number; + frontAxis: number; + frontAxisSign: number; + coordAxis: number; + coordAxisSign: number; + unitScaleFactor: number; +} + +/** + * Interpret a parsed FBX document into scene data. + */ +export function interpretFBX(doc: FBXDocument): FBXSceneData { + const objectMap = resolveConnections(doc); + const propertyTemplates = extractPropertyTemplates(doc); + + // Extract global settings + const globalSettings = extractGlobalSettings(doc); + + // Extract all materials + const materials: FBXMaterialData[] = []; + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name === "Material") { + materials.push(extractMaterial(node, id, objectMap, propertyTemplates)); + } + } + + // Extract all geometries + const geometries: FBXGeometryData[] = []; + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name === "Geometry") { + const subType = getPropertyValue(node, 2); + if (subType === "Mesh") { + geometries.push(extractGeometry(node, id)); + } + } + } + + // Extract skeleton/skinning data + const skins = extractSkins(objectMap); + const rigs = resolveRigs(objectMap, skins); + + // Extract blend shape data + const blendShapes = extractBlendShapes(objectMap); + + // Extract animation data + const animations = extractAnimations(objectMap); + + // Extract cameras and lights from NodeAttribute objects + const cameras = extractCameras(objectMap, propertyTemplates); + const lights = extractLights(objectMap, propertyTemplates); + const diagnostics = extractSceneDiagnostics(objectMap); + + // Build model hierarchy + const rootModels = buildModelHierarchy(objectMap, geometries, materials, propertyTemplates); + + return { + rootModels, + geometries, + materials, + skins, + rigs, + blendShapes, + animations, + cameras, + lights, + diagnostics, + ...globalSettings, + }; +} + +// ── Model Hierarchy ──────────────────────────────────────────────────────────── + +function buildModelHierarchy(objectMap: FBXObjectMap, geometries: FBXGeometryData[], materials: FBXMaterialData[], propertyTemplates: FBXPropertyTemplateMap): FBXModelData[] { + const geometryMap = new Map(); + for (const g of geometries) { + geometryMap.set(g.id, g); + } + + const materialMap = new Map(); + for (const m of materials) { + materialMap.set(m.id, m); + } + + // Find root models (those connected to ID 0, which is the scene root) + const rootChildren = objectMap.childrenOf.get(0n) ?? []; + const rootModels: FBXModelData[] = []; + + for (const { id } of rootChildren) { + const node = objectMap.objects.get(id); + if (node && node.name === "Model") { + rootModels.push(buildModel(id, node, objectMap, geometryMap, materialMap, propertyTemplates)); + } + } + + return rootModels; +} + +function buildModel( + modelId: bigint, + modelNode: FBXNode, + objectMap: FBXObjectMap, + geometryMap: Map, + materialMap: Map, + propertyTemplates: FBXPropertyTemplateMap +): FBXModelData { + const name = cleanFBXName(getPropertyValue(modelNode, 1) ?? "Model"); + const subType = getPropertyValue(modelNode, 2) ?? "Null"; + + // Find attached geometry + const geomChildren = getChildren(objectMap, modelId, "Geometry"); + const geometry = geomChildren.length > 0 ? geometryMap.get(geomChildren[0].id) : undefined; + + // Find attached materials + const matChildren = getChildren(objectMap, modelId, "Material"); + const modelMaterials: FBXMaterialData[] = []; + for (const { id } of matChildren) { + const mat = materialMap.get(id); + if (mat) { + modelMaterials.push(mat); + } + } + + // Extract transform + const transform = extractTransform(modelNode, getPropertyTemplate(propertyTemplates, "Model", "FbxNode") ?? getPropertyTemplate(propertyTemplates, "Model")); + + // Recursively build child models + const childModelNodes = getChildren(objectMap, modelId, "Model"); + const children: FBXModelData[] = []; + for (const { id, node } of childModelNodes) { + children.push(buildModel(id, node, objectMap, geometryMap, materialMap, propertyTemplates)); + } + + // Extract culling + const cullingNode = modelNode.children.find((c) => c.name === "Culling"); + const cullingOff = cullingNode ? getPropertyValue(cullingNode, 0) === "CullingOff" : false; + + // Extract user-defined custom properties + const customProperties = extractCustomProperties(modelNode); + + return { + id: modelId, + name, + subType, + geometry, + materials: modelMaterials, + children, + cullingOff, + customProperties, + ...transform, + }; +} + +function extractTransform( + modelNode: FBXNode, + template?: FBXPropertyTemplate +): { + translation: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; + preRotation: [number, number, number]; + postRotation: [number, number, number]; + rotationPivot: [number, number, number]; + scalingPivot: [number, number, number]; + rotationOffset: [number, number, number]; + scalingOffset: [number, number, number]; + geometricTranslation: [number, number, number]; + geometricRotation: [number, number, number]; + geometricScaling: [number, number, number]; + rotationOrder: number; + inheritType: number; + diagnostics: string[]; +} { + const translation = resolveVector3Property(modelNode, template, "Lcl Translation", [0, 0, 0]); + const rotation = resolveVector3Property(modelNode, template, "Lcl Rotation", [0, 0, 0]); + const scale = resolveVector3Property(modelNode, template, "Lcl Scaling", [1, 1, 1]); + const preRotation = resolveVector3Property(modelNode, template, "PreRotation", [0, 0, 0]); + const postRotation = resolveVector3Property(modelNode, template, "PostRotation", [0, 0, 0]); + const rotationPivot = resolveVector3Property(modelNode, template, "RotationPivot", [0, 0, 0]); + const scalingPivot = resolveVector3Property(modelNode, template, "ScalingPivot", [0, 0, 0]); + const rotationOffset = resolveVector3Property(modelNode, template, "RotationOffset", [0, 0, 0]); + const scalingOffset = resolveVector3Property(modelNode, template, "ScalingOffset", [0, 0, 0]); + const geometricTranslation = resolveVector3Property(modelNode, template, "GeometricTranslation", [0, 0, 0]); + const geometricRotation = resolveVector3Property(modelNode, template, "GeometricRotation", [0, 0, 0]); + const geometricScaling = resolveVector3Property(modelNode, template, "GeometricScaling", [1, 1, 1]); + const rotationOrder = resolveNumberProperty(modelNode, template, "RotationOrder", 0); + const inheritType = resolveNumberProperty(modelNode, template, "InheritType", 1); + const diagnostics = + inheritType !== 1 && inheritType !== 2 + ? [ + `InheritType ${inheritType} is parsed and preserved; runtime parent-scale inheritance remains gated to avoid changing existing visual behavior without a fixture-specific baseline.`, + ] + : []; + + return { + translation, + rotation, + scale, + preRotation, + postRotation, + rotationPivot, + scalingPivot, + rotationOffset, + scalingOffset, + geometricTranslation, + geometricRotation, + geometricScaling, + rotationOrder, + inheritType, + diagnostics, + }; +} + +// ── Global Settings ──────────────────────────────────────────────────────────── + +interface GlobalSettings { + upAxis: number; + upAxisSign: number; + frontAxis: number; + frontAxisSign: number; + coordAxis: number; + coordAxisSign: number; + unitScaleFactor: number; +} + +function extractGlobalSettings(doc: FBXDocument): GlobalSettings { + const defaults: GlobalSettings = { + upAxis: 1, + upAxisSign: 1, + frontAxis: 2, + frontAxisSign: 1, + coordAxis: 0, + coordAxisSign: 1, + unitScaleFactor: 1, + }; + + const gsNode = findDocumentNode(doc, "GlobalSettings"); + if (!gsNode) { + return defaults; + } + + const props70 = gsNode.children.find((c) => c.name === "Properties70"); + if (!props70) { + return defaults; + } + + for (const p of props70.children) { + if (p.name !== "P") { + continue; + } + const propName = getPropertyValue(p, 0); + const value = toNumber(p.properties[4]?.value); + if (propName && value !== undefined) { + switch (propName) { + case "UpAxis": + defaults.upAxis = value; + break; + case "UpAxisSign": + defaults.upAxisSign = value; + break; + case "FrontAxis": + defaults.frontAxis = value; + break; + case "FrontAxisSign": + defaults.frontAxisSign = value; + break; + case "CoordAxis": + defaults.coordAxis = value; + break; + case "CoordAxisSign": + defaults.coordAxisSign = value; + break; + case "UnitScaleFactor": + defaults.unitScaleFactor = value; + break; + } + } + } + + return defaults; +} + +// ── Cameras & Lights ────────────────────────────────────────────────────────── + +const SYSTEM_PROPERTIES = new Set([ + "Lcl Translation", + "Lcl Rotation", + "Lcl Scaling", + "PreRotation", + "PostRotation", + "RotationPivot", + "ScalingPivot", + "RotationOffset", + "ScalingOffset", + "RotationOrder", + "GeometricTranslation", + "GeometricRotation", + "GeometricScaling", + "Visibility", + "InheritType", + "ScalingMax", + "DefaultAttributeIndex", + "currentUVSet", + "lockInfluenceWeights", +]); + +function extractCustomProperties(modelNode: FBXNode): Record | undefined { + const props70 = findChildByName(modelNode, "Properties70"); + if (!props70) { + return undefined; + } + + const custom: Record = {}; + let hasAny = false; + + for (const p of props70.children) { + if (p.name !== "P") { + continue; + } + const propName = getPropertyValue(p, 0); + if (!propName || SYSTEM_PROPERTIES.has(propName)) { + continue; + } + + // Accept user-defined properties (type starts with something other than standard types) + // Standard FBX types: "KString", "Number", "double", "int", "bool", "Lcl"... + // User properties often have types like "KString", but are in the UDP (User Defined Properties) section + // Heuristic: if not in SYSTEM_PROPERTIES set, it's user-defined + const val = p.properties[4]?.value; + if (val === undefined) { + continue; + } + + if (typeof val === "string") { + custom[propName] = val; + hasAny = true; + } else if (typeof val === "number") { + custom[propName] = val; + hasAny = true; + } else if (typeof val === "bigint") { + custom[propName] = Number(val); + hasAny = true; + } else if (typeof val === "boolean") { + custom[propName] = val; + hasAny = true; + } + } + + return hasAny ? custom : undefined; +} + +const CAMERA_PROPERTIES = new Set([ + "FieldOfView", + "FieldOfViewX", + "FieldOfViewY", + "NearPlane", + "FarPlane", + "AspectWidth", + "AspectHeight", + "FilmAspectRatio", + "FocalLength", + "FilmWidth", + "FilmHeight", + "ApertureWidth", + "ApertureHeight", + "CameraProjectionType", + "ProjectionType", + "OrthoZoom", + "Roll", + "ApertureMode", +]); + +const LIGHT_PROPERTIES = new Set([ + "LightType", + "Color", + "Intensity", + "InnerAngle", + "OuterAngle", + "ConeAngle", + "DecayType", + "DecayStart", + "EnableNearAttenuation", + "EnableFarAttenuation", + "CastShadow", + "Shadow", +]); + +function extractCameras(objectMap: FBXObjectMap, templates: FBXPropertyTemplateMap): FBXCameraData[] { + const cameras: FBXCameraData[] = []; + const cameraTemplate = getPropertyTemplate(templates, "NodeAttribute", "FbxCamera") ?? getPropertyTemplate(templates, "NodeAttribute"); + + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name !== "NodeAttribute") { + continue; + } + const subType = getPropertyValue(node, 2); + if (subType !== "Camera") { + continue; + } + + // Find the model this camera is attached to (parent) + const parent = objectMap.parentOf.get(id); + if (!parent) { + continue; + } + const parentNode = objectMap.objects.get(parent.id); + if (!parentNode || parentNode.name !== "Model") { + continue; + } + + const name = cleanFBXName(getPropertyValue(parentNode, 1) ?? "Camera"); + + const nearPlane = resolveNumberProperty(node, cameraTemplate, "NearPlane", 0.1); + const farPlane = resolveNumberProperty(node, cameraTemplate, "FarPlane", 10000); + const aspectRatio = resolveCameraAspectRatio(node, cameraTemplate); + const projectionType = + resolveNumberProperty(node, cameraTemplate, "CameraProjectionType", 0) === 1 || resolveNumberProperty(node, cameraTemplate, "ProjectionType", 0) === 1 + ? "orthographic" + : "perspective"; + const focalLength = toNumber(resolvePropertyValue(node, cameraTemplate, "FocalLength")); + const filmWidth = toNumber(resolvePropertyValue(node, cameraTemplate, "FilmWidth")) ?? toNumber(resolvePropertyValue(node, cameraTemplate, "ApertureWidth")); + const filmHeight = toNumber(resolvePropertyValue(node, cameraTemplate, "FilmHeight")) ?? toNumber(resolvePropertyValue(node, cameraTemplate, "ApertureHeight")); + const orthoZoom = toNumber(resolvePropertyValue(node, cameraTemplate, "OrthoZoom")); + const roll = toNumber(resolvePropertyValue(node, cameraTemplate, "Roll")); + const fieldOfView = resolveCameraFieldOfView(node, cameraTemplate, aspectRatio, focalLength, filmHeight); + const diagnostics: string[] = []; + if (projectionType === "orthographic" && orthoZoom === undefined) { + diagnostics.push("Orthographic camera has no OrthoZoom; runtime orthographic bounds use a fallback."); + } + if (focalLength !== undefined && filmHeight === undefined && resolvePropertyValue(node, cameraTemplate, "FieldOfView") === undefined) { + diagnostics.push("FocalLength is present without FilmHeight; default field of view fallback may be used."); + } + + cameras.push({ + modelId: parent.id, + name, + fieldOfView, + nearPlane, + farPlane, + aspectRatio, + projectionType, + focalLength, + filmWidth, + filmHeight, + orthoZoom, + roll, + unknownProperties: collectUnknownLocalProperties(node, CAMERA_PROPERTIES), + diagnostics, + }); + } + + return cameras; +} + +function extractLights(objectMap: FBXObjectMap, templates: FBXPropertyTemplateMap): FBXLightData[] { + const lights: FBXLightData[] = []; + const lightTemplate = getPropertyTemplate(templates, "NodeAttribute", "FbxLight") ?? getPropertyTemplate(templates, "NodeAttribute"); + + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name !== "NodeAttribute") { + continue; + } + const subType = getPropertyValue(node, 2); + if (subType !== "Light") { + continue; + } + + // Find the model this light is attached to + const parent = objectMap.parentOf.get(id); + if (!parent) { + continue; + } + const parentNode = objectMap.objects.get(parent.id); + if (!parentNode || parentNode.name !== "Model") { + continue; + } + + const name = cleanFBXName(getPropertyValue(parentNode, 1) ?? "Light"); + + const lightType = resolveNumberProperty(node, lightTemplate, "LightType", 0); + const color = resolveVector3Property(node, lightTemplate, "Color", [1, 1, 1]); + const intensity = resolveNumberProperty(node, lightTemplate, "Intensity", 100) / 100; + const outerAngle = toNumber(resolvePropertyValue(node, lightTemplate, "OuterAngle")) ?? toNumber(resolvePropertyValue(node, lightTemplate, "ConeAngle")); + const innerAngle = toNumber(resolvePropertyValue(node, lightTemplate, "InnerAngle")); + const coneAngle = outerAngle ?? 45; + const decayType = resolveNumberProperty(node, lightTemplate, "DecayType", 2); + const decayStart = toNumber(resolvePropertyValue(node, lightTemplate, "DecayStart")); + const enableNearAttenuation = toBoolean(resolvePropertyValue(node, lightTemplate, "EnableNearAttenuation")); + const enableFarAttenuation = toBoolean(resolvePropertyValue(node, lightTemplate, "EnableFarAttenuation")); + const castShadows = + toBoolean(resolvePropertyValue(node, lightTemplate, "CastShadow")) ?? + toBoolean(resolvePropertyValue(parentNode, undefined, "CastShadow")) ?? + toBoolean(resolvePropertyValue(parentNode, undefined, "Shadow")); + const diagnostics: string[] = []; + if (decayType !== 2) { + diagnostics.push(`DecayType ${decayType} is preserved as metadata; Babylon falloff is not remapped in this pass.`); + } + if (decayStart !== undefined) { + diagnostics.push("DecayStart is preserved as metadata and is not mapped to Babylon light range."); + } + + lights.push({ + modelId: parent.id, + name, + lightType, + color, + intensity, + coneAngle, + decayType, + innerAngle, + outerAngle, + decayStart, + enableNearAttenuation, + enableFarAttenuation, + castShadows, + unknownProperties: collectUnknownLocalProperties(node, LIGHT_PROPERTIES), + diagnostics, + }); + } + + return lights; +} + +// ── Utilities ────────────────────────────────────────────────────────────────── + +function toNumber(value: unknown): number | undefined { + if (typeof value === "number") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + return undefined; +} + +function toBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value !== 0; + } + if (typeof value === "bigint") { + return value !== 0n; + } + return undefined; +} + +function resolveCameraAspectRatio(node: FBXNode, template?: FBXPropertyTemplate): number { + const filmAspectRatio = toNumber(resolvePropertyValue(node, template, "FilmAspectRatio")); + if (filmAspectRatio !== undefined && filmAspectRatio > 0) { + return filmAspectRatio; + } + + const aspectWidth = toNumber(resolvePropertyValue(node, template, "AspectWidth")); + const aspectHeight = toNumber(resolvePropertyValue(node, template, "AspectHeight")); + if (aspectWidth !== undefined && aspectHeight !== undefined && aspectWidth > 0 && aspectHeight > 0) { + return aspectWidth / aspectHeight; + } + + return 0; +} + +function resolveCameraFieldOfView( + node: FBXNode, + template: FBXPropertyTemplate | undefined, + aspectRatio: number, + focalLength: number | undefined, + filmHeight: number | undefined +): number { + const verticalFov = toNumber(resolvePropertyValue(node, template, "FieldOfViewY")) ?? toNumber(resolvePropertyValue(node, template, "FieldOfView")); + if (verticalFov !== undefined) { + return verticalFov; + } + + const horizontalFov = toNumber(resolvePropertyValue(node, template, "FieldOfViewX")); + if (horizontalFov !== undefined) { + if (aspectRatio > 0) { + return radiansToDegrees(2 * Math.atan(Math.tan(degreesToRadians(horizontalFov) / 2) / aspectRatio)); + } + return horizontalFov; + } + + if (focalLength !== undefined && focalLength > 0 && filmHeight !== undefined && filmHeight > 0) { + return radiansToDegrees(2 * Math.atan((filmHeight * 25.4) / (2 * focalLength))); + } + + return 45; +} + +function collectUnknownLocalProperties(node: FBXNode, known: Set): string[] { + const unknown = new Set(); + for (const containerName of ["Properties70", "Properties60"]) { + const container = findChildByName(node, containerName); + for (const propertyNode of container?.children ?? []) { + if (propertyNode.name !== "P" && propertyNode.name !== "Property") { + continue; + } + const propertyName = getPropertyValue(propertyNode, 0); + if (propertyName && !known.has(propertyName)) { + unknown.add(propertyName); + } + } + } + return Array.from(unknown).sort(); +} + +function degreesToRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} + +function radiansToDegrees(radians: number): number { + return (radians * 180) / Math.PI; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/geometry.ts b/packages/dev/loaders/src/FBX/interpreter/geometry.ts new file mode 100644 index 00000000000..41369f6e417 --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/geometry.ts @@ -0,0 +1,747 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXNode, findChildByName, findChildrenByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes"; + +/** A named UV set */ +export interface FBXUVSet { + /** UV set name (e.g. "UVMap", "lightmap") */ + name: string; + /** Per-vertex UV data [u,v, ...] (expanded to match triangle vertices) */ + data: Float64Array; +} + +export interface FBXGeometryDiagnostic { + type: "degenerate-polygon" | "triangulation-fallback" | "layer-index-out-of-bounds" | "layer-data-too-short"; + message: string; + polygonIndex?: number; + layerName?: string; + index?: number; +} + +/** Parsed geometry data ready for Babylon consumption */ +export interface FBXGeometryData { + /** Node ID from the FBX document */ + id: bigint; + /** Geometry name */ + name: string; + /** Flat array of vertex positions [x,y,z, x,y,z, ...] */ + positions: Float64Array; + /** Triangle indices (already triangulated from n-gons) */ + indices: Uint32Array; + /** Per-vertex normals [x,y,z, ...] (expanded to match triangle vertices) */ + normals: Float64Array | null; + /** Per-vertex UVs [u,v, ...] (expanded to match triangle vertices) — first UV set for convenience */ + uvs: Float64Array | null; + /** All UV sets (including the first) */ + uvSets: FBXUVSet[]; + /** Per-vertex colors [r,g,b,a, ...] (expanded to match triangle vertices) */ + colors: Float32Array | null; + /** Per-vertex tangents [x,y,z,w, ...] expanded to match triangle vertices */ + tangents: Float64Array | null; + /** Per-vertex binormals [x,y,z, ...] expanded to match triangle vertices */ + binormals: Float64Array | null; + /** Control point index for each polygon-vertex (for skinning lookup) */ + controlPointIndices: Uint32Array | null; + /** Per-triangle material index (which material each triangle belongs to) */ + materialIndices: Int32Array | null; + /** Recoverable geometry import issues */ + diagnostics: FBXGeometryDiagnostic[]; +} + +/** + * Extract geometry data from an FBX Geometry node. + * Handles polygon triangulation and layer element expansion. + */ +export function extractGeometry(geometryNode: FBXNode, nodeId: bigint): FBXGeometryData { + const name = cleanFBXName(getPropertyValue(geometryNode, 1) ?? "Geometry"); + + // Extract raw vertices + const verticesNode = findChildByName(geometryNode, "Vertices"); + if (!verticesNode) { + throw new Error(`Geometry '${name}' has no Vertices node`); + } + const rawPositions = toFloat64Array(getNodeArrayValue(verticesNode)); + + // Extract polygon vertex indices + const pviNode = findChildByName(geometryNode, "PolygonVertexIndex"); + if (!pviNode) { + throw new Error(`Geometry '${name}' has no PolygonVertexIndex node`); + } + const rawIndices = toInt32Array(getNodeArrayValue(pviNode)); + const diagnostics: FBXGeometryDiagnostic[] = []; + + // Parse polygons from the FBX negative-index convention + const polygons = parsePolygons(rawIndices); + + // Triangulate polygons while preserving polygon-vertex indices for layer data. + const triangles = triangulatePolygons(polygons, rawPositions, diagnostics); + + // Build the list of polygon-vertex pairs for layer element expansion + const polyVertexList = buildPolygonVertexList(polygons); + + // Extract normals + const normalNode = findChildByName(geometryNode, "LayerElementNormal"); + let normals: Float64Array | null = null; + if (normalNode) { + normals = expandLayerElement(normalNode, "Normals", "NormalsIndex", polyVertexList, rawPositions.length / 3, 3, diagnostics); + } + + // Extract all UV sets + const uvNodes = findChildrenByName(geometryNode, "LayerElementUV"); + const uvSets: FBXUVSet[] = []; + for (const uvNode of uvNodes) { + const nameNode = findChildByName(uvNode, "Name"); + const setName = nameNode ? (getPropertyValue(nameNode, 0) ?? `UVSet${uvSets.length}`) : `UVSet${uvSets.length}`; + const data = expandLayerElement(uvNode, "UV", "UVIndex", polyVertexList, rawPositions.length / 3, 2, diagnostics); + if (data) { + uvSets.push({ name: setName, data }); + } + } + const uvs = uvSets.length > 0 ? uvSets[0].data : null; + + // Extract vertex colors + const colorNode = findChildByName(geometryNode, "LayerElementColor"); + let colors: Float32Array | null = null; + if (colorNode) { + const colorData = expandLayerElement(colorNode, "Colors", "ColorIndex", polyVertexList, rawPositions.length / 3, 4, diagnostics); + if (colorData) { + colors = new Float32Array(colorData.length); + for (let i = 0; i < colorData.length; i++) { + colors[i] = colorData[i]; + } + } + } + + const tangentNode = findChildByName(geometryNode, "LayerElementTangent"); + const binormalNode = findChildByName(geometryNode, "LayerElementBinormal"); + const binormals = binormalNode ? expandLayerElement(binormalNode, "Binormals", "BinormalsIndex", polyVertexList, rawPositions.length / 3, 3, diagnostics) : null; + const tangents = tangentNode ? expandTangentLayer(tangentNode, polyVertexList, rawPositions.length / 3, normals, binormals, diagnostics) : null; + + // Extract per-polygon material indices + const matNode = findChildByName(geometryNode, "LayerElementMaterial"); + let polyMaterialIndices: Int32Array | null = null; + if (matNode) { + polyMaterialIndices = extractMaterialIndices(matNode, polygons.length); + } + + // Build final indexed mesh with expanded per-triangle-vertex attributes + const result = buildTriangleMesh(rawPositions, triangles, polyVertexList, normals, uvs, uvSets, colors, tangents, binormals); + + // Expand per-polygon material indices to per-triangle + let materialIndices: Int32Array | null = null; + if (polyMaterialIndices) { + // Check if all polygons use the same material (optimization) + let allSame = true; + const firstMat = polyMaterialIndices[0]; + for (let i = 1; i < polyMaterialIndices.length; i++) { + if (polyMaterialIndices[i] !== firstMat) { + allSame = false; + break; + } + } + + if (!allSame) { + const triCount = result.indices.length / 3; + materialIndices = new Int32Array(triCount); + for (let ti = 0; ti < triangles.length; ti++) { + materialIndices[ti] = polyMaterialIndices[triangles[ti].polyIndex] ?? 0; + } + } + } + + return { + id: nodeId, + name, + positions: result.positions, + indices: result.indices, + normals: result.normals, + uvs: result.uvs, + uvSets: result.uvSets, + colors: result.colors, + tangents: result.tangents, + binormals: result.binormals, + controlPointIndices: result.controlPointIndices, + materialIndices, + diagnostics, + }; +} + +// ── Polygon Parsing ──────────────────────────────────────────────────────────── + +interface Polygon { + /** Control point indices for this polygon */ + indices: number[]; + /** Starting index in the original PolygonVertexIndex array */ + startIndex: number; +} + +interface Triangle { + vertices: [number, number, number]; + polyIndex: number; +} + +function parsePolygons(rawIndices: Int32Array): Polygon[] { + const polygons: Polygon[] = []; + let currentPoly: number[] = []; + let startIndex = 0; + + for (let i = 0; i < rawIndices.length; i++) { + const idx = rawIndices[i]; + if (idx < 0) { + // End of polygon: actual index is -(idx + 1) + currentPoly.push(-(idx + 1)); + polygons.push({ indices: currentPoly, startIndex }); + currentPoly = []; + startIndex = i + 1; + } else { + currentPoly.push(idx); + } + } + + return polygons; +} + +function triangulatePolygons(polygons: Polygon[], rawPositions: Float64Array, diagnostics: FBXGeometryDiagnostic[]): Triangle[] { + const triangles: Triangle[] = []; + + for (let polyIndex = 0; polyIndex < polygons.length; polyIndex++) { + const poly = polygons[polyIndex]; + triangles.push(...triangulatePolygon(poly, polyIndex, rawPositions, diagnostics)); + } + + return triangles; +} + +function triangulatePolygon(poly: Polygon, polyIndex: number, rawPositions: Float64Array, diagnostics: FBXGeometryDiagnostic[]): Triangle[] { + if (poly.indices.length < 3) { + diagnostics.push({ + type: "degenerate-polygon", + message: `Polygon ${polyIndex} has fewer than three vertices.`, + polygonIndex: polyIndex, + }); + return []; + } + if (poly.indices.length === 3) { + return [{ vertices: [poly.startIndex, poly.startIndex + 1, poly.startIndex + 2], polyIndex }]; + } + + const projected = projectPolygonTo2D(poly, rawPositions); + if (!projected) { + diagnostics.push({ + type: "degenerate-polygon", + message: `Polygon ${polyIndex} has a near-zero normal; using fan triangulation.`, + polygonIndex: polyIndex, + }); + return fanTriangulate(poly, polyIndex); + } + + const polygonArea = signedArea2D(projected); + if (Math.abs(polygonArea) < 1e-12) { + diagnostics.push({ + type: "degenerate-polygon", + message: `Polygon ${polyIndex} projects to near-zero area; using fan triangulation.`, + polygonIndex: polyIndex, + }); + return fanTriangulate(poly, polyIndex); + } + + const isCCW = polygonArea > 0; + const remaining = poly.indices.map((_, i) => i); + const clipped: Triangle[] = []; + let guard = 0; + + while (remaining.length > 3 && guard++ < poly.indices.length * poly.indices.length) { + let clippedEar = false; + + for (let i = 0; i < remaining.length; i++) { + const prev = remaining[(i + remaining.length - 1) % remaining.length]; + const curr = remaining[i]; + const next = remaining[(i + 1) % remaining.length]; + + if (!isConvex(projected[prev], projected[curr], projected[next], isCCW)) { + continue; + } + if (containsAnyPoint(projected, remaining, prev, curr, next)) { + continue; + } + + clipped.push({ + vertices: [poly.startIndex + prev, poly.startIndex + curr, poly.startIndex + next], + polyIndex, + }); + remaining.splice(i, 1); + clippedEar = true; + break; + } + + if (!clippedEar) { + diagnostics.push({ + type: "triangulation-fallback", + message: `Polygon ${polyIndex} could not be fully ear-clipped; using fan triangulation.`, + polygonIndex: polyIndex, + }); + return fanTriangulate(poly, polyIndex); + } + } + + clipped.push({ + vertices: [poly.startIndex + remaining[0], poly.startIndex + remaining[1], poly.startIndex + remaining[2]], + polyIndex, + }); + return clipped; +} + +function fanTriangulate(poly: Polygon, polyIndex: number): Triangle[] { + const triangles: Triangle[] = []; + for (let i = 1; i < poly.indices.length - 1; i++) { + triangles.push({ + vertices: [poly.startIndex, poly.startIndex + i, poly.startIndex + i + 1], + polyIndex, + }); + } + return triangles; +} + +function projectPolygonTo2D(poly: Polygon, rawPositions: Float64Array): [number, number][] | null { + const normal = computeNewellNormal(poly, rawPositions); + const ax = Math.abs(normal[0]); + const ay = Math.abs(normal[1]); + const az = Math.abs(normal[2]); + if (ax + ay + az < 1e-12) { + return null; + } + + const dropAxis = ax > ay && ax > az ? 0 : ay > az ? 1 : 2; + return poly.indices.map((cp) => { + const x = rawPositions[cp * 3]; + const y = rawPositions[cp * 3 + 1]; + const z = rawPositions[cp * 3 + 2]; + if (dropAxis === 0) { + return normal[0] >= 0 ? [y, z] : [z, y]; + } + if (dropAxis === 1) { + return normal[1] >= 0 ? [z, x] : [x, z]; + } + return normal[2] >= 0 ? [x, y] : [y, x]; + }); +} + +function computeNewellNormal(poly: Polygon, rawPositions: Float64Array): [number, number, number] { + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 0; i < poly.indices.length; i++) { + const current = poly.indices[i] * 3; + const next = poly.indices[(i + 1) % poly.indices.length] * 3; + const x0 = rawPositions[current]; + const y0 = rawPositions[current + 1]; + const z0 = rawPositions[current + 2]; + const x1 = rawPositions[next]; + const y1 = rawPositions[next + 1]; + const z1 = rawPositions[next + 2]; + nx += (y0 - y1) * (z0 + z1); + ny += (z0 - z1) * (x0 + x1); + nz += (x0 - x1) * (y0 + y1); + } + return [nx, ny, nz]; +} + +function signedArea2D(points: [number, number][]): number { + let area = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + area += a[0] * b[1] - b[0] * a[1]; + } + return area / 2; +} + +function isConvex(a: [number, number], b: [number, number], c: [number, number], isCCW: boolean): boolean { + const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + return isCCW ? cross > 1e-12 : cross < -1e-12; +} + +function containsAnyPoint(points: [number, number][], remaining: number[], prev: number, curr: number, next: number): boolean { + for (const index of remaining) { + if (index === prev || index === curr || index === next) { + continue; + } + if (pointInTriangle(points[index], points[prev], points[curr], points[next])) { + return true; + } + } + return false; +} + +function pointInTriangle(p: [number, number], a: [number, number], b: [number, number], c: [number, number]): boolean { + const area = Math.abs(cross2D(a, b, c)); + const area1 = Math.abs(cross2D(p, a, b)); + const area2 = Math.abs(cross2D(p, b, c)); + const area3 = Math.abs(cross2D(p, c, a)); + return Math.abs(area - (area1 + area2 + area3)) < 1e-10; +} + +function cross2D(a: [number, number], b: [number, number], c: [number, number]): number { + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); +} + +/** Build a flat list of (polygonIndex, vertexInPolygon, controlPointIndex) for each polygon vertex */ +interface PolyVertex { + polyIndex: number; + vertexInPoly: number; + controlPointIndex: number; + /** Global polygon-vertex index (position in the original PolygonVertexIndex array) */ + globalIndex: number; +} + +function buildPolygonVertexList(polygons: Polygon[]): PolyVertex[] { + const list: PolyVertex[] = []; + for (let pi = 0; pi < polygons.length; pi++) { + const poly = polygons[pi]; + for (let vi = 0; vi < poly.indices.length; vi++) { + list.push({ + polyIndex: pi, + vertexInPoly: vi, + controlPointIndex: poly.indices[vi], + globalIndex: poly.startIndex + vi, + }); + } + } + return list; +} + +// ── Layer Element Expansion ──────────────────────────────────────────────────── + +/** + * Extract per-polygon material indices from LayerElementMaterial. + * Returns an Int32Array with one material index per polygon. + */ +function extractMaterialIndices(matNode: FBXNode, polygonCount: number): Int32Array | null { + const mappingNode = findChildByName(matNode, "MappingInformationType"); + const referenceNode = findChildByName(matNode, "ReferenceInformationType"); + + if (!mappingNode || !referenceNode) { + return null; + } + + const mapping = getPropertyValue(mappingNode, 0) ?? ""; + const reference = getPropertyValue(referenceNode, 0) ?? ""; + + if (mapping === "AllSame") { + // All polygons use material index 0 + const indices = new Int32Array(polygonCount); + return indices; // already filled with 0 + } + + if (mapping === "ByPolygon") { + const materialsNode = findChildByName(matNode, "Materials"); + if (!materialsNode) { + return null; + } + const rawIndices = toInt32Array(getNodeArrayValue(materialsNode)); + // For Direct reference, the Materials array has one index per polygon + if (reference === "Direct" || reference === "IndexToDirect") { + return rawIndices; + } + } + + return null; +} + +function expandLayerElement( + layerNode: FBXNode, + dataChildName: string, + indexChildName: string, + polyVertexList: PolyVertex[], + controlPointCount: number, + stride: number, + diagnostics: FBXGeometryDiagnostic[] +): Float64Array | null { + const mappingNode = findChildByName(layerNode, "MappingInformationType"); + const referenceNode = findChildByName(layerNode, "ReferenceInformationType"); + + if (!mappingNode || !referenceNode) { + return null; + } + + const mapping = getPropertyValue(mappingNode, 0) ?? ""; + const reference = getPropertyValue(referenceNode, 0) ?? ""; + + const dataNode = findChildByName(layerNode, dataChildName); + if (!dataNode) { + return null; + } + const data = toFloat64Array(getNodeArrayValue(dataNode)); + + let indexData: Int32Array | null = null; + if (reference === "IndexToDirect") { + const indexNode = findChildByName(layerNode, indexChildName); + if (indexNode) { + indexData = toInt32Array(getNodeArrayValue(indexNode)); + } + } + + // Expand to per-polygon-vertex + const result = new Float64Array(polyVertexList.length * stride); + + for (let i = 0; i < polyVertexList.length; i++) { + const pv = polyVertexList[i]; + let dataIndex: number; + + if (mapping === "ByPolygonVertex") { + if (reference === "IndexToDirect" && indexData) { + dataIndex = indexData[pv.globalIndex]; + } else { + // Direct + dataIndex = pv.globalIndex; + } + } else if (mapping === "ByControlPoint" || mapping === "ByVertice") { + if (reference === "IndexToDirect" && indexData) { + dataIndex = indexData[pv.controlPointIndex]; + } else { + dataIndex = pv.controlPointIndex; + } + } else if (mapping === "ByPolygon") { + if (reference === "IndexToDirect" && indexData) { + dataIndex = indexData[pv.polyIndex]; + } else { + dataIndex = pv.polyIndex; + } + } else if (mapping === "AllSame") { + dataIndex = 0; + } else { + dataIndex = pv.globalIndex; + } + + for (let s = 0; s < stride; s++) { + const sourceIndex = dataIndex * stride + s; + if (dataIndex < 0 || sourceIndex >= data.length) { + diagnostics.push({ + type: sourceIndex >= data.length ? "layer-data-too-short" : "layer-index-out-of-bounds", + message: `Layer '${layerNode.name}' references unavailable element ${dataIndex}.`, + layerName: layerNode.name, + index: dataIndex, + }); + result[i * stride + s] = 0; + } else { + result[i * stride + s] = data[sourceIndex]; + } + } + } + + return result; +} + +function expandTangentLayer( + tangentNode: FBXNode, + polyVertexList: PolyVertex[], + controlPointCount: number, + normals: Float64Array | null, + binormals: Float64Array | null, + diagnostics: FBXGeometryDiagnostic[] +): Float64Array | null { + const sourceStride = inferLayerElementStride(tangentNode, "Tangents", "TangentsIndex", polyVertexList, controlPointCount, diagnostics); + const expanded = expandLayerElement(tangentNode, "Tangents", "TangentsIndex", polyVertexList, controlPointCount, sourceStride, diagnostics); + if (!expanded) { + return null; + } + + const tangents = new Float64Array(polyVertexList.length * 4); + for (let i = 0; i < polyVertexList.length; i++) { + const sourceOffset = i * sourceStride; + const destOffset = i * 4; + tangents[destOffset] = expanded[sourceOffset]; + tangents[destOffset + 1] = expanded[sourceOffset + 1]; + tangents[destOffset + 2] = expanded[sourceOffset + 2]; + tangents[destOffset + 3] = sourceStride >= 4 ? expanded[sourceOffset + 3] : computeTangentHandedness(i, tangents, normals, binormals); + } + return tangents; +} + +function inferLayerElementStride( + layerNode: FBXNode, + dataChildName: string, + indexChildName: string, + polyVertexList: PolyVertex[], + controlPointCount: number, + diagnostics: FBXGeometryDiagnostic[] +): number { + const dataNode = findChildByName(layerNode, dataChildName); + if (!dataNode) { + return 3; + } + const data = toFloat64Array(getNodeArrayValue(dataNode)); + const mapping = getPropertyValue(findChildByName(layerNode, "MappingInformationType") ?? { name: "", properties: [], children: [] }, 0) ?? ""; + const reference = getPropertyValue(findChildByName(layerNode, "ReferenceInformationType") ?? { name: "", properties: [], children: [] }, 0) ?? ""; + const indexNode = findChildByName(layerNode, indexChildName); + const indexData = indexNode ? toInt32Array(getNodeArrayValue(indexNode)) : null; + const directCount = + reference === "IndexToDirect" && indexData + ? Math.max(...Array.from(indexData), 0) + 1 + : mapping === "ByControlPoint" || mapping === "ByVertice" + ? controlPointCount + : mapping === "AllSame" + ? 1 + : polyVertexList.length; + + if (directCount > 0 && data.length % directCount === 0) { + const stride = data.length / directCount; + if (stride === 3 || stride === 4) { + return stride; + } + } + + diagnostics.push({ + type: "layer-data-too-short", + message: `Could not infer stride for layer '${layerNode.name}', defaulting to 3.`, + layerName: layerNode.name, + }); + return 3; +} + +function computeTangentHandedness(vertexIndex: number, tangents: Float64Array, normals: Float64Array | null, binormals: Float64Array | null): number { + if (!normals || !binormals) { + return 1; + } + const to = vertexIndex * 4; + const no = vertexIndex * 3; + const nx = normals[no]; + const ny = normals[no + 1]; + const nz = normals[no + 2]; + const tx = tangents[to]; + const ty = tangents[to + 1]; + const tz = tangents[to + 2]; + const bx = binormals[no]; + const by = binormals[no + 1]; + const bz = binormals[no + 2]; + const cx = ny * tz - nz * ty; + const cy = nz * tx - nx * tz; + const cz = nx * ty - ny * tx; + return cx * bx + cy * by + cz * bz < 0 ? -1 : 1; +} + +// ── Final Mesh Assembly ──────────────────────────────────────────────────────── + +interface TriangleMeshData { + positions: Float64Array; + indices: Uint32Array; + normals: Float64Array | null; + uvs: Float64Array | null; + uvSets: FBXUVSet[]; + colors: Float32Array | null; + tangents: Float64Array | null; + binormals: Float64Array | null; + controlPointIndices: Uint32Array; +} + +/** + * Build the final triangle mesh. Since normals/UVs are per-polygon-vertex, + * we need to create unique vertices for each polygon-vertex combination. + */ +function buildTriangleMesh( + rawPositions: Float64Array, + triangles: Triangle[], + polyVertexList: PolyVertex[], + expandedNormals: Float64Array | null, + expandedUVs: Float64Array | null, + expandedUVSets: FBXUVSet[], + expandedColors: Float32Array | null, + expandedTangents: Float64Array | null, + expandedBinormals: Float64Array | null +): TriangleMeshData { + // Each polygon-vertex becomes a unique vertex in the output + const vertexCount = polyVertexList.length; + const positions = new Float64Array(vertexCount * 3); + const controlPointIndices = new Uint32Array(vertexCount); + + // Copy positions — keep in original RH space (root node handles RH→LH conversion) + for (let i = 0; i < polyVertexList.length; i++) { + const cp = polyVertexList[i].controlPointIndex; + positions[i * 3] = rawPositions[cp * 3]; + positions[i * 3 + 1] = rawPositions[cp * 3 + 1]; + positions[i * 3 + 2] = rawPositions[cp * 3 + 2]; + controlPointIndices[i] = cp; + } + + // Normals stay in RH space (root node handles conversion) + if (expandedNormals) { + // No transformation needed + } + + // Keep original winding order — Z negation handles handedness + const indexCount = triangles.length * 3; + const indices = new Uint32Array(indexCount); + for (let i = 0; i < triangles.length; i++) { + indices[i * 3] = triangles[i].vertices[0]; + indices[i * 3 + 1] = triangles[i].vertices[1]; + indices[i * 3 + 2] = triangles[i].vertices[2]; + } + + return { + positions, + indices, + normals: expandedNormals, + uvs: expandedUVs, + uvSets: expandedUVSets, + colors: expandedColors, + tangents: expandedTangents, + binormals: expandedBinormals, + controlPointIndices, + }; +} + +// ── Utilities ────────────────────────────────────────────────────────────────── + +function toFloat64Array(value: unknown): Float64Array { + if (value instanceof Float64Array) { + return value; + } + if (value instanceof Float32Array) { + return new Float64Array(value); + } + if (value instanceof Int32Array) { + return new Float64Array(value); + } + if (Array.isArray(value)) { + const result = new Float64Array(value.length); + for (let i = 0; i < value.length; i++) { + result[i] = Number(value[i]); + } + return result; + } + throw new Error(`Cannot convert ${typeof value} to Float64Array`); +} + +function toInt32Array(value: unknown): Int32Array { + if (value instanceof Int32Array) { + return value; + } + if (value instanceof Float64Array) { + const result = new Int32Array(value.length); + for (let i = 0; i < value.length; i++) { + result[i] = Math.round(value[i]); + } + return result; + } + if (value instanceof Float32Array) { + const result = new Int32Array(value.length); + for (let i = 0; i < value.length; i++) { + result[i] = Math.round(value[i]); + } + return result; + } + if (Array.isArray(value)) { + const result = new Int32Array(value.length); + for (let i = 0; i < value.length; i++) { + result[i] = Math.round(Number(value[i])); + } + return result; + } + throw new Error(`Cannot convert ${typeof value} to Int32Array`); +} + +function getNodeArrayValue(node: FBXNode): unknown { + if (node.properties.length === 1) { + return node.properties[0].value; + } + return node.properties.map((property) => property.value); +} diff --git a/packages/dev/loaders/src/FBX/interpreter/materials.ts b/packages/dev/loaders/src/FBX/interpreter/materials.ts new file mode 100644 index 00000000000..1a52ad9bb21 --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/materials.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXNode, findChildByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes"; + +import { type FBXObjectMap, getChildren } from "./connections"; + +import { getPropertyTemplate, resolvePropertyValue, resolvePropertyValues, type FBXPropertyTemplate, type FBXPropertyTemplateMap } from "./propertyTemplates"; + +/** Parsed material data */ +export interface FBXMaterialData { + id: bigint; + name: string; + type: "Lambert" | "Phong"; + properties: FBXMaterialProperties; + textures: FBXTextureRef[]; +} + +export interface FBXMaterialProperties { + diffuseColor?: [number, number, number]; + diffuseFactor?: number; + ambientColor?: [number, number, number]; + ambientFactor?: number; + specularColor?: [number, number, number]; + specularFactor?: number; + shininess?: number; + emissiveColor?: [number, number, number]; + emissiveFactor?: number; + opacity?: number; + transparencyFactor?: number; +} + +export interface FBXTextureRef { + /** Which material property this texture is connected to */ + propertyName: string; + /** Absolute file path from the FBX */ + fileName: string; + /** Relative file path from the FBX */ + relativeFileName: string; + /** Texture node ID */ + id: bigint; + /** Embedded texture data (from Video node Content), if available */ + embeddedData: Uint8Array | null; + /** UV translation [u, v] */ + uvTranslation?: [number, number]; + /** UV scaling [u, v] */ + uvScaling?: [number, number]; + /** UV rotation in degrees */ + uvRotation?: number; + /** Which UV set index this texture uses */ + uvSetIndex?: number; + /** Which named UV set this texture uses */ + uvSetName?: string; +} + +/** + * Extract material data from an FBX Material node. + */ +export function extractMaterial(materialNode: FBXNode, materialId: bigint, objectMap: FBXObjectMap, templates?: FBXPropertyTemplateMap): FBXMaterialData { + const name = cleanFBXName(getPropertyValue(materialNode, 1) ?? "Material"); + const template = getMaterialTemplate(materialNode, templates); + + // Determine Lambert vs Phong from ShadingModel property + const shadingModel = findChildByName(materialNode, "ShadingModel"); + const shadingType = shadingModel + ? (getPropertyValue(shadingModel, 0) ?? "Lambert") + : (resolvePropertyValue(materialNode, template, "ShadingModel") ?? "Lambert"); + const type: "Lambert" | "Phong" = shadingType.toLowerCase() === "phong" ? "Phong" : "Lambert"; + + // Extract properties from Properties70 + const properties = extractMaterialProperties(materialNode, template); + + // Find connected textures + const textureTemplate = templates ? (getPropertyTemplate(templates, "Texture", "FbxFileTexture") ?? getPropertyTemplate(templates, "Texture")) : undefined; + const textures = extractTextures(materialId, objectMap, textureTemplate); + + return { id: materialId, name, type, properties, textures }; +} + +function extractMaterialProperties(materialNode: FBXNode, template?: FBXPropertyTemplate): FBXMaterialProperties { + const props: FBXMaterialProperties = {}; + props.diffuseColor = getColorProperty(materialNode, template, "DiffuseColor") ?? getColorProperty(materialNode, template, "Diffuse"); + props.diffuseFactor = getNumberProperty(materialNode, template, "DiffuseFactor"); + props.ambientColor = getColorProperty(materialNode, template, "AmbientColor") ?? getColorProperty(materialNode, template, "Ambient"); + props.ambientFactor = getNumberProperty(materialNode, template, "AmbientFactor"); + props.specularColor = getColorProperty(materialNode, template, "SpecularColor") ?? getColorProperty(materialNode, template, "Specular"); + props.specularFactor = getNumberProperty(materialNode, template, "SpecularFactor"); + props.shininess = getNumberProperty(materialNode, template, "Shininess") ?? getNumberProperty(materialNode, template, "ShininessExponent"); + props.emissiveColor = getColorProperty(materialNode, template, "EmissiveColor") ?? getColorProperty(materialNode, template, "Emissive"); + props.emissiveFactor = getNumberProperty(materialNode, template, "EmissiveFactor"); + props.opacity = getNumberProperty(materialNode, template, "Opacity"); + props.transparencyFactor = getNumberProperty(materialNode, template, "TransparencyFactor"); + + return props; +} + +function extractTextures(materialId: bigint, objectMap: FBXObjectMap, template?: FBXPropertyTemplate): FBXTextureRef[] { + const textures: FBXTextureRef[] = []; + const textureChildren = getChildren(objectMap, materialId, "Texture"); + + for (const { id, node, propertyName } of textureChildren) { + const fileNameNode = findChildByName(node, "FileName"); + const relFileNameNode = findChildByName(node, "RelativeFilename"); + + const fileName = fileNameNode ? (getPropertyValue(fileNameNode, 0) ?? "") : ""; + const relativeFileName = relFileNameNode ? (getPropertyValue(relFileNameNode, 0) ?? "") : ""; + + // Extract UV transform properties + let uvTranslation: [number, number] | undefined; + let uvScaling: [number, number] | undefined; + const uvRotation = getNumberProperty(node, template, "UVRotation") ?? getNumberProperty(node, template, "Rotation"); + let uvSetName: string | undefined; + uvTranslation = getTextureVector2(node, template, "UVTranslation") ?? getTextureVector2(node, template, "Translation"); + uvScaling = getTextureVector2(node, template, "UVScaling") ?? getTextureVector2(node, template, "Scaling"); + const uvSet = resolvePropertyValue(node, template, "UVSet"); + if (uvSet && uvSet.length > 0) { + uvSetName = uvSet; + } + uvTranslation ??= getNumberPairChild(node, "ModelUVTranslation"); + uvScaling ??= getNumberPairChild(node, "ModelUVScaling"); + + // Check for embedded texture data in connected Video node + let embeddedData: Uint8Array | null = null; + const videoChildren = getChildren(objectMap, id, "Video"); + for (const { node: videoNode } of videoChildren) { + const contentNode = findChildByName(videoNode, "Content"); + if (contentNode && contentNode.properties.length > 0) { + const content = contentNode.properties[0].value; + if (content instanceof Uint8Array && content.length > 0) { + embeddedData = content; + } else if (content instanceof ArrayBuffer && (content as ArrayBuffer).byteLength > 0) { + embeddedData = new Uint8Array(content as ArrayBuffer); + } + } + } + + textures.push({ + propertyName: propertyName ?? "DiffuseColor", + fileName, + relativeFileName, + id, + embeddedData, + uvTranslation, + uvScaling, + uvRotation, + uvSetName, + }); + } + + return textures; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function getMaterialTemplate(materialNode: FBXNode, templates: FBXPropertyTemplateMap | undefined): FBXPropertyTemplate | undefined { + if (!templates) { + return undefined; + } + + const shadingModel = findChildByName(materialNode, "ShadingModel"); + const shadingType = shadingModel ? getPropertyValue(shadingModel, 0) : undefined; + if (shadingType?.toLowerCase() === "phong") { + return getPropertyTemplate(templates, "Material", "FbxSurfacePhong") ?? getPropertyTemplate(templates, "Material"); + } + if (shadingType?.toLowerCase() === "lambert") { + return getPropertyTemplate(templates, "Material", "FbxSurfaceLambert") ?? getPropertyTemplate(templates, "Material"); + } + + return getPropertyTemplate(templates, "Material"); +} + +function getColorProperty(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string): [number, number, number] | undefined { + const values = resolvePropertyValues(node, template, propertyName); + if (!values || values.length < 3) { + return undefined; + } + const r = toNumber(values[0]); + const g = toNumber(values[1]); + const b = toNumber(values[2]); + if (r === undefined || g === undefined || b === undefined) { + return undefined; + } + return [r, g, b]; +} + +function getNumberProperty(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string): number | undefined { + return toNumber(resolvePropertyValue(node, template, propertyName)); +} + +function getTextureVector2(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string): [number, number] | undefined { + const values = resolvePropertyValues(node, template, propertyName); + if (!values) { + return undefined; + } + const u = toNumber(values[0]); + const v = toNumber(values[1]); + return u !== undefined && v !== undefined ? [u, v] : undefined; +} + +function toNumber(value: unknown): number | undefined { + if (typeof value === "number") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + return undefined; +} + +function getNumberPairChild(node: FBXNode, childName: string): [number, number] | undefined { + const child = findChildByName(node, childName); + if (!child) { + return undefined; + } + const u = toNumber(child.properties[0]?.value); + const v = toNumber(child.properties[1]?.value); + return u !== undefined && v !== undefined ? [u, v] : undefined; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts b/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts new file mode 100644 index 00000000000..932fb8d1e19 --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts @@ -0,0 +1,172 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXDocument, type FBXNode, type FBXPropertyValue, findChildByName, findDocumentNode, getPropertyValue } from "../types/fbxTypes"; + +export interface FBXTemplateProperty { + name: string; + propertyType: string; + label: string; + flags: string; + values: FBXPropertyValue[]; +} + +export interface FBXPropertyTemplate { + objectType: string; + templateName: string; + properties: Map; +} + +export type FBXPropertyTemplateMap = Map>; + +export function extractPropertyTemplates(doc: FBXDocument): FBXPropertyTemplateMap { + const templates: FBXPropertyTemplateMap = new Map(); + const definitions = findDocumentNode(doc, "Definitions"); + if (!definitions) { + return templates; + } + + for (const objectTypeNode of definitions.children) { + if (objectTypeNode.name !== "ObjectType") { + continue; + } + + const objectType = getPropertyValue(objectTypeNode, 0); + if (!objectType) { + continue; + } + + for (const templateNode of objectTypeNode.children) { + if (templateNode.name !== "PropertyTemplate") { + continue; + } + + const templateName = getPropertyValue(templateNode, 0); + if (!templateName) { + continue; + } + + const template = extractPropertyTemplate(objectType, templateName, templateNode); + let templatesByName = templates.get(objectType); + if (!templatesByName) { + templatesByName = new Map(); + templates.set(objectType, templatesByName); + } + templatesByName.set(templateName, template); + } + } + + return templates; +} + +export function getPropertyTemplate(templates: FBXPropertyTemplateMap, objectType: string, templateName?: string): FBXPropertyTemplate | undefined { + const templatesByName = templates.get(objectType); + if (!templatesByName) { + return undefined; + } + if (templateName) { + return templatesByName.get(templateName); + } + return templatesByName.values().next().value; +} + +export function getTemplatePropertyValue(template: FBXPropertyTemplate | undefined, propertyName: string, valueIndex = 0): T | undefined { + return template?.properties.get(propertyName)?.values[valueIndex] as T | undefined; +} + +export function resolvePropertyValue(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string, valueIndex = 0): T | undefined { + return resolvePropertyValues(node, template, propertyName)?.[valueIndex] as T | undefined; +} + +export function resolveNumberProperty(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string, fallback: number): number { + return toNumber(resolvePropertyValue(node, template, propertyName)) ?? fallback; +} + +export function resolveVector2Property(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string, fallback: [number, number]): [number, number] { + const values = resolvePropertyValues(node, template, propertyName); + if (!values) { + return fallback; + } + const x = toNumber(values[0]); + const y = toNumber(values[1]); + return x !== undefined && y !== undefined ? [x, y] : fallback; +} + +export function resolveVector3Property( + node: FBXNode, + template: FBXPropertyTemplate | undefined, + propertyName: string, + fallback: [number, number, number] +): [number, number, number] { + const values = resolvePropertyValues(node, template, propertyName); + if (!values) { + return fallback; + } + const x = toNumber(values[0]); + const y = toNumber(values[1]); + const z = toNumber(values[2]); + return x !== undefined && y !== undefined && z !== undefined ? [x, y, z] : fallback; +} + +export function resolvePropertyValues(node: FBXNode, template: FBXPropertyTemplate | undefined, propertyName: string): FBXPropertyValue[] | undefined { + return findLocalPropertyValues(node, propertyName) ?? template?.properties.get(propertyName)?.values; +} + +function toNumber(value: FBXPropertyValue | undefined): number | undefined { + if (typeof value === "number") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + return undefined; +} + +function extractPropertyTemplate(objectType: string, templateName: string, templateNode: FBXNode): FBXPropertyTemplate { + const properties = new Map(); + const properties70 = findChildByName(templateNode, "Properties70"); + + for (const propertyNode of properties70?.children ?? []) { + if (propertyNode.name !== "P") { + continue; + } + + const property = extractPropertyNode(propertyNode); + if (property) { + properties.set(property.name, property); + } + } + + return { objectType, templateName, properties }; +} + +function findLocalPropertyValues(node: FBXNode, propertyName: string): FBXPropertyValue[] | undefined { + const propertyContainers = [findChildByName(node, "Properties70"), findChildByName(node, "Properties60")].filter((child): child is FBXNode => child !== undefined); + + for (const container of propertyContainers) { + for (const propertyNode of container.children) { + if (propertyNode.name !== "P" && propertyNode.name !== "Property") { + continue; + } + if (getPropertyValue(propertyNode, 0) !== propertyName) { + continue; + } + return propertyNode.properties.slice(propertyNode.name === "Property" ? 3 : 4).map((property) => property.value); + } + } + + return undefined; +} + +function extractPropertyNode(node: FBXNode): FBXTemplateProperty | null { + const name = getPropertyValue(node, 0); + if (!name) { + return null; + } + + return { + name, + propertyType: getPropertyValue(node, 1) ?? "", + label: getPropertyValue(node, 2) ?? "", + flags: getPropertyValue(node, 3) ?? "", + values: node.properties.slice(4).map((property) => property.value), + }; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/rig.ts b/packages/dev/loaders/src/FBX/interpreter/rig.ts new file mode 100644 index 00000000000..c4791347310 --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/rig.ts @@ -0,0 +1,331 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXObjectMap } from "./connections"; +import { type FBXBoneData, type FBXSkinData, extractBoneTransform, isSkeletonModel } from "./skeleton"; + +import { cleanFBXName, getPropertyValue } from "../types/fbxTypes"; + +export type FBXRigBoneData = FBXBoneData; + +export interface FBXSkinBindingData { + skinId: bigint; + geometryId: bigint; + rigId: string; + skinBoneIndexToRigBoneIndex: number[]; + clusterModelIds: Set; +} + +export interface FBXRigData { + id: string; + rootModelIds: bigint[]; + bones: FBXRigBoneData[]; + modelIdToBoneIndex: Map; + clusterModelIds: Set; + skinBindings: FBXSkinBindingData[]; + warnings: string[]; +} + +export function resolveRigs(objectMap: FBXObjectMap, skins: FBXSkinData[]): FBXRigData[] { + if (skins.length === 0) { + return []; + } + + const groupByRoot = new Map(); + + for (const skin of skins) { + const clusterModelIds = skin.bones.filter((bone) => bone.isCluster).map((bone) => bone.modelId); + if (clusterModelIds.length === 0) { + continue; + } + + const rootModelId = findRigGroupingRoot(clusterModelIds, objectMap); + const group = groupByRoot.get(rootModelId); + if (group) { + group.push(skin); + } else { + groupByRoot.set(rootModelId, [skin]); + } + } + + return Array.from(groupByRoot.entries()) + .sort(([a], [b]) => compareBigInt(a, b)) + .map(([rootModelId, groupSkins]) => buildRig(rootModelId, groupSkins, objectMap)); +} + +function buildRig(rootModelId: bigint, skins: FBXSkinData[], objectMap: FBXObjectMap): FBXRigData { + const clusterModelIds = new Set(); + const rigModelIds = new Set(); + const sourceBonesByModelId = new Map(); + const sourceOrderByModelId = new Map(); + + for (const skin of skins) { + for (const bone of skin.bones) { + if (!sourceOrderByModelId.has(bone.modelId)) { + sourceOrderByModelId.set(bone.modelId, sourceOrderByModelId.size); + } + + let sources = sourceBonesByModelId.get(bone.modelId); + if (!sources) { + sources = []; + sourceBonesByModelId.set(bone.modelId, sources); + } + sources.push(bone); + + if (!bone.isCluster) { + continue; + } + + clusterModelIds.add(bone.modelId); + for (const ancestorId of getModelAncestorChain(bone.modelId, objectMap)) { + rigModelIds.add(ancestorId); + } + } + } + + const warnings = collectTransformLinkWarnings(sourceBonesByModelId); + const preferredBoneByModelId = new Map(); + for (const [modelId, sources] of Array.from(sourceBonesByModelId)) { + preferredBoneByModelId.set(modelId, choosePreferredBoneSource(sources)); + } + + const parentByModelId = buildParentMap(rigModelIds, objectMap); + const orderedModelIds = orderParentsBeforeChildren(rigModelIds, parentByModelId, sourceOrderByModelId); + const bones: FBXRigBoneData[] = []; + const modelIdToBoneIndex = new Map(); + + for (const modelId of orderedModelIds) { + const sourceBone = preferredBoneByModelId.get(modelId) ?? createFallbackBone(modelId, objectMap); + if (!sourceBone) { + continue; + } + + const parentModelId = parentByModelId.get(modelId); + const parentIndex = parentModelId === undefined ? -1 : (modelIdToBoneIndex.get(parentModelId) ?? -1); + const index = bones.length; + const bone: FBXRigBoneData = { + ...sourceBone, + index, + parentIndex, + isCluster: clusterModelIds.has(modelId), + }; + bones.push(bone); + modelIdToBoneIndex.set(modelId, index); + } + + const skinBindings = skins.map((skin) => buildSkinBinding(skin, `rig_${rootModelId.toString()}`, modelIdToBoneIndex)); + + return { + id: `rig_${rootModelId.toString()}`, + rootModelIds: bones.filter((bone) => bone.parentIndex < 0).map((bone) => bone.modelId), + bones, + modelIdToBoneIndex, + clusterModelIds, + skinBindings, + warnings, + }; +} + +function buildSkinBinding(skin: FBXSkinData, rigId: string, modelIdToBoneIndex: Map): FBXSkinBindingData { + const skinBoneIndexToRigBoneIndex = skin.bones.map((bone) => { + const rigBoneIndex = modelIdToBoneIndex.get(bone.modelId); + if (rigBoneIndex === undefined && bone.isCluster) { + throw new Error(`FBX rig resolver: cluster bone ${bone.name} is missing from resolved rig ${rigId}`); + } + return rigBoneIndex ?? -1; + }); + + return { + skinId: skin.id, + geometryId: skin.geometryId, + rigId, + skinBoneIndexToRigBoneIndex, + clusterModelIds: new Set(skin.bones.filter((bone) => bone.isCluster).map((bone) => bone.modelId)), + }; +} + +function findRigGroupingRoot(clusterModelIds: bigint[], objectMap: FBXObjectMap): bigint { + const lca = findLowestCommonAncestor(clusterModelIds, objectMap) ?? clusterModelIds[0]; + let root = lca; + let parentId = findModelParentId(root, objectMap); + + while (parentId !== undefined) { + const parentNode = objectMap.objects.get(parentId); + if (!parentNode || parentNode.name !== "Model" || !isSkeletonModel(parentNode)) { + break; + } + + root = parentId; + parentId = findModelParentId(parentId, objectMap); + } + + return root; +} + +function findLowestCommonAncestor(modelIds: bigint[], objectMap: FBXObjectMap): bigint | undefined { + if (modelIds.length === 0) { + return undefined; + } + + const chains = modelIds.map((modelId) => getModelAncestorChain(modelId, objectMap)); + const common = new Set(chains[0]); + for (const chain of chains.slice(1)) { + for (const modelId of Array.from(common)) { + if (!chain.includes(modelId)) { + common.delete(modelId); + } + } + } + + return chains[0].find((modelId) => common.has(modelId)); +} + +function getModelAncestorChain(modelId: bigint, objectMap: FBXObjectMap): bigint[] { + const chain: bigint[] = []; + let currentId: bigint | undefined = modelId; + + while (currentId !== undefined) { + const node = objectMap.objects.get(currentId); + if (!node || node.name !== "Model") { + break; + } + + chain.push(currentId); + currentId = findModelParentId(currentId, objectMap); + } + + return chain; +} + +function buildParentMap(modelIds: Set, objectMap: FBXObjectMap): Map { + const parentByModelId = new Map(); + + for (const modelId of Array.from(modelIds)) { + const parentId = findModelParentId(modelId, objectMap); + if (parentId !== undefined && modelIds.has(parentId)) { + parentByModelId.set(modelId, parentId); + } + } + + return parentByModelId; +} + +function orderParentsBeforeChildren(modelIds: Set, parentByModelId: Map, sourceOrderByModelId: Map): bigint[] { + const childrenByModelId = new Map(); + for (const modelId of Array.from(modelIds)) { + const parentId = parentByModelId.get(modelId); + if (parentId === undefined) { + continue; + } + + let children = childrenByModelId.get(parentId); + if (!children) { + children = []; + childrenByModelId.set(parentId, children); + } + children.push(modelId); + } + + for (const children of Array.from(childrenByModelId.values())) { + children.sort((a, b) => compareSourceOrder(a, b, sourceOrderByModelId)); + } + + const roots = Array.from(modelIds) + .filter((modelId) => !parentByModelId.has(modelId)) + .sort((a, b) => compareSourceOrder(a, b, sourceOrderByModelId)); + const ordered: bigint[] = []; + const queue = [...roots]; + + while (queue.length > 0) { + const modelId = queue.shift()!; + ordered.push(modelId); + queue.push(...(childrenByModelId.get(modelId) ?? [])); + } + + return ordered; +} + +function findModelParentId(modelId: bigint, objectMap: FBXObjectMap): bigint | undefined { + const parentConnection = objectMap.connections.find((conn) => conn.type === "OO" && conn.childId === modelId && objectMap.objects.get(conn.parentId)?.name === "Model"); + return parentConnection?.parentId; +} + +function choosePreferredBoneSource(sources: FBXBoneData[]): FBXBoneData { + return ( + sources.find((bone) => bone.isCluster && bone.transformLinkMatrix) ?? + sources.find((bone) => bone.isCluster) ?? + sources.find((bone) => bone.modelBindPoseMatrix) ?? + sources[0] + ); +} + +function collectTransformLinkWarnings(sourceBonesByModelId: Map): string[] { + const warnings: string[] = []; + + for (const [modelId, sources] of Array.from(sourceBonesByModelId)) { + const matrices = sources.filter((bone) => bone.isCluster && bone.transformLinkMatrix).map((bone) => bone.transformLinkMatrix!); + if (matrices.length < 2) { + continue; + } + + const first = matrices[0]; + if (matrices.some((matrix) => !areMatricesEquivalent(first, matrix, 1e-5))) { + warnings.push(`Model ${modelId.toString()} has differing Cluster.TransformLink matrices across skins`); + } + } + + return warnings; +} + +function areMatricesEquivalent(a: Float64Array, b: Float64Array, epsilon: number): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > epsilon) { + return false; + } + } + return true; +} + +function createFallbackBone(modelId: bigint, objectMap: FBXObjectMap): FBXBoneData | null { + const modelNode = objectMap.objects.get(modelId); + if (!modelNode || modelNode.name !== "Model") { + return null; + } + + const transform = extractBoneTransform(modelNode); + return { + modelId, + name: cleanFBXName(getPropertyValue(modelNode, 1) ?? `Bone${modelId.toString()}`), + index: -1, + parentIndex: -1, + isCluster: false, + translation: transform.translation, + rotation: transform.rotation, + preRotation: transform.preRotation, + postRotation: transform.postRotation, + rotationPivot: transform.rotationPivot, + scalingPivot: transform.scalingPivot, + rotationOffset: transform.rotationOffset, + scalingOffset: transform.scalingOffset, + scale: transform.scale, + rotationOrder: transform.rotationOrder, + inheritType: transform.inheritType, + clusterMode: "Unknown", + bindPoseMatrix: null, + transformLinkMatrix: null, + transformAssociateModelMatrix: null, + modelBindPoseMatrix: null, + diagnostics: [], + }; +} + +function compareBigInt(a: bigint, b: bigint): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function compareSourceOrder(a: bigint, b: bigint, sourceOrderByModelId: Map): number { + const aOrder = sourceOrderByModelId.get(a) ?? Number.MAX_SAFE_INTEGER; + const bOrder = sourceOrderByModelId.get(b) ?? Number.MAX_SAFE_INTEGER; + return aOrder - bOrder || compareBigInt(a, b); +} diff --git a/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts b/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts new file mode 100644 index 00000000000..cfa7a64e25f --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts @@ -0,0 +1,118 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXNode, cleanFBXName, getPropertyValue } from "../types/fbxTypes"; + +import { type FBXObjectMap } from "./connections"; + +export type FBXSceneDiagnosticType = + | "unsupported-constraint" + | "unsupported-helper" + | "unsupported-deformer" + | "unsupported-node-attribute" + | "unsupported-pose" + | "unsupported-layered-texture" + | "connection-graph"; + +export interface FBXSceneDiagnostic { + type: FBXSceneDiagnosticType; + message: string; + objectId?: bigint; + objectName?: string; + nodeName?: string; + subType?: string; + /** Number of accepted parent graph edges for objectId, when objectId is known. */ + parentCount?: number; + childCount?: number; +} + +const HELPER_NODE_NAMES = new Set(["Character", "CharacterPose", "ControlSet", "ControlSetPlug", "SelectionSet", "CollectionExclusive"]); + +export function extractSceneDiagnostics(objectMap: FBXObjectMap): FBXSceneDiagnostic[] { + const diagnostics: FBXSceneDiagnostic[] = objectMap.diagnostics.map((diagnostic) => ({ + type: "connection-graph", + message: diagnostic.message, + objectId: diagnostic.childId, + subType: diagnostic.reason, + parentCount: diagnostic.childId === undefined ? undefined : objectMap.connections.filter((connection) => connection.childId === diagnostic.childId).length, + })); + + for (const [id, node] of Array.from(objectMap.objects)) { + const subType = getPropertyValue(node, 2) ?? ""; + if (node.name === "Constraint") { + diagnostics.push( + createObjectDiagnostic( + objectMap, + id, + node, + "unsupported-constraint", + `Constraint '${subType || cleanFBXName(getPropertyValue(node, 1) ?? "")}' is preserved as diagnostic data but not evaluated at runtime.` + ) + ); + continue; + } + + if (HELPER_NODE_NAMES.has(node.name)) { + diagnostics.push( + createObjectDiagnostic(objectMap, id, node, "unsupported-helper", `${node.name} helper data is preserved as diagnostic data but not evaluated at runtime.`) + ); + continue; + } + + if (node.name === "LayeredTexture") { + diagnostics.push( + createObjectDiagnostic( + objectMap, + id, + node, + "unsupported-layered-texture", + "LayeredTexture is preserved as diagnostic data; runtime texture layer blending is not implemented." + ) + ); + continue; + } + + if (node.name === "Pose" && subType !== "BindPose") { + diagnostics.push( + createObjectDiagnostic(objectMap, id, node, "unsupported-pose", `Pose subtype '${subType}' is preserved as diagnostic data but not evaluated at runtime.`) + ); + continue; + } + + if (node.name === "Deformer" && !isSupportedDeformer(subType)) { + diagnostics.push( + createObjectDiagnostic(objectMap, id, node, "unsupported-deformer", `Deformer subtype '${subType}' is preserved as diagnostic data but not evaluated at runtime.`) + ); + continue; + } + + if (node.name === "NodeAttribute" && subType && subType !== "Camera" && subType !== "Light") { + diagnostics.push( + createObjectDiagnostic( + objectMap, + id, + node, + "unsupported-node-attribute", + `NodeAttribute subtype '${subType}' is preserved as diagnostic data but not converted to a Babylon object.` + ) + ); + } + } + + return diagnostics; +} + +function isSupportedDeformer(subType: string): boolean { + return subType === "Skin" || subType === "Cluster" || subType === "BlendShape" || subType === "BlendShapeChannel"; +} + +function createObjectDiagnostic(objectMap: FBXObjectMap, id: bigint, node: FBXNode, type: FBXSceneDiagnosticType, message: string): FBXSceneDiagnostic { + return { + type, + message, + objectId: id, + objectName: cleanFBXName(getPropertyValue(node, 1) ?? node.name), + nodeName: node.name, + subType: getPropertyValue(node, 2) ?? "", + parentCount: objectMap.connections.filter((connection) => connection.childId === id).length, + childCount: objectMap.childrenOf.get(id)?.length ?? 0, + }; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/skeleton.ts b/packages/dev/loaders/src/FBX/interpreter/skeleton.ts new file mode 100644 index 00000000000..2de877ce52a --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/skeleton.ts @@ -0,0 +1,708 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXNode, findChildByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes"; + +import { type FBXObjectMap, getChildren } from "./connections"; + +const MAX_BONE_INFLUENCES = 8; + +export type FBXClusterMode = "Normalize" | "Additive" | "TotalOne" | "Unknown"; + +export interface FBXSkinDiagnostic { + type: "cluster-mode-runtime-unsupported" | "missing-cluster-transform" | "missing-cluster-transform-link" | "missing-bind-pose-matrix" | "associate-model-present"; + message: string; + boneModelId?: bigint; + boneName?: string; + clusterMode?: FBXClusterMode; +} + +/** Represents a single bone (cluster) in the FBX skeleton */ +export interface FBXBoneData { + /** The Model node ID for this bone */ + modelId: bigint; + /** Bone name (from the Model node) */ + name: string; + /** Index of this bone in the skeleton */ + index: number; + /** Index of the parent bone (-1 for root) */ + parentIndex: number; + /** Whether this bone corresponds to an FBX Cluster with vertex weights */ + isCluster: boolean; + /** Local translation from parent (Lcl Translation) */ + translation: [number, number, number]; + /** Local rotation in degrees (Lcl Rotation) */ + rotation: [number, number, number]; + /** Pre-rotation in degrees (applied before Lcl Rotation) */ + preRotation: [number, number, number]; + /** Post-rotation in degrees (applied after Lcl Rotation, inverted) */ + postRotation: [number, number, number]; + /** Rotation pivot point */ + rotationPivot: [number, number, number]; + /** Scaling pivot point */ + scalingPivot: [number, number, number]; + /** Rotation offset */ + rotationOffset: [number, number, number]; + /** Scaling offset */ + scalingOffset: [number, number, number]; + /** Local scale (Lcl Scaling) */ + scale: [number, number, number]; + /** Rotation order: 0=XYZ, 1=XZY, 2=YZX, 3=YXZ, 4=ZXY, 5=ZYX */ + rotationOrder: number; + /** FBX transform inheritance mode. 0=RrSs, 1=RSrs, 2=Rrs */ + inheritType: number; + /** Cluster skinning mode */ + clusterMode: FBXClusterMode; + /** Bind pose transform matrix (cluster Transform, 4x4) */ + bindPoseMatrix: Float64Array | null; + /** Bone's world transform at bind time (cluster TransformLink, 4x4) */ + transformLinkMatrix: Float64Array | null; + /** Associate model world transform at bind time (cluster TransformAssociateModel, 4x4) */ + transformAssociateModelMatrix: Float64Array | null; + /** Model's absolute matrix from the FBX BindPose, when present */ + modelBindPoseMatrix: Float64Array | null; + /** Recoverable bind/skinning diagnostics for this bone */ + diagnostics: FBXSkinDiagnostic[]; +} + +/** Represents a skin deformer with its clusters */ +export interface FBXSkinData { + /** Skin deformer ID */ + id: bigint; + /** Geometry ID this skin is attached to */ + geometryId: bigint; + /** Mesh model world matrix from the FBX BindPose, when present */ + meshBindPoseMatrix: Float64Array | null; + /** Bones in this skeleton */ + bones: FBXBoneData[]; + /** Per-vertex bone indices, sorted by descending weight and capped for Babylon skinning */ + boneIndices: number[][]; + /** Per-vertex bone weights, matching boneIndices */ + boneWeights: number[][]; + /** Recoverable skinning/bind diagnostics */ + diagnostics: FBXSkinDiagnostic[]; +} + +/** + * Extract all skin deformers from the FBX scene. + * Returns skin data including bone hierarchy and vertex weights. + */ +export function extractSkins(objectMap: FBXObjectMap): FBXSkinData[] { + const skins: FBXSkinData[] = []; + + for (const [id, node] of Array.from(objectMap.objects)) { + if (node.name === "Deformer" && getPropertyValue(node, 2) === "Skin") { + const skin = extractSkin(id, node, objectMap); + if (skin) { + skins.push(skin); + } + } + } + + return skins; +} + +function extractSkin(skinId: bigint, _skinNode: FBXNode, objectMap: FBXObjectMap): FBXSkinData | null { + // Find the geometry this skin is attached to + // Skin is a child of the geometry in FBX connection graph + const skinParent = objectMap.parentOf.get(skinId); + if (!skinParent) { + return null; + } + + const geometryId = skinParent.id; + const geometryNode = objectMap.objects.get(geometryId); + if (!geometryNode || geometryNode.name !== "Geometry") { + return null; + } + const modelParent = objectMap.parentOf.get(geometryId); + const modelParentNode = modelParent ? objectMap.objects.get(modelParent.id) : undefined; + const meshModelId = modelParentNode?.name === "Model" ? modelParent!.id : undefined; + + // Find all clusters (children of this skin) + const clusterEntries = getChildren(objectMap, skinId, "Deformer"); + if (clusterEntries.length === 0) { + return null; + } + + // For each cluster, find the connected bone Model + // Connection graph: BoneModel → Cluster (bone is child of cluster) + const boneModelMap = new Map(); + for (const { id: clusterId, node: clusterNode } of clusterEntries) { + const subType = getPropertyValue(clusterNode, 2); + if (subType !== "Cluster") { + continue; + } + + // The bone Model is a child of the Cluster + const boneChildren = getChildren(objectMap, clusterId, "Model"); + if (boneChildren.length > 0) { + boneModelMap.set(boneChildren[0].id, { clusterId, clusterNode }); + } + } + + // Build bone hierarchy from Model parent-child relationships. Include + // skeleton-like ancestors even when they are not weighted clusters; some + // rigs (for example 3ds Max Biped) animate a non-cluster root above the + // clustered bones. + const bindPoseMatrices = extractBindPoseMatrices(geometryId, objectMap); + const skinDiagnostics: FBXSkinDiagnostic[] = []; + const bones = buildBoneHierarchy(boneModelMap, bindPoseMatrices, objectMap, skinDiagnostics); + if (bones.length === 0) { + return null; + } + + // Extract per-vertex weights from clusters + const { boneIndices, boneWeights } = extractVertexWeights(bones, boneModelMap, objectMap); + + return { + id: skinId, + geometryId, + meshBindPoseMatrix: meshModelId !== undefined ? (bindPoseMatrices.get(meshModelId) ?? null) : null, + bones, + boneIndices, + boneWeights, + diagnostics: skinDiagnostics, + }; +} + +/** + * Build a flat ordered bone list with parent indices from the FBX Model hierarchy. + */ +function buildBoneHierarchy( + boneModelMap: Map, + bindPoseMatrices: Map, + objectMap: FBXObjectMap, + skinDiagnostics: FBXSkinDiagnostic[] +): FBXBoneData[] { + const bones: FBXBoneData[] = []; + const visited = new Set(); + const skeletonModelIds = collectSkeletonModelIds(boneModelMap, objectMap); + const parentByModelId = buildSkeletonParentMap(skeletonModelIds, objectMap); + const childrenByModelId = buildSkeletonChildrenMap(skeletonModelIds, parentByModelId); + + const rootBoneIds = Array.from(skeletonModelIds).filter((modelId) => !parentByModelId.has(modelId)); + + // BFS to build ordered list + const queue: { modelId: bigint; parentIndex: number }[] = rootBoneIds.map((id) => ({ + modelId: id, + parentIndex: -1, + })); + + while (queue.length > 0) { + const { modelId, parentIndex } = queue.shift()!; + if (visited.has(modelId)) { + continue; + } + visited.add(modelId); + + const modelNode = objectMap.objects.get(modelId); + if (!modelNode) { + continue; + } + + const boneIndex = bones.length; + + const clusterInfo = boneModelMap.get(modelId); + const transform = extractBoneTransform(modelNode); + const { bindPoseMatrix, transformLinkMatrix, transformAssociateModelMatrix, clusterMode } = clusterInfo + ? extractClusterMatrices(clusterInfo.clusterNode) + : { bindPoseMatrix: null, transformLinkMatrix: null, transformAssociateModelMatrix: null, clusterMode: "Unknown" as const }; + const diagnostics = createBoneDiagnostics( + modelId, + cleanFBXName(getPropertyValue(modelNode, 1) ?? `Bone${boneIndex}`), + clusterInfo !== undefined, + clusterMode, + bindPoseMatrix, + transformLinkMatrix, + transformAssociateModelMatrix, + bindPoseMatrices.get(modelId) ?? null + ); + skinDiagnostics.push(...diagnostics); + + bones.push({ + modelId, + name: cleanFBXName(getPropertyValue(modelNode, 1) ?? `Bone${boneIndex}`), + index: boneIndex, + parentIndex, + isCluster: clusterInfo !== undefined, + translation: transform.translation, + rotation: transform.rotation, + preRotation: transform.preRotation, + postRotation: transform.postRotation, + rotationPivot: transform.rotationPivot, + scalingPivot: transform.scalingPivot, + rotationOffset: transform.rotationOffset, + scalingOffset: transform.scalingOffset, + scale: transform.scale, + rotationOrder: transform.rotationOrder, + inheritType: transform.inheritType, + clusterMode, + bindPoseMatrix, + transformLinkMatrix, + transformAssociateModelMatrix, + modelBindPoseMatrix: bindPoseMatrices.get(modelId) ?? null, + diagnostics, + }); + + for (const childId of childrenByModelId.get(modelId) ?? []) { + if (!visited.has(childId)) { + queue.push({ modelId: childId, parentIndex: boneIndex }); + } + } + } + + return bones; +} + +function extractBindPoseMatrices(geometryId: bigint, objectMap: FBXObjectMap): Map { + const modelParent = objectMap.parentOf.get(geometryId); + const modelParentNode = modelParent ? objectMap.objects.get(modelParent.id) : undefined; + const modelId = modelParentNode?.name === "Model" ? modelParent!.id : undefined; + if (modelId === undefined) { + return new Map(); + } + + for (const [, poseNode] of Array.from(objectMap.objects)) { + if (poseNode.name !== "Pose" || getPropertyValue(poseNode, 2) !== "BindPose") { + continue; + } + + const matrices = new Map(); + for (const poseChild of poseNode.children) { + if (poseChild.name !== "PoseNode") { + continue; + } + + const nodeChild = findChildByName(poseChild, "Node"); + const matrixChild = findChildByName(poseChild, "Matrix"); + const nodeId = nodeChild?.properties[0]?.value; + const matrixValue = matrixChild?.properties[0]?.value; + if (typeof nodeId !== "bigint") { + continue; + } + + const matrix = toFloat64Array(matrixValue); + if (matrix?.length === 16) { + matrices.set(nodeId, matrix); + } + } + + if (matrices.has(modelId)) { + return matrices; + } + } + + return new Map(); +} + +function buildSkeletonChildrenMap(skeletonModelIds: Set, parentByModelId: Map): Map { + const childrenByModelId = new Map(); + + for (const modelId of Array.from(skeletonModelIds)) { + const parentId = parentByModelId.get(modelId); + if (parentId === undefined) { + continue; + } + + if (!childrenByModelId.has(parentId)) { + childrenByModelId.set(parentId, []); + } + childrenByModelId.get(parentId)!.push(modelId); + } + + return childrenByModelId; +} + +function collectSkeletonModelIds(boneModelMap: Map, objectMap: FBXObjectMap): Set { + const skeletonModelIds = new Set(Array.from(boneModelMap.keys())); + + for (const modelId of Array.from(boneModelMap.keys())) { + let parentId = findModelParentId(modelId, objectMap); + while (parentId !== undefined) { + const parentNode = objectMap.objects.get(parentId); + if (!parentNode || parentNode.name !== "Model") { + break; + } + + skeletonModelIds.add(parentId); + parentId = findModelParentId(parentId, objectMap); + } + } + + return skeletonModelIds; +} + +function buildSkeletonParentMap(skeletonModelIds: Set, objectMap: FBXObjectMap): Map { + const parentByModelId = new Map(); + + for (const modelId of Array.from(skeletonModelIds)) { + let parentId = findModelParentId(modelId, objectMap); + while (parentId !== undefined) { + if (skeletonModelIds.has(parentId)) { + parentByModelId.set(modelId, parentId); + break; + } + parentId = findModelParentId(parentId, objectMap); + } + } + + return parentByModelId; +} + +function findModelParentId(modelId: bigint, objectMap: FBXObjectMap): bigint | undefined { + const parentConnection = objectMap.connections.find((conn) => conn.type === "OO" && conn.childId === modelId && objectMap.objects.get(conn.parentId)?.name === "Model"); + return parentConnection?.parentId; +} + +export function isSkeletonModel(modelNode: FBXNode): boolean { + const subType = getPropertyValue(modelNode, 2); + return subType === "Root" || subType === "LimbNode"; +} + +export function extractBoneTransform(modelNode: FBXNode): { + translation: [number, number, number]; + rotation: [number, number, number]; + preRotation: [number, number, number]; + postRotation: [number, number, number]; + rotationPivot: [number, number, number]; + scalingPivot: [number, number, number]; + rotationOffset: [number, number, number]; + scalingOffset: [number, number, number]; + scale: [number, number, number]; + rotationOrder: number; + inheritType: number; +} { + const translation: [number, number, number] = [0, 0, 0]; + const rotation: [number, number, number] = [0, 0, 0]; + const preRotation: [number, number, number] = [0, 0, 0]; + const postRotation: [number, number, number] = [0, 0, 0]; + const rotationPivot: [number, number, number] = [0, 0, 0]; + const scalingPivot: [number, number, number] = [0, 0, 0]; + const rotationOffset: [number, number, number] = [0, 0, 0]; + const scalingOffset: [number, number, number] = [0, 0, 0]; + const scale: [number, number, number] = [1, 1, 1]; + let rotationOrder = 0; + let inheritType = 1; + + const props70 = findChildByName(modelNode, "Properties70"); + if (!props70) { + return { translation, rotation, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, scale, rotationOrder, inheritType }; + } + + for (const p of props70.children) { + if (p.name !== "P") { + continue; + } + const propName = getPropertyValue(p, 0); + if (!propName) { + continue; + } + + switch (propName) { + case "Lcl Translation": + translation[0] = toNumber(p.properties[4]?.value) ?? 0; + translation[1] = toNumber(p.properties[5]?.value) ?? 0; + translation[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "Lcl Rotation": + rotation[0] = toNumber(p.properties[4]?.value) ?? 0; + rotation[1] = toNumber(p.properties[5]?.value) ?? 0; + rotation[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "PreRotation": + preRotation[0] = toNumber(p.properties[4]?.value) ?? 0; + preRotation[1] = toNumber(p.properties[5]?.value) ?? 0; + preRotation[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "PostRotation": + postRotation[0] = toNumber(p.properties[4]?.value) ?? 0; + postRotation[1] = toNumber(p.properties[5]?.value) ?? 0; + postRotation[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "RotationPivot": + rotationPivot[0] = toNumber(p.properties[4]?.value) ?? 0; + rotationPivot[1] = toNumber(p.properties[5]?.value) ?? 0; + rotationPivot[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "ScalingPivot": + scalingPivot[0] = toNumber(p.properties[4]?.value) ?? 0; + scalingPivot[1] = toNumber(p.properties[5]?.value) ?? 0; + scalingPivot[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "RotationOffset": + rotationOffset[0] = toNumber(p.properties[4]?.value) ?? 0; + rotationOffset[1] = toNumber(p.properties[5]?.value) ?? 0; + rotationOffset[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "ScalingOffset": + scalingOffset[0] = toNumber(p.properties[4]?.value) ?? 0; + scalingOffset[1] = toNumber(p.properties[5]?.value) ?? 0; + scalingOffset[2] = toNumber(p.properties[6]?.value) ?? 0; + break; + case "Lcl Scaling": + scale[0] = toNumber(p.properties[4]?.value) ?? 1; + scale[1] = toNumber(p.properties[5]?.value) ?? 1; + scale[2] = toNumber(p.properties[6]?.value) ?? 1; + break; + case "RotationOrder": + rotationOrder = toNumber(p.properties[4]?.value) ?? 0; + break; + case "InheritType": + inheritType = toNumber(p.properties[4]?.value) ?? 1; + break; + } + } + + return { translation, rotation, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, scale, rotationOrder, inheritType }; +} + +function extractClusterMatrices(clusterNode: FBXNode): { + bindPoseMatrix: Float64Array | null; + transformLinkMatrix: Float64Array | null; + transformAssociateModelMatrix: Float64Array | null; + clusterMode: FBXClusterMode; +} { + let bindPoseMatrix: Float64Array | null = null; + let transformLinkMatrix: Float64Array | null = null; + let transformAssociateModelMatrix: Float64Array | null = null; + let clusterMode: FBXClusterMode = "Normalize"; + + const transformNode = findChildByName(clusterNode, "Transform"); + if (transformNode && transformNode.properties[0]) { + const val = transformNode.properties[0].value; + if (val instanceof Float64Array && val.length === 16) { + bindPoseMatrix = val; + } else if (val instanceof Float32Array && val.length === 16) { + bindPoseMatrix = new Float64Array(val); + } + } + + const transformLinkNode = findChildByName(clusterNode, "TransformLink"); + if (transformLinkNode && transformLinkNode.properties[0]) { + const val = transformLinkNode.properties[0].value; + if (val instanceof Float64Array && val.length === 16) { + transformLinkMatrix = val; + } else if (val instanceof Float32Array && val.length === 16) { + transformLinkMatrix = new Float64Array(val); + } + } + + const transformAssociateModelNode = findChildByName(clusterNode, "TransformAssociateModel"); + if (transformAssociateModelNode && transformAssociateModelNode.properties[0]) { + const val = transformAssociateModelNode.properties[0].value; + if (val instanceof Float64Array && val.length === 16) { + transformAssociateModelMatrix = val; + } else if (val instanceof Float32Array && val.length === 16) { + transformAssociateModelMatrix = new Float64Array(val); + } + } + + const modeNode = findChildByName(clusterNode, "Mode"); + const mode = modeNode ? getPropertyValue(modeNode, 0) : undefined; + if (mode === "Normalize" || mode === "Additive" || mode === "TotalOne") { + clusterMode = mode; + } else if (mode) { + clusterMode = "Unknown"; + } + + return { bindPoseMatrix, transformLinkMatrix, transformAssociateModelMatrix, clusterMode }; +} + +function createBoneDiagnostics( + modelId: bigint, + boneName: string, + isCluster: boolean, + clusterMode: FBXClusterMode, + bindPoseMatrix: Float64Array | null, + transformLinkMatrix: Float64Array | null, + transformAssociateModelMatrix: Float64Array | null, + modelBindPoseMatrix: Float64Array | null +): FBXSkinDiagnostic[] { + if (!isCluster) { + return []; + } + + const diagnostics: FBXSkinDiagnostic[] = []; + if (clusterMode === "Additive" || clusterMode === "TotalOne") { + diagnostics.push({ + type: "cluster-mode-runtime-unsupported", + message: `Cluster mode '${clusterMode}' is preserved but not applied by Babylon linear blend skinning.`, + boneModelId: modelId, + boneName, + clusterMode, + }); + } + if (!bindPoseMatrix) { + diagnostics.push({ + type: "missing-cluster-transform", + message: "Cluster is missing Transform matrix; falling back to rest/bind-pose data.", + boneModelId: modelId, + boneName, + clusterMode, + }); + } + if (!transformLinkMatrix) { + diagnostics.push({ + type: "missing-cluster-transform-link", + message: "Cluster is missing TransformLink matrix; falling back to model bind pose or rest transform.", + boneModelId: modelId, + boneName, + clusterMode, + }); + } + if (!modelBindPoseMatrix) { + diagnostics.push({ + type: "missing-bind-pose-matrix", + message: "No BindPose matrix was found for this bone model.", + boneModelId: modelId, + boneName, + clusterMode, + }); + } + if (transformAssociateModelMatrix) { + diagnostics.push({ + type: "associate-model-present", + message: "TransformAssociateModel is preserved for future associate-model skinning semantics.", + boneModelId: modelId, + boneName, + clusterMode, + }); + } + + return diagnostics; +} + +/** + * Extract per-vertex bone indices and weights from cluster data. + * Returns arrays indexed by control point index. + */ +function extractVertexWeights( + bones: FBXBoneData[], + boneModelMap: Map, + objectMap: FBXObjectMap +): { boneIndices: number[][]; boneWeights: number[][] } { + // We need to find the max vertex index to size our arrays + let maxVertexIndex = 0; + + // First pass: find max vertex index + for (const bone of bones) { + const clusterInfo = boneModelMap.get(bone.modelId); + if (!clusterInfo) { + continue; + } + + const indexesNode = findChildByName(clusterInfo.clusterNode, "Indexes"); + if (!indexesNode) { + continue; + } + + const indexes = toInt32Array(indexesNode.properties[0]?.value); + if (!indexes) { + continue; + } + + for (let i = 0; i < indexes.length; i++) { + if (indexes[i] > maxVertexIndex) { + maxVertexIndex = indexes[i]; + } + } + } + + // Initialize arrays + const vertexCount = maxVertexIndex + 1; + const boneIndices: number[][] = new Array(vertexCount); + const boneWeights: number[][] = new Array(vertexCount); + for (let i = 0; i < vertexCount; i++) { + boneIndices[i] = []; + boneWeights[i] = []; + } + + // Second pass: collect influences + for (const bone of bones) { + const clusterInfo = boneModelMap.get(bone.modelId); + if (!clusterInfo) { + continue; + } + + const indexesNode = findChildByName(clusterInfo.clusterNode, "Indexes"); + const weightsNode = findChildByName(clusterInfo.clusterNode, "Weights"); + if (!indexesNode || !weightsNode) { + continue; + } + + const indexes = toInt32Array(indexesNode.properties[0]?.value); + const weights = toFloat64Array(weightsNode.properties[0]?.value); + if (!indexes || !weights) { + continue; + } + + for (let i = 0; i < indexes.length; i++) { + const vertIdx = indexes[i]; + boneIndices[vertIdx].push(bone.index); + boneWeights[vertIdx].push(weights[i]); + } + } + + // Sort by weight descending and cap to Babylon's primary + extra influence buffers. + for (let i = 0; i < vertexCount; i++) { + if (boneIndices[i].length === 0) { + continue; + } + + const pairs = boneIndices[i].map((bi, idx) => ({ + index: bi, + weight: boneWeights[i][idx], + })); + pairs.sort((a, b) => b.weight - a.weight); + const cappedPairs = pairs.slice(0, MAX_BONE_INFLUENCES); + boneIndices[i] = cappedPairs.map((p) => p.index); + boneWeights[i] = cappedPairs.map((p) => p.weight); + } + + // Normalize weights to sum to 1.0 + for (let i = 0; i < vertexCount; i++) { + const sum = boneWeights[i].reduce((a, b) => a + b, 0); + if (sum > 0) { + for (let j = 0; j < boneWeights[i].length; j++) { + boneWeights[i][j] /= sum; + } + } + } + + return { boneIndices, boneWeights }; +} + +// ── Utilities ────────────────────────────────────────────────────────────────── + +function toNumber(value: unknown): number | undefined { + if (typeof value === "number") { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + return undefined; +} + +function toInt32Array(value: unknown): Int32Array | null { + if (value instanceof Int32Array) { + return value; + } + if (value instanceof Float64Array) { + const result = new Int32Array(value.length); + for (let i = 0; i < value.length; i++) { + result[i] = Math.round(value[i]); + } + return result; + } + return null; +} + +function toFloat64Array(value: unknown): Float64Array | null { + if (value instanceof Float64Array) { + return value; + } + if (value instanceof Float32Array) { + return new Float64Array(value); + } + return null; +} diff --git a/packages/dev/loaders/src/FBX/interpreter/transform.ts b/packages/dev/loaders/src/FBX/interpreter/transform.ts new file mode 100644 index 00000000000..93cf8aa075d --- /dev/null +++ b/packages/dev/loaders/src/FBX/interpreter/transform.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { Matrix } from "core/Maths/math.vector"; + +export type FBXVector3 = [number, number, number]; + +export interface FBXTransformComponents { + translation: FBXVector3; + rotation: FBXVector3; + scale: FBXVector3; + preRotation: FBXVector3; + postRotation: FBXVector3; + rotationPivot: FBXVector3; + scalingPivot: FBXVector3; + rotationOffset: FBXVector3; + scalingOffset: FBXVector3; + rotationOrder: number; + inheritType?: number; +} + +export function eulerToMatrixXYZ(rx: number, ry: number, rz: number): Matrix { + const mx = Matrix.RotationX(rx); + const my = Matrix.RotationY(ry); + const mz = Matrix.RotationZ(rz); + return mx.multiply(my).multiply(mz); +} + +export function eulerToMatrix(rx: number, ry: number, rz: number, order: number): Matrix { + const mx = Matrix.RotationX(rx); + const my = Matrix.RotationY(ry); + const mz = Matrix.RotationZ(rz); + + switch (order) { + case 0: + return mx.multiply(my).multiply(mz); // XYZ + case 1: + return mx.multiply(mz).multiply(my); // XZY + case 2: + return my.multiply(mz).multiply(mx); // YZX + case 3: + return my.multiply(mx).multiply(mz); // YXZ + case 4: + return mz.multiply(mx).multiply(my); // ZXY + case 5: + return mz.multiply(my).multiply(mx); // ZYX + default: + return mx.multiply(my).multiply(mz); // fallback to XYZ + } +} + +export function computeFBXGeometricMatrix(translation: FBXVector3, rotation: FBXVector3, scale: FBXVector3): Matrix { + const translationM = Matrix.Translation(translation[0], translation[1], translation[2]); + return computeFBXGeometricDeltaMatrix(rotation, scale).multiply(translationM); +} + +export function computeFBXGeometricDeltaMatrix(rotation: FBXVector3, scale: FBXVector3): Matrix { + const d2r = Math.PI / 180; + const scaleM = Matrix.Scaling(scale[0], scale[1], scale[2]); + const rotationM = eulerToMatrixXYZ(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r); + return scaleM.multiply(rotationM); +} + +export function computeFBXGeometricNormalMatrix(rotation: FBXVector3, scale: FBXVector3): Matrix { + const d2r = Math.PI / 180; + const inverseScaleM = Matrix.Scaling(scale[0] === 0 ? 0 : 1 / scale[0], scale[1] === 0 ? 0 : 1 / scale[1], scale[2] === 0 ? 0 : 1 / scale[2]); + const rotationM = eulerToMatrixXYZ(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r); + return inverseScaleM.multiply(rotationM); +} + +export function computeFBXLocalMatrix(components: FBXTransformComponents): Matrix { + const { translation, rotation, scale, preRotation, postRotation, rotationPivot, scalingPivot, rotationOffset, scalingOffset, rotationOrder } = components; + const d2r = Math.PI / 180; + + const hasPivots = rotationPivot[0] !== 0 || rotationPivot[1] !== 0 || rotationPivot[2] !== 0 || scalingPivot[0] !== 0 || scalingPivot[1] !== 0 || scalingPivot[2] !== 0; + const hasOffsets = rotationOffset[0] !== 0 || rotationOffset[1] !== 0 || rotationOffset[2] !== 0 || scalingOffset[0] !== 0 || scalingOffset[1] !== 0 || scalingOffset[2] !== 0; + const hasPostRot = postRotation[0] !== 0 || postRotation[1] !== 0 || postRotation[2] !== 0; + + if (!hasPivots && !hasOffsets && !hasPostRot) { + const preRotM = eulerToMatrixXYZ(preRotation[0] * d2r, preRotation[1] * d2r, preRotation[2] * d2r); + const lclRotM = eulerToMatrix(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r, rotationOrder); + const translationM = Matrix.Translation(translation[0], translation[1], translation[2]); + const rotationM = lclRotM.multiply(preRotM); + const scaleM = Matrix.Scaling(scale[0], scale[1], scale[2]); + return scaleM.multiply(rotationM).multiply(translationM); + } + + const T = Matrix.Translation(translation[0], translation[1], translation[2]); + const Roff = Matrix.Translation(rotationOffset[0], rotationOffset[1], rotationOffset[2]); + const Rp = Matrix.Translation(rotationPivot[0], rotationPivot[1], rotationPivot[2]); + const RpInv = Matrix.Translation(-rotationPivot[0], -rotationPivot[1], -rotationPivot[2]); + const Soff = Matrix.Translation(scalingOffset[0], scalingOffset[1], scalingOffset[2]); + const Sp = Matrix.Translation(scalingPivot[0], scalingPivot[1], scalingPivot[2]); + const SpInv = Matrix.Translation(-scalingPivot[0], -scalingPivot[1], -scalingPivot[2]); + + const Rpre = eulerToMatrixXYZ(preRotation[0] * d2r, preRotation[1] * d2r, preRotation[2] * d2r); + const R = eulerToMatrix(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r, rotationOrder); + const S = Matrix.Scaling(scale[0], scale[1], scale[2]); + + let RpostInv: Matrix; + if (hasPostRot) { + const Rpost = eulerToMatrixXYZ(postRotation[0] * d2r, postRotation[1] * d2r, postRotation[2] * d2r); + RpostInv = new Matrix(); + Rpost.invertToRef(RpostInv); + } else { + RpostInv = Matrix.Identity(); + } + + let result = SpInv; + result = result.multiply(S); + result = result.multiply(Sp); + result = result.multiply(Soff); + result = result.multiply(RpInv); + result = result.multiply(RpostInv); + result = result.multiply(R); + result = result.multiply(Rpre); + result = result.multiply(Rp); + result = result.multiply(Roff); + result = result.multiply(T); + return result; +} diff --git a/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts b/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts new file mode 100644 index 00000000000..74209794130 --- /dev/null +++ b/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts @@ -0,0 +1,364 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXDocument, type FBXNode, type FBXProperty } from "../types/fbxTypes"; + +/** + * Parse an ASCII FBX file into an FBXDocument. + */ +export function parseAsciiFBX(text: string): FBXDocument { + const tokenizer = new Tokenizer(text); + const version = parseVersion(text); + const nodes: FBXNode[] = []; + + while (!tokenizer.isEOF()) { + tokenizer.skipWhitespaceAndComments(); + if (tokenizer.isEOF()) { + break; + } + const node = parseNodeFromTokens(tokenizer); + if (node) { + nodes.push(node); + } + } + + return { version, nodes }; +} + +/** Extract FBX version from the header comment (e.g. "; FBX 7.7.0 project file") */ +function parseVersion(text: string): number { + const match = text.match(/;\s*FBX\s+(\d+)\.(\d+)\.(\d+)/); + if (!match) { + throw new Error("Cannot determine FBX version from ASCII header"); + } + return parseInt(match[1]) * 1000 + parseInt(match[2]) * 100 + parseInt(match[3]); +} + +// ── Tokenizer ────────────────────────────────────────────────────────────────── + +const enum TokenType { + Identifier, + Number, + String, + OpenBrace, + CloseBrace, + Colon, + Comma, + Star, + EOF, +} + +interface Token { + type: TokenType; + value: string; + pos: number; +} + +class Tokenizer { + private pos = 0; + private readonly len: number; + + constructor(private readonly text: string) { + this.len = text.length; + } + + isEOF(): boolean { + this.skipWhitespaceAndComments(); + return this.pos >= this.len; + } + + peek(): Token { + const saved = this.pos; + const tok = this.next(); + this.pos = saved; + return tok; + } + + next(): Token { + this.skipWhitespaceAndComments(); + if (this.pos >= this.len) { + return { type: TokenType.EOF, value: "", pos: this.pos }; + } + + const ch = this.text[this.pos]; + const startPos = this.pos; + + switch (ch) { + case "{": + this.pos++; + return { type: TokenType.OpenBrace, value: "{", pos: startPos }; + case "}": + this.pos++; + return { type: TokenType.CloseBrace, value: "}", pos: startPos }; + case ":": + this.pos++; + return { type: TokenType.Colon, value: ":", pos: startPos }; + case ",": + this.pos++; + return { type: TokenType.Comma, value: ",", pos: startPos }; + case "*": + this.pos++; + return { type: TokenType.Star, value: "*", pos: startPos }; + case '"': + return this.readString(); + default: + if (this.isNumberStart(ch)) { + return this.readNumber(); + } + if (this.isIdentStart(ch)) { + return this.readIdentifier(); + } + throw new Error(`Unexpected character '${ch}' at position ${this.pos}`); + } + } + + expect(type: TokenType): Token { + const tok = this.next(); + if (tok.type !== type) { + throw new Error(`Expected token type ${type} but got ${tok.type} ('${tok.value}') at pos ${tok.pos}`); + } + return tok; + } + + /** Look ahead to see if the next identifier + colon is a child node start */ + isNextNodeStart(): boolean { + const saved = this.pos; + this.skipWhitespaceAndComments(); + // Read the identifier + if (this.pos < this.len && this.isIdentStart(this.text[this.pos])) { + while (this.pos < this.len && this.isIdentChar(this.text[this.pos])) { + this.pos++; + } + // Skip whitespace between identifier and potential colon + while (this.pos < this.len && (this.text[this.pos] === " " || this.text[this.pos] === "\t")) { + this.pos++; + } + const isNode = this.pos < this.len && this.text[this.pos] === ":"; + this.pos = saved; + return isNode; + } + this.pos = saved; + return false; + } + + skipWhitespaceAndComments(): void { + while (this.pos < this.len) { + const ch = this.text[this.pos]; + if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") { + this.pos++; + } else if (ch === ";") { + // Skip comment to end of line + while (this.pos < this.len && this.text[this.pos] !== "\n") { + this.pos++; + } + } else { + break; + } + } + } + + private readString(): Token { + const startPos = this.pos; + this.pos++; // skip opening quote + let value = ""; + while (this.pos < this.len && this.text[this.pos] !== '"') { + if (this.text[this.pos] === "\\" && this.pos + 1 < this.len) { + this.pos++; + value += this.text[this.pos]; + } else { + value += this.text[this.pos]; + } + this.pos++; + } + if (this.pos < this.len) { + this.pos++; // skip closing quote + } + return { type: TokenType.String, value, pos: startPos }; + } + + private readNumber(): Token { + const startPos = this.pos; + // Handle leading sign + if (this.text[this.pos] === "-" || this.text[this.pos] === "+") { + this.pos++; + } + while (this.pos < this.len && this.isDigit(this.text[this.pos])) { + this.pos++; + } + if (this.pos < this.len && this.text[this.pos] === ".") { + this.pos++; + while (this.pos < this.len && this.isDigit(this.text[this.pos])) { + this.pos++; + } + } + // Scientific notation + if (this.pos < this.len && (this.text[this.pos] === "e" || this.text[this.pos] === "E")) { + this.pos++; + if (this.pos < this.len && (this.text[this.pos] === "+" || this.text[this.pos] === "-")) { + this.pos++; + } + while (this.pos < this.len && this.isDigit(this.text[this.pos])) { + this.pos++; + } + } + return { type: TokenType.Number, value: this.text.substring(startPos, this.pos), pos: startPos }; + } + + private readIdentifier(): Token { + const startPos = this.pos; + while (this.pos < this.len && this.isIdentChar(this.text[this.pos])) { + this.pos++; + } + return { type: TokenType.Identifier, value: this.text.substring(startPos, this.pos), pos: startPos }; + } + + private isDigit(ch: string): boolean { + return ch >= "0" && ch <= "9"; + } + + private isNumberStart(ch: string): boolean { + if (this.isDigit(ch)) { + return true; + } + if ((ch === "-" || ch === "+") && this.pos + 1 < this.len) { + return this.isDigit(this.text[this.pos + 1]) || this.text[this.pos + 1] === "."; + } + return false; + } + + private isIdentStart(ch: string): boolean { + return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_"; + } + + private isIdentChar(ch: string): boolean { + return this.isIdentStart(ch) || this.isDigit(ch) || ch === "|"; + } +} + +// ── Node Parsing ─────────────────────────────────────────────────────────────── + +function parseNodeFromTokens(tokenizer: Tokenizer): FBXNode | null { + const nameTok = tokenizer.peek(); + if (nameTok.type === TokenType.CloseBrace || nameTok.type === TokenType.EOF) { + return null; + } + + // Node name + const identTok = tokenizer.next(); + if (identTok.type !== TokenType.Identifier) { + throw new Error(`Expected identifier for node name, got '${identTok.value}' at pos ${identTok.pos}`); + } + const name = identTok.value; + tokenizer.expect(TokenType.Colon); + + // Parse properties until we hit '{' or end-of-line content + const properties: FBXProperty[] = []; + const children: FBXNode[] = []; + + // Check for array shorthand: *count { a: ... } + let peek = tokenizer.peek(); + if (peek.type === TokenType.Star) { + // Array node like "Vertices: *25959 {" + tokenizer.next(); // consume * + const countTok = tokenizer.expect(TokenType.Number); + const count = parseInt(countTok.value); + tokenizer.expect(TokenType.OpenBrace); + + // Expect "a:" followed by comma-separated values + const aTok = tokenizer.next(); + if (aTok.type === TokenType.Identifier && aTok.value === "a") { + tokenizer.expect(TokenType.Colon); + const values = parseArrayValues(tokenizer, count); + properties.push({ type: "float64[]", value: new Float64Array(values) }); + } + + tokenizer.expect(TokenType.CloseBrace); + return { name, properties, children }; + } + + // Parse inline properties (comma-separated values on the same logical line) + // Values can be: numbers, strings, or bare identifiers (e.g. "T", "Y", "CullingOff") + peek = tokenizer.peek(); + while (peek.type !== TokenType.OpenBrace && peek.type !== TokenType.CloseBrace && peek.type !== TokenType.EOF) { + if (peek.type === TokenType.Number) { + const tok = tokenizer.next(); + const numVal = parseNumericValue(tok.value); + if (typeof numVal === "bigint") { + properties.push({ type: "int64", value: numVal }); + } else if (Number.isInteger(numVal) && !tok.value.includes(".")) { + properties.push({ type: "int32", value: numVal as number }); + } else { + properties.push({ type: "float64", value: numVal as number }); + } + } else if (peek.type === TokenType.String) { + const tok = tokenizer.next(); + properties.push({ type: "string", value: tok.value }); + } else if (peek.type === TokenType.Identifier) { + // Check if this is a property value or the start of a new child node. + // If the next non-whitespace after the identifier is ':', it's a child node name — stop. + if (tokenizer.isNextNodeStart()) { + break; + } + // Bare identifier as a property value (e.g. "T", "Y", "CullingOff") + const tok = tokenizer.next(); + properties.push({ type: "string", value: tok.value }); + } else if (peek.type === TokenType.Comma) { + tokenizer.next(); // consume comma + } else { + break; + } + peek = tokenizer.peek(); + } + + // Check for block body { ... } + peek = tokenizer.peek(); + if (peek.type === TokenType.OpenBrace) { + tokenizer.next(); // consume '{' + // Parse child nodes + while (true) { + peek = tokenizer.peek(); + if (peek.type === TokenType.CloseBrace || peek.type === TokenType.EOF) { + break; + } + const child = parseNodeFromTokens(tokenizer); + if (child) { + children.push(child); + } else { + break; + } + } + tokenizer.expect(TokenType.CloseBrace); + } + + return { name, properties, children }; +} + +function parseArrayValues(tokenizer: Tokenizer, _count: number): number[] { + const values: number[] = []; + while (true) { + const peek = tokenizer.peek(); + if (peek.type === TokenType.CloseBrace || peek.type === TokenType.EOF) { + break; + } + if (peek.type === TokenType.Comma) { + tokenizer.next(); + continue; + } + if (peek.type === TokenType.Number) { + const tok = tokenizer.next(); + values.push(Number(tok.value)); + } else { + break; + } + } + return values; +} + +function parseNumericValue(str: string): number | bigint { + // If the number is very large (FBX UIDs are int64), use bigint + if (!str.includes(".") && !str.includes("e") && !str.includes("E")) { + const n = Number(str); + if (n > Number.MAX_SAFE_INTEGER || n < Number.MIN_SAFE_INTEGER) { + return BigInt(str); + } + } + return Number(str); +} diff --git a/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts b/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts new file mode 100644 index 00000000000..a3ed5d5892b --- /dev/null +++ b/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts @@ -0,0 +1,236 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +import { type FBXDocument, type FBXNode, type FBXProperty, type FBXPropertyType } from "../types/fbxTypes"; +import { inflateZlib } from "./zlibInflate"; + +const FBX_MAGIC = "Kaydara FBX Binary \0"; +const HEADER_SIZE = 27; // 21 magic + 2 padding + 4 version uint32 + +/** + * Parse a binary FBX file into an FBXDocument. + * Supports FBX versions 7.0–7.7 (v7.5+ uses 64-bit node headers). + */ +export function parseBinaryFBX(buffer: ArrayBuffer): FBXDocument { + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + // Validate magic + const magic = decodeASCII(bytes, 0, 21); + if (magic !== FBX_MAGIC) { + throw new Error("Not a valid binary FBX file"); + } + + const version = view.getUint32(23, true); + // v7.5+ uses 64-bit offsets in node records + const is64Bit = version >= 7500; + + const nodes: FBXNode[] = []; + let offset = HEADER_SIZE; + + while (offset < buffer.byteLength) { + const result = parseNode(view, bytes, offset, is64Bit); + if (result === null) { + break; // null sentinel node + } + nodes.push(result.node); + offset = result.endOffset; + } + + return { version, nodes }; +} + +interface ParsedNode { + node: FBXNode; + endOffset: number; +} + +function parseNode(view: DataView, bytes: Uint8Array, offset: number, is64Bit: boolean): ParsedNode | null { + // Read node header + let endOffset: number; + let numProperties: number; + let propertyListLen: number; + let headerSize: number; + + if (is64Bit) { + endOffset = Number(view.getBigUint64(offset, true)); + numProperties = Number(view.getBigUint64(offset + 8, true)); + propertyListLen = Number(view.getBigUint64(offset + 16, true)); + headerSize = 25; // 8+8+8+1 (nameLen byte) + } else { + endOffset = view.getUint32(offset, true); + numProperties = view.getUint32(offset + 4, true); + propertyListLen = view.getUint32(offset + 8, true); + headerSize = 13; // 4+4+4+1 (nameLen byte) + } + + // Null sentinel: all header fields are zero + if (endOffset === 0) { + return null; + } + + const nameLen = bytes[offset + headerSize - 1]; + const name = decodeASCII(bytes, offset + headerSize, nameLen); + + let cursor = offset + headerSize + nameLen; + const propertiesStart = cursor; + + // Parse properties + const properties: FBXProperty[] = []; + for (let i = 0; i < numProperties; i++) { + const result = parseProperty(view, bytes, cursor); + properties.push(result.property); + cursor = result.nextOffset; + } + if (cursor !== propertiesStart + propertyListLen) { + throw new Error(`Invalid FBX property list length for node '${name}' at offset ${offset}`); + } + + // Parse nested child nodes (between end of properties and endOffset) + const children: FBXNode[] = []; + if (cursor < endOffset) { + while (cursor < endOffset) { + const child = parseNode(view, bytes, cursor, is64Bit); + if (child === null) { + break; + } + children.push(child.node); + cursor = child.endOffset; + } + } + + return { + node: { name, properties, children }, + endOffset, + }; +} + +interface ParsedProperty { + property: FBXProperty; + nextOffset: number; +} + +function parseProperty(view: DataView, bytes: Uint8Array, offset: number): ParsedProperty { + const typeCode = String.fromCharCode(bytes[offset]); + offset += 1; + + switch (typeCode) { + case "C": { + // Boolean (1 byte) + const value = bytes[offset] !== 0; + return { property: { type: "boolean", value }, nextOffset: offset + 1 }; + } + case "Y": { + // Int16 + const value = view.getInt16(offset, true); + return { property: { type: "int16", value }, nextOffset: offset + 2 }; + } + case "I": { + // Int32 + const value = view.getInt32(offset, true); + return { property: { type: "int32", value }, nextOffset: offset + 4 }; + } + case "F": { + // Float32 + const value = view.getFloat32(offset, true); + return { property: { type: "float32", value }, nextOffset: offset + 4 }; + } + case "D": { + // Float64 + const value = view.getFloat64(offset, true); + return { property: { type: "float64", value }, nextOffset: offset + 8 }; + } + case "L": { + // Int64 + const value = view.getBigInt64(offset, true); + return { property: { type: "int64", value }, nextOffset: offset + 8 }; + } + case "S": { + // String (uint32 length + data) + const len = view.getUint32(offset, true); + const value = decodeUTF8(bytes, offset + 4, len); + return { property: { type: "string", value }, nextOffset: offset + 4 + len }; + } + case "R": { + // Raw binary data (uint32 length + data) + const len = view.getUint32(offset, true); + const value = bytes.slice(offset + 4, offset + 4 + len); + return { property: { type: "raw", value }, nextOffset: offset + 4 + len }; + } + // Array types + case "f": + return parseArrayProperty(view, bytes, offset, "float32[]", 4); + case "d": + return parseArrayProperty(view, bytes, offset, "float64[]", 8); + case "i": + return parseArrayProperty(view, bytes, offset, "int32[]", 4); + case "l": + return parseArrayProperty(view, bytes, offset, "int64[]", 8); + case "b": + return parseArrayProperty(view, bytes, offset, "boolean[]", 1); + default: + throw new Error(`Unknown FBX property type: '${typeCode}' at offset ${offset - 1}`); + } +} + +function parseArrayProperty(view: DataView, bytes: Uint8Array, offset: number, type: FBXPropertyType, elementSize: number): ParsedProperty { + const arrayLength = view.getUint32(offset, true); + const encoding = view.getUint32(offset + 4, true); // 0=raw, 1=zlib + const compressedLength = view.getUint32(offset + 8, true); + offset += 12; + const expectedByteLength = arrayLength * elementSize; + + let arrayData: Uint8Array; + if (encoding === 1) { + // zlib compressed + const compressed = bytes.subarray(offset, offset + compressedLength); + arrayData = inflateZlib(compressed, expectedByteLength); + } else { + if (encoding !== 0) { + throw new Error(`Unsupported FBX array encoding: ${encoding}`); + } + if (compressedLength !== expectedByteLength) { + throw new Error(`Invalid FBX array byte length for ${type}`); + } + arrayData = bytes.slice(offset, offset + compressedLength); + } + + const arrayBuffer = arrayData.buffer.slice(arrayData.byteOffset, arrayData.byteOffset + arrayData.byteLength); + + let value: Float32Array | Float64Array | Int32Array | BigInt64Array | Uint8Array; + switch (type) { + case "float32[]": + value = new Float32Array(arrayBuffer); + break; + case "float64[]": + value = new Float64Array(arrayBuffer); + break; + case "int32[]": + value = new Int32Array(arrayBuffer); + break; + case "boolean[]": + value = arrayData; + break; + case "int64[]": + value = new BigInt64Array(arrayBuffer); + break; + default: + throw new Error(`Unexpected array type: ${type}`); + } + + return { + property: { type, value }, + nextOffset: offset + compressedLength, + }; +} + +function decodeASCII(bytes: Uint8Array, offset: number, length: number): string { + let result = ""; + for (let i = 0; i < length; i++) { + result += String.fromCharCode(bytes[offset + i]); + } + return result; +} + +function decodeUTF8(bytes: Uint8Array, offset: number, length: number): string { + const decoder = new TextDecoder("utf-8"); + return decoder.decode(bytes.subarray(offset, offset + length)); +} diff --git a/packages/dev/loaders/src/FBX/parsers/zlibInflate.ts b/packages/dev/loaders/src/FBX/parsers/zlibInflate.ts new file mode 100644 index 00000000000..a84207e8497 --- /dev/null +++ b/packages/dev/loaders/src/FBX/parsers/zlibInflate.ts @@ -0,0 +1,403 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +const ADLER_MOD = 65521; +const MAX_BITS = 15; + +const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]; +const LENGTH_EXTRA_BITS = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]; +const DISTANCE_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]; +const DISTANCE_EXTRA_BITS = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]; +const CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]; + +/** + * Inflate a zlib-wrapped deflate stream. + * + * This implementation is intentionally scoped to FBX binary array payloads: one-shot, + * synchronous zlib streams with the exact uncompressed length known up front. + */ +export function inflateZlib(input: Uint8Array, expectedLength: number): Uint8Array { + if (!Number.isInteger(expectedLength) || expectedLength < 0) { + throw new Error("zlib: invalid expected length"); + } + if (input.byteLength < 6) { + throw new Error("zlib: unexpected end of input"); + } + + const cmf = input[0]; + const flg = input[1]; + if ((cmf & 0x0f) !== 8 || cmf >> 4 > 7 || ((cmf << 8) + flg) % 31 !== 0) { + throw new Error("zlib: invalid header"); + } + if ((flg & 0x20) !== 0) { + throw new Error("zlib: preset dictionary not supported"); + } + + const reader = new BitReader(input, 2, input.byteLength - 4); + const output = new OutputWriter(expectedLength); + + let isFinalBlock = false; + while (!isFinalBlock) { + isFinalBlock = reader.readBits(1) === 1; + const blockType = reader.readBits(2); + switch (blockType) { + case 0: + inflateStoredBlock(reader, output); + break; + case 1: + inflateCompressedBlock(reader, output, getFixedLiteralLengthTree(), getFixedDistanceTree()); + break; + case 2: { + const { literalLengthTree, distanceTree } = readDynamicTrees(reader); + inflateCompressedBlock(reader, output, literalLengthTree, distanceTree); + break; + } + default: + throw new Error("deflate: invalid block type"); + } + } + + if (reader.byteOffset < input.byteLength - 4) { + throw new Error("zlib: trailing deflate data"); + } + + output.finish(); + const expectedAdler = ((input[input.byteLength - 4] << 24) | (input[input.byteLength - 3] << 16) | (input[input.byteLength - 2] << 8) | input[input.byteLength - 1]) >>> 0; + if (output.adler32() !== expectedAdler) { + throw new Error("zlib: adler32 mismatch"); + } + + return output.bytes; +} + +class BitReader { + private bitBuffer = 0; + private bitCount = 0; + + public constructor( + private readonly input: Uint8Array, + public byteOffset: number, + private readonly endOffset: number + ) {} + + public readBits(count: number): number { + this.ensureBits(count); + const value = this.bitBuffer & ((1 << count) - 1); + this.bitBuffer >>>= count; + this.bitCount -= count; + return value; + } + + public readBit(): number { + if (this.bitCount === 0) { + if (this.byteOffset >= this.endOffset) { + throw new Error("zlib: unexpected end of input"); + } + this.bitBuffer = this.input[this.byteOffset++]; + this.bitCount = 8; + } + const bit = this.bitBuffer & 1; + this.bitBuffer >>>= 1; + this.bitCount--; + return bit; + } + + public alignToByte(): void { + this.bitBuffer = 0; + this.bitCount = 0; + } + + public readUint16LE(): number { + this.ensureByteAligned(); + if (this.byteOffset + 2 > this.endOffset) { + throw new Error("zlib: unexpected end of input"); + } + const value = this.input[this.byteOffset] | (this.input[this.byteOffset + 1] << 8); + this.byteOffset += 2; + return value; + } + + public readByte(): number { + this.ensureByteAligned(); + if (this.byteOffset >= this.endOffset) { + throw new Error("zlib: unexpected end of input"); + } + return this.input[this.byteOffset++]; + } + + private ensureByteAligned(): void { + if (this.bitCount !== 0) { + throw new Error("deflate: expected byte alignment"); + } + } + + private ensureBits(count: number): void { + while (this.bitCount < count) { + if (this.byteOffset >= this.endOffset) { + throw new Error("zlib: unexpected end of input"); + } + this.bitBuffer |= this.input[this.byteOffset++] << this.bitCount; + this.bitCount += 8; + } + } +} + +class OutputWriter { + private offset = 0; + private adlerA = 1; + private adlerB = 0; + + public readonly bytes: Uint8Array; + + public constructor(expectedLength: number) { + this.bytes = new Uint8Array(expectedLength); + } + + public writeByte(value: number): void { + if (this.offset >= this.bytes.byteLength) { + throw new Error("zlib: output length mismatch"); + } + const byte = value & 0xff; + this.bytes[this.offset++] = byte; + this.adlerA += byte; + this.adlerB += this.adlerA; + this.adlerA %= ADLER_MOD; + this.adlerB %= ADLER_MOD; + } + + public copy(distance: number, length: number): void { + if (distance <= 0 || distance > this.offset) { + throw new Error("deflate: distance out of range"); + } + for (let i = 0; i < length; i++) { + this.writeByte(this.bytes[this.offset - distance]); + } + } + + public finish(): void { + if (this.offset !== this.bytes.byteLength) { + throw new Error("zlib: output length mismatch"); + } + } + + public adler32(): number { + return ((this.adlerB << 16) | this.adlerA) >>> 0; + } +} + +class HuffmanTree { + private readonly symbolsByLength: Array; + private readonly maxCodeLength: number; + + public constructor(codeLengths: readonly number[], options: { allowEmpty?: boolean } = {}) { + const counts = new Array(MAX_BITS + 1).fill(0); + let nonZeroCount = 0; + let maxCodeLength = 0; + + for (const length of codeLengths) { + if (!Number.isInteger(length) || length < 0 || length > MAX_BITS) { + throw new Error("deflate: invalid huffman code lengths"); + } + if (length > 0) { + counts[length]++; + nonZeroCount++; + maxCodeLength = Math.max(maxCodeLength, length); + } + } + + if (nonZeroCount === 0) { + if (options.allowEmpty) { + this.symbolsByLength = []; + this.maxCodeLength = 0; + return; + } + throw new Error("deflate: invalid huffman code lengths"); + } + + let remaining = 1; + for (let bits = 1; bits <= MAX_BITS; bits++) { + remaining = (remaining << 1) - counts[bits]; + if (remaining < 0) { + throw new Error("deflate: invalid huffman code lengths"); + } + } + if (remaining !== 0 && nonZeroCount !== 1) { + throw new Error("deflate: invalid huffman code lengths"); + } + + const nextCode = new Array(MAX_BITS + 1).fill(0); + let code = 0; + for (let bits = 1; bits <= MAX_BITS; bits++) { + code = (code + counts[bits - 1]) << 1; + nextCode[bits] = code; + } + + this.symbolsByLength = Array.from({ length: MAX_BITS + 1 }, (_, length) => { + if (counts[length] === 0) { + return undefined; + } + const symbols = new Int16Array(1 << length); + symbols.fill(-1); + return symbols; + }); + for (let symbol = 0; symbol < codeLengths.length; symbol++) { + const length = codeLengths[symbol]; + if (length === 0) { + continue; + } + this.symbolsByLength[length]![nextCode[length]++] = symbol; + } + this.maxCodeLength = maxCodeLength; + } + + public decode(reader: BitReader): number { + if (this.maxCodeLength === 0) { + throw new Error("deflate: invalid huffman code"); + } + + let code = 0; + for (let length = 1; length <= this.maxCodeLength; length++) { + code = (code << 1) | reader.readBit(); + const symbol = this.symbolsByLength[length]?.[code] ?? -1; + if (symbol >= 0) { + return symbol; + } + } + throw new Error("deflate: invalid huffman code"); + } +} + +let fixedLiteralLengthTree: HuffmanTree | undefined; +let fixedDistanceTree: HuffmanTree | undefined; + +function getFixedLiteralLengthTree(): HuffmanTree { + if (!fixedLiteralLengthTree) { + const lengths = new Array(288); + for (let symbol = 0; symbol <= 143; symbol++) { + lengths[symbol] = 8; + } + for (let symbol = 144; symbol <= 255; symbol++) { + lengths[symbol] = 9; + } + for (let symbol = 256; symbol <= 279; symbol++) { + lengths[symbol] = 7; + } + for (let symbol = 280; symbol <= 287; symbol++) { + lengths[symbol] = 8; + } + fixedLiteralLengthTree = new HuffmanTree(lengths); + } + return fixedLiteralLengthTree; +} + +function getFixedDistanceTree(): HuffmanTree { + if (!fixedDistanceTree) { + fixedDistanceTree = new HuffmanTree(new Array(32).fill(5)); + } + return fixedDistanceTree; +} + +function inflateStoredBlock(reader: BitReader, output: OutputWriter): void { + reader.alignToByte(); + const length = reader.readUint16LE(); + const inverseLength = reader.readUint16LE(); + if (((length ^ inverseLength) & 0xffff) !== 0xffff) { + throw new Error("deflate: invalid stored block length"); + } + + for (let i = 0; i < length; i++) { + output.writeByte(reader.readByte()); + } +} + +function inflateCompressedBlock(reader: BitReader, output: OutputWriter, literalLengthTree: HuffmanTree, distanceTree: HuffmanTree): void { + while (true) { + const symbol = literalLengthTree.decode(reader); + if (symbol < 256) { + output.writeByte(symbol); + continue; + } + if (symbol === 256) { + return; + } + if (symbol > 285) { + throw new Error("deflate: invalid literal/length symbol"); + } + + const lengthIndex = symbol - 257; + const length = LENGTH_BASE[lengthIndex] + reader.readBits(LENGTH_EXTRA_BITS[lengthIndex]); + const distanceSymbol = distanceTree.decode(reader); + if (distanceSymbol > 29) { + throw new Error("deflate: invalid distance symbol"); + } + const distance = DISTANCE_BASE[distanceSymbol] + reader.readBits(DISTANCE_EXTRA_BITS[distanceSymbol]); + output.copy(distance, length); + } +} + +function readDynamicTrees(reader: BitReader): { + literalLengthTree: HuffmanTree; + distanceTree: HuffmanTree; +} { + const literalLengthCount = reader.readBits(5) + 257; + const distanceCount = reader.readBits(5) + 1; + const codeLengthCount = reader.readBits(4) + 4; + + const codeLengthLengths = new Array(19).fill(0); + for (let i = 0; i < codeLengthCount; i++) { + codeLengthLengths[CODE_LENGTH_ORDER[i]] = reader.readBits(3); + } + + const codeLengthTree = new HuffmanTree(codeLengthLengths); + const lengths = readCodeLengths(reader, codeLengthTree, literalLengthCount + distanceCount); + const literalLengthLengths = lengths.slice(0, literalLengthCount); + const distanceLengths = lengths.slice(literalLengthCount); + + if (literalLengthLengths[256] === 0) { + throw new Error("deflate: missing end-of-block code"); + } + + return { + literalLengthTree: new HuffmanTree(literalLengthLengths), + distanceTree: new HuffmanTree(distanceLengths, { allowEmpty: true }), + }; +} + +function readCodeLengths(reader: BitReader, codeLengthTree: HuffmanTree, count: number): number[] { + const lengths: number[] = []; + while (lengths.length < count) { + const symbol = codeLengthTree.decode(reader); + if (symbol <= 15) { + lengths.push(symbol); + continue; + } + + let repeatLength: number; + let repeatedValue: number; + switch (symbol) { + case 16: + if (lengths.length === 0) { + throw new Error("deflate: invalid code length repeat"); + } + repeatedValue = lengths[lengths.length - 1]; + repeatLength = reader.readBits(2) + 3; + break; + case 17: + repeatedValue = 0; + repeatLength = reader.readBits(3) + 3; + break; + case 18: + repeatedValue = 0; + repeatLength = reader.readBits(7) + 11; + break; + default: + throw new Error("deflate: invalid code length symbol"); + } + + if (lengths.length + repeatLength > count) { + throw new Error("deflate: invalid code length repeat"); + } + for (let i = 0; i < repeatLength; i++) { + lengths.push(repeatedValue); + } + } + return lengths; +} diff --git a/packages/dev/loaders/src/FBX/types/fbxTypes.ts b/packages/dev/loaders/src/FBX/types/fbxTypes.ts new file mode 100644 index 00000000000..fbbba10500a --- /dev/null +++ b/packages/dev/loaders/src/FBX/types/fbxTypes.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */ +/** + * Intermediate representation for parsed FBX data. + * Both binary and ASCII parsers produce this same structure. + */ + +/** Individual property value within an FBX node */ +export type FBXPropertyValue = boolean | number | bigint | string | Float32Array | Float64Array | Int32Array | BigInt64Array | Uint8Array; + +export type FBXPropertyType = + | "boolean" // 'C' + | "int16" // 'Y' + | "int32" // 'I' + | "int64" // 'L' + | "float32" // 'F' + | "float64" // 'D' + | "string" // 'S' + | "raw" // 'R' + | "float32[]" // 'f' + | "float64[]" // 'd' + | "int32[]" // 'i' + | "int64[]" // 'l' + | "boolean[]"; // 'b' (stored as Uint8Array where 0=false, 1=true) + +export interface FBXProperty { + type: FBXPropertyType; + value: FBXPropertyValue; +} + +/** A node in the FBX document tree */ +export interface FBXNode { + name: string; + properties: FBXProperty[]; + children: FBXNode[]; +} + +/** Top-level parsed FBX document */ +export interface FBXDocument { + version: number; + nodes: FBXNode[]; +} + +/** Helper to find a child node by name */ +export function findChildByName(node: FBXNode, name: string): FBXNode | undefined { + return node.children.find((c) => c.name === name); +} + +/** Helper to find all children with a given name */ +export function findChildrenByName(node: FBXNode, name: string): FBXNode[] { + return node.children.filter((c) => c.name === name); +} + +/** Helper to find a top-level node in a document */ +export function findDocumentNode(doc: FBXDocument, name: string): FBXNode | undefined { + return doc.nodes.find((n) => n.name === name); +} + +/** Extract a property value by index, with type narrowing */ +export function getPropertyValue(node: FBXNode, index: number): T | undefined { + if (index < node.properties.length) { + return node.properties[index].value as T; + } + return undefined; +} + +/** Get the numeric ID from a node (first property is typically the int64 UID) */ +export function getNodeId(node: FBXNode): bigint | undefined { + const prop = node.properties[0]; + if (prop && (prop.type === "int64" || prop.type === "int32")) { + return BigInt(prop.value as number | bigint); + } + return undefined; +} + +/** + * Clean FBX object names. + * FBX names may contain: + * - A "Class::" prefix (e.g. "Model::valkyrie_mesh") — strip it + * - A "\x00\x01ClassName" suffix in binary (e.g. "valkyrie_mesh\x00\x01Model") — strip it + */ +export function cleanFBXName(fbxName: string): string { + // Strip \x00\x01 suffix (binary FBX name/class separator) + const nullIdx = fbxName.indexOf("\0"); + if (nullIdx >= 0) { + fbxName = fbxName.substring(0, nullIdx); + } + + // Strip "ClassName::" prefix (ASCII FBX) + const colonIdx = fbxName.indexOf("::"); + if (colonIdx >= 0) { + fbxName = fbxName.substring(colonIdx + 2); + } + + return fbxName; +} diff --git a/packages/dev/loaders/src/dynamic.ts b/packages/dev/loaders/src/dynamic.ts index 2fd6942896a..85db0e56189 100644 --- a/packages/dev/loaders/src/dynamic.ts +++ b/packages/dev/loaders/src/dynamic.ts @@ -3,6 +3,7 @@ import { type ISceneLoaderPluginFactory, type SceneLoaderPluginOptions, RegisterSceneLoaderPlugin } from "core/Loading/sceneLoader"; import { BVHFileLoaderMetadata } from "./BVH/bvhFileLoader.metadata"; +import { FBXFileLoaderMetadata } from "./FBX/fbxFileLoader.metadata"; import { GLTFFileLoaderMetadata } from "./glTF/glTFFileLoader.metadata"; import { OBJFileLoaderMetadata } from "./OBJ/objFileLoader.metadata"; import { SPLATFileLoaderMetadata } from "./SPLAT/splatFileLoader.metadata"; @@ -24,6 +25,15 @@ export function registerBuiltInLoaders() { }, } satisfies ISceneLoaderPluginFactory); + // Register the FBX loader. + RegisterSceneLoaderPlugin({ + ...FBXFileLoaderMetadata, + createPlugin: async (options: SceneLoaderPluginOptions) => { + const { FBXFileLoader } = await import("./FBX/fbxFileLoader"); + return new FBXFileLoader(options[FBXFileLoaderMetadata.name]); + }, + } satisfies ISceneLoaderPluginFactory); + // Register the glTF loader (2.0) specifically/only. RegisterSceneLoaderPlugin({ ...GLTFFileLoaderMetadata, diff --git a/packages/dev/loaders/src/index.ts b/packages/dev/loaders/src/index.ts index b356aebe386..27f05ae3a4f 100644 --- a/packages/dev/loaders/src/index.ts +++ b/packages/dev/loaders/src/index.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-restricted-imports */ export * from "./BVH/index"; +export * from "./FBX/index"; export * from "./glTF/index"; export * from "./OBJ/index"; export * from "./STL/index"; diff --git a/packages/dev/loaders/test/unit/FBX/dynamic.test.ts b/packages/dev/loaders/test/unit/FBX/dynamic.test.ts new file mode 100644 index 00000000000..508ba77032f --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/dynamic.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { GetRegisteredSceneLoaderPluginMetadata, SceneLoader } from "core/Loading/sceneLoader"; +import { registerBuiltInLoaders } from "loaders/dynamic"; +import { FBXFileLoaderMetadata } from "loaders/FBX/fbxFileLoader.metadata"; +// eslint-disable-next-line babylonjs/no-directory-barrel-imports +import "loaders/FBX"; + +describe("FBX loader registration", () => { + it("registers the FBX loader when importing loaders/FBX", () => { + const plugin = SceneLoader.GetPluginForExtension(".fbx"); + const metadata = GetRegisteredSceneLoaderPluginMetadata().find((entry) => entry.name === FBXFileLoaderMetadata.name); + + expect(plugin?.name).toBe(FBXFileLoaderMetadata.name); + expect(metadata?.extensions).toEqual([ + { + extension: ".fbx", + isBinary: true, + }, + ]); + }); + + it("registers the FBX loader through dynamic built-in registration", () => { + registerBuiltInLoaders(); + + const plugin = SceneLoader.GetPluginForExtension(".fbx"); + const metadata = GetRegisteredSceneLoaderPluginMetadata().find((entry) => entry.name === FBXFileLoaderMetadata.name); + + expect(plugin?.name).toBe(FBXFileLoaderMetadata.name); + expect(metadata?.extensions).toEqual([ + { + extension: ".fbx", + isBinary: true, + }, + ]); + }); +}); diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts new file mode 100644 index 00000000000..ec1795a819a --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { extractBlendShapes } from "loaders/FBX/interpreter/blendShapes"; +import { resolveConnections } from "loaders/FBX/interpreter/connections"; +import { type FBXDocument, type FBXNode, type FBXProperty } from "loaders/FBX/types/fbxTypes"; + +describe("FBX blend shape in-betweens", () => { + it("extracts FullWeights and sorts in-between shapes by weight", () => { + const blendShapes = extractBlendShapes(resolveConnections(createBlendShapeDocument())); + const channel = blendShapes[0].channels[0]; + + expect(channel.fullWeights).toEqual([50, 100]); + expect(Array.from(channel.shapes[0].vertices)).toEqual([0.5, 0, 0]); + expect(Array.from(channel.shapes[1].vertices)).toEqual([1, 0, 0]); + expect(channel.diagnostics).toEqual([]); + }); + + it("ignores mismatched FullWeights when a channel has only one shape", () => { + const blendShapes = extractBlendShapes(resolveConnections(createSingleShapeFullWeightsDocument())); + const channel = blendShapes[0].channels[0]; + + expect(channel.fullWeights).toBeNull(); + expect(channel.shapes).toHaveLength(1); + expect(Array.from(channel.shapes[0].vertices)).toEqual([1, 0, 0]); + expect(channel.diagnostics).toEqual([]); + }); +}); + +function createBlendShapeDocument(): FBXDocument { + return { + version: 7500, + nodes: [ + { + name: "Objects", + properties: [], + children: [ + createObject("Geometry", 1n, "Geometry::Base", "Mesh"), + createObject("Deformer", 2n, "Deformer::BlendShape", "BlendShape"), + { + ...createObject("Deformer", 3n, "SubDeformer::Smile", "BlendShapeChannel"), + children: [{ name: "FullWeights", properties: [{ type: "float64[]", value: new Float64Array([100, 50]) }], children: [] }], + }, + createShape(4n, [1, 0, 0]), + createShape(5n, [0.5, 0, 0]), + ], + }, + { + name: "Connections", + properties: [], + children: [createConnection("OO", 2n, 1n), createConnection("OO", 3n, 2n), createConnection("OO", 4n, 3n), createConnection("OO", 5n, 3n)], + }, + ], + }; +} + +function createSingleShapeFullWeightsDocument(): FBXDocument { + return { + version: 7500, + nodes: [ + { + name: "Objects", + properties: [], + children: [ + createObject("Geometry", 1n, "Geometry::Base", "Mesh"), + createObject("Deformer", 2n, "Deformer::BlendShape", "BlendShape"), + { + ...createObject("Deformer", 3n, "SubDeformer::Blink", "BlendShapeChannel"), + children: [{ name: "FullWeights", properties: [{ type: "float64[]", value: new Float64Array([50, 100]) }], children: [] }], + }, + createShape(4n, [1, 0, 0]), + ], + }, + { + name: "Connections", + properties: [], + children: [createConnection("OO", 2n, 1n), createConnection("OO", 3n, 2n), createConnection("OO", 4n, 3n)], + }, + ], + }; +} + +function createShape(id: bigint, vertices: number[]): FBXNode { + return { + ...createObject("Geometry", id, `Geometry::Shape${id.toString()}`, "Shape"), + children: [ + { name: "Indexes", properties: [{ type: "int32[]", value: new Int32Array([0]) }], children: [] }, + { name: "Vertices", properties: [{ type: "float64[]", value: new Float64Array(vertices) }], children: [] }, + ], + }; +} + +function createObject(name: string, id: bigint, objectName: string, subType: string): FBXNode { + return { + name, + properties: [ + { type: "int64", value: id }, + { type: "string", value: objectName }, + { type: "string", value: subType }, + ], + children: [], + }; +} + +function createConnection(type: string, child: bigint, parent: bigint): FBXNode { + const properties: FBXProperty[] = [ + { type: "string", value: type }, + { type: "int64", value: child }, + { type: "int64", value: parent }, + ]; + return { name: "C", properties, children: [] }; +} diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts new file mode 100644 index 00000000000..9a51d1361ed --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { resolveConnections } from "loaders/FBX/interpreter/connections"; +import { type FBXNode } from "loaders/FBX/types/fbxTypes"; + +describe("resolveConnections", () => { + it("diagnoses unsupported or unresolved connections without adding them to the graph", () => { + const map = resolveConnections({ + version: 7500, + nodes: [ + { + name: "Objects", + properties: [], + children: [createObject("Model", 1n, "Root", "Null")], + }, + { + name: "Connections", + properties: [], + children: [createConnection("XX", 1n, 0n), createConnection("OO", "MissingLegacy", "Scene", "Connect")], + }, + ], + }); + + expect(map.connections).toHaveLength(0); + expect(map.connectionEntries).toHaveLength(2); + expect(map.diagnostics.map((diagnostic) => diagnostic.reason)).toEqual(["unsupported-connection-type", "unresolved-legacy-endpoint"]); + }); + + it("keeps legacy synthetic mesh geometry IDs separate from object names", () => { + const map = resolveConnections({ + version: 6100, + nodes: [ + { + name: "Objects", + properties: [], + children: [createLegacyObject("Model", "MeshA", "Mesh"), createLegacyObject("Model", "MeshA\0Geometry", "Null")], + }, + { + name: "Connections", + properties: [], + children: [createConnection("OO", "MeshA", "Scene", "Connect")], + }, + ], + }); + + const syntheticGeometryEntries = map.objectEntries.filter((entry) => entry.source === "legacySyntheticGeometry"); + expect(syntheticGeometryEntries).toHaveLength(1); + expect(syntheticGeometryEntries[0].id).not.toBe(map.objectEntries.find((entry) => entry.legacyName === "MeshA")?.id); + expect(map.childrenOf.get(syntheticGeometryEntries[0].id)).toBeUndefined(); + }); + + it("diagnoses duplicate parents while preserving existing last-parent behavior", () => { + const map = resolveConnections({ + version: 7500, + nodes: [ + { + name: "Objects", + properties: [], + children: [createObject("Model", 1n, "Child", "Null"), createObject("Model", 2n, "ParentA", "Null"), createObject("Model", 3n, "ParentB", "Null")], + }, + { + name: "Connections", + properties: [], + children: [createConnection("OO", 1n, 2n), createConnection("OO", 1n, 3n)], + }, + ], + }); + + expect(map.parentOf.get(1n)?.id).toBe(3n); + expect(map.diagnostics.some((diagnostic) => diagnostic.reason === "duplicate-parent")).toBe(true); + }); +}); + +function createObject(name: string, id: bigint, objectName: string, subType: string): FBXNode { + return { + name, + properties: [ + { type: "int64", value: id }, + { type: "string", value: objectName }, + { type: "string", value: subType }, + ], + children: [], + }; +} + +function createLegacyObject(name: string, objectName: string, subType: string): FBXNode { + return { + name, + properties: [ + { type: "string", value: objectName }, + { type: "string", value: subType }, + ], + children: [], + }; +} + +function createConnection(type: string, child: bigint | string, parent: bigint | string, nodeName = "C"): FBXNode { + return { + name: nodeName, + properties: [ + { type: "string", value: type }, + typeof child === "bigint" ? { type: "int64", value: child } : { type: "string", value: child }, + typeof parent === "bigint" ? { type: "int64", value: parent } : { type: "string", value: parent }, + ], + children: [], + }; +} diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts new file mode 100644 index 00000000000..ce86e3d6a17 --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { extractGeometry } from "loaders/FBX/interpreter/geometry"; +import { type FBXNode, type FBXPropertyValue } from "loaders/FBX/types/fbxTypes"; + +describe("FBX geometry fidelity", () => { + it("ear-clips concave polygons while preserving per-polygon material indices", () => { + const geometry = extractGeometry( + createGeometryNode([0, 0, 0, 2, 0, 0, 1, 1, 0, 2, 2, 0, 0, 2, 0, 3, 0, 0, 4, 0, 0, 3, 1, 0], [0, 1, 2, 3, -5, 5, 6, -8], [createLayerElementMaterial([7, 3])]), + 1n + ); + + expect(geometry.indices.length).toBe(12); + expect(Array.from(geometry.materialIndices ?? [])).toEqual([7, 7, 7, 3]); + expect(geometry.diagnostics).toEqual([]); + }); + + it("falls back for degenerate n-gons and records diagnostics", () => { + const geometry = extractGeometry(createGeometryNode([0, 0, 0, 1, 0, 0, 2, 0, 0, 3, 0, 0], [0, 1, 2, -4]), 1n); + + expect(geometry.indices.length).toBe(6); + expect(geometry.diagnostics.some((diagnostic) => diagnostic.type === "degenerate-polygon")).toBe(true); + }); + + it("expands tangent and binormal layer elements", () => { + const geometry = extractGeometry( + createGeometryNode( + [0, 0, 0, 1, 0, 0, 0, 1, 0], + [0, 1, -3], + [ + createLayerElement("LayerElementNormal", "Normals", "NormalsIndex", [0, 0, 1, 0, 0, 1, 0, 0, 1]), + createLayerElement("LayerElementTangent", "Tangents", "TangentsIndex", [1, 0, 0, 1, 0, 0, 1, 0, 0]), + createLayerElement("LayerElementBinormal", "Binormals", "BinormalsIndex", [0, 1, 0, 0, 1, 0, 0, 1, 0]), + ] + ), + 1n + ); + + expect(Array.from(geometry.tangents ?? [])).toEqual([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]); + expect(Array.from(geometry.binormals ?? [])).toEqual([0, 1, 0, 0, 1, 0, 0, 1, 0]); + }); +}); + +function createGeometryNode(vertices: number[], polygonVertexIndex: number[], children: FBXNode[] = []): FBXNode { + return { + name: "Geometry", + properties: [ + { type: "int64", value: 1n }, + { type: "string", value: "Geometry::Synthetic" }, + { type: "string", value: "Mesh" }, + ], + children: [ + { name: "Vertices", properties: [{ type: "float64[]", value: new Float64Array(vertices) }], children: [] }, + { name: "PolygonVertexIndex", properties: [{ type: "int32[]", value: new Int32Array(polygonVertexIndex) }], children: [] }, + ...children, + ], + }; +} + +function createLayerElementMaterial(materials: number[]): FBXNode { + return { + name: "LayerElementMaterial", + properties: [{ type: "int32", value: 0 }], + children: [ + child("MappingInformationType", "ByPolygon"), + child("ReferenceInformationType", "Direct"), + { name: "Materials", properties: [{ type: "int32[]", value: new Int32Array(materials) }], children: [] }, + ], + }; +} + +function createLayerElement(name: string, dataName: string, indexName: string, data: number[]): FBXNode { + return { + name, + properties: [{ type: "int32", value: 0 }], + children: [ + child("MappingInformationType", "ByPolygonVertex"), + child("ReferenceInformationType", "Direct"), + { name: dataName, properties: [{ type: "float64[]", value: new Float64Array(data) }], children: [] }, + { name: indexName, properties: [{ type: "int32[]", value: new Int32Array([]) }], children: [] }, + ], + }; +} + +function child(name: string, value: FBXPropertyValue): FBXNode { + return { + name, + properties: [{ type: typeof value === "number" ? "int32" : "string", value }], + children: [], + }; +} diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts new file mode 100644 index 00000000000..6feb584e741 --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { extractPropertyTemplates, getPropertyTemplate, resolvePropertyValues } from "loaders/FBX/interpreter/propertyTemplates"; +import { type FBXDocument, type FBXNode } from "loaders/FBX/types/fbxTypes"; + +describe("FBX property templates", () => { + it("resolves object-local properties before template defaults", () => { + const template = getPropertyTemplate(extractPropertyTemplates(createSyntheticTemplateDocument()), "Material", "FbxSurfaceLambert"); + const materialNode = createSyntheticMaterialNode(); + + expect(resolvePropertyValues(materialNode, template, "DiffuseFactor")).toEqual([0.25]); + expect(resolvePropertyValues(materialNode, template, "AmbientFactor")).toEqual([1]); + expect(resolvePropertyValues(materialNode, template, "MissingProperty")).toBeUndefined(); + }); +}); + +function createSyntheticTemplateDocument(): FBXDocument { + return { + version: 7500, + nodes: [ + { + name: "Definitions", + properties: [], + children: [ + { + name: "ObjectType", + properties: [{ type: "string", value: "Material" }], + children: [ + { + name: "PropertyTemplate", + properties: [{ type: "string", value: "FbxSurfaceLambert" }], + children: [ + { + name: "Properties70", + properties: [], + children: [createPropertyNode("DiffuseFactor", "Number", "", "A", [1]), createPropertyNode("AmbientFactor", "Number", "", "A", [1])], + }, + ], + }, + ], + }, + ], + }, + ], + }; +} + +function createSyntheticMaterialNode(): FBXNode { + return { + name: "Material", + properties: [ + { type: "int64", value: 1n }, + { type: "string", value: "Material" }, + { type: "string", value: "" }, + ], + children: [ + { + name: "Properties70", + properties: [], + children: [createPropertyNode("DiffuseFactor", "Number", "", "A", [0.25])], + }, + ], + }; +} + +function createPropertyNode(name: string, propertyType: string, label: string, flags: string, values: number[] | string[]): FBXNode { + return { + name: "P", + properties: [ + { type: "string", value: name }, + { type: "string", value: propertyType }, + { type: "string", value: label }, + { type: "string", value: flags }, + ...values.map((value) => ({ + type: typeof value === "number" ? ("float64" as const) : ("string" as const), + value, + })), + ], + children: [], + }; +} diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/transform.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/transform.test.ts new file mode 100644 index 00000000000..16d9b56885d --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/transform.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { Vector3 } from "core/Maths/math.vector"; +import { computeFBXGeometricMatrix, computeFBXLocalMatrix, eulerToMatrix, eulerToMatrixXYZ } from "loaders/FBX/interpreter/transform"; + +describe("FBX transform evaluator", () => { + it("keeps XYZ rotation order equivalent to the explicit XYZ helper", () => { + const d2r = Math.PI / 180; + const ordered = eulerToMatrix(10 * d2r, 20 * d2r, 30 * d2r, 0).asArray(); + const xyz = eulerToMatrixXYZ(10 * d2r, 20 * d2r, 30 * d2r).asArray(); + + expect(ordered).toEqual(xyz); + }); + + it("computes the existing row-vector local transform chain", () => { + const matrix = computeFBXLocalMatrix({ + translation: [10, 0, 0], + rotation: [0, 0, 90], + scale: [2, 1, 1], + preRotation: [0, 0, 0], + postRotation: [0, 0, 0], + rotationPivot: [0, 0, 0], + scalingPivot: [0, 0, 0], + rotationOffset: [0, 0, 0], + scalingOffset: [0, 0, 0], + rotationOrder: 0, + }); + + const transformed = Vector3.TransformCoordinates(Vector3.Right(), matrix); + + expect(transformed.x).toBeCloseTo(10, 6); + expect(transformed.y).toBeCloseTo(2, 6); + expect(transformed.z).toBeCloseTo(0, 6); + }); + + it("keeps geometric translation after rotation and scale", () => { + const matrix = computeFBXGeometricMatrix([10, 0, 0], [0, 0, 90], [2, 1, 1]); + const transformed = Vector3.TransformCoordinates(Vector3.Right(), matrix); + + expect(transformed.x).toBeCloseTo(10, 6); + expect(transformed.y).toBeCloseTo(2, 6); + expect(transformed.z).toBeCloseTo(0, 6); + }); +}); diff --git a/packages/dev/loaders/test/unit/FBX/materials.test.ts b/packages/dev/loaders/test/unit/FBX/materials.test.ts new file mode 100644 index 00000000000..7c52003f81a --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/materials.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NullEngine } from "core/Engines"; +import { Scene } from "core/scene"; +import { StandardMaterial } from "core/Materials/standardMaterial"; +import { Texture } from "core/Materials/Textures/texture"; +import { VertexBuffer } from "core/Buffers/buffer"; +import { type SceneLoaderPluginOptions } from "core/Loading/sceneLoader"; +import { FBXFileLoader } from "loaders/FBX/fbxFileLoader"; +import { type FBXTextureRef } from "loaders/FBX/interpreter/materials"; + +interface IFBXTextureFactoryForTest { + _createTexture(tex: FBXTextureRef, scene: Scene, rootUrl: string, isDataTexture: boolean): Texture | null; +} + +function buildMaterialOnlyFbx(textureSlot: string): string { + return `; FBX 7.4.0 project file +Objects: { + Material: 1, "Material::TestMaterial", "" { + ShadingModel: "Phong" + } + Texture: 2, "Texture::TestNormal", "" { + FileName: "normal.png" + RelativeFilename: "normal.png" + } +} +Connections: { + C: "OP", 2, 1, "${textureSlot}" +}`; +} + +function buildTriangleFbx(includeAuthoredTangents = false): string { + return `; FBX 7.4.0 project file +Objects: { + Geometry: 1, "Geometry::Triangle", "Mesh" { + Vertices: *9 { + a: 0,0,0,1,0,0,0,1,0 + } + PolygonVertexIndex: *3 { + a: 0,1,-3 + } + LayerElementNormal: 0 { + MappingInformationType: "ByControlPoint" + ReferenceInformationType: "Direct" + Normals: *9 { + a: 0,0,1,0,0,1,0,0,1 + } + } + LayerElementUV: 0 { + Name: "UVSet" + MappingInformationType: "ByPolygonVertex" + ReferenceInformationType: "Direct" + UV: *6 { + a: 0,0,1,0,0,1 + } + } + ${ + includeAuthoredTangents + ? `LayerElementTangent: 0 { + MappingInformationType: "ByControlPoint" + ReferenceInformationType: "Direct" + Tangents: *12 { + a: 1,0,0,1,1,0,0,1,1,0,0,1 + } + }` + : "" + } + } + Model: 2, "Model::Triangle", "Mesh" { + } +} +Connections: { + C: "OO", 1, 2 + C: "OO", 2, 0 +}`; +} + +describe("FBX material texture loading", () => { + let engine: NullEngine; + let scene: Scene; + + beforeEach(() => { + engine = new NullEngine(); + scene = new Scene(engine); + }); + + afterEach(() => { + scene.dispose(); + engine.dispose(); + }); + + it.each(["NormalMap", "NormalMapTexture", "normalCamera", "Bump", "BumpFactor"])("treats %s textures as Y-up normal maps by default", async (textureSlot) => { + const loader = new FBXFileLoader(); + + await loader.importMeshAsync(null, scene, buildMaterialOnlyFbx(textureSlot), "/textures/"); + + const material = scene.materials.find((entry): entry is StandardMaterial => entry instanceof StandardMaterial); + expect(material).toBeDefined(); + expect(material.bumpTexture).toBeDefined(); + expect(material.bumpTexture?.gammaSpace).toBe(false); + expect(material.invertNormalMapX).toBe(false); + expect(material.invertNormalMapY).toBe(false); + }); + + it.each(["NormalMap", "NormalMapTexture", "normalCamera", "Bump", "BumpFactor"])("uses the Y-down option for %s texture convention", async (textureSlot) => { + const loader = new FBXFileLoader({ normalMapCoordinateSystem: "y-down" }); + + await loader.importMeshAsync(null, scene, buildMaterialOnlyFbx(textureSlot), "/textures/"); + + const material = scene.materials.find((entry): entry is StandardMaterial => entry instanceof StandardMaterial); + expect(material).toBeDefined(); + expect(material.bumpTexture?.gammaSpace).toBe(false); + expect(material.invertNormalMapX).toBe(false); + expect(material.invertNormalMapY).toBe(true); + }); + + it("does not use scene handedness as the normal map coordinate system", async () => { + scene.useRightHandedSystem = true; + const loader = new FBXFileLoader(); + + await loader.importMeshAsync(null, scene, buildMaterialOnlyFbx("NormalMap"), "/textures/"); + + const material = scene.materials.find((entry): entry is StandardMaterial => entry instanceof StandardMaterial); + expect(material).toBeDefined(); + expect(material.invertNormalMapX).toBe(false); + expect(material.invertNormalMapY).toBe(false); + }); + + it("preserves generated tangent handedness for Y-up normal maps", async () => { + const loader = new FBXFileLoader(); + + const result = await loader.importMeshAsync(null, scene, buildTriangleFbx(), ""); + + const tangents = result.meshes[0].getVerticesData(VertexBuffer.TangentKind); + expect(tangents?.[3]).toBe(1); + }); + + it("flips generated tangent handedness for Y-down normal maps", async () => { + const loader = new FBXFileLoader({ normalMapCoordinateSystem: "y-down" }); + + const result = await loader.importMeshAsync(null, scene, buildTriangleFbx(), ""); + + const tangents = result.meshes[0].getVerticesData(VertexBuffer.TangentKind); + expect(tangents?.[3]).toBe(-1); + }); + + it("flips authored tangent handedness for Y-down normal maps", async () => { + const loader = new FBXFileLoader({ normalMapCoordinateSystem: "y-down" }); + + const result = await loader.importMeshAsync(null, scene, buildTriangleFbx(true), ""); + + const tangents = result.meshes[0].getVerticesData(VertexBuffer.TangentKind); + expect(tangents?.[3]).toBe(-1); + }); + + it("creates configured plugins from SceneLoader options", async () => { + const loader = new FBXFileLoader(); + const plugin = loader.createPlugin({ + fbx: { + normalMapCoordinateSystem: "y-down", + }, + } as SceneLoaderPluginOptions); + + const result = await plugin.importMeshAsync(null, scene, buildTriangleFbx(), ""); + + const tangents = result.meshes[0].getVerticesData(VertexBuffer.TangentKind); + expect(tangents?.[3]).toBe(-1); + }); + + it("loads embedded normal-compatible textures without creating blob URLs", () => { + const createTexture = (FBXFileLoader as unknown as IFBXTextureFactoryForTest)._createTexture; + const originalCreateObjectURL = URL.createObjectURL; + const createObjectURLSpy = vi.fn(() => { + throw new Error("Embedded FBX textures should not use blob URLs"); + }); + Object.defineProperty(URL, "createObjectURL", { configurable: true, writable: true, value: createObjectURLSpy }); + + try { + const texture = createTexture( + { + propertyName: "Bump", + fileName: "normal.png", + relativeFileName: "textures\\normal.png", + id: 2n, + embeddedData: new Uint8Array([137, 80, 78, 71]), + }, + scene, + "/textures/", + true + ); + + expect(texture).toBeDefined(); + expect(texture?.gammaSpace).toBe(false); + expect(texture?.name).toBe("normal.png"); + expect(texture?.url).toContain("data:fbx-embedded-texture/"); + expect(scene.textures).toContain(texture); + expect(createObjectURLSpy).not.toHaveBeenCalled(); + } finally { + if (originalCreateObjectURL) { + Object.defineProperty(URL, "createObjectURL", { configurable: true, writable: true, value: originalCreateObjectURL }); + } else { + delete (URL as { createObjectURL?: unknown }).createObjectURL; + } + } + }); + + it("keeps safe relative sidecar texture paths when embedded data is absent", () => { + const createTexture = (FBXFileLoader as unknown as IFBXTextureFactoryForTest)._createTexture; + + const texture = createTexture( + { + propertyName: "DiffuseColor", + fileName: "C:/authored/location/textures/diffuse.png", + relativeFileName: "textures\\diffuse.png", + id: 2n, + embeddedData: null, + }, + scene, + "/models/", + false + ); + + expect(texture).toBeDefined(); + expect(texture?.url).toBe("/models/textures/diffuse.png"); + }); + + it("puts materials and textures into asset containers", async () => { + const loader = new FBXFileLoader(); + + const container = await loader.loadAssetContainerAsync(scene, buildMaterialOnlyFbx("DiffuseColor"), "/textures/"); + + expect(container.materials).toHaveLength(1); + expect(container.textures).toHaveLength(1); + expect(scene.materials).not.toContain(container.materials[0]); + expect(scene.textures).not.toContain(container.textures[0]); + }); +}); diff --git a/packages/dev/loaders/test/unit/FBX/parsers/zlibInflate.test.ts b/packages/dev/loaders/test/unit/FBX/parsers/zlibInflate.test.ts new file mode 100644 index 00000000000..c9b94ab349e --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/parsers/zlibInflate.test.ts @@ -0,0 +1,191 @@ +import { deflateSync } from "zlib"; +import { describe, expect, it } from "vitest"; +import { inflateZlib } from "loaders/FBX/parsers/zlibInflate"; + +describe("inflateZlib", () => { + it("inflates an empty stored payload", () => { + const output = inflateZlib(hex("78 9c 03 00 00 00 00 01"), 0); + + expect(output).toEqual(new Uint8Array()); + }); + + it("inflates a fixed-Huffman ASCII payload", () => { + const output = inflateZlib(hex("78 9c f3 48 cd c9 c9 d7 51 08 cf 2f ca 49 51 04 00 1f 9e 04 6a"), 13); + + expect(output).toEqual(ascii("Hello, World!")); + }); + + it("inflates a stored block", () => { + const output = inflateZlib(hex("78 01 01 05 00 fa ff 41 42 43 44 45 03 e8 01 50"), 5); + + expect(output).toEqual(ascii("ABCDE")); + }); + + it("inflates multiple stored blocks in one stream", () => { + const expected = ascii("abcdef"); + const compressed = zlibStoredBlocks([ascii("abc"), ascii("def")]); + + expect(inflateZlib(compressed, expected.byteLength)).toEqual(expected); + }); + + it("inflates overlapping back-references", () => { + const expected = ascii("abcabcabcabcabcabcabcabc"); + const compressed = deflateSync(expected); + + expect(inflateZlib(compressed, expected.byteLength)).toEqual(expected); + }); + + it("inflates long overlapping run-length back-references", () => { + const expected = new Uint8Array(2048); + expected.fill("A".charCodeAt(0)); + const compressed = deflateSync(expected, { level: 9 }); + + expect(inflateZlib(compressed, expected.byteLength)).toEqual(expected); + }); + + it("inflates dynamic-Huffman payloads", () => { + const expected = pseudoRandomPrintableBytes(4096); + const compressed = deflateSync(expected, { level: 6 }); + expect(firstDeflateBlockType(compressed)).toBe(2); + + expect(inflateZlib(compressed, expected.byteLength)).toEqual(expected); + }); + + it("matches Node deflate output for deterministic random payloads", () => { + const sizes = [0, 1, 2, 3, 7, 31, 32, 127, 128, 1024, 4096]; + const levels = [0, 1, 6, 9]; + let seed = 0xdecafbad; + + for (const size of sizes) { + for (const level of levels) { + const expected = pseudoRandomBytes(size, seed); + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + const compressed = deflateSync(expected, { level }); + + expect(inflateZlib(compressed, expected.byteLength)).toEqual(expected); + } + } + }); + + it("rejects truncated input", () => { + const compressed = hex("78 9c f3 48 cd c9 c9 d7 51 08 cf 2f ca 49 51 04 00 1f 9e 04 6a"); + + expect(() => inflateZlib(compressed.subarray(0, compressed.byteLength - 1), 13)).toThrow(/unexpected end|adler32 mismatch|trailing deflate data/); + }); + + it("rejects corrupted Adler-32 trailers", () => { + const compressed = hex("78 9c f3 48 cd c9 c9 d7 51 08 cf 2f ca 49 51 04 00 1f 9e 04 6a"); + compressed[compressed.byteLength - 1] ^= 0xff; + + expect(() => inflateZlib(compressed, 13)).toThrow("zlib: adler32 mismatch"); + }); + + it("rejects invalid zlib headers", () => { + expect(() => inflateZlib(hex("00 00 03 00 00 00 00 01"), 0)).toThrow("zlib: invalid header"); + }); + + it("rejects streams that require a preset dictionary", () => { + const stream = hex(`${fdictHeaderHex()} 03 00 00 00 00 01`); + + expect(() => inflateZlib(stream, 0)).toThrow("zlib: preset dictionary not supported"); + }); + + it("rejects invalid stored-block lengths", () => { + const invalidStoredBlock = hex("78 01 01 05 00 00 00 41 42 43 44 45 00 00 00 00"); + + expect(() => inflateZlib(invalidStoredBlock, 5)).toThrow("deflate: invalid stored block length"); + }); + + it("rejects output-length mismatches", () => { + const compressed = hex("78 01 01 05 00 fa ff 41 42 43 44 45 03 e8 01 50"); + + expect(() => inflateZlib(compressed, 4)).toThrow("zlib: output length mismatch"); + expect(() => inflateZlib(compressed, 6)).toThrow("zlib: output length mismatch"); + }); +}); + +function hex(value: string): Uint8Array { + const bytes = value + .trim() + .split(/\s+/) + .filter((part) => part.length > 0) + .map((part) => Number.parseInt(part, 16)); + return new Uint8Array(bytes); +} + +function ascii(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function pseudoRandomPrintableBytes(length: number): Uint8Array { + let state = 0x12345678; + const output = new Uint8Array(length); + for (let i = 0; i < length; i++) { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + output[i] = 32 + (state % 95); + } + return output; +} + +function pseudoRandomBytes(length: number, seed: number): Uint8Array { + let state = seed; + const output = new Uint8Array(length); + for (let i = 0; i < length; i++) { + state = (Math.imul(state, 1664525) + 12345) >>> 0; + output[i] = state & 0xff; + } + return output; +} + +function zlibStoredBlocks(blocks: Uint8Array[]): Uint8Array { + const payloadLength = blocks.reduce((sum, block) => sum + block.byteLength, 0); + const output = new Uint8Array(2 + blocks.reduce((sum, block) => sum + 5 + block.byteLength, 0) + 4); + output[0] = 0x78; + output[1] = 0x01; + let offset = 2; + const payload = new Uint8Array(payloadLength); + let payloadOffset = 0; + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + output[offset++] = i === blocks.length - 1 ? 0x01 : 0x00; + output[offset++] = block.byteLength & 0xff; + output[offset++] = block.byteLength >> 8; + output[offset++] = ~block.byteLength & 0xff; + output[offset++] = (~block.byteLength >> 8) & 0xff; + output.set(block, offset); + offset += block.byteLength; + payload.set(block, payloadOffset); + payloadOffset += block.byteLength; + } + + const adler = adler32(payload); + output[offset++] = (adler >>> 24) & 0xff; + output[offset++] = (adler >>> 16) & 0xff; + output[offset++] = (adler >>> 8) & 0xff; + output[offset] = adler & 0xff; + return output; +} + +function adler32(bytes: Uint8Array): number { + let a = 1; + let b = 0; + for (const byte of bytes) { + a = (a + byte) % 65521; + b = (b + a) % 65521; + } + return ((b << 16) | a) >>> 0; +} + +function firstDeflateBlockType(zlibStream: Uint8Array): number { + return (zlibStream[2] >> 1) & 0x03; +} + +function fdictHeaderHex(): string { + const cmf = 0x78; + for (let flg = 0x20; flg < 0x100; flg++) { + if ((flg & 0x20) !== 0 && ((cmf << 8) + flg) % 31 === 0) { + return `${cmf.toString(16).padStart(2, "0")} ${flg.toString(16).padStart(2, "0")}`; + } + } + throw new Error("test setup failed to build FDICT header"); +} diff --git a/specs/fbx-loader/architecture.md b/specs/fbx-loader/architecture.md new file mode 100644 index 00000000000..a851e832dee --- /dev/null +++ b/specs/fbx-loader/architecture.md @@ -0,0 +1,139 @@ +# FBX Loader Architecture + +## Overview + +The Babylon.js FBX loader is a TypeScript loader plugin under `packages\dev\loaders\src\FBX`. It parses FBX data, interprets the scene graph, and builds Babylon meshes, materials, textures, skeletons, animation groups, cameras, and lights. + +This document records the intended architecture for normal-map handling and related future work. It is the durable spec location for decisions that should survive after root-level migration reports are deleted. + +## Loader option flow + +The FBX loader should follow the same option flow used by other Babylon.js loaders: + +1. Declare FBX-specific options by extending `SceneLoaderPluginOptions`. +2. Accept those options in the `FBXFileLoader` constructor. +3. Store normalized options on the loader instance. +4. Pass dynamic-loader plugin options from `packages\dev\loaders\src\dynamic.ts` into `new FBXFileLoader(...)`. +5. Keep side-effect registration as `new FBXFileLoader()` so default behavior remains available. + +The normal-map option is: + +```ts +export type FBXNormalMapCoordinateSystem = "y-up" | "y-down"; + +export interface FBXFileLoaderOptions { + normalMapCoordinateSystem?: FBXNormalMapCoordinateSystem; +} +``` + +The normalized default is: + +```ts +{ + normalMapCoordinateSystem: "y-up"; +} +``` + +## Normal-map slot handling + +FBX does not standardize tangent-space normal-map green/Y convention. The loader therefore treats the convention as source texture metadata, not as a Babylon scene-handedness decision. + +The following FBX texture slots are explicit tangent-space normal-map slots: + +- `NormalMap` +- `NormalMapTexture` +- `normalCamera` + +When one of these slots is connected: + +- assign the texture to `material.bumpTexture`; +- create or configure the texture with `gammaSpace = false`; +- set `material.invertNormalMapX = false`; +- set `material.invertNormalMapY = normalMapCoordinateSystem === "y-down"`; +- ensure tangent handedness matches the same coordinate-system decision. + +## Tangent handedness + +Babylon's shader path differs depending on whether explicit tangents are present. + +When tangents and normals are present, the shader builds the TBN basis from tangent data and uses `tangent.w` to construct the bitangent: + +```glsl +vec3 tbnBitangent = cross(tbnNormal, tbnTangent) * tangentUpdated.w; +vTBN = mat3(finalWorld) * mat3(tbnTangent, tbnBitangent, tbnNormal); +``` + +In that path, material inversion flags do not provide the full normal-map Y conversion. The FBX loader also generates tangents when geometry has normals and UVs but lacks authored tangents, so this explicit tangent path is common for normal-mapped FBX assets. + +The loader should use a single normal-map handedness scale: + +```ts +private _getNormalMapTangentHandednessScale(): 1 | -1 { + return this._options.normalMapCoordinateSystem === "y-down" ? -1 : 1; +} +``` + +Apply that scale to: + +- authored/source tangent `w` values after tangent vector transform handling; +- generated tangent `w` values after the generated handedness value is computed. + +This scale must not be derived from `scene.useRightHandedSystem`. + +## `Bump` and `BumpFactor` + +`Bump` and `BumpFactor` are ambiguous in FBX content: + +- semantically, they can mean traditional grayscale bump/height data; +- in real exporter output, they may also contain tangent-space RGB normal maps. + +Babylon `StandardMaterial.bumpTexture` is not a traditional grayscale height-map slot. It is sampled by the shader as normal-vector data. Because of that, the most compatibility-safe behavior for the initial integration is to continue treating `Bump` and `BumpFactor` as normal-map-like inputs when assigning them to `StandardMaterial.bumpTexture`. + +Do not add a loader option that claims to treat `Bump`/`BumpFactor` as true height maps unless the implementation also converts grayscale height/bump data to tangent-space normal data before assigning to `StandardMaterial.bumpTexture`, or routes the source texture to another height-aware path. + +Future conversion work should preserve the original bump texture in metadata so tools or downstream material conversion can inspect the source data. + +## Texture creation + +The loader creates textures from external files and embedded FBX texture bytes through Babylon `Texture` creation options. It uses `ITextureCreationOptions` to centralize: + +- embedded texture buffers; +- MIME type; +- forced extension; +- image loader options; +- sRGB policy; +- image upload orientation. + +Embedded FBX texture bytes should be loaded through a delayed Babylon texture: create the texture with MIME type and forced extension metadata, then call `updateURL(dataUrl, buffer, ...)` with a synthetic non-base64 `data:` URL. This matches Babylon's standard-image buffer path, ensuring formats such as PNG and JPEG consume the embedded buffer instead of attempting an external request. Embedded bytes should not be converted to `Blob` object URLs, because that duplicates lifetime management already handled by Babylon texture loading and creates a revocation hazard. + +External sidecar textures are used only when embedded bytes are absent. For safe relative FBX paths such as `textures/diffuse.png`, the loader resolves the path relative to the load `rootUrl`. For absolute authored machine paths or paths containing `..`, the loader falls back to the source basename under `rootUrl`. If the first external URL fails, the loader may retry common image-extension fallbacks while preserving the forced extension for the fallback URL. + +This is texture-loading infrastructure. It does not replace: + +- `gammaSpace = false` for normal-map data textures; +- `normalMapCoordinateSystem`; +- tangent `w` adjustment for explicit/generated tangent paths. + +`Texture` upload `invertY` is image row/UV orientation. It is not the same as tangent-space normal-map green/Y convention. + +## Asset container ownership + +`loadAssetContainerAsync` returns a container that owns all created FBX assets, including materials, multimaterials, active textures, cameras, lights, skeletons, animation groups, meshes, and transform nodes. Mesh-assigned materials, sub-materials of `MultiMaterial`, and material active textures are included before calling `container.removeAllFromScene()` so container add/remove lifecycle APIs behave consistently. + +Container-owned assets should have their `_parentContainer` reference assigned to the returned container before they are removed from the scene. This mirrors the ownership expectations used by Babylon asset containers and keeps later container lifecycle calls consistent. + +## Known limitations and future work + +### Traditional grayscale bump maps + +Traditional grayscale bump/height maps from FBX `Bump` or `BumpFactor` are not physically handled as height maps by the initial loader integration. They are treated as normal-map-like inputs for compatibility with existing Babylon behavior and exporter quirks. + +Future work may add true grayscale bump support by converting height/bump textures into tangent-space normal textures before assignment to `StandardMaterial.bumpTexture`. + +### Automatic bump classification + +Automatic classification of `Bump`/`BumpFactor` textures is deferred. If added later, classification should determine whether the source texture is a normal map or height map. It should not silently choose Y-up or Y-down in conflict with the explicit `normalMapCoordinateSystem` option. + +### Visual validation + +Normal-map convention changes should be covered by visualization tests because green/Y inversion is primarily a rendered-output bug. Tests should include an embedded normal-map FBX fixture and should verify that left-handed and right-handed scenes do not change the source normal-map convention. diff --git a/specs/fbx-loader/goals.md b/specs/fbx-loader/goals.md new file mode 100644 index 00000000000..b2c9395ffad --- /dev/null +++ b/specs/fbx-loader/goals.md @@ -0,0 +1,25 @@ +# FBX Loader Goals + +## Status + +This specification captures the current Babylon.js FBX loader integration goals. Root-level migration reports are temporary agent handoff notes and should not be treated as durable feature documentation. + +## Goals + +- Provide a built-in, SDK-free FBX loader for Babylon.js. +- Preserve Babylon.js loader conventions, including package exports, side-effect registration, dynamic registration, and `SceneLoader` plugin options. +- Load common FBX assets with meshes, transforms, materials, textures, skinning, animation, cameras, and lights when supported by the current implementation. +- Handle tangent-space normal maps independently from Babylon scene handedness. +- Default FBX tangent-space normal maps to Y-up normal-map convention, with an explicit Y-down option for assets authored with inverted green/Y normal channels. +- Preserve compatibility for FBX `Bump` and `BumpFactor` texture slots by treating them as normal-map-like inputs when assigning to Babylon `StandardMaterial.bumpTexture`. +- Use Babylon texture-loading primitives for FBX textures, including embedded texture buffers, MIME type detection, and forced extensions. +- Return asset containers that own the FBX-created scene nodes, materials, and textures they need for consistent add/remove lifecycle behavior. +- Document known limitations clearly so future work can build on the current loader without changing behavior accidentally. + +## Non-goals + +- Depend on the Autodesk FBX SDK. +- Fully implement every FBX feature or every exporter-specific convention in the initial integration. +- Treat grayscale height/bump textures as physically correct height maps without a conversion path. +- Use graphics API names such as `opengl` or `directx` as the primary public loader option names for normal-map convention. +- Make scene handedness select normal-map green/Y channel convention. diff --git a/specs/fbx-loader/requirements.md b/specs/fbx-loader/requirements.md new file mode 100644 index 00000000000..8f7c64d9e02 --- /dev/null +++ b/specs/fbx-loader/requirements.md @@ -0,0 +1,89 @@ +# FBX Loader Requirements + +## Loader registration and options + +- The FBX loader must be available through the Babylon.js loader package exports and side-effect registration. +- Dynamic loader registration must pass FBX-specific `SceneLoader` plugin options to the `FBXFileLoader` constructor. +- The loader must expose: + +```ts +export type FBXNormalMapCoordinateSystem = "y-up" | "y-down"; + +export interface FBXFileLoaderOptions { + /** + * Source convention for tangent-space normal maps connected through FBX normal-map slots. + * FBX does not standardize this convention. The default is "y-up". + */ + normalMapCoordinateSystem?: FBXNormalMapCoordinateSystem; +} +``` + +- The default `normalMapCoordinateSystem` must be `"y-up"`. +- `"y-down"` must be opt-in through direct loader construction or `SceneLoader` plugin options. + +## Tangent-space normal maps + +- Texture slots that explicitly represent tangent-space normal maps must be treated as normal maps: + - `NormalMap` + - `NormalMapTexture` + - `normalCamera` +- Normal-map textures must be treated as data textures by setting `texture.gammaSpace = false`. +- `material.invertNormalMapX` must not be driven by `scene.useRightHandedSystem`. +- `material.invertNormalMapY` must not be driven by `scene.useRightHandedSystem`. +- For Y-up normal maps, `material.invertNormalMapY` must be `false`. +- For Y-down normal maps, `material.invertNormalMapY` must be `true`. +- The normal-map coordinate-system option must also affect tangent handedness because Babylon's explicit/generated tangent shader path uses `tangent.w` to build the bitangent. +- Authored tangents and generated tangents must preserve their existing handedness for `"y-up"` and multiply `tangent.w` by `-1` for `"y-down"`. + +## `Bump` and `BumpFactor` compatibility + +- FBX `Bump` and `BumpFactor` slots are ambiguous in real assets. +- Some exporters use them for traditional grayscale bump/height maps. +- Some exporters use them for tangent-space normal maps. +- For compatibility, the initial Babylon.js integration must treat `Bump` and `BumpFactor` textures as normal-map-like inputs when assigning them to `StandardMaterial.bumpTexture`. +- The loader must not introduce a semantic height/bump option until it also implements a correct conversion path from grayscale height/bump data to tangent-space normal data, or another Babylon material path that correctly consumes grayscale height data. +- A future height/bump mode must not simply assign a grayscale height texture to `StandardMaterial.bumpTexture` and call it correct, because `StandardMaterial.bumpTexture` is sampled as normal-vector data in the shader. + +## Scene handedness + +- Scene handedness may still affect mesh transforms, side orientation, and other geometric conversion behavior. +- Scene handedness must not select the source normal-map green/Y convention. +- The same FBX normal-map convention should apply consistently in left-handed and right-handed Babylon scenes. + +## Texture creation + +- External and embedded FBX textures must be created through Babylon `Texture` creation options rather than loader-local image-loading paths. +- Embedded texture bytes must be loaded through Babylon's delayed texture `updateURL(dataUrl, buffer, ...)` path so standard image formats such as PNG and JPEG consume the buffer rather than issuing an external request. +- Embedded textures must include MIME type metadata when a MIME type can be inferred from the source filename. +- Texture creation must provide `forcedExtension` when a source filename or MIME type identifies the image type, so Babylon selects the expected texture loader even when the URL is synthetic or ambiguous. +- Embedded textures must not be converted to `Blob` object URLs. +- External sidecar textures must only be used when embedded bytes are absent. +- For sidecars, safe relative paths such as `textures/diffuse.png` should resolve relative to the loader `rootUrl`; absolute authored machine paths or paths containing `..` should fall back to the source basename under `rootUrl`. +- Normal-map-compatible slots must create or configure textures as data textures with `gammaSpace = false`. +- Texture upload `invertY` must not be used as a substitute for tangent-space normal-map green/Y convention handling. + +## Asset container ownership + +- `loadAssetContainerAsync` must populate the returned container with FBX-created scene nodes, materials, multimaterials, textures, skeletons, animation groups, cameras, and lights as applicable. +- Materials assigned to meshes, sub-materials of `MultiMaterial`, and active textures referenced by FBX-created materials must be included in the container before `container.removeAllFromScene()` is called. +- Container-owned assets must have their parent-container relationship set so later container add/remove/dispose lifecycle operations remain consistent. + +## Future work + +- Add a semantic grayscale height/bump mode only when the loader can convert height/bump textures to tangent-space normal textures before assigning to `StandardMaterial.bumpTexture`, or when a suitable height-aware material path is available. +- Preserve the original source bump texture in metadata if future conversion needs access to the unconverted texture. +- Consider auto-detection only after real fixtures are available. Any auto mode should classify whether a texture is a normal map or height map; it must not silently override the explicit `normalMapCoordinateSystem` option. + +## Validation requirements + +- Unit coverage should verify: + - default Y-up normal texture setup; + - opt-in Y-down material setup; + - generated tangent `w` scaling for Y-down; + - authored tangent `w` scaling for Y-down; + - scene handedness does not determine normal-map convention; + - `Bump` and `BumpFactor` preserve compatibility as normal-map-like `bumpTexture` inputs; + - embedded textures load through buffer-backed `Texture` creation rather than blob URLs; + - asset containers include FBX-created materials and textures. +- Rendering coverage should include an embedded FBX normal-map fixture that catches concave/convex inversion caused by the wrong green/Y convention. +- Visualization tests must include `dependsOn` tags when added to the Babylon.js visualization config. From ae031ba7439273a4e4852579c7beca35835de588 Mon Sep 17 00:00:00 2001 From: David Catuhe Date: Wed, 20 May 2026 08:45:43 -0700 Subject: [PATCH 2/3] Address FBX loader review feedback Remove bigint usage from the FBX loader and harden parser validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/dev/loaders/src/FBX/fbxFileLoader.ts | 74 ++++----- packages/dev/loaders/src/FBX/index.ts | 10 -- .../loaders/src/FBX/interpreter/animation.ts | 36 ++-- .../src/FBX/interpreter/blendShapes.ts | 14 +- .../src/FBX/interpreter/connections.ts | 69 ++++---- .../src/FBX/interpreter/fbxInterpreter.ts | 27 +-- .../loaders/src/FBX/interpreter/geometry.ts | 4 +- .../loaders/src/FBX/interpreter/materials.ts | 11 +- .../src/FBX/interpreter/propertyTemplates.ts | 3 - .../dev/loaders/src/FBX/interpreter/rig.ts | 64 ++++---- .../src/FBX/interpreter/sceneDiagnostics.ts | 4 +- .../loaders/src/FBX/interpreter/skeleton.ts | 47 +++--- .../loaders/src/FBX/parsers/fbxAsciiParser.ts | 26 ++- .../src/FBX/parsers/fbxBinaryParser.ts | 89 ++++++++-- .../dev/loaders/src/FBX/types/fbxTypes.ts | 6 +- .../unit/FBX/interpreter/animation.test.ts | 155 ++++++++++++++++++ .../unit/FBX/interpreter/blendShapes.test.ts | 28 ++-- .../unit/FBX/interpreter/connections.test.ts | 18 +- .../unit/FBX/interpreter/geometry.test.ts | 8 +- .../FBX/interpreter/propertyTemplates.test.ts | 2 +- .../unit/FBX/interpreter/skeleton.test.ts | 109 ++++++++++++ .../loaders/test/unit/FBX/materials.test.ts | 4 +- .../unit/FBX/parsers/fbxAsciiParser.test.ts | 40 +++++ .../unit/FBX/parsers/fbxBinaryParser.test.ts | 73 +++++++++ 24 files changed, 654 insertions(+), 267 deletions(-) create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/animation.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/interpreter/skeleton.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/parsers/fbxAsciiParser.test.ts create mode 100644 packages/dev/loaders/test/unit/FBX/parsers/fbxBinaryParser.test.ts diff --git a/packages/dev/loaders/src/FBX/fbxFileLoader.ts b/packages/dev/loaders/src/FBX/fbxFileLoader.ts index 559d0123853..1856022593a 100644 --- a/packages/dev/loaders/src/FBX/fbxFileLoader.ts +++ b/packages/dev/loaders/src/FBX/fbxFileLoader.ts @@ -228,7 +228,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi const nameFilter = this._buildNameFilter(meshesNames); // Create materials - const materialCache = new Map(); + const materialCache = new Map(); for (const matData of fbxScene.materials) { const material = this._createMaterial(matData, scene, rootUrl); materialCache.set(matData.id, material); @@ -237,10 +237,10 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi // Create one Babylon skeleton per resolved deformation rig. const skeletons: Skeleton[] = []; const skeletonByRigId = new Map(); - const skeletonByGeometryId = new Map(); - const skinByGeometryId = new Map(); - const skinBindingByGeometryId = new Map(); - const skinById = new Map(); + const skeletonByGeometryId = new Map(); + const skinByGeometryId = new Map(); + const skinBindingByGeometryId = new Map(); + const skinById = new Map(); for (const skin of fbxScene.skins) { skinById.set(skin.id, skin); @@ -264,7 +264,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi } // Collect model data for animation sampling. - const modelIdToData = new Map(); + const modelIdToData = new Map(); const collectModelData = (models: FBXModelData[]) => { for (const m of models) { modelIdToData.set(m.id, m); @@ -295,7 +295,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi FBXFileLoader._applyMatrixToTransform(assetRoot, axisConversion); transformNodes.push(assetRoot); } - const modelIdToNode = new Map(); + const modelIdToNode = new Map(); const fbxWorldIdentity = Matrix.Identity(); for (const model of fbxScene.rootModels) { @@ -522,15 +522,15 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi parent: Nullable, assetRoot: TransformNode, parentFBXWorldMatrix: Matrix, - materialCache: Map, + materialCache: Map, nameFilter: ((name: string) => boolean) | null, meshes: Mesh[], transformNodes: TransformNode[], - skeletonByGeometryId: Map, - skinByGeometryId: Map, - skinBindingByGeometryId: Map, - modelIdToNode: Map, - cullingConflictMaterialIds: Set, + skeletonByGeometryId: Map, + skinByGeometryId: Map, + skinBindingByGeometryId: Map, + modelIdToNode: Map, + cullingConflictMaterialIds: Set, cullingMaterialCloneCache: Map ): void { const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model); @@ -815,9 +815,9 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi private _applyMultiMaterial( mesh: Mesh, model: FBXModelData, - materialCache: Map, + materialCache: Map, scene: Scene, - cullingConflictMaterialIds: Set, + cullingConflictMaterialIds: Set, cullingMaterialCloneCache: Map ): void { const matIndices = model.geometry!.materialIndices!; @@ -886,10 +886,10 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi } } - private static _collectCullingConflictMaterialIds(models: FBXModelData[]): Set { + private static _collectCullingConflictMaterialIds(models: FBXModelData[]): Set { // Deliberately scan the full scene, not just name-filtered models. This // can over-clone for filtered imports, but avoids shared culling state. - const usage = new Map(); + const usage = new Map(); const collect = (model: FBXModelData): void => { for (const material of model.materials) { const state = usage.get(material.id) ?? { cullingOff: false, cullingOn: false }; @@ -908,7 +908,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi collect(model); } - const conflicts = new Set(); + const conflicts = new Set(); for (const [materialId, state] of Array.from(usage)) { if (state.cullingOff && state.cullingOn) { conflicts.add(materialId); @@ -1445,7 +1445,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi for (const bs of blendShapes) { // Find the mesh that uses this geometry const mesh = meshes.find((m) => { - const geomId = (m.metadata as { fbxGeometryId?: bigint } | undefined)?.fbxGeometryId; + const geomId = (m.metadata as { fbxGeometryId?: number } | undefined)?.fbxGeometryId; return geomId === bs.geometryId; }); if (!mesh) { @@ -1508,13 +1508,13 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi mesh.metadata = {}; } if (!(mesh.metadata as Record).fbxBlendShapeChannelIds) { - (mesh.metadata as Record).fbxBlendShapeChannelIds = new Map(); + (mesh.metadata as Record).fbxBlendShapeChannelIds = new Map(); } - ((mesh.metadata as Record).fbxBlendShapeChannelIds as Map).set(channel.id, targetIndices[0]); + ((mesh.metadata as Record).fbxBlendShapeChannelIds as Map).set(channel.id, targetIndices[0]); if (!(mesh.metadata as Record).fbxBlendShapeChannelTargets) { - (mesh.metadata as Record).fbxBlendShapeChannelTargets = new Map(); + (mesh.metadata as Record).fbxBlendShapeChannelTargets = new Map(); } - ((mesh.metadata as Record).fbxBlendShapeChannelTargets as Map).set(channel.id, { + ((mesh.metadata as Record).fbxBlendShapeChannelTargets as Map).set(channel.id, { targetIndices, fullWeights: channel.fullWeights, }); @@ -1527,7 +1527,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi } } - private _createCamera(camData: FBXCameraData, modelIdToNode: Map, scene: Scene): FreeCamera | null { + private _createCamera(camData: FBXCameraData, modelIdToNode: Map, scene: Scene): FreeCamera | null { const parentNode = modelIdToNode.get(camData.modelId); const position = parentNode ? parentNode.position.clone() : Vector3.Zero(); @@ -1567,7 +1567,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi return camera; } - private _createLight(lightData: FBXLightData, modelIdToNode: Map, scene: Scene): PointLight | DirectionalLight | SpotLight | null { + private _createLight(lightData: FBXLightData, modelIdToNode: Map, scene: Scene): PointLight | DirectionalLight | SpotLight | null { const parentNode = modelIdToNode.get(lightData.modelId); const position = parentNode ? parentNode.position.clone() : Vector3.Zero(); const color = new Color3(lightData.color[0], lightData.color[1], lightData.color[2]); @@ -1909,8 +1909,8 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi rigs: FBXRigData[], skeletonByRigId: Map, scene: Scene, - modelIdToNode: Map, - modelIdToData: Map, + modelIdToNode: Map, + modelIdToData: Map, meshes: Mesh[] ): AnimationGroup | null { if (animStack.curveNodes.length === 0) { @@ -1922,7 +1922,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi // Build a map from model ID to resolved rig bones. A single FBX model ID // should only appear once per resolved rig, but keeping an array preserves // the previous animation fan-out behavior for any future duplicate rigs. - const modelIdToBones = new Map(); + const modelIdToBones = new Map(); for (const rig of rigs) { const skeleton = skeletonByRigId.get(rig.id); if (!skeleton) { @@ -1945,8 +1945,8 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi } // Group curve nodes by target - const boneCurves = new Map(); - const nonBoneCurves = new Map(); + const boneCurves = new Map(); + const nonBoneCurves = new Map(); const blendShapeCurves: FBXCurveNodeData[] = []; for (const curveNode of animStack.curveNodes) { @@ -1971,7 +1971,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi // Process bone targets: compute full FBX local matrix per frame, decompose to TRS. // For bind-rest rigs, only the bones recorded in _bindRestBones need their // authored Lcl curves remapped onto the bind-rest local space. - const inheritedRigModelIds = new Set(); + const inheritedRigModelIds = new Set(); for (const rig of rigs) { const inheritType2ModelIds = new Set(rig.bones.filter((bone) => bone.inheritType === 2).map((bone) => bone.modelId)); if (inheritType2ModelIds.size === 0) { @@ -2060,7 +2060,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi continue; } const metadata = mesh.metadata as Record | undefined; - const channelTargets = metadata?.fbxBlendShapeChannelTargets as Map | undefined; + const channelTargets = metadata?.fbxBlendShapeChannelTargets as Map | undefined; const targetInfo = channelTargets?.get(targetChannelId); if (targetInfo && curveNode.curves.length > 0) { const fps = 30; @@ -2084,7 +2084,7 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi continue; } - const channelMap = metadata?.fbxBlendShapeChannelIds as Map | undefined; + const channelMap = metadata?.fbxBlendShapeChannelIds as Map | undefined; if (!channelMap) { continue; } @@ -2118,14 +2118,14 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi private _buildInheritedRigBoneAnimations( rig: FBXRigData, skeleton: Skeleton, - boneCurves: Map, - modelIdToData: Map, - compensatedModelIds: Set, + boneCurves: Map, + modelIdToData: Map, + compensatedModelIds: Set, startTime: number, stopTime: number ): { bone: Bone; animations: Animation[] }[] { const fps = 30; - const sampledModelIds = new Set(); + const sampledModelIds = new Set(); for (let i = 0; i < rig.bones.length; i++) { if (!compensatedModelIds.has(rig.bones[i].modelId)) { continue; diff --git a/packages/dev/loaders/src/FBX/index.ts b/packages/dev/loaders/src/FBX/index.ts index 690df80f2c1..0601f1aafa6 100644 --- a/packages/dev/loaders/src/FBX/index.ts +++ b/packages/dev/loaders/src/FBX/index.ts @@ -1,13 +1,3 @@ export { FBXFileLoader } from "./fbxFileLoader"; export type { FBXFileLoaderOptions, FBXNormalMapCoordinateSystem } from "./fbxFileLoader"; export { FBXFileLoaderMetadata } from "./fbxFileLoader.metadata"; -export { parseBinaryFBX } from "./parsers/fbxBinaryParser"; -export { parseAsciiFBX } from "./parsers/fbxAsciiParser"; -export { interpretFBX } from "./interpreter/fbxInterpreter"; -export type { FBXDocument, FBXNode, FBXProperty } from "./types/fbxTypes"; -export type { FBXSceneData, FBXModelData } from "./interpreter/fbxInterpreter"; -export type { FBXGeometryData } from "./interpreter/geometry"; -export type { FBXMaterialData, FBXTextureRef, FBXMaterialProperties } from "./interpreter/materials"; -export type { FBXSkinData, FBXBoneData } from "./interpreter/skeleton"; -export type { FBXRigData, FBXRigBoneData, FBXSkinBindingData } from "./interpreter/rig"; -export type { FBXAnimationStackData, FBXCurveNodeData, FBXCurveData, FBXKeyframe } from "./interpreter/animation"; diff --git a/packages/dev/loaders/src/FBX/interpreter/animation.ts b/packages/dev/loaders/src/FBX/interpreter/animation.ts index 3bb394bdd2e..09f051d2194 100644 --- a/packages/dev/loaders/src/FBX/interpreter/animation.ts +++ b/packages/dev/loaders/src/FBX/interpreter/animation.ts @@ -47,7 +47,7 @@ export interface FBXCurveNodeData { /** Property type: "T" (translation), "R" (rotation), "S" (scale) */ type: string; /** Target model (bone) ID */ - targetModelId: bigint; + targetModelId: number; /** Curves for each axis */ curves: FBXCurveData[]; } @@ -56,9 +56,9 @@ export interface FBXUnsupportedCurveNodeData { /** Raw AnimationCurveNode property type/name */ type: string; /** CurveNode object ID */ - id: bigint; + id: number; /** Target object ID if the curve node is connected to an object/property */ - targetId: bigint | null; + targetId: number | null; /** OP connection property name on the target, e.g. Visibility */ propertyName?: string; /** Number of connected animation curves that were ignored */ @@ -73,9 +73,9 @@ export interface FBXAnimationDiagnostic { type: "multiple-animation-layers" | "unsupported-layer-blend-mode" | "partial-layer-weight" | "unsupported-curve-node"; message: string; layerName?: string; - curveNodeId?: bigint; + curveNodeId?: number; curveNodeType?: string; - targetId?: bigint | null; + targetId?: number | null; propertyName?: string; } @@ -135,7 +135,7 @@ export function extractAnimations(objectMap: FBXObjectMap): FBXAnimationStackDat return stacks; } -function extractAnimStack(stackId: bigint, stackNode: FBXNode, objectMap: FBXObjectMap): FBXAnimationStackData | null { +function extractAnimStack(stackId: number, stackNode: FBXNode, objectMap: FBXObjectMap): FBXAnimationStackData | null { const name = cleanFBXName(getPropertyValue(stackNode, 1) ?? "Animation"); const declaredTimeSpan = extractAnimationStackTimeSpan(stackNode); @@ -175,8 +175,6 @@ function extractAnimStack(stackId: bigint, stackNode: FBXNode, objectMap: FBXObj const v = p.properties[4]?.value; if (typeof v === "number") { blendMode = v; - } else if (typeof v === "bigint") { - blendMode = Number(v); } } } @@ -338,7 +336,7 @@ function extractAnimationStackTimeSpan(stackNode: FBXNode): { start: number; sto return stop !== null ? { start, stop } : null; } -function extractCurveNode(curveNodeId: bigint, curveNodeNode: FBXNode, objectMap: FBXObjectMap): FBXCurveNodeData | null { +function extractCurveNode(curveNodeId: number, curveNodeNode: FBXNode, objectMap: FBXObjectMap): FBXCurveNodeData | null { const typeName = cleanFBXName(getPropertyValue(curveNodeNode, 1) ?? ""); // Handle T (translation), R (rotation), S (scale) targeting Models @@ -382,7 +380,7 @@ function extractCurveNode(curveNodeId: bigint, curveNodeNode: FBXNode, objectMap return null; } -function extractUnsupportedCurveNode(curveNodeId: bigint, curveNodeNode: FBXNode, objectMap: FBXObjectMap): FBXUnsupportedCurveNodeData | null { +function extractUnsupportedCurveNode(curveNodeId: number, curveNodeNode: FBXNode, objectMap: FBXObjectMap): FBXUnsupportedCurveNodeData | null { const typeName = cleanFBXName(getPropertyValue(curveNodeNode, 1) ?? ""); const curves = extractCurves(curveNodeId, objectMap); const defaultValues = extractCurveNodeDefaultValues(curveNodeNode); @@ -390,7 +388,7 @@ function extractUnsupportedCurveNode(curveNodeId: bigint, curveNodeNode: FBXNode return null; } - let targetId: bigint | null = null; + let targetId: number | null = null; let propertyName: string | undefined; for (const conn of objectMap.connections) { if (conn.childId === curveNodeId && conn.type === "OP") { @@ -423,7 +421,7 @@ function scanCurveTimes(curves: FBXCurveData[], visit: (time: number) => void): * Find the Model that an AnimationCurveNode targets. * The CurveNode connects to the Model via OP connection with a property name. */ -function findCurveNodeTarget(curveNodeId: bigint, objectMap: FBXObjectMap): bigint | null { +function findCurveNodeTarget(curveNodeId: number, objectMap: FBXObjectMap): number | null { // Look for connections where this curveNode is a child (going up to parent) // The OP connection from curveNode → Model has the property name (e.g. "Lcl Translation") for (const conn of objectMap.connections) { @@ -440,7 +438,7 @@ function findCurveNodeTarget(curveNodeId: bigint, objectMap: FBXObjectMap): bigi /** * Find the BlendShapeChannel that a DeformPercent AnimationCurveNode targets. */ -function findCurveNodeBlendShapeTarget(curveNodeId: bigint, objectMap: FBXObjectMap): bigint | null { +function findCurveNodeBlendShapeTarget(curveNodeId: number, objectMap: FBXObjectMap): number | null { for (const conn of objectMap.connections) { if (conn.childId === curveNodeId && conn.type === "OP") { const parentNode = objectMap.objects.get(conn.parentId); @@ -471,7 +469,7 @@ function findCurveNodeBlendShapeTarget(curveNodeId: bigint, objectMap: FBXObject * Extract AnimationCurves connected to a CurveNode. * Each curve connects via OP with channel "d|X", "d|Y", or "d|Z". */ -function extractCurves(curveNodeId: bigint, objectMap: FBXObjectMap): FBXCurveData[] { +function extractCurves(curveNodeId: number, objectMap: FBXObjectMap): FBXCurveData[] { const curves: FBXCurveData[] = []; // Find AnimationCurve children of this CurveNode @@ -718,8 +716,8 @@ export function sampleFBXCurveAtTime(curveData: FBXCurveData | undefined, time: // ── Utilities ────────────────────────────────────────────────────────────────── -function toInt64Array(value: unknown): BigInt64Array | null { - if (value instanceof BigInt64Array) { +function toInt64Array(value: unknown): Float64Array | null { + if (value instanceof Float64Array) { return value; } return null; @@ -733,9 +731,6 @@ function toInt32Array(value: unknown): Int32Array | null { } function fbxTimeToSeconds(value: unknown): number | null { - if (typeof value === "bigint") { - return Number(value) / FBX_TIME_UNIT; - } if (typeof value === "number") { return value / FBX_TIME_UNIT; } @@ -746,9 +741,6 @@ function toNumber(value: unknown): number | null { if (typeof value === "number") { return value; } - if (typeof value === "bigint") { - return Number(value); - } return null; } diff --git a/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts b/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts index a0ae97dce9b..970a30601ff 100644 --- a/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts +++ b/packages/dev/loaders/src/FBX/interpreter/blendShapes.ts @@ -16,7 +16,7 @@ export interface FBXShapeData { export interface FBXBlendShapeDiagnostic { type: "full-weights-mismatch" | "missing-full-weights"; message: string; - channelId: bigint; + channelId: number; channelName: string; } @@ -25,7 +25,7 @@ export interface FBXBlendShapeChannelData { /** Channel name */ name: string; /** Channel node ID */ - id: bigint; + id: number; /** Default weight (0-100 in FBX) */ deformPercent: number; /** Shape geometry (typically one per channel, but FBX supports in-between shapes) */ @@ -39,9 +39,9 @@ export interface FBXBlendShapeChannelData { /** A blend shape deformer attached to a geometry */ export interface FBXBlendShapeData { /** Deformer ID */ - id: bigint; + id: number; /** Geometry ID this blend shape is attached to */ - geometryId: bigint; + geometryId: number; /** Channels (each is an animatable morph target) */ channels: FBXBlendShapeChannelData[]; } @@ -64,7 +64,7 @@ export function extractBlendShapes(objectMap: FBXObjectMap): FBXBlendShapeData[] return blendShapes; } -function extractBlendShape(deformerId: bigint, _deformerNode: FBXNode, objectMap: FBXObjectMap): FBXBlendShapeData | null { +function extractBlendShape(deformerId: number, _deformerNode: FBXNode, objectMap: FBXObjectMap): FBXBlendShapeData | null { // Find the geometry this blend shape is attached to const parent = objectMap.parentOf.get(deformerId); if (!parent) { @@ -103,8 +103,6 @@ function extractBlendShape(deformerId: bigint, _deformerNode: FBXNode, objectMap const val = p.properties[4]?.value; if (typeof val === "number") { deformPercent = val; - } else if (typeof val === "bigint") { - deformPercent = Number(val); } } } @@ -169,7 +167,7 @@ function extractFullWeights(channelNode: FBXNode): number[] | null { function normalizeFullWeights( fullWeights: number[] | null, shapes: FBXShapeData[], - channelId: bigint, + channelId: number, channelName: string, diagnostics: FBXBlendShapeDiagnostic[] ): number[] | null { diff --git a/packages/dev/loaders/src/FBX/interpreter/connections.ts b/packages/dev/loaders/src/FBX/interpreter/connections.ts index 13c0947bbc8..2254d3561a6 100644 --- a/packages/dev/loaders/src/FBX/interpreter/connections.ts +++ b/packages/dev/loaders/src/FBX/interpreter/connections.ts @@ -6,14 +6,14 @@ export type ConnectionType = "OO" | "OP"; export interface FBXConnection { type: ConnectionType; - childId: bigint; - parentId: bigint; + childId: number; + parentId: number; /** For OP connections, the property name on the parent (e.g. "DiffuseColor") */ propertyName?: string; } export interface FBXObjectEntry { - id: bigint; + id: number; node: FBXNode; source: "Objects" | "legacySyntheticGeometry"; legacyName?: string; @@ -23,8 +23,8 @@ export interface FBXObjectEntry { export interface FBXConnectionEntry { source: "C" | "Connect"; rawType?: string; - childId?: bigint; - parentId?: bigint; + childId?: number; + parentId?: number; propertyName?: string; accepted: boolean; } @@ -42,20 +42,20 @@ export interface FBXConnectionDiagnostic { message: string; connectionIndex?: number; type?: string; - childId?: bigint; - parentId?: bigint; + childId?: number; + parentId?: number; propertyName?: string; } export interface FBXObjectMap { /** All objects by their unique ID */ - objects: Map; + objects: Map; /** Object table entries, including synthetic compatibility objects */ objectEntries: FBXObjectEntry[]; /** Children of each object ID */ - childrenOf: Map; + childrenOf: Map; /** Parent of each object ID */ - parentOf: Map; + parentOf: Map; /** Raw connection list */ connections: FBXConnection[]; /** Raw connection-table entries and whether they were accepted into the graph */ @@ -69,18 +69,18 @@ export interface FBXObjectMap { * Maps object IDs to their FBXNode and resolves parent-child relationships. */ export function resolveConnections(doc: FBXDocument): FBXObjectMap { - const objects = new Map(); + const objects = new Map(); const objectEntries: FBXObjectEntry[] = []; - const childrenOf = new Map(); - const parentOf = new Map(); + const childrenOf = new Map(); + const parentOf = new Map(); const connections: FBXConnection[] = []; const connectionEntries: FBXConnectionEntry[] = []; const diagnostics: FBXConnectionDiagnostic[] = []; - const legacyIds = new Map(); - const syntheticLegacyIds = new Map>(); - let nextLegacyId = -1n; + const legacyIds = new Map(); + const syntheticLegacyIds = new Map>(); + let nextLegacyId = -1; - const getLegacyId = (name: string): bigint => { + const getLegacyId = (name: string): number => { let id = legacyIds.get(name); if (id === undefined) { id = nextLegacyId--; @@ -89,7 +89,7 @@ export function resolveConnections(doc: FBXDocument): FBXObjectMap { return id; }; - const getSyntheticLegacyId = (role: string, name: string): bigint => { + const getSyntheticLegacyId = (role: string, name: string): number => { let idsByName = syntheticLegacyIds.get(role); if (!idsByName) { idsByName = new Map(); @@ -110,7 +110,7 @@ export function resolveConnections(doc: FBXDocument): FBXObjectMap { for (const obj of objectsNode.children) { const idProp = obj.properties[0]; if (idProp) { - const id = toBigInt(idProp.value); + const id = toObjectNumber(idProp.value); if (id !== undefined) { objects.set(id, obj); objectEntries.push({ id, node: obj, source: "Objects", synthetic: false }); @@ -216,7 +216,7 @@ export function resolveConnections(doc: FBXDocument): FBXObjectMap { propertyName, }); } - if (parentId !== 0n && !objects.has(parentId)) { + if (parentId !== 0 && !objects.has(parentId)) { diagnostics.push({ reason: "unresolved-object-reference", message: "FBX connection parent ID is not present in the object table.", @@ -237,9 +237,9 @@ export function resolveConnections(doc: FBXDocument): FBXObjectMap { } /** Get all child objects of a given parent ID, optionally filtered by node name */ -export function getChildren(map: FBXObjectMap, parentId: bigint, nodeName?: string): { id: bigint; node: FBXNode; propertyName?: string }[] { +export function getChildren(map: FBXObjectMap, parentId: number, nodeName?: string): { id: number; node: FBXNode; propertyName?: string }[] { const children = map.childrenOf.get(parentId) ?? []; - const result: { id: bigint; node: FBXNode; propertyName?: string }[] = []; + const result: { id: number; node: FBXNode; propertyName?: string }[] = []; for (const child of children) { const node = map.objects.get(child.id); @@ -251,18 +251,15 @@ export function getChildren(map: FBXObjectMap, parentId: bigint, nodeName?: stri return result; } -function toBigInt(value: unknown): bigint | undefined { - if (typeof value === "bigint") { - return value; - } +function toObjectNumber(value: unknown): number | undefined { if (typeof value === "number") { - return BigInt(Math.round(value)); + return value; } return undefined; } -function toObjectId(value: unknown, legacyIds: Map): bigint | undefined { - const numericId = toBigInt(value); +function toObjectId(value: unknown, legacyIds: Map): number | undefined { + const numericId = toObjectNumber(value); if (numericId !== undefined) { return numericId; } @@ -271,19 +268,19 @@ function toObjectId(value: unknown, legacyIds: Map): bigint | un } const legacyName = cleanFBXName(value); if (legacyName === "Scene") { - return 0n; + return 0; } return legacyIds.get(legacyName); } function addConnection( connections: FBXConnection[], - childrenOf: Map, - parentOf: Map, + childrenOf: Map, + parentOf: Map, diagnostics: FBXConnectionDiagnostic[], type: ConnectionType, - childId: bigint, - parentId: bigint, + childId: number, + parentId: number, propertyName?: string, connectionIndex?: number ): void { @@ -308,7 +305,7 @@ function addConnection( parentOf.set(childId, { id: parentId, propertyName }); } -function normalizeLegacyObject(node: FBXNode, id: bigint): FBXNode { +function normalizeLegacyObject(node: FBXNode, id: number): FBXNode { const name = cleanFBXName(getPropertyValue(node, 0) ?? node.name); const subType = getPropertyValue(node, 1) ?? ""; return { @@ -321,7 +318,7 @@ function normalizeLegacyObject(node: FBXNode, id: bigint): FBXNode { }; } -function createLegacyGeometry(modelNode: FBXNode, geometryId: bigint): FBXNode { +function createLegacyGeometry(modelNode: FBXNode, geometryId: number): FBXNode { const name = cleanFBXName(getPropertyValue(modelNode, 0) ?? "Geometry"); return { name: "Geometry", diff --git a/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts b/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts index 9bdc443f5dc..92df41d28c7 100644 --- a/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts +++ b/packages/dev/loaders/src/FBX/interpreter/fbxInterpreter.ts @@ -21,7 +21,7 @@ import { /** Represents a model (transform node) in the FBX scene */ export interface FBXModelData { - id: bigint; + id: number; name: string; subType: string; /** Geometry attached to this model (if it's a Mesh type) */ @@ -65,7 +65,7 @@ export interface FBXModelData { /** Camera data extracted from FBX */ export interface FBXCameraData { /** Model ID this camera is attached to */ - modelId: bigint; + modelId: number; /** Camera name */ name: string; /** Field of view in degrees */ @@ -97,7 +97,7 @@ export interface FBXCameraData { /** Light data extracted from FBX */ export interface FBXLightData { /** Model ID this light is attached to */ - modelId: bigint; + modelId: number; /** Light name */ name: string; /** Light type: 0=Point, 1=Directional, 2=Spot */ @@ -225,18 +225,18 @@ export function interpretFBX(doc: FBXDocument): FBXSceneData { // ── Model Hierarchy ──────────────────────────────────────────────────────────── function buildModelHierarchy(objectMap: FBXObjectMap, geometries: FBXGeometryData[], materials: FBXMaterialData[], propertyTemplates: FBXPropertyTemplateMap): FBXModelData[] { - const geometryMap = new Map(); + const geometryMap = new Map(); for (const g of geometries) { geometryMap.set(g.id, g); } - const materialMap = new Map(); + const materialMap = new Map(); for (const m of materials) { materialMap.set(m.id, m); } // Find root models (those connected to ID 0, which is the scene root) - const rootChildren = objectMap.childrenOf.get(0n) ?? []; + const rootChildren = objectMap.childrenOf.get(0) ?? []; const rootModels: FBXModelData[] = []; for (const { id } of rootChildren) { @@ -250,11 +250,11 @@ function buildModelHierarchy(objectMap: FBXObjectMap, geometries: FBXGeometryDat } function buildModel( - modelId: bigint, + modelId: number, modelNode: FBXNode, objectMap: FBXObjectMap, - geometryMap: Map, - materialMap: Map, + geometryMap: Map, + materialMap: Map, propertyTemplates: FBXPropertyTemplateMap ): FBXModelData { const name = cleanFBXName(getPropertyValue(modelNode, 1) ?? "Model"); @@ -490,9 +490,6 @@ function extractCustomProperties(modelNode: FBXNode): Record(geometryNode, 1) ?? "Geometry"); // Extract raw vertices diff --git a/packages/dev/loaders/src/FBX/interpreter/materials.ts b/packages/dev/loaders/src/FBX/interpreter/materials.ts index 1a52ad9bb21..bd61223433e 100644 --- a/packages/dev/loaders/src/FBX/interpreter/materials.ts +++ b/packages/dev/loaders/src/FBX/interpreter/materials.ts @@ -7,7 +7,7 @@ import { getPropertyTemplate, resolvePropertyValue, resolvePropertyValues, type /** Parsed material data */ export interface FBXMaterialData { - id: bigint; + id: number; name: string; type: "Lambert" | "Phong"; properties: FBXMaterialProperties; @@ -36,7 +36,7 @@ export interface FBXTextureRef { /** Relative file path from the FBX */ relativeFileName: string; /** Texture node ID */ - id: bigint; + id: number; /** Embedded texture data (from Video node Content), if available */ embeddedData: Uint8Array | null; /** UV translation [u, v] */ @@ -54,7 +54,7 @@ export interface FBXTextureRef { /** * Extract material data from an FBX Material node. */ -export function extractMaterial(materialNode: FBXNode, materialId: bigint, objectMap: FBXObjectMap, templates?: FBXPropertyTemplateMap): FBXMaterialData { +export function extractMaterial(materialNode: FBXNode, materialId: number, objectMap: FBXObjectMap, templates?: FBXPropertyTemplateMap): FBXMaterialData { const name = cleanFBXName(getPropertyValue(materialNode, 1) ?? "Material"); const template = getMaterialTemplate(materialNode, templates); @@ -92,7 +92,7 @@ function extractMaterialProperties(materialNode: FBXNode, template?: FBXProperty return props; } -function extractTextures(materialId: bigint, objectMap: FBXObjectMap, template?: FBXPropertyTemplate): FBXTextureRef[] { +function extractTextures(materialId: number, objectMap: FBXObjectMap, template?: FBXPropertyTemplate): FBXTextureRef[] { const textures: FBXTextureRef[] = []; const textureChildren = getChildren(objectMap, materialId, "Texture"); @@ -199,9 +199,6 @@ function toNumber(value: unknown): number | undefined { if (typeof value === "number") { return value; } - if (typeof value === "bigint") { - return Number(value); - } return undefined; } diff --git a/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts b/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts index 932fb8d1e19..d296fd843d3 100644 --- a/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts +++ b/packages/dev/loaders/src/FBX/interpreter/propertyTemplates.ts @@ -114,9 +114,6 @@ function toNumber(value: FBXPropertyValue | undefined): number | undefined { if (typeof value === "number") { return value; } - if (typeof value === "bigint") { - return Number(value); - } return undefined; } diff --git a/packages/dev/loaders/src/FBX/interpreter/rig.ts b/packages/dev/loaders/src/FBX/interpreter/rig.ts index c4791347310..e0b896bd107 100644 --- a/packages/dev/loaders/src/FBX/interpreter/rig.ts +++ b/packages/dev/loaders/src/FBX/interpreter/rig.ts @@ -7,19 +7,19 @@ import { cleanFBXName, getPropertyValue } from "../types/fbxTypes"; export type FBXRigBoneData = FBXBoneData; export interface FBXSkinBindingData { - skinId: bigint; - geometryId: bigint; + skinId: number; + geometryId: number; rigId: string; skinBoneIndexToRigBoneIndex: number[]; - clusterModelIds: Set; + clusterModelIds: Set; } export interface FBXRigData { id: string; - rootModelIds: bigint[]; + rootModelIds: number[]; bones: FBXRigBoneData[]; - modelIdToBoneIndex: Map; - clusterModelIds: Set; + modelIdToBoneIndex: Map; + clusterModelIds: Set; skinBindings: FBXSkinBindingData[]; warnings: string[]; } @@ -29,7 +29,7 @@ export function resolveRigs(objectMap: FBXObjectMap, skins: FBXSkinData[]): FBXR return []; } - const groupByRoot = new Map(); + const groupByRoot = new Map(); for (const skin of skins) { const clusterModelIds = skin.bones.filter((bone) => bone.isCluster).map((bone) => bone.modelId); @@ -47,15 +47,15 @@ export function resolveRigs(objectMap: FBXObjectMap, skins: FBXSkinData[]): FBXR } return Array.from(groupByRoot.entries()) - .sort(([a], [b]) => compareBigInt(a, b)) + .sort(([a], [b]) => compareNumber(a, b)) .map(([rootModelId, groupSkins]) => buildRig(rootModelId, groupSkins, objectMap)); } -function buildRig(rootModelId: bigint, skins: FBXSkinData[], objectMap: FBXObjectMap): FBXRigData { - const clusterModelIds = new Set(); - const rigModelIds = new Set(); - const sourceBonesByModelId = new Map(); - const sourceOrderByModelId = new Map(); +function buildRig(rootModelId: number, skins: FBXSkinData[], objectMap: FBXObjectMap): FBXRigData { + const clusterModelIds = new Set(); + const rigModelIds = new Set(); + const sourceBonesByModelId = new Map(); + const sourceOrderByModelId = new Map(); for (const skin of skins) { for (const bone of skin.bones) { @@ -82,7 +82,7 @@ function buildRig(rootModelId: bigint, skins: FBXSkinData[], objectMap: FBXObjec } const warnings = collectTransformLinkWarnings(sourceBonesByModelId); - const preferredBoneByModelId = new Map(); + const preferredBoneByModelId = new Map(); for (const [modelId, sources] of Array.from(sourceBonesByModelId)) { preferredBoneByModelId.set(modelId, choosePreferredBoneSource(sources)); } @@ -90,7 +90,7 @@ function buildRig(rootModelId: bigint, skins: FBXSkinData[], objectMap: FBXObjec const parentByModelId = buildParentMap(rigModelIds, objectMap); const orderedModelIds = orderParentsBeforeChildren(rigModelIds, parentByModelId, sourceOrderByModelId); const bones: FBXRigBoneData[] = []; - const modelIdToBoneIndex = new Map(); + const modelIdToBoneIndex = new Map(); for (const modelId of orderedModelIds) { const sourceBone = preferredBoneByModelId.get(modelId) ?? createFallbackBone(modelId, objectMap); @@ -124,7 +124,7 @@ function buildRig(rootModelId: bigint, skins: FBXSkinData[], objectMap: FBXObjec }; } -function buildSkinBinding(skin: FBXSkinData, rigId: string, modelIdToBoneIndex: Map): FBXSkinBindingData { +function buildSkinBinding(skin: FBXSkinData, rigId: string, modelIdToBoneIndex: Map): FBXSkinBindingData { const skinBoneIndexToRigBoneIndex = skin.bones.map((bone) => { const rigBoneIndex = modelIdToBoneIndex.get(bone.modelId); if (rigBoneIndex === undefined && bone.isCluster) { @@ -142,7 +142,7 @@ function buildSkinBinding(skin: FBXSkinData, rigId: string, modelIdToBoneIndex: }; } -function findRigGroupingRoot(clusterModelIds: bigint[], objectMap: FBXObjectMap): bigint { +function findRigGroupingRoot(clusterModelIds: number[], objectMap: FBXObjectMap): number { const lca = findLowestCommonAncestor(clusterModelIds, objectMap) ?? clusterModelIds[0]; let root = lca; let parentId = findModelParentId(root, objectMap); @@ -160,7 +160,7 @@ function findRigGroupingRoot(clusterModelIds: bigint[], objectMap: FBXObjectMap) return root; } -function findLowestCommonAncestor(modelIds: bigint[], objectMap: FBXObjectMap): bigint | undefined { +function findLowestCommonAncestor(modelIds: number[], objectMap: FBXObjectMap): number | undefined { if (modelIds.length === 0) { return undefined; } @@ -178,9 +178,9 @@ function findLowestCommonAncestor(modelIds: bigint[], objectMap: FBXObjectMap): return chains[0].find((modelId) => common.has(modelId)); } -function getModelAncestorChain(modelId: bigint, objectMap: FBXObjectMap): bigint[] { - const chain: bigint[] = []; - let currentId: bigint | undefined = modelId; +function getModelAncestorChain(modelId: number, objectMap: FBXObjectMap): number[] { + const chain: number[] = []; + let currentId: number | undefined = modelId; while (currentId !== undefined) { const node = objectMap.objects.get(currentId); @@ -195,8 +195,8 @@ function getModelAncestorChain(modelId: bigint, objectMap: FBXObjectMap): bigint return chain; } -function buildParentMap(modelIds: Set, objectMap: FBXObjectMap): Map { - const parentByModelId = new Map(); +function buildParentMap(modelIds: Set, objectMap: FBXObjectMap): Map { + const parentByModelId = new Map(); for (const modelId of Array.from(modelIds)) { const parentId = findModelParentId(modelId, objectMap); @@ -208,8 +208,8 @@ function buildParentMap(modelIds: Set, objectMap: FBXObjectMap): Map, parentByModelId: Map, sourceOrderByModelId: Map): bigint[] { - const childrenByModelId = new Map(); +function orderParentsBeforeChildren(modelIds: Set, parentByModelId: Map, sourceOrderByModelId: Map): number[] { + const childrenByModelId = new Map(); for (const modelId of Array.from(modelIds)) { const parentId = parentByModelId.get(modelId); if (parentId === undefined) { @@ -231,7 +231,7 @@ function orderParentsBeforeChildren(modelIds: Set, parentByModelId: Map< const roots = Array.from(modelIds) .filter((modelId) => !parentByModelId.has(modelId)) .sort((a, b) => compareSourceOrder(a, b, sourceOrderByModelId)); - const ordered: bigint[] = []; + const ordered: number[] = []; const queue = [...roots]; while (queue.length > 0) { @@ -243,7 +243,7 @@ function orderParentsBeforeChildren(modelIds: Set, parentByModelId: Map< return ordered; } -function findModelParentId(modelId: bigint, objectMap: FBXObjectMap): bigint | undefined { +function findModelParentId(modelId: number, objectMap: FBXObjectMap): number | undefined { const parentConnection = objectMap.connections.find((conn) => conn.type === "OO" && conn.childId === modelId && objectMap.objects.get(conn.parentId)?.name === "Model"); return parentConnection?.parentId; } @@ -257,7 +257,7 @@ function choosePreferredBoneSource(sources: FBXBoneData[]): FBXBoneData { ); } -function collectTransformLinkWarnings(sourceBonesByModelId: Map): string[] { +function collectTransformLinkWarnings(sourceBonesByModelId: Map): string[] { const warnings: string[] = []; for (const [modelId, sources] of Array.from(sourceBonesByModelId)) { @@ -287,7 +287,7 @@ function areMatricesEquivalent(a: Float64Array, b: Float64Array, epsilon: number return true; } -function createFallbackBone(modelId: bigint, objectMap: FBXObjectMap): FBXBoneData | null { +function createFallbackBone(modelId: number, objectMap: FBXObjectMap): FBXBoneData | null { const modelNode = objectMap.objects.get(modelId); if (!modelNode || modelNode.name !== "Model") { return null; @@ -320,12 +320,12 @@ function createFallbackBone(modelId: bigint, objectMap: FBXObjectMap): FBXBoneDa }; } -function compareBigInt(a: bigint, b: bigint): number { +function compareNumber(a: number, b: number): number { return a < b ? -1 : a > b ? 1 : 0; } -function compareSourceOrder(a: bigint, b: bigint, sourceOrderByModelId: Map): number { +function compareSourceOrder(a: number, b: number, sourceOrderByModelId: Map): number { const aOrder = sourceOrderByModelId.get(a) ?? Number.MAX_SAFE_INTEGER; const bOrder = sourceOrderByModelId.get(b) ?? Number.MAX_SAFE_INTEGER; - return aOrder - bOrder || compareBigInt(a, b); + return aOrder - bOrder || compareNumber(a, b); } diff --git a/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts b/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts index cfa7a64e25f..9c8ffeabcff 100644 --- a/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts +++ b/packages/dev/loaders/src/FBX/interpreter/sceneDiagnostics.ts @@ -15,7 +15,7 @@ export type FBXSceneDiagnosticType = export interface FBXSceneDiagnostic { type: FBXSceneDiagnosticType; message: string; - objectId?: bigint; + objectId?: number; objectName?: string; nodeName?: string; subType?: string; @@ -104,7 +104,7 @@ function isSupportedDeformer(subType: string): boolean { return subType === "Skin" || subType === "Cluster" || subType === "BlendShape" || subType === "BlendShapeChannel"; } -function createObjectDiagnostic(objectMap: FBXObjectMap, id: bigint, node: FBXNode, type: FBXSceneDiagnosticType, message: string): FBXSceneDiagnostic { +function createObjectDiagnostic(objectMap: FBXObjectMap, id: number, node: FBXNode, type: FBXSceneDiagnosticType, message: string): FBXSceneDiagnostic { return { type, message, diff --git a/packages/dev/loaders/src/FBX/interpreter/skeleton.ts b/packages/dev/loaders/src/FBX/interpreter/skeleton.ts index 2de877ce52a..e360ca6610c 100644 --- a/packages/dev/loaders/src/FBX/interpreter/skeleton.ts +++ b/packages/dev/loaders/src/FBX/interpreter/skeleton.ts @@ -10,7 +10,7 @@ export type FBXClusterMode = "Normalize" | "Additive" | "TotalOne" | "Unknown"; export interface FBXSkinDiagnostic { type: "cluster-mode-runtime-unsupported" | "missing-cluster-transform" | "missing-cluster-transform-link" | "missing-bind-pose-matrix" | "associate-model-present"; message: string; - boneModelId?: bigint; + boneModelId?: number; boneName?: string; clusterMode?: FBXClusterMode; } @@ -18,7 +18,7 @@ export interface FBXSkinDiagnostic { /** Represents a single bone (cluster) in the FBX skeleton */ export interface FBXBoneData { /** The Model node ID for this bone */ - modelId: bigint; + modelId: number; /** Bone name (from the Model node) */ name: string; /** Index of this bone in the skeleton */ @@ -66,9 +66,9 @@ export interface FBXBoneData { /** Represents a skin deformer with its clusters */ export interface FBXSkinData { /** Skin deformer ID */ - id: bigint; + id: number; /** Geometry ID this skin is attached to */ - geometryId: bigint; + geometryId: number; /** Mesh model world matrix from the FBX BindPose, when present */ meshBindPoseMatrix: Float64Array | null; /** Bones in this skeleton */ @@ -100,7 +100,7 @@ export function extractSkins(objectMap: FBXObjectMap): FBXSkinData[] { return skins; } -function extractSkin(skinId: bigint, _skinNode: FBXNode, objectMap: FBXObjectMap): FBXSkinData | null { +function extractSkin(skinId: number, _skinNode: FBXNode, objectMap: FBXObjectMap): FBXSkinData | null { // Find the geometry this skin is attached to // Skin is a child of the geometry in FBX connection graph const skinParent = objectMap.parentOf.get(skinId); @@ -125,7 +125,7 @@ function extractSkin(skinId: bigint, _skinNode: FBXNode, objectMap: FBXObjectMap // For each cluster, find the connected bone Model // Connection graph: BoneModel → Cluster (bone is child of cluster) - const boneModelMap = new Map(); + const boneModelMap = new Map(); for (const { id: clusterId, node: clusterNode } of clusterEntries) { const subType = getPropertyValue(clusterNode, 2); if (subType !== "Cluster") { @@ -168,13 +168,13 @@ function extractSkin(skinId: bigint, _skinNode: FBXNode, objectMap: FBXObjectMap * Build a flat ordered bone list with parent indices from the FBX Model hierarchy. */ function buildBoneHierarchy( - boneModelMap: Map, - bindPoseMatrices: Map, + boneModelMap: Map, + bindPoseMatrices: Map, objectMap: FBXObjectMap, skinDiagnostics: FBXSkinDiagnostic[] ): FBXBoneData[] { const bones: FBXBoneData[] = []; - const visited = new Set(); + const visited = new Set(); const skeletonModelIds = collectSkeletonModelIds(boneModelMap, objectMap); const parentByModelId = buildSkeletonParentMap(skeletonModelIds, objectMap); const childrenByModelId = buildSkeletonChildrenMap(skeletonModelIds, parentByModelId); @@ -182,7 +182,7 @@ function buildBoneHierarchy( const rootBoneIds = Array.from(skeletonModelIds).filter((modelId) => !parentByModelId.has(modelId)); // BFS to build ordered list - const queue: { modelId: bigint; parentIndex: number }[] = rootBoneIds.map((id) => ({ + const queue: { modelId: number; parentIndex: number }[] = rootBoneIds.map((id) => ({ modelId: id, parentIndex: -1, })); @@ -253,7 +253,7 @@ function buildBoneHierarchy( return bones; } -function extractBindPoseMatrices(geometryId: bigint, objectMap: FBXObjectMap): Map { +function extractBindPoseMatrices(geometryId: number, objectMap: FBXObjectMap): Map { const modelParent = objectMap.parentOf.get(geometryId); const modelParentNode = modelParent ? objectMap.objects.get(modelParent.id) : undefined; const modelId = modelParentNode?.name === "Model" ? modelParent!.id : undefined; @@ -266,7 +266,7 @@ function extractBindPoseMatrices(geometryId: bigint, objectMap: FBXObjectMap): M continue; } - const matrices = new Map(); + const matrices = new Map(); for (const poseChild of poseNode.children) { if (poseChild.name !== "PoseNode") { continue; @@ -276,7 +276,7 @@ function extractBindPoseMatrices(geometryId: bigint, objectMap: FBXObjectMap): M const matrixChild = findChildByName(poseChild, "Matrix"); const nodeId = nodeChild?.properties[0]?.value; const matrixValue = matrixChild?.properties[0]?.value; - if (typeof nodeId !== "bigint") { + if (typeof nodeId !== "number") { continue; } @@ -294,8 +294,8 @@ function extractBindPoseMatrices(geometryId: bigint, objectMap: FBXObjectMap): M return new Map(); } -function buildSkeletonChildrenMap(skeletonModelIds: Set, parentByModelId: Map): Map { - const childrenByModelId = new Map(); +function buildSkeletonChildrenMap(skeletonModelIds: Set, parentByModelId: Map): Map { + const childrenByModelId = new Map(); for (const modelId of Array.from(skeletonModelIds)) { const parentId = parentByModelId.get(modelId); @@ -312,8 +312,8 @@ function buildSkeletonChildrenMap(skeletonModelIds: Set, parentByModelId return childrenByModelId; } -function collectSkeletonModelIds(boneModelMap: Map, objectMap: FBXObjectMap): Set { - const skeletonModelIds = new Set(Array.from(boneModelMap.keys())); +function collectSkeletonModelIds(boneModelMap: Map, objectMap: FBXObjectMap): Set { + const skeletonModelIds = new Set(Array.from(boneModelMap.keys())); for (const modelId of Array.from(boneModelMap.keys())) { let parentId = findModelParentId(modelId, objectMap); @@ -331,8 +331,8 @@ function collectSkeletonModelIds(boneModelMap: Map, objectMap: FBXObjectMap): Map { - const parentByModelId = new Map(); +function buildSkeletonParentMap(skeletonModelIds: Set, objectMap: FBXObjectMap): Map { + const parentByModelId = new Map(); for (const modelId of Array.from(skeletonModelIds)) { let parentId = findModelParentId(modelId, objectMap); @@ -348,7 +348,7 @@ function buildSkeletonParentMap(skeletonModelIds: Set, objectMap: FBXObj return parentByModelId; } -function findModelParentId(modelId: bigint, objectMap: FBXObjectMap): bigint | undefined { +function findModelParentId(modelId: number, objectMap: FBXObjectMap): number | undefined { const parentConnection = objectMap.connections.find((conn) => conn.type === "OO" && conn.childId === modelId && objectMap.objects.get(conn.parentId)?.name === "Model"); return parentConnection?.parentId; } @@ -508,7 +508,7 @@ function extractClusterMatrices(clusterNode: FBXNode): { } function createBoneDiagnostics( - modelId: bigint, + modelId: number, boneName: string, isCluster: boolean, clusterMode: FBXClusterMode, @@ -577,7 +577,7 @@ function createBoneDiagnostics( */ function extractVertexWeights( bones: FBXBoneData[], - boneModelMap: Map, + boneModelMap: Map, objectMap: FBXObjectMap ): { boneIndices: number[][]; boneWeights: number[][] } { // We need to find the max vertex index to size our arrays @@ -677,9 +677,6 @@ function toNumber(value: unknown): number | undefined { if (typeof value === "number") { return value; } - if (typeof value === "bigint") { - return Number(value); - } return undefined; } diff --git a/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts b/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts index 74209794130..688a400749b 100644 --- a/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts +++ b/packages/dev/loaders/src/FBX/parsers/fbxAsciiParser.ts @@ -281,12 +281,10 @@ function parseNodeFromTokens(tokenizer: Tokenizer): FBXNode | null { if (peek.type === TokenType.Number) { const tok = tokenizer.next(); const numVal = parseNumericValue(tok.value); - if (typeof numVal === "bigint") { - properties.push({ type: "int64", value: numVal }); - } else if (Number.isInteger(numVal) && !tok.value.includes(".")) { - properties.push({ type: "int32", value: numVal as number }); + if (Number.isInteger(numVal) && !tok.value.includes(".") && !tok.value.includes("e") && !tok.value.includes("E")) { + properties.push({ type: isInt32(numVal) ? "int32" : "int64", value: numVal }); } else { - properties.push({ type: "float64", value: numVal as number }); + properties.push({ type: "float64", value: numVal }); } } else if (peek.type === TokenType.String) { const tok = tokenizer.next(); @@ -331,7 +329,7 @@ function parseNodeFromTokens(tokenizer: Tokenizer): FBXNode | null { return { name, properties, children }; } -function parseArrayValues(tokenizer: Tokenizer, _count: number): number[] { +function parseArrayValues(tokenizer: Tokenizer, count: number): number[] { const values: number[] = []; while (true) { const peek = tokenizer.peek(); @@ -349,16 +347,16 @@ function parseArrayValues(tokenizer: Tokenizer, _count: number): number[] { break; } } + if (values.length !== count) { + throw new Error(`ASCII FBX array declared ${count} values but parsed ${values.length}`); + } return values; } -function parseNumericValue(str: string): number | bigint { - // If the number is very large (FBX UIDs are int64), use bigint - if (!str.includes(".") && !str.includes("e") && !str.includes("E")) { - const n = Number(str); - if (n > Number.MAX_SAFE_INTEGER || n < Number.MIN_SAFE_INTEGER) { - return BigInt(str); - } - } +function parseNumericValue(str: string): number { return Number(str); } + +function isInt32(value: number): boolean { + return value >= -2147483648 && value <= 2147483647; +} diff --git a/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts b/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts index a3ed5d5892b..2c2a191441d 100644 --- a/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts +++ b/packages/dev/loaders/src/FBX/parsers/fbxBinaryParser.ts @@ -27,7 +27,7 @@ export function parseBinaryFBX(buffer: ArrayBuffer): FBXDocument { let offset = HEADER_SIZE; while (offset < buffer.byteLength) { - const result = parseNode(view, bytes, offset, is64Bit); + const result = parseNode(view, bytes, offset, is64Bit, buffer.byteLength); if (result === null) { break; // null sentinel node } @@ -43,7 +43,7 @@ interface ParsedNode { endOffset: number; } -function parseNode(view: DataView, bytes: Uint8Array, offset: number, is64Bit: boolean): ParsedNode | null { +function parseNode(view: DataView, bytes: Uint8Array, offset: number, is64Bit: boolean, limit: number): ParsedNode | null { // Read node header let endOffset: number; let numProperties: number; @@ -51,11 +51,13 @@ function parseNode(view: DataView, bytes: Uint8Array, offset: number, is64Bit: b let headerSize: number; if (is64Bit) { - endOffset = Number(view.getBigUint64(offset, true)); - numProperties = Number(view.getBigUint64(offset + 8, true)); - propertyListLen = Number(view.getBigUint64(offset + 16, true)); + ensureRange(bytes, offset, 25, limit, "FBX node header"); + endOffset = readUint64AsNumber(view, offset); + numProperties = readUint64AsNumber(view, offset + 8); + propertyListLen = readUint64AsNumber(view, offset + 16); headerSize = 25; // 8+8+8+1 (nameLen byte) } else { + ensureRange(bytes, offset, 13, limit, "FBX node header"); endOffset = view.getUint32(offset, true); numProperties = view.getUint32(offset + 4, true); propertyListLen = view.getUint32(offset + 8, true); @@ -66,21 +68,29 @@ function parseNode(view: DataView, bytes: Uint8Array, offset: number, is64Bit: b if (endOffset === 0) { return null; } + if (endOffset <= offset || endOffset > limit) { + throw new Error(`Invalid FBX node end offset ${endOffset} at offset ${offset}`); + } const nameLen = bytes[offset + headerSize - 1]; + ensureRange(bytes, offset + headerSize, nameLen, endOffset, "FBX node name"); const name = decodeASCII(bytes, offset + headerSize, nameLen); let cursor = offset + headerSize + nameLen; const propertiesStart = cursor; + const propertiesEnd = propertiesStart + propertyListLen; + if (propertiesEnd > endOffset) { + throw new Error(`Invalid FBX property list length for node '${name}' at offset ${offset}`); + } // Parse properties const properties: FBXProperty[] = []; for (let i = 0; i < numProperties; i++) { - const result = parseProperty(view, bytes, cursor); + const result = parseProperty(view, bytes, cursor, propertiesEnd); properties.push(result.property); cursor = result.nextOffset; } - if (cursor !== propertiesStart + propertyListLen) { + if (cursor !== propertiesEnd) { throw new Error(`Invalid FBX property list length for node '${name}' at offset ${offset}`); } @@ -88,10 +98,13 @@ function parseNode(view: DataView, bytes: Uint8Array, offset: number, is64Bit: b const children: FBXNode[] = []; if (cursor < endOffset) { while (cursor < endOffset) { - const child = parseNode(view, bytes, cursor, is64Bit); + const child = parseNode(view, bytes, cursor, is64Bit, endOffset); if (child === null) { break; } + if (child.endOffset <= cursor || child.endOffset > endOffset) { + throw new Error(`Invalid FBX child node end offset ${child.endOffset} at offset ${cursor}`); + } children.push(child.node); cursor = child.endOffset; } @@ -108,75 +121,88 @@ interface ParsedProperty { nextOffset: number; } -function parseProperty(view: DataView, bytes: Uint8Array, offset: number): ParsedProperty { +function parseProperty(view: DataView, bytes: Uint8Array, offset: number, limit: number): ParsedProperty { + ensureRange(bytes, offset, 1, limit, "FBX property type"); const typeCode = String.fromCharCode(bytes[offset]); offset += 1; switch (typeCode) { case "C": { // Boolean (1 byte) + ensureRange(bytes, offset, 1, limit, "FBX boolean property"); const value = bytes[offset] !== 0; return { property: { type: "boolean", value }, nextOffset: offset + 1 }; } case "Y": { // Int16 + ensureRange(bytes, offset, 2, limit, "FBX int16 property"); const value = view.getInt16(offset, true); return { property: { type: "int16", value }, nextOffset: offset + 2 }; } case "I": { // Int32 + ensureRange(bytes, offset, 4, limit, "FBX int32 property"); const value = view.getInt32(offset, true); return { property: { type: "int32", value }, nextOffset: offset + 4 }; } case "F": { // Float32 + ensureRange(bytes, offset, 4, limit, "FBX float32 property"); const value = view.getFloat32(offset, true); return { property: { type: "float32", value }, nextOffset: offset + 4 }; } case "D": { // Float64 + ensureRange(bytes, offset, 8, limit, "FBX float64 property"); const value = view.getFloat64(offset, true); return { property: { type: "float64", value }, nextOffset: offset + 8 }; } case "L": { // Int64 - const value = view.getBigInt64(offset, true); + ensureRange(bytes, offset, 8, limit, "FBX int64 property"); + const value = readInt64AsNumber(view, offset); return { property: { type: "int64", value }, nextOffset: offset + 8 }; } case "S": { // String (uint32 length + data) + ensureRange(bytes, offset, 4, limit, "FBX string property length"); const len = view.getUint32(offset, true); + ensureRange(bytes, offset + 4, len, limit, "FBX string property data"); const value = decodeUTF8(bytes, offset + 4, len); return { property: { type: "string", value }, nextOffset: offset + 4 + len }; } case "R": { // Raw binary data (uint32 length + data) + ensureRange(bytes, offset, 4, limit, "FBX raw property length"); const len = view.getUint32(offset, true); + ensureRange(bytes, offset + 4, len, limit, "FBX raw property data"); const value = bytes.slice(offset + 4, offset + 4 + len); return { property: { type: "raw", value }, nextOffset: offset + 4 + len }; } // Array types case "f": - return parseArrayProperty(view, bytes, offset, "float32[]", 4); + return parseArrayProperty(view, bytes, offset, "float32[]", 4, limit); case "d": - return parseArrayProperty(view, bytes, offset, "float64[]", 8); + return parseArrayProperty(view, bytes, offset, "float64[]", 8, limit); case "i": - return parseArrayProperty(view, bytes, offset, "int32[]", 4); + return parseArrayProperty(view, bytes, offset, "int32[]", 4, limit); case "l": - return parseArrayProperty(view, bytes, offset, "int64[]", 8); + return parseArrayProperty(view, bytes, offset, "int64[]", 8, limit); case "b": - return parseArrayProperty(view, bytes, offset, "boolean[]", 1); + return parseArrayProperty(view, bytes, offset, "boolean[]", 1, limit); default: throw new Error(`Unknown FBX property type: '${typeCode}' at offset ${offset - 1}`); } } -function parseArrayProperty(view: DataView, bytes: Uint8Array, offset: number, type: FBXPropertyType, elementSize: number): ParsedProperty { +function parseArrayProperty(view: DataView, bytes: Uint8Array, offset: number, type: FBXPropertyType, elementSize: number, limit: number): ParsedProperty { + ensureRange(bytes, offset, 12, limit, `FBX array property header for ${type}`); const arrayLength = view.getUint32(offset, true); const encoding = view.getUint32(offset + 4, true); // 0=raw, 1=zlib const compressedLength = view.getUint32(offset + 8, true); offset += 12; const expectedByteLength = arrayLength * elementSize; + ensureRange(bytes, offset, compressedLength, limit, `FBX array property data for ${type}`); let arrayData: Uint8Array; if (encoding === 1) { @@ -195,7 +221,7 @@ function parseArrayProperty(view: DataView, bytes: Uint8Array, offset: number, t const arrayBuffer = arrayData.buffer.slice(arrayData.byteOffset, arrayData.byteOffset + arrayData.byteLength); - let value: Float32Array | Float64Array | Int32Array | BigInt64Array | Uint8Array; + let value: Float32Array | Float64Array | Int32Array | Uint8Array; switch (type) { case "float32[]": value = new Float32Array(arrayBuffer); @@ -210,7 +236,7 @@ function parseArrayProperty(view: DataView, bytes: Uint8Array, offset: number, t value = arrayData; break; case "int64[]": - value = new BigInt64Array(arrayBuffer); + value = readInt64ArrayData(arrayData); break; default: throw new Error(`Unexpected array type: ${type}`); @@ -222,6 +248,33 @@ function parseArrayProperty(view: DataView, bytes: Uint8Array, offset: number, t }; } +function ensureRange(bytes: Uint8Array, offset: number, byteLength: number, limit: number, context: string): void { + if (offset < 0 || byteLength < 0 || offset + byteLength > limit || offset + byteLength > bytes.byteLength) { + throw new Error(`${context}: unexpected end of input`); + } +} + +function readUint64AsNumber(view: DataView, offset: number): number { + const low = view.getUint32(offset, true); + const high = view.getUint32(offset + 4, true); + return high * 0x100000000 + low; +} + +function readInt64AsNumber(view: DataView, offset: number): number { + const low = view.getUint32(offset, true); + const high = view.getInt32(offset + 4, true); + return high * 0x100000000 + low; +} + +function readInt64ArrayData(arrayData: Uint8Array): Float64Array { + const view = new DataView(arrayData.buffer, arrayData.byteOffset, arrayData.byteLength); + const values = new Float64Array(arrayData.byteLength / 8); + for (let i = 0; i < values.length; i++) { + values[i] = readInt64AsNumber(view, i * 8); + } + return values; +} + function decodeASCII(bytes: Uint8Array, offset: number, length: number): string { let result = ""; for (let i = 0; i < length; i++) { diff --git a/packages/dev/loaders/src/FBX/types/fbxTypes.ts b/packages/dev/loaders/src/FBX/types/fbxTypes.ts index fbbba10500a..149dccbe79d 100644 --- a/packages/dev/loaders/src/FBX/types/fbxTypes.ts +++ b/packages/dev/loaders/src/FBX/types/fbxTypes.ts @@ -5,7 +5,7 @@ */ /** Individual property value within an FBX node */ -export type FBXPropertyValue = boolean | number | bigint | string | Float32Array | Float64Array | Int32Array | BigInt64Array | Uint8Array; +export type FBXPropertyValue = boolean | number | string | Float32Array | Float64Array | Int32Array | Uint8Array; export type FBXPropertyType = | "boolean" // 'C' @@ -64,10 +64,10 @@ export function getPropertyValue(node: FBXNode, inde } /** Get the numeric ID from a node (first property is typically the int64 UID) */ -export function getNodeId(node: FBXNode): bigint | undefined { +export function getNodeId(node: FBXNode): number | undefined { const prop = node.properties[0]; if (prop && (prop.type === "int64" || prop.type === "int32")) { - return BigInt(prop.value as number | bigint); + return Number(prop.value); } return undefined; } diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/animation.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/animation.test.ts new file mode 100644 index 00000000000..5d4d34f6c15 --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/animation.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { extractAnimations, isFrameBakedSampledCurve, sampleFBXCurveAtTime, type FBXKeyframe } from "loaders/FBX/interpreter/animation"; +import { resolveConnections } from "loaders/FBX/interpreter/connections"; +import { type FBXDocument, type FBXNode } from "loaders/FBX/types/fbxTypes"; + +const FBX_TIME_UNIT = 46186158000; + +describe("FBX animation interpretation", () => { + it("samples constant and cubic curves according to their key interpolation", () => { + expect( + sampleFBXCurveAtTime( + { + channel: "d|X", + keys: [ + { time: 0, value: 1, interpolation: "constant" }, + { time: 1, value: 9, interpolation: "linear" }, + ], + }, + 0.5 + ) + ).toBe(1); + + expect( + sampleFBXCurveAtTime( + { + channel: "d|X", + keys: [ + { time: 0, value: 1, interpolation: "constant", constantMode: "next" }, + { time: 1, value: 9, interpolation: "linear" }, + ], + }, + 0.5 + ) + ).toBe(9); + + expect( + sampleFBXCurveAtTime( + { + channel: "d|X", + keys: [ + { time: 0, value: 0, interpolation: "cubic", rightSlope: 0, nextLeftSlope: 0 }, + { time: 1, value: 10, interpolation: "linear" }, + ], + }, + 0.25 + ) + ).toBeCloseTo(1.5625); + }); + + it("detects uniformly frame-baked sampled curves", () => { + const sampledKeys = createSampledKeys(8, 30); + + expect(isFrameBakedSampledCurve(sampledKeys)).toBe(true); + expect(isFrameBakedSampledCurve([{ ...sampledKeys[0], time: 0 }, { ...sampledKeys[1], time: 0.1 }, ...sampledKeys.slice(2)])).toBe(false); + }); + + it("extracts animation layers, rebased keyframes, and layer diagnostics", () => { + const animations = extractAnimations(resolveConnections(createAnimationDocument())); + + expect(animations).toHaveLength(1); + expect(animations[0].name).toBe("Take 001"); + expect(animations[0].startTime).toBe(0); + expect(animations[0].stopTime).toBe(1); + expect(animations[0].curveNodes[0].targetModelId).toBe(10); + expect(animations[0].curveNodes[0].curves[0].channel).toBe("d|X"); + expect(animations[0].curveNodes[0].curves[0].keys.map((key) => key.time)).toEqual([0, 1]); + expect(animations[0].layers[0].diagnostics.map((diagnostic) => diagnostic.type)).toEqual(["unsupported-layer-blend-mode", "partial-layer-weight"]); + }); +}); + +function createSampledKeys(count: number, fps: number): FBXKeyframe[] { + return Array.from({ length: count }, (_, index) => ({ + time: index / fps, + value: index, + interpolation: "linear" as const, + })); +} + +function createAnimationDocument(): FBXDocument { + return { + version: 7400, + nodes: [ + { + name: "Objects", + properties: [], + children: [ + createObject("Model", 10, "Model::Animated", "Null"), + createObject("AnimationStack", 1, "AnimStack::Take 001", "", [ + { + name: "Properties70", + properties: [], + children: [], + }, + ]), + createObject("AnimationLayer", 2, "AnimLayer::BaseLayer", "", [ + { + name: "Properties70", + properties: [], + children: [createProperty("Weight", 50), createProperty("BlendMode", 1)], + }, + ]), + createObject("AnimationCurveNode", 3, "AnimationCurveNode::T", ""), + createObject("AnimationCurve", 4, "AnimCurve::X", "", [ + { name: "KeyTime", properties: [{ type: "int64[]", value: new Float64Array([FBX_TIME_UNIT, FBX_TIME_UNIT * 2]) }], children: [] }, + { name: "KeyValueFloat", properties: [{ type: "float32[]", value: new Float32Array([3, 6]) }], children: [] }, + ]), + ], + }, + { + name: "Connections", + properties: [], + children: [createConnection("OO", 2, 1), createConnection("OO", 3, 2), createConnection("OP", 3, 10, "Lcl Translation"), createConnection("OP", 4, 3, "d|X")], + }, + ], + }; +} + +function createObject(name: string, id: number, objectName: string, subType: string, children: FBXNode[] = []): FBXNode { + return { + name, + properties: [ + { type: "int64", value: id }, + { type: "string", value: objectName }, + { type: "string", value: subType }, + ], + children, + }; +} + +function createProperty(name: string, value: number): FBXNode { + return { + name: "P", + properties: [ + { type: "string", value: name }, + { type: "string", value: "Number" }, + { type: "string", value: "" }, + { type: "string", value: "A" }, + { type: "float64", value }, + ], + children: [], + }; +} + +function createConnection(type: string, child: number, parent: number, propertyName?: string): FBXNode { + return { + name: "C", + properties: [ + { type: "string", value: type }, + { type: "int64", value: child }, + { type: "int64", value: parent }, + ...(propertyName ? [{ type: "string" as const, value: propertyName }] : []), + ], + children: [], + }; +} diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts index ec1795a819a..bedff84a535 100644 --- a/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts +++ b/packages/dev/loaders/test/unit/FBX/interpreter/blendShapes.test.ts @@ -33,20 +33,20 @@ function createBlendShapeDocument(): FBXDocument { name: "Objects", properties: [], children: [ - createObject("Geometry", 1n, "Geometry::Base", "Mesh"), - createObject("Deformer", 2n, "Deformer::BlendShape", "BlendShape"), + createObject("Geometry", 1, "Geometry::Base", "Mesh"), + createObject("Deformer", 2, "Deformer::BlendShape", "BlendShape"), { - ...createObject("Deformer", 3n, "SubDeformer::Smile", "BlendShapeChannel"), + ...createObject("Deformer", 3, "SubDeformer::Smile", "BlendShapeChannel"), children: [{ name: "FullWeights", properties: [{ type: "float64[]", value: new Float64Array([100, 50]) }], children: [] }], }, - createShape(4n, [1, 0, 0]), - createShape(5n, [0.5, 0, 0]), + createShape(4, [1, 0, 0]), + createShape(5, [0.5, 0, 0]), ], }, { name: "Connections", properties: [], - children: [createConnection("OO", 2n, 1n), createConnection("OO", 3n, 2n), createConnection("OO", 4n, 3n), createConnection("OO", 5n, 3n)], + children: [createConnection("OO", 2, 1), createConnection("OO", 3, 2), createConnection("OO", 4, 3), createConnection("OO", 5, 3)], }, ], }; @@ -60,25 +60,25 @@ function createSingleShapeFullWeightsDocument(): FBXDocument { name: "Objects", properties: [], children: [ - createObject("Geometry", 1n, "Geometry::Base", "Mesh"), - createObject("Deformer", 2n, "Deformer::BlendShape", "BlendShape"), + createObject("Geometry", 1, "Geometry::Base", "Mesh"), + createObject("Deformer", 2, "Deformer::BlendShape", "BlendShape"), { - ...createObject("Deformer", 3n, "SubDeformer::Blink", "BlendShapeChannel"), + ...createObject("Deformer", 3, "SubDeformer::Blink", "BlendShapeChannel"), children: [{ name: "FullWeights", properties: [{ type: "float64[]", value: new Float64Array([50, 100]) }], children: [] }], }, - createShape(4n, [1, 0, 0]), + createShape(4, [1, 0, 0]), ], }, { name: "Connections", properties: [], - children: [createConnection("OO", 2n, 1n), createConnection("OO", 3n, 2n), createConnection("OO", 4n, 3n)], + children: [createConnection("OO", 2, 1), createConnection("OO", 3, 2), createConnection("OO", 4, 3)], }, ], }; } -function createShape(id: bigint, vertices: number[]): FBXNode { +function createShape(id: number, vertices: number[]): FBXNode { return { ...createObject("Geometry", id, `Geometry::Shape${id.toString()}`, "Shape"), children: [ @@ -88,7 +88,7 @@ function createShape(id: bigint, vertices: number[]): FBXNode { }; } -function createObject(name: string, id: bigint, objectName: string, subType: string): FBXNode { +function createObject(name: string, id: number, objectName: string, subType: string): FBXNode { return { name, properties: [ @@ -100,7 +100,7 @@ function createObject(name: string, id: bigint, objectName: string, subType: str }; } -function createConnection(type: string, child: bigint, parent: bigint): FBXNode { +function createConnection(type: string, child: number, parent: number): FBXNode { const properties: FBXProperty[] = [ { type: "string", value: type }, { type: "int64", value: child }, diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts index 9a51d1361ed..448733c538b 100644 --- a/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts +++ b/packages/dev/loaders/test/unit/FBX/interpreter/connections.test.ts @@ -10,12 +10,12 @@ describe("resolveConnections", () => { { name: "Objects", properties: [], - children: [createObject("Model", 1n, "Root", "Null")], + children: [createObject("Model", 1, "Root", "Null")], }, { name: "Connections", properties: [], - children: [createConnection("XX", 1n, 0n), createConnection("OO", "MissingLegacy", "Scene", "Connect")], + children: [createConnection("XX", 1, 0), createConnection("OO", "MissingLegacy", "Scene", "Connect")], }, ], }); @@ -55,22 +55,22 @@ describe("resolveConnections", () => { { name: "Objects", properties: [], - children: [createObject("Model", 1n, "Child", "Null"), createObject("Model", 2n, "ParentA", "Null"), createObject("Model", 3n, "ParentB", "Null")], + children: [createObject("Model", 1, "Child", "Null"), createObject("Model", 2, "ParentA", "Null"), createObject("Model", 3, "ParentB", "Null")], }, { name: "Connections", properties: [], - children: [createConnection("OO", 1n, 2n), createConnection("OO", 1n, 3n)], + children: [createConnection("OO", 1, 2), createConnection("OO", 1, 3)], }, ], }); - expect(map.parentOf.get(1n)?.id).toBe(3n); + expect(map.parentOf.get(1)?.id).toBe(3); expect(map.diagnostics.some((diagnostic) => diagnostic.reason === "duplicate-parent")).toBe(true); }); }); -function createObject(name: string, id: bigint, objectName: string, subType: string): FBXNode { +function createObject(name: string, id: number, objectName: string, subType: string): FBXNode { return { name, properties: [ @@ -93,13 +93,13 @@ function createLegacyObject(name: string, objectName: string, subType: string): }; } -function createConnection(type: string, child: bigint | string, parent: bigint | string, nodeName = "C"): FBXNode { +function createConnection(type: string, child: number | string, parent: number | string, nodeName = "C"): FBXNode { return { name: nodeName, properties: [ { type: "string", value: type }, - typeof child === "bigint" ? { type: "int64", value: child } : { type: "string", value: child }, - typeof parent === "bigint" ? { type: "int64", value: parent } : { type: "string", value: parent }, + typeof child === "number" ? { type: "int64", value: child } : { type: "string", value: child }, + typeof parent === "number" ? { type: "int64", value: parent } : { type: "string", value: parent }, ], children: [], }; diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts index ce86e3d6a17..f7c9fc3d50f 100644 --- a/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts +++ b/packages/dev/loaders/test/unit/FBX/interpreter/geometry.test.ts @@ -6,7 +6,7 @@ describe("FBX geometry fidelity", () => { it("ear-clips concave polygons while preserving per-polygon material indices", () => { const geometry = extractGeometry( createGeometryNode([0, 0, 0, 2, 0, 0, 1, 1, 0, 2, 2, 0, 0, 2, 0, 3, 0, 0, 4, 0, 0, 3, 1, 0], [0, 1, 2, 3, -5, 5, 6, -8], [createLayerElementMaterial([7, 3])]), - 1n + 1 ); expect(geometry.indices.length).toBe(12); @@ -15,7 +15,7 @@ describe("FBX geometry fidelity", () => { }); it("falls back for degenerate n-gons and records diagnostics", () => { - const geometry = extractGeometry(createGeometryNode([0, 0, 0, 1, 0, 0, 2, 0, 0, 3, 0, 0], [0, 1, 2, -4]), 1n); + const geometry = extractGeometry(createGeometryNode([0, 0, 0, 1, 0, 0, 2, 0, 0, 3, 0, 0], [0, 1, 2, -4]), 1); expect(geometry.indices.length).toBe(6); expect(geometry.diagnostics.some((diagnostic) => diagnostic.type === "degenerate-polygon")).toBe(true); @@ -32,7 +32,7 @@ describe("FBX geometry fidelity", () => { createLayerElement("LayerElementBinormal", "Binormals", "BinormalsIndex", [0, 1, 0, 0, 1, 0, 0, 1, 0]), ] ), - 1n + 1 ); expect(Array.from(geometry.tangents ?? [])).toEqual([1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1]); @@ -44,7 +44,7 @@ function createGeometryNode(vertices: number[], polygonVertexIndex: number[], ch return { name: "Geometry", properties: [ - { type: "int64", value: 1n }, + { type: "int64", value: 1 }, { type: "string", value: "Geometry::Synthetic" }, { type: "string", value: "Mesh" }, ], diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts index 6feb584e741..84850f293b7 100644 --- a/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts +++ b/packages/dev/loaders/test/unit/FBX/interpreter/propertyTemplates.test.ts @@ -48,7 +48,7 @@ function createSyntheticMaterialNode(): FBXNode { return { name: "Material", properties: [ - { type: "int64", value: 1n }, + { type: "int64", value: 1 }, { type: "string", value: "Material" }, { type: "string", value: "" }, ], diff --git a/packages/dev/loaders/test/unit/FBX/interpreter/skeleton.test.ts b/packages/dev/loaders/test/unit/FBX/interpreter/skeleton.test.ts new file mode 100644 index 00000000000..596981473f7 --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/interpreter/skeleton.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { resolveConnections } from "loaders/FBX/interpreter/connections"; +import { resolveRigs } from "loaders/FBX/interpreter/rig"; +import { extractSkins } from "loaders/FBX/interpreter/skeleton"; +import { type FBXDocument, type FBXNode } from "loaders/FBX/types/fbxTypes"; + +describe("FBX skeleton interpretation", () => { + it("includes skeleton ancestors, sorts cluster weights, and resolves rig skin bindings", () => { + const objectMap = resolveConnections(createSkinDocument()); + const skins = extractSkins(objectMap); + const rigs = resolveRigs(objectMap, skins); + + expect(skins).toHaveLength(1); + expect(skins[0].bones.map((bone) => ({ name: bone.name, parentIndex: bone.parentIndex, isCluster: bone.isCluster }))).toEqual([ + { name: "Root", parentIndex: -1, isCluster: false }, + { name: "BoneA", parentIndex: 0, isCluster: true }, + { name: "BoneB", parentIndex: 0, isCluster: true }, + ]); + expect(skins[0].boneIndices).toEqual([ + [2, 1], + [1, 2], + ]); + expect(skins[0].boneWeights).toEqual([ + [0.8, 0.2], + [0.9, 0.1], + ]); + + expect(rigs).toHaveLength(1); + expect(rigs[0].rootModelIds).toEqual([10]); + expect(rigs[0].skinBindings[0].skinBoneIndexToRigBoneIndex).toEqual([0, 1, 2]); + expect(Array.from(rigs[0].skinBindings[0].clusterModelIds)).toEqual([11, 12]); + }); +}); + +function createSkinDocument(): FBXDocument { + return { + version: 7400, + nodes: [ + { + name: "Objects", + properties: [], + children: [ + createObject("Geometry", 1, "Geometry::Mesh", "Mesh"), + createObject("Model", 2, "Model::Mesh", "Mesh"), + createObject("Deformer", 3, "Deformer::Skin", "Skin"), + createCluster(4, "Deformer::ClusterA", [0, 1], [0.2, 0.9]), + createCluster(5, "Deformer::ClusterB", [0, 1], [0.8, 0.1]), + createObject("Model", 10, "Model::Root", "Root"), + createObject("Model", 11, "Model::BoneA", "LimbNode"), + createObject("Model", 12, "Model::BoneB", "LimbNode"), + ], + }, + { + name: "Connections", + properties: [], + children: [ + createConnection("OO", 1, 2), + createConnection("OO", 2, 0), + createConnection("OO", 3, 1), + createConnection("OO", 4, 3), + createConnection("OO", 5, 3), + createConnection("OO", 11, 4), + createConnection("OO", 12, 5), + createConnection("OO", 11, 10), + createConnection("OO", 12, 10), + createConnection("OO", 10, 0), + ], + }, + ], + }; +} + +function createObject(name: string, id: number, objectName: string, subType: string, children: FBXNode[] = []): FBXNode { + return { + name, + properties: [ + { type: "int64", value: id }, + { type: "string", value: objectName }, + { type: "string", value: subType }, + ], + children, + }; +} + +function createCluster(id: number, objectName: string, indexes: number[], weights: number[]): FBXNode { + return createObject("Deformer", id, objectName, "Cluster", [ + { name: "Indexes", properties: [{ type: "int32[]", value: new Int32Array(indexes) }], children: [] }, + { name: "Weights", properties: [{ type: "float64[]", value: new Float64Array(weights) }], children: [] }, + { name: "Mode", properties: [{ type: "string", value: "Normalize" }], children: [] }, + { name: "Transform", properties: [{ type: "float64[]", value: identityMatrix() }], children: [] }, + { name: "TransformLink", properties: [{ type: "float64[]", value: identityMatrix() }], children: [] }, + ]); +} + +function identityMatrix(): Float64Array { + return new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); +} + +function createConnection(type: string, child: number, parent: number): FBXNode { + return { + name: "C", + properties: [ + { type: "string", value: type }, + { type: "int64", value: child }, + { type: "int64", value: parent }, + ], + children: [], + }; +} diff --git a/packages/dev/loaders/test/unit/FBX/materials.test.ts b/packages/dev/loaders/test/unit/FBX/materials.test.ts index 7c52003f81a..2d24afddd34 100644 --- a/packages/dev/loaders/test/unit/FBX/materials.test.ts +++ b/packages/dev/loaders/test/unit/FBX/materials.test.ts @@ -180,7 +180,7 @@ describe("FBX material texture loading", () => { propertyName: "Bump", fileName: "normal.png", relativeFileName: "textures\\normal.png", - id: 2n, + id: 2, embeddedData: new Uint8Array([137, 80, 78, 71]), }, scene, @@ -211,7 +211,7 @@ describe("FBX material texture loading", () => { propertyName: "DiffuseColor", fileName: "C:/authored/location/textures/diffuse.png", relativeFileName: "textures\\diffuse.png", - id: 2n, + id: 2, embeddedData: null, }, scene, diff --git a/packages/dev/loaders/test/unit/FBX/parsers/fbxAsciiParser.test.ts b/packages/dev/loaders/test/unit/FBX/parsers/fbxAsciiParser.test.ts new file mode 100644 index 00000000000..d1899d9392a --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/parsers/fbxAsciiParser.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { parseAsciiFBX } from "loaders/FBX/parsers/fbxAsciiParser"; + +describe("parseAsciiFBX", () => { + it("parses shorthand arrays with the declared value count", () => { + const doc = parseAsciiFBX(`; FBX 7.4.0 project file +Vertices: *3 { + a: 1,2,3 +}`); + + expect(doc.version).toBe(7400); + expect(doc.nodes[0].properties[0].value).toEqual(new Float64Array([1, 2, 3])); + }); + + it("rejects shorthand arrays with fewer values than declared", () => { + expect(() => + parseAsciiFBX(`; FBX 7.4.0 project file +Vertices: *3 { + a: 1,2 +}`) + ).toThrow("ASCII FBX array declared 3 values but parsed 2"); + }); + + it("rejects shorthand arrays with more values than declared", () => { + expect(() => + parseAsciiFBX(`; FBX 7.4.0 project file +Vertices: *2 { + a: 1,2,3 +}`) + ).toThrow("ASCII FBX array declared 2 values but parsed 3"); + }); + + it("parses int64 object identifiers as number values", () => { + const doc = parseAsciiFBX(`; FBX 7.4.0 project file +Model: 3000000000, "Model::Root", "Null" { +}`); + + expect(doc.nodes[0].properties[0]).toEqual({ type: "int64", value: 3000000000 }); + }); +}); diff --git a/packages/dev/loaders/test/unit/FBX/parsers/fbxBinaryParser.test.ts b/packages/dev/loaders/test/unit/FBX/parsers/fbxBinaryParser.test.ts new file mode 100644 index 00000000000..c9baed272c9 --- /dev/null +++ b/packages/dev/loaders/test/unit/FBX/parsers/fbxBinaryParser.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { parseBinaryFBX } from "loaders/FBX/parsers/fbxBinaryParser"; + +const HEADER_SIZE = 27; +const NODE_HEADER_SIZE = 13; +const MAGIC = "Kaydara FBX Binary \0"; + +describe("parseBinaryFBX", () => { + it("parses a minimal binary node", () => { + const nodeOffset = HEADER_SIZE; + const nodeEnd = nodeOffset + NODE_HEADER_SIZE + 4; + const buffer = createBinaryFBX(nodeEnd + NODE_HEADER_SIZE); + writeNodeHeader(buffer, nodeOffset, nodeEnd, 0, 0, "Test"); + + const doc = parseBinaryFBX(buffer.buffer); + + expect(doc.version).toBe(7400); + expect(doc.nodes).toEqual([{ name: "Test", properties: [], children: [] }]); + }); + + it("rejects child nodes whose end offset escapes the parent node", () => { + const parentOffset = HEADER_SIZE; + const childOffset = parentOffset + NODE_HEADER_SIZE + 6; + const childEnd = childOffset + NODE_HEADER_SIZE + 5; + const parentEnd = childEnd; + const buffer = createBinaryFBX(parentEnd + NODE_HEADER_SIZE); + writeNodeHeader(buffer, parentOffset, parentEnd, 0, 0, "Parent"); + writeNodeHeader(buffer, childOffset, parentEnd + 1, 0, 0, "Child"); + + expect(() => parseBinaryFBX(buffer.buffer)).toThrow(`Invalid FBX node end offset ${parentEnd + 1} at offset ${childOffset}`); + }); + + it("rejects raw arrays whose payload length does not match the declared array length", () => { + const nodeOffset = HEADER_SIZE; + const propertyLength = 1 + 12 + 4; + const nodeEnd = nodeOffset + NODE_HEADER_SIZE + 4 + propertyLength; + const buffer = createBinaryFBX(nodeEnd + NODE_HEADER_SIZE); + writeNodeHeader(buffer, nodeOffset, nodeEnd, 1, propertyLength, "Ints"); + let offset = nodeOffset + NODE_HEADER_SIZE + 4; + buffer[offset++] = "i".charCodeAt(0); + writeUint32(buffer, offset, 2); + writeUint32(buffer, offset + 4, 0); + writeUint32(buffer, offset + 8, 4); + + expect(() => parseBinaryFBX(buffer.buffer)).toThrow("Invalid FBX array byte length for int32[]"); + }); +}); + +function createBinaryFBX(byteLength: number): Uint8Array { + const bytes = new Uint8Array(byteLength); + for (let i = 0; i < MAGIC.length; i++) { + bytes[i] = MAGIC.charCodeAt(i); + } + writeUint32(bytes, 23, 7400); + return bytes; +} + +function writeNodeHeader(bytes: Uint8Array, offset: number, endOffset: number, propertyCount: number, propertyListLength: number, name: string): void { + writeUint32(bytes, offset, endOffset); + writeUint32(bytes, offset + 4, propertyCount); + writeUint32(bytes, offset + 8, propertyListLength); + bytes[offset + 12] = name.length; + for (let i = 0; i < name.length; i++) { + bytes[offset + NODE_HEADER_SIZE + i] = name.charCodeAt(i); + } +} + +function writeUint32(bytes: Uint8Array, offset: number, value: number): void { + bytes[offset] = value & 0xff; + bytes[offset + 1] = (value >> 8) & 0xff; + bytes[offset + 2] = (value >> 16) & 0xff; + bytes[offset + 3] = (value >> 24) & 0xff; +} From 40b8c8559b23af5649a92f7cd5239af41ac4dc2d Mon Sep 17 00:00:00 2001 From: David Catuhe Date: Wed, 20 May 2026 08:58:48 -0700 Subject: [PATCH 3/3] Fix FBX loader TypeDoc comments Document SceneLoader callback parameters for FBX public loader methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/dev/loaders/src/FBX/fbxFileLoader.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/dev/loaders/src/FBX/fbxFileLoader.ts b/packages/dev/loaders/src/FBX/fbxFileLoader.ts index 1856022593a..d486413212b 100644 --- a/packages/dev/loaders/src/FBX/fbxFileLoader.ts +++ b/packages/dev/loaders/src/FBX/fbxFileLoader.ts @@ -118,6 +118,16 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi return new FBXFileLoader(options[FBXFileLoaderMetadata.name]); } + /** + * Imports meshes from an FBX file and adds them to the scene. + * @param meshesNames - A string or array of mesh names to import, or null/undefined to import all meshes + * @param scene - The scene to add imported meshes to + * @param data - The FBX data to load + * @param rootUrl - Root URL used to resolve external resources + * @param _onProgress - Callback called while the file is loading + * @param _fileName - Name of the file being loaded + * @returns A promise containing the loaded meshes, particle systems, skeletons, animation groups, transform nodes, geometries, and lights + */ public async importMeshAsync( meshesNames: string | readonly string[] | null | undefined, scene: Scene, @@ -131,12 +141,30 @@ export class FBXFileLoader implements ISceneLoaderPluginAsync, ISceneLoaderPlugi return this._buildScene(fbxScene, scene, rootUrl, meshesNames); } + /** + * Loads all FBX content into the scene. + * @param scene - The scene to load the FBX content into + * @param data - The FBX data to load + * @param rootUrl - Root URL used to resolve external resources + * @param _onProgress - Callback called while the file is loading + * @param _fileName - Name of the file being loaded + * @returns A promise that resolves when loading is complete + */ public async loadAsync(scene: Scene, data: unknown, rootUrl: string, _onProgress?: (event: ISceneLoaderProgressEvent) => void, _fileName?: string): Promise { const doc = this._parse(data); const fbxScene = interpretFBX(doc); this._buildScene(fbxScene, scene, rootUrl, null); } + /** + * Loads all FBX content into an asset container. + * @param scene - The scene used to create the asset container + * @param data - The FBX data to load + * @param rootUrl - Root URL used to resolve external resources + * @param _onProgress - Callback called while the file is loading + * @param _fileName - Name of the file being loaded + * @returns A promise containing the loaded asset container + */ public async loadAssetContainerAsync( scene: Scene, data: unknown,