From 09c9f66e4c79c1d7ed4b5185c912647be8e0825e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 19 Feb 2020 19:44:05 +0100 Subject: [PATCH 01/10] basic hook system --- pype/ftrack/lib/ftrack_app_handler.py | 5 +++++ pype/lib.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/pype/ftrack/lib/ftrack_app_handler.py b/pype/ftrack/lib/ftrack_app_handler.py index 9dc735987d..ea769ad167 100644 --- a/pype/ftrack/lib/ftrack_app_handler.py +++ b/pype/ftrack/lib/ftrack_app_handler.py @@ -273,6 +273,11 @@ class AppAction(BaseHandler): # Full path to executable launcher execfile = None + if application.get("launch_hook"): + hook = application.get("launch_hook") + self.log.info("launching hook: {}".format(hook)) + pypelib.execute_hook(application.get("launch_hook")) + if sys.platform == "win32": for ext in os.environ["PATHEXT"].split(os.pathsep): diff --git a/pype/lib.py b/pype/lib.py index 2235efa2f4..73bc16e97a 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -6,6 +6,8 @@ import contextlib import subprocess import inspect +import six + from avalon import io import avalon.api import avalon @@ -585,3 +587,21 @@ class CustomNone: def __repr__(self): """Representation of custom None.""" return "".format(str(self.identifier)) + + +def execute_hook(hook, **kwargs): + class_name = hook.split("/")[-1] + + abspath = os.path.join(os.getenv('PYPE_ROOT'), + 'repos', 'pype', **hook.split("/")[:-1]) + + try: + with open(abspath) as f: + six.exec_(f.read()) + + except Exception as exp: + log.exception("loading hook failed: {}".format(exp), + exc_info=True) + + hook_obj = globals()[class_name]() + hook_obj.execute(**kwargs) From 69c396ec3d3c3a3f5dbbfb9678e60e876bdc5a0e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 20 Feb 2020 17:35:38 +0100 Subject: [PATCH 02/10] polishing hooks --- pype/ftrack/lib/ftrack_app_handler.py | 8 ---- pype/hooks/unreal/unreal_prelaunch.py | 8 ++++ pype/lib.py | 54 +++++++++++++++++++++----- res/app_icons/ue4.png | Bin 0 -> 46503 bytes 4 files changed, 53 insertions(+), 17 deletions(-) create mode 100644 pype/hooks/unreal/unreal_prelaunch.py create mode 100644 res/app_icons/ue4.png diff --git a/pype/ftrack/lib/ftrack_app_handler.py b/pype/ftrack/lib/ftrack_app_handler.py index ea769ad167..825a0a1985 100644 --- a/pype/ftrack/lib/ftrack_app_handler.py +++ b/pype/ftrack/lib/ftrack_app_handler.py @@ -256,14 +256,6 @@ class AppAction(BaseHandler): env = acre.merge(env, current_env=dict(os.environ)) env = acre.append(dict(os.environ), env) - - # - # tools_env = acre.get_tools(tools) - # env = acre.compute(dict(tools_env)) - # env = acre.merge(env, dict(os.environ)) - # os.environ = acre.append(dict(os.environ), env) - # os.environ = acre.compute(os.environ) - # Get path to execute st_temp_path = os.environ['PYPE_CONFIG'] os_plat = platform.system().lower() diff --git a/pype/hooks/unreal/unreal_prelaunch.py b/pype/hooks/unreal/unreal_prelaunch.py new file mode 100644 index 0000000000..05d95a0b2a --- /dev/null +++ b/pype/hooks/unreal/unreal_prelaunch.py @@ -0,0 +1,8 @@ +from pype.lib import PypeHook + + +class UnrealPrelaunch(PypeHook): + + def execute(**kwargs): + print("I am inside!!!") + pass diff --git a/pype/lib.py b/pype/lib.py index 73bc16e97a..d1062e468f 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1,10 +1,13 @@ import os +import sys +import types import re import logging import itertools import contextlib import subprocess import inspect +from abc import ABCMeta, abstractmethod import six @@ -123,7 +126,8 @@ def modified_environ(*remove, **update): is sure to work in all situations. :param remove: Environment variables to remove. - :param update: Dictionary of environment variables and values to add/update. + :param update: Dictionary of environment variables + and values to add/update. """ env = os.environ update = update or {} @@ -347,8 +351,8 @@ def switch_item(container, "parent": version["_id"]} ) - assert representation, ("Could not find representation in the database with" - " the name '%s'" % representation_name) + assert representation, ("Could not find representation in the database " + "with the name '%s'" % representation_name) avalon.api.switch(container, representation) @@ -481,7 +485,9 @@ def get_subsets(asset_name, """ Query subsets with filter on name. - The method will return all found subsets and its defined version and subsets. Version could be specified with number. Representation can be filtered. + The method will return all found subsets and its defined version + and subsets. Version could be specified with number. Representation + can be filtered. Arguments: asset_name (str): asset (shot) name @@ -498,8 +504,8 @@ def get_subsets(asset_name, asset_io = io.find_one({"type": "asset", "name": asset_name}) # check if anything returned - assert asset_io, "Asset not existing. \ - Check correct name: `{}`".format(asset_name) + assert asset_io, ( + "Asset not existing. Check correct name: `{}`").format(asset_name) # create subsets query filter filter_query = {"type": "subset", "parent": asset_io["_id"]} @@ -513,7 +519,9 @@ def get_subsets(asset_name, # query all assets subsets = [s for s in io.find(filter_query)] - assert subsets, "No subsets found. Check correct filter. Try this for start `r'.*'`: asset: `{}`".format(asset_name) + assert subsets, ("No subsets found. Check correct filter. " + "Try this for start `r'.*'`: " + "asset: `{}`").format(asset_name) output_dict = {} # Process subsets @@ -593,15 +601,43 @@ def execute_hook(hook, **kwargs): class_name = hook.split("/")[-1] abspath = os.path.join(os.getenv('PYPE_ROOT'), - 'repos', 'pype', **hook.split("/")[:-1]) + 'repos', 'pype', *hook.split("/")[:-1]) + + mod_name, mod_ext = os.path.splitext(os.path.basename(abspath)) + + if not mod_ext == ".py": + return + + module = types.ModuleType(mod_name) + module.__file__ = abspath + + log.info("-" * 80) + print(module) try: with open(abspath) as f: - six.exec_(f.read()) + six.exec_(f.read(), module.__dict__) + + sys.modules[abspath] = module except Exception as exp: log.exception("loading hook failed: {}".format(exp), exc_info=True) + from pprint import pprint + print("-" * 80) + pprint(dir(module)) + hook_obj = globals()[class_name]() hook_obj.execute(**kwargs) + + +@six.add_metaclass(ABCMeta) +class PypeHook: + + def __init__(self): + pass + + @abstractmethod + def execute(**kwargs): + pass diff --git a/res/app_icons/ue4.png b/res/app_icons/ue4.png new file mode 100644 index 0000000000000000000000000000000000000000..39201de6643767b8b24c821316b910e2bd41b1ba GIT binary patch literal 46503 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
>Di8y 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

