copied maya look asigner to pype tools

This commit is contained in:
iLLiCiTiT 2021-03-03 17:29:00 +01:00
parent ba912fabba
commit feaaa387c0
8 changed files with 913 additions and 0 deletions

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Colorbleed
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,10 @@
# Maya Look Assigner
Tool to assign published lookdev shaders to selected or all objects.
Each shader variation is listed based on the unique asset Id of the
selected or all objects.
## Dependencies
* Avalon
* Colorbleed configuration for Avalon
* Autodesk Maya 2016 and up

View file

@ -0,0 +1,9 @@
from .app import (
App,
show
)
__all__ = [
"App",
"show"]

View file

@ -0,0 +1,248 @@
import sys
import time
import logging
import pype.hosts.maya.lib as cblib
from avalon import style, io
from avalon.tools import lib
from avalon.vendor.Qt import QtWidgets, QtCore
from maya import cmds
# old api for MFileIO
import maya.OpenMaya
import maya.api.OpenMaya as om
from . import widgets
from . import commands
module = sys.modules[__name__]
module.window = None
class App(QtWidgets.QWidget):
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent=parent)
self.log = logging.getLogger(__name__)
# Store callback references
self._callbacks = []
filename = commands.get_workfile()
self.setObjectName("lookManager")
self.setWindowTitle("Look Manager 1.3.0 - [{}]".format(filename))
self.setWindowFlags(QtCore.Qt.Window)
self.setParent(parent)
# Force to delete the window on close so it triggers
# closeEvent only once. Otherwise it's retriggered when
# the widget gets garbage collected.
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.resize(750, 500)
self.setup_ui()
self.setup_connections()
# Force refresh check on initialization
self._on_renderlayer_switch()
def setup_ui(self):
"""Build the UI"""
# Assets (left)
asset_outliner = widgets.AssetOutliner()
# Looks (right)
looks_widget = QtWidgets.QWidget()
looks_layout = QtWidgets.QVBoxLayout(looks_widget)
look_outliner = widgets.LookOutliner() # Database look overview
assign_selected = QtWidgets.QCheckBox("Assign to selected only")
assign_selected.setToolTip("Whether to assign only to selected nodes "
"or to the full asset")
remove_unused_btn = QtWidgets.QPushButton("Remove Unused Looks")
looks_layout.addWidget(look_outliner)
looks_layout.addWidget(assign_selected)
looks_layout.addWidget(remove_unused_btn)
# Footer
status = QtWidgets.QStatusBar()
status.setSizeGripEnabled(False)
status.setFixedHeight(25)
warn_layer = QtWidgets.QLabel("Current Layer is not "
"defaultRenderLayer")
warn_layer.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
warn_layer.setStyleSheet("color: #DD5555; font-weight: bold;")
warn_layer.setFixedHeight(25)
footer = QtWidgets.QHBoxLayout()
footer.setContentsMargins(0, 0, 0, 0)
footer.addWidget(status)
footer.addWidget(warn_layer)
# Build up widgets
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setSpacing(0)
main_splitter = QtWidgets.QSplitter()
main_splitter.setStyleSheet("QSplitter{ border: 0px; }")
main_splitter.addWidget(asset_outliner)
main_splitter.addWidget(looks_widget)
main_splitter.setSizes([350, 200])
main_layout.addWidget(main_splitter)
main_layout.addLayout(footer)
# Set column width
asset_outliner.view.setColumnWidth(0, 200)
look_outliner.view.setColumnWidth(0, 150)
# Open widgets
self.asset_outliner = asset_outliner
self.look_outliner = look_outliner
self.status = status
self.warn_layer = warn_layer
# Buttons
self.remove_unused = remove_unused_btn
self.assign_selected = assign_selected
def setup_connections(self):
"""Connect interactive widgets with actions"""
self.asset_outliner.selection_changed.connect(
self.on_asset_selection_changed)
self.asset_outliner.refreshed.connect(
lambda: self.echo("Loaded assets.."))
self.look_outliner.menu_apply_action.connect(self.on_process_selected)
self.remove_unused.clicked.connect(commands.remove_unused_looks)
# Maya renderlayer switch callback
callback = om.MEventMessage.addEventCallback(
"renderLayerManagerChange",
self._on_renderlayer_switch
)
self._callbacks.append(callback)
def closeEvent(self, event):
# Delete callbacks
for callback in self._callbacks:
om.MMessage.removeCallback(callback)
return super(App, self).closeEvent(event)
def _on_renderlayer_switch(self, *args):
"""Callback that updates on Maya renderlayer switch"""
if maya.OpenMaya.MFileIO.isNewingFile():
# Don't perform a check during file open or file new as
# the renderlayers will not be in a valid state yet.
return
layer = cmds.editRenderLayerGlobals(query=True,
currentRenderLayer=True)
if layer != "defaultRenderLayer":
self.warn_layer.show()
else:
self.warn_layer.hide()
def echo(self, message):
self.status.showMessage(message, 1500)
def refresh(self):
"""Refresh the content"""
# Get all containers and information
self.asset_outliner.clear()
found_items = self.asset_outliner.get_all_assets()
if not found_items:
self.look_outliner.clear()
def on_asset_selection_changed(self):
"""Get selected items from asset loader and fill look outliner"""
items = self.asset_outliner.get_selected_items()
self.look_outliner.clear()
self.look_outliner.add_items(items)
def on_process_selected(self):
"""Process all selected looks for the selected assets"""
assets = self.asset_outliner.get_selected_items()
assert assets, "No asset selected"
# Collect the looks we want to apply (by name)
look_items = self.look_outliner.get_selected_items()
looks = {look["subset"] for look in look_items}
selection = self.assign_selected.isChecked()
asset_nodes = self.asset_outliner.get_nodes(selection=selection)
start = time.time()
for i, (asset, item) in enumerate(asset_nodes.items()):
# Label prefix
prefix = "({}/{})".format(i+1, len(asset_nodes))
# Assign the first matching look relevant for this asset
# (since assigning multiple to the same nodes makes no sense)
assign_look = next((subset for subset in item["looks"]
if subset["name"] in looks), None)
if not assign_look:
self.echo("{} No matching selected "
"look for {}".format(prefix, asset))
continue
# Get the latest version of this asset's look subset
version = io.find_one({"type": "version",
"parent": assign_look["_id"]},
sort=[("name", -1)])
subset_name = assign_look["name"]
self.echo("{} Assigning {} to {}\t".format(prefix,
subset_name,
asset))
# Assign look
cblib.assign_look_by_version(nodes=item["nodes"],
version_id=version["_id"])
end = time.time()
self.echo("Finished assigning.. ({0:.3f}s)".format(end - start))
def show():
"""Display Loader GUI
Arguments:
debug (bool, optional): Run loader in debug-mode,
defaults to False
"""
try:
module.window.close()
del module.window
except (RuntimeError, AttributeError):
pass
# Get Maya main window
top_level_widgets = QtWidgets.QApplication.topLevelWidgets()
mainwindow = next(widget for widget in top_level_widgets
if widget.objectName() == "MayaWindow")
with lib.application():
window = App(parent=mainwindow)
window.setStyleSheet(style.load_stylesheet())
window.show()
module.window = window

