diff --git a/avalon/blender/__init__.py b/avalon/blender/__init__.py new file mode 100644 index 000000000..7041791e9 --- /dev/null +++ b/avalon/blender/__init__.py @@ -0,0 +1,60 @@ +"""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, + get_selection, + # unique_name, +) + + +__all__ = [ + "install", + "uninstall", + "Creator", + "Loader", + "ls", + "publish", + "containerise", + + # Workfiles API + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + + # Utility functions + "maintained_selection", + "lsattr", + "lsattrs", + "read", + "get_selection", + # "unique_name", +] diff --git a/avalon/blender/icons/pyblish-32x32.png b/avalon/blender/icons/pyblish-32x32.png new file mode 100644 index 000000000..b34e397e0 Binary files /dev/null and b/avalon/blender/icons/pyblish-32x32.png differ diff --git a/avalon/blender/lib.py b/avalon/blender/lib.py new file mode 100644 index 000000000..484b2a6f0 --- /dev/null +++ b/avalon/blender/lib.py @@ -0,0 +1,161 @@ +"""Standalone helper functions.""" + +import contextlib +from typing import Dict, List, Union + +import bpy + +from ..lib import logger +from . import pipeline + + +def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): + r"""Write `data` to `node` as userDefined attributes + + Arguments: + node: Long name of node + data: Dictionary of key/value pairs + + Example: + >>> import bpy + >>> def compute(): + ... return 6 + ... + >>> bpy.ops.mesh.primitive_cube_add() + >>> cube = bpy.context.view_layer.objects.active + >>> imprint(cube, { + ... "regularString": "myFamily", + ... "computedValue": lambda: compute() + ... }) + ... + >>> cube['avalon']['computedValue'] + 6 + """ + + imprint_data = dict() + + for key, value in data.items(): + if value is None: + continue + + if callable(value): + # Support values evaluated at imprint + value = value() + + if not isinstance(value, (int, float, bool, str, list)): + raise TypeError(f"Unsupported type: {type(value)}") + + imprint_data[key] = value + + pipeline.metadata_update(node, imprint_data) + + +def lsattr(attr: str, + value: Union[str, int, bool, List, Dict, None] = None) -> List: + r"""Return nodes matching `attr` and `value` + + Arguments: + attr: Name of Blender property + value: Value of attribute. If none + is provided, return all nodes with this attribute. + + Example: + >>> lsattr("id", "myId") + ... [bpy.data.objects["myNode"] + >>> lsattr("id") + ... [bpy.data.objects["myNode"], bpy.data.objects["myOtherNode"]] + + Returns: + list + """ + + return lsattrs({attr: value}) + + +def lsattrs(attrs: Dict) -> List: + r"""Return nodes with the given attribute(s). + + Arguments: + attrs: Name and value pairs of expected matches + + Example: + >>> lsattrs({"age": 5}) # Return nodes with an `age` of 5 + # Return nodes with both `age` and `color` of 5 and blue + >>> lsattrs({"age": 5, "color": "blue"}) + + Returns a list. + + """ + + # For now return all objects, not filtered by scene/collection/view_layer. + matches = set() + for coll in dir(bpy.data): + if not isinstance( + getattr(bpy.data, coll), + bpy.types.bpy_prop_collection, + ): + continue + for node in getattr(bpy.data, coll): + for attr, value in attrs.items(): + avalon_prop = node.get(pipeline.AVALON_PROPERTY) + if not avalon_prop: + continue + if (avalon_prop.get(attr) + and (value is None or avalon_prop.get(attr) == value)): + matches.add(node) + return list(matches) + + +def read(node: bpy.types.bpy_struct_meta_idprop): + """Return user-defined attributes from `node`""" + + data = dict(node.get(pipeline.AVALON_PROPERTY)) + + # Ignore hidden/internal data + data = { + key: value + for key, value in data.items() if not key.startswith("_") + } + + return data + + +def get_selection() -> List[bpy.types.Object]: + """Return the selected objects from the current scene.""" + return [obj for obj in bpy.context.scene.objects if obj.select_get()] + + +@contextlib.contextmanager +def maintained_selection(): + r"""Maintain selection during context + + Example: + >>> with maintained_selection(): + ... # Modify selection + ... bpy.ops.object.select_all(action='DESELECT') + >>> # Selection restored + """ + + previous_selection = get_selection() + previous_active = bpy.context.view_layer.objects.active + try: + yield + finally: + # Clear the selection + for node in get_selection(): + node.select_set(state=False) + if previous_selection: + for node in previous_selection: + try: + node.select_set(state=True) + except ReferenceError: + # This could happen if a selected node was deleted during + # the context. + 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..ec9108c39 --- /dev/null +++ b/avalon/blender/ops.py @@ -0,0 +1,264 @@ +"""Blender operators and menus for use with Avalon.""" + +import os +import sys +from functools import partial +from pathlib import Path +from types import ModuleType +from typing import Dict, List, Optional, Union + +import bpy +import bpy.utils.previews + +from .. import api +from ..vendor.Qt import QtWidgets + +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 _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 + + return False + + +def _process_app_events(app: QtWidgets.QApplication) -> Optional[float]: + """Process the events of the Qt app if the window is still visible. + + If the app has any top level windows and at least one of them is visible + return the time after which this function should be run again. Else return + None, so the function is not run again and will be unregistered. + """ + + if _has_visible_windows(app): + app.processEvents() + return TIMER_INTERVAL + + bpy.context.window_manager['is_avalon_qt_timer_running'] = False + return None + + +class LaunchQtApp(bpy.types.Operator): + """A Base class for opertors to launch a Qt app.""" + + _app: QtWidgets.QApplication + _window: Union[QtWidgets.QDialog, ModuleType] + _show_args: Optional[List] + _show_kwargs: Optional[Dict] + + def __init__(self): + from .. import style + print(f"Initialising {self.bl_idname}...") + self._app = (QtWidgets.QApplication.instance() + or QtWidgets.QApplication(sys.argv)) + self._app.setStyleSheet(style.load_stylesheet()) + + 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", None) 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()) + self._window.show(*args, **kwargs) + + 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'} + + +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 + return super().execute(context) + + +class LaunchLoader(LaunchQtApp): + """Launch Avalon Loader.""" + + bl_idname = "wm.avalon_loader" + bl_label = "Load..." + + def execute(self, context): + 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, + } + 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 + 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", ""), + os.environ.get("AVALON_SCENEDIR", ""), + )) + self._window = workfiles + self._show_kwargs = {"root": root} + 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 + + 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...") + layout.operator(LaunchLoader.bl_idname, text="Load...") + layout.operator( + LaunchPublisher.bl_idname, + text="Publish...", + icon_value=pyblish_menu_icon_id, + ) + layout.operator(LaunchManager.bl_idname, text="Manage...") + layout.separator() + layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...") + # TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and + # 'Reset Resolution'? + + +def draw_avalon_menu(self, context): + """Draw the Avalon menu in the top bar.""" + + self.layout.menu(TOPBAR_MT_avalon.bl_idname) + + +classes = [ + LaunchContextManager, + LaunchCreator, + LaunchLoader, + LaunchPublisher, + LaunchManager, + LaunchWorkFiles, + TOPBAR_MT_avalon, +] + + +def register(): + "Register the operators and menu." + + pcoll = bpy.utils.previews.new() + pyblish_icon_file = Path(__file__).parent / "icons" / "pyblish-32x32.png" + pcoll.load("pyblish_menu_icon", str(pyblish_icon_file.absolute()), 'IMAGE') + PREVIEW_COLLECTIONS["avalon"] = pcoll + + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.TOPBAR_MT_editor_menus.append(draw_avalon_menu) + + +def unregister(): + """Unregister the operators and menu.""" + + pcoll = PREVIEW_COLLECTIONS.pop("avalon") + bpy.utils.previews.remove(pcoll) + bpy.types.TOPBAR_MT_editor_menus.remove(draw_avalon_menu) + for cls in reversed(classes): + bpy.utils.unregister_class(cls) diff --git a/avalon/blender/pipeline.py b/avalon/blender/pipeline.py new file mode 100644 index 000000000..de299285c --- /dev/null +++ b/avalon/blender/pipeline.py @@ -0,0 +1,406 @@ +"""Pipeline integration functions.""" + +import importlib +import sys +from typing import Callable, Dict, Iterator, List, Optional + +import bpy + +import pyblish.api +import pyblish.util + +from .. import api, schema +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 + +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"] + logger.debug("New working directory: %s", workdir) + + +def _register_events(): + """Install callbacks for specific events.""" + + api.on("taskChanged", _on_task_changed) + logger.info("Installed event callback for 'taskChanged'...") + + +def install(): + """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") + + +def uninstall(): + """Uninstall Blender-specific functionality of avalon-core. + + This function is called automatically on calling `api.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 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() + + config = find_submodule(api.registered_config(), "blender") + has_metadata_collector = hasattr(config, "collect_container_metadata") + + 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? 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 + + 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 lib.get_selection(): + 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..a7ca579eb --- /dev/null +++ b/avalon/blender/workio.py @@ -0,0 +1,74 @@ +"""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 .. 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/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index f6c5e6fba..46e3be7a0 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -368,7 +368,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_() 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. diff --git a/setup/blender/startup/setup_avalon.py b/setup/blender/startup/setup_avalon.py new file mode 100644 index 000000000..96621f973 --- /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)