diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index 747394aad0..3081d3c9ba 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -5,11 +5,8 @@ def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Prepare path to implementation script implementation_user_script_path = os.path.join( - os.environ["OPENPYPE_REPOS_ROOT"], - "repos", - "avalon-core", - "setup", - "blender" + os.path.dirname(os.path.abspath(__file__)), + "blender_addon" ) # Add blender implementation script path to PYTHONPATH diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index ecf4fdf4da..e017d74d91 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -1,94 +1,64 @@ -import os -import sys -import traceback +"""Public API -import bpy +Anything that isn't defined here is INTERNAL and unreliable for external use. -from .lib import append_user_scripts +""" -from avalon import api as avalon -from pyblish import api as pyblish +from .pipeline import ( + install, + uninstall, + ls, + publish, + containerise, +) -import openpype.hosts.blender +from .plugin import ( + Creator, + Loader, +) -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root, +) -ORIGINAL_EXCEPTHOOK = sys.excepthook +from .lib import ( + lsattr, + lsattrs, + read, + maintained_selection, + get_selection, + # unique_name, +) -def pype_excepthook_handler(*args): - traceback.print_exception(*args) +__all__ = [ + "install", + "uninstall", + "ls", + "publish", + "containerise", + "Creator", + "Loader", -def install(): - """Install Blender configuration for Avalon.""" - sys.excepthook = pype_excepthook_handler - 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)) - append_user_scripts() - avalon.on("new", on_new) - avalon.on("open", on_open) + # Workfiles API + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", - -def uninstall(): - """Uninstall Blender configuration for Avalon.""" - sys.excepthook = ORIGINAL_EXCEPTHOOK - 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)) - - -def set_start_end_frames(): - from avalon import io - - asset_name = io.Session["AVALON_ASSET"] - asset_doc = io.find_one({ - "type": "asset", - "name": asset_name - }) - - scene = bpy.context.scene - - # Default scene settings - frameStart = scene.frame_start - frameEnd = scene.frame_end - fps = scene.render.fps - resolution_x = scene.render.resolution_x - resolution_y = scene.render.resolution_y - - # Check if settings are set - data = asset_doc.get("data") - - if not data: - return - - if data.get("frameStart"): - frameStart = data.get("frameStart") - if data.get("frameEnd"): - frameEnd = data.get("frameEnd") - if data.get("fps"): - fps = data.get("fps") - if data.get("resolutionWidth"): - resolution_x = data.get("resolutionWidth") - if data.get("resolutionHeight"): - resolution_y = data.get("resolutionHeight") - - scene.frame_start = frameStart - scene.frame_end = frameEnd - scene.render.fps = fps - scene.render.resolution_x = resolution_x - scene.render.resolution_y = resolution_y - - -def on_new(arg1, arg2): - set_start_end_frames() - - -def on_open(arg1, arg2): - set_start_end_frames() + # Utility functions + "maintained_selection", + "lsattr", + "lsattrs", + "read", + "get_selection", + # "unique_name", +] diff --git a/openpype/hosts/blender/api/icons/pyblish-32x32.png b/openpype/hosts/blender/api/icons/pyblish-32x32.png new file mode 100644 index 0000000000..b34e397e0b Binary files /dev/null and b/openpype/hosts/blender/api/icons/pyblish-32x32.png differ diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index e7210f7e31..20098c0fe8 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -1,9 +1,16 @@ import os import traceback import importlib +import contextlib +from typing import Dict, List, Union import bpy import addon_utils +from openpype.api import Logger + +from . import pipeline + +log = Logger.get_logger(__name__) def load_scripts(paths): @@ -125,3 +132,155 @@ def append_user_scripts(): except Exception: print("Couldn't load user scripts \"{}\"".format(user_scripts)) traceback.print_exc() + + +def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): + r"""Write `data` to `node` as userDefined attributes + + Arguments: + node: Long name of node + data: Dictionary of key/value pairs + + Example: + >>> import bpy + >>> def compute(): + ... return 6 + ... + >>> bpy.ops.mesh.primitive_cube_add() + >>> cube = bpy.context.view_layer.objects.active + >>> imprint(cube, { + ... "regularString": "myFamily", + ... "computedValue": lambda: compute() + ... }) + ... + >>> cube['avalon']['computedValue'] + 6 + """ + + imprint_data = dict() + + for key, value in data.items(): + if value is None: + continue + + if callable(value): + # Support values evaluated at imprint + value = value() + + if not isinstance(value, (int, float, bool, str, list)): + raise TypeError(f"Unsupported type: {type(value)}") + + imprint_data[key] = value + + pipeline.metadata_update(node, imprint_data) + + +def lsattr(attr: str, + value: Union[str, int, bool, List, Dict, None] = None) -> List: + r"""Return nodes matching `attr` and `value` + + Arguments: + attr: Name of Blender property + value: Value of attribute. If none + is provided, return all nodes with this attribute. + + Example: + >>> lsattr("id", "myId") + ... [bpy.data.objects["myNode"] + >>> lsattr("id") + ... [bpy.data.objects["myNode"], bpy.data.objects["myOtherNode"]] + + Returns: + list + """ + + return lsattrs({attr: value}) + + +def lsattrs(attrs: Dict) -> List: + r"""Return nodes with the given attribute(s). + + Arguments: + attrs: Name and value pairs of expected matches + + Example: + >>> lsattrs({"age": 5}) # Return nodes with an `age` of 5 + # Return nodes with both `age` and `color` of 5 and blue + >>> lsattrs({"age": 5, "color": "blue"}) + + Returns a list. + + """ + + # For now return all objects, not filtered by scene/collection/view_layer. + matches = set() + for coll in dir(bpy.data): + if not isinstance( + getattr(bpy.data, coll), + bpy.types.bpy_prop_collection, + ): + continue + for node in getattr(bpy.data, coll): + for attr, value in attrs.items(): + avalon_prop = node.get(pipeline.AVALON_PROPERTY) + if not avalon_prop: + continue + if (avalon_prop.get(attr) + and (value is None or avalon_prop.get(attr) == value)): + matches.add(node) + return list(matches) + + +def read(node: bpy.types.bpy_struct_meta_idprop): + """Return user-defined attributes from `node`""" + + data = dict(node.get(pipeline.AVALON_PROPERTY)) + + # Ignore hidden/internal data + data = { + key: value + for key, value in data.items() if not key.startswith("_") + } + + return data + + +def get_selection() -> List[bpy.types.Object]: + """Return the selected objects from the current scene.""" + return [obj for obj in bpy.context.scene.objects if obj.select_get()] + + +@contextlib.contextmanager +def maintained_selection(): + r"""Maintain selection during context + + Example: + >>> with maintained_selection(): + ... # Modify selection + ... bpy.ops.object.select_all(action='DESELECT') + >>> # Selection restored + """ + + previous_selection = get_selection() + previous_active = bpy.context.view_layer.objects.active + try: + yield + finally: + # Clear the selection + for node in get_selection(): + node.select_set(state=False) + if previous_selection: + for node in previous_selection: + try: + node.select_set(state=True) + except ReferenceError: + # This could happen if a selected node was deleted during + # the context. + log.exception("Failed to reselect") + continue + try: + bpy.context.view_layer.objects.active = previous_active + except ReferenceError: + # This could happen if the active node was deleted during the + # context. + log.exception("Failed to set active object.") diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py new file mode 100644 index 0000000000..a73ef0133a --- /dev/null +++ b/openpype/hosts/blender/api/ops.py @@ -0,0 +1,410 @@ +"""Blender operators and menus for use with Avalon.""" + +import os +import sys +import platform +import time +import traceback +import collections +from pathlib import Path +from types import ModuleType +from typing import Dict, List, Optional, Union + +from Qt import QtWidgets, QtCore + +import bpy +import bpy.utils.previews + +import avalon.api +from openpype.tools.utils import host_tools +from openpype import style + +from .workio import OpenFileCacher + +PREVIEW_COLLECTIONS: Dict = dict() + +# This seems like a good value to keep the Qt app responsive and doesn't slow +# down Blender. At least on macOS I the interace of Blender gets very laggy if +# you make it smaller. +TIMER_INTERVAL: float = 0.01 + + +class BlenderApplication(QtWidgets.QApplication): + _instance = None + blender_windows = {} + + def __init__(self, *args, **kwargs): + super(BlenderApplication, self).__init__(*args, **kwargs) + self.setQuitOnLastWindowClosed(False) + + self.setStyleSheet(style.load_stylesheet()) + self.lastWindowClosed.connect(self.__class__.reset) + + @classmethod + def get_app(cls): + if cls._instance is None: + cls._instance = cls(sys.argv) + return cls._instance + + @classmethod + def reset(cls): + cls._instance = None + + @classmethod + def store_window(cls, identifier, window): + current_window = cls.get_window(identifier) + cls.blender_windows[identifier] = window + if current_window: + current_window.close() + # current_window.deleteLater() + + @classmethod + def get_window(cls, identifier): + return cls.blender_windows.get(identifier) + + +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + sleep_time = 0.1 + + def __init__(self, callback, *args, **kwargs): + self.done = False + self.exception = self.not_set + self.result = self.not_set + self.callback = callback + self.args = args + self.kwargs = kwargs + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + print("Executing process in main thread") + if self.done: + print("- item is already processed") + return + + callback = self.callback + args = self.args + kwargs = self.kwargs + print("Running callback: {}".format(str(callback))) + try: + result = callback(*args, **kwargs) + self.result = result + + except Exception: + self.exception = sys.exc_info() + + finally: + print("Done") + self.done = True + + def wait(self): + """Wait for result from main thread. + + This method stops current thread until callback is executed. + + Returns: + object: Output of callback. May be any type or object. + + Raises: + Exception: Reraise any exception that happened during callback + execution. + """ + while not self.done: + print(self.done) + time.sleep(self.sleep_time) + + if self.exception is self.not_set: + return self.result + raise self.exception + + +class GlobalClass: + app = None + main_thread_callbacks = collections.deque() + is_windows = platform.system().lower() == "windows" + + +def execute_in_main_thread(main_thead_item): + print("execute_in_main_thread") + GlobalClass.main_thread_callbacks.append(main_thead_item) + + +def _process_app_events() -> Optional[float]: + """Process the events of the Qt app if the window is still visible. + + If the app has any top level windows and at least one of them is visible + return the time after which this function should be run again. Else return + None, so the function is not run again and will be unregistered. + """ + while GlobalClass.main_thread_callbacks: + main_thread_item = GlobalClass.main_thread_callbacks.popleft() + main_thread_item.execute() + if main_thread_item.exception is not MainThreadItem.not_set: + _clc, val, tb = main_thread_item.exception + msg = str(val) + detail = "\n".join(traceback.format_exception(_clc, val, tb)) + dialog = QtWidgets.QMessageBox( + QtWidgets.QMessageBox.Warning, + "Error", + msg) + dialog.setMinimumWidth(500) + dialog.setDetailedText(detail) + dialog.exec_() + + if not GlobalClass.is_windows: + if OpenFileCacher.opening_file: + return TIMER_INTERVAL + + app = GlobalClass.app + if app._instance: + app.processEvents() + return TIMER_INTERVAL + return TIMER_INTERVAL + + +class LaunchQtApp(bpy.types.Operator): + """A Base class for opertors to launch a Qt app.""" + + _app: QtWidgets.QApplication + _window = Union[QtWidgets.QDialog, ModuleType] + _tool_name: str = None + _init_args: Optional[List] = list() + _init_kwargs: Optional[Dict] = dict() + bl_idname: str = None + + def __init__(self): + if self.bl_idname is None: + raise NotImplementedError("Attribute `bl_idname` must be set!") + print(f"Initialising {self.bl_idname}...") + self._app = BlenderApplication.get_app() + GlobalClass.app = self._app + + bpy.app.timers.register( + _process_app_events, + persistent=True + ) + + def execute(self, context): + """Execute the operator. + + The child class must implement `execute()` where it only has to set + `self._window` to the desired Qt window and then simply run + `return super().execute(context)`. + `self._window` is expected to have a `show` method. + If the `show` method requires arguments, you can set `self._show_args` + and `self._show_kwargs`. `args` should be a list, `kwargs` a + dictionary. + """ + + if self._tool_name is None: + if self._window is None: + raise AttributeError("`self._window` is not set.") + + else: + window = self._app.get_window(self.bl_idname) + if window is None: + window = host_tools.get_tool_by_name(self._tool_name) + self._app.store_window(self.bl_idname, window) + self._window = window + + if not isinstance( + self._window, + (QtWidgets.QMainWindow, QtWidgets.QDialog, ModuleType) + ): + raise AttributeError( + "`window` should be a `QDialog or module`. Got: {}".format( + str(type(window)) + ) + ) + + self.before_window_show() + + if isinstance(self._window, ModuleType): + self._window.show() + window = None + if hasattr(self._window, "window"): + window = self._window.window + elif hasattr(self._window, "_window"): + window = self._window.window + + if window: + self._app.store_window(self.bl_idname, window) + + else: + origin_flags = self._window.windowFlags() + on_top_flags = origin_flags | QtCore.Qt.WindowStaysOnTopHint + self._window.setWindowFlags(on_top_flags) + self._window.show() + + if on_top_flags != origin_flags: + self._window.setWindowFlags(origin_flags) + self._window.show() + + return {'FINISHED'} + + def before_window_show(self): + return + + +class LaunchCreator(LaunchQtApp): + """Launch Avalon Creator.""" + + bl_idname = "wm.avalon_creator" + bl_label = "Create..." + _tool_name = "creator" + + def before_window_show(self): + self._window.refresh() + + +class LaunchLoader(LaunchQtApp): + """Launch Avalon Loader.""" + + bl_idname = "wm.avalon_loader" + bl_label = "Load..." + _tool_name = "loader" + + def before_window_show(self): + self._window.set_context( + {"asset": avalon.api.Session["AVALON_ASSET"]}, + refresh=True + ) + + +class LaunchPublisher(LaunchQtApp): + """Launch Avalon Publisher.""" + + bl_idname = "wm.avalon_publisher" + bl_label = "Publish..." + + def execute(self, context): + host_tools.show_publish() + return {"FINISHED"} + + +class LaunchManager(LaunchQtApp): + """Launch Avalon Manager.""" + + bl_idname = "wm.avalon_manager" + bl_label = "Manage..." + _tool_name = "sceneinventory" + + def before_window_show(self): + self._window.refresh() + + +class LaunchWorkFiles(LaunchQtApp): + """Launch Avalon Work Files.""" + + bl_idname = "wm.avalon_workfiles" + bl_label = "Work Files..." + _tool_name = "workfiles" + + def execute(self, context): + result = super().execute(context) + self._window.set_context({ + "asset": avalon.api.Session["AVALON_ASSET"], + "silo": avalon.api.Session["AVALON_SILO"], + "task": avalon.api.Session["AVALON_TASK"] + }) + return result + + def before_window_show(self): + self._window.root = str(Path( + os.environ.get("AVALON_WORKDIR", ""), + os.environ.get("AVALON_SCENEDIR", ""), + )) + self._window.refresh() + + +class TOPBAR_MT_avalon(bpy.types.Menu): + """Avalon menu.""" + + bl_idname = "TOPBAR_MT_avalon" + bl_label = os.environ.get("AVALON_LABEL") + + def draw(self, context): + """Draw the menu in the UI.""" + + layout = self.layout + + pcoll = PREVIEW_COLLECTIONS.get("avalon") + if pcoll: + pyblish_menu_icon = pcoll["pyblish_menu_icon"] + pyblish_menu_icon_id = pyblish_menu_icon.icon_id + else: + pyblish_menu_icon_id = 0 + + asset = avalon.api.Session['AVALON_ASSET'] + task = avalon.api.Session['AVALON_TASK'] + context_label = f"{asset}, {task}" + context_label_item = layout.row() + context_label_item.operator( + LaunchWorkFiles.bl_idname, text=context_label + ) + context_label_item.enabled = False + layout.separator() + layout.operator(LaunchCreator.bl_idname, text="Create...") + layout.operator(LaunchLoader.bl_idname, text="Load...") + layout.operator( + LaunchPublisher.bl_idname, + text="Publish...", + icon_value=pyblish_menu_icon_id, + ) + layout.operator(LaunchManager.bl_idname, text="Manage...") + layout.separator() + layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") + # TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and + # 'Reset Resolution'? + + +def draw_avalon_menu(self, context): + """Draw the Avalon menu in the top bar.""" + + self.layout.menu(TOPBAR_MT_avalon.bl_idname) + + +classes = [ + LaunchCreator, + LaunchLoader, + LaunchPublisher, + LaunchManager, + LaunchWorkFiles, + TOPBAR_MT_avalon, +] + + +def register(): + "Register the operators and menu." + + pcoll = bpy.utils.previews.new() + pyblish_icon_file = Path(__file__).parent / "icons" / "pyblish-32x32.png" + pcoll.load("pyblish_menu_icon", str(pyblish_icon_file.absolute()), 'IMAGE') + PREVIEW_COLLECTIONS["avalon"] = pcoll + + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.TOPBAR_MT_editor_menus.append(draw_avalon_menu) + + +def unregister(): + """Unregister the operators and menu.""" + + pcoll = PREVIEW_COLLECTIONS.pop("avalon") + bpy.utils.previews.remove(pcoll) + bpy.types.TOPBAR_MT_editor_menus.remove(draw_avalon_menu) + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py new file mode 100644 index 0000000000..0e5104fea9 --- /dev/null +++ b/openpype/hosts/blender/api/pipeline.py @@ -0,0 +1,427 @@ +import os +import sys +import importlib +import traceback +from typing import Callable, Dict, Iterator, List, Optional + +import bpy + +from . import lib +from . import ops + +import pyblish.api +import avalon.api +from avalon import io, schema +from avalon.pipeline import AVALON_CONTAINER_ID + +from openpype.api import Logger +import openpype.hosts.blender + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +ORIGINAL_EXCEPTHOOK = sys.excepthook + +AVALON_INSTANCES = "AVALON_INSTANCES" +AVALON_CONTAINERS = "AVALON_CONTAINERS" +AVALON_PROPERTY = 'avalon' +IS_HEADLESS = bpy.app.background + +log = Logger.get_logger(__name__) + + +def pype_excepthook_handler(*args): + traceback.print_exception(*args) + + +def install(): + """Install Blender configuration for Avalon.""" + sys.excepthook = pype_excepthook_handler + + pyblish.api.register_host("blender") + pyblish.api.register_plugin_path(str(PUBLISH_PATH)) + + avalon.api.register_plugin_path(avalon.api.Loader, str(LOAD_PATH)) + avalon.api.register_plugin_path(avalon.api.Creator, str(CREATE_PATH)) + + lib.append_user_scripts() + + avalon.api.on("new", on_new) + avalon.api.on("open", on_open) + _register_callbacks() + _register_events() + + if not IS_HEADLESS: + ops.register() + + +def uninstall(): + """Uninstall Blender configuration for Avalon.""" + sys.excepthook = ORIGINAL_EXCEPTHOOK + + pyblish.api.deregister_host("blender") + pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) + + avalon.api.deregister_plugin_path(avalon.api.Loader, str(LOAD_PATH)) + avalon.api.deregister_plugin_path(avalon.api.Creator, str(CREATE_PATH)) + + if not IS_HEADLESS: + ops.unregister() + + +def set_start_end_frames(): + asset_name = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) + + scene = bpy.context.scene + + # Default scene settings + frameStart = scene.frame_start + frameEnd = scene.frame_end + fps = scene.render.fps + resolution_x = scene.render.resolution_x + resolution_y = scene.render.resolution_y + + # Check if settings are set + data = asset_doc.get("data") + + if not data: + return + + if data.get("frameStart"): + frameStart = data.get("frameStart") + if data.get("frameEnd"): + frameEnd = data.get("frameEnd") + if data.get("fps"): + fps = data.get("fps") + if data.get("resolutionWidth"): + resolution_x = data.get("resolutionWidth") + if data.get("resolutionHeight"): + resolution_y = data.get("resolutionHeight") + + scene.frame_start = frameStart + scene.frame_end = frameEnd + scene.render.fps = fps + scene.render.resolution_x = resolution_x + scene.render.resolution_y = resolution_y + + +def on_new(arg1, arg2): + set_start_end_frames() + + +def on_open(arg1, arg2): + set_start_end_frames() + + +@bpy.app.handlers.persistent +def _on_save_pre(*args): + avalon.api.emit("before_save", args) + + +@bpy.app.handlers.persistent +def _on_save_post(*args): + avalon.api.emit("save", args) + + +@bpy.app.handlers.persistent +def _on_load_post(*args): + # Detect new file or opening an existing file + if bpy.data.filepath: + # Likely this was an open operation since it has a filepath + avalon.api.emit("open", args) + else: + avalon.api.emit("new", args) + + ops.OpenFileCacher.post_load() + + +def _register_callbacks(): + """Register callbacks for certain events.""" + def _remove_handler(handlers: List, callback: Callable): + """Remove the callback from the given handler list.""" + + try: + handlers.remove(callback) + except ValueError: + pass + + # TODO (jasper): implement on_init callback? + + # Be sure to remove existig ones first. + _remove_handler(bpy.app.handlers.save_pre, _on_save_pre) + _remove_handler(bpy.app.handlers.save_post, _on_save_post) + _remove_handler(bpy.app.handlers.load_post, _on_load_post) + + bpy.app.handlers.save_pre.append(_on_save_pre) + bpy.app.handlers.save_post.append(_on_save_post) + bpy.app.handlers.load_post.append(_on_load_post) + + log.info("Installed event handler _on_save_pre...") + log.info("Installed event handler _on_save_post...") + log.info("Installed event handler _on_load_post...") + + +def _on_task_changed(*args): + """Callback for when the task in the context is changed.""" + + # TODO (jasper): Blender has no concept of projects or workspace. + # It would be nice to override 'bpy.ops.wm.open_mainfile' so it takes the + # workdir as starting directory. But I don't know if that is possible. + # Another option would be to create a custom 'File Selector' and add the + # `directory` attribute, so it opens in that directory (does it?). + # https://docs.blender.org/api/blender2.8/bpy.types.Operator.html#calling-a-file-selector + # https://docs.blender.org/api/blender2.8/bpy.types.WindowManager.html#bpy.types.WindowManager.fileselect_add + workdir = avalon.api.Session["AVALON_WORKDIR"] + log.debug("New working directory: %s", workdir) + + +def _register_events(): + """Install callbacks for specific events.""" + + avalon.api.on("taskChanged", _on_task_changed) + log.info("Installed event callback for 'taskChanged'...") + + +def reload_pipeline(*args): + """Attempt to reload pipeline at run-time. + + Warning: + This is primarily for development and debugging purposes and not well + tested. + + """ + + avalon.api.uninstall() + + for module in ( + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.tools.creator.app", + "avalon.tools.manager.app", + "avalon.api", + "avalon.tools", + ): + module = importlib.import_module(module) + importlib.reload(module) + + +def _discover_gui() -> Optional[Callable]: + """Return the most desirable of the currently registered GUIs""" + + # Prefer last registered + guis = reversed(pyblish.api.registered_guis()) + + for gui in guis: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + return None + + +def add_to_avalon_container(container: bpy.types.Collection): + """Add the container to the Avalon container.""" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + + # Link the container to the scene so it's easily visible to the artist + # and can be managed easily. Otherwise it's only found in "Blender + # File" view and it will be removed by Blenders garbage collection, + # unless you set a 'fake user'. + bpy.context.scene.collection.children.link(avalon_container) + + avalon_container.children.link(container) + + # Disable Avalon containers for the view layers. + for view_layer in bpy.context.scene.view_layers: + for child in view_layer.layer_collection.children: + if child.collection == avalon_container: + child.exclude = True + + +def metadata_update(node: bpy.types.bpy_struct_meta_idprop, data: Dict): + """Imprint the node with metadata. + + Existing metadata will be updated. + """ + + if not node.get(AVALON_PROPERTY): + node[AVALON_PROPERTY] = dict() + for key, value in data.items(): + if value is None: + continue + node[AVALON_PROPERTY][key] = value + + +def containerise(name: str, + namespace: str, + nodes: List, + context: Dict, + loader: Optional[str] = None, + suffix: Optional[str] = "CON") -> bpy.types.Collection: + """Bundle `nodes` into an assembly and imprint it with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name: Name of resulting assembly + namespace: Namespace under which to host container + nodes: Long names of nodes to containerise + context: Asset information + loader: Name of loader used to produce this container. + suffix: Suffix of container, defaults to `_CON`. + + Returns: + The container assembly + + """ + + node_name = f"{context['asset']['name']}_{name}" + if namespace: + node_name = f"{namespace}:{node_name}" + if suffix: + node_name = f"{node_name}_{suffix}" + container = bpy.data.collections.new(name=node_name) + # Link the children nodes + for obj in nodes: + container.objects.link(obj) + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + } + + metadata_update(container, data) + add_to_avalon_container(container) + + return container + + +def containerise_existing( + container: bpy.types.Collection, + name: str, + namespace: str, + context: Dict, + loader: Optional[str] = None, + suffix: Optional[str] = "CON") -> bpy.types.Collection: + """Imprint or update container with metadata. + + Arguments: + name: Name of resulting assembly + namespace: Namespace under which to host container + context: Asset information + loader: Name of loader used to produce this container. + suffix: Suffix of container, defaults to `_CON`. + + Returns: + The container assembly + """ + + node_name = container.name + if suffix: + node_name = f"{node_name}_{suffix}" + container.name = node_name + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + } + + metadata_update(container, data) + add_to_avalon_container(container) + + return container + + +def parse_container(container: bpy.types.Collection, + validate: bool = True) -> Dict: + """Return the container node's full container data. + + Args: + container: A container node name. + validate: turn the validation for the container on or off + + Returns: + The container schema data for this container node. + + """ + + data = lib.read(container) + + # Append transient data + data["objectName"] = container.name + + if validate: + schema.validate(data) + + return data + + +def ls() -> Iterator: + """List containers from active Blender scene. + + This is the host-equivalent of api.ls(), but instead of listing assets on + disk, it lists assets already loaded in Blender; once loaded they are + called containers. + """ + + for container in lib.lsattr("id", AVALON_CONTAINER_ID): + yield parse_container(container) + + +def update_hierarchy(containers): + """Hierarchical container support + + This is the function to support Scene Inventory to draw hierarchical + view for containers. + + We need both parent and children to visualize the graph. + + """ + + all_containers = set(ls()) # lookup set + + for container in containers: + # Find parent + # FIXME (jasperge): re-evaluate this. How would it be possible + # to 'nest' assets? Collections can have several parents, for + # now assume it has only 1 parent + parent = [ + coll for coll in bpy.data.collections if container in coll.children + ] + for node in parent: + if node in all_containers: + container["parent"] = node + break + + log.debug("Container: %s", container) + + yield container + + +def publish(): + """Shorthand to publish from within host.""" + + return pyblish.util.publish() diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 6d437059b8..602b3b0ff9 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -5,10 +5,17 @@ from typing import Dict, List, Optional import bpy -from avalon import api, blender -from avalon.blender import ops -from avalon.blender.pipeline import AVALON_CONTAINERS +import avalon.api from openpype.api import PypeCreatorMixin +from .pipeline import AVALON_CONTAINERS +from .ops import ( + MainThreadItem, + execute_in_main_thread +) +from .lib import ( + imprint, + get_selection +) VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"] @@ -119,11 +126,27 @@ def deselect_all(): bpy.context.view_layer.objects.active = active -class Creator(PypeCreatorMixin, blender.Creator): - pass +class Creator(PypeCreatorMixin, avalon.api.Creator): + """Base class for Creator plug-ins.""" + def process(self): + collection = bpy.data.collections.new(name=self.data["subset"]) + bpy.context.scene.collection.children.link(collection) + imprint(collection, self.data) + + if (self.options or {}).get("useSelection"): + for obj in get_selection(): + collection.objects.link(obj) + + return collection -class AssetLoader(api.Loader): +class Loader(avalon.api.Loader): + """Base class for Loader plug-ins.""" + + hosts = ["blender"] + + +class AssetLoader(avalon.api.Loader): """A basic AssetLoader for Blender This will implement the basic logic for linking/appending assets @@ -191,8 +214,8 @@ class AssetLoader(api.Loader): namespace: Optional[str] = None, options: Optional[Dict] = None) -> Optional[bpy.types.Collection]: """ Run the loader on Blender main thread""" - mti = ops.MainThreadItem(self._load, context, name, namespace, options) - ops.execute_in_main_thread(mti) + mti = MainThreadItem(self._load, context, name, namespace, options) + execute_in_main_thread(mti) def _load(self, context: dict, @@ -257,8 +280,8 @@ class AssetLoader(api.Loader): def update(self, container: Dict, representation: Dict): """ Run the update on Blender main thread""" - mti = ops.MainThreadItem(self.exec_update, container, representation) - ops.execute_in_main_thread(mti) + mti = MainThreadItem(self.exec_update, container, representation) + execute_in_main_thread(mti) def exec_remove(self, container: Dict) -> bool: """Must be implemented by a sub-class""" @@ -266,5 +289,5 @@ class AssetLoader(api.Loader): def remove(self, container: Dict) -> bool: """ Run the remove on Blender main thread""" - mti = ops.MainThreadItem(self.exec_remove, container) - ops.execute_in_main_thread(mti) + mti = MainThreadItem(self.exec_remove, container) + execute_in_main_thread(mti) diff --git a/openpype/hosts/blender/api/workio.py b/openpype/hosts/blender/api/workio.py new file mode 100644 index 0000000000..fd68761982 --- /dev/null +++ b/openpype/hosts/blender/api/workio.py @@ -0,0 +1,90 @@ +"""Host API required for Work Files.""" + +from pathlib import Path +from typing import List, Optional + +import bpy +from avalon import api + + +class OpenFileCacher: + """Store information about opening file. + + When file is opening QApplcation events should not be processed. + """ + opening_file = False + + @classmethod + def post_load(cls): + cls.opening_file = False + + @classmethod + def set_opening(cls): + cls.opening_file = True + + +def open_file(filepath: str) -> Optional[str]: + """Open the scene file in Blender.""" + OpenFileCacher.set_opening() + + preferences = bpy.context.preferences + load_ui = preferences.filepaths.use_load_ui + use_scripts = preferences.filepaths.use_scripts_auto_execute + result = bpy.ops.wm.open_mainfile( + filepath=filepath, + load_ui=load_ui, + use_scripts=use_scripts, + ) + + if result == {'FINISHED'}: + return filepath + return None + + +def save_file(filepath: str, copy: bool = False) -> Optional[str]: + """Save the open scene file.""" + + preferences = bpy.context.preferences + compress = preferences.filepaths.use_file_compression + relative_remap = preferences.filepaths.use_relative_paths + result = bpy.ops.wm.save_as_mainfile( + filepath=filepath, + compress=compress, + relative_remap=relative_remap, + copy=copy, + ) + + if result == {'FINISHED'}: + return filepath + return None + + +def current_file() -> Optional[str]: + """Return the path of the open scene file.""" + + current_filepath = bpy.data.filepath + if Path(current_filepath).is_file(): + return current_filepath + return None + + +def has_unsaved_changes() -> bool: + """Does the open scene file have unsaved changes?""" + + return bpy.data.is_dirty + + +def file_extensions() -> List[str]: + """Return the supported file extensions for Blender scene files.""" + + return api.HOST_WORKFILE_EXTENSIONS["blender"] + + +def work_root(session: dict) -> str: + """Return the default root to browse for work files.""" + + work_dir = session["AVALON_WORKDIR"] + scene_dir = session.get("AVALON_SCENEDIR") + if scene_dir: + return str(Path(work_dir, scene_dir)) + return work_dir diff --git a/openpype/hosts/blender/blender_addon/startup/init.py b/openpype/hosts/blender/blender_addon/startup/init.py new file mode 100644 index 0000000000..e43373bc6c --- /dev/null +++ b/openpype/hosts/blender/blender_addon/startup/init.py @@ -0,0 +1,4 @@ +from avalon import pipeline +from openpype.hosts.blender import api + +pipeline.install(api) diff --git a/openpype/hosts/blender/plugins/__init__.py b/openpype/hosts/blender/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/create/__init__.py b/openpype/hosts/blender/plugins/create/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/create/create_action.py b/openpype/hosts/blender/plugins/create/create_action.py index f7bb2bfc26..5f66f5da6e 100644 --- a/openpype/hosts/blender/plugins/create/create_action.py +++ b/openpype/hosts/blender/plugins/create/create_action.py @@ -4,7 +4,7 @@ import bpy from avalon import api import openpype.hosts.blender.api.plugin -from avalon.blender import lib +from openpype.hosts.blender.api import lib class CreateAction(openpype.hosts.blender.api.plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_animation.py b/openpype/hosts/blender/plugins/create/create_animation.py index 3b4cabe8ec..b88010ae90 100644 --- a/openpype/hosts/blender/plugins/create/create_animation.py +++ b/openpype/hosts/blender/plugins/create/create_animation.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateAnimation(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 6fa80b5a5d..cc796d464d 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateCamera(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index dac12e19b1..f62cbc52ba 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateLayout(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index 903b70033b..75c90f9bb1 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateModel(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 03a468f82e..bf5a84048f 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -3,8 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib import openpype.hosts.blender.api.plugin +from openpype.hosts.blender.api import lib class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index ec74e279c6..65f5061924 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -3,9 +3,8 @@ import bpy from avalon import api -from avalon.blender import lib, ops -from avalon.blender.pipeline import AVALON_INSTANCES -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES class CreateRig(plugin.Creator): diff --git a/openpype/hosts/blender/plugins/load/__init__.py b/openpype/hosts/blender/plugins/load/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 5969432c36..07800521c9 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -7,11 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender import lib -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) +from openpype.hosts.blender.api import plugin, lib class CacheModelLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_animation.py b/openpype/hosts/blender/plugins/load/load_animation.py index 47c48248b2..6b8d4abd04 100644 --- a/openpype/hosts/blender/plugins/load/load_animation.py +++ b/openpype/hosts/blender/plugins/load/load_animation.py @@ -1,16 +1,11 @@ """Load an animation in Blender.""" -import logging from typing import Dict, List, Optional import bpy -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin - - -logger = logging.getLogger("openpype").getChild( - "blender").getChild("load_animation") +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class BlendAnimationLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py index 660e4d7890..e065150c15 100644 --- a/openpype/hosts/blender/plugins/load/load_audio.py +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -7,10 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class AudioLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py index 834eb467d8..61955f124d 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_blend.py +++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py @@ -8,10 +8,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) logger = logging.getLogger("openpype").getChild( "blender").getChild("load_camera") diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index 5edba7ec0c..175ddacf9f 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -7,11 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender import lib -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class FbxCameraLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index 5f69aecb1a..c6e6af5592 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -7,11 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender import lib -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api import plugin, lib +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class FbxModelLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index 4c1f751a77..dff7ffb9c6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -7,10 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class BlendLayoutLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 442cf05d85..2378ae4807 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -1,18 +1,20 @@ """Load a layout in Blender.""" +import json from pathlib import Path from pprint import pformat from typing import Dict, Optional import bpy -import json from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY -from avalon.blender.pipeline import AVALON_INSTANCES from openpype import lib +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) from openpype.hosts.blender.api import plugin diff --git a/openpype/hosts/blender/plugins/load/load_look.py b/openpype/hosts/blender/plugins/load/load_look.py index 279af2b626..066ec0101b 100644 --- a/openpype/hosts/blender/plugins/load/load_look.py +++ b/openpype/hosts/blender/plugins/load/load_look.py @@ -8,8 +8,12 @@ import os import json import bpy -from avalon import api, blender -import openpype.hosts.blender.api.plugin as plugin +from avalon import api +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + containerise_existing, + AVALON_PROPERTY +) class BlendLookLoader(plugin.AssetLoader): @@ -105,7 +109,7 @@ class BlendLookLoader(plugin.AssetLoader): container = bpy.data.collections.new(lib_container) container.name = container_name - blender.pipeline.containerise_existing( + containerise_existing( container, name, namespace, @@ -113,7 +117,7 @@ class BlendLookLoader(plugin.AssetLoader): self.__class__.__name__, ) - metadata = container.get(blender.pipeline.AVALON_PROPERTY) + metadata = container.get(AVALON_PROPERTY) metadata["libpath"] = libpath metadata["lib_container"] = lib_container @@ -161,7 +165,7 @@ class BlendLookLoader(plugin.AssetLoader): f"Unsupported file: {libpath}" ) - collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY) + collection_metadata = collection.get(AVALON_PROPERTY) collection_libpath = collection_metadata["libpath"] normalized_collection_libpath = ( @@ -204,7 +208,7 @@ class BlendLookLoader(plugin.AssetLoader): if not collection: return False - collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY) + collection_metadata = collection.get(AVALON_PROPERTY) for obj in collection_metadata['objects']: for child in self.get_all_children(obj): diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index c33c656dec..861da9b852 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -7,10 +7,12 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class BlendModelLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index e80da8af45..b753488144 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -7,11 +7,13 @@ from typing import Dict, List, Optional import bpy from avalon import api -from avalon.blender.pipeline import AVALON_CONTAINERS -from avalon.blender.pipeline import AVALON_CONTAINER_ID -from avalon.blender.pipeline import AVALON_PROPERTY from openpype import lib from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, + AVALON_CONTAINER_ID +) class BlendRigLoader(plugin.AssetLoader): diff --git a/openpype/hosts/blender/plugins/publish/__init__.py b/openpype/hosts/blender/plugins/publish/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 0d683dace4..bc4b5ab092 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,11 +1,13 @@ +import json from typing import Generator import bpy -import json import pyblish.api -from avalon.blender.pipeline import AVALON_PROPERTY -from avalon.blender.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api.pipeline import ( + AVALON_INSTANCES, + AVALON_PROPERTY, +) class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index b75bec4e28..a26a92f7e4 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -1,10 +1,10 @@ import os +import bpy + from openpype import api from openpype.hosts.blender.api import plugin -from avalon.blender.pipeline import AVALON_PROPERTY - -import bpy +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractABC(api.Extractor): diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index 565e2fe425..9add633f05 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -2,7 +2,6 @@ import os import bpy -# import avalon.blender.workio import openpype.api diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera.py index a0e78178c8..597dcecd21 100644 --- a/openpype/hosts/blender/plugins/publish/extract_camera.py +++ b/openpype/hosts/blender/plugins/publish/extract_camera.py @@ -1,10 +1,10 @@ import os +import bpy + from openpype import api from openpype.hosts.blender.api import plugin -import bpy - class ExtractCamera(api.Extractor): """Extract as the camera as FBX.""" diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index f9ffdea1d1..26344777a8 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -1,10 +1,10 @@ import os +import bpy + from openpype import api from openpype.hosts.blender.api import plugin -from avalon.blender.pipeline import AVALON_PROPERTY - -import bpy +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractFBX(api.Extractor): diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py index 16443b760c..50a414c0d6 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx_animation.py @@ -7,7 +7,7 @@ import bpy_extras.anim_utils from openpype import api from openpype.hosts.blender.api import plugin -from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY class ExtractAnimationFBX(api.Extractor): diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index cd081b4479..1ecf66099c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -4,7 +4,7 @@ import json import bpy from avalon import io -from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY import openpype.api diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index b81e1111ea..986842d0d6 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -1,5 +1,5 @@ import pyblish.api -import avalon.blender.workio +from openpype.hosts.blender.api.workio import save_file class IncrementWorkfileVersion(pyblish.api.ContextPlugin): @@ -20,6 +20,6 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): path = context.data["currentFile"] filepath = version_up(path) - avalon.blender.workio.save_file(filepath, copy=False) + save_file(filepath, copy=False) self.log.info('Incrementing script version') diff --git a/openpype/hosts/blender/startup/init.py b/openpype/hosts/blender/startup/init.py deleted file mode 100644 index 4b4e48fedc..0000000000 --- a/openpype/hosts/blender/startup/init.py +++ /dev/null @@ -1,3 +0,0 @@ -from openpype.hosts.blender import api - -api.install()