r@KoO^&9yCj+{ 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 opy3f*F#kPbd<=|71&O Date: Fri, 21 Feb 2020 11:36:33 +0100 Subject: [PATCH 03/10] unreal project creation and launching --- pype/ftrack/lib/ftrack_app_handler.py | 9 +- pype/hooks/unreal/unreal_prelaunch.py | 80 ++++++- pype/lib.py | 33 ++- pype/unreal/__init__.py | 0 pype/unreal/lib.py | 305 ++++++++++++++++++++++++++ 5 files changed, 411 insertions(+), 16 deletions(-) create mode 100644 pype/unreal/__init__.py create mode 100644 pype/unreal/lib.py diff --git a/pype/ftrack/lib/ftrack_app_handler.py b/pype/ftrack/lib/ftrack_app_handler.py index 825a0a1985..5dd33c1492 100644 --- a/pype/ftrack/lib/ftrack_app_handler.py +++ b/pype/ftrack/lib/ftrack_app_handler.py @@ -268,7 +268,14 @@ class AppAction(BaseHandler): if application.get("launch_hook"): hook = application.get("launch_hook") self.log.info("launching hook: {}".format(hook)) - pypelib.execute_hook(application.get("launch_hook")) + ret_val = pypelib.execute_hook( + application.get("launch_hook"), env=env) + if not ret_val: + return { + 'success': False, + 'message': "Hook didn't finish successfully {0}" + .format(self.label) + } if sys.platform == "win32": diff --git a/pype/hooks/unreal/unreal_prelaunch.py b/pype/hooks/unreal/unreal_prelaunch.py index 05d95a0b2a..83ba4bf8aa 100644 --- a/pype/hooks/unreal/unreal_prelaunch.py +++ b/pype/hooks/unreal/unreal_prelaunch.py @@ -1,8 +1,82 @@ +import logging +import os + from pype.lib import PypeHook +from pype.unreal import lib as unreal_lib +from pypeapp import Logger + +log = logging.getLogger(__name__) class UnrealPrelaunch(PypeHook): + """ + This hook will check if current workfile path has Unreal + project inside. IF not, it initialize it and finally it pass + path to the project by environment variable to Unreal launcher + shell script. + """ - def execute(**kwargs): - print("I am inside!!!") - pass + def __init__(self, logger=None): + if not logger: + self.log = Logger().get_logger(self.__class__.__name__) + else: + self.log = logger + + self.signature = "( {} )".format(self.__class__.__name__) + + def execute(self, *args, env: dict = None) -> bool: + if not env: + env = os.environ + asset = env["AVALON_ASSET"] + task = env["AVALON_TASK"] + workdir = env["AVALON_WORKDIR"] + engine_version = env["AVALON_APP_NAME"].split("_")[-1] + project_name = f"{asset}_{task}" + + # Unreal is sensitive about project names longer then 20 chars + if len(project_name) > 20: + self.log.warning((f"Project name exceed 20 characters " + f"[ {project_name} ]!")) + + # Unreal doesn't accept non alphabet characters at the start + # of the project name. This is because project name is then used + # in various places inside c++ code and there variable names cannot + # start with non-alpha. We append 'P' before project name to solve it. + # :scream: + if not project_name[:1].isalpha(): + self.log.warning(f"Project name doesn't start with alphabet " + f"character ({project_name}). Appending 'P'") + project_name = f"P{project_name}" + + project_path = os.path.join(workdir, project_name) + + self.log.info((f"{self.signature} requested UE4 version: " + f"[ {engine_version} ]")) + + detected = unreal_lib.get_engine_versions() + detected_str = ', '.join(detected.keys()) or 'none' + self.log.info((f"{self.signature} detected UE4 versions: " + f"[ {detected_str} ]")) + del(detected_str) + engine_version = ".".join(engine_version.split(".")[:2]) + if engine_version not in detected.keys(): + self.log.error((f"{self.signature} requested version not " + f"detected [ {engine_version} ]")) + return False + + os.makedirs(project_path, exist_ok=True) + + project_file = os.path.join(project_path, f"{project_name}.uproject") + if not os.path.isfile(project_file): + self.log.info((f"{self.signature} creating unreal " + f"project [ {project_name} ]")) + if env.get("AVALON_UNREAL_PLUGIN"): + os.environ["AVALON_UNREAL_PLUGIN"] = env.get("AVALON_UNREAL_PLUGIN") # noqa: E501 + unreal_lib.create_unreal_project(project_name, + engine_version, project_path) + + self.log.info((f"{self.signature} preparing unreal project ... ")) + unreal_lib.prepare_project(project_file, detected[engine_version]) + + env["PYPE_UNREAL_PROJECT_FILE"] = project_file + return True diff --git a/pype/lib.py b/pype/lib.py index d1062e468f..87c206e758 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -597,7 +597,20 @@ class CustomNone: return "".format(str(self.identifier)) -def execute_hook(hook, **kwargs): +def execute_hook(hook, *args, **kwargs): + """ + This will load hook file, instantiate class and call `execute` method + on it. Hook must be in a form: + + `$PYPE_ROOT/repos/pype/path/to/hook.py/HookClass` + + This will load `hook.py`, instantiate HookClass and then execute_hook + `execute(*args, **kwargs)` + + :param hook: path to hook class + :type hook: str + """ + class_name = hook.split("/")[-1] abspath = os.path.join(os.getenv('PYPE_ROOT'), @@ -606,14 +619,11 @@ def execute_hook(hook, **kwargs): mod_name, mod_ext = os.path.splitext(os.path.basename(abspath)) if not mod_ext == ".py": - return + return False module = types.ModuleType(mod_name) module.__file__ = abspath - log.info("-" * 80) - print(module) - try: with open(abspath) as f: six.exec_(f.read(), module.__dict__) @@ -623,13 +633,12 @@ def execute_hook(hook, **kwargs): except Exception as exp: log.exception("loading hook failed: {}".format(exp), exc_info=True) + return False - from pprint import pprint - print("-" * 80) - pprint(dir(module)) - - hook_obj = globals()[class_name]() - hook_obj.execute(**kwargs) + obj = getattr(module, class_name) + hook_obj = obj() + ret_val = hook_obj.execute(*args, **kwargs) + return ret_val @six.add_metaclass(ABCMeta) @@ -639,5 +648,5 @@ class PypeHook: pass @abstractmethod - def execute(**kwargs): + def execute(self, *args, **kwargs): pass diff --git a/pype/unreal/__init__.py b/pype/unreal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pype/unreal/lib.py b/pype/unreal/lib.py new file mode 100644 index 0000000000..8217e834a3 --- /dev/null +++ b/pype/unreal/lib.py @@ -0,0 +1,305 @@ +import os +import platform +import json +from distutils import dir_util +import subprocess + + +def get_engine_versions(): + """ + This will try to detect location and versions of installed Unreal Engine. + Location can be overridden by `UNREAL_ENGINE_LOCATION` environment + variable. + + Returns dictionary with version as a key and dir as value. + """ + try: + engine_locations = {} + root, dirs, files = next(os.walk(os.environ["UNREAL_ENGINE_LOCATION"])) + + for dir in dirs: + if dir.startswith("UE_"): + ver = dir.split("_")[1] + engine_locations[ver] = os.path.join(root, dir) + except KeyError: + # environment variable not set + pass + except OSError: + # specified directory doesn't exists + pass + + # if we've got something, terminate autodetection process + if engine_locations: + return engine_locations + + # else kick in platform specific detection + if platform.system().lower() == "windows": + return _win_get_engine_versions() + elif platform.system().lower() == "linux": + # on linux, there is no installation and getting Unreal Engine involves + # git clone. So we'll probably depend on `UNREAL_ENGINE_LOCATION`. + pass + elif platform.system().lower() == "darwin": + return _darwin_get_engine_version() + + return {} + + +def _win_get_engine_versions(): + """ + If engines are installed via Epic Games Launcher then there is: + `%PROGRAMDATA%/Epic/UnrealEngineLauncher/LauncherInstalled.dat` + This file is JSON file listing installed stuff, Unreal engines + are marked with `"AppName" = "UE_X.XX"`` like `UE_4.24` + """ + install_json_path = os.path.join( + os.environ.get("PROGRAMDATA"), + "Epic", + "UnrealEngineLauncher", + "LauncherInstalled.dat", + ) + + return _parse_launcher_locations(install_json_path) + + +def _darwin_get_engine_version(): + """ + It works the same as on Windows, just JSON file location is different. + """ + install_json_path = os.path.join( + os.environ.get("HOME"), + "Library", + "Application Support", + "Epic", + "UnrealEngineLauncher", + "LauncherInstalled.dat", + ) + + return _parse_launcher_locations(install_json_path) + + +def _parse_launcher_locations(install_json_path): + engine_locations = {} + if os.path.isfile(install_json_path): + with open(install_json_path, "r") as ilf: + try: + install_data = json.load(ilf) + except json.JSONDecodeError: + raise Exception( + "Invalid `LauncherInstalled.dat file. `" + "Cannot determine Unreal Engine location." + ) + + for installation in install_data.get("InstallationList", []): + if installation.get("AppName").startswith("UE_"): + ver = installation.get("AppName").split("_")[1] + engine_locations[ver] = installation.get("InstallLocation") + + return engine_locations + + +def create_unreal_project(project_name, ue_version, dir): + """ + This will create `.uproject` file at specified location. As there is no + way I know to create project via command line, this is easiest option. + Unreal project file is basically JSON file. If we find + `AVALON_UNREAL_PLUGIN` environment variable we assume this is location + of Avalon Integration Plugin and we copy its content to project folder + and enable this plugin. + """ + + if os.path.isdir(os.environ.get("AVALON_UNREAL_PLUGIN", "")): + # copy plugin to correct path under project + plugin_path = os.path.join(dir, "Plugins", "Avalon") + if not os.path.isdir(plugin_path): + os.makedirs(plugin_path, exist_ok=True) + dir_util._path_created = {} + dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), + plugin_path) + + data = { + "FileVersion": 3, + "EngineAssociation": ue_version, + "Category": "", + "Description": "", + "Modules": [ + { + "Name": project_name, + "Type": "Runtime", + "LoadingPhase": "Default", + "AdditionalDependencies": ["Engine"], + } + ], + "Plugins": [ + {"Name": "PythonScriptPlugin", "Enabled": True}, + {"Name": "EditorScriptingUtilities", "Enabled": True}, + {"Name": "Avalon", "Enabled": True}, + ], + } + + project_file = os.path.join(dir, "{}.uproject".format(project_name)) + with open(project_file, mode="w") as pf: + json.dump(data, pf, indent=4) + + +def prepare_project(project_file: str, engine_path: str): + """ + This function will add source files needed for project to be + rebuild along with the avalon integration plugin. + + There seems not to be automated way to do it from command line. + But there might be way to create at least those target and build files + by some generator. This needs more research as manually writing + those files is rather hackish. :skull_and_crossbones: + + :param project_file: path to .uproject file + :type project_file: str + :param engine_path: path to unreal engine associated with project + :type engine_path: str + """ + + project_name = os.path.splitext(os.path.basename(project_file))[0] + project_dir = os.path.dirname(project_file) + targets_dir = os.path.join(project_dir, "Source") + sources_dir = os.path.join(targets_dir, project_name) + + os.makedirs(sources_dir, exist_ok=True) + os.makedirs(os.path.join(project_dir, "Content"), exist_ok=True) + + module_target = ''' +using UnrealBuildTool; +using System.Collections.Generic; + +public class {0}Target : TargetRules +{{ + public {0}Target( TargetInfo Target) : base(Target) + {{ + Type = TargetType.Game; + ExtraModuleNames.AddRange( new string[] {{ "{0}" }} ); + }} +}} +'''.format(project_name) + + editor_module_target = ''' +using UnrealBuildTool; +using System.Collections.Generic; + +public class {0}EditorTarget : TargetRules +{{ + public {0}EditorTarget( TargetInfo Target) : base(Target) + {{ + Type = TargetType.Editor; + + ExtraModuleNames.AddRange( new string[] {{ "{0}" }} ); + }} +}} +'''.format(project_name) + + module_build = ''' +using UnrealBuildTool; +public class {0} : ModuleRules +{{ + public {0}(ReadOnlyTargetRules Target) : base(Target) + {{ + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + PublicDependencyModuleNames.AddRange(new string[] {{ "Core", + "CoreUObject", "Engine", "InputCore" }}); + PrivateDependencyModuleNames.AddRange(new string[] {{ }}); + }} +}} +'''.format(project_name) + + module_cpp = ''' +#include "{0}.h" +#include "Modules/ModuleManager.h" + +IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, {0}, "{0}" ); +'''.format(project_name) + + module_header = ''' +#pragma once +#include "CoreMinimal.h" +''' + + game_mode_cpp = ''' +#include "{0}GameModeBase.h" +'''.format(project_name) + + game_mode_h = ''' +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/GameModeBase.h" +#include "{0}GameModeBase.generated.h" + +UCLASS() +class {1}_API A{0}GameModeBase : public AGameModeBase +{{ + GENERATED_BODY() +}}; +'''.format(project_name, project_name.upper()) + + with open(os.path.join( + targets_dir, f"{project_name}.Target.cs"), mode="w") as f: + f.write(module_target) + + with open(os.path.join( + targets_dir, f"{project_name}Editor.Target.cs"), mode="w") as f: + f.write(editor_module_target) + + with open(os.path.join( + sources_dir, f"{project_name}.Build.cs"), mode="w") as f: + f.write(module_build) + + with open(os.path.join( + sources_dir, f"{project_name}.cpp"), mode="w") as f: + f.write(module_cpp) + + with open(os.path.join( + sources_dir, f"{project_name}.h"), mode="w") as f: + f.write(module_header) + + with open(os.path.join( + sources_dir, f"{project_name}GameModeBase.cpp"), mode="w") as f: + f.write(game_mode_cpp) + + with open(os.path.join( + sources_dir, f"{project_name}GameModeBase.h"), mode="w") as f: + f.write(game_mode_h) + + if platform.system().lower() == "windows": + u_build_tool = (f"{engine_path}/Engine/Binaries/DotNET/" + "UnrealBuildTool.exe") + u_header_tool = (f"{engine_path}/Engine/Binaries/Win64/" + f"UnrealHeaderTool.exe") + elif platform.system().lower() == "linux": + # WARNING: there is no UnrealBuildTool on linux? + u_build_tool = "" + u_header_tool = "" + elif platform.system().lower() == "darwin": + # WARNING: there is no UnrealBuildTool on Mac? + u_build_tool = "" + u_header_tool = "" + + u_build_tool = u_build_tool.replace("\\", "/") + u_header_tool = u_header_tool.replace("\\", "/") + + command1 = [u_build_tool, "-projectfiles", f"-project={project_file}", + "-progress"] + + subprocess.run(command1) + + command2 = [u_build_tool, f"-ModuleWithSuffix={project_name},3555" + "Win64", "Development", "-TargetType=Editor" + f'-Project="{project_file}"', f'"{project_file}"' + "-IgnoreJunk"] + + subprocess.run(command2) + + uhtmanifest = os.path.join(os.path.dirname(project_file), + f"{project_name}.uhtmanifest") + + command3 = [u_header_tool, f'"{project_file}"', f'"{uhtmanifest}"', + "-Unattended", "-WarningsAsErrors", "-installed"] + + subprocess.run(command3) From 1537b2ba571e0e614277907377e30f303d4b48c4 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 21 Feb 2020 21:06:30 +0100 Subject: [PATCH 04/10] switch to UnrealEnginePython --- pype/hooks/unreal/unreal_prelaunch.py | 1 + pype/unreal/lib.py | 49 ++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/pype/hooks/unreal/unreal_prelaunch.py b/pype/hooks/unreal/unreal_prelaunch.py index 83ba4bf8aa..cb3b6e8e64 100644 --- a/pype/hooks/unreal/unreal_prelaunch.py +++ b/pype/hooks/unreal/unreal_prelaunch.py @@ -79,4 +79,5 @@ class UnrealPrelaunch(PypeHook): unreal_lib.prepare_project(project_file, detected[engine_version]) env["PYPE_UNREAL_PROJECT_FILE"] = project_file + env["AVALON_CURRENT_UNREAL_ENGINE"] = detected[engine_version] return True diff --git a/pype/unreal/lib.py b/pype/unreal/lib.py index 8217e834a3..6130b38764 100644 --- a/pype/unreal/lib.py +++ b/pype/unreal/lib.py @@ -1,3 +1,4 @@ +import sys import os import platform import json @@ -107,15 +108,52 @@ def create_unreal_project(project_name, ue_version, dir): of Avalon Integration Plugin and we copy its content to project folder and enable this plugin. """ + import git if os.path.isdir(os.environ.get("AVALON_UNREAL_PLUGIN", "")): # copy plugin to correct path under project - plugin_path = os.path.join(dir, "Plugins", "Avalon") - if not os.path.isdir(plugin_path): - os.makedirs(plugin_path, exist_ok=True) + plugins_path = os.path.join(dir, "Plugins") + avalon_plugin_path = os.path.join(plugins_path, "Avalon") + if not os.path.isdir(avalon_plugin_path): + os.makedirs(avalon_plugin_path, exist_ok=True) dir_util._path_created = {} dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), - plugin_path) + avalon_plugin_path) + + # If `PYPE_UNREAL_ENGINE_PYTHON_PLUGIN` is set, copy it from there to + # support offline installation. + # Otherwise clone UnrealEnginePython to Plugins directory + # https://github.com/20tab/UnrealEnginePython.git + uep_path = os.path.join(plugins_path, "UnrealEnginePython") + if os.environ.get("PYPE_UNREAL_ENGINE_PYTHON_PLUGIN"): + + os.makedirs(uep_path, exist_ok=True) + dir_util._path_created = {} + dir_util.copy_tree(os.environ.get("PYPE_UNREAL_ENGINE_PYTHON_PLUGIN"), + uep_path) + else: + git.Repo.clone_from("https://github.com/20tab/UnrealEnginePython.git", + uep_path) + + # now we need to fix python path in: + # `UnrealEnginePython.Build.cs` + # to point to our python + with open(os.path.join( + uep_path, "Source", + "UnrealEnginePython", + "UnrealEnginePython.Build.cs"), mode="r") as f: + build_file = f.read() + + fix = build_file.replace( + 'private string pythonHome = "";', + 'private string pythonHome = "{}";'.format( + sys.base_prefix.replace("\\", "/"))) + + with open(os.path.join( + uep_path, "Source", + "UnrealEnginePython", + "UnrealEnginePython.Build.cs"), mode="w") as f: + f.write(fix) data = { "FileVersion": 3, @@ -134,6 +172,7 @@ def create_unreal_project(project_name, ue_version, dir): {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, {"Name": "Avalon", "Enabled": True}, + {"Name": "UnrealEnginePython", "Enabled": True} ], } @@ -296,6 +335,7 @@ class {1}_API A{0}GameModeBase : public AGameModeBase subprocess.run(command2) + """ uhtmanifest = os.path.join(os.path.dirname(project_file), f"{project_name}.uhtmanifest") @@ -303,3 +343,4 @@ class {1}_API A{0}GameModeBase : public AGameModeBase "-Unattended", "-WarningsAsErrors", "-installed"] subprocess.run(command3) + """ From 5a56df384d0cdb4fd3c19bf64841269a26b8c025 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 2 Mar 2020 12:25:15 +0100 Subject: [PATCH 05/10] set cpp project mode on demand --- pype/hooks/unreal/unreal_prelaunch.py | 12 +- pype/unreal/lib.py | 173 ++++++++++++++++++-------- 2 files changed, 127 insertions(+), 58 deletions(-) diff --git a/pype/hooks/unreal/unreal_prelaunch.py b/pype/hooks/unreal/unreal_prelaunch.py index cb3b6e8e64..efb5d9157b 100644 --- a/pype/hooks/unreal/unreal_prelaunch.py +++ b/pype/hooks/unreal/unreal_prelaunch.py @@ -42,7 +42,7 @@ class UnrealPrelaunch(PypeHook): # of the project name. This is because project name is then used # in various places inside c++ code and there variable names cannot # start with non-alpha. We append 'P' before project name to solve it. - # :scream: + # 😱 if not project_name[:1].isalpha(): self.log.warning(f"Project name doesn't start with alphabet " f"character ({project_name}). Appending 'P'") @@ -67,17 +67,17 @@ class UnrealPrelaunch(PypeHook): os.makedirs(project_path, exist_ok=True) project_file = os.path.join(project_path, f"{project_name}.uproject") + engine_path = detected[engine_version] if not os.path.isfile(project_file): self.log.info((f"{self.signature} creating unreal " f"project [ {project_name} ]")) if env.get("AVALON_UNREAL_PLUGIN"): os.environ["AVALON_UNREAL_PLUGIN"] = env.get("AVALON_UNREAL_PLUGIN") # noqa: E501 unreal_lib.create_unreal_project(project_name, - engine_version, project_path) - - self.log.info((f"{self.signature} preparing unreal project ... ")) - unreal_lib.prepare_project(project_file, detected[engine_version]) + engine_version, + project_path, + engine_path=engine_path) env["PYPE_UNREAL_PROJECT_FILE"] = project_file - env["AVALON_CURRENT_UNREAL_ENGINE"] = detected[engine_version] + env["AVALON_CURRENT_UNREAL_ENGINE"] = engine_path return True diff --git a/pype/unreal/lib.py b/pype/unreal/lib.py index 6130b38764..be6314b09b 100644 --- a/pype/unreal/lib.py +++ b/pype/unreal/lib.py @@ -4,6 +4,7 @@ import platform import json from distutils import dir_util import subprocess +from pypeapp import config def get_engine_versions(): @@ -63,7 +64,7 @@ def _win_get_engine_versions(): return _parse_launcher_locations(install_json_path) -def _darwin_get_engine_version(): +def _darwin_get_engine_version() -> dict: """ It works the same as on Windows, just JSON file location is different. """ @@ -79,7 +80,16 @@ def _darwin_get_engine_version(): return _parse_launcher_locations(install_json_path) -def _parse_launcher_locations(install_json_path): +def _parse_launcher_locations(install_json_path: str) -> dict: + """ + This will parse locations from json file. + + :param install_json_path: path to `LauncherInstalled.dat` + :type install_json_path: str + :returns: returns dict with unreal engine versions as keys and + paths to those engine installations as value. + :rtype: dict + """ engine_locations = {} if os.path.isfile(install_json_path): with open(install_json_path, "r") as ilf: @@ -99,7 +109,11 @@ def _parse_launcher_locations(install_json_path): return engine_locations -def create_unreal_project(project_name, ue_version, dir): +def create_unreal_project(project_name: str, + ue_version: str, + pr_dir: str, + engine_path: str, + dev_mode: bool = False) -> None: """ This will create `.uproject` file at specified location. As there is no way I know to create project via command line, this is easiest option. @@ -107,12 +121,30 @@ def create_unreal_project(project_name, ue_version, dir): `AVALON_UNREAL_PLUGIN` environment variable we assume this is location of Avalon Integration Plugin and we copy its content to project folder and enable this plugin. + + :param project_name: project name + :type project_name: str + :param ue_version: unreal engine version (like 4.23) + :type ue_version: str + :param pr_dir: path to directory where project will be created + :type pr_dir: str + :param engine_path: Path to Unreal Engine installation + :type engine_path: str + :param dev_mode: Flag to trigger C++ style Unreal project needing + Visual Studio and other tools to compile plugins from + sources. This will trigger automatically if `Binaries` + directory is not found in plugin folders as this indicates + this is only source distribution of the plugin. Dev mode + is also set by preset file `unreal/project_setup.json` in + **PYPE_CONFIG**. + :type dev_mode: bool + :returns: None """ - import git + preset = config.get_presets()["unreal"]["project_setup"] if os.path.isdir(os.environ.get("AVALON_UNREAL_PLUGIN", "")): # copy plugin to correct path under project - plugins_path = os.path.join(dir, "Plugins") + plugins_path = os.path.join(pr_dir, "Plugins") avalon_plugin_path = os.path.join(plugins_path, "Avalon") if not os.path.isdir(avalon_plugin_path): os.makedirs(avalon_plugin_path, exist_ok=True) @@ -120,68 +152,105 @@ def create_unreal_project(project_name, ue_version, dir): dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), avalon_plugin_path) - # If `PYPE_UNREAL_ENGINE_PYTHON_PLUGIN` is set, copy it from there to - # support offline installation. - # Otherwise clone UnrealEnginePython to Plugins directory - # https://github.com/20tab/UnrealEnginePython.git - uep_path = os.path.join(plugins_path, "UnrealEnginePython") - if os.environ.get("PYPE_UNREAL_ENGINE_PYTHON_PLUGIN"): - - os.makedirs(uep_path, exist_ok=True) - dir_util._path_created = {} - dir_util.copy_tree(os.environ.get("PYPE_UNREAL_ENGINE_PYTHON_PLUGIN"), - uep_path) - else: - git.Repo.clone_from("https://github.com/20tab/UnrealEnginePython.git", - uep_path) - - # now we need to fix python path in: - # `UnrealEnginePython.Build.cs` - # to point to our python - with open(os.path.join( - uep_path, "Source", - "UnrealEnginePython", - "UnrealEnginePython.Build.cs"), mode="r") as f: - build_file = f.read() - - fix = build_file.replace( - 'private string pythonHome = "";', - 'private string pythonHome = "{}";'.format( - sys.base_prefix.replace("\\", "/"))) - - with open(os.path.join( - uep_path, "Source", - "UnrealEnginePython", - "UnrealEnginePython.Build.cs"), mode="w") as f: - f.write(fix) + if (not os.path.isdir(os.path.join(avalon_plugin_path, "Binaries")) + or not os.path.join(avalon_plugin_path, "Intermediate")): + dev_mode = True + # data for project file data = { "FileVersion": 3, "EngineAssociation": ue_version, "Category": "", "Description": "", - "Modules": [ - { + "Plugins": [ + {"Name": "PythonScriptPlugin", "Enabled": True}, + {"Name": "EditorScriptingUtilities", "Enabled": True}, + {"Name": "Avalon", "Enabled": True} + ] + } + + if preset["install_unreal_python_engine"]: + # If `PYPE_UNREAL_ENGINE_PYTHON_PLUGIN` is set, copy it from there to + # support offline installation. + # Otherwise clone UnrealEnginePython to Plugins directory + # https://github.com/20tab/UnrealEnginePython.git + uep_path = os.path.join(plugins_path, "UnrealEnginePython") + if os.environ.get("PYPE_UNREAL_ENGINE_PYTHON_PLUGIN"): + + os.makedirs(uep_path, exist_ok=True) + dir_util._path_created = {} + dir_util.copy_tree( + os.environ.get("PYPE_UNREAL_ENGINE_PYTHON_PLUGIN"), + uep_path) + else: + # WARNING: this will trigger dev_mode, because we need to compile + # this plugin. + dev_mode = True + import git + git.Repo.clone_from( + "https://github.com/20tab/UnrealEnginePython.git", + uep_path) + + data["Plugins"].append( + {"Name": "UnrealEnginePython", "Enabled": True}) + + if (not os.path.isdir(os.path.join(uep_path, "Binaries")) + or not os.path.join(uep_path, "Intermediate")): + dev_mode = True + + if dev_mode or preset["dev_mode"]: + # this will add project module and necessary source file to make it + # C++ project and to (hopefully) make Unreal Editor to compile all + # sources at start + + data["Modules"] = [{ "Name": project_name, "Type": "Runtime", "LoadingPhase": "Default", "AdditionalDependencies": ["Engine"], - } - ], - "Plugins": [ - {"Name": "PythonScriptPlugin", "Enabled": True}, - {"Name": "EditorScriptingUtilities", "Enabled": True}, - {"Name": "Avalon", "Enabled": True}, - {"Name": "UnrealEnginePython", "Enabled": True} - ], - } + }] - project_file = os.path.join(dir, "{}.uproject".format(project_name)) + if preset["install_unreal_python_engine"]: + # now we need to fix python path in: + # `UnrealEnginePython.Build.cs` + # to point to our python + with open(os.path.join( + uep_path, "Source", + "UnrealEnginePython", + "UnrealEnginePython.Build.cs"), mode="r") as f: + build_file = f.read() + + fix = build_file.replace( + 'private string pythonHome = "";', + 'private string pythonHome = "{}";'.format( + sys.base_prefix.replace("\\", "/"))) + + with open(os.path.join( + uep_path, "Source", + "UnrealEnginePython", + "UnrealEnginePython.Build.cs"), mode="w") as f: + f.write(fix) + + # write project file + project_file = os.path.join(pr_dir, "{}.uproject".format(project_name)) with open(project_file, mode="w") as pf: json.dump(data, pf, indent=4) + # ensure we have PySide installed in engine + # TODO: make it work for other platforms 🍎 🐧 + if platform.system().lower() == "windows": + python_path = os.path.join(engine_path, "Engine", "Binaries", + "ThirdParty", "Python", "Win64", + "python.exe") -def prepare_project(project_file: str, engine_path: str): + subprocess.run([python_path, "-m", + "pip", "install", "pyside"]) + + if dev_mode or preset["dev_mode"]: + _prepare_cpp_project(pr_dir, engine_path) + + +def _prepare_cpp_project(project_file: str, engine_path: str) -> None: """ This function will add source files needed for project to be rebuild along with the avalon integration plugin. From 9a8655be1da5a8939a34cf66f71a036b13141fb2 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Sat, 14 Mar 2020 00:41:53 +0100 Subject: [PATCH 06/10] publishing unreal static mesh from maya --- pype/hooks/unreal/unreal_prelaunch.py | 2 +- .../global/publish/collect_scene_version.py | 3 + pype/plugins/global/publish/integrate_new.py | 3 +- .../maya/create/create_unreal_staticmesh.py | 11 ++ .../maya/publish/collect_unreal_staticmesh.py | 33 ++++ pype/plugins/maya/publish/extract_fbx.py | 5 +- .../validate_unreal_mesh_triangulated.py | 33 ++++ .../validate_unreal_staticmesh_naming.py | 120 ++++++++++++++ .../maya/publish/validate_unreal_up_axis.py | 25 +++ pype/plugins/unreal/create/create_fbx.py | 14 ++ .../plugins/unreal/load/load_staticmeshfbx.py | 53 ++++++ .../unreal/publish/collect_instances.py | 152 ++++++++++++++++++ pype/unreal/__init__.py | 45 ++++++ pype/unreal/plugin.py | 9 ++ 14 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 pype/plugins/maya/create/create_unreal_staticmesh.py create mode 100644 pype/plugins/maya/publish/collect_unreal_staticmesh.py create mode 100644 pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py create mode 100644 pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py create mode 100644 pype/plugins/maya/publish/validate_unreal_up_axis.py create mode 100644 pype/plugins/unreal/create/create_fbx.py create mode 100644 pype/plugins/unreal/load/load_staticmeshfbx.py create mode 100644 pype/plugins/unreal/publish/collect_instances.py create mode 100644 pype/unreal/plugin.py diff --git a/pype/hooks/unreal/unreal_prelaunch.py b/pype/hooks/unreal/unreal_prelaunch.py index efb5d9157b..5b6b8e08e0 100644 --- a/pype/hooks/unreal/unreal_prelaunch.py +++ b/pype/hooks/unreal/unreal_prelaunch.py @@ -36,7 +36,7 @@ class UnrealPrelaunch(PypeHook): # Unreal is sensitive about project names longer then 20 chars if len(project_name) > 20: self.log.warning((f"Project name exceed 20 characters " - f"[ {project_name} ]!")) + f"({project_name})!")) # Unreal doesn't accept non alphabet characters at the start # of the project name. This is because project name is then used diff --git a/pype/plugins/global/publish/collect_scene_version.py b/pype/plugins/global/publish/collect_scene_version.py index 02e913199b..314a64f550 100644 --- a/pype/plugins/global/publish/collect_scene_version.py +++ b/pype/plugins/global/publish/collect_scene_version.py @@ -16,6 +16,9 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): if "standalonepublisher" in context.data.get("host", []): return + if "unreal" in context.data.get("host", []): + return + filename = os.path.basename(context.data.get('currentFile')) if '' in filename: diff --git a/pype/plugins/global/publish/integrate_new.py b/pype/plugins/global/publish/integrate_new.py index 1d061af173..8935127e9e 100644 --- a/pype/plugins/global/publish/integrate_new.py +++ b/pype/plugins/global/publish/integrate_new.py @@ -80,7 +80,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "matchmove", "image" "source", - "assembly" + "assembly", + "fbx" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/pype/plugins/maya/create/create_unreal_staticmesh.py b/pype/plugins/maya/create/create_unreal_staticmesh.py new file mode 100644 index 0000000000..5a74cb22d5 --- /dev/null +++ b/pype/plugins/maya/create/create_unreal_staticmesh.py @@ -0,0 +1,11 @@ +import avalon.maya + + +class CreateUnrealStaticMesh(avalon.maya.Creator): + name = "staticMeshMain" + label = "Unreal - Static Mesh" + family = "unrealStaticMesh" + icon = "cube" + + def __init__(self, *args, **kwargs): + super(CreateUnrealStaticMesh, self).__init__(*args, **kwargs) diff --git a/pype/plugins/maya/publish/collect_unreal_staticmesh.py b/pype/plugins/maya/publish/collect_unreal_staticmesh.py new file mode 100644 index 0000000000..5ab9643f4b --- /dev/null +++ b/pype/plugins/maya/publish/collect_unreal_staticmesh.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from maya import cmds +import pyblish.api + + +class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): + """Collect unreal static mesh + + Ensures always only a single frame is extracted (current frame). This + also sets correct FBX options for later extraction. + + Note: + This is a workaround so that the `pype.model` family can use the + same pointcache extractor implementation as animation and pointcaches. + This always enforces the "current" frame to be published. + + """ + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Model Data" + families = ["unrealStaticMesh"] + + def process(self, instance): + # add fbx family to trigger fbx extractor + instance.data["families"].append("fbx") + # set fbx overrides on instance + instance.data["smoothingGroups"] = True + instance.data["smoothMesh"] = True + instance.data["triangulate"] = True + + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame diff --git a/pype/plugins/maya/publish/extract_fbx.py b/pype/plugins/maya/publish/extract_fbx.py index 01b58241c2..6a75bfce0e 100644 --- a/pype/plugins/maya/publish/extract_fbx.py +++ b/pype/plugins/maya/publish/extract_fbx.py @@ -212,12 +212,11 @@ class ExtractFBX(pype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'mov', - 'ext': 'mov', + 'name': 'fbx', + 'ext': 'fbx', 'files': filename, "stagingDir": stagingDir, } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) diff --git a/pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py b/pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py new file mode 100644 index 0000000000..77f7144c4e --- /dev/null +++ b/pype/plugins/maya/publish/validate_unreal_mesh_triangulated.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- + +from maya import cmds +import pyblish.api +import pype.api + + +class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): + """Validate if mesh is made of triangles for Unreal Engine""" + + order = pype.api.ValidateMeshOder + hosts = ["maya"] + families = ["unrealStaticMesh"] + category = "geometry" + label = "Mesh is Triangulated" + actions = [pype.maya.action.SelectInvalidAction] + + @classmethod + def get_invalid(cls, instance): + invalid = [] + meshes = cmds.ls(instance, type="mesh", long=True) + for mesh in meshes: + faces = cmds.polyEvaluate(mesh, f=True) + tris = cmds.polyEvaluate(mesh, t=True) + if faces != tris: + invalid.append(mesh) + + return invalid + + def process(self, instance): + invalid = self.get_invalid(instance) + assert len(invalid) == 0, ( + "Found meshes without triangles") diff --git a/pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py b/pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py new file mode 100644 index 0000000000..b62a855da9 --- /dev/null +++ b/pype/plugins/maya/publish/validate_unreal_staticmesh_naming.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +from maya import cmds +import pyblish.api +import pype.api +import pype.maya.action +import re + + +class ValidateUnrealStaticmeshName(pyblish.api.InstancePlugin): + """Validate name of Unreal Static Mesh + + Unreals naming convention states that staticMesh sould start with `SM` + prefix - SM_[Name]_## (Eg. SM_sube_01). This plugin also validates other + types of meshes - collision meshes: + + UBX_[RenderMeshName]_##: + Boxes are created with the Box objects type in + Max or with the Cube polygonal primitive in Maya. + You cannot move the vertices around or deform it + in any way to make it something other than a + rectangular prism, or else it will not work. + + UCP_[RenderMeshName]_##: + Capsules are created with the Capsule object type. + The capsule does not need to have many segments + (8 is a good number) at all because it is + converted into a true capsule for collision. Like + boxes, you should not move the individual + vertices around. + + USP_[RenderMeshName]_##: + Spheres are created with the Sphere object type. + The sphere does not need to have many segments + (8 is a good number) at all because it is + converted into a true sphere for collision. Like + boxes, you should not move the individual + vertices around. + + UCX_[RenderMeshName]_##: + Convex objects can be any completely closed + convex 3D shape. For example, a box can also be + a convex object + + This validator also checks if collision mesh [RenderMeshName] matches one + of SM_[RenderMeshName]. + + """ + optional = True + order = pype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["unrealStaticMesh"] + label = "Unreal StaticMesh Name" + actions = [pype.maya.action.SelectInvalidAction] + regex_mesh = r"SM_(?P.*)_(\d{2})" + regex_collision = r"((UBX)|(UCP)|(USP)|(UCX))_(?P.*)_(\d{2})" + + @classmethod + def get_invalid(cls, instance): + + # find out if supplied transform is group or not + def is_group(groupName): + try: + children = cmds.listRelatives(groupName, children=True) + for child in children: + if not cmds.ls(child, transforms=True): + return False + return True + except Exception: + return False + + invalid = [] + content_instance = instance.data.get("setMembers", None) + if not content_instance: + cls.log.error("Instance has no nodes!") + return True + pass + descendants = cmds.listRelatives(content_instance, + allDescendents=True, + fullPath=True) or [] + + descendants = cmds.ls(descendants, noIntermediate=True, long=True) + trns = cmds.ls(descendants, long=False, type=('transform')) + + # filter out groups + filter = [node for node in trns if not is_group(node)] + + # compile regex for testing names + sm_r = re.compile(cls.regex_mesh) + cl_r = re.compile(cls.regex_collision) + + sm_names = [] + col_names = [] + for obj in filter: + sm_m = sm_r.match(obj) + if sm_m is None: + # test if it matches collision mesh + cl_r = sm_r.match(obj) + if cl_r is None: + cls.log.error("invalid mesh name on: {}".format(obj)) + invalid.append(obj) + else: + col_names.append((cl_r.group("renderName"), obj)) + else: + sm_names.append(sm_m.group("renderName")) + + for c_mesh in col_names: + if c_mesh[0] not in sm_names: + cls.log.error(("collision name {} doesn't match any " + "static mesh names.").format(obj)) + invalid.append(c_mesh[1]) + + return invalid + + def process(self, instance): + + invalid = self.get_invalid(instance) + + if invalid: + raise RuntimeError("Model naming is invalid. See log.") diff --git a/pype/plugins/maya/publish/validate_unreal_up_axis.py b/pype/plugins/maya/publish/validate_unreal_up_axis.py new file mode 100644 index 0000000000..6641edb4a5 --- /dev/null +++ b/pype/plugins/maya/publish/validate_unreal_up_axis.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from maya import cmds +import pyblish.api +import pype.api + + +class ValidateUnrealUpAxis(pyblish.api.ContextPlugin): + """Validate if Z is set as up axis in Maya""" + + optional = True + order = pype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["unrealStaticMesh"] + label = "Unreal Up-Axis check" + actions = [pype.api.RepairAction] + + def process(self, context): + assert cmds.upAxis(q=True, axis=True) == "z", ( + "Invalid axis set as up axis" + ) + + @classmethod + def repair(cls, instance): + cmds.upAxis(axis="z", rotateView=True) diff --git a/pype/plugins/unreal/create/create_fbx.py b/pype/plugins/unreal/create/create_fbx.py new file mode 100644 index 0000000000..0d5b0bf316 --- /dev/null +++ b/pype/plugins/unreal/create/create_fbx.py @@ -0,0 +1,14 @@ +from pype.unreal.plugin import Creator + + +class CreateFbx(Creator): + """Static FBX geometry""" + + name = "modelMain" + label = "Model" + family = "model" + icon = "cube" + asset_types = ["StaticMesh"] + + def __init__(self, *args, **kwargs): + super(CreateFbx, self).__init__(*args, **kwargs) diff --git a/pype/plugins/unreal/load/load_staticmeshfbx.py b/pype/plugins/unreal/load/load_staticmeshfbx.py new file mode 100644 index 0000000000..056c81d54d --- /dev/null +++ b/pype/plugins/unreal/load/load_staticmeshfbx.py @@ -0,0 +1,53 @@ +from avalon import api +from avalon import unreal as avalon_unreal +import unreal +import time + + +class StaticMeshFBXLoader(api.Loader): + """Load Unreal StaticMesh from FBX""" + + families = ["unrealStaticMesh"] + label = "Import FBX Static Mesh" + representations = ["fbx"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + + tools = unreal.AssetToolsHelpers().get_asset_tools() + temp_dir, temp_name = tools.create_unique_asset_name( + "/Game/{}".format(name), "_TMP" + ) + + # asset_path = "/Game/{}".format(namespace) + unreal.EditorAssetLibrary.make_directory(temp_dir) + + task = unreal.AssetImportTask() + + task.filename = self.fname + task.destination_path = temp_dir + task.destination_name = name + task.replace_existing = False + task.automated = True + task.save = True + + # set import options here + task.options = unreal.FbxImportUI() + task.options.import_animations = False + + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + imported_assets = unreal.EditorAssetLibrary.list_assets( + temp_dir, recursive=True, include_folder=True + ) + new_dir = avalon_unreal.containerise( + name, namespace, imported_assets, context, self.__class__.__name__) + + asset_content = unreal.EditorAssetLibrary.list_assets( + new_dir, recursive=True, include_folder=True + ) + + unreal.EditorAssetLibrary.delete_directory(temp_dir) + + return asset_content diff --git a/pype/plugins/unreal/publish/collect_instances.py b/pype/plugins/unreal/publish/collect_instances.py new file mode 100644 index 0000000000..fa604f79d3 --- /dev/null +++ b/pype/plugins/unreal/publish/collect_instances.py @@ -0,0 +1,152 @@ +import unreal + +import pyblish.api + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by objectSet and pre-defined attribute + + This collector takes into account assets that are associated with + an objectSet and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance" + + Limitations: + - Does not take into account nodes connected to those + within an objectSet. Extractors are assumed to export + with history preserved, but this limits what they will + be able to achieve and the amount of data available + to validators. An additional collector could also + append this input data into the instance, as we do + for `pype.rig` with collect_history. + + """ + + label = "Collect Instances" + order = pyblish.api.CollectorOrder + hosts = ["unreal"] + + def process(self, context): + + objectset = cmds.ls("*.id", long=True, type="objectSet", + recursive=True, objectsOnly=True) + + context.data['objectsets'] = objectset + for objset in objectset: + + if not cmds.attributeQuery("id", node=objset, exists=True): + continue + + id_attr = "{}.id".format(objset) + if cmds.getAttr(id_attr) != "pyblish.avalon.instance": + continue + + # The developer is responsible for specifying + # the family of each instance. + has_family = cmds.attributeQuery("family", + node=objset, + exists=True) + assert has_family, "\"%s\" was missing a family" % objset + + members = cmds.sets(objset, query=True) + if members is None: + self.log.warning("Skipped empty instance: \"%s\" " % objset) + continue + + self.log.info("Creating instance for {}".format(objset)) + + data = dict() + + # Apply each user defined attribute as data + for attr in cmds.listAttr(objset, userDefined=True) or list(): + try: + value = cmds.getAttr("%s.%s" % (objset, attr)) + except Exception: + # Some attributes cannot be read directly, + # such as mesh and color attributes. These + # are considered non-essential to this + # particular publishing pipeline. + value = None + data[attr] = value + + # temporarily translation of `active` to `publish` till issue has + # been resolved, https://github.com/pyblish/pyblish-base/issues/307 + if "active" in data: + data["publish"] = data["active"] + + # Collect members + members = cmds.ls(members, long=True) or [] + + # `maya.cmds.listRelatives(noIntermediate=True)` only works when + # `shapes=True` argument is passed, since we also want to include + # transforms we filter afterwards. + children = cmds.listRelatives(members, + allDescendents=True, + fullPath=True) or [] + children = cmds.ls(children, noIntermediate=True, long=True) + + parents = [] + if data.get("includeParentHierarchy", True): + # If `includeParentHierarchy` then include the parents + # so they will also be picked up in the instance by validators + parents = self.get_all_parents(members) + members_hierarchy = list(set(members + children + parents)) + + if 'families' not in data: + data['families'] = [data.get('family')] + + # Create the instance + instance = context.create_instance(objset) + instance[:] = members_hierarchy + + # Store the exact members of the object set + instance.data["setMembers"] = members + + + # Define nice label + name = cmds.ls(objset, long=False)[0] # use short name + label = "{0} ({1})".format(name, + data["asset"]) + + # Append start frame and end frame to label if present + if "frameStart" and "frameEnd" in data: + label += " [{0}-{1}]".format(int(data["frameStart"]), + int(data["frameEnd"])) + + instance.data["label"] = label + + instance.data.update(data) + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) + self.log.debug("DATA: \"%s\" " % instance.data) + + + def sort_by_family(instance): + """Sort by family""" + return instance.data.get("families", instance.data.get("family")) + + # Sort/grouped by family (preserving local index) + context[:] = sorted(context, key=sort_by_family) + + return context + + def get_all_parents(self, nodes): + """Get all parents by using string operations (optimization) + + Args: + nodes (list): the nodes which are found in the objectSet + + Returns: + list + """ + + parents = [] + for node in nodes: + splitted = node.split("|") + items = ["|".join(splitted[0:i]) for i in range(2, len(splitted))] + parents.extend(items) + + return list(set(parents)) diff --git a/pype/unreal/__init__.py b/pype/unreal/__init__.py index e69de29bb2..bb8a765a43 100644 --- a/pype/unreal/__init__.py +++ b/pype/unreal/__init__.py @@ -0,0 +1,45 @@ +import os +import logging + +from avalon import api as avalon +from pyblish import api as pyblish + +logger = logging.getLogger("pype.unreal") + +PARENT_DIR = os.path.dirname(__file__) +PACKAGE_DIR = os.path.dirname(PARENT_DIR) +PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") + +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "unreal", "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "unreal", "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "unreal", "create") + + +def install(): + """Install Unreal configuration for Avalon.""" + print("-=" * 40) + logo = '''. +. + ____________ + / \\ __ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ + \\ \\ \\___// \\ \\ + \\ \\____\\ \\ \\_____\\ + \\/_____/ \\/______/ PYPE Club . +. +''' + print(logo) + print("installing Pype for Unreal ...") + print("-=" * 40) + logger.info("installing Pype for Unreal") + pyblish.register_plugin_path(str(PUBLISH_PATH)) + avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) + avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) + + +def uninstall(): + """Uninstall Unreal configuration for Avalon.""" + pyblish.deregister_plugin_path(str(PUBLISH_PATH)) + avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) + avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) diff --git a/pype/unreal/plugin.py b/pype/unreal/plugin.py new file mode 100644 index 0000000000..d403417ad1 --- /dev/null +++ b/pype/unreal/plugin.py @@ -0,0 +1,9 @@ +from avalon import api + + +class Creator(api.Creator): + pass + + +class Loader(api.Loader): + pass From ada2cc0f4e9b6a351c9cb9ae6b1d56ed1d37e631 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Mar 2020 21:43:47 +0100 Subject: [PATCH 07/10] base of unreal staticMesh pipeline --- .../global/publish/collect_scene_version.py | 2 +- pype/plugins/unreal/create/create_fbx.py | 14 -- .../unreal/create/create_staticmeshfbx.py | 33 ++++ .../plugins/unreal/load/load_staticmeshfbx.py | 52 +++++- .../unreal/publish/collect_instances.py | 149 ++++-------------- 5 files changed, 112 insertions(+), 138 deletions(-) delete mode 100644 pype/plugins/unreal/create/create_fbx.py create mode 100644 pype/plugins/unreal/create/create_staticmeshfbx.py diff --git a/pype/plugins/global/publish/collect_scene_version.py b/pype/plugins/global/publish/collect_scene_version.py index 314a64f550..8c2bacf6e1 100644 --- a/pype/plugins/global/publish/collect_scene_version.py +++ b/pype/plugins/global/publish/collect_scene_version.py @@ -16,7 +16,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): if "standalonepublisher" in context.data.get("host", []): return - if "unreal" in context.data.get("host", []): + if "unreal" in pyblish.api.registered_hosts(): return filename = os.path.basename(context.data.get('currentFile')) diff --git a/pype/plugins/unreal/create/create_fbx.py b/pype/plugins/unreal/create/create_fbx.py deleted file mode 100644 index 0d5b0bf316..0000000000 --- a/pype/plugins/unreal/create/create_fbx.py +++ /dev/null @@ -1,14 +0,0 @@ -from pype.unreal.plugin import Creator - - -class CreateFbx(Creator): - """Static FBX geometry""" - - name = "modelMain" - label = "Model" - family = "model" - icon = "cube" - asset_types = ["StaticMesh"] - - def __init__(self, *args, **kwargs): - super(CreateFbx, self).__init__(*args, **kwargs) diff --git a/pype/plugins/unreal/create/create_staticmeshfbx.py b/pype/plugins/unreal/create/create_staticmeshfbx.py new file mode 100644 index 0000000000..8002299f0a --- /dev/null +++ b/pype/plugins/unreal/create/create_staticmeshfbx.py @@ -0,0 +1,33 @@ +import unreal +from pype.unreal.plugin import Creator +from avalon.unreal import ( + instantiate, +) + + +class CreateStaticMeshFBX(Creator): + """Static FBX geometry""" + + name = "unrealStaticMeshMain" + label = "Unreal - Static Mesh" + family = "unrealStaticMesh" + icon = "cube" + asset_types = ["StaticMesh"] + + root = "/Game" + suffix = "_INS" + + def __init__(self, *args, **kwargs): + super(CreateStaticMeshFBX, self).__init__(*args, **kwargs) + + def process(self): + + name = self.data["subset"] + + selection = [] + if (self.options or {}).get("useSelection"): + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [a.get_path_name() for a in sel_objects] + + unreal.log("selection: {}".format(selection)) + instantiate(self.root, name, self.data, selection, self.suffix) diff --git a/pype/plugins/unreal/load/load_staticmeshfbx.py b/pype/plugins/unreal/load/load_staticmeshfbx.py index 056c81d54d..61e765f7c2 100644 --- a/pype/plugins/unreal/load/load_staticmeshfbx.py +++ b/pype/plugins/unreal/load/load_staticmeshfbx.py @@ -1,7 +1,6 @@ from avalon import api from avalon import unreal as avalon_unreal import unreal -import time class StaticMeshFBXLoader(api.Loader): @@ -14,13 +13,33 @@ class StaticMeshFBXLoader(api.Loader): color = "orange" def load(self, context, name, namespace, data): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ tools = unreal.AssetToolsHelpers().get_asset_tools() temp_dir, temp_name = tools.create_unique_asset_name( "/Game/{}".format(name), "_TMP" ) - # asset_path = "/Game/{}".format(namespace) unreal.EditorAssetLibrary.make_directory(temp_dir) task = unreal.AssetImportTask() @@ -51,3 +70,32 @@ class StaticMeshFBXLoader(api.Loader): unreal.EditorAssetLibrary.delete_directory(temp_dir) return asset_content + + def update(self, container, representation): + node = container["objectName"] + source_path = api.get_representation_path(representation) + destination_path = container["namespace"] + + task = unreal.AssetImportTask() + + task.filename = source_path + task.destination_path = destination_path + # strip suffix + task.destination_name = node[:-4] + task.replace_existing = True + task.automated = True + task.save = True + + task.options = unreal.FbxImportUI() + task.options.import_animations = False + + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + container_path = "{}/{}".format(container["namespace"], + container["objectName"]) + # update metadata + avalon_unreal.imprint( + container_path, {"_id": str(representation["_id"])}) + + def remove(self, container): + unreal.EditorAssetLibrary.delete_directory(container["namespace"]) diff --git a/pype/plugins/unreal/publish/collect_instances.py b/pype/plugins/unreal/publish/collect_instances.py index fa604f79d3..766a73028c 100644 --- a/pype/plugins/unreal/publish/collect_instances.py +++ b/pype/plugins/unreal/publish/collect_instances.py @@ -4,23 +4,14 @@ import pyblish.api class CollectInstances(pyblish.api.ContextPlugin): - """Gather instances by objectSet and pre-defined attribute + """Gather instances by AvalonPublishInstance class - This collector takes into account assets that are associated with - an objectSet and marked with a unique identifier; + This collector finds all paths containing `AvalonPublishInstance` class + asset Identifier: id (str): "pyblish.avalon.instance" - Limitations: - - Does not take into account nodes connected to those - within an objectSet. Extractors are assumed to export - with history preserved, but this limits what they will - be able to achieve and the amount of data available - to validators. An additional collector could also - append this input data into the instance, as we do - for `pype.rig` with collect_history. - """ label = "Collect Instances" @@ -29,124 +20,40 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): - objectset = cmds.ls("*.id", long=True, type="objectSet", - recursive=True, objectsOnly=True) + ar = unreal.AssetRegistryHelpers.get_asset_registry() + instance_containers = ar.get_assets_by_class( + "AvalonPublishInstance", True) - context.data['objectsets'] = objectset - for objset in objectset: + for container_data in instance_containers: + asset = container_data.get_asset() + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = container_data.asset_name + # convert to strings + data = {str(key): str(value) for (key, value) in data.items()} + assert data.get("family"), ( + "instance has no family" + ) - if not cmds.attributeQuery("id", node=objset, exists=True): - continue + # content of container + members = unreal.EditorAssetLibrary.list_assets( + asset.get_path_name(), recursive=True, include_folder=True + ) + self.log.debug(members) + self.log.debug(asset.get_path_name()) + # remove instance container + members.remove(asset.get_path_name()) + self.log.info("Creating instance for {}".format(asset.get_name())) - id_attr = "{}.id".format(objset) - if cmds.getAttr(id_attr) != "pyblish.avalon.instance": - continue - - # The developer is responsible for specifying - # the family of each instance. - has_family = cmds.attributeQuery("family", - node=objset, - exists=True) - assert has_family, "\"%s\" was missing a family" % objset - - members = cmds.sets(objset, query=True) - if members is None: - self.log.warning("Skipped empty instance: \"%s\" " % objset) - continue - - self.log.info("Creating instance for {}".format(objset)) - - data = dict() - - # Apply each user defined attribute as data - for attr in cmds.listAttr(objset, userDefined=True) or list(): - try: - value = cmds.getAttr("%s.%s" % (objset, attr)) - except Exception: - # Some attributes cannot be read directly, - # such as mesh and color attributes. These - # are considered non-essential to this - # particular publishing pipeline. - value = None - data[attr] = value - - # temporarily translation of `active` to `publish` till issue has - # been resolved, https://github.com/pyblish/pyblish-base/issues/307 - if "active" in data: - data["publish"] = data["active"] - - # Collect members - members = cmds.ls(members, long=True) or [] - - # `maya.cmds.listRelatives(noIntermediate=True)` only works when - # `shapes=True` argument is passed, since we also want to include - # transforms we filter afterwards. - children = cmds.listRelatives(members, - allDescendents=True, - fullPath=True) or [] - children = cmds.ls(children, noIntermediate=True, long=True) - - parents = [] - if data.get("includeParentHierarchy", True): - # If `includeParentHierarchy` then include the parents - # so they will also be picked up in the instance by validators - parents = self.get_all_parents(members) - members_hierarchy = list(set(members + children + parents)) - - if 'families' not in data: - data['families'] = [data.get('family')] - - # Create the instance - instance = context.create_instance(objset) - instance[:] = members_hierarchy + instance = context.create_instance(asset.get_name()) + instance[:] = members # Store the exact members of the object set instance.data["setMembers"] = members + instance.data["families"] = [data.get("family")] - - # Define nice label - name = cmds.ls(objset, long=False)[0] # use short name - label = "{0} ({1})".format(name, + label = "{0} ({1})".format(asset.get_name()[:-4], data["asset"]) - # Append start frame and end frame to label if present - if "frameStart" and "frameEnd" in data: - label += " [{0}-{1}]".format(int(data["frameStart"]), - int(data["frameEnd"])) - instance.data["label"] = label instance.data.update(data) - - # Produce diagnostic message for any graphical - # user interface interested in visualising it. - self.log.info("Found: \"%s\" " % instance.data["name"]) - self.log.debug("DATA: \"%s\" " % instance.data) - - - def sort_by_family(instance): - """Sort by family""" - return instance.data.get("families", instance.data.get("family")) - - # Sort/grouped by family (preserving local index) - context[:] = sorted(context, key=sort_by_family) - - return context - - def get_all_parents(self, nodes): - """Get all parents by using string operations (optimization) - - Args: - nodes (list): the nodes which are found in the objectSet - - Returns: - list - """ - - parents = [] - for node in nodes: - splitted = node.split("|") - items = ["|".join(splitted[0:i]) for i in range(2, len(splitted))] - parents.extend(items) - - return list(set(parents)) From a1a27784ae85f5c7da925059c4ff3f0b4ef66429 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 17 Mar 2020 00:01:35 +0100 Subject: [PATCH 08/10] updated docs --- pype/unreal/lib.py | 12 +++++++++++- pype/unreal/plugin.py | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pype/unreal/lib.py b/pype/unreal/lib.py index be6314b09b..faf3a6e99f 100644 --- a/pype/unreal/lib.py +++ b/pype/unreal/lib.py @@ -13,7 +13,17 @@ def get_engine_versions(): Location can be overridden by `UNREAL_ENGINE_LOCATION` environment variable. - Returns dictionary with version as a key and dir as value. + Returns: + + dict: dictionary with version as a key and dir as value. + + Example: + + >>> get_engine_version() + { + "4.23": "C:/Epic Games/UE_4.23", + "4.24": "C:/Epic Games/UE_4.24" + } """ try: engine_locations = {} diff --git a/pype/unreal/plugin.py b/pype/unreal/plugin.py index d403417ad1..0c00eb77d6 100644 --- a/pype/unreal/plugin.py +++ b/pype/unreal/plugin.py @@ -2,8 +2,10 @@ from avalon import api class Creator(api.Creator): + """This serves as skeleton for future Pype specific functionality""" pass class Loader(api.Loader): + """This serves as skeleton for future Pype specific functionality""" pass From 3bf653b006e336734cfecb421964cc2226e61717 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Mar 2020 11:25:13 +0100 Subject: [PATCH 09/10] fixed bug with cpp project setup --- pype/unreal/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/unreal/lib.py b/pype/unreal/lib.py index faf3a6e99f..8f87fdbf4e 100644 --- a/pype/unreal/lib.py +++ b/pype/unreal/lib.py @@ -257,7 +257,7 @@ def create_unreal_project(project_name: str, "pip", "install", "pyside"]) if dev_mode or preset["dev_mode"]: - _prepare_cpp_project(pr_dir, engine_path) + _prepare_cpp_project(project_file, engine_path) def _prepare_cpp_project(project_file: str, engine_path: str) -> None: From 3d0b7e49c7cadc849b3d165443e16923ae33309f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 25 Mar 2020 13:52:40 +0100 Subject: [PATCH 10/10] PEP fixes --- .flake8 | 2 +- pype/ftrack/lib/ftrack_app_handler.py | 7 +++---- pype/plugins/unreal/load/load_staticmeshfbx.py | 4 ++-- pype/unreal/lib.py | 10 +++++----- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.flake8 b/.flake8 index b04062ceab..f9c81de232 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,6 @@ [flake8] # ignore = D203 -ignore = BLK100, W504 +ignore = BLK100, W504, W503 max-line-length = 79 exclude = .git, diff --git a/pype/ftrack/lib/ftrack_app_handler.py b/pype/ftrack/lib/ftrack_app_handler.py index 58c550b3dd..b5576ae046 100644 --- a/pype/ftrack/lib/ftrack_app_handler.py +++ b/pype/ftrack/lib/ftrack_app_handler.py @@ -277,7 +277,7 @@ class AppAction(BaseHandler): 'success': False, 'message': "Hook didn't finish successfully {0}" .format(self.label) - } + } if sys.platform == "win32": @@ -290,7 +290,7 @@ class AppAction(BaseHandler): # Run SW if was found executable if execfile is not None: - popen = avalonlib.launch( + avalonlib.launch( executable=execfile, args=[], environment=env ) else: @@ -298,8 +298,7 @@ class AppAction(BaseHandler): 'success': False, 'message': "We didn't found launcher for {0}" .format(self.label) - } - pass + } if sys.platform.startswith('linux'): execfile = os.path.join(path.strip('"'), self.executable) diff --git a/pype/plugins/unreal/load/load_staticmeshfbx.py b/pype/plugins/unreal/load/load_staticmeshfbx.py index 61e765f7c2..4c27f9aa92 100644 --- a/pype/plugins/unreal/load/load_staticmeshfbx.py +++ b/pype/plugins/unreal/load/load_staticmeshfbx.py @@ -37,7 +37,7 @@ class StaticMeshFBXLoader(api.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() temp_dir, temp_name = tools.create_unique_asset_name( - "/Game/{}".format(name), "_TMP" + "/Game/{}".format(name), "_TMP" ) unreal.EditorAssetLibrary.make_directory(temp_dir) @@ -95,7 +95,7 @@ class StaticMeshFBXLoader(api.Loader): container["objectName"]) # update metadata avalon_unreal.imprint( - container_path, {"_id": str(representation["_id"])}) + container_path, {"_id": str(representation["_id"])}) def remove(self, container): unreal.EditorAssetLibrary.delete_directory(container["namespace"]) diff --git a/pype/unreal/lib.py b/pype/unreal/lib.py index 8f87fdbf4e..0b049c8b1d 100644 --- a/pype/unreal/lib.py +++ b/pype/unreal/lib.py @@ -214,11 +214,11 @@ def create_unreal_project(project_name: str, # sources at start data["Modules"] = [{ - "Name": project_name, - "Type": "Runtime", - "LoadingPhase": "Default", - "AdditionalDependencies": ["Engine"], - }] + "Name": project_name, + "Type": "Runtime", + "LoadingPhase": "Default", + "AdditionalDependencies": ["Engine"], + }] if preset["install_unreal_python_engine"]: # now we need to fix python path in: