├── .gitignore ├── LICENSE ├── README.MD └── mayalookassigner ├── __init__.py ├── app.py ├── commands.py ├── models.py ├── version.py ├── views.py └── widgets.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | .static_storage/ 58 | .media/ 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | #.idea 109 | .idea/ 110 | 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Colorbleed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Maya Look Assigner 2 | 3 | Tool to assign published lookdev shaders to selected or all objects. 4 | Each shader variation is listed based on the unique asset Id of the 5 | selected or all objects. 6 | 7 | ## Dependencies 8 | * [Avalon](https://github.com/getavalon/core) 9 | * [Colorbleed configuration for Avalon](https://github.com/Colorbleed/colorbleed-config) 10 | * Autodesk Maya 2016 and up 11 | -------------------------------------------------------------------------------- /mayalookassigner/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import ( 2 | App, 3 | show 4 | ) 5 | 6 | 7 | __all__ = [ 8 | "App", 9 | "show"] 10 | -------------------------------------------------------------------------------- /mayalookassigner/app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import logging 4 | 5 | import colorbleed.maya.lib as cblib 6 | 7 | from avalon import style, io 8 | from avalon.tools import lib 9 | from avalon.vendor.Qt import QtWidgets, QtCore 10 | 11 | from maya import cmds 12 | import maya.OpenMaya # old api for MFileIO 13 | import maya.api.OpenMaya as om 14 | 15 | from . import widgets 16 | from . import commands 17 | from .version import version 18 | 19 | module = sys.modules[__name__] 20 | module.window = None 21 | 22 | 23 | class App(QtWidgets.QWidget): 24 | 25 | def __init__(self, parent=None): 26 | QtWidgets.QWidget.__init__(self, parent=parent) 27 | 28 | self.log = logging.getLogger(__name__) 29 | 30 | # Store callback references 31 | self._callbacks = [] 32 | 33 | filename = commands.get_workfile() 34 | 35 | self.setObjectName("lookManager") 36 | self.setWindowTitle("Look Manager {version} - [{filename}]".format( 37 | version=version, 38 | filename=filename 39 | )) 40 | self.setWindowFlags(QtCore.Qt.Window) 41 | self.setParent(parent) 42 | 43 | # Force to delete the window on close so it triggers 44 | # closeEvent only once. Otherwise it's retriggered when 45 | # the widget gets garbage collected. 46 | self.setAttribute(QtCore.Qt.WA_DeleteOnClose) 47 | 48 | self.resize(750, 500) 49 | 50 | self.setup_ui() 51 | 52 | self.setup_connections() 53 | 54 | # Force refresh check on initialization 55 | self._on_renderlayer_switch() 56 | 57 | def setup_ui(self): 58 | """Build the UI""" 59 | 60 | # Assets (left) 61 | asset_outliner = widgets.AssetOutliner() 62 | 63 | # Looks (right) 64 | looks_widget = QtWidgets.QWidget() 65 | looks_layout = QtWidgets.QVBoxLayout(looks_widget) 66 | 67 | look_outliner = widgets.LookOutliner() # Database look overview 68 | 69 | assign_selected = QtWidgets.QCheckBox("Assign to selected only") 70 | assign_selected.setToolTip("Whether to assign only to selected nodes " 71 | "or to the full asset") 72 | remove_unused_btn = QtWidgets.QPushButton("Remove Unused Looks") 73 | 74 | looks_layout.addWidget(look_outliner) 75 | looks_layout.addWidget(assign_selected) 76 | looks_layout.addWidget(remove_unused_btn) 77 | 78 | # Footer 79 | status = QtWidgets.QStatusBar() 80 | status.setSizeGripEnabled(False) 81 | status.setFixedHeight(25) 82 | warn_layer = QtWidgets.QLabel("Current Layer is not " 83 | "defaultRenderLayer") 84 | warn_layer.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) 85 | warn_layer.setStyleSheet("color: #DD5555; font-weight: bold;") 86 | warn_layer.setFixedHeight(25) 87 | 88 | footer = QtWidgets.QHBoxLayout() 89 | footer.setContentsMargins(0, 0, 0, 0) 90 | footer.addWidget(status) 91 | footer.addWidget(warn_layer) 92 | 93 | # Build up widgets 94 | main_layout = QtWidgets.QVBoxLayout(self) 95 | main_layout.setSpacing(0) 96 | main_splitter = QtWidgets.QSplitter() 97 | main_splitter.setStyleSheet("QSplitter{ border: 0px; }") 98 | main_splitter.addWidget(asset_outliner) 99 | main_splitter.addWidget(looks_widget) 100 | main_splitter.setSizes([350, 200]) 101 | main_layout.addWidget(main_splitter) 102 | main_layout.addLayout(footer) 103 | 104 | # Set column width 105 | asset_outliner.view.setColumnWidth(0, 200) 106 | look_outliner.view.setColumnWidth(0, 150) 107 | 108 | # Open widgets 109 | self.asset_outliner = asset_outliner 110 | self.look_outliner = look_outliner 111 | self.status = status 112 | self.warn_layer = warn_layer 113 | 114 | # Buttons 115 | self.remove_unused = remove_unused_btn 116 | self.assign_selected = assign_selected 117 | 118 | def setup_connections(self): 119 | """Connect interactive widgets with actions""" 120 | 121 | self.asset_outliner.selection_changed.connect( 122 | self.on_asset_selection_changed) 123 | 124 | self.asset_outliner.refreshed.connect( 125 | lambda: self.echo("Loaded assets..")) 126 | 127 | self.look_outliner.menu_apply_action.connect(self.on_process_selected) 128 | self.remove_unused.clicked.connect(commands.remove_unused_looks) 129 | 130 | # Maya renderlayer switch callback 131 | callback = om.MEventMessage.addEventCallback( 132 | "renderLayerManagerChange", 133 | self._on_renderlayer_switch 134 | ) 135 | self._callbacks.append(callback) 136 | 137 | def closeEvent(self, event): 138 | 139 | # Delete callbacks 140 | for callback in self._callbacks: 141 | om.MMessage.removeCallback(callback) 142 | 143 | return super(App, self).closeEvent(event) 144 | 145 | def _on_renderlayer_switch(self, *args): 146 | """Callback that updates on Maya renderlayer switch""" 147 | 148 | if maya.OpenMaya.MFileIO.isNewingFile(): 149 | # Don't perform a check during file open or file new as 150 | # the renderlayers will not be in a valid state yet. 151 | return 152 | 153 | layer = cmds.editRenderLayerGlobals(query=True, 154 | currentRenderLayer=True) 155 | if layer != "defaultRenderLayer": 156 | self.warn_layer.show() 157 | else: 158 | self.warn_layer.hide() 159 | 160 | def echo(self, message): 161 | self.status.showMessage(message, 1500) 162 | 163 | def refresh(self): 164 | """Refresh the content""" 165 | 166 | # Get all containers and information 167 | self.asset_outliner.clear() 168 | found_items = self.asset_outliner.get_all_assets() 169 | if not found_items: 170 | self.look_outliner.clear() 171 | 172 | def on_asset_selection_changed(self): 173 | """Get selected items from asset loader and fill look outliner""" 174 | 175 | items = self.asset_outliner.get_selected_items() 176 | self.look_outliner.clear() 177 | self.look_outliner.add_items(items) 178 | 179 | def on_process_selected(self): 180 | """Process all selected looks for the selected assets""" 181 | 182 | assets = self.asset_outliner.get_selected_items() 183 | assert assets, "No asset selected" 184 | 185 | # Collect the looks we want to apply (by name) 186 | look_items = self.look_outliner.get_selected_items() 187 | looks = {look["subset"] for look in look_items} 188 | 189 | selection = self.assign_selected.isChecked() 190 | asset_nodes = self.asset_outliner.get_nodes(selection=selection) 191 | 192 | start = time.time() 193 | for i, (asset, item) in enumerate(asset_nodes.items()): 194 | 195 | # Label prefix 196 | prefix = "({}/{})".format(i+1, len(asset_nodes)) 197 | 198 | # Assign the first matching look relevant for this asset 199 | # (since assigning multiple to the same nodes makes no sense) 200 | assign_look = next((subset for subset in item["looks"] 201 | if subset["name"] in looks), None) 202 | if not assign_look: 203 | self.echo("{} No matching selected " 204 | "look for {}".format(prefix, asset)) 205 | continue 206 | 207 | # Get the latest version of this asset's look subset 208 | version = io.find_one({"type": "version", 209 | "parent": assign_look["_id"]}, 210 | sort=[("name", -1)]) 211 | 212 | subset_name = assign_look["name"] 213 | self.echo("{} Assigning {} to {}\t".format(prefix, 214 | subset_name, 215 | asset)) 216 | 217 | # Assign look 218 | cblib.assign_look_by_version(nodes=item["nodes"], 219 | version_id=version["_id"]) 220 | 221 | end = time.time() 222 | 223 | self.echo("Finished assigning.. ({0:.3f}s)".format(end - start)) 224 | 225 | 226 | def show(): 227 | """Display Loader GUI 228 | 229 | Arguments: 230 | debug (bool, optional): Run loader in debug-mode, 231 | defaults to False 232 | 233 | """ 234 | 235 | try: 236 | module.window.close() 237 | del module.window 238 | except (RuntimeError, AttributeError): 239 | pass 240 | 241 | # Get Maya main window 242 | top_level_widgets = QtWidgets.QApplication.topLevelWidgets() 243 | mainwindow = next(widget for widget in top_level_widgets 244 | if widget.objectName() == "MayaWindow") 245 | 246 | with lib.application(): 247 | window = App(parent=mainwindow) 248 | window.setStyleSheet(style.load_stylesheet()) 249 | window.show() 250 | 251 | module.window = window 252 | -------------------------------------------------------------------------------- /mayalookassigner/commands.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import logging 3 | import os 4 | 5 | import maya.cmds as cmds 6 | 7 | import colorbleed.maya.lib as cblib 8 | from avalon import io, api 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | 13 | def get_workfile(): 14 | path = cmds.file(query=True, sceneName=True) or "untitled" 15 | return os.path.basename(path) 16 | 17 | 18 | def get_workfolder(): 19 | return os.path.dirname(cmds.file(query=True, sceneName=True)) 20 | 21 | 22 | def select(nodes): 23 | cmds.select(nodes) 24 | 25 | 26 | def get_namespace_from_node(node): 27 | """Get the namespace from the given node 28 | 29 | Args: 30 | node (str): name of the node 31 | 32 | Returns: 33 | namespace (str) 34 | 35 | """ 36 | parts = node.rsplit("|", 1)[-1].rsplit(":", 1) 37 | return parts[0] if len(parts) > 1 else u":" 38 | 39 | 40 | def list_descendents(nodes): 41 | """Include full descendant hierarchy of given nodes. 42 | 43 | This is a workaround to cmds.listRelatives(allDescendents=True) because 44 | this way correctly keeps children instance paths (see Maya documentation) 45 | 46 | This fixes LKD-26: assignments not working as expected on instanced shapes. 47 | 48 | Return: 49 | list: List of children descendents of nodes 50 | 51 | """ 52 | result = [] 53 | while True: 54 | nodes = cmds.listRelatives(nodes, 55 | fullPath=True) 56 | if nodes: 57 | result.extend(nodes) 58 | else: 59 | return result 60 | 61 | 62 | def get_selected_nodes(): 63 | """Get information from current selection""" 64 | 65 | selection = cmds.ls(selection=True, long=True) 66 | hierarchy = list_descendents(selection) 67 | nodes = list(set(selection + hierarchy)) 68 | 69 | return nodes 70 | 71 | 72 | def get_all_asset_nodes(): 73 | """Get all assets from the scene, container based 74 | 75 | Returns: 76 | list: list of dictionaries 77 | """ 78 | 79 | host = api.registered_host() 80 | 81 | nodes = [] 82 | for container in host.ls(): 83 | # We are not interested in looks but assets! 84 | if container["loader"] == "LookLoader": 85 | continue 86 | 87 | # Gather all information 88 | container_name = container["objectName"] 89 | members = cmds.sets(container_name, query=True, nodesOnly=True) or [] 90 | members = cmds.ls(members, long=True, type="dagNode") 91 | nodes += members 92 | 93 | return nodes 94 | 95 | 96 | def create_asset_id_hash(nodes): 97 | """Create a hash based on cbId attribute value 98 | Args: 99 | nodes (list): a list of nodes 100 | 101 | Returns: 102 | dict 103 | """ 104 | node_id_hash = defaultdict(list) 105 | for node in nodes: 106 | value = cblib.get_id(node) 107 | if value is None: 108 | continue 109 | 110 | asset_id = value.split(":")[0] 111 | node_id_hash[asset_id].append(node) 112 | 113 | return dict(node_id_hash) 114 | 115 | 116 | def create_items_from_nodes(nodes): 117 | """Create an item for the view based the container and content of it 118 | 119 | It fetches the look document based on the asset ID found in the content. 120 | The item will contain all important information for the tool to work. 121 | 122 | If there is an asset ID which is not registered in the project's collection 123 | it will log a warning message. 124 | 125 | Args: 126 | nodes (list): list of maya nodes 127 | 128 | Returns: 129 | list of dicts 130 | 131 | """ 132 | 133 | asset_view_items = [] 134 | 135 | id_hashes = create_asset_id_hash(nodes) 136 | if not id_hashes: 137 | return asset_view_items 138 | 139 | for _id, id_nodes in id_hashes.items(): 140 | 141 | try: 142 | database_id = io.ObjectId(_id) 143 | except io.InvalidId: 144 | log.warning("Invalid ObjectId '%s' on nodes: %s" % 145 | (_id, id_nodes)) 146 | continue 147 | 148 | asset = io.find_one({"_id": database_id}, 149 | projection={"name": True}) 150 | 151 | # Skip if asset id is not found 152 | if not asset: 153 | log.warning("Id not found in the database, skipping '%s'." % _id) 154 | log.warning("Nodes: %s" % id_nodes) 155 | continue 156 | 157 | # Collect available look subsets for this asset 158 | looks = cblib.list_looks(asset["_id"]) 159 | 160 | # Collect namespaces the asset is found in 161 | namespaces = set() 162 | for node in id_nodes: 163 | namespace = get_namespace_from_node(node) 164 | namespaces.add(namespace) 165 | 166 | asset_view_items.append({"label": asset["name"], 167 | "asset": asset, 168 | "looks": looks, 169 | "namespaces": namespaces}) 170 | 171 | return asset_view_items 172 | 173 | 174 | def remove_unused_looks(): 175 | """Removes all loaded looks for which none of the shaders are used. 176 | 177 | This will cleanup all loaded "LookLoader" containers that are unused in 178 | the current scene. 179 | 180 | """ 181 | 182 | host = api.registered_host() 183 | 184 | unused = list() 185 | for container in host.ls(): 186 | if container['loader'] == "LookLoader": 187 | members = cmds.sets(container['objectName'], query=True) 188 | look_sets = cmds.ls(members, type="objectSet") 189 | for look_set in look_sets: 190 | # If the set is used than we consider this look *in use* 191 | if cmds.sets(look_set, query=True): 192 | break 193 | else: 194 | unused.append(container) 195 | 196 | for container in unused: 197 | log.info("Removing unused look container: %s", container['objectName']) 198 | api.remove(container) 199 | 200 | log.info("Finished removing unused looks. (see log for details)") 201 | -------------------------------------------------------------------------------- /mayalookassigner/models.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from avalon.tools import models 3 | 4 | from avalon.vendor.Qt import QtCore 5 | from avalon.vendor import qtawesome 6 | from avalon.style import colors 7 | 8 | 9 | class AssetModel(models.TreeModel): 10 | 11 | Columns = ["label"] 12 | 13 | def add_items(self, items): 14 | """ 15 | Add items to model with needed data 16 | Args: 17 | items(list): collection of item data 18 | 19 | Returns: 20 | None 21 | """ 22 | 23 | self.beginResetModel() 24 | 25 | # Add the items sorted by label 26 | sorter = lambda x: x["label"] 27 | 28 | for item in sorted(items, key=sorter): 29 | 30 | asset_item = models.Item() 31 | asset_item.update(item) 32 | asset_item["icon"] = "folder" 33 | 34 | # Add namespace children 35 | namespaces = item["namespaces"] 36 | for namespace in sorted(namespaces): 37 | child = models.Item() 38 | child.update(item) 39 | child.update({ 40 | "label": (namespace if namespace != ":" 41 | else "(no namespace)"), 42 | "namespace": namespace, 43 | "looks": item["looks"], 44 | "icon": "folder-o" 45 | }) 46 | asset_item.add_child(child) 47 | 48 | self.add_child(asset_item) 49 | 50 | self.endResetModel() 51 | 52 | def data(self, index, role): 53 | 54 | if not index.isValid(): 55 | return 56 | 57 | if role == models.TreeModel.ItemRole: 58 | item = index.internalPointer() 59 | return item 60 | 61 | # Add icon 62 | if role == QtCore.Qt.DecorationRole: 63 | if index.column() == 0: 64 | item = index.internalPointer() 65 | icon = item.get("icon") 66 | if icon: 67 | return qtawesome.icon("fa.{0}".format(icon), 68 | color=colors.default) 69 | 70 | return super(AssetModel, self).data(index, role) 71 | 72 | 73 | class LookModel(models.TreeModel): 74 | """Model displaying a list of looks and matches for assets""" 75 | 76 | Columns = ["label", "match"] 77 | 78 | def add_items(self, items): 79 | """Add items to model with needed data 80 | 81 | An item exists of: 82 | { 83 | "subset": 'name of subset', 84 | "asset": asset_document 85 | } 86 | 87 | Args: 88 | items(list): collection of item data 89 | 90 | Returns: 91 | None 92 | """ 93 | 94 | self.beginResetModel() 95 | 96 | # Collect the assets per look name (from the items of the AssetModel) 97 | look_subsets = defaultdict(list) 98 | for asset_item in items: 99 | asset = asset_item["asset"] 100 | for look in asset_item["looks"]: 101 | look_subsets[look["name"]].append(asset) 102 | 103 | for subset, assets in sorted(look_subsets.iteritems()): 104 | 105 | # Define nice label without "look" prefix for readability 106 | label = subset if not subset.startswith("look") else subset[4:] 107 | 108 | item = models.Item() 109 | item["label"] = label 110 | item["subset"] = subset 111 | 112 | # Amount of matching assets for this look 113 | item["match"] = len(assets) 114 | 115 | # Store the assets that have this subset available 116 | item["assets"] = assets 117 | 118 | self.add_child(item) 119 | 120 | self.endResetModel() 121 | -------------------------------------------------------------------------------- /mayalookassigner/version.py: -------------------------------------------------------------------------------- 1 | 2 | VERSION_MAJOR = 1 3 | VERSION_MINOR = 4 4 | VERSION_PATCH = 0 5 | 6 | version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) 7 | version = '%i.%i.%i' % version_info 8 | __version__ = version 9 | 10 | __all__ = ['version', 'version_info', '__version__'] 11 | -------------------------------------------------------------------------------- /mayalookassigner/views.py: -------------------------------------------------------------------------------- 1 | from avalon.vendor.Qt import QtWidgets, QtCore 2 | 3 | 4 | DEFAULT_COLOR = "#fb9c15" 5 | 6 | 7 | class View(QtWidgets.QTreeView): 8 | data_changed = QtCore.Signal() 9 | 10 | def __init__(self, parent=None): 11 | super(View, self).__init__(parent=parent) 12 | 13 | # view settings 14 | self.setAlternatingRowColors(False) 15 | self.setSortingEnabled(True) 16 | self.setSelectionMode(self.ExtendedSelection) 17 | self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 18 | 19 | def get_indices(self): 20 | """Get the selected rows""" 21 | selection_model = self.selectionModel() 22 | return selection_model.selectedRows() 23 | 24 | def extend_to_children(self, indices): 25 | """Extend the indices to the children indices. 26 | 27 | Top-level indices are extended to its children indices. Sub-items 28 | are kept as is. 29 | 30 | :param indices: The indices to extend. 31 | :type indices: list 32 | 33 | :return: The children indices 34 | :rtype: list 35 | """ 36 | 37 | subitems = set() 38 | for i in indices: 39 | valid_parent = i.parent().isValid() 40 | if valid_parent and i not in subitems: 41 | subitems.add(i) 42 | else: 43 | # is top level node 44 | model = i.model() 45 | rows = model.rowCount(parent=i) 46 | for row in range(rows): 47 | child = model.index(row, 0, parent=i) 48 | subitems.add(child) 49 | 50 | return list(subitems) 51 | -------------------------------------------------------------------------------- /mayalookassigner/widgets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | 4 | from avalon.vendor.Qt import QtWidgets, QtCore 5 | 6 | from avalon.tools.lib import preserve_expanded_rows, preserve_selection 7 | 8 | from . import models 9 | from . import commands 10 | from . import views 11 | 12 | from maya import cmds 13 | 14 | 15 | NODEROLE = QtCore.Qt.UserRole + 1 16 | MODELINDEX = QtCore.QModelIndex() 17 | 18 | 19 | class AssetOutliner(QtWidgets.QWidget): 20 | 21 | refreshed = QtCore.Signal() 22 | selection_changed = QtCore.Signal() 23 | 24 | def __init__(self, parent=None): 25 | QtWidgets.QWidget.__init__(self, parent) 26 | 27 | layout = QtWidgets.QVBoxLayout() 28 | 29 | title = QtWidgets.QLabel("Assets") 30 | title.setAlignment(QtCore.Qt.AlignCenter) 31 | title.setStyleSheet("font-weight: bold; font-size: 12px") 32 | 33 | model = models.AssetModel() 34 | view = views.View() 35 | view.setModel(model) 36 | view.customContextMenuRequested.connect(self.right_mouse_menu) 37 | view.setSortingEnabled(False) 38 | view.setHeaderHidden(True) 39 | view.setIndentation(10) 40 | 41 | from_all_asset_btn = QtWidgets.QPushButton("Get All Assets") 42 | from_selection_btn = QtWidgets.QPushButton("Get Assets From Selection") 43 | 44 | layout.addWidget(title) 45 | layout.addWidget(from_all_asset_btn) 46 | layout.addWidget(from_selection_btn) 47 | layout.addWidget(view) 48 | 49 | # Build connections 50 | from_selection_btn.clicked.connect(self.get_selected_assets) 51 | from_all_asset_btn.clicked.connect(self.get_all_assets) 52 | 53 | selection_model = view.selectionModel() 54 | selection_model.selectionChanged.connect(self.selection_changed) 55 | 56 | self.view = view 57 | self.model = model 58 | 59 | self.setLayout(layout) 60 | 61 | self.log = logging.getLogger(__name__) 62 | 63 | def clear(self): 64 | self.model.clear() 65 | 66 | # fix looks remaining visible when no items present after "refresh" 67 | # todo: figure out why this workaround is needed. 68 | self.selection_changed.emit() 69 | 70 | def add_items(self, items): 71 | """Add new items to the outliner""" 72 | 73 | self.model.add_items(items) 74 | self.refreshed.emit() 75 | 76 | def get_selected_items(self): 77 | """Get current selected items from view 78 | 79 | Returns: 80 | list: list of dictionaries 81 | """ 82 | 83 | selection_model = self.view.selectionModel() 84 | items = [row.data(NODEROLE) for row in 85 | selection_model.selectedRows(0)] 86 | 87 | return items 88 | 89 | def get_all_assets(self): 90 | """Add all items from the current scene""" 91 | 92 | with preserve_expanded_rows(self.view): 93 | with preserve_selection(self.view): 94 | self.clear() 95 | nodes = commands.get_all_asset_nodes() 96 | items = commands.create_items_from_nodes(nodes) 97 | self.add_items(items) 98 | 99 | def get_selected_assets(self): 100 | """Add all selected items from the current scene""" 101 | 102 | with preserve_expanded_rows(self.view): 103 | with preserve_selection(self.view): 104 | self.clear() 105 | nodes = commands.get_selected_nodes() 106 | items = commands.create_items_from_nodes(nodes) 107 | self.add_items(items) 108 | 109 | def get_nodes(self, selection=False): 110 | """Find the nodes in the current scene per asset.""" 111 | 112 | items = self.get_selected_items() 113 | 114 | # Collect all nodes by hash (optimization) 115 | if not selection: 116 | nodes = cmds.ls(dag=True, long=True) 117 | else: 118 | nodes = commands.get_selected_nodes() 119 | id_nodes = commands.create_asset_id_hash(nodes) 120 | 121 | # Collect the asset item entries per asset 122 | # and collect the namespaces we'd like to apply 123 | assets = dict() 124 | asset_namespaces = defaultdict(set) 125 | for item in items: 126 | asset_id = str(item["asset"]["_id"]) 127 | asset_name = item["asset"]["name"] 128 | asset_namespaces[asset_name].add(item.get("namespace")) 129 | 130 | if asset_name in assets: 131 | continue 132 | 133 | assets[asset_name] = item 134 | assets[asset_name]["nodes"] = id_nodes.get(asset_id, []) 135 | 136 | # Filter nodes to namespace (if only namespaces were selected) 137 | for asset_name in assets: 138 | namespaces = asset_namespaces[asset_name] 139 | 140 | # When None is present there should be no filtering 141 | if None in namespaces: 142 | continue 143 | 144 | # Else only namespaces are selected and *not* the top entry so 145 | # we should filter to only those namespaces. 146 | nodes = assets[asset_name]["nodes"] 147 | nodes = [node for node in nodes if 148 | commands.get_namespace_from_node(node) in namespaces] 149 | assets[asset_name]["nodes"] = nodes 150 | 151 | return assets 152 | 153 | def select_asset_from_items(self): 154 | """Select nodes from listed asset""" 155 | 156 | items = self.get_nodes(selection=False) 157 | nodes = [] 158 | for item in items.values(): 159 | nodes.extend(item["nodes"]) 160 | 161 | commands.select(nodes) 162 | 163 | def right_mouse_menu(self, pos): 164 | """Build RMB menu for asset outliner""" 165 | 166 | active = self.view.currentIndex() # index under mouse 167 | active = active.sibling(active.row(), 0) # get first column 168 | globalpos = self.view.viewport().mapToGlobal(pos) 169 | 170 | menu = QtWidgets.QMenu(self.view) 171 | 172 | # Direct assignment 173 | apply_action = QtWidgets.QAction(menu, text="Select nodes") 174 | apply_action.triggered.connect(self.select_asset_from_items) 175 | 176 | if not active.isValid(): 177 | apply_action.setEnabled(False) 178 | 179 | menu.addAction(apply_action) 180 | 181 | menu.exec_(globalpos) 182 | 183 | 184 | class LookOutliner(QtWidgets.QWidget): 185 | 186 | menu_apply_action = QtCore.Signal() 187 | 188 | def __init__(self, parent=None): 189 | QtWidgets.QWidget.__init__(self, parent) 190 | 191 | # look manager layout 192 | layout = QtWidgets.QVBoxLayout(self) 193 | layout.setContentsMargins(0, 0, 0, 0) 194 | layout.setSpacing(10) 195 | 196 | # Looks from database 197 | title = QtWidgets.QLabel("Looks") 198 | title.setAlignment(QtCore.Qt.AlignCenter) 199 | title.setStyleSheet("font-weight: bold; font-size: 12px") 200 | title.setAlignment(QtCore.Qt.AlignCenter) 201 | 202 | model = models.LookModel() 203 | 204 | # Proxy for dynamic sorting 205 | proxy = QtCore.QSortFilterProxyModel() 206 | proxy.setSourceModel(model) 207 | 208 | view = views.View() 209 | view.setModel(proxy) 210 | view.setMinimumHeight(180) 211 | view.setToolTip("Use right mouse button menu for direct actions") 212 | view.customContextMenuRequested.connect(self.right_mouse_menu) 213 | view.sortByColumn(0, QtCore.Qt.AscendingOrder) 214 | 215 | layout.addWidget(title) 216 | layout.addWidget(view) 217 | 218 | self.view = view 219 | self.model = model 220 | 221 | def clear(self): 222 | self.model.clear() 223 | 224 | def add_items(self, items): 225 | self.model.add_items(items) 226 | 227 | def get_selected_items(self): 228 | """Get current selected items from view 229 | 230 | Returns: 231 | list: list of dictionaries 232 | """ 233 | 234 | datas = [i.data(NODEROLE) for i in self.view.get_indices()] 235 | items = [d for d in datas if d is not None] # filter Nones 236 | 237 | return items 238 | 239 | def right_mouse_menu(self, pos): 240 | """Build RMB menu for look view""" 241 | 242 | active = self.view.currentIndex() # index under mouse 243 | active = active.sibling(active.row(), 0) # get first column 244 | globalpos = self.view.viewport().mapToGlobal(pos) 245 | 246 | if not active.isValid(): 247 | return 248 | 249 | menu = QtWidgets.QMenu(self.view) 250 | 251 | # Direct assignment 252 | apply_action = QtWidgets.QAction(menu, text="Assign looks..") 253 | apply_action.triggered.connect(self.menu_apply_action) 254 | 255 | menu.addAction(apply_action) 256 | 257 | menu.exec_(globalpos) 258 | 259 | 260 | --------------------------------------------------------------------------------