diff --git a/avalon/fusion/__init__.py b/avalon/fusion/__init__.py index 30203d1cd..8f75111d4 100644 --- a/avalon/fusion/__init__.py +++ b/avalon/fusion/__init__.py @@ -15,7 +15,15 @@ get_current_comp, comp_lock_and_undo_chunk +) +from .workio import ( + open, + save, + current_file, + has_unsaved_changes, + file_extensions, + work_root ) from .lib import ( @@ -33,6 +41,14 @@ "get_current_comp", "comp_lock_and_undo_chunk", + # Workfiles API + "open", + "save", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + "maintained_selection" ] diff --git a/avalon/fusion/workio.py b/avalon/fusion/workio.py new file mode 100644 index 000000000..b163dedbf --- /dev/null +++ b/avalon/fusion/workio.py @@ -0,0 +1,50 @@ +"""Host API required Work Files tool""" +import sys +import os + + +def file_extensions(): + return [".comp"] + + +def has_unsaved_changes(): + from avalon.fusion.pipeline import get_current_comp + + comp = get_current_comp() + return comp.GetAttrs()["COMPB_Modified"] + + +def save(filepath): + from avalon.fusion.pipeline import get_current_comp + + comp = get_current_comp() + comp.Save(filepath) + + +def open(filepath): + # Hack to get fusion, see avalon.fusion.pipeline.get_current_comp() + fusion = getattr(sys.modules["__main__"], "fusion", None) + + return fusion.LoadComp(filepath) + + +def current_file(): + from avalon.fusion.pipeline import get_current_comp + + comp = get_current_comp() + current_filepath = comp.GetAttrs()["COMPS_FileName"] + if not current_filepath: + return None + + return current_filepath + + +def work_root(): + from avalon import api + + work_dir = api.Session["AVALON_WORKDIR"] + scene_dir = api.Session.get("AVALON_SCENEDIR") + if scene_dir: + return os.path.join(work_dir, scene_dir) + else: + return work_dir diff --git a/avalon/houdini/__init__.py b/avalon/houdini/__init__.py index 55a6b6bb1..14b40c0f4 100644 --- a/avalon/houdini/__init__.py +++ b/avalon/houdini/__init__.py @@ -9,6 +9,15 @@ ) +from .workio import ( + open, + save, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + from .lib import ( lsattr, lsattrs, @@ -28,11 +37,19 @@ "ls", "containerise", - # Utility functions - "maintained_selection", + # Workfiles API + "open", + "save", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + # Utility functions "lsattr", "lsattrs", "read", + + "maintained_selection", "unique_name" ] diff --git a/avalon/houdini/lib.py b/avalon/houdini/lib.py index abdaabd29..66890a24d 100644 --- a/avalon/houdini/lib.py +++ b/avalon/houdini/lib.py @@ -174,9 +174,11 @@ def maintained_selection(): try: yield finally: + # Clear the selection + # todo: does hou.clearAllSelected() do the same? + for node in hou.selectedNodes(): + node.setSelected(on=False) + if previous_selection: for node in previous_selection: node.setSelected(on=True) - else: - for node in previous_selection: - node.setSelected(on=False) diff --git a/avalon/houdini/pipeline.py b/avalon/houdini/pipeline.py index 17d70d28c..33170c4bd 100644 --- a/avalon/houdini/pipeline.py +++ b/avalon/houdini/pipeline.py @@ -20,7 +20,7 @@ self._parent = None self._events = dict() -AVALON_CONTAINERS = "AVALON_CONTAINERS" +AVALON_CONTAINERS = "/obj/AVALON_CONTAINERS" IS_HEADLESS = not hasattr(hou, "ui") @@ -30,6 +30,7 @@ def install(config): """ + print("Registering callbacks") _register_callbacks() pyblish.api.register_host("houdini") @@ -151,15 +152,11 @@ def containerise(name, context, loader=None, suffix=""): - """Bundle `nodes` into an assembly and imprint it with metadata + """Bundle `nodes` into a subnet and imprint it with metadata Containerisation enables a tracking of version, author and origin for loaded assets. - In Houdini it is not possible to next goemetry nodes in geometry nodes - directly. To counter this we place a Object Network node calles ROOT - in the HOUDINI_CONTAINERS node. - Arguments: name (str): Name of resulting assembly namespace (str): Namespace under which to host container @@ -173,16 +170,18 @@ def containerise(name, """ - # Get main object network node - obj_network = hou.node("/obj") + # Ensure AVALON_CONTAINERS subnet exists + subnet = hou.node(AVALON_CONTAINERS) + if subnet is None: + obj_network = hou.node("/obj") + subnet = obj_network.createNode("subnet", + node_name="AVALON_CONTAINERS") - # Check if the AVALON_CONTAINERS exists - main_container = obj_network.node(AVALON_CONTAINERS) - if main_container is None: - main_container = obj_network.createNode("subnet", - node_name="AVALON_CONTAINERS") + # Create proper container name + container_name = "{}_{}".format(name, suffix or "CON") + container = hou.node("/obj/{}".format(name)) + container.setName(container_name) - container = obj_network.node(name) data = { "schema": "avalon-core:container-2.0", "id": AVALON_CONTAINER_ID, @@ -195,10 +194,9 @@ def containerise(name, lib.imprint(container, data) # "Parent" the container under the container network - hou.moveNodesTo([container], main_container) + hou.moveNodesTo([container], subnet) - # Get the container and set good position - main_container.node(name).moveToGoodPosition() + subnet.node(container_name).moveToGoodPosition() return container @@ -265,7 +263,7 @@ class Creator(api.Creator): """ def __init__(self, *args, **kwargs): - api.Creator.__init__(self, *args, **kwargs) + super(Creator, self).__init__(*args, **kwargs) self.nodes = list() def process(self): @@ -302,8 +300,8 @@ def process(self): node_type = "geometry" # Get out node - out = hou.node("out") - instance = out.createNode(node_type, node_name=self.data["subset"]) + out = hou.node("/out") + instance = out.createNode(node_type, node_name=self.name) instance.moveToGoodPosition() lib.imprint(instance, self.data) @@ -311,16 +309,15 @@ def process(self): return instance -def _on_scene_open(*args): - api.emit("open", *[]) - - -def _on_scene_new(*args): - api.emit("new", *[]) - - -def _on_scene_save(*args): - api.emit("save", *[]) +def on_file_event_callback(event): + if event == hou.hipFileEventType.AfterLoad: + api.emit("open", [event]) + elif event == hou.hipFileEventType.AfterSave: + api.emit("save", [event]) + elif event == hou.hipFileEventType.BeforeSave: + api.emit("before_save", [event]) + elif event == hou.hipFileEventType.AfterClear: + api.emit("new", [event]) def on_houdini_initialize(): @@ -340,8 +337,6 @@ def _register_callbacks(): except RuntimeError as e: logger.info(e) - self._events[_on_scene_save] = hou.hipFile.addEventCallback(_on_scene_save) - - self._events[_on_scene_new] = hou.hipFile.addEventCallback(_on_scene_new) - - self._events[_on_scene_open] = hou.hipFile.addEventCallback(_on_scene_open) + self._events[on_file_event_callback] = hou.hipFile.addEventCallback( + on_file_event_callback + ) diff --git a/avalon/houdini/workio.py b/avalon/houdini/workio.py new file mode 100644 index 000000000..a34a16cf0 --- /dev/null +++ b/avalon/houdini/workio.py @@ -0,0 +1,59 @@ +"""Host API required Work Files tool""" +import os + +import hou + + +def file_extensions(): + return [".hip", ".hiplc", ".hipnc"] + + +def has_unsaved_changes(): + return hou.hipFile.hasUnsavedChanges() + + +def save(filepath): + + # Force forwards slashes to avoid segfault + filepath = filepath.replace("\\", "/") + + hou.hipFile.save(file_name=filepath, + save_to_recent_files=True) + + return filepath + + +def open(filepath): + + # Force forwards slashes to avoid segfault + filepath = filepath.replace("\\", "/") + + hou.hipFile.load(filepath, + suppress_save_prompt=True, + ignore_load_warnings=False) + + return filepath + + +def current_file(): + + current_filepath = hou.hipFile.path() + if (os.path.basename(current_filepath) == "untitled.hip" and + not os.path.exists(current_filepath)): + # By default a new scene in houdini is saved in the current + # working directory as "untitled.hip" so we need to capture + # that and consider it 'not saved' when it's in that state. + return None + + return current_filepath + + +def work_root(): + from avalon import api + + work_dir = api.Session["AVALON_WORKDIR"] + scene_dir = api.Session.get("AVALON_SCENEDIR") + if scene_dir: + return os.path.join(work_dir, scene_dir) + else: + return work_dir diff --git a/avalon/inventory.py b/avalon/inventory.py index a735cc0a6..54bc07572 100644 --- a/avalon/inventory.py +++ b/avalon/inventory.py @@ -326,6 +326,7 @@ def _save_config_1_0(project_name, data): config["tasks"] = data.get("tasks", []) config["template"].update(data.get("template", {})) config["families"] = data.get("families", []) + config["groups"] = data.get("groups", []) schema.validate(document) diff --git a/avalon/io.py b/avalon/io.py index d95b1d47b..541662ab9 100644 --- a/avalon/io.py +++ b/avalon/io.py @@ -135,6 +135,9 @@ def _from_environment(): # Path to working directory ("AVALON_WORKDIR", None), + # Optional path to scenes directory (see Work Files API) + ("AVALON_SCENEDIR", None), + # Name of current Config # TODO(marcus): Establish a suitable default config ("AVALON_CONFIG", "no_config"), diff --git a/avalon/maya/__init__.py b/avalon/maya/__init__.py index 5317131f8..46ef5be26 100644 --- a/avalon/maya/__init__.py +++ b/avalon/maya/__init__.py @@ -26,6 +26,15 @@ ) +from .workio import ( + open, + save, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + from .lib import ( export_alembic, lsattr, @@ -61,6 +70,15 @@ "is_locked", "lock_ignored", + # Workfiles API + "open", + "save", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", + + # Utility functions "export_alembic", "lsattr", "lsattrs", diff --git a/avalon/maya/lib.py b/avalon/maya/lib.py index 4f9b3c76a..b3ed544a7 100644 --- a/avalon/maya/lib.py +++ b/avalon/maya/lib.py @@ -115,11 +115,19 @@ def export_alembic(nodes, Arguments: nodes (list): Long names of nodes to cache + file (str): Absolute path to output destination + frame_range (tuple, optional): Start- and end-frame of cache, default to current animation range. - uv_write (bool, optional): Whether or not to include UVs, + + write_uv (bool, optional): Whether or not to include UVs, default to True + + write_visibility (bool, optional): Turn on to store the visibility + state of objects in the Alembic file. Otherwise, all objects are + considered visible, default to True + attribute_prefix (str, optional): Include all user-defined attributes with this prefix. @@ -164,6 +172,17 @@ def export_alembic(nodes, return mel.eval(mel_cmd) +@contextlib.contextmanager +def undo_chunk(): + """Open a undo chunk during context.""" + + try: + cmds.undoInfo(openChunk=True) + yield + finally: + cmds.undoInfo(closeChunk=True) + + def imprint(node, data): """Write `data` to `node` as userDefined attributes @@ -428,7 +447,10 @@ def lsattr(attr, value=None): """ if value is None: - return cmds.ls("*.%s" % attr) + return cmds.ls("*.%s" % attr, + recursive=True, + objectsOnly=True, + long=True) return lsattrs({attr: value}) @@ -439,12 +461,13 @@ def lsattrs(attrs): attrs (dict): Name and value pairs of expected matches Example: - >> lsattr("age") # Return nodes with attribute `age` - >> lsattr({"age": 5}) # Return nodes with an `age` of 5 - >> # Return nodes with both `age` and `color` of 5 and blue - >> lsattr({"age": 5, "color": "blue"}) + >> # Return nodes with an `age` of five. + >> lsattr({"age": "five"}) + >> # Return nodes with both `age` and `color` of five and blue. + >> lsattr({"age": "five", "color": "blue"}) - Returns a list. + Return: + list: matching nodes. """ @@ -457,8 +480,8 @@ def lsattrs(attrs): try: selection_list.add("*.{0}".format(first_attr), searchChildNamespaces=True) - except RuntimeError, e: - if str(e).endswith("Object does not exist"): + except RuntimeError as exc: + if str(exc).endswith("Object does not exist"): return [] matches = set() diff --git a/avalon/maya/pipeline.py b/avalon/maya/pipeline.py index 338392729..5ac1851f7 100644 --- a/avalon/maya/pipeline.py +++ b/avalon/maya/pipeline.py @@ -491,11 +491,12 @@ class Creator(api.Creator): def process(self): nodes = list() - if (self.options or {}).get("useSelection"): - nodes = cmds.ls(selection=True) + with lib.undo_chunk(): + if (self.options or {}).get("useSelection"): + nodes = cmds.ls(selection=True) - instance = cmds.sets(nodes, name=self.data["subset"]) - lib.imprint(instance, self.data) + instance = cmds.sets(nodes, name=self.name) + lib.imprint(instance, self.data) return instance diff --git a/avalon/maya/workio.py b/avalon/maya/workio.py new file mode 100644 index 000000000..eb5a8719b --- /dev/null +++ b/avalon/maya/workio.py @@ -0,0 +1,39 @@ +"""Host API required Work Files tool""" +import os + +from maya import cmds + + +def file_extensions(): + return [".ma", ".mb"] + + +def has_unsaved_changes(): + return cmds.file(query=True, modified=True) + + +def save(filepath): + cmds.file(rename=filepath) + cmds.file(save=True, type="mayaAscii") + + +def open(filepath): + return cmds.file(filepath, open=True, force=True) + + +def current_file(): + + current_filepath = cmds.file(query=True, sceneName=True) + if not current_filepath: + return None + + return current_filepath + + +def work_root(): + + # Base the root on the current Maya workspace. + return os.path.join( + cmds.workspace(query=True, rootDirectory=True), + cmds.workspace(fileRuleEntry="scene") + ) diff --git a/avalon/schema/config-1.0.json b/avalon/schema/config-1.0.json index 97392e9dd..fe49ce1c9 100644 --- a/avalon/schema/config-1.0.json +++ b/avalon/schema/config-1.0.json @@ -60,7 +60,21 @@ "properties": { "name": {"type": "string"}, "icon": {"type": "string"}, - "label": {"type": "string"} + "label": {"type": "string"}, + "hideFilter": {"type": "boolean"} + }, + "required": ["name"] + } + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "icon": {"type": "string"}, + "color": {"type": "string"}, + "order": {"type": ["integer", "number"]} }, "required": ["name"] } diff --git a/avalon/tests/test_inventory.py b/avalon/tests/test_inventory.py index 8c653d14e..bc9471d4d 100644 --- a/avalon/tests/test_inventory.py +++ b/avalon/tests/test_inventory.py @@ -137,6 +137,14 @@ def test_save(): "families": [ {"name": "avalon.model", "label": "Model", "icon": "cube"} ], + "groups": [ + { + "name": "charCaches", + "icon": "diamond", + "color": "#C4CEDC", + "order": -99 + }, + ], "copy": {} } diff --git a/avalon/tools/contextmanager/app.py b/avalon/tools/contextmanager/app.py index ee6b7c4dd..141713b0c 100644 --- a/avalon/tools/contextmanager/app.py +++ b/avalon/tools/contextmanager/app.py @@ -33,7 +33,7 @@ def __init__(self, parent=None): accept_btn = QtWidgets.QPushButton("Accept") # Asset picker - assets = AssetWidget() + assets = AssetWidget(silo_creatable=False) # Task picker tasks_widgets = QtWidgets.QWidget() diff --git a/avalon/tools/creator/app.py b/avalon/tools/creator/app.py index 3a263b4b7..34aff8b83 100644 --- a/avalon/tools/creator/app.py +++ b/avalon/tools/creator/app.py @@ -319,22 +319,19 @@ def on_create(self): result = self.data["Result"] item = listing.currentItem() - useselection_chk = self.data["Use Selection Checkbox"] - - if item is not None: - subset_name = result.text() - asset = asset.text() - family = item.data(FamilyRole) - else: + if item is None: return + subset_name = result.text() + asset = asset.text() + family = item.data(FamilyRole) + use_selection = self.data["Use Selection Checkbox"].isChecked() + try: api.create(subset_name, asset, family, - options={"useSelection": - useselection_chk.checkState()} - ) + options={"useSelection": use_selection}) except NameError as e: self.echo(e) @@ -344,6 +341,8 @@ def on_create(self): self.echo("Program error: %s" % str(e)) raise + self.echo("Created %s .." % subset_name) + def echo(self, message): widget = self.data["Error Message"] widget.setText(str(message)) diff --git a/avalon/tools/loader/app.py b/avalon/tools/loader/app.py index e46456236..843a1f0eb 100644 --- a/avalon/tools/loader/app.py +++ b/avalon/tools/loader/app.py @@ -1,13 +1,21 @@ import sys import time -from ..projectmanager.widget import AssetWidget, AssetModel +from ..projectmanager.widget import ( + AssetWidget, + AssetModel, + preserve_selection, +) from ...vendor.Qt import QtWidgets, QtCore from ... import api, io, style from .. import lib -from .lib import refresh_family_config +from .lib import ( + refresh_family_config, + refresh_group_config, + get_active_group_config, +) from .widgets import SubsetWidget, VersionWidget, FamilyListWidget module = sys.modules[__name__] @@ -37,7 +45,7 @@ def __init__(self, parent=None): container = QtWidgets.QWidget() - assets = AssetWidget() + assets = AssetWidget(silo_creatable=False) families = FamilyListWidget() subsets = SubsetWidget() version = VersionWidget() @@ -57,6 +65,10 @@ def __init__(self, parent=None): split.addWidget(subsets) split.addWidget(version) split.setSizes([180, 950, 200]) + + # Remove QSplitter border + split.setStyleSheet("QSplitter { border: 0px; }") + container_layout.addWidget(split) body_layout = QtWidgets.QHBoxLayout(body) @@ -91,6 +103,7 @@ def __init__(self, parent=None): "root": None, "project": None, "asset": None, + "assetId": None, "silo": None, "subset": None, "version": None, @@ -105,6 +118,7 @@ def __init__(self, parent=None): subsets.version_changed.connect(self.on_versionschanged) refresh_family_config() + refresh_group_config() # Defaults self.resize(1330, 700) @@ -173,15 +187,11 @@ def _assetschanged(self): document = asset_item.data(DocumentRole) subsets_model.set_asset(document['_id']) - # Enforce the columns to fit the data (purely cosmetic) - rows = subsets_model.rowCount(QtCore.QModelIndex()) - for i in range(rows): - subsets.view.resizeColumnToContents(i) - # Clear the version information on asset change self.data['model']['version'].set_version(None) self.data["state"]["context"]["asset"] = document["name"] + self.data["state"]["context"]["assetId"] = document["_id"] self.data["state"]["context"]["silo"] = document["silo"] self.echo("Duration: %.3fs" % (time.time() - t1)) @@ -198,7 +208,8 @@ def _versionschanged(self): rows = selection.selectedRows(column=active.column()) if active in rows: node = active.data(subsets.model.NodeRole) - version = node['version_document']['_id'] + if node is not None and not node.get("isGroup"): + version = node['version_document']['_id'] self.data['model']['version'].set_version(version) @@ -269,6 +280,114 @@ def closeEvent(self, event): print("Good bye") return super(Window, self).closeEvent(event) + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping subsets on pressing Ctrl + G + if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and + not event.isAutoRepeat()): + self.show_grouping_dialog() + return + + return super(Window, self).keyPressEvent(event) + + def show_grouping_dialog(self): + subsets = self.data["model"]["subsets"] + if not subsets.is_groupable(): + self.echo("Grouping not enabled.") + return + + selected = subsets.selected_subsets() + if not selected: + self.echo("No selected subset.") + return + + dialog = SubsetGroupingDialog(items=selected, parent=self) + dialog.grouped.connect(self._assetschanged) + dialog.show() + + +class SubsetGroupingDialog(QtWidgets.QDialog): + + grouped = QtCore.Signal() + + def __init__(self, items, parent=None): + super(SubsetGroupingDialog, self).__init__(parent=parent) + self.setWindowTitle("Grouping Subsets") + self.setMinimumWidth(250) + self.setModal(True) + + self.items = items + self.subsets = parent.data["model"]["subsets"] + self.asset_id = parent.data["state"]["context"]["assetId"] + + name = QtWidgets.QLineEdit() + name.setPlaceholderText("Remain blank to ungroup..") + + # Menu for pre-defined subset groups + name_button = QtWidgets.QPushButton() + name_button.setFixedWidth(18) + name_button.setFixedHeight(20) + name_menu = QtWidgets.QMenu(name_button) + name_button.setMenu(name_menu) + + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(name) + name_layout.addWidget(name_button) + name_layout.setContentsMargins(0, 0, 0, 0) + + group_btn = QtWidgets.QPushButton("Apply") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Group Name")) + layout.addLayout(name_layout) + layout.addWidget(group_btn) + + group_btn.clicked.connect(self.on_group) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + self.name = name + self.name_menu = name_menu + + self._build_menu() + + def _build_menu(self): + menu = self.name_menu + button = menu.parent() + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + active_groups = get_active_group_config(self.asset_id, + include_predefined=True) + # Build new action group + group = QtWidgets.QActionGroup(button) + for data in sorted(active_groups, key=lambda x: x["order"]): + name = data["name"] + icon = data["icon"] + + action = group.addAction(name) + action.setIcon(icon) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + button.setEnabled(not menu.isEmpty()) + + def _on_action_clicked(self, action): + self.name.setText(action.text()) + + def on_group(self): + name = self.name.text().strip() + self.subsets.group_subsets(name, self.asset_id, self.items) + + with preserve_selection(tree_view=self.subsets.view, + current_index=False): + self.grouped.emit() + self.close() + def show(debug=False, parent=None, use_context=False): """Display Loader GUI diff --git a/avalon/tools/loader/delegates.py b/avalon/tools/loader/delegates.py index 59d7fbbba..7b9772e45 100644 --- a/avalon/tools/loader/delegates.py +++ b/avalon/tools/loader/delegates.py @@ -120,6 +120,10 @@ def displayText(self, value, locale): return self._format_version(value) def createEditor(self, parent, option, index): + node = index.data(SubsetsModel.NodeRole) + if node.get("isGroup"): + return + editor = QtWidgets.QComboBox(parent) def commit_data(): diff --git a/avalon/tools/loader/lib.py b/avalon/tools/loader/lib.py index 897ac8931..caa5edf5a 100644 --- a/avalon/tools/loader/lib.py +++ b/avalon/tools/loader/lib.py @@ -1,8 +1,9 @@ -from ...vendor import qtawesome -from ... import io, api +from ...vendor import qtawesome, Qt +from ... import io, api, style FAMILY_ICON_COLOR = "#0091B2" FAMILY_CONFIG = {} +GROUP_CONFIG = {} def get(config, name): @@ -11,6 +12,16 @@ def get(config, name): return config.get(name, config.get("__default__", None)) +def is_filtering_recursible(): + """Does Qt binding support recursive filtering for QSortFilterProxyModel ? + + (NOTE) Recursive filtering was introduced in Qt 5.10. + + """ + return hasattr(Qt.QtCore.QSortFilterProxyModel, + "setRecursiveFilteringEnabled") + + def refresh_family_config(): """Get the family configurations from the database @@ -71,3 +82,101 @@ def refresh_family_config(): FAMILY_CONFIG.update(families) return families + + +def refresh_group_config(): + """Get subset group configurations from the database + + The 'group' configuration must be stored in the project `config` field. + See schema `config-1.0.json` + + """ + + # Subset group item's default icon and order + default_group_icon = qtawesome.icon("fa.object-group", + color=style.colors.default) + default_group_config = {"icon": default_group_icon, + "order": 0} + + # Get pre-defined group name and apperance from project config + project = io.find_one({"type": "project"}, + projection={"config.groups": True}) + + assert project, "Project not found!" + group_configs = project["config"].get("groups", []) + + # Build pre-defined group configs + groups = dict() + for config in group_configs: + name = config["name"] + icon = "fa." + config.get("icon", "object-group") + color = config.get("color", style.colors.default) + order = float(config.get("order", 0)) + + groups[name] = {"icon": qtawesome.icon(icon, color=color), + "order": order} + + # Default configuration + groups["__default__"] = default_group_config + + GROUP_CONFIG.clear() + GROUP_CONFIG.update(groups) + + return groups + + +def get_active_group_config(asset_id, include_predefined=False): + """Collect all active groups from each subset + """ + predefineds = GROUP_CONFIG.copy() + default_group_config = predefineds.pop("__default__") + + _orders = set([0]) # default order zero included + for config in predefineds.values(): + _orders.add(config["order"]) + + # Remap order to list index + orders = sorted(_orders) + + # Collect groups from subsets + group_names = set(io.distinct("data.subsetGroup", + {"type": "subset", "parent": asset_id})) + if include_predefined: + # Ensure all predefined group configs will be included + group_names.update(predefineds.keys()) + + groups = list() + + for name in group_names: + # Get group config + config = predefineds.get(name, default_group_config) + # Base order + remapped_order = orders.index(config["order"]) + + data = { + "name": name, + "icon": config["icon"], + "_order": remapped_order, + } + + groups.append(data) + + # Sort by tuple (base_order, name) + # If there are multiple groups in same order, will sorted by name. + ordered = sorted(groups, key=lambda dat: (dat.pop("_order"), dat["name"])) + + total = len(ordered) + order_temp = "%0{}d".format(len(str(total))) + + # Update sorted order to config + for index, data in enumerate(ordered): + order = index + inverse_order = total - order + + data.update({ + # Format orders into fixed length string for groups sorting + "order": order_temp % order, + "inverseOrder": order_temp % inverse_order, + }) + + return ordered diff --git a/avalon/tools/loader/model.py b/avalon/tools/loader/model.py index d559365ec..756f9ec93 100644 --- a/avalon/tools/loader/model.py +++ b/avalon/tools/loader/model.py @@ -21,9 +21,14 @@ class SubsetsModel(TreeModel): "handles", "step"] - def __init__(self, parent=None): + SortAscendingRole = QtCore.Qt.UserRole + 2 + SortDescendingRole = QtCore.Qt.UserRole + 3 + + def __init__(self, grouping=True, parent=None): super(SubsetsModel, self).__init__(parent=parent) self._asset_id = None + self._sorter = None + self._grouping = grouping self._icons = {"subset": qta.icon("fa.file-o", color=style.colors.default)} @@ -31,6 +36,10 @@ def set_asset(self, asset_id): self._asset_id = asset_id self.refresh() + def set_grouping(self, state): + self._grouping = state + self.refresh() + def setData(self, index, value, role=QtCore.Qt.EditRole): # Trigger additional edit when `version` column changed @@ -77,7 +86,8 @@ def set_version(self, index, version): frames = None duration = None - family = version_data.get("families", [None])[0] + families = version_data.get("families", [None]) + family = families[0] family_config = lib.get(lib.FAMILY_CONFIG, family) node.update({ @@ -88,6 +98,7 @@ def set_version(self, index, version): "family": family, "familyLabel": family_config.get("label", family), "familyIcon": family_config.get('icon', None), + "families": set(families), "startFrame": start, "endFrame": end, "duration": duration, @@ -104,31 +115,61 @@ def refresh(self): self.endResetModel() return - row = 0 - for subset in io.find({"type": "subset", - "parent": self._asset_id}): + asset_id = self._asset_id + + active_groups = lib.get_active_group_config(asset_id) + + # Generate subset group nodes + group_nodes = dict() + + if self._grouping: + for data in active_groups: + name = data.pop("name") + group = Node() + group.update({"subset": name, "isGroup": True, "childRow": 0}) + group.update(data) + + group_nodes[name] = group + self.add_child(group) + + filter = {"type": "subset", "parent": asset_id} + + # Process subsets + row = len(group_nodes) + for subset in io.find(filter): last_version = io.find_one({"type": "version", - "parent": subset['_id']}, + "parent": subset["_id"]}, sort=[("name", -1)]) if not last_version: # No published version for the subset continue data = subset.copy() - data['subset'] = data['name'] + data["subset"] = data["name"] + + group_name = subset["data"].get("subsetGroup") + if self._grouping and group_name: + group = group_nodes[group_name] + parent = group + parent_index = self.createIndex(0, 0, group) + row_ = group["childRow"] + group["childRow"] += 1 + else: + parent = None + parent_index = QtCore.QModelIndex() + row_ = row + row += 1 node = Node() node.update(data) - self.add_child(node) + self.add_child(node, parent=parent) # Set the version information - index = self.index(row, 0, parent=QtCore.QModelIndex()) + index = self.index(row_, 0, parent=parent_index) self.set_version(index, last_version) - row += 1 - self.endResetModel() def data(self, index, role): @@ -146,13 +187,41 @@ def data(self, index, role): # Add icon to subset column if index.column() == 0: - return self._icons['subset'] + node = index.internalPointer() + if node.get("isGroup"): + return node["icon"] + else: + return self._icons["subset"] # Add icon to family column if index.column() == 1: node = index.internalPointer() return node.get("familyIcon", None) + if role == self.SortDescendingRole: + node = index.internalPointer() + if node.get("isGroup"): + # Ensure groups be on top when sorting by descending order + prefix = "1" + order = node["inverseOrder"] + else: + prefix = "0" + order = str(super(SubsetsModel, + self).data(index, QtCore.Qt.DisplayRole)) + return prefix + order + + if role == self.SortAscendingRole: + node = index.internalPointer() + if node.get("isGroup"): + # Ensure groups be on top when sorting by ascending order + prefix = "0" + order = node["order"] + else: + prefix = "1" + order = str(super(SubsetsModel, + self).data(index, QtCore.Qt.DisplayRole)) + return prefix + order + return super(SubsetsModel, self).data(index, role) def flags(self, index): @@ -165,7 +234,55 @@ def flags(self, index): return flags -class FamiliesFilterProxyModel(QtCore.QSortFilterProxyModel): +class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel): + """Provide the feature of filtering group by the acceptance of members + + The subset group nodes will not be filtered directly, the group node's + acceptance depends on it's child subsets' acceptance. + + """ + + if lib.is_filtering_recursible(): + def _is_group_acceptable(self, index, node): + # (NOTE) With the help of `RecursiveFiltering` feature from + # Qt 5.10, group always not be accepted by default. + return False + filter_accepts_group = _is_group_acceptable + + else: + # Patch future function + setRecursiveFilteringEnabled = (lambda *args: None) + + def _is_group_acceptable(self, index, model): + # (NOTE) This is not recursive. + for child_row in range(model.rowCount(index)): + if self.filterAcceptsRow(child_row, index): + return True + return False + filter_accepts_group = _is_group_acceptable + + def __init__(self, *args, **kwargs): + super(GroupMemberFilterProxyModel, self).__init__(*args, **kwargs) + self.setRecursiveFilteringEnabled(True) + + +class SubsetFilterProxyModel(GroupMemberFilterProxyModel): + + def filterAcceptsRow(self, row, parent): + + model = self.sourceModel() + index = model.index(row, + self.filterKeyColumn(), + parent) + node = index.internalPointer() + if node.get("isGroup"): + return self.filter_accepts_group(index, model) + else: + return super(SubsetFilterProxyModel, + self).filterAcceptsRow(row, parent) + + +class FamiliesFilterProxyModel(GroupMemberFilterProxyModel): """Filters to specified families""" def __init__(self, *args, **kwargs): @@ -195,10 +312,31 @@ def filterAcceptsRow(self, row=0, parent=QtCore.QModelIndex()): # Get the node data and validate node = model.data(index, TreeModel.NodeRole) - family = node.get("family", None) - if not family: + if node.get("isGroup"): + return self.filter_accepts_group(index, model) + + families = node.get("families", []) + + filterable_families = set() + for name in families: + family_config = lib.get(lib.FAMILY_CONFIG, name) + if not family_config.get("hideFilter"): + filterable_families.add(name) + + if not filterable_families: return True # We want to keep the families which are not in the list - return family in self._families + return filterable_families.issubset(self._families) + + def sort(self, column, order): + proxy = self.sourceModel() + model = proxy.sourceModel() + # We need to know the sorting direction for pinning groups on top + if order == QtCore.Qt.AscendingOrder: + self.setSortRole(model.SortAscendingRole) + else: + self.setSortRole(model.SortDescendingRole) + + super(FamiliesFilterProxyModel, self).sort(column, order) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index 35f0233e0..571db6134 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -8,7 +8,13 @@ from ... import api from ... import pipeline -from .model import SubsetsModel, FamiliesFilterProxyModel +from ..projectmanager.widget import preserve_selection + +from .model import ( + SubsetsModel, + SubsetFilterProxyModel, + FamiliesFilterProxyModel, +) from .delegates import PrettyTimeDelegate, VersionDelegate from . import lib @@ -19,19 +25,26 @@ class SubsetWidget(QtWidgets.QWidget): active_changed = QtCore.Signal() # active index changed version_changed = QtCore.Signal() # version state changed for a subset - def __init__(self, parent=None): + def __init__(self, enable_grouping=True, parent=None): super(SubsetWidget, self).__init__(parent=parent) - model = SubsetsModel() - proxy = QtCore.QSortFilterProxyModel() + model = SubsetsModel(grouping=enable_grouping) + proxy = SubsetFilterProxyModel() family_proxy = FamiliesFilterProxyModel() family_proxy.setSourceModel(proxy) filter = QtWidgets.QLineEdit() filter.setPlaceholderText("Filter subsets..") + groupable = QtWidgets.QCheckBox("Enable Grouping") + groupable.setChecked(enable_grouping) + + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addWidget(filter) + top_bar_layout.addWidget(groupable) + view = QtWidgets.QTreeView() - view.setIndentation(5) + view.setIndentation(20) view.setStyleSheet(""" QTreeView::item{ padding: 5px 1px; @@ -51,7 +64,7 @@ def __init__(self, parent=None): layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(filter) + layout.addLayout(top_bar_layout) layout.addWidget(view) view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) @@ -64,6 +77,9 @@ def __init__(self, parent=None): "delegates": { "version": version_delegate, "time": time_delegate + }, + "state": { + "groupable": groupable } } @@ -81,29 +97,48 @@ def __init__(self, parent=None): self.view.setModel(self.family_proxy) self.view.customContextMenuRequested.connect(self.on_context_menu) + header = self.view.header() + # Enforce the columns to fit the data (purely cosmetic) + header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) + selection = view.selectionModel() selection.selectionChanged.connect(self.active_changed) version_delegate.version_changed.connect(self.version_changed) + groupable.stateChanged.connect(self.set_grouping) + self.filter.textChanged.connect(self.proxy.setFilterRegExp) + self.filter.textChanged.connect(self.view.expandAll) self.model.refresh() # Expose this from the widget as a method self.set_family_filters = self.family_proxy.setFamiliesFilter + def is_groupable(self): + return self.data["state"]["groupable"].checkState() + + def set_grouping(self, state): + with preserve_selection(tree_view=self.view, + current_index=False): + self.model.set_grouping(state) + def on_context_menu(self, point): point_index = self.view.indexAt(point) if not point_index.isValid(): return + node = point_index.data(self.model.NodeRole) + if node.get("isGroup"): + return + # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. available_loaders = api.discover(api.Loader) loaders = list() - node = point_index.data(self.model.NodeRole) + version_id = node['version_document']['_id'] representations = io.find({"type": "representation", "parent": version_id}) @@ -188,6 +223,9 @@ def sorter(value): # Trigger for row in rows: node = row.data(self.model.NodeRole) + if node.get("isGroup"): + continue + version_id = node["version_document"]["_id"] representation = io.find_one({"type": "representation", "name": representation_name, @@ -205,6 +243,39 @@ def sorter(value): self.echo(exc) continue + def selected_subsets(self): + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + subsets = list() + for row in rows: + node = row.data(self.model.NodeRole) + if not node.get("isGroup"): + subsets.append(node) + + return subsets + + def group_subsets(self, name, asset_id, nodes): + field = "data.subsetGroup" + + if name: + update = {"$set": {field: name}} + self.echo("Group subsets to '%s'.." % name) + else: + update = {"$unset": {field: ""}} + self.echo("Ungroup subsets..") + + subsets = list() + for node in nodes: + subsets.append(node["subset"]) + + filter = { + "type": "subset", + "parent": asset_id, + "name": {"$in": subsets}, + } + io.update_many(filter, update) + def echo(self, message): print(message) @@ -385,6 +456,9 @@ def refresh(self): for name in sorted(unique_families): family = lib.get(lib.FAMILY_CONFIG, name) + if family.get("hideFilter"): + continue + label = family.get("label", name) icon = family.get("icon", None) diff --git a/avalon/tools/projectmanager/widget.py b/avalon/tools/projectmanager/widget.py index 81fab548a..1b64a4adb 100644 --- a/avalon/tools/projectmanager/widget.py +++ b/avalon/tools/projectmanager/widget.py @@ -138,7 +138,8 @@ def preserve_selection(tree_view, selection_model.select(index, flags) if current_index_value and value == current_index_value: - tree_view.setCurrentIndex(index) + selection_model.setCurrentIndex(index, + selection_model.NoUpdate) def _list_project_silos(): @@ -318,8 +319,9 @@ class SiloTabWidget(QtWidgets.QTabBar): silo_changed = QtCore.Signal(str) silo_added = QtCore.Signal(str) - def __init__(self, parent=None): + def __init__(self, silo_creatable=True, parent=None): super(SiloTabWidget, self).__init__(parent=parent) + self.silo_creatable = silo_creatable self._previous_tab_index = -1 self.set_silos([]) @@ -338,7 +340,7 @@ def on_tab_changed(self, index): # If it's the last tab num = self.count() - if index == num - 1: + if self.silo_creatable and index == num - 1: self.on_add_silo() self.setCurrentIndex(self._previous_tab_index) return @@ -372,8 +374,9 @@ def set_silos(self, silos): for silo in sorted(silos): self.addTab(silo) - # Add the "+" tab - self.addTab("+") + if self.silo_creatable: + # Add the "+" tab + self.addTab("+") self.set_current_silo(current_silo) self.blockSignals(False) @@ -460,7 +463,7 @@ class AssetWidget(QtWidgets.QWidget): selection_changed = QtCore.Signal() # on view selection change current_changed = QtCore.Signal() # on view current index change - def __init__(self, parent=None): + def __init__(self, silo_creatable=True, parent=None): super(AssetWidget, self).__init__(parent=parent) self.setContentsMargins(0, 0, 0, 0) @@ -471,7 +474,7 @@ def __init__(self, parent=None): # Header header = QtWidgets.QHBoxLayout() - silo = SiloTabWidget() + silo = SiloTabWidget(silo_creatable=silo_creatable) icon = awesome.icon("fa.refresh", color=style.colors.light) refresh = QtWidgets.QPushButton(icon, "") diff --git a/avalon/tools/sceneinventory/app.py b/avalon/tools/sceneinventory/app.py index fc360f7c2..bc1691344 100644 --- a/avalon/tools/sceneinventory/app.py +++ b/avalon/tools/sceneinventory/app.py @@ -481,7 +481,15 @@ def __init__(self, parent=None, items=None): self._representations_box = SearchComboBox( placeholder="") - input_layout = QtWidgets.QHBoxLayout() + self._asset_label = QtWidgets.QLabel('') + self._subset_label = QtWidgets.QLabel('') + self._repre_label = QtWidgets.QLabel('') + + main_layout = QtWidgets.QVBoxLayout() + context_layout = QtWidgets.QHBoxLayout() + asset_layout = QtWidgets.QVBoxLayout() + subset_layout = QtWidgets.QVBoxLayout() + repre_layout = QtWidgets.QVBoxLayout() accept_icon = qta.icon("fa.check", color="white") accept_btn = QtWidgets.QPushButton() @@ -489,15 +497,28 @@ def __init__(self, parent=None, items=None): accept_btn.setFixedWidth(24) accept_btn.setFixedHeight(24) - input_layout.addWidget(self._assets_box) - input_layout.addWidget(self._subsets_box) - input_layout.addWidget(self._representations_box) - input_layout.addWidget(accept_btn) + asset_layout.addWidget(self._assets_box) + asset_layout.addWidget(self._asset_label) + subset_layout.addWidget(self._subsets_box) + subset_layout.addWidget(self._subset_label) + repre_layout.addWidget(self._representations_box) + repre_layout.addWidget(self._repre_label) + + context_layout.addLayout(asset_layout) + context_layout.addLayout(subset_layout) + context_layout.addLayout(repre_layout) + context_layout.addWidget(accept_btn) - self._input_layout = input_layout self._accept_btn = accept_btn - self.setLayout(input_layout) + self._assets_box.currentIndexChanged.connect(self.on_assets_change) + self._subsets_box.currentIndexChanged.connect(self.on_subset_change) + self._representations_box.currentIndexChanged.connect( + self.on_repre_change + ) + + main_layout.addLayout(context_layout) + self.setLayout(main_layout) self.setWindowTitle("Switch selected items ...") self.connections() @@ -513,32 +534,266 @@ def __init__(self, parent=None, items=None): def connections(self): self._accept_btn.clicked.connect(self._on_accept) - def refresh(self): - """Build the need comboboxes with content""" + def on_assets_change(self): + self.refresh(1) - assets = sorted(self._get_assets()) - self._assets_box.populate(assets) + def on_subset_change(self): + self.refresh(2) - subsets = sorted(self._get_subsets()) - self._subsets_box.populate(subsets) + def on_repre_change(self): + self.refresh(3) + + def refresh(self, refresh_type=0): + """Build the need comboboxes with content""" + if refresh_type < 1: + assets = sorted(self._get_assets()) + self._assets_box.populate(assets) + + if refresh_type < 2: + last_subset = self._subsets_box.currentText() + + subsets = sorted(self._get_subsets()) + self._subsets_box.populate(subsets) + + if (last_subset != "" and last_subset in list(subsets)): + index = None + for i in range(self._subsets_box.count()): + if last_subset == str(self._subsets_box.itemText(i)): + index = i + break + if index is not None: + self._subsets_box.setCurrentIndex(index) + + if refresh_type < 3: + last_repre = self._representations_box.currentText() + + representations = sorted(self._get_representations()) + self._representations_box.populate(representations) + + if (last_repre != "" and last_repre in list(representations)): + index = None + for i in range(self._representations_box.count()): + if last_repre == self._representations_box.itemText(i): + index = i + break + if index is not None: + self._representations_box.setCurrentIndex(index) + + self.set_labels() + self.validate() + + def set_labels(self): + default = "*No changes" + asset_label = default + subset_label = default + repre_label = default + + if self._assets_box.currentText() != '': + asset_label = self._assets_box.currentText() + if self._subsets_box.currentText() != '': + subset_label = self._subsets_box.currentText() + if self._representations_box.currentText() != '': + repre_label = self._representations_box.currentText() + + self._asset_label.setText(asset_label) + self._subset_label.setText(subset_label) + self._repre_label.setText(repre_label) + + def validate(self): + asset_name = self._assets_box.get_valid_value() or None + subset_name = self._subsets_box.get_valid_value() or None + repre_name = self._representations_box.get_valid_value() or None + + asset_ok = True + subset_ok = True + repre_ok = True + for item in self._items: + if any(not x for x in [asset_name, subset_name, repre_name]): + _id = io.ObjectId(item["representation"]) + representation = io.find_one({ + "type": "representation", + "_id": _id + }) + version, subset, asset, project = io.parenthood(representation) + + if asset_name is None: + asset_name = asset["name"] + + if subset_name is None: + subset_name = subset["name"] + + if repre_name is None: + repre_name = representation["name"] + + asset = io.find_one({"name": asset_name, "type": "asset"}) + if asset is None: + asset_ok = False + continue + subset = io.find_one({ + "name": subset_name, + "type": "subset", + "parent": asset["_id"] + }) + if subset is None: + subset_ok = False + continue + version = io.find_one( + { + "type": "version", + "parent": subset["_id"] + }, + sort=[('name', -1)] + ) + if version is None: + repre_ok = False + continue - representations = sorted(self._get_representations()) - self._representations_box.populate(representations) + repre = io.find_one({ + "name": repre_name, + "type": "representation", + "parent": version["_id"] + }) + if repre is None: + repre_ok = False + + asset_sheet = '' + subset_sheet = '' + repre_sheet = '' + accept_sheet = '' + error_msg = '*Please select' + if asset_ok is False: + asset_sheet = 'border: 1px solid red;' + self._asset_label.setText(error_msg) + if subset_ok is False: + subset_sheet = 'border: 1px solid red;' + self._subset_label.setText(error_msg) + if repre_ok is False: + repre_sheet = 'border: 1px solid red;' + self._repre_label.setText(error_msg) + if asset_ok and subset_ok and repre_ok: + accept_sheet = 'border: 1px solid green;' + + self._assets_box.setStyleSheet(asset_sheet) + self._subsets_box.setStyleSheet(subset_sheet) + self._representations_box.setStyleSheet(repre_sheet) + + self._accept_btn.setEnabled(asset_ok and subset_ok and repre_ok) + self._accept_btn.setStyleSheet(accept_sheet) def _get_assets(self): - return self._get_document_names("asset") + filtered_assets = [] + for asset in io.find({'type': 'asset'}): + subsets = io.find({ + 'type': 'subset', + 'parent': asset['_id'] + }) + for subs in subsets: + filtered_assets.append(asset['name']) + break + + return filtered_assets def _get_subsets(self): - return self._get_document_names("subset") + # Filter subsets by asset in dropdown + if self._assets_box.currentText() != "": + parents = [] + parents.append(io.find_one({ + 'type': 'asset', + 'name': self._assets_box.currentText() + })) + + return self._get_document_names("subset", parents) + # If any asset in dropdown is selected + # - filter subsets by selected assets in scene inventory + assets = [] + for item in self._items: + _id = io.ObjectId(item["representation"]) + representation = io.find_one( + {"type": "representation", "_id": _id} + ) + version, subset, asset, project = io.parenthood(representation) + assets.append(asset) + + possible_subsets = None + for asset in assets: + subsets = io.find({ + 'type': 'subset', + 'parent': asset['_id'] + }) + asset_subsets = set() + for subset in subsets: + asset_subsets.add(subset['name']) + + if possible_subsets is None: + possible_subsets = asset_subsets + else: + possible_subsets = (possible_subsets & asset_subsets) + + return list(possible_subsets) def _get_representations(self): - return self._get_document_names("representation") + if self._subsets_box.currentText() != "": + subsets = [] + parents = [] + subsets.append(self._subsets_box.currentText()) + + for subset in subsets: + entity = io.find_one({ + 'type': 'subset', + 'name': subset + }) + + entity = io.find_one( + { + 'type': 'version', + 'parent': entity['_id'] + }, + sort=[('name', -1)] + ) + if entity not in parents: + parents.append(entity) + + return self._get_document_names("representation", parents) + + versions = [] + for item in self._items: + _id = io.ObjectId(item["representation"]) + representation = io.find_one( + {"type": "representation", "_id": _id} + ) + version, subset, asset, project = io.parenthood(representation) + versions.append(version) + + possible_repres = None + for version in versions: + representations = io.find({ + 'type': 'representation', + 'parent': version['_id'] + }) + repres = set() + for repre in representations: + repres.add(repre['name']) + + if possible_repres is None: + possible_repres = repres + else: + possible_repres = (possible_repres & repres) + + return list(possible_repres) - def _get_document_names(self, document_type, parent=None): + def _get_document_names(self, document_type, parents=[]): query = {"type": document_type} - if parent: - query["parent"] = parent["_id"] + + if len(parents) == 1: + query["parent"] = parents[0]["_id"] + elif len(parents) > 1: + or_exprs = [] + for parent in parents: + expr = {"parent": parent["_id"]} + or_exprs.append(expr) + + query["$or"] = or_exprs return io.find(query).distinct("name") diff --git a/avalon/tools/workfiles/README.md b/avalon/tools/workfiles/README.md index 7f5ca7b08..7371b6174 100644 --- a/avalon/tools/workfiles/README.md +++ b/avalon/tools/workfiles/README.md @@ -5,6 +5,8 @@ The Workfiles app facilitates easy saving, creation and launching of work files. The current supported hosts are: - Maya +- Houdini +- Fusion The app is available inside hosts via. the ```Avalon > Work Files``` menu. @@ -13,12 +15,7 @@ The app is available inside hosts via. the ```Avalon > Work Files``` menu. By default the Workfiles app will not launch on startup, so it has to be explicitly enabled in a config. ```python -workfiles.show( - os.path.join( - cmds.workspace(query=True, rootDirectory=True), - cmds.workspace(fileRuleEntry="scene") - ) -) +workfiles.show() ``` ## Naming Files @@ -56,3 +53,91 @@ There are other variables to customize the template with: ### Optional template groups The default template contains an optional template group ```<_{comment}>```. If any template group (```{comment}```) within angle bracket ```<>``` does not exist, the whole optional group is discarded. + + +## Implementing a new host integration for Work Files + +For the Work Files tool to work with a new host integration the host must +implement the following functions: + +- `file_extensions()`: The files the host should allow to open and show in the Work Files view. +- `open(filepath)`: Open a file. +- `save(filepath)`: Save the current file. This should return None if it failed to save, and return the path if it succeeded +- `has_unsaved_changes()`: Return whether the current scene has unsaved changes. +- `current_file()`: The path to the current file. None if not saved. +- `work_root()`: The path to where the work files for this app should be saved. + +Here's an example code layout: + +```python +def file_extensions(): + """Return the filename extension formats that should be shown. + + Note: + The first entry in the list will be used as the default file + format to save to when the current scene is not saved yet. + + Returns: + list: A list of the file extensions supported by Work Files. + + """ + return list() + + +def has_unsaved_changes(): + """Return whether current file has unsaved modifications.""" + + +def save(filepath): + """Save to filepath. + + This should return None if it failed to save, and return the path if it + succeeded. + """ + pass + + +def open(filepath): + """Open file""" + pass + + +def current_file(): + """Return path to currently open file or None if not saved. + + Returns: + str or None: The full path to current file or None when not saved. + + """ + pass + + +def work_root(): + """Return the default root for the Host to browse in for Work Files + + Returns: + str: The path to look in. + + """ + pass +``` + +#### Work Files Scenes root (AVALON_SCENEDIR) + +Whenever the host application has no built-in implementation that defines +where scene files should be saved to then the Work Files API for that host +should fall back to the `AVALON_SCENEDIR` variable in `api.Session`. + +When `AVALON_SCENEDIR` is set the directory is the relative folder inside the +`AVALON_WORKDIR`. Otherwise, when it is not set or empty it should fall back +to the Work Directory's root, `AVALON_WORKDIR` + +```python +AVALON_WORKDIR="/path/to/work" +AVALON_SCENEDIR="scenes" +# Result: /path/to/work/scenes + +AVALON_WORKDIR="/path/to/work" +AVALON_SCENEDIR=None +# Result: /path/to/work +``` \ No newline at end of file diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index c9c9bf53f..16f884fa3 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -1,43 +1,24 @@ import sys import os -import tempfile import getpass import re import shutil from ...vendor.Qt import QtWidgets, QtCore -from ... import style -from avalon import io - - -def determine_application(): - # Determine executable - application = None - - basename = os.path.basename(sys.executable).lower() - - if "maya" in basename: - application = "maya" - - if application is None: - raise ValueError( - "Could not determine application from executable:" - " \"{0}\"".format(sys.executable) - ) - - return application +from ... import style, io, api +from .. import lib as parentlib class NameWindow(QtWidgets.QDialog): """Name Window""" - def __init__(self, root, temp_file): + def __init__(self, root): super(NameWindow, self).__init__() self.setWindowFlags(QtCore.Qt.FramelessWindowHint) + self.result = None self.setup(root) - self.temp_file = temp_file self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) @@ -107,19 +88,18 @@ def on_comment_changed(self, text): self.refresh() def on_ok_pressed(self): - self.write_data() + self.result = self.work_file.replace("\\", "/") self.close() def on_cancel_pressed(self): self.close() - def write_data(self): - self.temp_file.write(self.work_file.replace("\\", "/")) - self.close() + def get_result(self): + return self.result - def get_work_file(self): + def get_work_file(self, template=None): data = self.data.copy() - template = self.template + template = template or self.template if not data["comment"]: data.pop("comment", None) @@ -142,7 +122,16 @@ def get_work_file(self): work_file = work_file.replace("<", "") work_file = work_file.replace(">", "") - work_file = work_file + self.extensions[self.application] + # Define saving file extension + current_file = self.host.current_file() + if current_file: + # Match the extension of current file + _, extension = os.path.splitext(current_file) + else: + # Fall back to the first extension supported for this host. + extension = self.host.file_extensions()[0] + + work_file = work_file + extension return work_file @@ -150,12 +139,42 @@ def refresh(self): if self.version_checkbox.isChecked(): self.version_spinbox.setEnabled(False) - for i in range(1, 9999): - self.data["version"] = i - self.work_file = self.get_work_file() - path = os.path.join(self.root, self.work_file) - if not os.path.exists(path): - break + # Find matching files + files = os.listdir(self.root) + + # Fast match on extension + extensions = self.host.file_extensions() + files = [f for f in files if os.path.splitext(f)[1] in extensions] + + # Build template without optionals, version to digits only regex + # and comment to any definable value. + # Note: with auto-increment the `version` key may not be optional. + template = self.template + template = re.sub("<.*?>", ".*?", template) + template = re.sub("{version.*}", "([0-9]+)", template) + template = re.sub("{comment.*?}", ".+?", template) + template = self.get_work_file(template) + template = "^" + template + "$" # match beginning to end + + # Get highest version among existing matching files + version = 1 + for file in sorted(files): + match = re.match(template, file) + if not match: + continue + + file_version = int(match.group(1)) + + if file_version >= version: + version = file_version + 1 + + self.data["version"] = version + + # safety check + path = os.path.join(self.root, self.get_work_file()) + assert not os.path.exists(path), \ + "This is a bug, file exists: %s" % path + else: self.version_spinbox.setEnabled(True) self.data["version"] = self.version_spinbox.value() @@ -176,19 +195,19 @@ def refresh(self): def setup(self, root): self.root = root - self.application = determine_application() + self.host = api.registered_host() # Get work file name self.data = { "project": io.find_one( - {"name": os.environ["AVALON_PROJECT"], "type": "project"} + {"name": api.Session["AVALON_PROJECT"], "type": "project"} ), "asset": io.find_one( - {"name": os.environ["AVALON_ASSET"], "type": "asset"} + {"name": api.Session["AVALON_ASSET"], "type": "asset"} ), "task": { - "name": os.environ["AVALON_TASK"].lower(), - "label": os.environ["AVALON_TASK"] + "name": api.Session["AVALON_TASK"].lower(), + "label": api.Session["AVALON_TASK"] }, "version": 1, "user": getpass.getuser(), @@ -200,8 +219,6 @@ def setup(self, root): if "workfile" in templates: self.template = templates["workfile"] - self.extensions = {"maya": ".ma"} - class Window(QtWidgets.QDialog): """Work Files Window""" @@ -215,15 +232,26 @@ def __init__(self, root=None): if self.root is None: self.root = os.getcwd() - filters = { - "maya": [".ma", ".mb"] - } - self.application = determine_application() - self.filter = filters[self.application] + self.host = api.registered_host() self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) + # Display current context + # todo: context should update on update task + label = u"Asset {0} \u25B6 Task {1}".format( + api.Session["AVALON_ASSET"], + api.Session["AVALON_TASK"] + ) + self.context_label = QtWidgets.QLabel(label) + self.context_label.setStyleSheet("QLabel{ font-size: 12pt; }") + self.layout.addWidget(self.context_label) + + separator = QtWidgets.QFrame() + separator.setFrameShape(QtWidgets.QFrame.HLine) + separator.setFrameShadow(QtWidgets.QFrame.Plain) + self.layout.addWidget(separator) + self.list = QtWidgets.QListWidget() self.layout.addWidget(self.list) @@ -241,9 +269,12 @@ def __init__(self, root=None): separator.setFrameShadow(QtWidgets.QFrame.Sunken) self.layout.addWidget(separator) - current_file_label = QtWidgets.QLabel( - "Current File: " + self.current_file() - ) + current_file = self.host.current_file() + if current_file: + current_label = os.path.basename(current_file) + else: + current_label = "" + current_file_label = QtWidgets.QLabel("Current File: " + current_label) self.layout.addWidget(current_file_label) buttons_layout = QtWidgets.QHBoxLayout() @@ -253,72 +284,53 @@ def __init__(self, root=None): self.duplicate_button.pressed.connect(self.on_duplicate_pressed) self.open_button.pressed.connect(self.on_open_pressed) + self.list.doubleClicked.connect(self.on_open_pressed) self.browse_button.pressed.connect(self.on_browse_pressed) self.save_as_button.pressed.connect(self.on_save_as_pressed) self.open_button.setFocus() self.refresh() + self.resize(400, 550) def get_name(self): - temp = tempfile.TemporaryFile(mode="w+t") - window = NameWindow(self.root, temp) + window = NameWindow(self.root) window.setStyleSheet(style.load_stylesheet()) window.exec_() - temp.seek(0) - name = temp.read() - temp.close() - return name - - def current_file(self): - func = {"maya": self.current_file_maya} - return func[self.application]() - - def current_file_maya(self): - import os - from maya import cmds - - current_file = cmds.file(sceneName=True, query=True) - - # Maya returns forward-slashes by default - normalised = os.path.basename(os.path.normpath(current_file)) - - # Unsaved current file - if normalised == ".": - return "NOT SAVED" - - return normalised + return window.get_result() def refresh(self): self.list.clear() - items = [] + modified = [] - for f in os.listdir(self.root): - if os.path.isdir(os.path.join(self.root, f)): + extensions = set(self.host.file_extensions()) + for f in sorted(os.listdir(self.root)): + path = os.path.join(self.root, f) + if os.path.isdir(path): continue - if self.filter and os.path.splitext(f)[1] not in self.filter: + if extensions and os.path.splitext(f)[1] not in extensions: continue + self.list.addItem(f) - items.append(self.list.findItems(f, QtCore.Qt.MatchExactly)[0]) - modified.append(os.path.getmtime(os.path.join(self.root, f))) + modified.append(os.path.getmtime(path)) # Select last modified file - if items: - items[modified.index(max(modified))].setSelected(True) + if self.list.count(): + item = self.list.item(modified.index(max(modified))) + item.setSelected(True) + + # Scroll list so item is visible + QtCore.QTimer.singleShot(100, lambda: self.list.scrollToItem(item)) + self.duplicate_button.setEnabled(True) else: self.duplicate_button.setEnabled(False) self.list.setMinimumWidth(self.list.sizeHintForColumn(0) + 30) - def save_as_maya(self, file_path): - from maya import cmds - cmds.file(rename=file_path) - cmds.file(save=True, type="mayaAscii") - def save_changes_prompt(self): messagebox = QtWidgets.QMessageBox() messagebox.setWindowFlags(QtCore.Qt.FramelessWindowHint) @@ -340,33 +352,25 @@ def save_changes_prompt(self): else: return None - def open_maya(self, file_path): - from maya import cmds + def open(self, filepath): - force = False - if cmds.file(q=True, modified=True): + host = self.host + if host.has_unsaved_changes(): result = self.save_changes_prompt() if result is None: + # Cancel operation return False if result: - cmds.file(save=True, type="mayaAscii") - else: - force = True - - cmds.file(file_path, open=True, force=force) + # Save current scene, continue to open file + host.save(host.current_file()) - return True - - def open(self, file_path): - func = {"maya": self.open_maya} - - work_file = os.path.join( - self.root, self.list.selectedItems()[0].text() - ) + else: + # Don't save, continue to open file + pass - return func[self.application](work_file) + return host.open(filepath) def on_duplicate_pressed(self): work_file = self.get_name() @@ -385,18 +389,21 @@ def on_duplicate_pressed(self): self.refresh() def on_open_pressed(self): - work_file = os.path.join( - self.root, self.list.selectedItems()[0].text() - ) - result = self.open(work_file) + selection = self.list.selectedItems() + if not selection: + print("No file selected to open..") + return + + work_file = os.path.join(self.root, selection[0].text()) + result = self.open(work_file) if result: self.close() def on_browse_pressed(self): - filter = " *".join(self.filter) + filter = " *".join(self.host.file_extensions()) filter = "Work File (*{0})".format(filter) work_file = QtWidgets.QFileDialog.getOpenFileName( caption="Work Files", @@ -418,22 +425,40 @@ def on_save_as_pressed(self): if not work_file: return - save_as = {"maya": self.save_as_maya} - application = determine_application() - if application not in save_as: - raise ValueError( - "Could not find a save as method for this application." - ) - file_path = os.path.join(self.root, work_file) - - save_as[application](file_path) + self.host.save(file_path) self.close() -def show(root): +def show(root=None): """Show Work Files GUI""" - window = Window(root) - window.setStyleSheet(style.load_stylesheet()) - window.exec_() + + host = api.registered_host() + if host is None: + raise RuntimeError("No registered host.") + + # Verify the host has implemented the api for Work Files + required = ["open", "save", "current_file", "work_root"] + missing = [] + for name in required: + if not hasattr(host, name): + missing.append(name) + if missing: + raise RuntimeError("Host is missing required Work Files interfaces: " + "%s (host: %s)" % (", ".join(missing), host)) + + # Allow to use a Host's default root. + if root is None: + root = host.work_root() + if not root: + raise ValueError("Root not given and no root returned by " + "default from current host %s" % host.__name__) + + if not os.path.exists(root): + raise OSError("Root set for Work Files app does not exist: %s" % root) + + with parentlib.application(): + window = Window(root) + window.setStyleSheet(style.load_stylesheet()) + window.exec_() diff --git a/res/houdini/MainMenuCommon.XML b/res/houdini/MainMenuCommon.XML index 8184e1aed..a449c0af4 100644 --- a/res/houdini/MainMenuCommon.XML +++ b/res/houdini/MainMenuCommon.XML @@ -54,6 +54,18 @@ publish.show(parent) + + + + + + + diff --git a/setup/fusion/scripts/Comp/avalon/workfiles.py b/setup/fusion/scripts/Comp/avalon/workfiles.py new file mode 100644 index 000000000..d93eeadc9 --- /dev/null +++ b/setup/fusion/scripts/Comp/avalon/workfiles.py @@ -0,0 +1,7 @@ +import avalon.api +import avalon.fusion +import avalon.tools.workfiles as tool + + +avalon.api.install(avalon.fusion) +tool.show()