View file

@ -0,0 +1,194 @@
from collections import defaultdict
import logging
import os
import maya.cmds as cmds
try:
import pype.maya.lib as cblib
except Exception:
import pype.hosts.maya.lib as cblib
from avalon import io, api
log = logging.getLogger(__name__)
def get_workfile():
path = cmds.file(query=True, sceneName=True) or "untitled"
return os.path.basename(path)
def get_workfolder():
return os.path.dirname(cmds.file(query=True, sceneName=True))
def select(nodes):
cmds.select(nodes)
def get_namespace_from_node(node):
"""Get the namespace from the given node
Args:
node (str): name of the node
Returns:
namespace (str)
"""
parts = node.rsplit("|", 1)[-1].rsplit(":", 1)
return parts[0] if len(parts) > 1 else u":"
def list_descendents(nodes):
"""Include full descendant hierarchy of given nodes.
This is a workaround to cmds.listRelatives(allDescendents=True) because
this way correctly keeps children instance paths (see Maya documentation)
This fixes LKD-26: assignments not working as expected on instanced shapes.
Return:
list: List of children descendents of nodes
"""
result = []
while True:
nodes = cmds.listRelatives(nodes,
fullPath=True)
if nodes:
result.extend(nodes)
else:
return result
def get_selected_nodes():
"""Get information from current selection"""
selection = cmds.ls(selection=True, long=True)
hierarchy = list_descendents(selection)
nodes = list(set(selection + hierarchy))
return nodes
def get_all_asset_nodes():
"""Get all assets from the scene, container based
Returns:
list: list of dictionaries
"""
host = api.registered_host()
nodes = []
for container in host.ls():
# We are not interested in looks but assets!
if container["loader"] == "LookLoader":
continue
# Gather all information
container_name = container["objectName"]
nodes += cmds.sets(container_name, query=True, nodesOnly=True) or []
return nodes
def create_asset_id_hash(nodes):
"""Create a hash based on cbId attribute value
Args:
nodes (list): a list of nodes
Returns:
dict
"""
node_id_hash = defaultdict(list)
for node in nodes:
value = cblib.get_id(node)
if value is None:
continue
asset_id = value.split(":")[0]
node_id_hash[asset_id].append(node)
return dict(node_id_hash)
def create_items_from_nodes(nodes):
"""Create an item for the view based the container and content of it
It fetches the look document based on the asset ID found in the content.
The item will contain all important information for the tool to work.
If there is an asset ID which is not registered in the project's collection
it will log a warning message.
Args:
nodes (list): list of maya nodes
Returns:
list of dicts
"""
asset_view_items = []
id_hashes = create_asset_id_hash(nodes)
if not id_hashes:
return asset_view_items
for _id, id_nodes in id_hashes.items():
asset = io.find_one({"_id": io.ObjectId(_id)},
projection={"name": True})
# Skip if asset id is not found
if not asset:
log.warning("Id not found in the database, skipping '%s'." % _id)
log.warning("Nodes: %s" % id_nodes)
continue
# Collect available look subsets for this asset
looks = cblib.list_looks(asset["_id"])
# Collect namespaces the asset is found in
namespaces = set()
for node in id_nodes:
namespace = get_namespace_from_node(node)
namespaces.add(namespace)
asset_view_items.append({"label": asset["name"],
"asset": asset,
"looks": looks,
"namespaces": namespaces})
return asset_view_items
def remove_unused_looks():
"""Removes all loaded looks for which none of the shaders are used.
This will cleanup all loaded "LookLoader" containers that are unused in
the current scene.
"""
host = api.registered_host()
unused = list()
for container in host.ls():
if container['loader'] == "LookLoader":
members = cmds.sets(container['objectName'], query=True)
look_sets = cmds.ls(members, type="objectSet")
for look_set in look_sets:
# If the set is used than we consider this look *in use*
if cmds.sets(look_set, query=True):
break
else:
unused.append(container)
for container in unused:
log.info("Removing unused look container: %s", container['objectName'])
api.remove(container)
log.info("Finished removing unused looks. (see log for details)")

