Files
PythonInUnreal/Content/Python/ue_workshop/skeletal.py
T
2026-10-31 20:16:32 +01:00

135 lines
6.7 KiB
Python

"""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)