From 1a7911df8ebc435652a8abc0f4e89698bda5681e Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 30 Oct 2019 13:15:03 +0100 Subject: [PATCH 01/25] Add Blender 2.80 support --- avalon/blender/__init__.py | 58 ++++ avalon/blender/icons/pyblish-32x32.png | Bin 0 -> 632 bytes avalon/blender/lib.py | 171 ++++++++++ avalon/blender/ops.py | 391 +++++++++++++++++++++++ avalon/blender/pipeline.py | 421 +++++++++++++++++++++++++ avalon/blender/workio.py | 68 ++++ setup/blender/startup/avalon_setup.py | 7 + 7 files changed, 1116 insertions(+) create mode 100644 avalon/blender/__init__.py create mode 100644 avalon/blender/icons/pyblish-32x32.png create mode 100644 avalon/blender/lib.py create mode 100644 avalon/blender/ops.py create mode 100644 avalon/blender/pipeline.py create mode 100644 avalon/blender/workio.py create mode 100644 setup/blender/startup/avalon_setup.py diff --git a/avalon/blender/__init__.py b/avalon/blender/__init__.py new file mode 100644 index 000000000..8c1d34ba7 --- /dev/null +++ b/avalon/blender/__init__.py @@ -0,0 +1,58 @@ +"""Public API + +Anything that isn't defined here is INTERNAL and unreliable for external use. + +""" + +from .pipeline import ( + install, + uninstall, + Creator, + Loader, + ls, + publish, + containerise, +) + +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root, +) + +from .lib import ( + lsattr, + lsattrs, + read, + maintained_selection, + # unique_name, +) + + +__all__ = [ + "install", + "uninstall", + "Creator", + "Loader", + "ls", + "publish", + "containerise", + + # Workfiles API + "open", + "save", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + + # Utility functions + "maintained_selection", + "lsattr", + "lsattrs", + "read", + # "unique_name", +] diff --git a/avalon/blender/icons/pyblish-32x32.png b/avalon/blender/icons/pyblish-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b34e397e0bd502eb336f994f014a518198d93599 GIT binary patch literal 632 zcmV-;0*C#HP)?m3OVFzoDHzW14L zXLc9IkqG*a3_I>wqC(4bTV70aHK&_zb)S-or zdR!e?Gz01oa2+_3G`|NP#ua@8z5!o>X<#;9R|2bmL0|;f63ZT7V-l+d919^l%3Ar^ zg#3!SwovNY$J`#X`Bzud{=Sy+^`w3nIH_(b$uI|`d*DIZ+4=~EfmcB5%AW!Efyc4_ z377pl-+RGsR;{ENE2 zlz9SiqM-S_dY}^X1mao&fb*4_M}R@~`4Y_Us>><|h!DGM9;+<*qLEA%q6-GNXH<>i|*pjP~hX0nBH#FZ2qakcE>` z0W5F17Z?vA{OT}XF{!tbqt{SR^~5*hEygi&)Nu5GKn2{MR3FJp2!~ Sv8(t10000 List[bpy.types.Object]: + """Return the currently selected objects in the current view layer. + + Note: + It may seem trivial in Blender to use bpy.context.selected_objects + however the context may vary on how the code is being run and the + `selected_objects` might not be available. So this function queries + it explicitly from the current view layer. + + See: + https://blender.stackexchange.com/questions/36281/bpy-context-selected-objects-context-object-has-no-attribute-selected-objects + + Returns: + The selected objects. + + """ + # Note that in Blender 2.8+ the code to see if an object is selected is + # object.select_get() as opposed to the object.select getter property + # it was before. + return [obj for obj in bpy.context.view_layer.objects if obj.select_get()] + + +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 + + +@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 = bpy.context.selected_objects + previous_active = bpy.context.view_layer.objects.active + try: + yield + finally: + # Clear the selection + for node in bpy.context.selected_objects: + 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. + logger.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. + logger.exception("Failed to set active object.") diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py new file mode 100644 index 000000000..09794c899 --- /dev/null +++ b/avalon/blender/ops.py @@ -0,0 +1,391 @@ +"""Blender operators and menus for use with Avalon.""" + +import os +import sys +from distutils.util import strtobool +from pathlib import Path +from pprint import pformat +from types import ModuleType +from typing import Dict, List, Optional, Union + +import bpy +import bpy.utils.previews + +import avalon.api as api +from avalon.vendor.Qt import QtCore, QtWidgets +from avalon.vendor.Qt.QtWidgets import ( + QApplication, + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) + +preview_collections: Dict = dict() + + +def _is_avalon_in_debug_mode() -> bool: + """Check if Avalon is in debug mode.""" + try: + # It says `strtobool` returns a bool, but it returns an int :/ + return bool(strtobool(os.environ.get('AVALON_DEBUG', "False"))) + except: + # If it can't logically be converted to a bool, assume it's False. + return False + + +class Example(QDialog): + """Silly window with quit button.""" + + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + """The UI for this silly window.""" + + bpybtn = QPushButton('Print bpy.context', self) + bpybtn.clicked.connect(self.print_bpy_context) + + activebtn = QPushButton('Print Active Object', self) + activebtn.clicked.connect(self.print_active) + + selectedbtn = QPushButton('Print Selected Objects', self) + selectedbtn.clicked.connect(self.print_selected) + + qbtn = QPushButton('Quit', self) + qbtn.clicked.connect(self.close) + + vbox = QVBoxLayout(self) + + hbox_output = QHBoxLayout() + self.label = QLabel('') + size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Preferred) + self.label.setSizePolicy(size_policy) + self.label.setMinimumSize(QtCore.QSize(100, 0)) + self.label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTrailing + | QtCore.Qt.AlignVCenter) + hbox_output.addWidget(self.label) + self.outputlabel = QLabel('') + hbox_output.addWidget(self.outputlabel) + vbox.addLayout(hbox_output) + + hbox_buttons = QHBoxLayout() + hbox_buttons.addWidget(bpybtn) + hbox_buttons.addWidget(activebtn) + hbox_buttons.addWidget(selectedbtn) + hbox_buttons.addWidget(qbtn) + vbox.addLayout(hbox_buttons) + + self.setGeometry(300, 300, 300, 200) + self.setWindowTitle('Blender Qt test') + + def print_bpy_context(self): + """Print `bpy.context` console and UI.""" + context_string = pformat(bpy.context.copy(), indent=2) + print(f"Context: {context_string}") + self.label.setText('Context:') + # Limit the text to 50 lines for display in the UI + context_list = context_string.split('\n') + limited_context_list = context_list[0:50] + if len(context_list) > len(limited_context_list): + limited_context_list.append('(...)') + limited_context_string = '\n'.join(limited_context_list) + self.outputlabel.setText(limited_context_string) + + def print_active(self): + """Print the active object to console and UI.""" + context = bpy.context.copy() + if context.get('object'): + objname = context['object'].name + else: + objname = 'No active object.' + print(f"Active Object: {objname}") + self.label.setText('Active Object:') + self.outputlabel.setText(objname) + + def print_selected(self): + """Print the selected objects to console and UI.""" + context = bpy.context.copy() + if context.get('selected_objects'): + selected_list = [obj.name for obj in context['selected_objects']] + else: + selected_list = ['No selected objects.'] + selected_string = '\n'.join(selected_list) + print(f"Selected Objects: {selected_string}") + # Limit the text to 50 lines for display in the UI + limited_selected_list = selected_list[0:50] + if len(selected_list) > len(limited_selected_list): + limited_selected_list.append('(...)') + limited_selected_string = '\n'.join(limited_selected_list) + self.label.setText('Selected Objects:') + self.outputlabel.setText(limited_selected_string) + + +class LaunchQtApp(bpy.types.Operator): + """A Base class for opertors to launch a Qt app.""" + + _app: Optional[QApplication] + _window: Optional[Union[QDialog, ModuleType]] + _timer: Optional[bpy.types.Timer] + _show_args: Optional[List] + _show_kwargs: Optional[Dict] + + def __init__(self): + from .. import style + print(f"Initialising {self.bl_idname}...") + self._app = QApplication.instance() or QApplication(sys.argv) + self._app.setStyleSheet(style.load_stylesheet()) + + def _is_window_visible(self) -> bool: + """Check if the window of the app is visible. + + If `self._window` is an instance of `QDialog`, simply return + `self._window.isVisible()`. If `self._window` is a module check + if it has `self._window.app.window` and if so, return `isVisible()` + on that. + Else return False, because we don't know how to check if the + window is visible. + """ + window: Optional[QDialog] = None + if isinstance(self._window, QDialog): + window = self._window + if isinstance(self._window, ModuleType): + try: + window = self._window.app.window + except AttributeError: + return False + + try: + return window is not None and window.isVisible() + except (AttributeError, RuntimeError): + pass + + return False + + def modal(self, context, event): + """Run modal to keep Blender and the Qt UI responsive.""" + + if event.type == 'TIMER': + if self._is_window_visible(): + # Process events if the window is visible + self._app.processEvents() + else: + # Stop the operator if the window is closed + self.cancel(context) + print(f"Stopping modal execution of '{self.bl_idname}'") + return {'FINISHED'} + + return {'PASS_THROUGH'} + + 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. + """ + + # Check if `self._window` is properly set + if (getattr(self, "_window") is None + or not isinstance(self._window, (QDialog, ModuleType))): + raise AttributeError("`self._window` should be set.") + + args = getattr(self, "_show_args", list()) + kwargs = getattr(self, "_show_kwargs", dict()) + self._window.show(*args, **kwargs) + wm = context.window_manager + # Run every 0.01 seconds + self._timer = wm.event_timer_add(0.01, window=context.window) + wm.modal_handler_add(self) + + return {'RUNNING_MODAL'} + + def cancel(self, context): + """Remove the event timer when stopping the operator.""" + wm = context.window_manager + wm.event_timer_remove(self._timer) + + +class LaunchContextManager(LaunchQtApp): + """Launch Avalon Context Manager.""" + + bl_idname = "wm.avalon_contextmanager" + bl_label = "Set Avalon Context..." + + def execute(self, context): + from ..tools import contextmanager + self._window = contextmanager + return super().execute(context) + + +class LaunchCreator(LaunchQtApp): + """Launch Avalon Creator.""" + + bl_idname = "wm.avalon_creator" + bl_label = "Create..." + + def execute(self, context): + from ..tools import creator + self._window = creator + # self._show_kwargs = {'debug': _is_avalon_in_debug_mode()} + return super().execute(context) + + +class LaunchLoader(LaunchQtApp): + """Launch Avalon Loader.""" + + bl_idname = "wm.avalon_loader" + bl_label = "Load..." + + def execute(self, context): + from .. import style + from ..tools import loader + self._window = loader + if self._window.app.window is not None: + self._window.app.window = None + self._show_kwargs = { + 'use_context': True, + # 'debug': _is_avalon_in_debug_mode(), + } + return super().execute(context) + + +class LaunchPublisher(LaunchQtApp): + """Launch Avalon Publisher.""" + + bl_idname = "wm.avalon_publisher" + bl_label = "Publish..." + + def execute(self, context): + from ..tools import publish + publish_show = publish._discover_gui() + if publish_show.__module__ == 'pyblish_qml': + # When using Pyblish QML we don't have to do anything special + publish.show() + return {'FINISHED'} + self._window = publish + return super().execute(context) + + +class LaunchManager(LaunchQtApp): + """Launch Avalon Manager.""" + + bl_idname = "wm.avalon_manager" + bl_label = "Manage..." + + def execute(self, context): + from ..tools import cbsceneinventory + self._window = cbsceneinventory + # self._show_kwargs = {'debug': _is_avalon_in_debug_mode()} + return super().execute(context) + + +class LaunchWorkFiles(LaunchQtApp): + """Launch Avalon Work Files.""" + + bl_idname = "wm.avalon_workfiles" + bl_label = "Work Files..." + + def execute(self, context): + from ..tools import workfiles + root = str(Path(os.environ.get("AVALON_WORKDIR", ""), "scene")) + self._window = workfiles + self._show_kwargs = {"root": root} + return super().execute(context) + + +class TestApp(LaunchQtApp): + """Launch a simple test app.""" + + bl_idname = "wm.avalon_test_app" + bl_label = "Test App..." + + def execute(self, context): + from .. import style + self._window = Example() + self._window.setStyleSheet(style.load_stylesheet()) + return super().execute(context) + + +class TOPBAR_MT_avalon(bpy.types.Menu): + """Avalon menu.""" + + bl_idname = "TOPBAR_MT_avalon" + bl_label = "Avalon" + + 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 + + context_label = f"{api.Session['AVALON_ASSET']}, {api.Session['AVALON_TASK']}" + layout.operator(LaunchContextManager.bl_idname, text=context_label) + 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...") + if _is_avalon_in_debug_mode(): + layout.separator() + layout.operator(TestApp.bl_idname, text="Test App...") + # 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 = [ + LaunchContextManager, + LaunchCreator, + LaunchLoader, + LaunchPublisher, + LaunchManager, + LaunchWorkFiles, +] +if _is_avalon_in_debug_mode(): + # Enable the test app in debug mode + classes.append(TestApp) +classes.append(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/avalon/blender/pipeline.py b/avalon/blender/pipeline.py new file mode 100644 index 000000000..8406a3070 --- /dev/null +++ b/avalon/blender/pipeline.py @@ -0,0 +1,421 @@ +"""Pipeline integration functions.""" + +import importlib +import sys +from types import ModuleType +from typing import Callable, Dict, Iterator, List, Optional + +import bpy + +import pyblish.api +import pyblish.util +from avalon import api, schema + +from ..lib import logger +from ..pipeline import AVALON_CONTAINER_ID +from . import lib, ops + +self = sys.modules[__name__] +self._events = dict() # Registered Blender callbacks +self._parent = None # Main window +self._ignore_lock = False + +AVALON_CONTAINERS = "AVALON_CONTAINERS" +AVALON_PROPERTY = 'avalon' +IS_HEADLESS = bpy.app.background + + +@bpy.app.handlers.persistent +def _on_save_pre(*args): + api.emit("before_save", args) + + +@bpy.app.handlers.persistent +def _on_save_post(*args): + 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 + api.emit("open", args) + else: + api.emit("new", args) + + +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) + + logger.info("Installed event handler _on_save_pre...") + logger.info("Installed event handler _on_save_post...") + logger.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 = api.Session["AVALON_WORKDIR"] + + +def _register_events(): + """Install callbacks for specific events.""" + api.on("taskChanged", _on_task_changed) + logger.info("Installed event callback for 'taskChanged'...") + + +def find_host_config(config: ModuleType) -> Optional[ModuleType]: + """Find the config for the current host (Blender).""" + + config_name = f"{config.__name__}.blender" + try: + return importlib.import_module(config_name) + except ImportError as exc: + if str(exc) != f"No module named '{config_name}'": + raise + return None + + +def install(config: ModuleType): + """Install Blender-specific functionality for Avalon. + + This function is called automatically on calling `api.install(blender)`. + """ + + _register_callbacks() + _register_events() + + if not IS_HEADLESS: + ops.register() + + pyblish.api.register_host("blender") + + host_config = find_host_config(config) + if hasattr(host_config, "install"): + host_config.install() + + +def uninstall(config: ModuleType): + """Uninstall Blender-specific functionality of avalon-core. + + This function is called automatically on calling `api.uninstall()`. + + Args: + config: configuration module + """ + + host_config = find_host_config(config) + if hasattr(config, "uninstall"): + host_config.uninstall() + + if not IS_HEADLESS: + ops.unregister() + + pyblish.api.deregister_host("blender") + + +def reload_pipeline(*args): + """Attempt to reload pipeline at run-time. + + Warning: + This is primarily for development and debugging purposes and not well + tested. + + """ + + api.uninstall() + + for module in ("avalon.io", "avalon.lib", "avalon.pipeline", "avalon.blender.pipeline", + "avalon.blender.lib", "avalon.tools.loader.app", "avalon.tools.creator.app", + "avalon.tools.manager.app", "avalon.api", "avalon.tools", "avalon.blender"): + module = importlib.import_module(module) + importlib.reload(module) + + import avalon.blender + api.install(avalon.blender) + + +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 teardown(): + """Remove integration""" + if not self._has_been_setup: + return + + self._has_been_setup = False + logger.info("pyblish: Integration torn down successfully") + + +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": "avalon-core: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 namespace: + node_name = f"{namespace}:{node_name}" + if suffix: + node_name = f"{node_name}_{suffix}" + container.name = node_name + data = { + "schema": "avalon-core: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(): + return lib.lsattr("id", AVALON_CONTAINER_ID) + + +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. + """ + containers = _ls() + + has_metadata_collector = False + config = find_host_config(api.registered_config()) + if config is None: + logger.error("Could not find host config for Blender") + return tuple() + if hasattr(config, "collect_container_metadata"): + has_metadata_collector = True + + for container in containers: + data = parse_container(container) + + # Collect custom data if property is present + if has_metadata_collector: + metadata = config.collect_container_metadata(container) + data.update(metadata) + + yield data + + +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? What is container["objectName"] and where does it come + # from? + # 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 + + # List children + # children = [child for child in container.children if child in all_containers] + + logger.debug("Container: %s", container) + + yield container + + +def publish(): + """Shorthand to publish from within host.""" + return pyblish.util.publish() + + +class Creator(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) + lib.imprint(collection, self.data) + + if (self.options or {}).get("useSelection"): + for obj in bpy.context.selected_objects: + collection.objects.link(obj) + + return collection + + +class Loader(api.Loader): + """Base class for Loader plug-ins.""" + hosts = ["blender"] + + def __init__(self, context): + super().__init__(context) + self.fname = self.fname.replace(api.registered_root(), "$AVALON_PROJECTS") diff --git a/avalon/blender/workio.py b/avalon/blender/workio.py new file mode 100644 index 000000000..4990a7929 --- /dev/null +++ b/avalon/blender/workio.py @@ -0,0 +1,68 @@ +"""Host API required for Work Files.""" + +from pathlib import Path +from typing import List, Optional + +import bpy + + +def open_file(filepath: str) -> Optional[str]: + """Open the scene file in Blender.""" + 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 [".blend"] + + +def work_root() -> str: + """Return the default root to browse for work files.""" + from avalon import api + + work_dir = api.Session["AVALON_WORKDIR"] + scene_dir = api.Session.get("AVALON_SCENEDIR") + if scene_dir: + return str(Path(work_dir, scene_dir)) + return work_dir diff --git a/setup/blender/startup/avalon_setup.py b/setup/blender/startup/avalon_setup.py new file mode 100644 index 000000000..932ec5c2e --- /dev/null +++ b/setup/blender/startup/avalon_setup.py @@ -0,0 +1,7 @@ +from avalon import api, blender + + +def register(): + """Register Avalon with Blender.""" + print("Registering Avalon...") + api.install(blender) From baf6c49d7a1314f7054a8018328cc39852efa16a Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 30 Oct 2019 15:40:46 +0100 Subject: [PATCH 02/25] Some style changes and cleanup --- avalon/blender/lib.py | 41 +++++++++++--------------------------- avalon/blender/ops.py | 3 +-- avalon/blender/pipeline.py | 41 +++++++++++++++++++++++++------------- 3 files changed, 40 insertions(+), 45 deletions(-) diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py index 6cc929af9..92943c2d5 100644 --- a/avalon/blender/lib.py +++ b/avalon/blender/lib.py @@ -9,30 +9,6 @@ from . import pipeline -# TODO (jasper): Remove this function when everyting runs fine with the new way -# of calling the Qt apps. -def get_selected() -> List[bpy.types.Object]: - """Return the currently selected objects in the current view layer. - - Note: - It may seem trivial in Blender to use bpy.context.selected_objects - however the context may vary on how the code is being run and the - `selected_objects` might not be available. So this function queries - it explicitly from the current view layer. - - See: - https://blender.stackexchange.com/questions/36281/bpy-context-selected-objects-context-object-has-no-attribute-selected-objects - - Returns: - The selected objects. - - """ - # Note that in Blender 2.8+ the code to see if an object is selected is - # object.select_get() as opposed to the object.select getter property - # it was before. - return [obj for obj in bpy.context.view_layer.objects if obj.select_get()] - - def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): r"""Write `data` to `node` as userDefined attributes @@ -75,7 +51,8 @@ def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): pipeline.metadata_update(node, imprint_data) -def lsattr(attr: str, value: Union[str, int, bool, List, Dict, None] = None) -> List: +def lsattr(attr: str, + value: Union[str, int, bool, List, Dict, None] = None) -> List: r"""Return nodes matching `attr` and `value` Arguments: @@ -112,14 +89,18 @@ def lsattrs(attrs: Dict) -> 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): + 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): + if avalon_prop.get(attr) and (value is None or + avalon_prop.get(attr) == value): matches.add(node) return list(matches) @@ -129,7 +110,10 @@ def read(node: bpy.types.bpy_struct_meta_idprop): data = dict(node.get(pipeline.AVALON_PROPERTY)) # Ignore hidden/internal data - data = {key: value for key, value in data.items() if not key.startswith("_")} + data = { + key: value + for key, value in data.items() if not key.startswith("_") + } return data @@ -162,7 +146,6 @@ def maintained_selection(): # the context. logger.exception("Failed to reselect") continue - try: bpy.context.view_layer.objects.active = previous_active except ReferenceError: diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 09794c899..24e05d1ed 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -30,7 +30,7 @@ def _is_avalon_in_debug_mode() -> bool: try: # It says `strtobool` returns a bool, but it returns an int :/ return bool(strtobool(os.environ.get('AVALON_DEBUG', "False"))) - except: + except (ValueError, AttributeError): # If it can't logically be converted to a bool, assume it's False. return False @@ -245,7 +245,6 @@ class LaunchLoader(LaunchQtApp): bl_label = "Load..." def execute(self, context): - from .. import style from ..tools import loader self._window = loader if self._window.app.window is not None: diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 8406a3070..bd508fafb 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -150,9 +150,19 @@ def reload_pipeline(*args): api.uninstall() - for module in ("avalon.io", "avalon.lib", "avalon.pipeline", "avalon.blender.pipeline", - "avalon.blender.lib", "avalon.tools.loader.app", "avalon.tools.creator.app", - "avalon.tools.manager.app", "avalon.api", "avalon.tools", "avalon.blender"): + for module in ( + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.blender.pipeline", + "avalon.blender.lib", + "avalon.tools.loader.app", + "avalon.tools.creator.app", + "avalon.tools.manager.app", + "avalon.api", + "avalon.tools", + "avalon.blender", + ): module = importlib.import_module(module) importlib.reload(module) @@ -273,7 +283,8 @@ def containerise_existing(container: bpy.types.Collection, namespace: str, context: Dict, loader: Optional[str] = None, - suffix: Optional[str] = "CON") -> bpy.types.Collection: + suffix: Optional[str] = "CON" + ) -> bpy.types.Collection: """Imprint or update container with metadata. Arguments: @@ -307,7 +318,8 @@ def containerise_existing(container: bpy.types.Collection, return container -def parse_container(container: bpy.types.Collection, validate: bool = True) -> Dict: +def parse_container(container: bpy.types.Collection, + validate: bool = True) -> Dict: """Return the container node's full container data. Args: @@ -374,19 +386,17 @@ def update_hierarchy(containers): for container in containers: # Find parent - # FIXME (jasperge): re-evaluate this. How would it be possible to - # 'nest' assets? What is container["objectName"] and where does it come - # from? - # 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] + # 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 - # List children - # children = [child for child in container.children if child in all_containers] - logger.debug("Container: %s", container) yield container @@ -418,4 +428,7 @@ class Loader(api.Loader): def __init__(self, context): super().__init__(context) - self.fname = self.fname.replace(api.registered_root(), "$AVALON_PROJECTS") + self.fname = self.fname.replace( + api.registered_root(), + "$AVALON_PROJECTS", + ) From d83484cc81a174f432a0a6b788135545e67a8b66 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 30 Oct 2019 15:56:49 +0100 Subject: [PATCH 03/25] Fix: renamed open_file and save_file in __all__ --- avalon/blender/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/avalon/blender/__init__.py b/avalon/blender/__init__.py index 8c1d34ba7..2004870d1 100644 --- a/avalon/blender/__init__.py +++ b/avalon/blender/__init__.py @@ -42,8 +42,8 @@ "containerise", # Workfiles API - "open", - "save", + "open_file", + "save_file", "current_file", "has_unsaved_changes", "file_extensions", From 4e2ab623100f3b7c729ab71890991b3cc48e0516 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 30 Oct 2019 16:16:50 +0100 Subject: [PATCH 04/25] Fix: fix line lenght + unused var --- avalon/blender/ops.py | 4 +++- avalon/blender/pipeline.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 24e05d1ed..0e4f03b6f 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -330,7 +330,9 @@ def draw(self, context): else: pyblish_menu_icon_id = 0 - context_label = f"{api.Session['AVALON_ASSET']}, {api.Session['AVALON_TASK']}" + asset = api.Session['AVALON_ASSET'] + task = api.Session['AVALON_TASK'] + context_label = f"{asset}, {task}" layout.operator(LaunchContextManager.bl_idname, text=context_label) layout.separator() layout.operator(LaunchCreator.bl_idname, text="Create...") diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index bd508fafb..4bea609c8 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -81,6 +81,7 @@ def _on_task_changed(*args): # 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 = api.Session["AVALON_WORKDIR"] + logger.debug("New working directory: %s", workdir) def _register_events(): From a1bf183cf1acaba570541dcc95c7be4a085a13fd Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 30 Oct 2019 16:17:50 +0100 Subject: [PATCH 05/25] Style fix Actually unrelated to Blender, but thought I snuck it in... --- avalon/tools/workfiles/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index 87689392f..a6f3ed86f 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -367,7 +367,8 @@ def save_changes_prompt(self): "\nDo you want to save the changes?" ) self._messagebox.setStandardButtons( - self._messagebox.Yes | self._messagebox.No | + self._messagebox.Yes | + self._messagebox.No | self._messagebox.Cancel ) result = self._messagebox.exec_() From ee2954b2d1dfdc09c2cfca4c50d1e7d7cb3d29f6 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Fri, 1 Nov 2019 15:35:37 +0100 Subject: [PATCH 06/25] Import QtWidgets with namespace --- avalon/blender/ops.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 0e4f03b6f..cd951407c 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -127,8 +127,8 @@ def print_selected(self): class LaunchQtApp(bpy.types.Operator): """A Base class for opertors to launch a Qt app.""" - _app: Optional[QApplication] - _window: Optional[Union[QDialog, ModuleType]] + _app: Optional[QtWidgets.QApplication] + _window: Optional[Union[QtWidgets.QDialog, ModuleType]] _timer: Optional[bpy.types.Timer] _show_args: Optional[List] _show_kwargs: Optional[Dict] @@ -136,21 +136,23 @@ class LaunchQtApp(bpy.types.Operator): def __init__(self): from .. import style print(f"Initialising {self.bl_idname}...") - self._app = QApplication.instance() or QApplication(sys.argv) + self._app = (QtWidgets.QApplication.instance() + or QtWidgets.QApplication(sys.argv)) self._app.setStyleSheet(style.load_stylesheet()) def _is_window_visible(self) -> bool: """Check if the window of the app is visible. - If `self._window` is an instance of `QDialog`, simply return + If `self._window` is an instance of `QtWidgets.QDialog`, simply return `self._window.isVisible()`. If `self._window` is a module check if it has `self._window.app.window` and if so, return `isVisible()` on that. Else return False, because we don't know how to check if the window is visible. """ - window: Optional[QDialog] = None - if isinstance(self._window, QDialog): + + window: Optional[QtWidgets.QDialog] = None + if isinstance(self._window, QtWidgets.QDialog): window = self._window if isinstance(self._window, ModuleType): try: @@ -193,9 +195,11 @@ def execute(self, context): """ # Check if `self._window` is properly set - if (getattr(self, "_window") is None - or not isinstance(self._window, (QDialog, ModuleType))): + if getattr(self, "_window") is None: raise AttributeError("`self._window` should be set.") + if not isinstance(self._window, (QtWidgets.QDialog, ModuleType)): + raise AttributeError( + "`self._window` should be a `QDialog or module`.") args = getattr(self, "_show_args", list()) kwargs = getattr(self, "_show_kwargs", dict()) @@ -385,6 +389,7 @@ def register(): 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) From 809e53d02933033d7a555f0b8d8dd1f6cede1d24 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Fri, 1 Nov 2019 15:35:54 +0100 Subject: [PATCH 07/25] Style changes --- avalon/blender/lib.py | 8 +++-- avalon/blender/ops.py | 73 +++++++++++++++++++++----------------- avalon/blender/pipeline.py | 13 +++++++ 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py index 92943c2d5..52535137d 100644 --- a/avalon/blender/lib.py +++ b/avalon/blender/lib.py @@ -30,7 +30,6 @@ def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): ... >>> cube['avalon']['computedValue'] 6 - """ imprint_data = dict() @@ -69,6 +68,7 @@ def lsattr(attr: str, Returns: list """ + return lsattrs({attr: value}) @@ -86,6 +86,7 @@ def lsattrs(attrs: Dict) -> List: Returns a list. """ + # For now return all objects, not filtered by scene/collection/view_layer. matches = set() for coll in dir(bpy.data): @@ -99,14 +100,15 @@ def lsattrs(attrs: Dict) -> List: 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): + 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 diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index cd951407c..a085ea29f 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -13,20 +13,13 @@ import avalon.api as api from avalon.vendor.Qt import QtCore, QtWidgets -from avalon.vendor.Qt.QtWidgets import ( - QApplication, - QDialog, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, -) preview_collections: Dict = dict() def _is_avalon_in_debug_mode() -> bool: """Check if Avalon is in debug mode.""" + try: # It says `strtobool` returns a bool, but it returns an int :/ return bool(strtobool(os.environ.get('AVALON_DEBUG', "False"))) @@ -35,44 +28,54 @@ def _is_avalon_in_debug_mode() -> bool: return False -class Example(QDialog): - """Silly window with quit button.""" +class TestApp(QtWidgets.QDialog): + """A simple test app to check if a Qt app runs fine in Blender. + Is Blender still responsive when the app is visible? + Is the Blender context available from within the app? + """ def __init__(self): super().__init__() self.init_ui() def init_ui(self): - """The UI for this silly window.""" + """The UI for this test app.""" - bpybtn = QPushButton('Print bpy.context', self) + bpybtn = QtWidgets.QPushButton('Print bpy.context', self) bpybtn.clicked.connect(self.print_bpy_context) - activebtn = QPushButton('Print Active Object', self) + activebtn = QtWidgets.QPushButton('Print Active Object', self) activebtn.clicked.connect(self.print_active) - selectedbtn = QPushButton('Print Selected Objects', self) + selectedbtn = QtWidgets.QPushButton('Print Selected Objects', self) selectedbtn.clicked.connect(self.print_selected) - qbtn = QPushButton('Quit', self) + qbtn = QtWidgets.QPushButton('Quit', self) qbtn.clicked.connect(self.close) - vbox = QVBoxLayout(self) + vbox = QtWidgets.QVBoxLayout(self) - hbox_output = QHBoxLayout() - self.label = QLabel('') - size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Preferred) + hbox_output = QtWidgets.QHBoxLayout() + self.label = QtWidgets.QLabel('') + size_policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Preferred, + ) self.label.setSizePolicy(size_policy) self.label.setMinimumSize(QtCore.QSize(100, 0)) - self.label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignTrailing - | QtCore.Qt.AlignVCenter) + # yapf: disable + self.label.setAlignment( + QtCore.Qt.AlignRight | + QtCore.Qt.AlignTrailing | + QtCore.Qt.AlignVCenter + ) + # yapf: enable hbox_output.addWidget(self.label) - self.outputlabel = QLabel('') + self.outputlabel = QtWidgets.QLabel('') hbox_output.addWidget(self.outputlabel) vbox.addLayout(hbox_output) - hbox_buttons = QHBoxLayout() + hbox_buttons = QtWidgets.QHBoxLayout() hbox_buttons.addWidget(bpybtn) hbox_buttons.addWidget(activebtn) hbox_buttons.addWidget(selectedbtn) @@ -83,7 +86,8 @@ def init_ui(self): self.setWindowTitle('Blender Qt test') def print_bpy_context(self): - """Print `bpy.context` console and UI.""" + """Print `bpy.context` to the console and UI.""" + context_string = pformat(bpy.context.copy(), indent=2) print(f"Context: {context_string}") self.label.setText('Context:') @@ -96,7 +100,8 @@ def print_bpy_context(self): self.outputlabel.setText(limited_context_string) def print_active(self): - """Print the active object to console and UI.""" + """Print the active object to the console and UI.""" + context = bpy.context.copy() if context.get('object'): objname = context['object'].name @@ -107,7 +112,8 @@ def print_active(self): self.outputlabel.setText(objname) def print_selected(self): - """Print the selected objects to console and UI.""" + """Print the selected objects to the console and UI.""" + context = bpy.context.copy() if context.get('selected_objects'): selected_list = [obj.name for obj in context['selected_objects']] @@ -304,7 +310,7 @@ def execute(self, context): return super().execute(context) -class TestApp(LaunchQtApp): +class LaunchTestApp(LaunchQtApp): """Launch a simple test app.""" bl_idname = "wm.avalon_test_app" @@ -312,7 +318,7 @@ class TestApp(LaunchQtApp): def execute(self, context): from .. import style - self._window = Example() + self._window = TestApp() self._window.setStyleSheet(style.load_stylesheet()) return super().execute(context) @@ -325,6 +331,7 @@ class TOPBAR_MT_avalon(bpy.types.Menu): def draw(self, context): """Draw the menu in the UI.""" + layout = self.layout pcoll = preview_collections.get("avalon") @@ -351,13 +358,14 @@ def draw(self, context): layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") if _is_avalon_in_debug_mode(): layout.separator() - layout.operator(TestApp.bl_idname, text="Test App...") + layout.operator(LaunchTestApp.bl_idname, text="Test App...") # TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and - # 'Reset Resolution'? + # 'Reset Resolution'? def draw_avalon_menu(self, context): """Draw the Avalon menu in the top bar.""" + self.layout.menu(TOPBAR_MT_avalon.bl_idname) @@ -371,12 +379,13 @@ def draw_avalon_menu(self, context): ] if _is_avalon_in_debug_mode(): # Enable the test app in debug mode - classes.append(TestApp) + classes.append(LaunchTestApp) classes.append(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') diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 4bea609c8..dc2a28b5e 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -50,6 +50,7 @@ def _register_callbacks(): def _remove_handler(handlers: List, callback: Callable): """Remove the callback from the given handler list.""" + try: handlers.remove(callback) except ValueError: @@ -73,6 +74,7 @@ def _remove_handler(handlers: List, callback: Callable): 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. @@ -86,6 +88,7 @@ def _on_task_changed(*args): def _register_events(): """Install callbacks for specific events.""" + api.on("taskChanged", _on_task_changed) logger.info("Installed event callback for 'taskChanged'...") @@ -190,6 +193,7 @@ def _discover_gui() -> Optional[Callable]: def teardown(): """Remove integration""" + if not self._has_been_setup: return @@ -199,6 +203,7 @@ def teardown(): 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) @@ -223,6 +228,7 @@ def metadata_update(node: bpy.types.bpy_struct_meta_idprop, data: Dict): Existing metadata will be updated. """ + if not node.get(AVALON_PROPERTY): node[AVALON_PROPERTY] = dict() for key, value in data.items(): @@ -254,6 +260,7 @@ def containerise(name: str, The container assembly """ + node_name = f"{context['asset']['name']}_{name}" if namespace: node_name = f"{namespace}:{node_name}" @@ -298,6 +305,7 @@ def containerise_existing(container: bpy.types.Collection, Returns: The container assembly """ + node_name = container.name if namespace: node_name = f"{namespace}:{node_name}" @@ -331,6 +339,7 @@ def parse_container(container: bpy.types.Collection, The container schema data for this container node. """ + data = lib.read(container) # Append transient data @@ -353,6 +362,7 @@ def ls() -> Iterator: disk, it lists assets already loaded in Blender; once loaded they are called containers. """ + containers = _ls() has_metadata_collector = False @@ -383,6 +393,7 @@ def update_hierarchy(containers): We need both parent and children to visualize the graph. """ + all_containers = set(_ls()) # lookup set for container in containers: @@ -405,6 +416,7 @@ def update_hierarchy(containers): def publish(): """Shorthand to publish from within host.""" + return pyblish.util.publish() @@ -425,6 +437,7 @@ def process(self): class Loader(api.Loader): """Base class for Loader plug-ins.""" + hosts = ["blender"] def __init__(self, context): From 5bc3bb3253e762355d154d74c3ac028f3dea1e43 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Tue, 17 Dec 2019 10:48:39 +0100 Subject: [PATCH 08/25] Add bpy.context wrapper Because `bpy.context` might not be fully populated when queried from a Qt app, you can use `lib.bpy_context()`. At the moment only `active_object`, `object` and `selected_objects` are added. Other attributes will follow when needed. --- avalon/blender/lib.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py index 52535137d..e82fb6a2c 100644 --- a/avalon/blender/lib.py +++ b/avalon/blender/lib.py @@ -154,3 +154,43 @@ def maintained_selection(): # This could happen if the active node was deleted during the # context. logger.exception("Failed to set active object.") + + +class BpyContext(): + """Because there are issues with retreiving `bpy.context` from a Qt app (for + example 'selected_objects'), you can use this function instead. It tries to + fill in all missing keys. + + It returns the custom BpyContext object that should behave (almost) + identical to the regular `bpy.context` object. + + If the attribute is found, return it. If not get the value in another way. + """ + def __init__(self): + self.__dict__ = bpy.context.copy() + + @property + def active_object(self): + return getattr(self, 'active_object', self._active_object()) + + @property + def object(self): + return self.active_object + + @property + def selected_objects(self): + return getattr(self, 'selected_objects', self._selected_objects()) + + def _active_object(self): + """Return the active object from the view layer.""" + return self.view_layer.objects.active + + def _selected_objects(self): + """Return the selected objects from the view layer.""" + return [obj for obj in self.scene.objects if obj.select_get()] + + +def bpy_context() -> BpyContext: + """Convenience function to return a BpyContext object.""" + + return BpyContext() From bf540523f1e089019d6e55c43cec388bfd3be0d7 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Tue, 17 Dec 2019 10:52:51 +0100 Subject: [PATCH 09/25] Use bpy.context wrapper in TestApp --- avalon/blender/ops.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index a085ea29f..0549ff906 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -14,6 +14,8 @@ import avalon.api as api from avalon.vendor.Qt import QtCore, QtWidgets +from . import lib + preview_collections: Dict = dict() @@ -88,7 +90,9 @@ def init_ui(self): def print_bpy_context(self): """Print `bpy.context` to the console and UI.""" - context_string = pformat(bpy.context.copy(), indent=2) + # context = bpy.context.copy() + context = lib.bpy_context() + context_string = pformat(context, indent=2) print(f"Context: {context_string}") self.label.setText('Context:') # Limit the text to 50 lines for display in the UI @@ -102,7 +106,8 @@ def print_bpy_context(self): def print_active(self): """Print the active object to the console and UI.""" - context = bpy.context.copy() + # context = bpy.context.copy() + context = lib.bpy_context() if context.get('object'): objname = context['object'].name else: @@ -114,7 +119,8 @@ def print_active(self): def print_selected(self): """Print the selected objects to the console and UI.""" - context = bpy.context.copy() + # context = bpy.context.copy() + context = lib.bpy_context() if context.get('selected_objects'): selected_list = [obj.name for obj in context['selected_objects']] else: From 0570d60cf52ec095e58fdeeebdc1da4ffe28108b Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Tue, 17 Dec 2019 21:46:43 +0100 Subject: [PATCH 10/25] Add `bpy.context` wrapper Because the `bpy.context` is not always properly populated when queried from within a Qt app, a wrapper is added. Instead of directly using `bpy` you can `from avalon.blender import bpy`. All needed overrides can now be handled there and everything else is simply passed through from the original `bpy`. Hopefully this is a (fairly) short-term remedy and a proper solution will be found in the near future. 8) --- avalon/blender/__init__.py | 3 ++ avalon/blender/bpy.py | 94 ++++++++++++++++++++++++++++++++++++++ avalon/blender/lib.py | 46 +------------------ avalon/blender/ops.py | 12 ++--- avalon/blender/pipeline.py | 20 ++++---- avalon/blender/workio.py | 2 +- 6 files changed, 112 insertions(+), 65 deletions(-) create mode 100644 avalon/blender/bpy.py diff --git a/avalon/blender/__init__.py b/avalon/blender/__init__.py index 2004870d1..3eac25822 100644 --- a/avalon/blender/__init__.py +++ b/avalon/blender/__init__.py @@ -31,6 +31,8 @@ # unique_name, ) +from . import bpy + __all__ = [ "install", @@ -55,4 +57,5 @@ "lsattrs", "read", # "unique_name", + "bpy", ] diff --git a/avalon/blender/bpy.py b/avalon/blender/bpy.py new file mode 100644 index 000000000..3fbbb64db --- /dev/null +++ b/avalon/blender/bpy.py @@ -0,0 +1,94 @@ +"""A wrapper for the `bpy` module. + +This a a 'quick hack' to remedy an issue with running Qt apps inside of Blender. +The problem is that `bpy.context` is not fully populated when queried from inside a Qt app. +In the future the underlying problem should definitely be fixed, but for now +this is a fairly simple workaround. The only thing that needs to be changed in +other modules and plug-ins is the import statement. These should be changed to +`from avalon.blender import bpy` (instead of `import bpy`). + +So far it seems everything is fine on Linux, on Windows `bpy.context` contains +almost nothing, hence this 'fix'. On macOS the situation is worse: both Blender +and the Qt app interfaces are basically locked when starting the Qt app. +""" + +from typing import Dict, List, Optional + +import bpy as _bpy + + +class BpyContext: + """Because there are issues with retreiving `bpy.context` from a Qt app + (for example 'selected_objects'), you can use this class instead. It tries + to fill in all missing keys. + + This object should behave (almost) identical to the regular `bpy.context` + object. + + The process is simple: + If an attribute is found in the original `bpy.context` simply return it. If + it is not found try to determine the proper value in another way. + + Note: + To add more overrides, simply add the relevant properties and functions. + Also add them to the `copy` method, else they will not be available + when you do a `bpy.context.copy()`. + """ + + def __getattr__(self, name): + return getattr(_bpy.context, name) + + def __setattr__(self, name, value): + setattr(_bpy.context, name, value) + + @property + def active_object(self): + return self._active_object() + + @property + def object(self): + return self._active_object() + + @property + def selected_objects(self): + return self._selected_objects() + + def copy(self) -> Dict: + """Mimic `bpy.context.copy()`. + + Return `bpy.context.copy()` with the missing keys added. + """ + + copy = _bpy.context.copy() + copy['active_object'] = copy.get( + 'active_object', + self._active_object(), + ) + copy['object'] = copy.get( + 'object', + self._active_object(), + ) + copy['selected_objects'] = copy.get( + 'selected_objects', + self._selected_objects(), + ) + + return copy + + def _active_object(self) -> Optional[_bpy.types.Object]: + """Return the active object from the view layer.""" + + return _bpy.context.view_layer.objects.active + + def _selected_objects(self) -> List[Optional[_bpy.types.Object]]: + """Return the selected objects from the view layer.""" + + return [obj for obj in _bpy.context.scene.objects if obj.select_get()] + + +context = BpyContext() + + +def __getattr__(name): + """Return the attribute from `bpy` if it is not defined here.""" + return getattr(_bpy, name) diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py index e82fb6a2c..da52717cb 100644 --- a/avalon/blender/lib.py +++ b/avalon/blender/lib.py @@ -1,12 +1,10 @@ """Standalone helper functions.""" import contextlib -from typing import Dict, List, Union - -import bpy +from typing import Dict, List, Optional, Union from ..lib import logger -from . import pipeline +from . import bpy, pipeline def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): @@ -154,43 +152,3 @@ def maintained_selection(): # This could happen if the active node was deleted during the # context. logger.exception("Failed to set active object.") - - -class BpyContext(): - """Because there are issues with retreiving `bpy.context` from a Qt app (for - example 'selected_objects'), you can use this function instead. It tries to - fill in all missing keys. - - It returns the custom BpyContext object that should behave (almost) - identical to the regular `bpy.context` object. - - If the attribute is found, return it. If not get the value in another way. - """ - def __init__(self): - self.__dict__ = bpy.context.copy() - - @property - def active_object(self): - return getattr(self, 'active_object', self._active_object()) - - @property - def object(self): - return self.active_object - - @property - def selected_objects(self): - return getattr(self, 'selected_objects', self._selected_objects()) - - def _active_object(self): - """Return the active object from the view layer.""" - return self.view_layer.objects.active - - def _selected_objects(self): - """Return the selected objects from the view layer.""" - return [obj for obj in self.scene.objects if obj.select_get()] - - -def bpy_context() -> BpyContext: - """Convenience function to return a BpyContext object.""" - - return BpyContext() diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 0549ff906..ea9936928 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -8,13 +8,12 @@ from types import ModuleType from typing import Dict, List, Optional, Union -import bpy import bpy.utils.previews import avalon.api as api from avalon.vendor.Qt import QtCore, QtWidgets -from . import lib +from . import bpy preview_collections: Dict = dict() @@ -90,8 +89,7 @@ def init_ui(self): def print_bpy_context(self): """Print `bpy.context` to the console and UI.""" - # context = bpy.context.copy() - context = lib.bpy_context() + context = bpy.context.copy() context_string = pformat(context, indent=2) print(f"Context: {context_string}") self.label.setText('Context:') @@ -106,8 +104,7 @@ def print_bpy_context(self): def print_active(self): """Print the active object to the console and UI.""" - # context = bpy.context.copy() - context = lib.bpy_context() + context = bpy.context.copy() if context.get('object'): objname = context['object'].name else: @@ -119,8 +116,7 @@ def print_active(self): def print_selected(self): """Print the selected objects to the console and UI.""" - # context = bpy.context.copy() - context = lib.bpy_context() + context = bpy.context.copy() if context.get('selected_objects'): selected_list = [obj.name for obj in context['selected_objects']] else: diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index dc2a28b5e..74a32c96f 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -5,15 +5,13 @@ from types import ModuleType from typing import Callable, Dict, Iterator, List, Optional -import bpy - import pyblish.api import pyblish.util from avalon import api, schema from ..lib import logger from ..pipeline import AVALON_CONTAINER_ID -from . import lib, ops +from . import bpy, lib, ops self = sys.modules[__name__] self._events = dict() # Registered Blender callbacks @@ -47,7 +45,6 @@ def _on_load_post(*args): def _register_callbacks(): """Register callbacks for certain events.""" - def _remove_handler(handlers: List, callback: Callable): """Remove the callback from the given handler list.""" @@ -286,13 +283,13 @@ def containerise(name: str, 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: +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: @@ -422,7 +419,6 @@ def publish(): class Creator(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) diff --git a/avalon/blender/workio.py b/avalon/blender/workio.py index 4990a7929..216ef17e1 100644 --- a/avalon/blender/workio.py +++ b/avalon/blender/workio.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Optional -import bpy +from . import bpy def open_file(filepath: str) -> Optional[str]: From 834371b419e8ae1717f48f4170f1d0cd9d9b9af6 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Mon, 30 Dec 2019 16:14:49 +0100 Subject: [PATCH 11/25] Revert adding the bpy wrapper This reverts commit 5bc3bb3253e762355d154d74c3ac028f3dea1e43. --- avalon/blender/__init__.py | 3 -- avalon/blender/bpy.py | 94 -------------------------------------- avalon/blender/lib.py | 6 ++- avalon/blender/ops.py | 6 +-- avalon/blender/pipeline.py | 20 ++++---- avalon/blender/workio.py | 2 +- 6 files changed, 19 insertions(+), 112 deletions(-) delete mode 100644 avalon/blender/bpy.py diff --git a/avalon/blender/__init__.py b/avalon/blender/__init__.py index 3eac25822..2004870d1 100644 --- a/avalon/blender/__init__.py +++ b/avalon/blender/__init__.py @@ -31,8 +31,6 @@ # unique_name, ) -from . import bpy - __all__ = [ "install", @@ -57,5 +55,4 @@ "lsattrs", "read", # "unique_name", - "bpy", ] diff --git a/avalon/blender/bpy.py b/avalon/blender/bpy.py deleted file mode 100644 index 3fbbb64db..000000000 --- a/avalon/blender/bpy.py +++ /dev/null @@ -1,94 +0,0 @@ -"""A wrapper for the `bpy` module. - -This a a 'quick hack' to remedy an issue with running Qt apps inside of Blender. -The problem is that `bpy.context` is not fully populated when queried from inside a Qt app. -In the future the underlying problem should definitely be fixed, but for now -this is a fairly simple workaround. The only thing that needs to be changed in -other modules and plug-ins is the import statement. These should be changed to -`from avalon.blender import bpy` (instead of `import bpy`). - -So far it seems everything is fine on Linux, on Windows `bpy.context` contains -almost nothing, hence this 'fix'. On macOS the situation is worse: both Blender -and the Qt app interfaces are basically locked when starting the Qt app. -""" - -from typing import Dict, List, Optional - -import bpy as _bpy - - -class BpyContext: - """Because there are issues with retreiving `bpy.context` from a Qt app - (for example 'selected_objects'), you can use this class instead. It tries - to fill in all missing keys. - - This object should behave (almost) identical to the regular `bpy.context` - object. - - The process is simple: - If an attribute is found in the original `bpy.context` simply return it. If - it is not found try to determine the proper value in another way. - - Note: - To add more overrides, simply add the relevant properties and functions. - Also add them to the `copy` method, else they will not be available - when you do a `bpy.context.copy()`. - """ - - def __getattr__(self, name): - return getattr(_bpy.context, name) - - def __setattr__(self, name, value): - setattr(_bpy.context, name, value) - - @property - def active_object(self): - return self._active_object() - - @property - def object(self): - return self._active_object() - - @property - def selected_objects(self): - return self._selected_objects() - - def copy(self) -> Dict: - """Mimic `bpy.context.copy()`. - - Return `bpy.context.copy()` with the missing keys added. - """ - - copy = _bpy.context.copy() - copy['active_object'] = copy.get( - 'active_object', - self._active_object(), - ) - copy['object'] = copy.get( - 'object', - self._active_object(), - ) - copy['selected_objects'] = copy.get( - 'selected_objects', - self._selected_objects(), - ) - - return copy - - def _active_object(self) -> Optional[_bpy.types.Object]: - """Return the active object from the view layer.""" - - return _bpy.context.view_layer.objects.active - - def _selected_objects(self) -> List[Optional[_bpy.types.Object]]: - """Return the selected objects from the view layer.""" - - return [obj for obj in _bpy.context.scene.objects if obj.select_get()] - - -context = BpyContext() - - -def __getattr__(name): - """Return the attribute from `bpy` if it is not defined here.""" - return getattr(_bpy, name) diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py index da52717cb..52535137d 100644 --- a/avalon/blender/lib.py +++ b/avalon/blender/lib.py @@ -1,10 +1,12 @@ """Standalone helper functions.""" import contextlib -from typing import Dict, List, Optional, Union +from typing import Dict, List, Union + +import bpy from ..lib import logger -from . import bpy, pipeline +from . import pipeline def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index ea9936928..a085ea29f 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -8,13 +8,12 @@ from types import ModuleType from typing import Dict, List, Optional, Union +import bpy import bpy.utils.previews import avalon.api as api from avalon.vendor.Qt import QtCore, QtWidgets -from . import bpy - preview_collections: Dict = dict() @@ -89,8 +88,7 @@ def init_ui(self): def print_bpy_context(self): """Print `bpy.context` to the console and UI.""" - context = bpy.context.copy() - context_string = pformat(context, indent=2) + context_string = pformat(bpy.context.copy(), indent=2) print(f"Context: {context_string}") self.label.setText('Context:') # Limit the text to 50 lines for display in the UI diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 74a32c96f..dc2a28b5e 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -5,13 +5,15 @@ from types import ModuleType from typing import Callable, Dict, Iterator, List, Optional +import bpy + import pyblish.api import pyblish.util from avalon import api, schema from ..lib import logger from ..pipeline import AVALON_CONTAINER_ID -from . import bpy, lib, ops +from . import lib, ops self = sys.modules[__name__] self._events = dict() # Registered Blender callbacks @@ -45,6 +47,7 @@ def _on_load_post(*args): def _register_callbacks(): """Register callbacks for certain events.""" + def _remove_handler(handlers: List, callback: Callable): """Remove the callback from the given handler list.""" @@ -283,13 +286,13 @@ def containerise(name: str, 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: +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: @@ -419,6 +422,7 @@ def publish(): class Creator(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) diff --git a/avalon/blender/workio.py b/avalon/blender/workio.py index 216ef17e1..4990a7929 100644 --- a/avalon/blender/workio.py +++ b/avalon/blender/workio.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Optional -from . import bpy +import bpy def open_file(filepath: str) -> Optional[str]: From 2433fdc8b21ce9226d813b619797cc8a1f88485f Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Mon, 30 Dec 2019 16:34:22 +0100 Subject: [PATCH 12/25] Add library function to get selected objects --- avalon/blender/__init__.py | 2 ++ avalon/blender/lib.py | 9 +++++++-- avalon/blender/pipeline.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/avalon/blender/__init__.py b/avalon/blender/__init__.py index 2004870d1..7041791e9 100644 --- a/avalon/blender/__init__.py +++ b/avalon/blender/__init__.py @@ -28,6 +28,7 @@ lsattrs, read, maintained_selection, + get_selection, # unique_name, ) @@ -54,5 +55,6 @@ "lsattr", "lsattrs", "read", + "get_selection", # "unique_name", ] diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py index 52535137d..484b2a6f0 100644 --- a/avalon/blender/lib.py +++ b/avalon/blender/lib.py @@ -120,6 +120,11 @@ def read(node: bpy.types.bpy_struct_meta_idprop): 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 @@ -131,13 +136,13 @@ def maintained_selection(): >>> # Selection restored """ - previous_selection = bpy.context.selected_objects + previous_selection = get_selection() previous_active = bpy.context.view_layer.objects.active try: yield finally: # Clear the selection - for node in bpy.context.selected_objects: + for node in get_selection(): node.select_set(state=False) if previous_selection: for node in previous_selection: diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index dc2a28b5e..1633cc8e3 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -429,7 +429,7 @@ def process(self): lib.imprint(collection, self.data) if (self.options or {}).get("useSelection"): - for obj in bpy.context.selected_objects: + for obj in lib.get_selection(): collection.objects.link(obj) return collection From 0a655ea85c0ffa3e8f5abc292b992cb230ba151e Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Mon, 30 Dec 2019 16:34:45 +0100 Subject: [PATCH 13/25] Make Avalon imports relative --- avalon/blender/ops.py | 5 +++-- avalon/blender/pipeline.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index a085ea29f..9f175cefd 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -11,8 +11,8 @@ import bpy import bpy.utils.previews -import avalon.api as api -from avalon.vendor.Qt import QtCore, QtWidgets +from .. import api +from ..vendor.Qt import QtCore, QtWidgets preview_collections: Dict = dict() @@ -34,6 +34,7 @@ class TestApp(QtWidgets.QDialog): Is Blender still responsive when the app is visible? Is the Blender context available from within the app? """ + def __init__(self): super().__init__() self.init_ui() diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 1633cc8e3..14f05725c 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -9,8 +9,8 @@ import pyblish.api import pyblish.util -from avalon import api, schema +from .. import api, schema from ..lib import logger from ..pipeline import AVALON_CONTAINER_ID from . import lib, ops From d2ec489c55c037a5f4542ff4833df06fe4eb266b Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Mon, 30 Dec 2019 16:53:30 +0100 Subject: [PATCH 14/25] Add note to change to `bpy.app.timers` --- avalon/blender/ops.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 9f175cefd..bc2ad9209 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -134,6 +134,11 @@ def print_selected(self): class LaunchQtApp(bpy.types.Operator): """A Base class for opertors to launch a Qt app.""" + # TODO (jasper): use `bpy.app.timers` instead of timed modal operator. + # Figure out what is the best way to remove the timer when the app + # is closed. Keep a reference to the window and check if it is + # visible/open. If not return 0, so the timer will not run again. + _app: Optional[QtWidgets.QApplication] _window: Optional[Union[QtWidgets.QDialog, ModuleType]] _timer: Optional[bpy.types.Timer] From a78b312a8784fd4aa27007949441c2297b5bc858 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Mon, 30 Dec 2019 17:11:14 +0100 Subject: [PATCH 15/25] Remove hardcoded 'scene' directory --- avalon/blender/ops.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index bc2ad9209..9877507a7 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -310,7 +310,10 @@ class LaunchWorkFiles(LaunchQtApp): def execute(self, context): from ..tools import workfiles - root = str(Path(os.environ.get("AVALON_WORKDIR", ""), "scene")) + root = str(Path( + os.environ.get("AVALON_WORKDIR", ""), + os.environ.get("AVALON_SCENEDIR", "") + )) self._window = workfiles self._show_kwargs = {"root": root} return super().execute(context) From 3d46168a6f3d67a061f84887933b24d818de23b2 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Tue, 31 Dec 2019 13:50:47 +0100 Subject: [PATCH 16/25] Use `bpy.app.timers` and remove test app --- avalon/blender/ops.py | 240 ++++++++---------------------------------- 1 file changed, 42 insertions(+), 198 deletions(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 9877507a7..15298ab34 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -2,9 +2,8 @@ import os import sys -from distutils.util import strtobool +from functools import partial from pathlib import Path -from pprint import pformat from types import ModuleType from typing import Dict, List, Optional, Union @@ -12,136 +11,49 @@ import bpy.utils.previews from .. import api -from ..vendor.Qt import QtCore, QtWidgets +from ..vendor.Qt import QtWidgets -preview_collections: Dict = dict() +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 -def _is_avalon_in_debug_mode() -> bool: - """Check if Avalon is in debug mode.""" - try: - # It says `strtobool` returns a bool, but it returns an int :/ - return bool(strtobool(os.environ.get('AVALON_DEBUG', "False"))) - except (ValueError, AttributeError): - # If it can't logically be converted to a bool, assume it's False. - return False +def _has_visible_windows(app: QtWidgets.QApplication) -> bool: + """Check if the Qt application has any visible top level windows.""" + for window in app.topLevelWindows(): + try: + if window.isVisible(): + return True + except RuntimeError: + continue -class TestApp(QtWidgets.QDialog): - """A simple test app to check if a Qt app runs fine in Blender. - - Is Blender still responsive when the app is visible? - Is the Blender context available from within the app? - """ - - def __init__(self): - super().__init__() - self.init_ui() - - def init_ui(self): - """The UI for this test app.""" - - bpybtn = QtWidgets.QPushButton('Print bpy.context', self) - bpybtn.clicked.connect(self.print_bpy_context) - - activebtn = QtWidgets.QPushButton('Print Active Object', self) - activebtn.clicked.connect(self.print_active) - - selectedbtn = QtWidgets.QPushButton('Print Selected Objects', self) - selectedbtn.clicked.connect(self.print_selected) + return False - qbtn = QtWidgets.QPushButton('Quit', self) - qbtn.clicked.connect(self.close) - vbox = QtWidgets.QVBoxLayout(self) +def _process_app_events(app: QtWidgets.QApplication) -> Optional[float]: + """Process the events of the Qt app if the window is still visible. - hbox_output = QtWidgets.QHBoxLayout() - self.label = QtWidgets.QLabel('') - size_policy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Preferred, - ) - self.label.setSizePolicy(size_policy) - self.label.setMinimumSize(QtCore.QSize(100, 0)) - # yapf: disable - self.label.setAlignment( - QtCore.Qt.AlignRight | - QtCore.Qt.AlignTrailing | - QtCore.Qt.AlignVCenter - ) - # yapf: enable - hbox_output.addWidget(self.label) - self.outputlabel = QtWidgets.QLabel('') - hbox_output.addWidget(self.outputlabel) - vbox.addLayout(hbox_output) - - hbox_buttons = QtWidgets.QHBoxLayout() - hbox_buttons.addWidget(bpybtn) - hbox_buttons.addWidget(activebtn) - hbox_buttons.addWidget(selectedbtn) - hbox_buttons.addWidget(qbtn) - vbox.addLayout(hbox_buttons) - - self.setGeometry(300, 300, 300, 200) - self.setWindowTitle('Blender Qt test') - - def print_bpy_context(self): - """Print `bpy.context` to the console and UI.""" - - context_string = pformat(bpy.context.copy(), indent=2) - print(f"Context: {context_string}") - self.label.setText('Context:') - # Limit the text to 50 lines for display in the UI - context_list = context_string.split('\n') - limited_context_list = context_list[0:50] - if len(context_list) > len(limited_context_list): - limited_context_list.append('(...)') - limited_context_string = '\n'.join(limited_context_list) - self.outputlabel.setText(limited_context_string) - - def print_active(self): - """Print the active object to the console and UI.""" - - context = bpy.context.copy() - if context.get('object'): - objname = context['object'].name - else: - objname = 'No active object.' - print(f"Active Object: {objname}") - self.label.setText('Active Object:') - self.outputlabel.setText(objname) + 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. + """ - def print_selected(self): - """Print the selected objects to the console and UI.""" + if _has_visible_windows(app): + app.processEvents() + return TIMER_INTERVAL - context = bpy.context.copy() - if context.get('selected_objects'): - selected_list = [obj.name for obj in context['selected_objects']] - else: - selected_list = ['No selected objects.'] - selected_string = '\n'.join(selected_list) - print(f"Selected Objects: {selected_string}") - # Limit the text to 50 lines for display in the UI - limited_selected_list = selected_list[0:50] - if len(selected_list) > len(limited_selected_list): - limited_selected_list.append('(...)') - limited_selected_string = '\n'.join(limited_selected_list) - self.label.setText('Selected Objects:') - self.outputlabel.setText(limited_selected_string) + return None class LaunchQtApp(bpy.types.Operator): """A Base class for opertors to launch a Qt app.""" - # TODO (jasper): use `bpy.app.timers` instead of timed modal operator. - # Figure out what is the best way to remove the timer when the app - # is closed. Keep a reference to the window and check if it is - # visible/open. If not return 0, so the timer will not run again. - - _app: Optional[QtWidgets.QApplication] - _window: Optional[Union[QtWidgets.QDialog, ModuleType]] - _timer: Optional[bpy.types.Timer] + _app: QtWidgets.QApplication + _window: Union[QtWidgets.QDialog, ModuleType] _show_args: Optional[List] _show_kwargs: Optional[Dict] @@ -152,48 +64,6 @@ def __init__(self): or QtWidgets.QApplication(sys.argv)) self._app.setStyleSheet(style.load_stylesheet()) - def _is_window_visible(self) -> bool: - """Check if the window of the app is visible. - - If `self._window` is an instance of `QtWidgets.QDialog`, simply return - `self._window.isVisible()`. If `self._window` is a module check - if it has `self._window.app.window` and if so, return `isVisible()` - on that. - Else return False, because we don't know how to check if the - window is visible. - """ - - window: Optional[QtWidgets.QDialog] = None - if isinstance(self._window, QtWidgets.QDialog): - window = self._window - if isinstance(self._window, ModuleType): - try: - window = self._window.app.window - except AttributeError: - return False - - try: - return window is not None and window.isVisible() - except (AttributeError, RuntimeError): - pass - - return False - - def modal(self, context, event): - """Run modal to keep Blender and the Qt UI responsive.""" - - if event.type == 'TIMER': - if self._is_window_visible(): - # Process events if the window is visible - self._app.processEvents() - else: - # Stop the operator if the window is closed - self.cancel(context) - print(f"Stopping modal execution of '{self.bl_idname}'") - return {'FINISHED'} - - return {'PASS_THROUGH'} - def execute(self, context): """Execute the operator. @@ -216,17 +86,12 @@ def execute(self, context): args = getattr(self, "_show_args", list()) kwargs = getattr(self, "_show_kwargs", dict()) self._window.show(*args, **kwargs) - wm = context.window_manager - # Run every 0.01 seconds - self._timer = wm.event_timer_add(0.01, window=context.window) - wm.modal_handler_add(self) - - return {'RUNNING_MODAL'} + bpy.app.timers.register( + partial(_process_app_events, self._app), + persistent=True, + ) - def cancel(self, context): - """Remove the event timer when stopping the operator.""" - wm = context.window_manager - wm.event_timer_remove(self._timer) + return {'FINISHED'} class LaunchContextManager(LaunchQtApp): @@ -250,7 +115,6 @@ class LaunchCreator(LaunchQtApp): def execute(self, context): from ..tools import creator self._window = creator - # self._show_kwargs = {'debug': _is_avalon_in_debug_mode()} return super().execute(context) @@ -267,7 +131,6 @@ def execute(self, context): self._window.app.window = None self._show_kwargs = { 'use_context': True, - # 'debug': _is_avalon_in_debug_mode(), } return super().execute(context) @@ -298,7 +161,6 @@ class LaunchManager(LaunchQtApp): def execute(self, context): from ..tools import cbsceneinventory self._window = cbsceneinventory - # self._show_kwargs = {'debug': _is_avalon_in_debug_mode()} return super().execute(context) @@ -310,28 +172,16 @@ class LaunchWorkFiles(LaunchQtApp): def execute(self, context): from ..tools import workfiles - root = str(Path( - os.environ.get("AVALON_WORKDIR", ""), - os.environ.get("AVALON_SCENEDIR", "") - )) + root = str( + Path( + os.environ.get("AVALON_WORKDIR", ""), + os.environ.get("AVALON_SCENEDIR", ""), + )) self._window = workfiles self._show_kwargs = {"root": root} return super().execute(context) -class LaunchTestApp(LaunchQtApp): - """Launch a simple test app.""" - - bl_idname = "wm.avalon_test_app" - bl_label = "Test App..." - - def execute(self, context): - from .. import style - self._window = TestApp() - self._window.setStyleSheet(style.load_stylesheet()) - return super().execute(context) - - class TOPBAR_MT_avalon(bpy.types.Menu): """Avalon menu.""" @@ -343,7 +193,7 @@ def draw(self, context): layout = self.layout - pcoll = preview_collections.get("avalon") + pcoll = PREVIEW_COLLECTIONS.get("avalon") if pcoll: pyblish_menu_icon = pcoll["pyblish_menu_icon"] pyblish_menu_icon_id = pyblish_menu_icon.icon_id @@ -365,9 +215,6 @@ def draw(self, context): layout.operator(LaunchManager.bl_idname, text="Manage...") layout.separator() layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") - if _is_avalon_in_debug_mode(): - layout.separator() - layout.operator(LaunchTestApp.bl_idname, text="Test App...") # TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and # 'Reset Resolution'? @@ -385,11 +232,8 @@ def draw_avalon_menu(self, context): LaunchPublisher, LaunchManager, LaunchWorkFiles, + TOPBAR_MT_avalon, ] -if _is_avalon_in_debug_mode(): - # Enable the test app in debug mode - classes.append(LaunchTestApp) -classes.append(TOPBAR_MT_avalon) def register(): @@ -398,7 +242,7 @@ def register(): 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 + PREVIEW_COLLECTIONS["avalon"] = pcoll for cls in classes: bpy.utils.register_class(cls) @@ -408,7 +252,7 @@ def register(): def unregister(): """Unregister the operators and menu.""" - pcoll = preview_collections.pop("avalon") + 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): From 08405372c36c6a0e7e88202902f9ca4a43f0faa5 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Tue, 31 Dec 2019 13:51:30 +0100 Subject: [PATCH 17/25] Cleanup --- avalon/blender/pipeline.py | 16 +++++++--------- avalon/blender/workio.py | 8 +++++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 14f05725c..30496929f 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -47,7 +47,6 @@ def _on_load_post(*args): def _register_callbacks(): """Register callbacks for certain events.""" - def _remove_handler(handlers: List, callback: Callable): """Remove the callback from the given handler list.""" @@ -286,13 +285,13 @@ def containerise(name: str, 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: +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: @@ -422,7 +421,6 @@ def publish(): class Creator(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) diff --git a/avalon/blender/workio.py b/avalon/blender/workio.py index 4990a7929..a7ca579eb 100644 --- a/avalon/blender/workio.py +++ b/avalon/blender/workio.py @@ -8,6 +8,7 @@ def open_file(filepath: str) -> Optional[str]: """Open the scene file in Blender.""" + preferences = bpy.context.preferences load_ui = preferences.filepaths.use_load_ui use_scripts = preferences.filepaths.use_scripts_auto_execute @@ -24,6 +25,7 @@ def open_file(filepath: str) -> Optional[str]: 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 @@ -41,6 +43,7 @@ def save_file(filepath: str, copy: bool = False) -> Optional[str]: 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 @@ -49,17 +52,20 @@ def current_file() -> Optional[str]: 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 [".blend"] def work_root() -> str: """Return the default root to browse for work files.""" - from avalon import api + + from .. import api work_dir = api.Session["AVALON_WORKDIR"] scene_dir = api.Session.get("AVALON_SCENEDIR") From 23331c49f45e1b32135e4d10089eb088d1babad4 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 8 Jan 2020 08:33:39 +0100 Subject: [PATCH 18/25] Refactor to find_submodule (needed after merge of #501) --- avalon/blender/pipeline.py | 32 +++----------------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 30496929f..8fd712c18 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -11,14 +11,13 @@ import pyblish.util from .. import api, schema -from ..lib import logger +from ..lib import find_submodule, logger from ..pipeline import AVALON_CONTAINER_ID from . import lib, ops self = sys.modules[__name__] self._events = dict() # Registered Blender callbacks self._parent = None # Main window -self._ignore_lock = False AVALON_CONTAINERS = "AVALON_CONTAINERS" AVALON_PROPERTY = 'avalon' @@ -92,18 +91,6 @@ def _register_events(): logger.info("Installed event callback for 'taskChanged'...") -def find_host_config(config: ModuleType) -> Optional[ModuleType]: - """Find the config for the current host (Blender).""" - - config_name = f"{config.__name__}.blender" - try: - return importlib.import_module(config_name) - except ImportError as exc: - if str(exc) != f"No module named '{config_name}'": - raise - return None - - def install(config: ModuleType): """Install Blender-specific functionality for Avalon. @@ -118,10 +105,6 @@ def install(config: ModuleType): pyblish.api.register_host("blender") - host_config = find_host_config(config) - if hasattr(host_config, "install"): - host_config.install() - def uninstall(config: ModuleType): """Uninstall Blender-specific functionality of avalon-core. @@ -132,10 +115,6 @@ def uninstall(config: ModuleType): config: configuration module """ - host_config = find_host_config(config) - if hasattr(config, "uninstall"): - host_config.uninstall() - if not IS_HEADLESS: ops.unregister() @@ -364,13 +343,8 @@ def ls() -> Iterator: containers = _ls() - has_metadata_collector = False - config = find_host_config(api.registered_config()) - if config is None: - logger.error("Could not find host config for Blender") - return tuple() - if hasattr(config, "collect_container_metadata"): - has_metadata_collector = True + config = find_submodule(api.registered_config(), "blender") + has_metadata_collector = hasattr(config, "collect_container_metadata") for container in containers: data = parse_container(container) From 37c2fa893738213b2dade2fd77c0dcae9fa159b2 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 8 Jan 2020 08:34:08 +0100 Subject: [PATCH 19/25] Remove teardown function --- avalon/blender/pipeline.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index 8fd712c18..d8e5a0fbf 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -169,16 +169,6 @@ def _discover_gui() -> Optional[Callable]: return None -def teardown(): - """Remove integration""" - - if not self._has_been_setup: - return - - self._has_been_setup = False - logger.info("pyblish: Integration torn down successfully") - - def add_to_avalon_container(container: bpy.types.Collection): """Add the container to the Avalon container.""" From 12633c75de96a661c7c57f3fbb9af0bf149faee3 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 8 Jan 2020 12:26:07 +0100 Subject: [PATCH 20/25] Make sure only 1 timer is registered for Qt apps --- avalon/blender/ops.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index 15298ab34..afbb38eec 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -46,6 +46,7 @@ def _process_app_events(app: QtWidgets.QApplication) -> Optional[float]: app.processEvents() return TIMER_INTERVAL + bpy.context.window_manager['is_avalon_qt_timer_running'] = False return None @@ -86,10 +87,14 @@ def execute(self, context): args = getattr(self, "_show_args", list()) kwargs = getattr(self, "_show_kwargs", dict()) self._window.show(*args, **kwargs) - bpy.app.timers.register( - partial(_process_app_events, self._app), - persistent=True, - ) + + wm = bpy.context.window_manager + if not wm.get('is_avalon_qt_timer_running', False): + bpy.app.timers.register( + partial(_process_app_events, self._app), + persistent=True, + ) + wm['is_avalon_qt_timer_running'] = True return {'FINISHED'} From 5019142c0d1f7099aa1863d3fd8cad02c46b8928 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 8 Jan 2020 16:34:52 +0100 Subject: [PATCH 21/25] Fix check for self._window --- avalon/blender/ops.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/avalon/blender/ops.py b/avalon/blender/ops.py index afbb38eec..ec9108c39 100644 --- a/avalon/blender/ops.py +++ b/avalon/blender/ops.py @@ -78,7 +78,7 @@ def execute(self, context): """ # Check if `self._window` is properly set - if getattr(self, "_window") is None: + if getattr(self, "_window", None) is None: raise AttributeError("`self._window` should be set.") if not isinstance(self._window, (QtWidgets.QDialog, ModuleType)): raise AttributeError( From cff9ad614e08da3557543eba3a9f621512de4058 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 8 Jan 2020 20:54:49 +0100 Subject: [PATCH 22/25] Exclude blender from tests Maya and Houdini are also excluded, so it seems safe and the sensible thing to do. --- run_maya_tests.py | 1 + run_tests.py | 1 + 2 files changed, 2 insertions(+) diff --git a/run_maya_tests.py b/run_maya_tests.py index 7b8029ea7..e52b6eee7 100644 --- a/run_maya_tests.py +++ b/run_maya_tests.py @@ -42,6 +42,7 @@ "--exclude-dir=avalon/nuke", "--exclude-dir=avalon/houdini", + "--exclude-dir=avalon/blender", "--exclude-dir=avalon/schema", # We can expect any vendors to diff --git a/run_tests.py b/run_tests.py index 7dfa8f247..11d6f48ab 100644 --- a/run_tests.py +++ b/run_tests.py @@ -23,6 +23,7 @@ "--exclude-dir=avalon/maya", "--exclude-dir=avalon/nuke", "--exclude-dir=avalon/houdini", + "--exclude-dir=avalon/blender", # We can expect any vendors to # be well tested beforehand. From a320eb36ab8de92e6ad2420f66f55db8ff14b789 Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Wed, 8 Jan 2020 21:50:29 +0100 Subject: [PATCH 23/25] =?UTF-8?q?Rename=20avalon=5Fsetup=20=E2=86=92=20set?= =?UTF-8?q?up=5Favalon=20and=20add=20comment=20about=20sys.excepthook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup/blender/startup/avalon_setup.py | 7 ------- setup/blender/startup/setup_avalon.py | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) delete mode 100644 setup/blender/startup/avalon_setup.py create mode 100644 setup/blender/startup/setup_avalon.py diff --git a/setup/blender/startup/avalon_setup.py b/setup/blender/startup/avalon_setup.py deleted file mode 100644 index 932ec5c2e..000000000 --- a/setup/blender/startup/avalon_setup.py +++ /dev/null @@ -1,7 +0,0 @@ -from avalon import api, blender - - -def register(): - """Register Avalon with Blender.""" - print("Registering Avalon...") - api.install(blender) diff --git a/setup/blender/startup/setup_avalon.py b/setup/blender/startup/setup_avalon.py new file mode 100644 index 000000000..639e20ea9 --- /dev/null +++ b/setup/blender/startup/setup_avalon.py @@ -0,0 +1,16 @@ +from avalon import api, blender + + +def register(): + """Register Avalon with Blender.""" + + print("Registering Avalon...") + api.install(blender) + + # Uncomment the following lines if you temporarily need to prevent Blender + # from crashing due to errors in Qt related code. Note however that this + # can be dangerous and have unwanted complications. The excepthook may + # already be overridden (with good reason) and this will remove the + # previous override. + # import sys + # sys.excepthook = lambda *exc_info: traceback.print_exception(*exc_info) From fe4bd5c71959d15a43f584dd7e4facd31769561b Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Fri, 24 Jan 2020 13:10:30 +0100 Subject: [PATCH 24/25] Remove unneeded `config` variable from blender (un)install --- avalon/blender/pipeline.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index d8e5a0fbf..b0de47a6f 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -91,7 +91,7 @@ def _register_events(): logger.info("Installed event callback for 'taskChanged'...") -def install(config: ModuleType): +def install(): """Install Blender-specific functionality for Avalon. This function is called automatically on calling `api.install(blender)`. @@ -106,13 +106,10 @@ def install(config: ModuleType): pyblish.api.register_host("blender") -def uninstall(config: ModuleType): +def uninstall(): """Uninstall Blender-specific functionality of avalon-core. This function is called automatically on calling `api.uninstall()`. - - Args: - config: configuration module """ if not IS_HEADLESS: From fd10091eead87d73bd12b6286497eca30e42f15e Mon Sep 17 00:00:00 2001 From: Jasper van Nieuwenhuizen Date: Fri, 24 Jan 2020 14:21:42 +0100 Subject: [PATCH 25/25] Remove unused import + fix indentation --- avalon/blender/pipeline.py | 1 - setup/blender/startup/setup_avalon.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py index b0de47a6f..de299285c 100644 --- a/avalon/blender/pipeline.py +++ b/avalon/blender/pipeline.py @@ -2,7 +2,6 @@ import importlib import sys -from types import ModuleType from typing import Callable, Dict, Iterator, List, Optional import bpy diff --git a/setup/blender/startup/setup_avalon.py b/setup/blender/startup/setup_avalon.py index 639e20ea9..96621f973 100644 --- a/setup/blender/startup/setup_avalon.py +++ b/setup/blender/startup/setup_avalon.py @@ -7,10 +7,10 @@ def register(): print("Registering Avalon...") api.install(blender) - # Uncomment the following lines if you temporarily need to prevent Blender - # from crashing due to errors in Qt related code. Note however that this + # Uncomment the following lines if you temporarily need to prevent Blender + # from crashing due to errors in Qt related code. Note however that this # can be dangerous and have unwanted complications. The excepthook may # already be overridden (with good reason) and this will remove the # previous override. - # import sys + # import sys # sys.excepthook = lambda *exc_info: traceback.print_exception(*exc_info)