View file

@ -0,0 +1,120 @@
from collections import defaultdict
from avalon.tools import models
from avalon.vendor.Qt import QtCore
from avalon.vendor import qtawesome
from avalon.style import colors
class AssetModel(models.TreeModel):
Columns = ["label"]
def add_items(self, items):
"""
Add items to model with needed data
Args:
items(list): collection of item data
Returns:
None
"""
self.beginResetModel()
# Add the items sorted by label
sorter = lambda x: x["label"]
for item in sorted(items, key=sorter):
asset_item = models.Item()
asset_item.update(item)
asset_item["icon"] = "folder"
# Add namespace children
namespaces = item["namespaces"]
for namespace in sorted(namespaces):
child = models.Item()
child.update(item)
child.update({
"label": (namespace if namespace != ":"
else "(no namespace)"),
"namespace": namespace,
"looks": item["looks"],
"icon": "folder-o"
})
asset_item.add_child(child)
self.add_child(asset_item)
self.endResetModel()
def data(self, index, role):
if not index.isValid():
return
if role == models.TreeModel.ItemRole:
node = index.internalPointer()
return node
# Add icon
if role == QtCore.Qt.DecorationRole:
if index.column() == 0:
node = index.internalPointer()
icon = node.get("icon")
if icon:
return qtawesome.icon("fa.{0}".format(icon),
color=colors.default)
return super(AssetModel, self).data(index, role)
class LookModel(models.TreeModel):
"""Model displaying a list of looks and matches for assets"""
Columns = ["label", "match"]
def add_items(self, items):
"""Add items to model with needed data
An item exists of:
{
"subset": 'name of subset',
"asset": asset_document
}
Args:
items(list): collection of item data
Returns:
None
"""
self.beginResetModel()
# Collect the assets per look name (from the items of the AssetModel)
look_subsets = defaultdict(list)
for asset_item in items:
asset = asset_item["asset"]
for look in asset_item["looks"]:
look_subsets[look["name"]].append(asset)
for subset, assets in sorted(look_subsets.iteritems()):
# Define nice label without "look" prefix for readability
label = subset if not subset.startswith("look") else subset[4:]
item_node = models.Item()
item_node["label"] = label
item_node["subset"] = subset
# Amount of matching assets for this look
item_node["match"] = len(assets)
# Store the assets that have this subset available
item_node["assets"] = assets
self.add_child(item_node)
self.endResetModel()

