"""FBX skeletal meshes and animation clips, including exports from Blender.""" from dataclasses import dataclass from pathlib import Path import unreal from .paths import GENERATED, PROJECT, slug from .characters import _clips @dataclass class SkeletalImport: mesh: unreal.SkeletalMesh animations: dict[str, unreal.AnimSequence] def _source(filename): source = Path(filename).expanduser() source = (source if source.is_absolute() else PROJECT / source).resolve() if not source.is_file() or source.suffix.lower() != ".fbx": raise ValueError(f"Wymagany istniejacy FBX ze szkieletem: {source}") return source def _cached(destination, name, skeleton=None): asset = unreal.load_asset(destination + "/" + name) expected = unreal.SkeletalMesh if skeleton is None else unreal.AnimSequence if not isinstance(asset, expected): return None objects = [asset] if skeleton is None: for path in unreal.EditorAssetLibrary.list_assets(destination, recursive=False, include_folder=False): clip = unreal.load_asset(path) if isinstance(clip, unreal.AnimSequence): objects.append(clip) elif asset.get_editor_property("skeleton") != skeleton: return None return objects def _import(source, destination, name, skeleton=None, reimport=False): if not reimport: objects = _cached(destination, name, skeleton) if objects is not None: return objects options = unreal.FbxImportUI() options.set_editor_property("automated_import_should_detect_type", False) options.set_editor_property("import_as_skeletal", True) options.set_editor_property("import_mesh", skeleton is None) options.set_editor_property("import_animations", True) options.set_editor_property("create_physics_asset", False) options.set_editor_property("import_materials", skeleton is None) options.set_editor_property("import_textures", skeleton is None) options.set_editor_property("override_animation_name", name + "_Anim" if skeleton is None else name) options.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_SKELETAL_MESH if skeleton is None else unreal.FBXImportType.FBXIT_ANIMATION) if skeleton is not None: options.set_editor_property("skeleton", skeleton) options.anim_sequence_import_data.set_editor_property( "animation_length", unreal.FBXAnimationLengthImportType.FBXALIT_EXPORTED_TIME) options.anim_sequence_import_data.set_editor_property("snap_to_closest_frame_boundary", True) task = unreal.AssetImportTask() for key, value in {"filename": str(source), "destination_path": destination, "destination_name": name, "automated": True, "replace_existing": True, "replace_existing_settings": True, "save": True, "async_": False, "options": options, "factory": unreal.FbxFactory()}.items(): task.set_editor_property(key, value) # UE 5.7 otherwise routes FBX reimports through Interchange, which does not # consistently honor FbxImportUI (notably the target animation skeleton). flag = "Interchange.FeatureFlags.Import.FBX" previous = unreal.SystemLibrary.get_console_variable_int_value(flag) unreal.SystemLibrary.execute_console_command(None, flag + " 0") try: unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) objects = list(task.get_objects()) # Saving the mesh alone can leave its new skeleton/materials unsaved. unreal.EditorAssetLibrary.save_directory(destination, only_if_is_dirty=True, recursive=True) # Legacy FBX reports the primary mesh only; embedded animation assets # are registered alongside it rather than included in task.get_objects(). paths = {obj.get_path_name() for obj in objects} embedded = (unreal.EditorAssetLibrary.list_assets(destination, recursive=False, include_folder=False) if skeleton is None else []) for path in embedded: obj = unreal.load_asset(path) if isinstance(obj, unreal.AnimSequence) and obj.get_path_name() not in paths: objects.append(obj) return objects finally: unreal.SystemLibrary.execute_console_command(None, f"{flag} {previous}") def import_animation(filename, skeleton, name="Clip", destination=None, reimport=False): """Import a clip for this exact skeleton; no automatic retargeting.""" source = _source(filename) name = slug(name) objects = _import(source, destination or f"{GENERATED}/Skeletal/{name}/Animations", name, skeleton, reimport=reimport) clips = [obj for obj in objects if isinstance(obj, unreal.AnimSequence)] if len(clips) != 1: raise RuntimeError(f"{source.name}: oczekiwano jednej animacji, otrzymano {len(clips)}.") if clips[0].get_editor_property("skeleton") != skeleton: raise ValueError("Animacja ma inny szkielet. Uzyj animacji dla tej samej postaci z Mixamo.") return clips[0] def import_model(filename, name="Character", animations=None, collection="Character", reimport=False): """Import an FBX/.blend and discover animation FBXs beside the source model. animations=None loads all neighboring clips using names derived from filenames. Pass a dictionary to select files explicitly; {} skips neighboring clips. """ source = Path(filename).expanduser() source = (source if source.is_absolute() else PROJECT / source).resolve() if animations is None: animations = _clips(source) name = slug(name) destination = f"{GENERATED}/Skeletal/{name}" objects = None if reimport else _cached(destination, name) if objects is None: if source.suffix.lower() == ".blend": from .blender import export source = export(source, name=name, collection=collection, skeletal=True) source = _source(source) objects = _import(source, destination, name, reimport=True) meshes = [obj for obj in objects if isinstance(obj, unreal.SkeletalMesh)] if len(meshes) != 1: raise RuntimeError(f"{source.name}: oczekiwano jednego SkeletalMesh, otrzymano {len(meshes)}. " "Eksportuj jedna postac ze szkieletem i wagami (Mixamo: With Skin).") clips = {obj.get_name(): obj for obj in objects if isinstance(obj, unreal.AnimSequence)} skeleton = meshes[0].get_editor_property("skeleton") for alias, filename in (animations or {}).items(): clips[alias] = import_animation(filename, skeleton, name=slug(alias), destination=destination + "/Animations", reimport=reimport) return SkeletalImport(meshes[0], clips)