139 lines
6.9 KiB
Python
139 lines
6.9 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
|
|
from .animation_catalog import update_catalog
|
|
|
|
|
|
@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()
|
|
discover_all = animations is None
|
|
if discover_all:
|
|
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)
|
|
update_catalog(name, {key: clip.get_play_length() for key, clip in clips.items()},
|
|
replace=discover_all)
|
|
return SkeletalImport(meshes[0], clips)
|