View file

@ -0,0 +1,50 @@
from avalon.vendor.Qt import QtWidgets, QtCore
DEFAULT_COLOR = "#fb9c15"
class View(QtWidgets.QTreeView):
data_changed = QtCore.Signal()
def __init__(self, parent=None):
super(View, self).__init__(parent=parent)
# view settings
self.setAlternatingRowColors(False)
self.setSortingEnabled(True)
self.setSelectionMode(self.ExtendedSelection)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
def get_indices(self):
"""Get the selected rows"""
selection_model = self.selectionModel()
return selection_model.selectedRows()
def extend_to_children(self, indices):
"""Extend the indices to the children indices.
Top-level indices are extended to its children indices. Sub-items
are kept as is.
:param indices: The indices to extend.
:type indices: list
:return: The children indices
:rtype: list
"""
subitems = set()
for i in indices:
valid_parent = i.parent().isValid()
if valid_parent and i not in subitems:
subitems.add(i)
else:
# is top level node
model = i.model()
rows = model.rowCount(parent=i)
for row in range(rows):
child = model.index(row, 0, parent=i)
subitems.add(child)
return list(subitems)

View file

@ -0,0 +1,261 @@
import logging
from collections import defaultdict
from avalon.vendor.Qt import QtWidgets, QtCore
# TODO: expose this better in avalon core
from avalon.tools import lib
from avalon.tools.models import TreeModel
from . import models
from . import commands
from . import views
from maya import cmds
MODELINDEX = QtCore.QModelIndex()
class AssetOutliner(QtWidgets.QWidget):
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
layout = QtWidgets.QVBoxLayout()
title = QtWidgets.QLabel("Assets")
title.setAlignment(QtCore.Qt.AlignCenter)
title.setStyleSheet("font-weight: bold; font-size: 12px")
model = models.AssetModel()
view = views.View()
view.setModel(model)
view.customContextMenuRequested.connect(self.right_mouse_menu)
view.setSortingEnabled(False)
view.setHeaderHidden(True)
view.setIndentation(10)
from_all_asset_btn = QtWidgets.QPushButton("Get All Assets")
from_selection_btn = QtWidgets.QPushButton("Get Assets From Selection")
layout.addWidget(title)
layout.addWidget(from_all_asset_btn)
layout.addWidget(from_selection_btn)
layout.addWidget(view)
# Build connections
from_selection_btn.clicked.connect(self.get_selected_assets)
from_all_asset_btn.clicked.connect(self.get_all_assets)
selection_model = view.selectionModel()
selection_model.selectionChanged.connect(self.selection_changed)
self.view = view
self.model = model
self.setLayout(layout)
self.log = logging.getLogger(__name__)
def clear(self):
self.model.clear()
# fix looks remaining visible when no items present after "refresh"
# todo: figure out why this workaround is needed.
self.selection_changed.emit()
def add_items(self, items):
"""Add new items to the outliner"""
self.model.add_items(items)
self.refreshed.emit()
def get_selected_items(self):
"""Get current selected items from view
Returns:
list: list of dictionaries
"""
selection_model = self.view.selectionModel()
items = [row.data(TreeModel.ItemRole) for row in
selection_model.selectedRows(0)]
return items
def get_all_assets(self):
"""Add all items from the current scene"""
with lib.preserve_expanded_rows(self.view):
with lib.preserve_selection(self.view):
self.clear()
nodes = commands.get_all_asset_nodes()
items = commands.create_items_from_nodes(nodes)
self.add_items(items)
return len(items) > 0
def get_selected_assets(self):
"""Add all selected items from the current scene"""
with lib.preserve_expanded_rows(self.view):
with lib.preserve_selection(self.view):
self.clear()
nodes = commands.get_selected_nodes()
items = commands.create_items_from_nodes(nodes)
self.add_items(items)
def get_nodes(self, selection=False):
"""Find the nodes in the current scene per asset."""
items = self.get_selected_items()
# Collect all nodes by hash (optimization)
if not selection:
nodes = cmds.ls(dag=True, long=True)
else:
nodes = commands.get_selected_nodes()
id_nodes = commands.create_asset_id_hash(nodes)
# Collect the asset item entries per asset
# and collect the namespaces we'd like to apply
assets = dict()
asset_namespaces = defaultdict(set)
for item in items:
asset_id = str(item["asset"]["_id"])
asset_name = item["asset"]["name"]
asset_namespaces[asset_name].add(item.get("namespace"))
if asset_name in assets:
continue
assets[asset_name] = item
assets[asset_name]["nodes"] = id_nodes.get(asset_id, [])
# Filter nodes to namespace (if only namespaces were selected)
for asset_name in assets:
namespaces = asset_namespaces[asset_name]
# When None is present there should be no filtering
if None in namespaces:
continue
# Else only namespaces are selected and *not* the top entry so
# we should filter to only those namespaces.
nodes = assets[asset_name]["nodes"]
nodes = [node for node in nodes if
commands.get_namespace_from_node(node) in namespaces]
assets[asset_name]["nodes"] = nodes
return assets
def select_asset_from_items(self):
"""Select nodes from listed asset"""
items = self.get_nodes(selection=False)
nodes = []
for item in items.values():
nodes.extend(item["nodes"])
commands.select(nodes)
def right_mouse_menu(self, pos):
"""Build RMB menu for asset outliner"""
active = self.view.currentIndex() # index under mouse
active = active.sibling(active.row(), 0) # get first column
globalpos = self.view.viewport().mapToGlobal(pos)
menu = QtWidgets.QMenu(self.view)
# Direct assignment
apply_action = QtWidgets.QAction(menu, text="Select nodes")
apply_action.triggered.connect(self.select_asset_from_items)
if not active.isValid():
apply_action.setEnabled(False)
menu.addAction(apply_action)
menu.exec_(globalpos)
class LookOutliner(QtWidgets.QWidget):
menu_apply_action = QtCore.Signal()
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
# look manager layout
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
# Looks from database
title = QtWidgets.QLabel("Looks")
title.setAlignment(QtCore.Qt.AlignCenter)
title.setStyleSheet("font-weight: bold; font-size: 12px")
title.setAlignment(QtCore.Qt.AlignCenter)
model = models.LookModel()
# Proxy for dynamic sorting
proxy = QtCore.QSortFilterProxyModel()
proxy.setSourceModel(model)
view = views.View()
view.setModel(proxy)
view.setMinimumHeight(180)
view.setToolTip("Use right mouse button menu for direct actions")
view.customContextMenuRequested.connect(self.right_mouse_menu)
view.sortByColumn(0, QtCore.Qt.AscendingOrder)
layout.addWidget(title)
layout.addWidget(view)
self.view = view
self.model = model
def clear(self):
self.model.clear()
def add_items(self, items):
self.model.add_items(items)
def get_selected_items(self):
"""Get current selected items from view
Returns:
list: list of dictionaries
"""
datas = [i.data(TreeModel.ItemRole) for i in self.view.get_indices()]
items = [d for d in datas if d is not None] # filter Nones
return items
def right_mouse_menu(self, pos):
"""Build RMB menu for look view"""
active = self.view.currentIndex() # index under mouse
active = active.sibling(active.row(), 0) # get first column
globalpos = self.view.viewport().mapToGlobal(pos)
if not active.isValid():
return
menu = QtWidgets.QMenu(self.view)
# Direct assignment
apply_action = QtWidgets.QAction(menu, text="Assign looks..")
apply_action.triggered.connect(self.menu_apply_action)
menu.addAction(apply_action)
menu.exec_(globalpos)